Files
FamilyBook_harmonyos/entry/src/main/ets/data/FbKeyStore.ets
T
laoyang f020a671c0 v0.8.1 界面六条:删离线入口 / 标语统一 / 最近纪念日 / 图标真上色 / 真头像 / 日程四修
用户 2026-10-18 六条(全部已在真机逐条验证):

① 登录页「先不登录 / 离线使用」彻底删除
   用户:「把那些不登录、看离线数据之类的提示全部去掉。以后就不再拥有这样的
   功能了,把这些功能也全部删除。」
   ⇒ 两个按钮 + onSkip + allowSkip 参数 + Index 里的 skip 分支一起删净。
   这是产品决定(不是隐藏):该路径本身就是安全缺口,v58 上了整库加密后更要堵死。

② 标语统一为「一家人用岁月写成的一本书」
   用户:「这也是我们"家书"这个名字的由来。所以你不要再乱换这个标语了,
   只要是想出现标语或者副标题的地方,就用这样一句话。」
   · 端上 FB_DEFAULT_SLOGAN 是唯一定义处(登录页副标题 + 抽屉回落都用它);
   · 抽屉左上角原来是家庭名,现在显示**标语**;
   · 三层优先级(个人 slogan → 家庭 motto → 应用默认口号)**在服务端算**
     (Family::slogan,与网页端首页 heroSlogan 同一套),/login 与 /me 都返回。
   用户还说:「如果这个用户自己有标语……这个地方就是用户自己自定义的标语。」
   真机实测:登录者杨永全自己写了「修身,齐家」⇒ 抽屉就显示他那句。

③ 首页新增「最近纪念日」(放在近期任务之后、往年的今天之前)
   用户:「未来最近的三个纪念日,包括每个人的生日,以及手动填写的各个纪念日。
   以倒计时形式展示:距离哪个纪念日还有多少天,距离谁谁谁的生日还有多少天。」
   · /home.recent_days = Anniversary::upcoming(fam,pid,370) 取前 3 ——
     取满一年:窗口太小会让"刚过完生日"的家庭一个都取不到,模块直接空着;
   · 数据源 all() = 派生生日 + 自建纪念日,正好覆盖用户说的两样;
   · 倒计时:0 天显示「就是今天」(写「还有 0 天」读起来像已经过了)。

