v0.8.0 本地加密 / 被踢清数据 / 增量同步 / 附件队列 / 顶栏同步进度

用户 2026-10-18 两条:
「直接做 L3 锁界面 + 本地加密(HUKS,踢设备即吊销密钥):数据在但读不出,
  重登后恢复密钥即可用。」
「右上角头像和提醒前面加同步进度…首次全量、之后增量;进来先展示本地数据,
  一边同步一边更新;附件后台队列同步(名册头像 / 电影海报 / 观影照片 / 资源附件)。」

① 整库加密(FbSchema v4→v5,新增 module_sync / attach_cache 两张表)
   RDB encrypt=true。加密属性一经创建不可修改 ⇒ openWithEncryption 三段式:
   直接开 → 失败删库重建 → 再失败降级明文(degraded 字段,设置页如实显示,
   不静默降级)。

② 两层密钥(FbKeyStore)
   HUKS 里 KEK(fb_kek_v1,不可导出)包裹 32 字节 DEK;revoke() 删 KEK
   ⇒ 已落盘附件密文在数学上解不开,这就是"踢设备即吊销密钥"。
   重新登录重新生成 DEK 并重下附件(不做服务端托管,为了省一次重下
   把能解你本地数据的密钥放到服务端不值)。
   会话属性里 PURPOSE 只出现一次:原来把含 ENCRYPT|DECRYPT 的生成属性直接
   拿去开会话再 push 一个 PURPOSE,同一 tag 出现两次在 HUKS 里行为未定义
   ⇒ 拆成 kekBaseProps / kekGenProps / kekSessionProps。

③ AES-256-GCM(FbCrypto)
   落盘 iv(12)|ct|tag(16);变换串必须带 padding 段(AES256|GCM|PKCS7,
   缺了抛 801);解密从尾部切 tag。

④ 附件后台队列(FbAttachQueue)
   attach_cache 台账(pending/done/fail,MAX_TRIES=3);密文放 filesDir、
   明文临时放 cacheDir;扩展名白名单筛 key。
   图片地址收成统一入口 fbImageSrc(key,token):本地有就用 file://,
   没有才回网络 —— 收成一个函数才漏不掉(5 个页面全部改过来)。

⑤ 被踢 / 退出清数据
   lockOut → 清令牌 → FB_ATTACH.wipe(第一步就是吊销 KEK)→ FbDb.wipe
   (先关句柄再 deleteRdbStore,并发去重,open 会等它)→ 同步状态复位。
   重新登录后重建密钥 + 库 + 附件索引,否则之后只读网络不写缓存且不报错。

⑥ 增量同步(FbSyncAll 重写)
   先问 /api/v1/changes;指纹相同就跳过。本地没水位必须拉(否则老用户升级后
   一个模块都不同步);24h 陈旧上限兜"改无 updated_at 表的已有行";
   /changes 拿不到时判 ping status>0 —— 断网直接收工,不退回全量
   (否则 12 个模块各等 15 秒)。
   家谱也进了预同步列表(原来只在打开时拉,离线是一片空白);
   总步数由 CACHE_MODULES.length 算出,不写死。

⑦ 删除传播
   服务端是物理删除、没有墓碑 ⇒ 观影同步改成"先清后插"
   (persons 跨模块共用,不清)。

⑧ 顶栏同步进度
   头像与铃铛之间加三态按钮(转圈 / 红感叹号 / 灰对勾)+ bindSheet 面板:
   进度条、「N 个模块没有变化」、附件行、失败清单、立即同步(防连点)。
   状态中心 FbSyncState(@ObservedV2 + @Trace,AppStorageV2 单例);
   界面直接读 @Trace 字段,不提供派生 getter。

