// entry/src/main/ets/common/AccountStore.ets // DAV 账号持久化(加密): // - 账号列表(含密码)序列化为 JSON 后整体加密,存储为 caldav_vault 偏好中的 data_b64。 // - **首选 HUKS 硬件密钥库**:AES-256-GCM 密钥在 TEE 内生成、**不可导出、不落盘**(只留 keyAlias), // 密文格式 `huks1::`。这样"整机备份被导出"也解不开 —— 密钥不在备份里。 // 写入前做**自检**:HUKS 密文必须能自解回原文才允许落盘,否则本进程直接回落软件 AES。 // - **回落**:个别设备 HUKS 不可用(初始化/权限异常/加解密不一致)时退回"AES 密钥存偏好"的软件方案 // (`aes1:` 前缀),宁可安全性降级也不能让"账号存不上"。探测结果在进程内缓存,避免每轮同步重复试错。 // ⚠️ 清密钥(purgeSoftKey)**必须确认密文已是 `huks1:`**,否则会把仍依赖软件密钥的 `aes1:` 密文解坏。 // - **旧数据迁移**:更早的无前缀软件 AES 密文、旧版明文存储(caldav_account 的 acc_0..n) // 在首次 loadAll 时读出并就地升级为 HUKS 密文,随后清除偏好中的明文密钥与旧存储。 import { preferences } from '@kit.ArkData'; import { cryptoFramework } from '@kit.CryptoArchitectureKit'; import { huks } from '@kit.UniversalKeystoreKit'; import { util } from '@kit.ArkTS'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; export const TYPE_CALDAV: string = 'caldav'; export const TYPE_CARDDAV: string = 'carddav'; export const TYPE_WEBDAV: string = 'webdav'; export const TYPE_KEYS: string[] = [TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV]; /** 本机事件所属的虚拟日历 key */ export const LOCAL_CAL_KEY: string = 'local'; /** 旧版明文存储(迁移后清除) */ const LEGACY_STORE: string = 'caldav_account'; const LEGACY_COUNT_KEY: string = 'accountCount'; /** 加密存储 */ const VAULT_STORE: string = 'caldav_vault'; /** 旧版"软件 AES 密钥"(明文存偏好);升级到 HUKS 后会被清除 */ const VAULT_KEY: string = 'key_b64'; const VAULT_DATA: string = 'data_b64'; /** HUKS 密钥别名:固定不变;密钥本体在 TEE 内,应用永远拿不到明文 */ const HUKS_ALIAS: string = 'synccalendar_vault_aes256_gcm'; /** 密文前缀:区分 HUKS 密文 / 软件 AES 密文(无前缀 = 更早的旧格式,按软件 AES 解) */ const PREFIX_HUKS: string = 'huks1:'; const PREFIX_AES: string = 'aes1:'; /** * DAV 账号(@Observed 使同步状态变化能刷新列表 UI) */ @Observed export class DavAccount { id: string = ''; type: string = TYPE_CALDAV; name: string = ''; serverUrl: string = ''; username: string = ''; password: string = ''; calendarHrefs: string[] = []; calendarNames: string[] = []; /** 服务器端定义的日历本颜色(calendar-color),与 calendarHrefs 一一对应,空串表示未定义 */ calendarColors: string[] = []; /** 各日历本写权限('1'=可写 '0'=只读),与 calendarHrefs 一一对应;空数组表示未知(按可写处理) */ calendarWritable: string[] = []; itemCount: number = 0; lastSyncTime: string = ''; } /** 日历本来源(用于界面显隐与取色) */ export class CalSource { calKey: string = ''; name: string = ''; color: string = '#007DFF'; source: string = 'dav'; // 'dav' | 'system' | 'local' visible: boolean = true; writable: boolean = true; // 只读日历本中的日程只能查看详情,不能编辑 } /** 与 UI 无关的调色板 */ export class BookPalette { static colors: string[] = [ '#007DFF', '#FF7D00', '#00B96B', '#9F44FF', '#F5317F', '#00B2C6', '#B58600', '#6D4AFF' ]; static colorFor(index: number): string { return BookPalette.colors[index % BookPalette.colors.length]; } } function bytesToB64(u: Uint8Array): string { return new util.Base64Helper().encodeToStringSync(u); } function b64ToBytes(s: string): Uint8Array { return new util.Base64Helper().decodeSync(s); } function strToBytes(s: string): Uint8Array { return new util.TextEncoder().encodeInto(s); } function bytesToStr(u: Uint8Array): string { return new util.TextDecoder('utf-8').decodeToString(u); } /** * 账号加密存储实现。 * * **首选 HUKS(硬件密钥库)**:AES-256-GCM 密钥在 TEE 内生成、**不可导出、不落盘**, * 应用侧只持有 keyAlias。因此「整机备份被导出」到其他设备也无法解密(密钥不在备份中、且与设备绑定)。 * * **回落**:HUKS 初始化/调用异常时退回软件 AES(密钥存偏好,`aes1:` 前缀)—— * 本环境无法真机验证 HUKS,留此安全网可确保"最坏情况 = 与改造前一致",绝不出现存不上账号。 * 探测结果缓存在 `huksReady`,避免每轮同步反复试错。 * * 密文自描述(前缀区分算法),便于日后识别与再迁移: * `huks1::` | `aes1::` | `:`(更早旧格式) */ class Vault { /** 软件方案:进程内缓存,避免每轮同步重复读偏好 */ private static keyB64: string = ''; /** HUKS 可用性:null=未探测;true=可用;false=已判定不可用(本进程不再重试) */ private static huksReady: boolean | null = null; static async prefs(context: common.Context): Promise { return preferences.getPreferences(context, VAULT_STORE); } // ==================== HUKS(首选路径) ==================== /** 生成密钥所用属性(AES-256-GCM,同时具备加密与解密用途) */ private static genProperties(): huks.HuksParam[] { return [ { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES }, { tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT | huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT }, { tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_AES_KEY_SIZE_256 }, { tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_GCM }, { tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_NONE }, { tag: huks.HuksTag.HUKS_TAG_KEY_ALIAS, value: strToBytes(HUKS_ALIAS) }, { tag: huks.HuksTag.HUKS_TAG_IS_KEY_ALIAS, value: true } ]; } /** 单次加/解密操作属性(NONCE 由调用方追加;GCM 无需 digest/padding 之外参数) */ private static opProperties(purpose: number): huks.HuksParam[] { return [ { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES }, { tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: purpose }, { tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_AES_KEY_SIZE_256 }, { tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_GCM }, { tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_NONE } ]; } /** * 确保 HUKS 密钥存在(不存在则生成)。返回当前是否可用。 * 任何异常都视为"HUKS 不可用"并缓存,后续直接走软件回落,不再反复抛错。 */ private static async ensureHuksKey(): Promise { if (Vault.huksReady === false) { return false; } try { const exists: boolean = await huks.isKeyItemExist(HUKS_ALIAS, { properties: Vault.genProperties() }); if (exists) { Vault.huksReady = true; return true; } try { await huks.generateKeyItem(HUKS_ALIAS, { properties: Vault.genProperties() }); console.info('[Vault] 已在 HUKS 中生成账号库密钥'); } catch (genErr) { // 生成失败也可能只是"密钥其实已存在"(isKeyItemExist 属性校验差异 / 并发): // 再确认一次,确认存在就视为可用 —— 否则会误判为"不可用"而静默降级成软件加密。 const ge = genErr as BusinessError; const again: boolean = await huks.isKeyItemExist(HUKS_ALIAS, { properties: Vault.genProperties() }); if (!again) { throw new Error(`HUKS 生成密钥失败(code=${ge.code}): ${ge.message}`); } console.info('[Vault] HUKS 密钥已存在,直接使用'); } Vault.huksReady = true; return true; } catch (err) { const e = err as BusinessError; console.error(`[Vault] HUKS 不可用(code=${e.code}),本次回落软件加密: ${e.message}`); Vault.huksReady = false; return false; } } /** GCM 附加认证数据(固定常量):把密文绑定到"账号库"这一用途,防密文被挪作他用 */ private static aad(): Uint8Array { return strToBytes('synccalendar-vault-v1'); } /** * HUKS 加密(initSession → finishSession 一次性完成)。 * 本 SDK **没有** `huks.encryptItem` 一次性接口,必须走会话式 API。 * 返回 `b64(nonce):b64(密文+GCM tag)`(不含前缀)——nonce 在加密时显式指定, * 故返回数据为「密文 + AEAD(16B)」,nonce 由我们单独保存。 */ private static async huksEncrypt(plain: string): Promise { const nonce: Uint8Array = cryptoFramework.createRandom().generateRandomSync(12).data; const props: huks.HuksParam[] = Vault.opProperties(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT); props.push({ tag: huks.HuksTag.HUKS_TAG_NONCE, value: nonce }); props.push({ tag: huks.HuksTag.HUKS_TAG_ASSOCIATED_DATA, value: Vault.aad() }); const session: huks.HuksSessionHandle = await huks.initSession(HUKS_ALIAS, { properties: props }); const result: huks.HuksReturnResult = await huks.finishSession(session.handle, { properties: props, inData: strToBytes(plain) }); const buf: Uint8Array | undefined = result.outData; if (buf === undefined || buf.length === 0) { throw new Error('HUKS 加密返回空数据'); } return `${bytesToB64(nonce)}:${bytesToB64(buf)}`; } /** HUKS 解密(nonce 与密文分开传递);失败抛异常由上层统一兜底 */ private static async huksDecrypt(nonceB64: string, cipherB64: string): Promise { const props: huks.HuksParam[] = Vault.opProperties(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT); props.push({ tag: huks.HuksTag.HUKS_TAG_NONCE, value: b64ToBytes(nonceB64) }); props.push({ tag: huks.HuksTag.HUKS_TAG_ASSOCIATED_DATA, value: Vault.aad() }); const session: huks.HuksSessionHandle = await huks.initSession(HUKS_ALIAS, { properties: props }); const result: huks.HuksReturnResult = await huks.finishSession(session.handle, { properties: props, inData: b64ToBytes(cipherB64) }); const buf: Uint8Array | undefined = result.outData; return buf === undefined ? null : bytesToStr(buf); } /** * 清除偏好里的旧"软件 AES 密钥"(密文已升级为 HUKS 后调用)。 * 目的:不再让可解密的密钥留在偏好(也就不会随备份外泄)。 */ static async purgeSoftKey(context: common.Context): Promise { try { const store: preferences.Preferences = await Vault.prefs(context); const had: string = await store.get(VAULT_KEY, '') as string; if (had === '') { return; } store.delete(VAULT_KEY); await store.flush(); Vault.keyB64 = ''; console.info('[Vault] 已清除偏好中的旧 AES 密钥(密文已由 HUKS 保护)'); } catch (err) { // 清理失败不影响功能,下次启动再试 } } /** * 【逃生舱 · 内部实现】清空账号库:删除偏好中的账号密文与软件密钥,并尽力删除 HUKS 密钥。 * * 用途:账号库**存在但解不开**时(例如密钥丢失、HUKS 密钥失效),`canReadExisting()` 会拒绝 * 任何写入 —— 用户既看不到账号,也加不了新账号,只能"清除整个应用数据"才能恢复。 * 本方法提供一条更窄的出路:**只清账号,不动设置与日程**。 * * ⚠️ 仅供 `AccountStore.resetVault()` 在**用户显式二次确认后**调用,**绝不自动触发** * (自动清理会破坏 `canReadExisting` 的防覆盖保护)。 */ static async clearVault(context: common.Context): Promise { const store: preferences.Preferences = await Vault.prefs(context); store.delete(VAULT_DATA); store.delete(VAULT_KEY); await store.flush(); Vault.keyB64 = ''; try { await huks.deleteKeyItem(HUKS_ALIAS, { properties: Vault.genProperties() }); console.info('[Vault] 已删除 HUKS 账号库密钥'); } catch (err) { // 密钥不存在或删除失败都不影响"重置"的目的(密文已删,重新添加时会重新生成) const e = err as BusinessError; console.error(`[Vault] 删除 HUKS 密钥失败(code=${e.code}): ${e.message}`); } Vault.huksReady = null; // 让后续重新探测 console.info('[Vault] 账号库已清空'); } // ==================== 软件 AES(回落路径) ==================== /** * 读取**已存在**的软件 AES 密钥;不存在返回 null。 * * ⚠️ 解密路径必须用这个:早期 `softKey()` 在解密时若发现密钥缺失,会"顺手生成一把新密钥", * 于是旧密文被一把错误的新密钥去解 → 表现为"账号突然全没了,而且再也加不上"。 */ private static async softKeyExisting(context: common.Context): Promise { if (Vault.keyB64 === '') { const store: preferences.Preferences = await Vault.prefs(context); Vault.keyB64 = await store.get(VAULT_KEY, '') as string; } if (Vault.keyB64 === '') { return null; } return cryptoFramework.createSymKeyGenerator('AES256').convertKey({ data: b64ToBytes(Vault.keyB64) }); } /** * 获取(或首次生成)软件 AES-256 密钥;进程内缓存 keyB64。 * **仅加密路径可调用**(只有加密时才允许"没有就新建一把")。 * * ⚠️ 仅作为 HUKS 不可用时的回落:此方案下密钥与密文同存 caldav_vault, * 对"备份被导出"无防护(`backup_config.json` 已把 preferences 排除出备份以缓解)。 */ private static async softKey(context: common.Context): Promise { const existing: cryptoFramework.SymKey | null = await Vault.softKeyExisting(context); if (existing !== null) { return existing; } const gen: cryptoFramework.SymKeyGenerator = cryptoFramework.createSymKeyGenerator('AES256'); const key: cryptoFramework.SymKey = await gen.generateSymKey(); Vault.keyB64 = bytesToB64(key.getEncoded().data); const store: preferences.Preferences = await Vault.prefs(context); await store.put(VAULT_KEY, Vault.keyB64); await store.flush(); return key; } private static gcmParams(iv: Uint8Array, authTag: Uint8Array): cryptoFramework.GcmParamsSpec { return { algName: 'GcmParamsSpec', iv: { data: iv }, aad: { data: new Uint8Array(0) }, authTag: { data: authTag } }; } /** 软件 AES 加密:返回 `b64(iv):b64(密文+tag)`(不含前缀) */ private static async softEncrypt(context: common.Context, plain: string): Promise { const key: cryptoFramework.SymKey = await Vault.softKey(context); const iv: Uint8Array = cryptoFramework.createRandom().generateRandomSync(12).data; const cipher: cryptoFramework.Cipher = cryptoFramework.createCipher('AES256|GCM|NoPadding'); await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, key, Vault.gcmParams(iv, new Uint8Array(16))); const out: cryptoFramework.DataBlob = await cipher.doFinal({ data: strToBytes(plain) }); return `${bytesToB64(iv)}:${bytesToB64(out.data)}`; } /** 软件 AES 解密(body = `b64(iv):b64(密文+tag)`);格式不符返回 null */ private static async softDecrypt(context: common.Context, body: string): Promise { const sep: number = body.indexOf(':'); if (sep <= 0) { return null; } const iv: Uint8Array = b64ToBytes(body.substring(0, sep)); const blob: Uint8Array = b64ToBytes(body.substring(sep + 1)); if (blob.length <= 16) { return null; } const tag: Uint8Array = blob.slice(blob.length - 16); const ct: Uint8Array = blob.slice(0, blob.length - 16); const key: cryptoFramework.SymKey | null = await Vault.softKeyExisting(context); if (key === null) { console.error('[Vault] 软件 AES 密钥缺失,无法解密(不重新生成,避免把可用密文彻底解坏)'); return null; } const cipher: cryptoFramework.Cipher = cryptoFramework.createCipher('AES256|GCM|NoPadding'); await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, key, Vault.gcmParams(iv, tag)); const out: cryptoFramework.DataBlob = await cipher.doFinal({ data: ct }); return bytesToStr(out.data); } // ==================== 对外统一入口 ==================== /** * 加密:优先 HUKS,失败回落软件 AES;返回带前缀的自描述密文。 * * ⚠️ **落盘前自检**:HUKS 写出的密文必须先能自己解回来,才允许以 `huks1:` 写到偏好。 * 否则某些设备上 HUKS 行为不一致(能加密却解不开),会把"读不回来的密文"写进账号库 —— * 下次启动即表现为"账号全部消失且再也加不上"。自检不通过 → 本进程永久回落软件 AES。 */ static async encrypt(context: common.Context, plain: string): Promise { if (await Vault.ensureHuksKey()) { try { const body: string = await Vault.huksEncrypt(plain); const sep: number = body.indexOf(':'); const back: string | null = await Vault.huksDecrypt(body.substring(0, sep), body.substring(sep + 1)); if (back === plain) { return PREFIX_HUKS + body; } console.error('[Vault] HUKS 自检失败(加密后解不回原文),本进程回落软件加密'); Vault.huksReady = false; } catch (err) { const e = err as BusinessError; console.error(`[Vault] HUKS 加密失败(code=${e.code}),回落软件加密: ${e.message}`); Vault.huksReady = false; } } return PREFIX_AES + await Vault.softEncrypt(context, plain); } /** * 解密:按前缀分派(`huks1:` / `aes1:` / 无前缀旧格式)。 * **任何失败都返回 null**(调用方按"无账号 / 迁移路径"处理,绝不静默清空账号)。 */ static async decrypt(context: common.Context, stored: string): Promise { try { if (stored.startsWith(PREFIX_HUKS)) { const parts: string[] = stored.substring(PREFIX_HUKS.length).split(':'); if (parts.length !== 2 || !(await Vault.ensureHuksKey())) { return null; } return await Vault.huksDecrypt(parts[0], parts[1]); } if (stored.startsWith(PREFIX_AES)) { return await Vault.softDecrypt(context, stored.substring(PREFIX_AES.length)); } // 更早的旧格式:无前缀软件 AES 密文 return await Vault.softDecrypt(context, stored); } catch (err) { const e = err as BusinessError; console.error(`[Vault] 解密失败: ${e.message}`); return null; } } } /** * 账号持久化:加密 JSON 存储 + 旧版明文自动迁移 */ export class AccountStore { /** 规范化颜色:#RRGGBBAA → #RRGGBB;非法返回空串 */ static normalizeColor(raw: string): string { const v: string = raw.trim(); if (/^#[0-9A-Fa-f]{8}$/.test(v)) { return '#' + v.substring(3, 9); } if (/^#[0-9A-Fa-f]{6}$/.test(v)) { return v.toUpperCase(); } return ''; } private static decodeAccount(raw: string): DavAccount | null { const parts: string[] = raw.split('|'); if (parts.length < 9) { return null; } const acc = new DavAccount(); acc.type = parts[0] === '' ? TYPE_CALDAV : parts[0]; acc.name = parts[1]; acc.serverUrl = parts[2]; acc.username = parts[3]; acc.password = parts[4]; const count: number = Number(parts[5]); acc.itemCount = Number.isNaN(count) ? 0 : count; acc.lastSyncTime = parts[6]; acc.calendarHrefs = parts[7] === '' ? [] : parts[7].split(';'); acc.calendarNames = parts[8] === '' ? [] : parts[8].split(';'); if (parts.length >= 10) { acc.calendarColors = parts[9] === '' ? [] : parts[9].split(';'); } // 兼容旧版本存储(无 id 字段):加载时为空,由 loadAll 统一补发并持久化 if (parts.length >= 11) { acc.id = parts[10]; } if (parts.length >= 12) { acc.calendarWritable = parts[11] === '' ? [] : parts[11].split(';'); } return acc; } /** 旧版明文账号编码(迁移旧数据时使用) */ private static encodeLegacyAccount(acc: DavAccount): string { const safe = (s: string): string => s.split('|').join('∥'); const parts: string[] = [ safe(acc.type), safe(acc.name), safe(acc.serverUrl), safe(acc.username), safe(acc.password), String(acc.itemCount), safe(acc.lastSyncTime), acc.calendarHrefs.join(';'), acc.calendarNames.join(';'), acc.calendarColors.join(';'), safe(acc.id), acc.calendarWritable.join(';') ]; return parts.join('|'); } /** 从旧版明文存储读取(无则返回 null) */ private static async loadLegacy(context: common.Context): Promise { try { const store: preferences.Preferences = await preferences.getPreferences(context, LEGACY_STORE); const count: number = await store.get(LEGACY_COUNT_KEY, 0) as number; if (count <= 0) { return null; } const result: DavAccount[] = []; for (let i = 0; i < count; i++) { const raw = await store.get(`acc_${i}`, '') as string; if (raw === '') { continue; } const acc = AccountStore.decodeAccount(raw); if (acc !== null) { if (acc.id === '') { acc.id = `acc${Date.now()}_${i}`; } result.push(acc); } } return result; } catch (err) { return null; } } /** 清除旧版明文存储(迁移完成后调用) */ private static async clearLegacy(context: common.Context): Promise { try { const store: preferences.Preferences = await preferences.getPreferences(context, LEGACY_STORE); const count: number = await store.get(LEGACY_COUNT_KEY, 0) as number; for (let i = 0; i < count; i++) { store.delete(`acc_${i}`); } store.delete(LEGACY_COUNT_KEY); await store.flush(); } catch (err) { // 清理失败不影响使用,下次迁移再试 } } static async loadAll(context: common.Context): Promise { // 1) 新加密存储 try { const store: preferences.Preferences = await Vault.prefs(context); const data: string = await store.get(VAULT_DATA, '') as string; if (data !== '') { const plain: string | null = await Vault.decrypt(context, data); if (plain !== null) { const arr: Array> = JSON.parse(plain) as Array>; const result: DavAccount[] = []; for (const o of arr) { const a = new DavAccount(); a.id = o['id'] !== undefined ? o['id'] as string : ''; a.type = o['type'] !== undefined ? o['type'] as string : TYPE_CALDAV; a.name = o['name'] !== undefined ? o['name'] as string : ''; a.serverUrl = o['serverUrl'] !== undefined ? o['serverUrl'] as string : ''; a.username = o['username'] !== undefined ? o['username'] as string : ''; a.password = o['password'] !== undefined ? o['password'] as string : ''; a.calendarHrefs = o['calendarHrefs'] !== undefined ? o['calendarHrefs'] as string[] : []; a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : []; a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : []; a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : []; a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0; a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : ''; if (a.id === '') { a.id = `acc${Date.now()}_${result.length}`; } result.push(a); } console.info(`[AccountStore] loadAll: ${result.length} 个账号(加密存储)`); // 旧格式密文(软件 AES / 更早的无前缀格式):HUKS 可用时就地升级为 huks1: 密文, // 随后清除偏好里的明文密钥 —— 让已有账号也获得"密钥不落盘"的保护。 // ⚠️ 清密钥必须**以"回读到的密文确实是 huks1:"为前提**: // 若 HUKS 不可用,saveAll 写回的仍然是 aes1: 密文,此时清掉 key_b64 // 会把刚写的密文连同旧账号一起变成永久解不开(账号凭空消失 + 再也存不上)。 if (!data.startsWith(PREFIX_HUKS)) { try { await AccountStore.saveAll(context, result, true); const now: string = await store.get(VAULT_DATA, '') as string; if (now.startsWith(PREFIX_HUKS)) { await Vault.purgeSoftKey(context); console.info('[AccountStore] 账号密文已升级为 HUKS 保护'); } else { console.info('[AccountStore] HUKS 不可用,维持软件 AES 加密(保留密钥,不清除)'); } } catch (err) { // 升级失败不阻断本次读取,下次启动再试 } } return result; } console.error('[AccountStore] 加密账号数据解密失败'); } } catch (err) { const e = err as BusinessError; console.error(`[AccountStore] 读取加密账号失败: ${e.message}`); } // 2) 旧版明文存储 → 迁移 const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context); if (legacy !== null) { console.info(`[AccountStore] 迁移 ${legacy.length} 个明文账号到加密存储`); await AccountStore.saveAll(context, legacy, true); await AccountStore.clearLegacy(context); return legacy; } return []; } /** * 保存全部账号(整体加密写入)。 * 防御:当前已有账号时,禁止用空列表覆盖(调用方因读取异常拿到空列表再回写, * 会把所有账号抹掉)。仅删除账号的合法场景通过 force=true 放行。 */ static async saveAll(context: common.Context, accounts: DavAccount[], force: boolean = false): Promise { // 防御:先看加密存储里现有数量(空列表覆盖保护) let existing: number = -1; try { const store: preferences.Preferences = await Vault.prefs(context); const data: string = await store.get(VAULT_DATA, '') as string; if (data !== '') { const plain: string | null = await Vault.decrypt(context, data); if (plain !== null) { existing = (JSON.parse(plain) as Array>).length; } else { // 有密文但解不开:按"存在数据"处理,宁可拦住空写,也不把账号整体清掉 existing = 1; } } } catch (err) { // 读取失败按"未知"处理,不拦截 existing = -1; } if (accounts.length === 0 && existing > 0 && !force) { console.error(`[AccountStore] 拒绝用空列表覆盖账号存储(原有 ${existing} 个账号)`); throw new Error('账号列表为空,已阻止覆盖存储(保护原有账号数据)'); } const plain: string = JSON.stringify(accounts); const enc: string = await Vault.encrypt(context, plain); const store: preferences.Preferences = await Vault.prefs(context); await store.put(VAULT_DATA, enc); await store.flush(); console.info(`[AccountStore] saveAll: ${accounts.length} 个账号已加密写入`); } /** * 追加一个账号(读全量 → push → 整体加密回写)。 * * 防覆盖保护:回写前先探测"现有存储是否可读"(canReadExisting)。 * 若存储里有密文却解不开(例如 key_b64 丢失或被重新生成),loadAll 会回落成空数组, * 此时 push 一个账号后长度 = 1,会绕过 saveAll 的"空列表覆盖保护"→ 其余账号连同密码全部丢失。 * 因此遇到该情况直接抛错中止:宁可让用户看到失败提示,也不能静默清空账号。 */ static async addAccount(context: common.Context, acc: DavAccount): Promise { if (!(await AccountStore.canReadExisting(context))) { console.error('[AccountStore] 现有账号存储不可读,已中止 addAccount 以防覆盖'); throw new Error('读取已有账号失败,为避免覆盖已保存的账号,已中止本次添加'); } const list: DavAccount[] = await AccountStore.loadAll(context); list.push(acc); await AccountStore.saveAll(context, list); } /** * 【逃生舱】重置账号库:清空本地保存的全部账号、服务器地址与密码。 * * 场景:账号库存在但**解不开**(密钥丢失 / HUKS 密钥失效等),此时 `canReadExisting()` * 会拒绝任何写入 —— 表现为"账号消失且再也加不上",而唯一的原生出路是清除**整个**应用数据 * (会一并丢掉设置偏好、功能引导标记、本地日程库)。本方法只清账号那一块。 * * ⚠️ **必须由用户在界面上二次确认后调用**;日程数据在 CalDAV 服务器上,重新添加账号 * 并同步即可恢复,但本地保存的密码会被删除且不可撤销。 */ static async resetVault(context: common.Context): Promise { await Vault.clearVault(context); console.info('[AccountStore] 账号库已重置,可重新添加账号'); } /** * 探测"现有账号存储是否可读",供写前加固使用。 * - 无密文(全新安装 / 已清空)→ true:可以安全写入 * - 有密文且能解密 → true * - 有密文但解密失败 / 读取异常 → false:**绝不能回写**(否则会整体覆盖、丢失全部账号) */ private static async canReadExisting(context: common.Context): Promise { try { const store: preferences.Preferences = await Vault.prefs(context); const data: string = await store.get(VAULT_DATA, '') as string; if (data === '') { return true; } return (await Vault.decrypt(context, data)) !== null; } catch (err) { return false; } } }