@@ -1,11 +1,17 @@
|
||||
// entry/src/main/ets/common/AccountStore.ets
|
||||
// DAV 账号持久化(加密版):
|
||||
// - 账号列表(含密码)序列化为 JSON 后用 AES-256-GCM 整体加密,
|
||||
// 存储为 caldav_vault 偏好中的 data_b64(iv:密文+tag);
|
||||
// - AES 密钥首次随机生成后存于同一偏好(key_b64);
|
||||
// - 旧版明文存储(caldav_account 的 acc_0..n)在首次 loadAll 时自动迁移并清除。
|
||||
// DAV 账号持久化(加密):
|
||||
// - 账号列表(含密码)序列化为 JSON 后整体加密,存储为 caldav_vault 偏好中的 data_b64。
|
||||
// - **首选 HUKS 硬件密钥库**:AES-256-GCM 密钥在 TEE 内生成、**不可导出、不落盘**(只留 keyAlias),
|
||||
// 密文格式 `huks1:<b64(nonce)>:<b64(密文+tag)>`。这样"整机备份被导出"也解不开 —— 密钥不在备份里。
|
||||
// 写入前做**自检**: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';
|
||||
@@ -24,9 +30,16 @@ 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)
|
||||
*/
|
||||
@@ -84,28 +97,214 @@ function bytesToStr(u: Uint8Array): string {
|
||||
return new util.TextDecoder('utf-8').decodeToString(u);
|
||||
}
|
||||
|
||||
/** 账号加密存储实现(AES-256-GCM,密钥持久化) */
|
||||
/**
|
||||
* 账号加密存储实现。
|
||||
*
|
||||
* **首选 HUKS(硬件密钥库)**:AES-256-GCM 密钥在 TEE 内生成、**不可导出、不落盘**,
|
||||
* 应用侧只持有 keyAlias。因此「整机备份被导出」到其他设备也无法解密(密钥不在备份中、且与设备绑定)。
|
||||
*
|
||||
* **回落**:HUKS 初始化/调用异常时退回软件 AES(密钥存偏好,`aes1:` 前缀)——
|
||||
* 本环境无法真机验证 HUKS,留此安全网可确保"最坏情况 = 与改造前一致",绝不出现存不上账号。
|
||||
* 探测结果缓存在 `huksReady`,避免每轮同步反复试错。
|
||||
*
|
||||
* 密文自描述(前缀区分算法),便于日后识别与再迁移:
|
||||
* `huks1:<b64(nonce)>:<b64(密文+tag)>` | `aes1:<b64(iv)>:<b64(密文+tag)>` | `<b64(iv)>:<b64(...)>`(更早旧格式)
|
||||
*/
|
||||
class Vault {
|
||||
private static keyB64: string = ''; // 进程内缓存,避免每轮同步重复读偏好
|
||||
/** 软件方案:进程内缓存,避免每轮同步重复读偏好 */
|
||||
private static keyB64: string = '';
|
||||
/** HUKS 可用性:null=未探测;true=可用;false=已判定不可用(本进程不再重试) */
|
||||
private static huksReady: boolean | null = null;
|
||||
|
||||
static async prefs(context: common.Context): Promise<preferences.Preferences> {
|
||||
return preferences.getPreferences(context, VAULT_STORE);
|
||||
}
|
||||
|
||||
/** 获取(或首次生成)AES-256 密钥 */
|
||||
private static async aesKey(context: common.Context): Promise<cryptoFramework.SymKey> {
|
||||
// ==================== 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<boolean> {
|
||||
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<string> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<cryptoFramework.SymKey | null> {
|
||||
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<cryptoFramework.SymKey> {
|
||||
const existing: cryptoFramework.SymKey | null = await Vault.softKeyExisting(context);
|
||||
if (existing !== null) {
|
||||
return existing;
|
||||
}
|
||||
const gen: cryptoFramework.SymKeyGenerator =
|
||||
cryptoFramework.createSymKeyGenerator('AES256');
|
||||
if (Vault.keyB64 !== '') {
|
||||
return gen.convertKey({ data: b64ToBytes(Vault.keyB64) });
|
||||
}
|
||||
const key: cryptoFramework.SymKey = await gen.generateSymKey();
|
||||
const raw: cryptoFramework.DataBlob = key.getEncoded();
|
||||
Vault.keyB64 = bytesToB64(raw.data);
|
||||
Vault.keyB64 = bytesToB64(key.getEncoded().data);
|
||||
const store: preferences.Preferences = await Vault.prefs(context);
|
||||
await store.put(VAULT_KEY, Vault.keyB64);
|
||||
await store.flush();
|
||||
@@ -121,9 +320,9 @@ class Vault {
|
||||
};
|
||||
}
|
||||
|
||||
/** 加密:返回 base64(iv) + ':' + base64(密文+authTag) */
|
||||
static async encrypt(context: common.Context, plain: string): Promise<string> {
|
||||
const key: cryptoFramework.SymKey = await Vault.aesKey(context);
|
||||
/** 软件 AES 加密:返回 `b64(iv):b64(密文+tag)`(不含前缀) */
|
||||
private static async softEncrypt(context: common.Context, plain: string): Promise<string> {
|
||||
const key: cryptoFramework.SymKey = await Vault.softKey(context);
|
||||
const iv: Uint8Array =
|
||||
cryptoFramework.createRandom().generateRandomSync(12).data;
|
||||
const cipher: cryptoFramework.Cipher =
|
||||
@@ -135,20 +334,24 @@ class Vault {
|
||||
return `${bytesToB64(iv)}:${bytesToB64(out.data)}`;
|
||||
}
|
||||
|
||||
/** 解密:失败返回 null(调用方按"无账号"或迁移路径处理) */
|
||||
static async decrypt(context: common.Context, stored: string): Promise<string | null> {
|
||||
const sep: number = stored.indexOf(':');
|
||||
/** 软件 AES 解密(body = `b64(iv):b64(密文+tag)`);格式不符返回 null */
|
||||
private static async softDecrypt(context: common.Context, body: string): Promise<string | null> {
|
||||
const sep: number = body.indexOf(':');
|
||||
if (sep <= 0) {
|
||||
return null;
|
||||
}
|
||||
const iv: Uint8Array = b64ToBytes(stored.substring(0, sep));
|
||||
const blob: Uint8Array = b64ToBytes(stored.substring(sep + 1));
|
||||
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 = await Vault.aesKey(context);
|
||||
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,
|
||||
@@ -156,6 +359,61 @@ class Vault {
|
||||
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<string> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,6 +557,25 @@ export class AccountStore {
|
||||
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] 加密账号数据解密失败');
|
||||
@@ -334,6 +611,9 @@ export class AccountStore {
|
||||
const plain: string | null = await Vault.decrypt(context, data);
|
||||
if (plain !== null) {
|
||||
existing = (JSON.parse(plain) as Array<Record<string, Object>>).length;
|
||||
} else {
|
||||
// 有密文但解不开:按"存在数据"处理,宁可拦住空写,也不把账号整体清掉
|
||||
existing = 1;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -352,9 +632,55 @@ export class AccountStore {
|
||||
console.info(`[AccountStore] saveAll: ${accounts.length} 个账号已加密写入`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一个账号(读全量 → push → 整体加密回写)。
|
||||
*
|
||||
* 防覆盖保护:回写前先探测"现有存储是否可读"(canReadExisting)。
|
||||
* 若存储里有密文却解不开(例如 key_b64 丢失或被重新生成),loadAll 会回落成空数组,
|
||||
* 此时 push 一个账号后长度 = 1,会绕过 saveAll 的"空列表覆盖保护"→ 其余账号连同密码全部丢失。
|
||||
* 因此遇到该情况直接抛错中止:宁可让用户看到失败提示,也不能静默清空账号。
|
||||
*/
|
||||
static async addAccount(context: common.Context, acc: DavAccount): Promise<void> {
|
||||
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<void> {
|
||||
await Vault.clearVault(context);
|
||||
console.info('[AccountStore] 账号库已重置,可重新添加账号');
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测"现有账号存储是否可读",供写前加固使用。
|
||||
* - 无密文(全新安装 / 已清空)→ true:可以安全写入
|
||||
* - 有密文且能解密 → true
|
||||
* - 有密文但解密失败 / 读取异常 → false:**绝不能回写**(否则会整体覆盖、丢失全部账号)
|
||||
*/
|
||||
private static async canReadExisting(context: common.Context): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user