验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 290/0(本批新增 59 条);
_fb_v58_falsify.py 31 项注入 38/0;b57 反向验证 12/0(锚点跟上本批 logout 改动);
本地 e2e 1519/0;全量流水线 PUSH_RC=0。
This commit is contained in:
2026-10-18 16:01:41 +08:00
parent 776c95ba0b
commit 2132ee42d2
19 changed files with 2202 additions and 118 deletions
+314
View File
@@ -0,0 +1,314 @@
/**
* 家书 FamilyBook · 本地数据密钥(鸿蒙端,v58)
*
* 🔴 这里解决的是用户 2026-10-17 定的 L3 那一句:
* 「本地加密(HarmonyOS 密钥库 HUKS,踢设备即吊销密钥):数据在但读不出,
* 重登后恢复密钥即可用。」
*
* 做法(两层密钥):
* · **KEK**(密钥加密密钥):HUKS 里生成,别名 `fb_kek_v1`。**不可导出**,
* 跟着「应用 + 本机」绑定 —— 拷走文件也拿不到它。
* · **DEK**(数据密钥):32 字节随机数,真正用来 AES-GCM 加密附件内容。
* 它**用 KEK 包裹**后存在 preferences 里(`dek_wrap`)。
* ⇒ 删掉 HUKS 里的 KEK("踢设备"/退出登录)→ 包裹体永远解不开 → DEK 没了
* → 附件全成乱码。这就是"数据在但读不出"。
* ⇒ 重新登录后:**生成一把新 DEK、重新下载附件**。
* 为什么不做"用密码从服务端取回原 DEK":那等于让服务端持有一把能解开
* 你手机本地数据的密钥 —— 为了省一次重下,反而把风险搬到了服务端。
* 本地库/附件本来就只是服务端数据的缓存,重下是正确且便宜的恢复路径。
*
* ⚠️⚠️ **降级路径是刻意设计的,不是偷懒**:
* 如果这台机器上 HUKS 用不了(老系统、KeyStore 异常、被安全策略禁掉),
* 我们**不会**让附件功能瘫掉 —— 而是把 DEK **明文**存进 preferences,
* 并把 `weak` 置为 true。
* 代价:这台机器上"密钥可被拷走",加密只剩一层(RDB 那层还在)。
* 收益:用户还能正常看图。
* ⚠️ 这个 `weak` **必须在设置页的「诊断」里如实显示**,不能静默降级
* —— 静默降级等于骗用户"你的数据是加密的"。
*/
import { huks } from '@kit.UniversalKeystoreKit';
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
import { preferences } from '@kit.ArkData';
import { util } from '@kit.ArkTS';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbKeyStore';
const STORE_NAME: string = 'fb_keys';
/** 包裹后的 DEK(base64(iv|ct|tag)) */
const KEY_WRAP: string = 'dek_wrap';
/** 降级时明文 DEK(base64)。正常路径下**不存在** */
const KEY_PLAIN: string = 'dek_plain';
/** HUKS 里 KEK 的别名(删它 = 吊销) */
const KEK_ALIAS: string = 'fb_kek_v1';
const DEK_LEN: number = 32;
const IV_LEN: number = 12;
const TAG_LEN: number = 16;
class FbKeyStoreImpl {
private store: preferences.Preferences | null = null;
private dekBytes: Uint8Array | null = null;
private weak: boolean = false;
private ready: boolean = false;
private lastError: string = '';
/**
* KEK 的**固定**属性(算法 / 长度 / 填充 / 分组模式)。
*
* ⚠️ 这三项 + `PURPOSE` 是 HUKS 生成密钥必须的一整套;而"加/解密会话"也
* 需要同一套里的算法与模式。**两处共用这一份,各自再补一个 `PURPOSE`** ——
* 不能像最初那样把 `kekGenProps()`(里面已经带了 `ENCRYPT|DECRYPT`)
* 直接拿去开会话再 push 一个 `PURPOSE`:**同一个 tag 出现两次**的行为
* 在 HUKS 里没有定义(不同版本可能取第一个,也可能直接报错)。
*/
private kekBaseProps(): huks.HuksParam[] {
return [
{ tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES },
{ tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_AES_KEY_SIZE_256 },
{ tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_NONE },
{ tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_GCM }
];
}
/** 生成 KEK 用(固定属性 + 加解密两个用途) */
private kekGenProps(): huks.HuksParam[] {
const props: huks.HuksParam[] = this.kekBaseProps();
props.push({
tag: huks.HuksTag.HUKS_TAG_PURPOSE,
value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT
| huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT
});
return props;
}
/** 开会话用(固定属性 + **单个**用途,绝不让 `PURPOSE` 出现两次) */
private kekSessionProps(purpose: number): huks.HuksParam[] {
const props: huks.HuksParam[] = this.kekBaseProps();
props.push({ tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: purpose });
return props;
}
/**
* 预加载 + 拿到可用的 DEK(启动时调一次即可)。
*
* @returns DEK 是否可用(true = 附件可以正常读写)
*/
async init(ctx: common.Context): Promise<boolean> {
if (this.ready) {
return this.dekBytes !== null;
}
try {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
} catch (err) {
hilog.warn(DOMAIN, TAG, '密钥库预加载失败:%{public}s', JSON.stringify(err));
this.ready = true;
return false;
}
// ① 先用降级路径里那份明文 DEK(上次已经降级过,别再试一遍 HUKS 白等)
const plainV: preferences.ValueType = await this.store.get(KEY_PLAIN, '');
if (typeof plainV === 'string' && plainV !== '') {
this.dekBytes = this.b64Decode(plainV);
this.weak = true;
this.ready = true;
hilog.info(DOMAIN, TAG, '沿用上一次的降级密钥(本机不支持 HUKS)');
return this.dekBytes !== null;
}
// ② 正常路径:解包
await this.loadOrCreate(ctx);
this.ready = true;
return this.dekBytes !== null;
}
/** 读包裹体 → 解包;没有包裹体就新生成一把 */
private async loadOrCreate(ctx: common.Context): Promise<void> {
if (this.store === null) {
return;
}
const wrapV: preferences.ValueType = await this.store.get(KEY_WRAP, '');
const wrap: string = typeof wrapV === 'string' ? wrapV : '';
if (wrap !== '') {
const dek: Uint8Array | null = await this.unwrap(wrap);
if (dek !== null) {
this.dekBytes = dek;
this.weak = false;
hilog.info(DOMAIN, TAG, '本地数据密钥已解包(HUKS 保护)');
return;
}
// ⚠️ 解包失败 = KEK 已被删(被踢/清过数据)⇒ 旧附件**永久读不出**。
// 这里不报错,直接换新 DEK:旧缓存会在队列里被判成"无效"并重下。
hilog.warn(DOMAIN, TAG, '包裹体解不开(密钥已被吊销),换一把新的数据密钥');
}
// 新 DEK
const fresh: Uint8Array | null = this.randomBytes(DEK_LEN);
if (fresh === null) {
this.lastError = '随机数生成失败';
return;
}
const wrapped: string | null = await this.wrap(fresh);
if (wrapped === null) {
// ③ 降级:明文存
this.weak = true;
this.dekBytes = fresh;
this.lastError = 'HUKS 不可用,密钥以明文保存(已降级)';
await this.store.put(KEY_PLAIN, this.b64Encode(fresh));
await this.store.delete(KEY_WRAP);
await this.store.flush();
hilog.warn(DOMAIN, TAG, '本地数据密钥降级为明文保存:%{public}s', this.lastError);
return;
}
this.weak = false;
this.dekBytes = fresh;
await this.store.put(KEY_WRAP, wrapped);
await this.store.delete(KEY_PLAIN);
await this.store.flush();
hilog.info(DOMAIN, TAG, '新数据密钥已生成并用 HUKS 包裹');
}
/** 用 HUKS 的 KEK 加密 DEK,返回 base64(iv|ct|tag);失败返回 null(→ 降级) */
private async wrap(dek: Uint8Array): Promise<string | null> {
try {
const exists: boolean = await huks.isKeyItemExist(KEK_ALIAS, { properties: [] });
if (!exists) {
await huks.generateKeyItem(KEK_ALIAS, { properties: this.kekGenProps() });
hilog.info(DOMAIN, TAG, 'HUKS 里已生成 KEK(不可导出)');
}
const iv: Uint8Array | null = this.randomBytes(IV_LEN);
if (iv === null) {
return null;
}
const props: huks.HuksParam[] =
this.kekSessionProps(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT);
props.push({ tag: huks.HuksTag.HUKS_TAG_NONCE, value: iv });
const opt: huks.HuksOptions = { properties: props, inData: dek };
const handle: huks.HuksSessionHandle = await huks.initSession(KEK_ALIAS, opt);
const ret: huks.HuksReturnResult = await huks.finishSession(handle.handle, opt);
const out: Uint8Array | undefined = ret.outData;
if (out === undefined || out.length === 0) {
return null;
}
// 落盘格式:iv(12) | 密文+tag
const blob: Uint8Array = new Uint8Array(IV_LEN + out.length);
blob.set(iv, 0);
blob.set(out, IV_LEN);
return this.b64Encode(blob);
} catch (err) {
const e: BusinessError = err as BusinessError;
hilog.warn(DOMAIN, TAG, 'HUKS 包裹失败:code=%{public}d msg=%{public}s',
e.code !== undefined ? e.code : -1,
e.message !== undefined ? e.message : JSON.stringify(err));
return null;
}
}
/** 解包;失败返回 null(= 密钥已吊销) */
private async unwrap(b64: string): Promise<Uint8Array | null> {
try {
const blob: Uint8Array | null = this.b64Decode(b64);
if (blob === null || blob.length <= IV_LEN + TAG_LEN) {
return null;
}
const iv: Uint8Array = blob.slice(0, IV_LEN);
const body: Uint8Array = blob.slice(IV_LEN);
const ct: Uint8Array = body.slice(0, body.length - TAG_LEN);
const tag: Uint8Array = body.slice(body.length - TAG_LEN);
const props: huks.HuksParam[] =
this.kekSessionProps(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT);
props.push({ tag: huks.HuksTag.HUKS_TAG_NONCE, value: iv });
props.push({ tag: huks.HuksTag.HUKS_TAG_AE_TAG, value: tag });
const opt: huks.HuksOptions = { properties: props, inData: ct };
const handle: huks.HuksSessionHandle = await huks.initSession(KEK_ALIAS, opt);
const ret: huks.HuksReturnResult = await huks.finishSession(handle.handle, opt);
const out: Uint8Array | undefined = ret.outData;
return out !== undefined && out.length === DEK_LEN ? out : null;
} catch (err) {
hilog.warn(DOMAIN, TAG, 'HUKS 解包失败(密钥多半已被吊销):%{public}s', JSON.stringify(err));
return null;
}
}
/** 当前 DEK(null = 还没有 / 已被吊销) */
dek(): Uint8Array | null {
return this.dekBytes;
}
/** 是否处于"降级"状态(密钥明文保存)—— 设置页诊断要显示 */
isWeak(): boolean {
return this.weak;
}
/** 诊断用:最近一次失败原因 */
errorText(): string {
return this.lastError;
}
/**
* 🔴 **吊销密钥**(被踢下线 / 彻底退出登录时调)。
*
* 删 HUKS 里的 KEK + 删包裹体 + 清内存 → 已经落盘的附件密文**再也解不开**
* (不是"暂时不给看",是数学上解不开)。这是用户要的"踢设备即吊销密钥"。
*
* ⚠️ 与"清库"的先后顺序不重要,但**两件都要做**:
* 只吊销密钥不清库 = 库里还有明文 JSON 缓存;只清库不吊销 = 附件密文还在。
*/
async revoke(ctx: common.Context): Promise<void> {
this.dekBytes = null;
this.weak = false;
try {
await huks.deleteKeyItem(KEK_ALIAS, { properties: [] });
hilog.info(DOMAIN, TAG, 'HUKS 里的 KEK 已删除');
} catch (err) {
// 本来就不存在也会走到这里 —— 不算错误
hilog.info(DOMAIN, TAG, '删除 KEK 时返回(可能本来就不存在):%{public}s', JSON.stringify(err));
}
try {
if (this.store === null) {
this.store = await preferences.getPreferences(ctx, STORE_NAME);
}
await this.store.delete(KEY_WRAP);
await this.store.delete(KEY_PLAIN);
await this.store.flush();
} catch (err) {
hilog.warn(DOMAIN, TAG, '清除包裹体失败:%{public}s', JSON.stringify(err));
}
// 吊销之后重新初始化:下一次用附件时会生成一把全新的 DEK(旧缓存作废)
this.ready = false;
}
/* ---- 小工具 ---- */
private randomBytes(n: number): Uint8Array | null {
try {
const rnd: cryptoFramework.Random = cryptoFramework.createRandom();
const blob: cryptoFramework.DataBlob = rnd.generateRandomSync(n);
return blob.data;
} catch (err) {
hilog.error(DOMAIN, TAG, '随机数失败:%{public}s', JSON.stringify(err));
return null;
}
}
private b64Encode(b: Uint8Array): string {
return new util.Base64Helper().encodeToStringSync(b);
}
private b64Decode(s: string): Uint8Array | null {
try {
return new util.Base64Helper().decodeSync(s);
} catch (err) {
return null;
}
}
}
/** 全局单例 */
export const FB_KEY_STORE: FbKeyStoreImpl = new FbKeyStoreImpl();