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
+165 -3
View File
@@ -15,7 +15,7 @@ import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { FB_DB_VERSION, FB_SCHEMA_SQL, FB_STORE_CONFIG } from './FbSchema';
import { FB_DB_NAME, FB_DB_VERSION, FB_SCHEMA_SQL, FB_STORE_CONFIG } from './FbSchema';
const DOMAIN: number = 0x0000;
const TAG: string = 'FbDb';
@@ -42,8 +42,20 @@ export class FbDb {
private lastError: string = '';
/** 打开进度打点(走到哪一步了,同样为了诊断) */
private lastStage: string = '';
/**
* 🔴 v58:是否**降级成了明文库**(true = 加密没起来)。
*
* ⚠️ 为什么要单独一个字段,而不是看 `lastError` 里有没有"降级"两个字:
* `doOpen` 成功时会清空 `lastError`(那是"这次打开失败没有"的语义),
* 拿它当"加密与否"的判据会在成功路径上立刻变成误判。
* ⇒ 两个语义分开存。
*/
private degraded: boolean = false;
private degradedReason: string = '';
/** 打开中的 Promise(并发去重,见文件头说明) */
private openPromise: Promise<relationalStore.RdbStore> | null = null;
/** 正在清库的 Promise(`open` 要等它,见 open 里的说明) */
private wipePromise: Promise<boolean> | null = null;
/**
* 打开(必要时创建)本地库并保证结构就绪。
@@ -52,6 +64,11 @@ export class FbDb {
* ⚠️ 不能传 `undefined`——RDB 要靠它定位沙箱目录)
*/
open(ctx: common.Context): Promise<relationalStore.RdbStore> {
// 🔴 v58:正在清库时**不能**打开(会打开一个正在被删除的库,表现为各种诡异错误)。
// 等它清完再走一遍 open —— `wipePromise` 在 finally 里清空,所以只会递归一次。
if (this.wipePromise !== null) {
return this.wipePromise.then((): Promise<relationalStore.RdbStore> => this.open(ctx));
}
if (this.store !== null) {
return Promise.resolve(this.store);
}
@@ -70,7 +87,7 @@ export class FbDb {
// 说明**某一步静默失败了**。分步打点才能定位是哪一步。
this.lastStage = '[1] 准备 getRdbStore';
hilog.info(DOMAIN, TAG, '[1] 准备 getRdbStore,name=%{public}s', FB_STORE_CONFIG.name);
const store: relationalStore.RdbStore = await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG);
const store: relationalStore.RdbStore = await this.openWithEncryption(ctx);
this.lastStage = '[2] getRdbStore 成功,version=' + store.version.toString();
hilog.info(DOMAIN, TAG, '[2] getRdbStore 成功,当前 version=%{public}d', store.version);
@@ -85,7 +102,10 @@ export class FbDb {
this.store = store;
this.lastStage = '[5] 就绪,version=' + store.version.toString();
this.lastError = '';
// ⚠️ 降级必须**一直看得见**,不能被"这次打开成功"掩盖掉
this.lastError = this.degraded
? ('本地库未能加密(已降级为明文):' + this.degradedReason)
: '';
hilog.info(DOMAIN, TAG, '[5] 本地库就绪,版本=%{public}d', store.version);
return store;
} catch (err) {
@@ -104,6 +124,60 @@ export class FbDb {
}
}
/**
* 🔴 v58:带加密地打开本地库,并把"老明文库"平滑过去。
*
* 三种情况:
* ① 新装 / 已是加密库 → 直接成功。
* ② **老用户手里是明文库** → 带 `encrypt:true` 打开必然失败(ArkData 不允许改加密属性),
* 此时**删库重建**。敢删是因为本地库只是服务端数据的缓存
* (见 `FbSchema` 头部的"本地优先"说明):下一次同步会把数据重新拉回来。
* ③ 这台机器根本不支持加密(老系统/内核异常)→ **降级明文打开**,
* 并把原因写进 `lastError`(设置页的「诊断」会显示)。
*
* 🔴 为什么宁可降级也不让用户打不开:
* 这首先是一个"看家里日程"的应用。加密是加分项,可用性是底线。
* 但也**绝不能静默** —— 降级必须留痕,否则用户会以为自己受着保护。
*
* ⚠️ 别把 `deleteRdbStore` 当成"安全擦除":闪存上可能还有旧块。
* 真正起作用的是"新库是密文 + 密钥在系统密钥库"。
*/
private async openWithEncryption(ctx: common.Context): Promise<relationalStore.RdbStore> {
try {
return await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG);
} catch (first) {
const e1: BusinessError = first as BusinessError;
hilog.warn(DOMAIN, TAG, '加密打开失败(可能是老明文库):code=%{public}d msg=%{public}s',
e1.code !== undefined ? e1.code : -1,
e1.message !== undefined ? e1.message : JSON.stringify(first));
// ② 删掉旧库,重建一个加密库
this.lastStage = '[1b] 旧库不兼容加密,删库重建';
try {
await relationalStore.deleteRdbStore(ctx, FB_DB_NAME);
const store: relationalStore.RdbStore = await relationalStore.getRdbStore(ctx, FB_STORE_CONFIG);
this.degraded = false; // 重建成功 = 这次真的加密上了
this.degradedReason = '';
hilog.info(DOMAIN, TAG, '旧明文库已重建为加密库(数据要靠重新同步补回来)');
return store;
} catch (second) {
// ③ 降级:明文打开,绝不把用户挡在门外
const e2: BusinessError = second as BusinessError;
hilog.error(DOMAIN, TAG, '加密库建不起来,降级明文:code=%{public}d msg=%{public}s',
e2.code !== undefined ? e2.code : -1,
e2.message !== undefined ? e2.message : JSON.stringify(second));
this.degraded = true;
this.degradedReason = e2.message !== undefined ? e2.message : JSON.stringify(second);
const plain: relationalStore.StoreConfig = {
name: FB_DB_NAME,
securityLevel: relationalStore.SecurityLevel.S1,
encrypt: false
};
return await relationalStore.getRdbStore(ctx, plain);
}
}
}
/**
* 首装建表。
*
@@ -219,6 +293,25 @@ export class FbDb {
hilog.info(DOMAIN, TAG, 'v4 迁移完成(名册电话)');
}
// ---- v4 → v5:增量同步水位 + 附件缓存台账(v58/v59)----
// 两张都是新表,用 IF NOT EXISTS 可重复执行。
// ⚠️ 老用户升级后**第一次同步必须是全量**:`module_sync` 是空的,
// 端上把"没有指纹记录"当成"一定得拉"(见 FbSyncAll 的 skip 判定)。
if (from < 5) {
await store.executeSql(
'CREATE TABLE IF NOT EXISTS module_sync ('
+ ' name TEXT PRIMARY KEY, fp TEXT NOT NULL DEFAULT \'\','
+ ' synced_at INTEGER NOT NULL DEFAULT 0, full_at INTEGER NOT NULL DEFAULT 0)');
await store.executeSql(
'CREATE TABLE IF NOT EXISTS attach_cache ('
+ ' key TEXT PRIMARY KEY, path TEXT NOT NULL DEFAULT \'\','
+ ' size INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT \'pending\','
+ ' tries INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0)');
await store.executeSql(
'CREATE INDEX IF NOT EXISTS idx_attach_state ON attach_cache (state, tries)');
hilog.info(DOMAIN, TAG, 'v5 迁移完成(增量水位 + 附件台账)');
}
store.version = FB_DB_VERSION;
}
@@ -347,6 +440,75 @@ export class FbDb {
}
}
/**
* 🔴 v58:**摧毁本地数据**(被踢下线 / 彻底退出登录时调)。
*
* 为什么被踢要连库一起删(而不是只锁界面):
* 用户 2026-10-17 的原话是「退到登录页面之后,这里面所有的资源、
* 所有的内容都是不允许看的。目的就是为了保证数据的安全性」。
* 只锁界面挡得住"顺手拿起来点两下",挡不住"拷走沙箱"。
* 而本地库**本来只是服务端数据的缓存** —— 删了不丢东西,重新登录就全回来了。
* ⇒ 锁界面 + 删库 + 清附件 + 删令牌,四件事一起做才叫"读不出"。
*
* ⚠️ 顺序很重要:**先关句柄再删库**。RDB 在打开状态下 delete 会失败
* (Windows/部分版本上是 EBUSY 那类),表现成"删了但还在"。
* ⚠️ 失败**不抛**:删不掉也要让用户回到登录页(那才是主诉求)。
* 失败原因记进 `lastError`,设置页诊断能看见。
*
* @returns 是否真的删掉了(false 时调用方不必报错,但值得记一笔)
*/
async wipe(ctx: common.Context): Promise<boolean> {
// 并发去重:两处同时触发(比如"被踢"和"用户自己点了退出")只清一次
if (this.wipePromise !== null) {
return this.wipePromise;
}
const p: Promise<boolean> = this.doWipe(ctx);
this.wipePromise = p;
try {
return await p;
} finally {
this.wipePromise = null;
}
}
/** `wipe` 的实际动作(拆出来只为了包并发去重,逻辑见上) */
private async doWipe(ctx: common.Context): Promise<boolean> {
// ① 先关句柄(必须)
try {
await this.close();
} catch (err) {
hilog.warn(DOMAIN, TAG, '清库前关闭失败(继续尝试删除):%{public}s', JSON.stringify(err));
}
// ② 删库文件(密钥随之失去意义)
let ok: boolean = true;
try {
await relationalStore.deleteRdbStore(ctx, FB_DB_NAME);
hilog.info(DOMAIN, TAG, '本地库已删除');
} catch (err) {
const e: BusinessError = err as BusinessError;
ok = false;
this.lastError = '清空本地库失败:' + (e.message !== undefined ? e.message : JSON.stringify(err));
hilog.error(DOMAIN, TAG, '清空本地库失败:%{public}s', this.lastError);
}
// ③ 重置状态:下次 open() 会重新建库(新库 = 空的,等重新同步)
this.store = null;
this.openPromise = null;
this.lastStage = ok ? '[wipe] 已清空' : '[wipe] 清空失败';
return ok;
}
/**
* 本地库现在是不是"加密的"。
*
* ⚠️ 这是**推断**,不是权威查询:ArkData 没有"这个库加密了吗"的公开接口。
* 判据是"打开过程里没有走过降级分支"(`lastError` 里没有降级记录)。
* 用途只有一个:设置页的「诊断」如实告诉用户"你的本机数据是加密的/不是"。
* 别拿它做安全决策。
*/
isEncrypted(): boolean {
return this.isReady() && !this.degraded;
}
// ---- 单例 ----
private static instance: FbDb | null = null;