/** * 家书 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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();