增强了安全配置。

Signed-off-by: Yang Yongquan <i@yangyq.net>
This commit is contained in:
2026-09-15 12:51:58 +08:00
parent 088f7b3773
commit 5eaeeb0f4c
19 changed files with 1062 additions and 267 deletions
+349 -23
View File
@@ -1,11 +1,17 @@
// entry/src/main/ets/common/AccountStore.ets
// DAV 账号持久化(加密):
// - 账号列表(含密码)序列化为 JSON 后用 AES-256-GCM 整体加密,
// 存储为 caldav_vault 偏好中的 data_b64iv:密文+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;
}
}
}
@@ -16,7 +16,6 @@ export class CardItem {
endTime: string = ''; // 结束时间 '10:00'(全天事件为空)
date: string = ''; // '9月15日'
showDate: boolean = false; // 是否为当天分组的第一条(卡片上渲染日期头)
calName: string = ''; // 所属日历本名(右侧显示,颜色同日历色)
color: string = '#007DFF';
// 当前时间红线:start/end 为日程实际起止毫秒(用于定位"现在"位置);
// showNowLine 表示该日程上方应绘制红线,isNow 表示该日程正在进行中
@@ -252,7 +251,6 @@ export class CardDataService {
}
item.date = dateLabel;
item.showDate = i === 0; // 当天分组第一条 → 卡片上显示日期头
item.calName = e.calName;
item.color = e.color;
// 红线定位:记录实际起止毫秒(用于卡片渲染时判断"现在"位置)
item.startMs = e.startTime;
@@ -316,7 +314,6 @@ export class CardDataService {
oi.title = e.title === '' ? '(无标题)' : e.title;
oi.time = dayLong ? '全天' : `${p(ds.getHours())}:${p(ds.getMinutes())}`;
oi.endTime = dayLong ? '' : `${p(de.getHours())}:${p(de.getMinutes())}`;
oi.calName = e.calName;
oi.color = e.color;
oi.startMs = e.startTime;
oi.endMs = e.endTime;
+106 -1
View File
@@ -20,6 +20,22 @@ export class DavColorEntry {
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
}
/** 日历本集合条目("添加/编辑账号"页列日历本用) */
export class DavCalendarEntry {
href: string = ''; // 完整 URL(已补全域名)
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
color: string = ''; // 规范化后的 #RRGGBB,可能为空
}
/**
* 日历本发现结果。
* 带 statusCode —— 调用方需要据此区分「401 凭据失效」与「非 207 异常」,故不能只返回列表。
*/
export class DavCalendarDiscovery {
statusCode: number = 0;
entries: DavCalendarEntry[] = [];
}
export class DavClient {
/**
* PROPFIND 拉取某路径下所有集合的 calendar-color
@@ -93,6 +109,88 @@ export class DavClient {
}
}
/**
* PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。
*
* 供「添加账号 / 编辑账号」两页复用,取代它们各自维护的一份同逻辑拷贝 ——
* 解析逻辑集中在此,避免多处正则各自漂移。
* 解析用正则而非 XML 解析器(见 extractTag 的 XXE 说明)。
* 网络/解析异常向上抛出由调用方决定提示文案;仅"HTTP 状态码非成功"经 statusCode 返回。
*/
static async listCalendars(serverUrl: string, auth: string): Promise<DavCalendarDiscovery> {
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
const out = new DavCalendarDiscovery();
try {
const resp: http.HttpResponse = await httpRequest.request(serverUrl, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': auth,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: requestBody,
connectTimeout: 10000,
readTimeout: 15000
});
out.statusCode = resp.responseCode;
LogUtil.write(`HTTP PROPFIND(日历本) ${serverUrl} → ${resp.responseCode}`);
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
return out;
}
const xml: string = resp.result as string;
const originMatch = /https?:\/\/[^/]+/i.exec(serverUrl);
const origin: string = originMatch !== null ? originMatch[0] : '';
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
for (const block of blocks) {
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
continue;
}
const href: string = DavClient.extractTag(block, 'href');
if (href === '') {
continue;
}
const resourcetype: string = DavClient.extractTag(block, 'resourcetype');
if (!/calendar/i.test(resourcetype)) {
continue;
}
let name: string = DavClient.extractTag(block, 'displayname');
if (name === '') {
// 服务器没给 displayname → 回退为 href 路径末段
const segs: string[] = href.split('/').filter((s: string) => s !== '');
if (segs.length > 0) {
const last: string = segs[segs.length - 1];
try {
name = decodeURIComponent(last);
} catch (err) {
name = last;
}
} else {
name = href;
}
}
// 两个命名空间都试:cs:getcolorCalendarServer/ ical:calendar-colorApple
let color: string = DavClient.normalizeHex(DavClient.extractTag(block, 'getcolor'));
if (color === '') {
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
}
const entry = new DavCalendarEntry();
entry.href = href.startsWith('http') ? href : origin + href;
entry.displayName = name;
entry.color = color;
out.entries.push(entry);
}
return out;
} finally {
httpRequest.destroy();
}
}
/** 颜色规范化:#RRGGBBAA → #RRGGBB */
static normalizeHex(raw: string): string {
const v: string = raw.trim();
@@ -370,7 +468,14 @@ export class DavClient {
}
}
/** 提取任意命名空间前缀标签的内容 */
/**
* 提取任意命名空间前缀标签的文本内容(如 `<d:href>` / `<cs:getcolor>`)。
*
* 安全说明:此处**刻意用正则而不是 XML 解析器**——DAV 响应来自用户自填的远端服务器,
* 属不可信输入;正则提取不构建 DOM、不解析实体,从根上规避了 XXE(外部实体扩展)
* 与「十亿笑声」实体炸弹这类解析器层面的攻击面。
* 另外 `tag` 只由本文件内的代码常量传入,不来自远端数据,故无需防注入。
*/
static extractTag(xml: string, tag: string): string {
const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>';
const regex: RegExp = new RegExp(pattern, 'i');
+2
View File
@@ -346,6 +346,8 @@ export class EventDb {
* 用远端数据刷新某个日历本(增量):
* - etag 未变的跳过;变化的更新;远端没有的本地图删掉(排除本地待推送的新事件)
* - 返回统计描述:"新增X 更新Y 删除Z 不变W"
* - 实例唯一键用 `${uid}_${startTime}`:同一 uid 的多日展开各自成行,
* 因此**改期会表现为"旧实例删除 + 新实例新增"**,属预期行为。
*/
static async applyRemote(context: common.Context, calKey: string, href: string,
remote: RemoteEvent[], isTodo: boolean): Promise<string> {
+7 -1
View File
@@ -216,7 +216,13 @@ export class IcsUtil {
return parsed;
}
/** 由本地事件构建 VCALENDAR 文本(时间统一转 UTC,保证服务器端时区正确) */
/**
* 由本地事件构建 VCALENDAR 文本(时间统一转 UTC,保证服务器端时区正确)。
*
* 已知取舍:摘要 / 地点 / 备注等文本字段**未做 ICS 转义**(`,` `;` `\` 与换行)。
* 含这些字符的标题在服务器端可能被截断或串行;
* 若线上出现"标题被截断"反馈,需在此补 escapeIcsText()。
*/
static build(e: LocalEvent): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const fmtUtc = (ms: number): string => {
+11 -10
View File
@@ -39,11 +39,12 @@ export class SyncEngine {
if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) {
throw new Error('该账号正在同步中,请稍后再试');
}
LogUtil.write(`同步互斥锁超时残留(${acc.name}),强制释放并重新同步`);
LogUtil.write(`同步互斥锁超时残留(账号 id=${acc.id}),强制释放并重新同步`);
}
SyncEngine.activeSyncs.set(acc.id, Date.now());
const t0: number = Date.now();
LogUtil.write(`========== 同步账号「${acc.name}」开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
// 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位
LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
try {
// 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟
const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context);
@@ -54,11 +55,11 @@ export class SyncEngine {
SyncEngine.activeSyncs.delete(acc.id);
});
const r: number = await SyncEngine.withTimeout<number>(inner, timeoutMs);
LogUtil.write(`同步账号${acc.name}」完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
LogUtil.write(`同步账号 id=${acc.id} 完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
return r;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`同步账号${acc.name}」失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
LogUtil.write(`同步账号 id=${acc.id} 失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`);
}
}
@@ -287,13 +288,13 @@ export class SyncEngine {
static async pushDirtyForAccount(context: common.Context, acc: DavAccount, auth: string): Promise<void> {
const dirty: LocalEvent[] = await EventDb.getDirty(context);
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号${acc.name}」的 ${mine.length} 条`);
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`);
for (const e of mine) {
// 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推)
const bookIdx: number = acc.calendarHrefs.indexOf(e.href);
if (bookIdx >= 0 && acc.calendarWritable.length > bookIdx
&& acc.calendarWritable[bookIdx] === '0') {
LogUtil.write(`推送跳过只读日历本事件「${e.title}」(uid=${e.uid})`);
LogUtil.write(`推送跳过只读日历本事件(标题已脱敏)uid=${e.uid}`);
continue;
}
if (e.kind === 'todo') {
@@ -304,23 +305,23 @@ export class SyncEngine {
if (e.recurring && e.rrule === '') {
// 重复日程的"单次覆盖实例"RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持
await EventDb.clearDirty(context, e.id, e.etag);
LogUtil.write(`推送跳过重复日程实例「${e.title}」(uid=${e.uid})`);
LogUtil.write(`推送跳过重复日程实例(标题已脱敏)uid=${e.uid}`);
continue;
}
if (e.recurring) {
// 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送
LogUtil.write(`推送重复主事件「${e.title}」(uid=${e.uid})`);
LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`);
}
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
if (e.deleted) {
await DavClient.deleteRemote(url, auth);
await EventDb.purge(context, e.id);
LogUtil.write(`推送删除「${e.title}」→ ${url}`);
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
} else {
const ics: string = IcsUtil.build(e);
const etag: string = await DavClient.putEvent(url, auth, ics);
await EventDb.clearDirty(context, e.id, etag);
LogUtil.write(`推送保存「${e.title}」→ ${url}${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'}`);
LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'}`);
}
}
}
@@ -16,7 +16,9 @@ export class TimelineBlock {
eventKey: string = ''; // 与视图 ForEach key 一致
title: string = '';
timeText: string = ''; // '09:00 - 10:30'
location: string = ''; // 地点(第 2/3 行显示)
color: string = '#007DFF';
writable: boolean = true; // 所属日历本是否可写(false → 色块标题后挂白框「只读」小标)
topRatio: number = 0; // 距 0 点的比例 0~1(乘时间轴总高)
heightRatio: number = 0; // 高度比例(最小高度由视图侧兜底)
leftRatio: number = 0; // 横向起始比例 0~1(冲突平分)
@@ -85,6 +87,10 @@ export class TimelineUtil {
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
// ---- 只读外观 ------------------------------------------------------------
// 只读不再用"灰蒙版底色"(观感不好,已撤),改为在色块标题后挂一个**白框白字**的「只读」小标
// (见 Index 的 roTag()/卡片内的同名小标)。这里只负责把可写标记带出来。
/**
* 构建某一天的时间轴布局。
* @param events 与 dateMs 这一天相交的全部日程
@@ -165,6 +171,8 @@ export class TimelineUtil {
b.groupIndex = groupIdx;
b.title = e.title === '' ? '(无标题)' : e.title;
b.timeText = `${TimelineUtil.fmtTime(e.startTime)} - ${TimelineUtil.fmtTime(e.endTime)}`;
b.location = e.location;
b.writable = e.writable;
b.color = e.color;
// 与今天的交集(跨天日程已排除,这里都是日内)
const s: number = Math.max(e.startTime, dayStart);