④ 悬浮栏图标也上色(用户:「除了下面的汉字,上面图标的颜色也变成跟汉字一样的颜色」)
   🔴🔴 真根因两层,都是"无报错失效":
   · 第一层:Image.fillColor() **只对 SVG 生效**,而本工程 30+ 图标**全是 PNG**
     ⇒ 全工程 24 处图标染色一直是空操作(文字走 fontColor 所以只有文字变了)。
   · 第二层(真机才现形):改用 colorFilter 后,矩阵写成了"系数乘 RGB"
     (R' = R_原 × 系数) —— 对白色图标成立,但本工程图标是**纯黑**,
     黑 × 任何系数 = 仍是黑 ⇒ 真机上图标像素依旧是 (0,0,0)。
   ⇒ 最终形态:`fbTint` 用 **alpha 当载体**(R' = (r/255) × A_原):
     系数是归一化约定(官方灰度示例系数和为 1,量纲确定);
     形状完全由原图 alpha 决定 ⇒ 黑/白图标都成立,描边抗锯齿自然过渡。
   真机像素实测:日程项图标与文字同为 (176,120,64)≈#B0763A;
   未选中项图标 (44,39,35) 深灰(不再是纯黑)。
   ⚠️ 全工程 24 处 fillColor 一并改掉;FbIconButton.tint 类型改成 string,
      让"误传 Resource"变成编译错误而不是又一个静默失效。

⑤ 顶栏:真实头像 + 拉开按钮间距
   用户:「右上角的头像应该是真实的……现在提醒的按钮距离头像的按钮太近了。」
   · /home 给 me_avatar、/login 给 avatar_url(avatar_url() 按线上存储驱动拼好);
   · 三颗按钮(同步 / 铃铛 / 头像)统一用 FbMetric.gapTopActions = 8。

⑥ 日程四处
   · 「日期下面有几个点」⇒ 月视图与周视图的圆点全部删除,
     连 FbDot / dots() / count / dotColor 一起清(不留死代码);
   · 「农历全部都是初一」⇒ 真根因是端上 lunarOf() **硬编码 return '初一'**,
     而服务端 lunarRange() 本来就每天给真实农历(title)⇒ 改取 title;
   · 「列表这个视图,今天的数据缺一条」⇒ 服务端 /calendar 的 from/to **默认只给本月**,
     而列表视图要显示 -7 ~ +60 天 ⇒ 端上显式传范围
     (loadModule 新增 pathOverride 参数);
   · 「点击了某一天之后并不明显,应该把这一天的颜色变深,而今天用一个较浅的颜色」
     ⇒ 照 SyncCalendar:选中 = 深色实底白字、今天 = 浅色底。
       真机实测:17 号(选中)bg=#FFB0763A,18 号(今天)bg=#FFF5F0EA。

另:FbKeyStore 补上一个真机抓到的 HUKS 坑 —— isKeyItemExist 在密钥不存在时
**会抛 12000011**(不是返回 false),首次运行必被误判成"本机不支持 HUKS"
⇒ 永久明文降级。改成把"不存在"当正常分支,且降级态每次启动重试一次
(把同一把 DEK 重新用 KEK 包起来,不换密钥以免重下全部附件)。

验证:hvigor BUILD SUCCESSFUL;_fb_arkts_guard.js 328/0;
_fb_v59ui_falsify.py 19 项注入 23/0;_fb_home_api.py 60/0;
六条已在真机逐条取像素/布局树确认。
2026-10-18 20:14:40 +08:00

390 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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;
/**
* HUKS「条目不存在」错误码。
*
* 🔴🔴 2026-10-18 真机实测(harmonyos 真机,非模拟器):
* `huks.isKeyItemExist(alias, ...)` 在密钥**不存在**时**会抛异常**,
* 而不是文档说的"返回 false" —— 抛出的 code 正是 12000011
* (HUKS_ERR_CODE_ITEM_NOT_EXIST;HUKS 服务侧日志为
* `HksKeyExist: leave KeyExist, result = -13`,-13 = HKS_ERROR_NOT_EXIST)。
*
* 首次运行一定走到这条(KEK 还没生成)⇒ 如果不接住这个异常,
* 就会被当成"本机不支持 HUKS",直接**永久降级成明文密钥**
* (实测症状:设置页显示"已降级(明文保存)",而 HUKS 其实完全可用)。
*/
const HUKS_ERR_ITEM_NOT_EXIST: number = 12000011;
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 可能是被临时性原因挡住的(系统刚升级、密钥库服务还没起来…),
// 下次启动也许就好了。所以这里试着把同一把 DEK **重新用 KEK 包起来**;
// 只有真包不上才继续留在降级态。
// 🔴 2026-10-18 为什么这样改:原来只要见过一次 KEY_PLAIN 就永远走降级
// ("上次已降级,别再试一遍 HUKS 白等")⇒ 一次误判 ⇒ **终身明文**。
// 真机实测正是如此:`isKeyItemExist` 抛的一个"不存在"被误当成"不支持",
// 之后每次启动都直接沿用明文,HUKS 明明可用也升不回来。
const plainV: preferences.ValueType = await this.store.get(KEY_PLAIN, '');
if (typeof plainV === 'string' && plainV !== '') {
const plainDek: Uint8Array | null = this.b64Decode(plainV);
this.dekBytes = plainDek;
this.weak = true;
this.ready = true;
hilog.info(DOMAIN, TAG, '读到了上次的降级密钥,尝试重新用 HUKS 保护');
if (plainDek !== null) {
await this.tryUpgradeFromPlain(plainDek);
}
return this.dekBytes !== null;
}
// ② 正常路径:解包
await this.loadOrCreate(ctx);
this.ready = true;
return this.dekBytes !== null;
}
/**
* 把"已降级"的那把明文 DEK **重新包进 HUKS**(成功则升回正常态)。
*
* ⚠️ 只重新包裹、**不换密钥**:附件密文是用这把 DEK 加的,
* 换一把会让已落盘的附件全部解不开(白下几百个文件)。
* 等价的错误做法是"直接调 loadOrCreate 生成新 DEK" —— 那等于每次
* 从降级态恢复都要重下全部附件。
*/
private async tryUpgradeFromPlain(dek: Uint8Array): Promise<void> {
if (this.store === null) {
return;
}
const wrapped: string | null = await this.wrap(dek);
if (wrapped === null) {
hilog.info(DOMAIN, TAG, 'HUKS 仍不可用,继续使用明文密钥');
return;
}
this.weak = false;
this.lastError = '';
await this.store.put(KEY_WRAP, wrapped);
await this.store.delete(KEY_PLAIN);
await this.store.flush();
hilog.info(DOMAIN, TAG, '已把降级的密钥重新交给 HUKS 保护(不再明文保存)');
}
/** 读包裹体 → 解包;没有包裹体就新生成一把 */
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 包裹');
}
/**
* KEK 是否已存在。
*
* ⚠️ **不能用** `huks.isKeyItemExist()` 的返回值直接判 —— 它在"不存在"时
* 会抛 `12000011`(见 `HUKS_ERR_ITEM_NOT_EXIST` 的说明)。
* ⇒ 这里把"不存在"(异常码 / 返回 false)统一成 false,
* 真正的不支持(其它错误码)才往上抛,由调用方决定是否降级。
*/
private async kekExists(): Promise<boolean> {
try {
return await huks.isKeyItemExist(KEK_ALIAS, { properties: [] });
} catch (err) {
const e: BusinessError = err as BusinessError;
if (e.code === HUKS_ERR_ITEM_NOT_EXIST) {
return false; // 正常情况:还没生成过 KEK
}
// ⚠️ ArkTS 的 `throw` **不接受任意类型**(arkts-limited-throw)——
// catch 到的 `err` 是 unknown,`throw err` 编译不过,必须包成 Error。
throw new Error('判断 KEK 是否存在失败:code='
+ (e.code !== undefined ? e.code : -1)
+ ' msg=' + (e.message !== undefined ? e.message : ''));
}
}
/** 用 HUKS 的 KEK 加密 DEK,返回 base64(iv|ct|tag);失败返回 null(→ 降级) */
private async wrap(dek: Uint8Array): Promise<string | null> {
try {
const exists: boolean = await this.kekExists();
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();