@agentunion/fastaun-browser 0.5.3 → 0.5.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -56
- package/_packed_docs/AUN/347/246/273/347/272/277/346/216/250/351/200/201/346/216/245/345/205/245/344/270/216/346/274/224/347/244/272/346/214/207/345/215/227.md +290 -0
- package/_packed_docs/AUN/347/246/273/347/272/277/346/216/250/351/200/201/346/234/215/345/212/241/346/236/266/346/236/204/344/270/216/350/257/246/347/273/206/345/256/236/347/216/260/350/256/241/345/210/222-codex.md +994 -0
- package/_packed_docs/AUN/347/246/273/347/272/277/346/216/250/351/200/201/346/234/215/345/212/241/350/277/220/347/273/264/344/270/216/345/217/221/345/270/203/346/214/207/345/215/227.md +144 -0
- package/_packed_docs/CHANGELOG.md +130 -56
- package/_packed_docs/INDEX.md +127 -69
- package/_packed_docs/KITE_DOCS_GUIDE.md +63 -29
- package/_packed_docs/audit/AUN/346/234/215/345/212/241Go/345/214/226/351/207/215/346/236/204/347/262/276/347/273/206/345/214/226/346/272/220/347/240/201/345/256/241/346/237/245-20260718.md +366 -0
- package/_packed_docs/aun/345/205/254/347/275/221/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +227 -0
- package/_packed_docs/aun/345/210/206/345/270/203/345/274/217/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +1004 -0
- package/_packed_docs/aun/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +999 -0
- package/_packed_docs/cli/CLI/346/211/213/345/206/214.md +6 -4
- package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +58 -20
- package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +219 -247
- package/_packed_docs/protocol/12-Stream-/345/255/220/345/215/217/350/256/256.md +14 -14
- package/_packed_docs/protocol/13-Agent/350/241/214/344/270/272/350/247/204/350/214/203.md +3 -3
- package/_packed_docs/protocol/15-/347/246/273/347/272/277/346/216/250/351/200/201/351/200/232/347/237/245/345/215/217/350/256/256.md +165 -421
- package/_packed_docs/protocol/README.md +1 -0
- package/_packed_docs/protocol/aun-docs-guide.md +7 -4
- package/_packed_docs/protocol/index.md +25 -19
- package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +62 -17
- package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +22 -0
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +67 -78
- package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +6 -2
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +87 -41
- package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +18 -5
- package/_packed_docs/sdk/09-group-rpc-manual.md +307 -267
- package/_packed_docs/sdk/09-message-rpc-manual.md +142 -103
- package/_packed_docs/sdk/09-payload-reference.md +1 -1
- package/_packed_docs/sdk/09-stream-rpc-manual.md +8 -8
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +26 -17
- package/_packed_docs/sdk/INDEX.md +42 -34
- package/_packed_docs/sdk/README.md +7 -6
- package/_packed_docs//346/266/210/346/201/257/345/220/214/346/255/245/344/270/216/345/216/206/345/217/262/346/213/211/345/217/226/344/273/243/347/240/201/345/256/241/346/237/245/351/227/256/351/242/230/346/270/205/345/215/225.md +229 -0
- package/_packed_docs//346/266/210/346/201/257/345/220/214/346/255/245/344/270/216/345/216/206/345/217/262/346/213/211/345/217/226/346/224/271/351/200/240/346/226/271/346/241/210.md +750 -0
- package/dist/agent-md.d.ts +4 -5
- package/dist/agent-md.d.ts.map +1 -1
- package/dist/agent-md.js +66 -43
- package/dist/agent-md.js.map +1 -1
- package/dist/aid-store.d.ts +2 -6
- package/dist/aid-store.d.ts.map +1 -1
- package/dist/aid-store.js +20 -14
- package/dist/aid-store.js.map +1 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +30 -11
- package/dist/auth.js.map +1 -1
- package/dist/bundle.js +3399 -1069
- package/dist/client/delivery.d.ts +75 -8
- package/dist/client/delivery.d.ts.map +1 -1
- package/dist/client/delivery.js +1419 -245
- package/dist/client/delivery.js.map +1 -1
- package/dist/client/group-state.d.ts.map +1 -1
- package/dist/client/group-state.js +27 -24
- package/dist/client/group-state.js.map +1 -1
- package/dist/client/lifecycle.d.ts.map +1 -1
- package/dist/client/lifecycle.js +48 -4
- package/dist/client/lifecycle.js.map +1 -1
- package/dist/client/mention-mode.d.ts +7 -0
- package/dist/client/mention-mode.d.ts.map +1 -0
- package/dist/client/mention-mode.js +184 -0
- package/dist/client/mention-mode.js.map +1 -0
- package/dist/client/peers.d.ts +1 -1
- package/dist/client/peers.d.ts.map +1 -1
- package/dist/client/peers.js +26 -3
- package/dist/client/peers.js.map +1 -1
- package/dist/client/rpc-pipeline.d.ts +22 -1
- package/dist/client/rpc-pipeline.d.ts.map +1 -1
- package/dist/client/rpc-pipeline.js +309 -74
- package/dist/client/rpc-pipeline.js.map +1 -1
- package/dist/client/v2-e2ee.d.ts +25 -2
- package/dist/client/v2-e2ee.d.ts.map +1 -1
- package/dist/client/v2-e2ee.js +606 -97
- package/dist/client/v2-e2ee.js.map +1 -1
- package/dist/client.d.ts +24 -5
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +404 -215
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -1
- package/dist/errors.js.map +1 -1
- package/dist/facades.d.ts +6 -0
- package/dist/facades.d.ts.map +1 -1
- package/dist/facades.js +199 -104
- package/dist/facades.js.map +1 -1
- package/dist/group-index.d.ts +6 -1
- package/dist/group-index.d.ts.map +1 -1
- package/dist/group-index.js +44 -25
- package/dist/group-index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keystore/index.d.ts +10 -5
- package/dist/keystore/index.d.ts.map +1 -1
- package/dist/keystore/indexeddb-identity-store.d.ts +0 -12
- package/dist/keystore/indexeddb-identity-store.d.ts.map +1 -1
- package/dist/keystore/indexeddb-identity-store.js +0 -60
- package/dist/keystore/indexeddb-identity-store.js.map +1 -1
- package/dist/keystore/indexeddb-shared.d.ts.map +1 -1
- package/dist/keystore/indexeddb-shared.js +9 -5
- package/dist/keystore/indexeddb-shared.js.map +1 -1
- package/dist/keystore/indexeddb-token-store.d.ts +3 -1
- package/dist/keystore/indexeddb-token-store.d.ts.map +1 -1
- package/dist/keystore/indexeddb-token-store.js +47 -2
- package/dist/keystore/indexeddb-token-store.js.map +1 -1
- package/dist/register-flow.d.ts.map +1 -1
- package/dist/register-flow.js +28 -3
- package/dist/register-flow.js.map +1 -1
- package/dist/seq-tracker.d.ts +28 -8
- package/dist/seq-tracker.d.ts.map +1 -1
- package/dist/seq-tracker.js +218 -61
- package/dist/seq-tracker.js.map +1 -1
- package/dist/storage/vfs.d.ts +1 -0
- package/dist/storage/vfs.d.ts.map +1 -1
- package/dist/storage/vfs.js +26 -2
- package/dist/storage/vfs.js.map +1 -1
- package/dist/tools/cross-sdk-agent.js +376 -10
- package/dist/tools/cross-sdk-agent.js.map +1 -1
- package/dist/transport.d.ts +12 -0
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +218 -101
- package/dist/transport.js.map +1 -1
- package/dist/v2/session/session.d.ts.map +1 -1
- package/dist/v2/session/session.js +3 -4
- package/dist/v2/session/session.js.map +1 -1
- package/dist/v2/state/commitment.d.ts.map +1 -1
- package/dist/v2/state/commitment.js +1 -2
- package/dist/v2/state/commitment.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -5
- package/dist/group-resources.d.ts +0 -98
- package/dist/group-resources.d.ts.map +0 -1
- package/dist/group-resources.js +0 -635
- package/dist/group-resources.js.map +0 -1
- package/dist/keystore/indexeddb.d.ts +0 -179
- package/dist/keystore/indexeddb.d.ts.map +0 -1
- package/dist/keystore/indexeddb.js +0 -2031
- package/dist/keystore/indexeddb.js.map +0 -1
- package/dist/namespaces/auth.d.ts +0 -98
- package/dist/namespaces/auth.d.ts.map +0 -1
- package/dist/namespaces/auth.js +0 -992
- package/dist/namespaces/auth.js.map +0 -1
- package/dist/namespaces/custody.d.ts +0 -51
- package/dist/namespaces/custody.d.ts.map +0 -1
- package/dist/namespaces/custody.js +0 -302
- package/dist/namespaces/custody.js.map +0 -1
- package/dist/namespaces/meta.d.ts +0 -109
- package/dist/namespaces/meta.d.ts.map +0 -1
- package/dist/namespaces/meta.js +0 -549
- package/dist/namespaces/meta.js.map +0 -1
|
@@ -1,2031 +0,0 @@
|
|
|
1
|
-
// ── IndexedDB KeyStore 实现 ──────────────────────────────
|
|
2
|
-
import { pemToArrayBuffer } from '../crypto.js';
|
|
3
|
-
import { normalizeInstanceId, slotIsolationKey } from '../config.js';
|
|
4
|
-
const _noopLog = { error: () => { }, warn: () => { }, info: () => { }, debug: () => { } };
|
|
5
|
-
import { isJsonObject, } from '../types.js';
|
|
6
|
-
/** AID 安全化(替换路径分隔符) */
|
|
7
|
-
function safeAid(aid) {
|
|
8
|
-
return aid.replace(/[/\\:]/g, '_');
|
|
9
|
-
}
|
|
10
|
-
/** 从 cert PEM 中提取 SPKI 公钥的 base64(纯 ASN.1 DER 解析,浏览器兼容) */
|
|
11
|
-
function extractSpkiB64FromCertPem(certPem) {
|
|
12
|
-
try {
|
|
13
|
-
const der = new Uint8Array(pemToArrayBuffer(certPem));
|
|
14
|
-
// 最小 ASN.1 TLV 读取
|
|
15
|
-
const tlv = (d, o) => {
|
|
16
|
-
let lo = o + 1, len;
|
|
17
|
-
if (d[lo] & 0x80) {
|
|
18
|
-
const n = d[lo] & 0x7f;
|
|
19
|
-
len = 0;
|
|
20
|
-
for (let i = 0; i < n; i++)
|
|
21
|
-
len = (len << 8) | d[lo + 1 + i];
|
|
22
|
-
lo += 1 + n;
|
|
23
|
-
}
|
|
24
|
-
else {
|
|
25
|
-
len = d[lo];
|
|
26
|
-
lo += 1;
|
|
27
|
-
}
|
|
28
|
-
return [lo, len]; // [valueStart, valueLen]
|
|
29
|
-
};
|
|
30
|
-
const skip = (d, o) => { const [vs, vl] = tlv(d, o); return vs + vl; };
|
|
31
|
-
// Certificate → SEQUENCE
|
|
32
|
-
let [vs] = tlv(der, 0);
|
|
33
|
-
// TBSCertificate → SEQUENCE
|
|
34
|
-
let pos = vs;
|
|
35
|
-
const [tbsVs] = tlv(der, pos);
|
|
36
|
-
pos = tbsVs;
|
|
37
|
-
// version [0] EXPLICIT (optional)
|
|
38
|
-
if (der[pos] === 0xa0)
|
|
39
|
-
pos = skip(der, pos);
|
|
40
|
-
// serialNumber, signatureAlgorithm, issuer, validity, subject — 跳过 5 个字段
|
|
41
|
-
for (let i = 0; i < 5; i++)
|
|
42
|
-
pos = skip(der, pos);
|
|
43
|
-
// SubjectPublicKeyInfo — 就是当前位置
|
|
44
|
-
const spkiEnd = skip(der, pos);
|
|
45
|
-
const spkiBytes = der.slice(pos, spkiEnd);
|
|
46
|
-
// base64 编码
|
|
47
|
-
let b = '';
|
|
48
|
-
for (let i = 0; i < spkiBytes.length; i++)
|
|
49
|
-
b += String.fromCharCode(spkiBytes[i]);
|
|
50
|
-
return btoa(b);
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return '';
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
function encodePart(value) {
|
|
57
|
-
return encodeURIComponent(value);
|
|
58
|
-
}
|
|
59
|
-
function deepClone(value) {
|
|
60
|
-
return JSON.parse(JSON.stringify(value));
|
|
61
|
-
}
|
|
62
|
-
function isRecord(value) {
|
|
63
|
-
return isJsonObject(value);
|
|
64
|
-
}
|
|
65
|
-
function toBufferSource(bytes) {
|
|
66
|
-
return bytes.slice().buffer;
|
|
67
|
-
}
|
|
68
|
-
function sameJson(a, b) {
|
|
69
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
70
|
-
}
|
|
71
|
-
// ── 私钥字段级加密 (AES-256-GCM + PBKDF2) ──────────────
|
|
72
|
-
const _ENC_ALGO = 'AES-GCM';
|
|
73
|
-
const _PBKDF2_ITERATIONS = 100_000;
|
|
74
|
-
export class SeedMigrationError extends Error {
|
|
75
|
-
constructor(message) {
|
|
76
|
-
super(message);
|
|
77
|
-
this.name = 'SeedMigrationError';
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
/** 将 Uint8Array 编码为 base64 字符串 */
|
|
81
|
-
function _uint8ToBase64(bytes) {
|
|
82
|
-
let b = '';
|
|
83
|
-
for (let i = 0; i < bytes.length; i++)
|
|
84
|
-
b += String.fromCharCode(bytes[i]);
|
|
85
|
-
return btoa(b);
|
|
86
|
-
}
|
|
87
|
-
/** 将 base64 字符串解码为 Uint8Array */
|
|
88
|
-
function _base64ToUint8(b64) {
|
|
89
|
-
return Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
|
90
|
-
}
|
|
91
|
-
/** 从 seed 派生 AES-256 加密密钥 */
|
|
92
|
-
async function _deriveEncKey(seed, salt) {
|
|
93
|
-
const raw = new TextEncoder().encode(seed);
|
|
94
|
-
const base = await crypto.subtle.importKey('raw', raw, 'PBKDF2', false, ['deriveKey']);
|
|
95
|
-
return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: salt, iterations: _PBKDF2_ITERATIONS, hash: 'SHA-256' }, base, { name: _ENC_ALGO, length: 256 }, false, ['encrypt', 'decrypt']);
|
|
96
|
-
}
|
|
97
|
-
/** 加密 PEM 字符串,返回加密信封 */
|
|
98
|
-
async function _encryptPEM(pem, seed) {
|
|
99
|
-
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
100
|
-
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
101
|
-
const key = await _deriveEncKey(seed, salt);
|
|
102
|
-
const ct = await crypto.subtle.encrypt({ name: _ENC_ALGO, iv: iv }, key, new TextEncoder().encode(pem));
|
|
103
|
-
return {
|
|
104
|
-
ct: _uint8ToBase64(new Uint8Array(ct)),
|
|
105
|
-
iv: _uint8ToBase64(iv),
|
|
106
|
-
salt: _uint8ToBase64(salt),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
/** 解密加密信封,还原 PEM 字符串 */
|
|
110
|
-
async function _decryptPEM(enc, seed) {
|
|
111
|
-
const salt = _base64ToUint8(enc.salt);
|
|
112
|
-
const iv = _base64ToUint8(enc.iv);
|
|
113
|
-
const ct = _base64ToUint8(enc.ct);
|
|
114
|
-
const key = await _deriveEncKey(seed, salt);
|
|
115
|
-
const pt = await crypto.subtle.decrypt({ name: _ENC_ALGO, iv: toBufferSource(iv) }, key, toBufferSource(ct));
|
|
116
|
-
return new TextDecoder().decode(pt);
|
|
117
|
-
}
|
|
118
|
-
function hasEncryptionSeed(seed) {
|
|
119
|
-
return seed !== undefined;
|
|
120
|
-
}
|
|
121
|
-
// ── IndexedDB 工具 ──────────────────────────────────────
|
|
122
|
-
const DB_NAME = 'aun-keystore';
|
|
123
|
-
const DB_VERSION = 7;
|
|
124
|
-
/** 对象仓库名称 */
|
|
125
|
-
const STORE_KEY_PAIRS = 'key_pairs';
|
|
126
|
-
const STORE_CERTS = 'certs';
|
|
127
|
-
const STORE_METADATA = 'metadata';
|
|
128
|
-
const STORE_INSTANCE_STATE = 'instance_state';
|
|
129
|
-
const STORE_PREKEYS = 'prekeys';
|
|
130
|
-
const STORE_GROUP_CURRENT = 'group_current';
|
|
131
|
-
const STORE_GROUP_OLD_EPOCHS = 'group_old_epochs';
|
|
132
|
-
const STORE_SESSIONS = 'e2ee_sessions';
|
|
133
|
-
const STORE_GROUP_STATE = 'group_state';
|
|
134
|
-
const STORE_AGENT_MD_CACHE = 'agent_md_cache';
|
|
135
|
-
const STORE_PENDING_IDENTITIES = 'pending_identities';
|
|
136
|
-
const STRUCTURED_RECOVERY_RETENTION_MS = 7 * 24 * 3600 * 1000;
|
|
137
|
-
const CRITICAL_METADATA_KEYS = [];
|
|
138
|
-
function metadataStoreKey(aid) {
|
|
139
|
-
return safeAid(aid);
|
|
140
|
-
}
|
|
141
|
-
function randomHex(byteLength) {
|
|
142
|
-
const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
|
|
143
|
-
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
144
|
-
}
|
|
145
|
-
function pendingIdentityPrefix(aid) {
|
|
146
|
-
return `${safeAid(aid)}-`;
|
|
147
|
-
}
|
|
148
|
-
function normalizeCertFingerprint(certFingerprint) {
|
|
149
|
-
const normalized = String(certFingerprint ?? '').trim().toLowerCase();
|
|
150
|
-
if (!normalized)
|
|
151
|
-
return '';
|
|
152
|
-
if (!normalized.startsWith('sha256:'))
|
|
153
|
-
return '';
|
|
154
|
-
const hexPart = normalized.slice(7);
|
|
155
|
-
if (hexPart.length !== 64 || /[^0-9a-f]/.test(hexPart))
|
|
156
|
-
return '';
|
|
157
|
-
return normalized;
|
|
158
|
-
}
|
|
159
|
-
async function fingerprintFromCertPem(certPem) {
|
|
160
|
-
try {
|
|
161
|
-
const der = pemToArrayBuffer(certPem);
|
|
162
|
-
const hash = await crypto.subtle.digest('SHA-256', der);
|
|
163
|
-
const hex = Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
164
|
-
return `sha256:${hex}`;
|
|
165
|
-
}
|
|
166
|
-
catch {
|
|
167
|
-
return '';
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
function certStoreKey(aid, certFingerprint) {
|
|
171
|
-
const normalized = normalizeCertFingerprint(certFingerprint);
|
|
172
|
-
if (!normalized)
|
|
173
|
-
return safeAid(aid);
|
|
174
|
-
return `${safeAid(aid)}|${encodePart(normalized)}`;
|
|
175
|
-
}
|
|
176
|
-
function instanceStateStoreKey(aid, deviceId, slotId = '') {
|
|
177
|
-
const normalizedDevice = normalizeInstanceId(deviceId, 'device_id');
|
|
178
|
-
const slotKey = slotIsolationKey(slotId) || '_singleton';
|
|
179
|
-
return `${safeAid(aid)}|${encodePart(normalizedDevice)}|${encodePart(slotKey)}`;
|
|
180
|
-
}
|
|
181
|
-
function prekeyPrefix(aid) {
|
|
182
|
-
return `${safeAid(aid)}|`;
|
|
183
|
-
}
|
|
184
|
-
function prekeyStoreKey(aid, prekeyId, deviceId = '') {
|
|
185
|
-
const normalizedDeviceId = String(deviceId ?? '').trim();
|
|
186
|
-
if (!normalizedDeviceId) {
|
|
187
|
-
return `${safeAid(aid)}|${encodePart(prekeyId)}`;
|
|
188
|
-
}
|
|
189
|
-
return `${safeAid(aid)}|${encodePart(normalizedDeviceId)}|${encodePart(prekeyId)}`;
|
|
190
|
-
}
|
|
191
|
-
function groupCurrentPrefix(aid) {
|
|
192
|
-
return `${safeAid(aid)}|`;
|
|
193
|
-
}
|
|
194
|
-
function groupCurrentStoreKey(aid, groupId) {
|
|
195
|
-
return `${safeAid(aid)}|${encodePart(groupId)}`;
|
|
196
|
-
}
|
|
197
|
-
function groupOldPrefix(aid, groupId) {
|
|
198
|
-
const base = `${safeAid(aid)}|`;
|
|
199
|
-
if (groupId === undefined)
|
|
200
|
-
return base;
|
|
201
|
-
return `${base}${encodePart(groupId)}|`;
|
|
202
|
-
}
|
|
203
|
-
function groupOldStoreKey(aid, groupId, epoch) {
|
|
204
|
-
return `${safeAid(aid)}|${encodePart(groupId)}|${epoch}`;
|
|
205
|
-
}
|
|
206
|
-
function sessionPrefix(aid) {
|
|
207
|
-
return `${safeAid(aid)}|`;
|
|
208
|
-
}
|
|
209
|
-
function sessionStoreKey(aid, sessionId) {
|
|
210
|
-
return `${safeAid(aid)}|${encodePart(sessionId)}`;
|
|
211
|
-
}
|
|
212
|
-
function seqTrackerPrefix(aid, deviceId, slotId) {
|
|
213
|
-
const normalizedDevice = normalizeInstanceId(deviceId, 'device_id');
|
|
214
|
-
const slotKey = slotIsolationKey(slotId) || '_singleton';
|
|
215
|
-
return `_seq_|${safeAid(aid)}|${encodePart(normalizedDevice)}|${encodePart(slotKey)}|`;
|
|
216
|
-
}
|
|
217
|
-
function seqTrackerStoreKey(aid, deviceId, slotId, namespace) {
|
|
218
|
-
return `${seqTrackerPrefix(aid, deviceId, slotId)}${encodePart(namespace)}`;
|
|
219
|
-
}
|
|
220
|
-
function agentMdCachePrefix(ownerAid) {
|
|
221
|
-
return `${safeAid(ownerAid)}|`;
|
|
222
|
-
}
|
|
223
|
-
function agentMdCacheStoreKey(ownerAid, targetAid) {
|
|
224
|
-
return `${agentMdCachePrefix(ownerAid)}${encodePart(targetAid)}`;
|
|
225
|
-
}
|
|
226
|
-
function defaultAgentMdCacheRecord(aid) {
|
|
227
|
-
return {
|
|
228
|
-
aid,
|
|
229
|
-
content: '',
|
|
230
|
-
local_etag: '',
|
|
231
|
-
remote_etag: '',
|
|
232
|
-
last_modified: '',
|
|
233
|
-
fetched_at: 0,
|
|
234
|
-
observed_at: 0,
|
|
235
|
-
checked_at: 0,
|
|
236
|
-
remote_status: '',
|
|
237
|
-
verify_status: '',
|
|
238
|
-
verify_error: '',
|
|
239
|
-
last_error: '',
|
|
240
|
-
updated_at: 0,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
function normalizeAgentMdCacheRecord(aid, value) {
|
|
244
|
-
if (!isRecord(value))
|
|
245
|
-
return null;
|
|
246
|
-
const out = defaultAgentMdCacheRecord(aid);
|
|
247
|
-
for (const key of ['content', 'local_etag', 'remote_etag', 'last_modified', 'remote_status', 'verify_status', 'verify_error', 'last_error']) {
|
|
248
|
-
out[key] = String(value[key] ?? '');
|
|
249
|
-
}
|
|
250
|
-
for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
|
|
251
|
-
const n = Number(value[key] ?? 0);
|
|
252
|
-
out[key] = Number.isFinite(n) ? Math.trunc(n) : 0;
|
|
253
|
-
}
|
|
254
|
-
out.aid = String(value.aid ?? aid).trim() || aid;
|
|
255
|
-
return out;
|
|
256
|
-
}
|
|
257
|
-
function mergeAgentMdCacheRecord(aid, current, fields) {
|
|
258
|
-
const out = current ? { ...current } : defaultAgentMdCacheRecord(aid);
|
|
259
|
-
out.aid = aid;
|
|
260
|
-
for (const key of ['content', 'local_etag', 'remote_etag', 'last_modified', 'remote_status', 'verify_status', 'verify_error', 'last_error']) {
|
|
261
|
-
if (Object.prototype.hasOwnProperty.call(fields, key) && fields[key] !== undefined && fields[key] !== null) {
|
|
262
|
-
out[key] = String(fields[key] ?? '');
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
for (const key of ['fetched_at', 'observed_at', 'checked_at']) {
|
|
266
|
-
if (Object.prototype.hasOwnProperty.call(fields, key) && fields[key] !== undefined && fields[key] !== null) {
|
|
267
|
-
const n = Number(fields[key] ?? 0);
|
|
268
|
-
out[key] = Number.isFinite(n) ? Math.trunc(n) : 0;
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
out.updated_at = Date.now();
|
|
272
|
-
return out;
|
|
273
|
-
}
|
|
274
|
-
function stripStructuredFields(metadata) {
|
|
275
|
-
const plain = deepClone(metadata);
|
|
276
|
-
delete plain.e2ee_prekeys;
|
|
277
|
-
delete plain.group_secrets;
|
|
278
|
-
delete plain.e2ee_sessions;
|
|
279
|
-
return plain;
|
|
280
|
-
}
|
|
281
|
-
/** 缓存的数据库连接 */
|
|
282
|
-
let _cachedDB = null;
|
|
283
|
-
/** 并发竞态保护:缓存正在进行的 openDB Promise,避免多个并发调用同时创建连接 */
|
|
284
|
-
let _openDBPromise = null;
|
|
285
|
-
/** 打开或升级数据库(复用已有连接) */
|
|
286
|
-
function openDB() {
|
|
287
|
-
if (_cachedDB)
|
|
288
|
-
return Promise.resolve(_cachedDB);
|
|
289
|
-
// 如果已有正在进行的打开请求,直接复用同一个 Promise
|
|
290
|
-
if (_openDBPromise)
|
|
291
|
-
return _openDBPromise;
|
|
292
|
-
_openDBPromise = new Promise((resolve, reject) => {
|
|
293
|
-
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
294
|
-
request.onupgradeneeded = () => {
|
|
295
|
-
const db = request.result;
|
|
296
|
-
if (!db.objectStoreNames.contains(STORE_KEY_PAIRS)) {
|
|
297
|
-
db.createObjectStore(STORE_KEY_PAIRS);
|
|
298
|
-
}
|
|
299
|
-
if (!db.objectStoreNames.contains(STORE_CERTS)) {
|
|
300
|
-
db.createObjectStore(STORE_CERTS);
|
|
301
|
-
}
|
|
302
|
-
if (!db.objectStoreNames.contains(STORE_METADATA)) {
|
|
303
|
-
db.createObjectStore(STORE_METADATA);
|
|
304
|
-
}
|
|
305
|
-
if (!db.objectStoreNames.contains(STORE_INSTANCE_STATE)) {
|
|
306
|
-
db.createObjectStore(STORE_INSTANCE_STATE);
|
|
307
|
-
}
|
|
308
|
-
if (!db.objectStoreNames.contains(STORE_PREKEYS)) {
|
|
309
|
-
db.createObjectStore(STORE_PREKEYS);
|
|
310
|
-
}
|
|
311
|
-
if (!db.objectStoreNames.contains(STORE_GROUP_CURRENT)) {
|
|
312
|
-
db.createObjectStore(STORE_GROUP_CURRENT);
|
|
313
|
-
}
|
|
314
|
-
if (!db.objectStoreNames.contains(STORE_GROUP_OLD_EPOCHS)) {
|
|
315
|
-
db.createObjectStore(STORE_GROUP_OLD_EPOCHS);
|
|
316
|
-
}
|
|
317
|
-
if (!db.objectStoreNames.contains(STORE_SESSIONS)) {
|
|
318
|
-
db.createObjectStore(STORE_SESSIONS);
|
|
319
|
-
}
|
|
320
|
-
if (!db.objectStoreNames.contains(STORE_GROUP_STATE)) {
|
|
321
|
-
db.createObjectStore(STORE_GROUP_STATE);
|
|
322
|
-
}
|
|
323
|
-
if (!db.objectStoreNames.contains(STORE_AGENT_MD_CACHE)) {
|
|
324
|
-
db.createObjectStore(STORE_AGENT_MD_CACHE);
|
|
325
|
-
}
|
|
326
|
-
if (!db.objectStoreNames.contains(STORE_PENDING_IDENTITIES)) {
|
|
327
|
-
db.createObjectStore(STORE_PENDING_IDENTITIES);
|
|
328
|
-
}
|
|
329
|
-
};
|
|
330
|
-
request.onsuccess = () => {
|
|
331
|
-
const db = request.result;
|
|
332
|
-
// H22: 其它 tab 持有旧版本连接时,触发 versionchange 通知它们关闭,避免 onupgradeneeded 挂起
|
|
333
|
-
db.onversionchange = () => {
|
|
334
|
-
_cachedDB = null;
|
|
335
|
-
_openDBPromise = null;
|
|
336
|
-
try {
|
|
337
|
-
db.close();
|
|
338
|
-
}
|
|
339
|
-
catch { /* ignore */ }
|
|
340
|
-
};
|
|
341
|
-
db.onclose = () => { _cachedDB = null; };
|
|
342
|
-
_cachedDB = db;
|
|
343
|
-
_openDBPromise = null; // 成功后清除 Promise 缓存
|
|
344
|
-
resolve(db);
|
|
345
|
-
};
|
|
346
|
-
request.onerror = () => {
|
|
347
|
-
_openDBPromise = null; // 失败后清除,允许后续重试
|
|
348
|
-
reject(new Error('IndexedDB 不可用(可能处于隐私模式或 IndexedDB 被禁用)。' +
|
|
349
|
-
'请使用 FileKeyStore (Node.js) 或在浏览器中允许 IndexedDB。'));
|
|
350
|
-
};
|
|
351
|
-
// H22: onblocked = 其它 tab 持有旧版本阻塞升级;必须显式 reject 让上层感知而非永久挂起
|
|
352
|
-
request.onblocked = () => {
|
|
353
|
-
_openDBPromise = null; // 阻塞时清除,允许后续重试
|
|
354
|
-
reject(new Error('IndexedDB 升级被其它 tab 阻塞,请关闭其它页面后重试'));
|
|
355
|
-
};
|
|
356
|
-
});
|
|
357
|
-
return _openDBPromise;
|
|
358
|
-
}
|
|
359
|
-
/** 从指定仓库读取一条记录 */
|
|
360
|
-
async function idbGet(storeName, key) {
|
|
361
|
-
const db = await openDB();
|
|
362
|
-
return new Promise((resolve, reject) => {
|
|
363
|
-
const tx = db.transaction(storeName, 'readonly');
|
|
364
|
-
const store = tx.objectStore(storeName);
|
|
365
|
-
const req = store.get(key);
|
|
366
|
-
tx.oncomplete = () => {
|
|
367
|
-
resolve(req.result ?? null);
|
|
368
|
-
};
|
|
369
|
-
tx.onerror = () => {
|
|
370
|
-
reject(tx.error ?? new Error(`读取 ${storeName} 失败`));
|
|
371
|
-
};
|
|
372
|
-
tx.onabort = () => {
|
|
373
|
-
reject(tx.error ?? new Error(`读取 ${storeName} 被中止`));
|
|
374
|
-
};
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
/** 读取仓库内全部记录 */
|
|
378
|
-
async function idbGetAll(storeName) {
|
|
379
|
-
const db = await openDB();
|
|
380
|
-
return new Promise((resolve, reject) => {
|
|
381
|
-
const tx = db.transaction(storeName, 'readonly');
|
|
382
|
-
const store = tx.objectStore(storeName);
|
|
383
|
-
const valuesReq = store.getAll();
|
|
384
|
-
const keysReq = store.getAllKeys();
|
|
385
|
-
tx.oncomplete = () => {
|
|
386
|
-
const values = valuesReq.result ?? [];
|
|
387
|
-
const keys = keysReq.result ?? [];
|
|
388
|
-
resolve(keys.map((key, index) => ({
|
|
389
|
-
key: typeof key === 'string' ? key : String(key),
|
|
390
|
-
value: values[index],
|
|
391
|
-
})));
|
|
392
|
-
};
|
|
393
|
-
tx.onerror = () => {
|
|
394
|
-
reject(tx.error ?? new Error(`读取 ${storeName} 全量数据失败`));
|
|
395
|
-
};
|
|
396
|
-
tx.onabort = () => {
|
|
397
|
-
reject(tx.error ?? new Error(`读取 ${storeName} 全量数据被中止`));
|
|
398
|
-
};
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
async function idbGetAllByPrefix(storeName, prefix) {
|
|
402
|
-
const db = await openDB();
|
|
403
|
-
return new Promise((resolve, reject) => {
|
|
404
|
-
const tx = db.transaction(storeName, 'readonly');
|
|
405
|
-
const store = tx.objectStore(storeName);
|
|
406
|
-
const range = IDBKeyRange.bound(prefix, `${prefix}\uffff`, false, false);
|
|
407
|
-
const result = [];
|
|
408
|
-
const req = store.openCursor(range);
|
|
409
|
-
req.onsuccess = () => {
|
|
410
|
-
const cursor = req.result;
|
|
411
|
-
if (!cursor)
|
|
412
|
-
return;
|
|
413
|
-
result.push({
|
|
414
|
-
key: typeof cursor.key === 'string' ? cursor.key : String(cursor.key),
|
|
415
|
-
value: cursor.value,
|
|
416
|
-
});
|
|
417
|
-
cursor.continue();
|
|
418
|
-
};
|
|
419
|
-
tx.oncomplete = () => {
|
|
420
|
-
resolve(result);
|
|
421
|
-
};
|
|
422
|
-
tx.onerror = () => {
|
|
423
|
-
reject(tx.error ?? new Error(`按前缀读取 ${storeName} 失败`));
|
|
424
|
-
};
|
|
425
|
-
tx.onabort = () => {
|
|
426
|
-
reject(tx.error ?? new Error(`按前缀读取 ${storeName} 被中止`));
|
|
427
|
-
};
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
/** 向指定仓库写入一条记录 */
|
|
431
|
-
async function idbPut(storeName, key, value) {
|
|
432
|
-
const db = await openDB();
|
|
433
|
-
return new Promise((resolve, reject) => {
|
|
434
|
-
const tx = db.transaction(storeName, 'readwrite');
|
|
435
|
-
tx.objectStore(storeName).put(value, key);
|
|
436
|
-
tx.oncomplete = () => {
|
|
437
|
-
resolve();
|
|
438
|
-
};
|
|
439
|
-
tx.onerror = () => {
|
|
440
|
-
reject(tx.error ?? new Error(`写入 ${storeName} 失败`));
|
|
441
|
-
};
|
|
442
|
-
tx.onabort = () => {
|
|
443
|
-
reject(tx.error ?? new Error(`写入 ${storeName} 被中止`));
|
|
444
|
-
};
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
/** 从指定仓库删除一条记录 */
|
|
448
|
-
async function idbDelete(storeName, key) {
|
|
449
|
-
const db = await openDB();
|
|
450
|
-
return new Promise((resolve, reject) => {
|
|
451
|
-
const tx = db.transaction(storeName, 'readwrite');
|
|
452
|
-
tx.objectStore(storeName).delete(key);
|
|
453
|
-
tx.oncomplete = () => {
|
|
454
|
-
resolve();
|
|
455
|
-
};
|
|
456
|
-
tx.onerror = () => {
|
|
457
|
-
reject(tx.error ?? new Error(`删除 ${storeName} 失败`));
|
|
458
|
-
};
|
|
459
|
-
tx.onabort = () => {
|
|
460
|
-
reject(tx.error ?? new Error(`删除 ${storeName} 被中止`));
|
|
461
|
-
};
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
// ── KeyStore 实现 ───────────────────────────────────────
|
|
465
|
-
/**
|
|
466
|
-
* 基于 IndexedDB 的密钥存储实现。
|
|
467
|
-
*
|
|
468
|
-
* 设计语义:
|
|
469
|
-
* - metadata 只保存普通 metadata 字段;
|
|
470
|
-
* - e2ee_prekeys / group_secrets 只保存到结构化 store;
|
|
471
|
-
* - loadMetadata() 返回的是运行时拼出来的 merged view;
|
|
472
|
-
* - 若检测到旧版本把结构化数据写进了 metadata,会自动迁移到结构化 store。
|
|
473
|
-
*/
|
|
474
|
-
export class IndexedDBKeyStore {
|
|
475
|
-
_log = _noopLog;
|
|
476
|
-
setLogger(log) { this._log = log; }
|
|
477
|
-
static _aidTails = new Map();
|
|
478
|
-
/** 私钥加密种子;空字符串也是有效 seed,默认不再写入明文私钥。 */
|
|
479
|
-
_encryptionSeed;
|
|
480
|
-
constructor(opts) {
|
|
481
|
-
this._encryptionSeed = opts?.encryptionSeed ?? '';
|
|
482
|
-
}
|
|
483
|
-
static async changeSeed(oldSeed, newSeed) {
|
|
484
|
-
if (oldSeed === '.seed') {
|
|
485
|
-
throw new SeedMigrationError('JS IndexedDB keystore does not support file .seed migration');
|
|
486
|
-
}
|
|
487
|
-
const rows = await idbGetAll(STORE_KEY_PAIRS);
|
|
488
|
-
const migrations = [];
|
|
489
|
-
for (const row of rows) {
|
|
490
|
-
if (!isRecord(row.value))
|
|
491
|
-
continue;
|
|
492
|
-
const envelope = row.value._encrypted_pk;
|
|
493
|
-
if (!isRecord(envelope)) {
|
|
494
|
-
const plain = row.value.private_key_pem;
|
|
495
|
-
if (typeof plain === 'string' && plain) {
|
|
496
|
-
migrations.push({ key: row.key, value: deepClone(row.value), privateKeyPem: plain });
|
|
497
|
-
}
|
|
498
|
-
continue;
|
|
499
|
-
}
|
|
500
|
-
try {
|
|
501
|
-
const privateKeyPem = await _decryptPEM(envelope, oldSeed);
|
|
502
|
-
migrations.push({ key: row.key, value: deepClone(row.value), privateKeyPem });
|
|
503
|
-
}
|
|
504
|
-
catch {
|
|
505
|
-
throw new SeedMigrationError(`seed migration refused: private key is not encrypted by old seed: aid=${row.key}`);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
if (migrations.length === 0) {
|
|
509
|
-
throw new SeedMigrationError('seed migration refused: no encrypted private key verified with old seed');
|
|
510
|
-
}
|
|
511
|
-
const result = {
|
|
512
|
-
migrated: 0,
|
|
513
|
-
skipped: 0,
|
|
514
|
-
errors: 0,
|
|
515
|
-
privateKeysVerified: migrations.length,
|
|
516
|
-
privateKeysMigrated: 0,
|
|
517
|
-
};
|
|
518
|
-
for (const item of migrations) {
|
|
519
|
-
const next = deepClone(item.value);
|
|
520
|
-
next._encrypted_pk = await _encryptPEM(item.privateKeyPem, newSeed);
|
|
521
|
-
const verified = await _decryptPEM(next._encrypted_pk, newSeed);
|
|
522
|
-
if (verified !== item.privateKeyPem) {
|
|
523
|
-
throw new SeedMigrationError(`new seed verification failed for private key: aid=${item.key}`);
|
|
524
|
-
}
|
|
525
|
-
delete next.private_key_pem;
|
|
526
|
-
await idbPut(STORE_KEY_PAIRS, item.key, next);
|
|
527
|
-
result.migrated += 1;
|
|
528
|
-
result.privateKeysMigrated += 1;
|
|
529
|
-
}
|
|
530
|
-
return result;
|
|
531
|
-
}
|
|
532
|
-
async changeSeed(oldSeed, newSeed) {
|
|
533
|
-
const result = await IndexedDBKeyStore.changeSeed(oldSeed, newSeed);
|
|
534
|
-
this._encryptionSeed = newSeed;
|
|
535
|
-
return result;
|
|
536
|
-
}
|
|
537
|
-
async _withAidLock(aid, fn) {
|
|
538
|
-
const key = safeAid(aid);
|
|
539
|
-
const previous = IndexedDBKeyStore._aidTails.get(key) ?? Promise.resolve();
|
|
540
|
-
let release;
|
|
541
|
-
const current = new Promise((resolve) => {
|
|
542
|
-
release = resolve;
|
|
543
|
-
});
|
|
544
|
-
const tail = previous.catch(() => undefined).then(() => current);
|
|
545
|
-
IndexedDBKeyStore._aidTails.set(key, tail);
|
|
546
|
-
await previous.catch(() => undefined);
|
|
547
|
-
try {
|
|
548
|
-
return await fn();
|
|
549
|
-
}
|
|
550
|
-
finally {
|
|
551
|
-
release();
|
|
552
|
-
if (IndexedDBKeyStore._aidTails.get(key) === tail) {
|
|
553
|
-
IndexedDBKeyStore._aidTails.delete(key);
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
async listIdentities() {
|
|
558
|
-
const tStart = Date.now();
|
|
559
|
-
this._log.debug('listIdentities enter');
|
|
560
|
-
try {
|
|
561
|
-
const records = await idbGetAll(STORE_METADATA);
|
|
562
|
-
const aids = new Set();
|
|
563
|
-
for (const item of records) {
|
|
564
|
-
if (item.key.startsWith('_seq_|'))
|
|
565
|
-
continue;
|
|
566
|
-
const value = item.value;
|
|
567
|
-
const aid = typeof value === 'object' && value !== null && isRecord(value) && typeof value.aid === 'string' && value.aid
|
|
568
|
-
? value.aid
|
|
569
|
-
: item.key;
|
|
570
|
-
aids.add(String(aid));
|
|
571
|
-
}
|
|
572
|
-
for (const item of await idbGetAll(STORE_KEY_PAIRS)) {
|
|
573
|
-
if (!item.key.startsWith('_seq_|'))
|
|
574
|
-
aids.add(item.key);
|
|
575
|
-
}
|
|
576
|
-
for (const item of await idbGetAll(STORE_CERTS)) {
|
|
577
|
-
if (item.key.startsWith('_seq_|'))
|
|
578
|
-
continue;
|
|
579
|
-
const [safe] = item.key.split('|', 1);
|
|
580
|
-
if (safe)
|
|
581
|
-
aids.add(safe);
|
|
582
|
-
}
|
|
583
|
-
const result = [...aids].sort();
|
|
584
|
-
this._log.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=${result.length}`);
|
|
585
|
-
return result;
|
|
586
|
-
}
|
|
587
|
-
catch (err) {
|
|
588
|
-
this._log.debug(`listIdentities exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
589
|
-
throw err;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
// ── 密钥对 ──────────────────────────────────────────
|
|
593
|
-
async loadKeyPair(aid) {
|
|
594
|
-
const data = await idbGet(STORE_KEY_PAIRS, metadataStoreKey(aid));
|
|
595
|
-
if (!isRecord(data))
|
|
596
|
-
return null;
|
|
597
|
-
const result = deepClone(data);
|
|
598
|
-
// 如果存在加密私钥信封,尝试解密还原
|
|
599
|
-
const epk = result._encrypted_pk;
|
|
600
|
-
if (epk && typeof epk === 'object' && !Array.isArray(epk) && hasEncryptionSeed(this._encryptionSeed)) {
|
|
601
|
-
try {
|
|
602
|
-
const envelope = epk;
|
|
603
|
-
result.private_key_pem = await _decryptPEM(envelope, this._encryptionSeed);
|
|
604
|
-
delete result._encrypted_pk;
|
|
605
|
-
}
|
|
606
|
-
catch {
|
|
607
|
-
this._log.error(`[keystore] decrypt ${aid} private key failed, maybe encryptionSeed mismatch`);
|
|
608
|
-
throw new Error(`private key decrypt failed for aid ${aid}: seed_password mismatch or IndexedDB record corrupted`);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
else if (
|
|
612
|
-
// 透明迁移:旧版明文数据自动加密回写
|
|
613
|
-
!epk && typeof result.private_key_pem === 'string' && hasEncryptionSeed(this._encryptionSeed)) {
|
|
614
|
-
await this.saveKeyPair(aid, result);
|
|
615
|
-
}
|
|
616
|
-
return result;
|
|
617
|
-
}
|
|
618
|
-
async saveKeyPair(aid, keyPair) {
|
|
619
|
-
const record = deepClone(keyPair);
|
|
620
|
-
// 如果配置了加密种子且包含明文私钥,加密后再存储
|
|
621
|
-
if (hasEncryptionSeed(this._encryptionSeed) && typeof record.private_key_pem === 'string') {
|
|
622
|
-
record._encrypted_pk = await _encryptPEM(record.private_key_pem, this._encryptionSeed);
|
|
623
|
-
delete record.private_key_pem;
|
|
624
|
-
}
|
|
625
|
-
await idbPut(STORE_KEY_PAIRS, metadataStoreKey(aid), record);
|
|
626
|
-
}
|
|
627
|
-
// ── RegisterAID pending 身份 ─────────────────────────
|
|
628
|
-
async pendingIdentityDir(aid) {
|
|
629
|
-
const handle = `${pendingIdentityPrefix(aid)}${randomHex(4)}-${Math.floor(Date.now() / 1000)}`;
|
|
630
|
-
const now = Date.now();
|
|
631
|
-
await idbPut(STORE_PENDING_IDENTITIES, handle, {
|
|
632
|
-
aid,
|
|
633
|
-
created_at: now,
|
|
634
|
-
updated_at: now,
|
|
635
|
-
});
|
|
636
|
-
return handle;
|
|
637
|
-
}
|
|
638
|
-
async listPendingIdentityDirs(aid) {
|
|
639
|
-
const prefix = pendingIdentityPrefix(aid);
|
|
640
|
-
const rows = await idbGetAllByPrefix(STORE_PENDING_IDENTITIES, prefix);
|
|
641
|
-
return rows
|
|
642
|
-
.filter((row) => row.key.startsWith(prefix) && isRecord(row.value) && String(row.value.aid ?? '') === aid)
|
|
643
|
-
.sort((a, b) => Number(b.value.updated_at ?? 0) - Number(a.value.updated_at ?? 0))
|
|
644
|
-
.map((row) => row.key);
|
|
645
|
-
}
|
|
646
|
-
async savePendingKeyPair(handle, aid, keyPair) {
|
|
647
|
-
const current = await this._loadPendingRecord(handle, aid);
|
|
648
|
-
if (!current)
|
|
649
|
-
throw new Error(`pending identity not found: ${handle}`);
|
|
650
|
-
const record = deepClone(keyPair);
|
|
651
|
-
const privateKeyPem = record.private_key_pem;
|
|
652
|
-
if (typeof privateKeyPem !== 'string' || !privateKeyPem) {
|
|
653
|
-
throw new Error('savePendingKeyPair requires private_key_pem');
|
|
654
|
-
}
|
|
655
|
-
record._encrypted_pk = await _encryptPEM(privateKeyPem, this._encryptionSeed ?? '');
|
|
656
|
-
delete record.private_key_pem;
|
|
657
|
-
await idbPut(STORE_PENDING_IDENTITIES, handle, {
|
|
658
|
-
...current,
|
|
659
|
-
aid,
|
|
660
|
-
key_pair: record,
|
|
661
|
-
updated_at: Date.now(),
|
|
662
|
-
});
|
|
663
|
-
}
|
|
664
|
-
async loadPendingKeyPair(handle, aid) {
|
|
665
|
-
const current = await this._loadPendingRecord(handle, aid, false);
|
|
666
|
-
if (!current || !isRecord(current.key_pair))
|
|
667
|
-
return null;
|
|
668
|
-
const result = deepClone(current.key_pair);
|
|
669
|
-
const encrypted = result._encrypted_pk;
|
|
670
|
-
if (isRecord(encrypted)) {
|
|
671
|
-
try {
|
|
672
|
-
const envelope = encrypted;
|
|
673
|
-
result.private_key_pem = await _decryptPEM(envelope, this._encryptionSeed ?? '');
|
|
674
|
-
delete result._encrypted_pk;
|
|
675
|
-
return result;
|
|
676
|
-
}
|
|
677
|
-
catch {
|
|
678
|
-
this._log.error(`[keystore] decrypt pending ${aid} private key failed, maybe encryptionSeed mismatch`);
|
|
679
|
-
throw new Error(`pending identity private key decrypt failed for aid ${aid}: seed_password mismatch or IndexedDB record corrupted`);
|
|
680
|
-
}
|
|
681
|
-
}
|
|
682
|
-
if (typeof result.private_key_pem === 'string' && result.private_key_pem) {
|
|
683
|
-
// 兼容历史 pending 明文记录:首次加载成功后立即加密回写,避免 pending 私钥继续明文落盘。
|
|
684
|
-
await this.savePendingKeyPair(handle, aid, result);
|
|
685
|
-
return result;
|
|
686
|
-
}
|
|
687
|
-
return result;
|
|
688
|
-
}
|
|
689
|
-
async savePendingCert(handle, certPem) {
|
|
690
|
-
const current = await this._loadPendingRecord(handle, undefined);
|
|
691
|
-
if (!current)
|
|
692
|
-
throw new Error(`pending identity not found: ${handle}`);
|
|
693
|
-
await idbPut(STORE_PENDING_IDENTITIES, handle, {
|
|
694
|
-
...current,
|
|
695
|
-
cert: certPem,
|
|
696
|
-
updated_at: Date.now(),
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
async promotePendingIdentity(handle, aid) {
|
|
700
|
-
const current = await this._loadPendingRecord(handle, aid);
|
|
701
|
-
if (!current)
|
|
702
|
-
throw new Error(`pending identity not found: ${handle}`);
|
|
703
|
-
if (!isRecord(current.key_pair)) {
|
|
704
|
-
throw new Error(`promotePendingIdentity: missing pending key pair: ${handle}`);
|
|
705
|
-
}
|
|
706
|
-
const targetKey = metadataStoreKey(aid);
|
|
707
|
-
const [existingKeyPair, existingCert, existingMetadata] = await Promise.all([
|
|
708
|
-
idbGet(STORE_KEY_PAIRS, targetKey),
|
|
709
|
-
idbGet(STORE_CERTS, certStoreKey(aid)),
|
|
710
|
-
idbGet(STORE_METADATA, targetKey),
|
|
711
|
-
]);
|
|
712
|
-
if (existingKeyPair || existingCert || existingMetadata) {
|
|
713
|
-
throw new Error(`promotePendingIdentity: target exists: ${targetKey}`);
|
|
714
|
-
}
|
|
715
|
-
const keyPair = await this._protectedPendingKeyPair(current, aid);
|
|
716
|
-
await idbPut(STORE_KEY_PAIRS, targetKey, keyPair);
|
|
717
|
-
if (typeof current.cert === 'string' && current.cert) {
|
|
718
|
-
await idbPut(STORE_CERTS, certStoreKey(aid), current.cert);
|
|
719
|
-
}
|
|
720
|
-
await idbDelete(STORE_PENDING_IDENTITIES, handle);
|
|
721
|
-
return targetKey;
|
|
722
|
-
}
|
|
723
|
-
async _protectedPendingKeyPair(current, aid) {
|
|
724
|
-
if (!isRecord(current.key_pair)) {
|
|
725
|
-
throw new Error(`pending identity missing key pair for ${aid}`);
|
|
726
|
-
}
|
|
727
|
-
const keyPair = deepClone(current.key_pair);
|
|
728
|
-
const privateKeyPem = keyPair.private_key_pem;
|
|
729
|
-
if (typeof privateKeyPem === 'string' && privateKeyPem) {
|
|
730
|
-
throw new Error(`pending identity private key is plaintext for ${aid}`);
|
|
731
|
-
}
|
|
732
|
-
if (!isRecord(keyPair._encrypted_pk)) {
|
|
733
|
-
throw new Error(`pending identity private key is not encrypted for ${aid}`);
|
|
734
|
-
}
|
|
735
|
-
return keyPair;
|
|
736
|
-
}
|
|
737
|
-
async cleanupPendingDirs(maxAgeMs = 600_000) {
|
|
738
|
-
const rows = await idbGetAll(STORE_PENDING_IDENTITIES);
|
|
739
|
-
const now = Date.now();
|
|
740
|
-
let removed = 0;
|
|
741
|
-
for (const row of rows) {
|
|
742
|
-
if (!isRecord(row.value))
|
|
743
|
-
continue;
|
|
744
|
-
const updatedAt = Number(row.value.updated_at ?? row.value.created_at ?? 0);
|
|
745
|
-
if (updatedAt && now - updatedAt < maxAgeMs)
|
|
746
|
-
continue;
|
|
747
|
-
await idbDelete(STORE_PENDING_IDENTITIES, row.key);
|
|
748
|
-
removed++;
|
|
749
|
-
}
|
|
750
|
-
return removed;
|
|
751
|
-
}
|
|
752
|
-
async discardPendingIdentity(handle) {
|
|
753
|
-
await idbDelete(STORE_PENDING_IDENTITIES, handle);
|
|
754
|
-
}
|
|
755
|
-
// ── 证书 ──────────────────────────────────────────
|
|
756
|
-
async loadCert(aid, certFingerprint) {
|
|
757
|
-
const tStart = Date.now();
|
|
758
|
-
this._log.debug(`loadCert enter: aid=${aid} fingerprint=${certFingerprint ?? '<none>'}`);
|
|
759
|
-
try {
|
|
760
|
-
const normalized = normalizeCertFingerprint(certFingerprint);
|
|
761
|
-
if (normalized) {
|
|
762
|
-
const versioned = await idbGet(STORE_CERTS, certStoreKey(aid, normalized));
|
|
763
|
-
if (typeof versioned === 'string') {
|
|
764
|
-
this._log.debug(`loadCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} found=true source=versioned`);
|
|
765
|
-
return versioned;
|
|
766
|
-
}
|
|
767
|
-
const active = await idbGet(STORE_CERTS, certStoreKey(aid));
|
|
768
|
-
if (typeof active === 'string' && await fingerprintFromCertPem(active) === normalized) {
|
|
769
|
-
this._log.debug(`loadCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} found=true source=active`);
|
|
770
|
-
return active;
|
|
771
|
-
}
|
|
772
|
-
this._log.debug(`loadCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} found=false`);
|
|
773
|
-
return null;
|
|
774
|
-
}
|
|
775
|
-
const data = await idbGet(STORE_CERTS, certStoreKey(aid));
|
|
776
|
-
const found = typeof data === 'string';
|
|
777
|
-
this._log.debug(`loadCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} found=${found}`);
|
|
778
|
-
return found ? data : null;
|
|
779
|
-
}
|
|
780
|
-
catch (err) {
|
|
781
|
-
this._log.debug(`loadCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
782
|
-
throw err;
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
async saveCert(aid, certPem, certFingerprint, opts) {
|
|
786
|
-
const tStart = Date.now();
|
|
787
|
-
this._log.debug(`saveCert enter: aid=${aid} fingerprint=${certFingerprint ?? '<none>'} make_active=${opts?.makeActive ?? false}`);
|
|
788
|
-
try {
|
|
789
|
-
const normalized = normalizeCertFingerprint(certFingerprint);
|
|
790
|
-
if (normalized) {
|
|
791
|
-
await idbPut(STORE_CERTS, certStoreKey(aid, normalized), certPem);
|
|
792
|
-
if (opts?.makeActive) {
|
|
793
|
-
await idbPut(STORE_CERTS, certStoreKey(aid), certPem);
|
|
794
|
-
}
|
|
795
|
-
this._log.debug(`saveCert exit: elapsed=${Date.now() - tStart}ms aid=${aid}`);
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
await idbPut(STORE_CERTS, certStoreKey(aid), certPem);
|
|
799
|
-
this._log.debug(`saveCert exit: elapsed=${Date.now() - tStart}ms aid=${aid}`);
|
|
800
|
-
}
|
|
801
|
-
catch (err) {
|
|
802
|
-
this._log.debug(`saveCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
803
|
-
throw err;
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
// ── 实例态 ──────────────────────────────────────────
|
|
807
|
-
async loadInstanceState(aid, deviceId, slotId = '') {
|
|
808
|
-
return this._withAidLock(aid, async () => {
|
|
809
|
-
const data = await idbGet(STORE_INSTANCE_STATE, instanceStateStoreKey(aid, deviceId, slotId));
|
|
810
|
-
return isRecord(data) ? deepClone(data) : null;
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
async saveInstanceState(aid, deviceId, slotId, state) {
|
|
814
|
-
await this._withAidLock(aid, async () => {
|
|
815
|
-
await idbPut(STORE_INSTANCE_STATE, instanceStateStoreKey(aid, deviceId, slotId), deepClone(state));
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
async updateInstanceState(aid, deviceId, slotId, updater) {
|
|
819
|
-
return this._withAidLock(aid, async () => {
|
|
820
|
-
const currentRaw = await idbGet(STORE_INSTANCE_STATE, instanceStateStoreKey(aid, deviceId, slotId));
|
|
821
|
-
const current = isRecord(currentRaw) ? deepClone(currentRaw) : {};
|
|
822
|
-
const working = deepClone(current);
|
|
823
|
-
const updated = updater(working) ?? working;
|
|
824
|
-
await idbPut(STORE_INSTANCE_STATE, instanceStateStoreKey(aid, deviceId, slotId), deepClone(updated));
|
|
825
|
-
return deepClone(updated);
|
|
826
|
-
});
|
|
827
|
-
}
|
|
828
|
-
// ── 身份信息(组合操作) ──────────────────────────
|
|
829
|
-
async loadIdentity(aid) {
|
|
830
|
-
const tStart = Date.now();
|
|
831
|
-
this._log.debug(`loadIdentity enter: aid=${aid}`);
|
|
832
|
-
try {
|
|
833
|
-
const result = await this._withAidLock(aid, async () => {
|
|
834
|
-
const [keyPair, cert] = await Promise.all([
|
|
835
|
-
this._loadKeyPairUnlocked(aid),
|
|
836
|
-
this._loadCertUnlocked(aid),
|
|
837
|
-
]);
|
|
838
|
-
// 直接读取 metadata KV(含旧数据迁移)
|
|
839
|
-
const metadataOnly = await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
840
|
-
const hasMeta = Object.keys(metadataOnly).length > 0;
|
|
841
|
-
if (!keyPair && !cert && !hasMeta)
|
|
842
|
-
return null;
|
|
843
|
-
const identity = {};
|
|
844
|
-
if (hasMeta)
|
|
845
|
-
Object.assign(identity, metadataOnly);
|
|
846
|
-
if (keyPair)
|
|
847
|
-
Object.assign(identity, keyPair);
|
|
848
|
-
if (cert) {
|
|
849
|
-
// key/cert 公钥一致性校验:防止 cert 被意外覆盖
|
|
850
|
-
const localPubB64 = keyPair?.public_key_der_b64;
|
|
851
|
-
if (typeof localPubB64 === 'string' && localPubB64) {
|
|
852
|
-
const certSpkiB64 = extractSpkiB64FromCertPem(cert);
|
|
853
|
-
if (certSpkiB64 && certSpkiB64 !== localPubB64) {
|
|
854
|
-
this._log.error(`[keystore] identity ${aid} key public key mismatches cert public key, discard cert`);
|
|
855
|
-
}
|
|
856
|
-
else {
|
|
857
|
-
identity.cert = cert;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
else {
|
|
861
|
-
identity.cert = cert;
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
return identity;
|
|
865
|
-
});
|
|
866
|
-
this._log.debug(`loadIdentity exit: elapsed=${Date.now() - tStart}ms aid=${aid} found=${result !== null}`);
|
|
867
|
-
return result;
|
|
868
|
-
}
|
|
869
|
-
catch (err) {
|
|
870
|
-
this._log.debug(`loadIdentity exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
871
|
-
throw err;
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
async saveIdentity(aid, identity) {
|
|
875
|
-
const tStart = Date.now();
|
|
876
|
-
this._log.debug(`saveIdentity enter: aid=${aid} has_cert=${!!identity.cert}`);
|
|
877
|
-
try {
|
|
878
|
-
await this._withAidLock(aid, async () => {
|
|
879
|
-
const keyPairFields = {};
|
|
880
|
-
for (const key of ['private_key_pem', 'public_key_der_b64', 'curve']) {
|
|
881
|
-
if (key in identity)
|
|
882
|
-
keyPairFields[key] = identity[key];
|
|
883
|
-
}
|
|
884
|
-
if (Object.keys(keyPairFields).length > 0) {
|
|
885
|
-
await this._saveKeyPairUnlocked(aid, keyPairFields);
|
|
886
|
-
}
|
|
887
|
-
const cert = identity.cert;
|
|
888
|
-
if (typeof cert === 'string' && cert) {
|
|
889
|
-
await this._saveCertUnlocked(aid, cert);
|
|
890
|
-
}
|
|
891
|
-
const metadataFields = {};
|
|
892
|
-
for (const [key, value] of Object.entries(identity)) {
|
|
893
|
-
if (!['private_key_pem', 'public_key_der_b64', 'curve', 'cert'].includes(key)) {
|
|
894
|
-
metadataFields[key] = value;
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
// 增量保存 metadata 字段
|
|
898
|
-
if (Object.keys(metadataFields).length > 0) {
|
|
899
|
-
await this._replaceStructuredStateUnlocked(aid, metadataFields);
|
|
900
|
-
const plain = stripStructuredFields(metadataFields);
|
|
901
|
-
if (Object.keys(plain).length > 0) {
|
|
902
|
-
const current = await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
903
|
-
Object.assign(current, plain);
|
|
904
|
-
await this._saveMetadataOnlyUnlocked(aid, current);
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
});
|
|
908
|
-
this._log.debug(`saveIdentity exit: elapsed=${Date.now() - tStart}ms aid=${aid}`);
|
|
909
|
-
}
|
|
910
|
-
catch (err) {
|
|
911
|
-
this._log.debug(`saveIdentity exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
912
|
-
throw err;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
// ── 结构化 prekeys ───────────────────────────────────
|
|
916
|
-
async loadE2EEPrekeys(aid, deviceId) {
|
|
917
|
-
return this._withAidLock(aid, async () => {
|
|
918
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
919
|
-
return this._loadPrekeysUnlocked(aid, String(deviceId ?? '').trim());
|
|
920
|
-
});
|
|
921
|
-
}
|
|
922
|
-
/**
|
|
923
|
-
* 按 prekey_id 单点查询本地 prekey(O(log N) IndexedDB primary-key 查找)。
|
|
924
|
-
*
|
|
925
|
-
* 解密入站消息时,envelope 里只带 prekey_id,没有 device_id。原先要走
|
|
926
|
-
* `loadE2EEPrekeys` 全量加载(prekey 池大时一次扫描数百毫秒)。
|
|
927
|
-
* 本方法优先用 IndexedDB 主键直查;命中则 O(log N),未命中再回退到前缀扫描,
|
|
928
|
-
* 保证跨 device_id 也能找到(兼容设备级 prekey 场景)。
|
|
929
|
-
*
|
|
930
|
-
* 返回结构与 `_loadPrekeysUnlocked` 单条值一致(已剥离 prekey_id / device_id 字段)。
|
|
931
|
-
*/
|
|
932
|
-
async loadE2EEPrekeyById(aid, prekeyId) {
|
|
933
|
-
return this._withAidLock(aid, async () => {
|
|
934
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
935
|
-
return this._loadPrekeyByIdUnlocked(aid, prekeyId);
|
|
936
|
-
});
|
|
937
|
-
}
|
|
938
|
-
async saveE2EEPrekey(aid, prekeyId, prekeyData, deviceId) {
|
|
939
|
-
await this._withAidLock(aid, async () => {
|
|
940
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
941
|
-
const record = deepClone(prekeyData);
|
|
942
|
-
record.prekey_id = prekeyId;
|
|
943
|
-
const normalizedDeviceId = String(deviceId ?? '').trim();
|
|
944
|
-
if (normalizedDeviceId) {
|
|
945
|
-
record.device_id = normalizedDeviceId;
|
|
946
|
-
}
|
|
947
|
-
else {
|
|
948
|
-
delete record.device_id;
|
|
949
|
-
}
|
|
950
|
-
const targetKey = prekeyStoreKey(aid, prekeyId, normalizedDeviceId);
|
|
951
|
-
for (const item of await idbGetAllByPrefix(STORE_PREKEYS, prekeyPrefix(aid))) {
|
|
952
|
-
if (!isRecord(item.value))
|
|
953
|
-
continue;
|
|
954
|
-
const existingPrekeyId = typeof item.value.prekey_id === 'string'
|
|
955
|
-
? item.value.prekey_id
|
|
956
|
-
: '';
|
|
957
|
-
const existingDeviceId = String(item.value.device_id ?? '').trim();
|
|
958
|
-
if (existingPrekeyId === prekeyId && existingDeviceId === normalizedDeviceId && item.key !== targetKey) {
|
|
959
|
-
await idbDelete(STORE_PREKEYS, item.key);
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
await idbPut(STORE_PREKEYS, targetKey, record);
|
|
963
|
-
});
|
|
964
|
-
}
|
|
965
|
-
async cleanupE2EEPrekeys(aid, cutoffMs, keepLatest = 7, deviceId) {
|
|
966
|
-
return this._withAidLock(aid, async () => {
|
|
967
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
968
|
-
const normalizedDeviceId = String(deviceId ?? '').trim();
|
|
969
|
-
const prekeys = await this._loadPrekeysUnlocked(aid, normalizedDeviceId);
|
|
970
|
-
const retainedIds = new Set(Object.entries(prekeys)
|
|
971
|
-
.sort((left, right) => {
|
|
972
|
-
const leftMarker = Number(left[1].created_at ?? left[1].updated_at ?? left[1].expires_at ?? 0);
|
|
973
|
-
const rightMarker = Number(right[1].created_at ?? right[1].updated_at ?? right[1].expires_at ?? 0);
|
|
974
|
-
if (rightMarker !== leftMarker)
|
|
975
|
-
return rightMarker - leftMarker;
|
|
976
|
-
return right[0].localeCompare(left[0]);
|
|
977
|
-
})
|
|
978
|
-
.slice(0, keepLatest)
|
|
979
|
-
.map(([prekeyId]) => prekeyId));
|
|
980
|
-
const removed = Object.entries(prekeys)
|
|
981
|
-
.filter(([prekeyId, data]) => Number(data.created_at ?? data.updated_at ?? data.expires_at ?? 0) < cutoffMs
|
|
982
|
-
&& !retainedIds.has(prekeyId))
|
|
983
|
-
.map(([prekeyId]) => prekeyId);
|
|
984
|
-
const removedIds = new Set(removed);
|
|
985
|
-
for (const item of await idbGetAllByPrefix(STORE_PREKEYS, prekeyPrefix(aid))) {
|
|
986
|
-
if (!isRecord(item.value))
|
|
987
|
-
continue;
|
|
988
|
-
const recordDeviceId = String(item.value.device_id ?? '').trim();
|
|
989
|
-
if (recordDeviceId !== normalizedDeviceId)
|
|
990
|
-
continue;
|
|
991
|
-
const prekeyId = typeof item.value.prekey_id === 'string' ? item.value.prekey_id : '';
|
|
992
|
-
if (removedIds.has(prekeyId))
|
|
993
|
-
await idbDelete(STORE_PREKEYS, item.key);
|
|
994
|
-
}
|
|
995
|
-
return removed;
|
|
996
|
-
});
|
|
997
|
-
}
|
|
998
|
-
// ── 结构化群组密钥 ─────────────────────────────────
|
|
999
|
-
async listGroupSecretIds(aid) {
|
|
1000
|
-
return this._withAidLock(aid, async () => {
|
|
1001
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
1002
|
-
const ids = new Set();
|
|
1003
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_CURRENT, groupCurrentPrefix(aid))) {
|
|
1004
|
-
const [, encodedGroupId] = item.key.split('|');
|
|
1005
|
-
if (encodedGroupId)
|
|
1006
|
-
ids.add(decodeURIComponent(encodedGroupId));
|
|
1007
|
-
}
|
|
1008
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid))) {
|
|
1009
|
-
const [, encodedGroupId] = item.key.split('|');
|
|
1010
|
-
if (encodedGroupId)
|
|
1011
|
-
ids.add(decodeURIComponent(encodedGroupId));
|
|
1012
|
-
}
|
|
1013
|
-
return [...ids].sort();
|
|
1014
|
-
});
|
|
1015
|
-
}
|
|
1016
|
-
async cleanupGroupOldEpochsState(aid, groupId, cutoffMs) {
|
|
1017
|
-
return this._withAidLock(aid, async () => {
|
|
1018
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
1019
|
-
let removed = 0;
|
|
1020
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
|
|
1021
|
-
if (!isRecord(item.value))
|
|
1022
|
-
continue;
|
|
1023
|
-
if (Number(item.value.updated_at ?? 0) <= cutoffMs) {
|
|
1024
|
-
await idbDelete(STORE_GROUP_OLD_EPOCHS, item.key);
|
|
1025
|
-
removed += 1;
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
return removed;
|
|
1029
|
-
});
|
|
1030
|
-
}
|
|
1031
|
-
async loadGroupSecretEpoch(aid, groupId, epoch) {
|
|
1032
|
-
return this._withAidLock(aid, async () => {
|
|
1033
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
1034
|
-
const current = await idbGet(STORE_GROUP_CURRENT, groupCurrentStoreKey(aid, groupId));
|
|
1035
|
-
if (isRecord(current)) {
|
|
1036
|
-
const record = deepClone(current);
|
|
1037
|
-
delete record.group_id;
|
|
1038
|
-
if (epoch === undefined || epoch === null || Number(record.epoch ?? 0) === Number(epoch)) {
|
|
1039
|
-
return record;
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
else if (epoch === undefined || epoch === null) {
|
|
1043
|
-
return null;
|
|
1044
|
-
}
|
|
1045
|
-
const old = await idbGet(STORE_GROUP_OLD_EPOCHS, groupOldStoreKey(aid, groupId, Number(epoch)));
|
|
1046
|
-
if (!isRecord(old))
|
|
1047
|
-
return null;
|
|
1048
|
-
const record = deepClone(old);
|
|
1049
|
-
delete record.group_id;
|
|
1050
|
-
return record;
|
|
1051
|
-
});
|
|
1052
|
-
}
|
|
1053
|
-
async loadGroupSecretEpochs(aid, groupId) {
|
|
1054
|
-
return this._withAidLock(aid, async () => {
|
|
1055
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
1056
|
-
const result = [];
|
|
1057
|
-
const current = await idbGet(STORE_GROUP_CURRENT, groupCurrentStoreKey(aid, groupId));
|
|
1058
|
-
if (isRecord(current)) {
|
|
1059
|
-
const record = deepClone(current);
|
|
1060
|
-
delete record.group_id;
|
|
1061
|
-
result.push(record);
|
|
1062
|
-
}
|
|
1063
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
|
|
1064
|
-
if (!isRecord(item.value))
|
|
1065
|
-
continue;
|
|
1066
|
-
const record = deepClone(item.value);
|
|
1067
|
-
delete record.group_id;
|
|
1068
|
-
result.push(record);
|
|
1069
|
-
}
|
|
1070
|
-
return result;
|
|
1071
|
-
});
|
|
1072
|
-
}
|
|
1073
|
-
async storeGroupSecretTransition(aid, groupId, opts) {
|
|
1074
|
-
return this._withAidLock(aid, async () => {
|
|
1075
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
1076
|
-
return this._storeGroupSecretTransitionUnlocked(aid, groupId, opts);
|
|
1077
|
-
});
|
|
1078
|
-
}
|
|
1079
|
-
async storeGroupSecretEpoch(aid, groupId, opts) {
|
|
1080
|
-
return this._withAidLock(aid, async () => {
|
|
1081
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
1082
|
-
return this._storeGroupSecretEpochUnlocked(aid, groupId, opts);
|
|
1083
|
-
});
|
|
1084
|
-
}
|
|
1085
|
-
async discardPendingGroupSecretState(aid, groupId, epoch, rotationId) {
|
|
1086
|
-
return this._withAidLock(aid, async () => {
|
|
1087
|
-
const rid = String(rotationId ?? '').trim();
|
|
1088
|
-
if (!rid)
|
|
1089
|
-
return false;
|
|
1090
|
-
await this._migrateLegacyStructuredStateUnlocked(aid);
|
|
1091
|
-
return this._discardPendingGroupSecretUnlocked(aid, groupId, epoch, rid);
|
|
1092
|
-
});
|
|
1093
|
-
}
|
|
1094
|
-
async deleteGroupSecretState(aid, groupId) {
|
|
1095
|
-
await this._withAidLock(aid, async () => {
|
|
1096
|
-
// 删除 current epoch
|
|
1097
|
-
await idbDelete(STORE_GROUP_CURRENT, groupCurrentStoreKey(aid, groupId));
|
|
1098
|
-
// 删除所有 old epochs
|
|
1099
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
|
|
1100
|
-
await idbDelete(STORE_GROUP_OLD_EPOCHS, item.key);
|
|
1101
|
-
}
|
|
1102
|
-
});
|
|
1103
|
-
}
|
|
1104
|
-
// ── E2EE Sessions ─────────────────────────────────────
|
|
1105
|
-
async loadE2EESessions(aid) {
|
|
1106
|
-
return this._withAidLock(aid, async () => {
|
|
1107
|
-
await this._migrateLegacySessionsUnlocked(aid);
|
|
1108
|
-
return this._loadSessionsUnlocked(aid);
|
|
1109
|
-
});
|
|
1110
|
-
}
|
|
1111
|
-
async saveE2EESession(aid, sessionId, data) {
|
|
1112
|
-
await this._withAidLock(aid, async () => {
|
|
1113
|
-
await this._migrateLegacySessionsUnlocked(aid);
|
|
1114
|
-
const record = deepClone(data);
|
|
1115
|
-
record.session_id = sessionId;
|
|
1116
|
-
await idbPut(STORE_SESSIONS, sessionStoreKey(aid, sessionId), record);
|
|
1117
|
-
});
|
|
1118
|
-
}
|
|
1119
|
-
/**
|
|
1120
|
-
* 读取单个 metadata KV(如 gateway_url 等跨进程缓存项)。
|
|
1121
|
-
*
|
|
1122
|
-
* 不存在时返回空字符串,与 Python keystore 的 get_metadata 语义保持一致。
|
|
1123
|
-
*/
|
|
1124
|
-
async getMetadata(aid, key) {
|
|
1125
|
-
if (!key)
|
|
1126
|
-
return '';
|
|
1127
|
-
return this._withAidLock(aid, async () => {
|
|
1128
|
-
const md = (await this._loadMetadataOnlyUnlocked(aid)) ?? {};
|
|
1129
|
-
const value = md[key];
|
|
1130
|
-
if (typeof value === 'string')
|
|
1131
|
-
return value;
|
|
1132
|
-
if (value === null || value === undefined)
|
|
1133
|
-
return '';
|
|
1134
|
-
return String(value);
|
|
1135
|
-
});
|
|
1136
|
-
}
|
|
1137
|
-
/**
|
|
1138
|
-
* 写入单个 metadata KV。空字符串视为删除该字段。
|
|
1139
|
-
*
|
|
1140
|
-
* 与 _saveMetadataOnlyUnlocked 不同,本方法只更新指定 key,不影响其他字段。
|
|
1141
|
-
*/
|
|
1142
|
-
async setMetadata(aid, key, value) {
|
|
1143
|
-
if (!key)
|
|
1144
|
-
return;
|
|
1145
|
-
await this._withAidLock(aid, async () => {
|
|
1146
|
-
const current = (await this._loadMetadataOnlyUnlocked(aid)) ?? {};
|
|
1147
|
-
const next = deepClone(current);
|
|
1148
|
-
if (value === '' || value === undefined || value === null) {
|
|
1149
|
-
delete next[key];
|
|
1150
|
-
}
|
|
1151
|
-
else {
|
|
1152
|
-
next[key] = value;
|
|
1153
|
-
}
|
|
1154
|
-
await this._saveMetadataOnlyUnlocked(aid, next);
|
|
1155
|
-
});
|
|
1156
|
-
}
|
|
1157
|
-
// ── 内部辅助 ─────────────────────────────────────────
|
|
1158
|
-
async _loadPendingRecord(handle, aid, required = true) {
|
|
1159
|
-
const data = await idbGet(STORE_PENDING_IDENTITIES, handle);
|
|
1160
|
-
if (!isRecord(data)) {
|
|
1161
|
-
if (required)
|
|
1162
|
-
throw new Error(`pending identity not found: ${handle}`);
|
|
1163
|
-
return null;
|
|
1164
|
-
}
|
|
1165
|
-
const record = deepClone(data);
|
|
1166
|
-
if (aid !== undefined && String(record.aid ?? '') !== aid) {
|
|
1167
|
-
throw new Error(`pending identity aid mismatch: ${handle}`);
|
|
1168
|
-
}
|
|
1169
|
-
return record;
|
|
1170
|
-
}
|
|
1171
|
-
async _loadKeyPairUnlocked(aid) {
|
|
1172
|
-
const data = await idbGet(STORE_KEY_PAIRS, metadataStoreKey(aid));
|
|
1173
|
-
if (!isRecord(data))
|
|
1174
|
-
return null;
|
|
1175
|
-
const result = deepClone(data);
|
|
1176
|
-
// 如果存在加密私钥信封,尝试解密还原
|
|
1177
|
-
if (isRecord(result._encrypted_pk) && hasEncryptionSeed(this._encryptionSeed)) {
|
|
1178
|
-
try {
|
|
1179
|
-
const envelope = result._encrypted_pk;
|
|
1180
|
-
result.private_key_pem = await _decryptPEM(envelope, this._encryptionSeed);
|
|
1181
|
-
delete result._encrypted_pk;
|
|
1182
|
-
}
|
|
1183
|
-
catch {
|
|
1184
|
-
this._log.error(`[keystore] decrypt ${aid} private key failed, maybe encryptionSeed mismatch`);
|
|
1185
|
-
throw new Error(`private key decrypt failed for aid ${aid}: seed_password mismatch or IndexedDB record corrupted`);
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
else if (
|
|
1189
|
-
// 透明迁移:旧版明文数据自动加密回写
|
|
1190
|
-
!isRecord(result._encrypted_pk) && typeof result.private_key_pem === 'string' && hasEncryptionSeed(this._encryptionSeed)) {
|
|
1191
|
-
await this._saveKeyPairUnlocked(aid, result);
|
|
1192
|
-
}
|
|
1193
|
-
return result;
|
|
1194
|
-
}
|
|
1195
|
-
async _saveKeyPairUnlocked(aid, keyPair) {
|
|
1196
|
-
const record = deepClone(keyPair);
|
|
1197
|
-
// 如果配置了加密种子且包含明文私钥,加密后再存储
|
|
1198
|
-
if (hasEncryptionSeed(this._encryptionSeed) && typeof record.private_key_pem === 'string') {
|
|
1199
|
-
record._encrypted_pk = await _encryptPEM(record.private_key_pem, this._encryptionSeed);
|
|
1200
|
-
delete record.private_key_pem;
|
|
1201
|
-
}
|
|
1202
|
-
await idbPut(STORE_KEY_PAIRS, metadataStoreKey(aid), record);
|
|
1203
|
-
}
|
|
1204
|
-
async _loadCertUnlocked(aid) {
|
|
1205
|
-
const data = await idbGet(STORE_CERTS, certStoreKey(aid));
|
|
1206
|
-
return typeof data === 'string' ? data : null;
|
|
1207
|
-
}
|
|
1208
|
-
async _saveCertUnlocked(aid, certPem) {
|
|
1209
|
-
await idbPut(STORE_CERTS, certStoreKey(aid), certPem);
|
|
1210
|
-
}
|
|
1211
|
-
async _loadMetadataOnlyUnlocked(aid) {
|
|
1212
|
-
const data = await idbGet(STORE_METADATA, metadataStoreKey(aid));
|
|
1213
|
-
return isRecord(data) ? deepClone(data) : null;
|
|
1214
|
-
}
|
|
1215
|
-
async _replaceStructuredStateUnlocked(aid, metadata) {
|
|
1216
|
-
// 增量保存:只处理传入的字段,不删除未传入的
|
|
1217
|
-
if ('e2ee_prekeys' in metadata && isRecord(metadata.e2ee_prekeys)) {
|
|
1218
|
-
await this._replacePrekeysUnlocked(aid, metadata.e2ee_prekeys, '');
|
|
1219
|
-
}
|
|
1220
|
-
if ('group_secrets' in metadata && isRecord(metadata.group_secrets)) {
|
|
1221
|
-
await this._replaceGroupEntriesUnlocked(aid, metadata.group_secrets);
|
|
1222
|
-
}
|
|
1223
|
-
if ('e2ee_sessions' in metadata && Array.isArray(metadata.e2ee_sessions)) {
|
|
1224
|
-
await this._replaceSessionsUnlocked(aid, metadata.e2ee_sessions);
|
|
1225
|
-
}
|
|
1226
|
-
}
|
|
1227
|
-
async _saveMetadataOnlyUnlocked(aid, metadata) {
|
|
1228
|
-
const plain = stripStructuredFields(metadata);
|
|
1229
|
-
await idbPut(STORE_METADATA, metadataStoreKey(aid), plain);
|
|
1230
|
-
}
|
|
1231
|
-
async _migrateLegacyStructuredStateUnlocked(aid) {
|
|
1232
|
-
const metadataOnly = (await this._loadMetadataOnlyUnlocked(aid)) ?? {};
|
|
1233
|
-
const hasLegacyPrekeys = isRecord(metadataOnly.e2ee_prekeys);
|
|
1234
|
-
const hasLegacyGroups = isRecord(metadataOnly.group_secrets);
|
|
1235
|
-
const hasLegacySessions = Array.isArray(metadataOnly.e2ee_sessions) && metadataOnly.e2ee_sessions.length > 0;
|
|
1236
|
-
if (!hasLegacyPrekeys && !hasLegacyGroups && !hasLegacySessions) {
|
|
1237
|
-
return metadataOnly;
|
|
1238
|
-
}
|
|
1239
|
-
const cleaned = deepClone(metadataOnly);
|
|
1240
|
-
if (hasLegacyPrekeys) {
|
|
1241
|
-
const legacyPrekeys = metadataOnly.e2ee_prekeys;
|
|
1242
|
-
const structuredPrekeys = await this._loadPrekeysUnlocked(aid);
|
|
1243
|
-
let changed = false;
|
|
1244
|
-
if (isRecord(legacyPrekeys)) {
|
|
1245
|
-
for (const [prekeyId, rawData] of Object.entries(legacyPrekeys)) {
|
|
1246
|
-
if (!isRecord(rawData) || structuredPrekeys[prekeyId])
|
|
1247
|
-
continue;
|
|
1248
|
-
if (!this._isPrekeyRecoverable(rawData))
|
|
1249
|
-
continue;
|
|
1250
|
-
structuredPrekeys[prekeyId] = deepClone(rawData);
|
|
1251
|
-
changed = true;
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
if (changed) {
|
|
1255
|
-
await this._replacePrekeysUnlocked(aid, structuredPrekeys);
|
|
1256
|
-
}
|
|
1257
|
-
delete cleaned.e2ee_prekeys;
|
|
1258
|
-
}
|
|
1259
|
-
if (hasLegacyGroups) {
|
|
1260
|
-
const legacyGroups = metadataOnly.group_secrets;
|
|
1261
|
-
const structuredGroups = await this._loadGroupEntriesUnlocked(aid);
|
|
1262
|
-
let changed = false;
|
|
1263
|
-
if (isRecord(legacyGroups)) {
|
|
1264
|
-
for (const [groupId, rawEntry] of Object.entries(legacyGroups)) {
|
|
1265
|
-
if (!isRecord(rawEntry))
|
|
1266
|
-
continue;
|
|
1267
|
-
const merged = this._mergeGroupEntryFromLegacy(structuredGroups[groupId], rawEntry);
|
|
1268
|
-
if (!sameJson(structuredGroups[groupId] ?? null, merged)) {
|
|
1269
|
-
if (Object.keys(merged).length > 0) {
|
|
1270
|
-
structuredGroups[groupId] = merged;
|
|
1271
|
-
changed = true;
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
if (changed) {
|
|
1277
|
-
await this._replaceGroupEntriesUnlocked(aid, structuredGroups);
|
|
1278
|
-
}
|
|
1279
|
-
delete cleaned.group_secrets;
|
|
1280
|
-
}
|
|
1281
|
-
if (hasLegacySessions) {
|
|
1282
|
-
const legacySessions = metadataOnly.e2ee_sessions;
|
|
1283
|
-
for (const session of legacySessions) {
|
|
1284
|
-
const sid = typeof session.session_id === 'string' ? session.session_id : '';
|
|
1285
|
-
if (!sid)
|
|
1286
|
-
continue;
|
|
1287
|
-
const record = deepClone(session);
|
|
1288
|
-
record.session_id = sid;
|
|
1289
|
-
await idbPut(STORE_SESSIONS, sessionStoreKey(aid, sid), record);
|
|
1290
|
-
}
|
|
1291
|
-
delete cleaned.e2ee_sessions;
|
|
1292
|
-
}
|
|
1293
|
-
await this._saveMetadataOnlyUnlocked(aid, cleaned);
|
|
1294
|
-
return cleaned;
|
|
1295
|
-
}
|
|
1296
|
-
async _loadPrekeysUnlocked(aid, deviceId = '') {
|
|
1297
|
-
const items = await idbGetAllByPrefix(STORE_PREKEYS, prekeyPrefix(aid));
|
|
1298
|
-
const prefix = prekeyPrefix(aid);
|
|
1299
|
-
const result = {};
|
|
1300
|
-
const normalizedDeviceId = String(deviceId ?? '').trim();
|
|
1301
|
-
for (const item of items) {
|
|
1302
|
-
if (!item.key.startsWith(prefix) || !isRecord(item.value))
|
|
1303
|
-
continue;
|
|
1304
|
-
const recordDeviceId = String(item.value.device_id ?? '').trim();
|
|
1305
|
-
if (recordDeviceId !== normalizedDeviceId)
|
|
1306
|
-
continue;
|
|
1307
|
-
const prekeyId = typeof item.value.prekey_id === 'string'
|
|
1308
|
-
? item.value.prekey_id
|
|
1309
|
-
: item.key.slice(prefix.length);
|
|
1310
|
-
const record = deepClone(item.value);
|
|
1311
|
-
delete record.prekey_id;
|
|
1312
|
-
delete record.device_id;
|
|
1313
|
-
const canonicalKey = prekeyStoreKey(aid, prekeyId, normalizedDeviceId);
|
|
1314
|
-
if (!(prekeyId in result) || item.key === canonicalKey) {
|
|
1315
|
-
result[prekeyId] = record;
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
return result;
|
|
1319
|
-
}
|
|
1320
|
-
/**
|
|
1321
|
-
* 按 prekey_id 单点查找(不锁外部,调用方负责加锁)。
|
|
1322
|
-
*
|
|
1323
|
-
* 实现策略:
|
|
1324
|
-
* 1) 先按"无 device_id"的 canonical key 直查(命中即 O(log N))。
|
|
1325
|
-
* 2) 未命中(设备级 prekey 走的是含 device_id 的复合 key)→ 回退前缀扫描,
|
|
1326
|
-
* 在 prekey_id 字段匹配的第一条记录上返回;这种情况下扫描代价仍存在,但
|
|
1327
|
-
* 只在边缘 case 触发。
|
|
1328
|
-
*
|
|
1329
|
-
* 返回结构与 `_loadPrekeysUnlocked` 单条值保持一致:剥离 prekey_id / device_id。
|
|
1330
|
-
*/
|
|
1331
|
-
async _loadPrekeyByIdUnlocked(aid, prekeyId) {
|
|
1332
|
-
if (!prekeyId)
|
|
1333
|
-
return null;
|
|
1334
|
-
// 优先:deviceId='' 形态的 canonical key(generatePrekey 默认走这条路径)。
|
|
1335
|
-
const directKey = prekeyStoreKey(aid, prekeyId, '');
|
|
1336
|
-
const direct = await idbGet(STORE_PREKEYS, directKey);
|
|
1337
|
-
if (isRecord(direct)) {
|
|
1338
|
-
const record = deepClone(direct);
|
|
1339
|
-
delete record.prekey_id;
|
|
1340
|
-
delete record.device_id;
|
|
1341
|
-
return record;
|
|
1342
|
-
}
|
|
1343
|
-
// 回退:设备级 prekey(key 形如 aid|device|prekey_id)→ 前缀扫描匹配 prekey_id 字段。
|
|
1344
|
-
for (const item of await idbGetAllByPrefix(STORE_PREKEYS, prekeyPrefix(aid))) {
|
|
1345
|
-
if (!isRecord(item.value))
|
|
1346
|
-
continue;
|
|
1347
|
-
const itemPrekeyId = typeof item.value.prekey_id === 'string' ? item.value.prekey_id : '';
|
|
1348
|
-
if (itemPrekeyId !== prekeyId)
|
|
1349
|
-
continue;
|
|
1350
|
-
const record = deepClone(item.value);
|
|
1351
|
-
delete record.prekey_id;
|
|
1352
|
-
delete record.device_id;
|
|
1353
|
-
return record;
|
|
1354
|
-
}
|
|
1355
|
-
return null;
|
|
1356
|
-
}
|
|
1357
|
-
async _replacePrekeysUnlocked(aid, prekeys, deviceId = '') {
|
|
1358
|
-
const desired = new Set(Object.keys(prekeys));
|
|
1359
|
-
const normalizedDeviceId = String(deviceId ?? '').trim();
|
|
1360
|
-
for (const item of await idbGetAllByPrefix(STORE_PREKEYS, prekeyPrefix(aid))) {
|
|
1361
|
-
if (!isRecord(item.value))
|
|
1362
|
-
continue;
|
|
1363
|
-
const recordDeviceId = String(item.value.device_id ?? '').trim();
|
|
1364
|
-
if (recordDeviceId !== normalizedDeviceId)
|
|
1365
|
-
continue;
|
|
1366
|
-
const prekeyId = typeof item.value.prekey_id === 'string'
|
|
1367
|
-
? item.value.prekey_id
|
|
1368
|
-
: '';
|
|
1369
|
-
const canonicalKey = prekeyStoreKey(aid, prekeyId, normalizedDeviceId);
|
|
1370
|
-
if (!desired.has(prekeyId) || item.key !== canonicalKey) {
|
|
1371
|
-
await idbDelete(STORE_PREKEYS, item.key);
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
for (const [prekeyId, prekeyData] of Object.entries(prekeys)) {
|
|
1375
|
-
const record = deepClone(prekeyData);
|
|
1376
|
-
if (normalizedDeviceId) {
|
|
1377
|
-
record.device_id = normalizedDeviceId;
|
|
1378
|
-
}
|
|
1379
|
-
else {
|
|
1380
|
-
delete record.device_id;
|
|
1381
|
-
}
|
|
1382
|
-
await idbPut(STORE_PREKEYS, prekeyStoreKey(aid, prekeyId, normalizedDeviceId), {
|
|
1383
|
-
...record,
|
|
1384
|
-
prekey_id: prekeyId,
|
|
1385
|
-
});
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
async _loadGroupEntriesUnlocked(aid) {
|
|
1389
|
-
const result = {};
|
|
1390
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_CURRENT, groupCurrentPrefix(aid))) {
|
|
1391
|
-
if (!isRecord(item.value))
|
|
1392
|
-
continue;
|
|
1393
|
-
const groupId = typeof item.value.group_id === 'string'
|
|
1394
|
-
? item.value.group_id
|
|
1395
|
-
: item.key.slice(groupCurrentPrefix(aid).length);
|
|
1396
|
-
const record = deepClone(item.value);
|
|
1397
|
-
delete record.group_id;
|
|
1398
|
-
result[groupId] = record;
|
|
1399
|
-
}
|
|
1400
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid))) {
|
|
1401
|
-
if (!isRecord(item.value))
|
|
1402
|
-
continue;
|
|
1403
|
-
const groupId = typeof item.value.group_id === 'string'
|
|
1404
|
-
? item.value.group_id
|
|
1405
|
-
: '';
|
|
1406
|
-
if (!groupId)
|
|
1407
|
-
continue;
|
|
1408
|
-
const record = deepClone(item.value);
|
|
1409
|
-
delete record.group_id;
|
|
1410
|
-
const entry = result[groupId] ?? {};
|
|
1411
|
-
const oldEpochs = Array.isArray(entry.old_epochs)
|
|
1412
|
-
? entry.old_epochs
|
|
1413
|
-
: [];
|
|
1414
|
-
oldEpochs.push(record);
|
|
1415
|
-
oldEpochs.sort((a, b) => Number(a.epoch ?? 0) - Number(b.epoch ?? 0));
|
|
1416
|
-
entry.old_epochs = oldEpochs;
|
|
1417
|
-
result[groupId] = entry;
|
|
1418
|
-
}
|
|
1419
|
-
return result;
|
|
1420
|
-
}
|
|
1421
|
-
async _replaceGroupEntriesUnlocked(aid, groups) {
|
|
1422
|
-
const desiredGroups = new Set(Object.keys(groups));
|
|
1423
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_CURRENT, groupCurrentPrefix(aid))) {
|
|
1424
|
-
const groupId = isRecord(item.value) && typeof item.value.group_id === 'string'
|
|
1425
|
-
? item.value.group_id
|
|
1426
|
-
: item.key.slice(groupCurrentPrefix(aid).length);
|
|
1427
|
-
if (!desiredGroups.has(groupId)) {
|
|
1428
|
-
await idbDelete(STORE_GROUP_CURRENT, item.key);
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid))) {
|
|
1432
|
-
if (!isRecord(item.value))
|
|
1433
|
-
continue;
|
|
1434
|
-
const groupId = typeof item.value.group_id === 'string' ? item.value.group_id : '';
|
|
1435
|
-
if (!groupId || desiredGroups.has(groupId))
|
|
1436
|
-
continue;
|
|
1437
|
-
await idbDelete(STORE_GROUP_OLD_EPOCHS, item.key);
|
|
1438
|
-
}
|
|
1439
|
-
for (const [groupId, rawEntry] of Object.entries(groups)) {
|
|
1440
|
-
if (!isRecord(rawEntry))
|
|
1441
|
-
continue;
|
|
1442
|
-
await this._saveSingleGroupEntryUnlocked(aid, groupId, rawEntry);
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
async _saveSingleGroupEntryUnlocked(aid, groupId, entry) {
|
|
1446
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
|
|
1447
|
-
if (!isRecord(item.value))
|
|
1448
|
-
continue;
|
|
1449
|
-
const existingGroupId = typeof item.value.group_id === 'string' ? item.value.group_id : '';
|
|
1450
|
-
if (existingGroupId === groupId) {
|
|
1451
|
-
await idbDelete(STORE_GROUP_OLD_EPOCHS, item.key);
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
const current = deepClone(entry);
|
|
1455
|
-
const oldEpochs = Array.isArray(current.old_epochs)
|
|
1456
|
-
? current.old_epochs
|
|
1457
|
-
: [];
|
|
1458
|
-
delete current.old_epochs;
|
|
1459
|
-
if (typeof current.epoch === 'number') {
|
|
1460
|
-
await idbPut(STORE_GROUP_CURRENT, groupCurrentStoreKey(aid, groupId), {
|
|
1461
|
-
...current,
|
|
1462
|
-
group_id: groupId,
|
|
1463
|
-
});
|
|
1464
|
-
}
|
|
1465
|
-
else {
|
|
1466
|
-
await idbDelete(STORE_GROUP_CURRENT, groupCurrentStoreKey(aid, groupId));
|
|
1467
|
-
}
|
|
1468
|
-
for (const oldEpoch of oldEpochs) {
|
|
1469
|
-
if (!isRecord(oldEpoch) || typeof oldEpoch.epoch !== 'number')
|
|
1470
|
-
continue;
|
|
1471
|
-
await idbPut(STORE_GROUP_OLD_EPOCHS, groupOldStoreKey(aid, groupId, oldEpoch.epoch), {
|
|
1472
|
-
...deepClone(oldEpoch),
|
|
1473
|
-
group_id: groupId,
|
|
1474
|
-
});
|
|
1475
|
-
}
|
|
1476
|
-
}
|
|
1477
|
-
async _storeGroupSecretTransitionUnlocked(aid, groupId, opts) {
|
|
1478
|
-
const currentKey = groupCurrentStoreKey(aid, groupId);
|
|
1479
|
-
const currentRaw = await idbGet(STORE_GROUP_CURRENT, currentKey);
|
|
1480
|
-
const now = Date.now();
|
|
1481
|
-
const epoch = Number(opts.epoch);
|
|
1482
|
-
const members = [...(opts.memberAids ?? [])].map((item) => String(item)).sort();
|
|
1483
|
-
const pendingRotationId = String(opts.pendingRotationId ?? '').trim();
|
|
1484
|
-
if (isRecord(currentRaw)) {
|
|
1485
|
-
const current = deepClone(currentRaw);
|
|
1486
|
-
delete current.group_id;
|
|
1487
|
-
const localEpoch = Number(current.epoch ?? 0);
|
|
1488
|
-
if (epoch < localEpoch)
|
|
1489
|
-
return false;
|
|
1490
|
-
if (epoch === localEpoch && typeof current.secret === 'string') {
|
|
1491
|
-
if (current.secret !== opts.secret) {
|
|
1492
|
-
if (String(current.pending_rotation_id ?? '').trim()) {
|
|
1493
|
-
await idbPut(STORE_GROUP_CURRENT, currentKey, {
|
|
1494
|
-
...this._buildGroupCurrentRecord(groupId, opts, members, now),
|
|
1495
|
-
});
|
|
1496
|
-
return true;
|
|
1497
|
-
}
|
|
1498
|
-
return false;
|
|
1499
|
-
}
|
|
1500
|
-
const updated = { ...current };
|
|
1501
|
-
let changed = false;
|
|
1502
|
-
const oldMembers = Array.isArray(updated.member_aids) ? updated.member_aids.map(String).sort() : [];
|
|
1503
|
-
if (members.length > 0 && JSON.stringify(oldMembers) !== JSON.stringify(members)) {
|
|
1504
|
-
updated.member_aids = members;
|
|
1505
|
-
updated.commitment = opts.commitment;
|
|
1506
|
-
updated.updated_at = now;
|
|
1507
|
-
changed = true;
|
|
1508
|
-
}
|
|
1509
|
-
if (opts.epochChain !== undefined && updated.epoch_chain !== opts.epochChain) {
|
|
1510
|
-
updated.epoch_chain = opts.epochChain;
|
|
1511
|
-
updated.updated_at = now;
|
|
1512
|
-
changed = true;
|
|
1513
|
-
}
|
|
1514
|
-
if (opts.epochChainUnverified === true) {
|
|
1515
|
-
if (updated.epoch_chain_unverified !== true) {
|
|
1516
|
-
updated.epoch_chain_unverified = true;
|
|
1517
|
-
changed = true;
|
|
1518
|
-
}
|
|
1519
|
-
if (opts.epochChainUnverifiedReason && updated.epoch_chain_unverified_reason !== opts.epochChainUnverifiedReason) {
|
|
1520
|
-
updated.epoch_chain_unverified_reason = opts.epochChainUnverifiedReason;
|
|
1521
|
-
changed = true;
|
|
1522
|
-
}
|
|
1523
|
-
}
|
|
1524
|
-
else if (opts.epochChainUnverified === false) {
|
|
1525
|
-
if ('epoch_chain_unverified' in updated || 'epoch_chain_unverified_reason' in updated) {
|
|
1526
|
-
delete updated.epoch_chain_unverified;
|
|
1527
|
-
delete updated.epoch_chain_unverified_reason;
|
|
1528
|
-
changed = true;
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
if (pendingRotationId && updated.pending_rotation_id !== pendingRotationId) {
|
|
1532
|
-
updated.pending_rotation_id = pendingRotationId;
|
|
1533
|
-
updated.pending_created_at = now;
|
|
1534
|
-
changed = true;
|
|
1535
|
-
}
|
|
1536
|
-
if (!pendingRotationId && updated.pending_rotation_id) {
|
|
1537
|
-
delete updated.pending_rotation_id;
|
|
1538
|
-
delete updated.pending_created_at;
|
|
1539
|
-
changed = true;
|
|
1540
|
-
}
|
|
1541
|
-
if (changed) {
|
|
1542
|
-
await idbPut(STORE_GROUP_CURRENT, currentKey, { ...updated, group_id: groupId });
|
|
1543
|
-
}
|
|
1544
|
-
return true;
|
|
1545
|
-
}
|
|
1546
|
-
if (localEpoch !== epoch) {
|
|
1547
|
-
const oldEntry = { ...current };
|
|
1548
|
-
oldEntry.expires_at = Number(current.updated_at ?? now) + opts.oldEpochRetentionMs;
|
|
1549
|
-
await idbPut(STORE_GROUP_OLD_EPOCHS, groupOldStoreKey(aid, groupId, localEpoch), {
|
|
1550
|
-
...oldEntry,
|
|
1551
|
-
group_id: groupId,
|
|
1552
|
-
});
|
|
1553
|
-
}
|
|
1554
|
-
else {
|
|
1555
|
-
// epoch === localEpoch 但 secret 为空:合并 data,保留已有字段
|
|
1556
|
-
const merged = { ...current, ...this._buildGroupCurrentRecord(groupId, opts, members, now) };
|
|
1557
|
-
if (!opts.epochChain && current.epoch_chain) {
|
|
1558
|
-
merged.epoch_chain = current.epoch_chain;
|
|
1559
|
-
}
|
|
1560
|
-
if (current.pending_rotation_id && !opts.pendingRotationId) {
|
|
1561
|
-
merged.pending_rotation_id = current.pending_rotation_id;
|
|
1562
|
-
if (current.pending_created_at)
|
|
1563
|
-
merged.pending_created_at = current.pending_created_at;
|
|
1564
|
-
}
|
|
1565
|
-
await idbPut(STORE_GROUP_CURRENT, currentKey, merged);
|
|
1566
|
-
return true;
|
|
1567
|
-
}
|
|
1568
|
-
}
|
|
1569
|
-
await idbPut(STORE_GROUP_CURRENT, currentKey, {
|
|
1570
|
-
...this._buildGroupCurrentRecord(groupId, opts, members, now),
|
|
1571
|
-
});
|
|
1572
|
-
return true;
|
|
1573
|
-
}
|
|
1574
|
-
async _storeGroupSecretEpochUnlocked(aid, groupId, opts) {
|
|
1575
|
-
const now = Date.now();
|
|
1576
|
-
const epoch = Number(opts.epoch);
|
|
1577
|
-
const members = [...(opts.memberAids ?? [])].map((item) => String(item)).sort();
|
|
1578
|
-
const pendingRotationId = String(opts.pendingRotationId ?? '').trim();
|
|
1579
|
-
const currentKey = groupCurrentStoreKey(aid, groupId);
|
|
1580
|
-
const currentRaw = await idbGet(STORE_GROUP_CURRENT, currentKey);
|
|
1581
|
-
const newRecord = this._buildGroupCurrentRecord(groupId, opts, members, now);
|
|
1582
|
-
if (!isRecord(currentRaw)) {
|
|
1583
|
-
await idbPut(STORE_GROUP_CURRENT, currentKey, newRecord);
|
|
1584
|
-
return true;
|
|
1585
|
-
}
|
|
1586
|
-
const current = deepClone(currentRaw);
|
|
1587
|
-
delete current.group_id;
|
|
1588
|
-
const localEpoch = Number(current.epoch ?? 0);
|
|
1589
|
-
if (epoch > localEpoch) {
|
|
1590
|
-
// 归档旧 epoch 到 old_epochs,然后用新 epoch 更新 current
|
|
1591
|
-
const expiresAt = Date.now() + (opts.oldEpochRetentionMs ?? 604800_000);
|
|
1592
|
-
const oldRecord = { ...current, group_id: groupId, expires_at: expiresAt };
|
|
1593
|
-
const oldKey = groupOldStoreKey(aid, groupId, localEpoch);
|
|
1594
|
-
await idbPut(STORE_GROUP_OLD_EPOCHS, oldKey, oldRecord);
|
|
1595
|
-
await idbPut(STORE_GROUP_CURRENT, currentKey, newRecord);
|
|
1596
|
-
return true;
|
|
1597
|
-
}
|
|
1598
|
-
if (epoch === localEpoch) {
|
|
1599
|
-
if (typeof current.secret === 'string' && current.secret !== opts.secret) {
|
|
1600
|
-
if (!String(current.pending_rotation_id ?? '').trim())
|
|
1601
|
-
return false;
|
|
1602
|
-
await idbPut(STORE_GROUP_CURRENT, currentKey, newRecord);
|
|
1603
|
-
return true;
|
|
1604
|
-
}
|
|
1605
|
-
const updated = { ...current };
|
|
1606
|
-
let changed = false;
|
|
1607
|
-
const oldMembers = Array.isArray(updated.member_aids) ? updated.member_aids.map(String).sort() : [];
|
|
1608
|
-
if (members.length > 0 && JSON.stringify(oldMembers) !== JSON.stringify(members)) {
|
|
1609
|
-
updated.member_aids = members;
|
|
1610
|
-
updated.commitment = opts.commitment;
|
|
1611
|
-
updated.updated_at = now;
|
|
1612
|
-
changed = true;
|
|
1613
|
-
}
|
|
1614
|
-
if (opts.epochChain !== undefined && updated.epoch_chain !== opts.epochChain) {
|
|
1615
|
-
updated.epoch_chain = opts.epochChain;
|
|
1616
|
-
updated.updated_at = now;
|
|
1617
|
-
changed = true;
|
|
1618
|
-
}
|
|
1619
|
-
if (opts.epochChainUnverified === true) {
|
|
1620
|
-
if (updated.epoch_chain_unverified !== true) {
|
|
1621
|
-
updated.epoch_chain_unverified = true;
|
|
1622
|
-
changed = true;
|
|
1623
|
-
}
|
|
1624
|
-
if (opts.epochChainUnverifiedReason && updated.epoch_chain_unverified_reason !== opts.epochChainUnverifiedReason) {
|
|
1625
|
-
updated.epoch_chain_unverified_reason = opts.epochChainUnverifiedReason;
|
|
1626
|
-
changed = true;
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
else if (opts.epochChainUnverified === false) {
|
|
1630
|
-
if ('epoch_chain_unverified' in updated || 'epoch_chain_unverified_reason' in updated) {
|
|
1631
|
-
delete updated.epoch_chain_unverified;
|
|
1632
|
-
delete updated.epoch_chain_unverified_reason;
|
|
1633
|
-
changed = true;
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
if (pendingRotationId && updated.pending_rotation_id !== pendingRotationId) {
|
|
1637
|
-
updated.pending_rotation_id = pendingRotationId;
|
|
1638
|
-
updated.pending_created_at = now;
|
|
1639
|
-
changed = true;
|
|
1640
|
-
}
|
|
1641
|
-
if (!pendingRotationId && updated.pending_rotation_id) {
|
|
1642
|
-
delete updated.pending_rotation_id;
|
|
1643
|
-
delete updated.pending_created_at;
|
|
1644
|
-
changed = true;
|
|
1645
|
-
}
|
|
1646
|
-
if (changed)
|
|
1647
|
-
await idbPut(STORE_GROUP_CURRENT, currentKey, { ...updated, group_id: groupId });
|
|
1648
|
-
return true;
|
|
1649
|
-
}
|
|
1650
|
-
const oldKey = groupOldStoreKey(aid, groupId, epoch);
|
|
1651
|
-
const oldRaw = await idbGet(STORE_GROUP_OLD_EPOCHS, oldKey);
|
|
1652
|
-
if (isRecord(oldRaw)) {
|
|
1653
|
-
const old = deepClone(oldRaw);
|
|
1654
|
-
if (typeof old.secret === 'string' && old.secret !== opts.secret)
|
|
1655
|
-
return false;
|
|
1656
|
-
}
|
|
1657
|
-
await idbPut(STORE_GROUP_OLD_EPOCHS, oldKey, {
|
|
1658
|
-
...newRecord,
|
|
1659
|
-
epoch,
|
|
1660
|
-
expires_at: now + opts.oldEpochRetentionMs,
|
|
1661
|
-
});
|
|
1662
|
-
return true;
|
|
1663
|
-
}
|
|
1664
|
-
async _discardPendingGroupSecretUnlocked(aid, groupId, epoch, rotationId) {
|
|
1665
|
-
const currentKey = groupCurrentStoreKey(aid, groupId);
|
|
1666
|
-
const currentRaw = await idbGet(STORE_GROUP_CURRENT, currentKey);
|
|
1667
|
-
if (!isRecord(currentRaw))
|
|
1668
|
-
return false;
|
|
1669
|
-
const current = deepClone(currentRaw);
|
|
1670
|
-
if (Number(current.epoch ?? 0) !== Number(epoch))
|
|
1671
|
-
return false;
|
|
1672
|
-
if (String(current.pending_rotation_id ?? '').trim() !== rotationId)
|
|
1673
|
-
return false;
|
|
1674
|
-
let restored = null;
|
|
1675
|
-
let restoredKey = '';
|
|
1676
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
|
|
1677
|
-
if (!isRecord(item.value))
|
|
1678
|
-
continue;
|
|
1679
|
-
const old = deepClone(item.value);
|
|
1680
|
-
const oldEpoch = Number(old.epoch ?? 0);
|
|
1681
|
-
if (oldEpoch >= Number(epoch) || !old.secret)
|
|
1682
|
-
continue;
|
|
1683
|
-
if (!restored || oldEpoch > Number(restored.epoch ?? 0)) {
|
|
1684
|
-
restored = old;
|
|
1685
|
-
restoredKey = item.key;
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
if (!restored) {
|
|
1689
|
-
await idbDelete(STORE_GROUP_CURRENT, currentKey);
|
|
1690
|
-
for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
|
|
1691
|
-
await idbDelete(STORE_GROUP_OLD_EPOCHS, item.key);
|
|
1692
|
-
}
|
|
1693
|
-
return true;
|
|
1694
|
-
}
|
|
1695
|
-
delete restored.group_id;
|
|
1696
|
-
delete restored.expires_at;
|
|
1697
|
-
await idbPut(STORE_GROUP_CURRENT, currentKey, { ...restored, group_id: groupId, updated_at: Date.now() });
|
|
1698
|
-
if (restoredKey)
|
|
1699
|
-
await idbDelete(STORE_GROUP_OLD_EPOCHS, restoredKey);
|
|
1700
|
-
return true;
|
|
1701
|
-
}
|
|
1702
|
-
_buildGroupCurrentRecord(groupId, opts, memberAids, now) {
|
|
1703
|
-
const record = {
|
|
1704
|
-
group_id: groupId,
|
|
1705
|
-
epoch: opts.epoch,
|
|
1706
|
-
secret: opts.secret,
|
|
1707
|
-
commitment: opts.commitment,
|
|
1708
|
-
member_aids: memberAids,
|
|
1709
|
-
updated_at: now,
|
|
1710
|
-
};
|
|
1711
|
-
if (opts.epochChain !== undefined)
|
|
1712
|
-
record.epoch_chain = opts.epochChain;
|
|
1713
|
-
const pendingRotationId = String(opts.pendingRotationId ?? '').trim();
|
|
1714
|
-
if (pendingRotationId) {
|
|
1715
|
-
record.pending_rotation_id = pendingRotationId;
|
|
1716
|
-
record.pending_created_at = now;
|
|
1717
|
-
}
|
|
1718
|
-
if (opts.epochChainUnverified === true) {
|
|
1719
|
-
record.epoch_chain_unverified = true;
|
|
1720
|
-
if (opts.epochChainUnverifiedReason)
|
|
1721
|
-
record.epoch_chain_unverified_reason = opts.epochChainUnverifiedReason;
|
|
1722
|
-
}
|
|
1723
|
-
return record;
|
|
1724
|
-
}
|
|
1725
|
-
_mergeGroupEntryFromLegacy(existing, incoming) {
|
|
1726
|
-
const oldByEpoch = new Map();
|
|
1727
|
-
let current = existing && typeof existing.epoch === 'number'
|
|
1728
|
-
? deepClone(Object.fromEntries(Object.entries(existing).filter(([key]) => key !== 'old_epochs')))
|
|
1729
|
-
: null;
|
|
1730
|
-
if (existing && Array.isArray(existing.old_epochs)) {
|
|
1731
|
-
for (const rawOld of existing.old_epochs) {
|
|
1732
|
-
if (typeof rawOld.epoch !== 'number')
|
|
1733
|
-
continue;
|
|
1734
|
-
oldByEpoch.set(rawOld.epoch, deepClone(rawOld));
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
if (typeof incoming.epoch === 'number' && this._isGroupEpochRecoverable(incoming)) {
|
|
1738
|
-
const incomingCurrent = deepClone(Object.fromEntries(Object.entries(incoming).filter(([key]) => key !== 'old_epochs')));
|
|
1739
|
-
if (!current) {
|
|
1740
|
-
current = incomingCurrent;
|
|
1741
|
-
}
|
|
1742
|
-
else if (incomingCurrent.epoch > current.epoch) {
|
|
1743
|
-
oldByEpoch.set(current.epoch, this._preferNewerGroupEpochRecord(oldByEpoch.get(current.epoch), current));
|
|
1744
|
-
current = incomingCurrent;
|
|
1745
|
-
}
|
|
1746
|
-
else if (incomingCurrent.epoch === current.epoch) {
|
|
1747
|
-
current = this._preferNewerGroupEpochRecord(current, incomingCurrent);
|
|
1748
|
-
}
|
|
1749
|
-
else {
|
|
1750
|
-
oldByEpoch.set(incomingCurrent.epoch, this._preferNewerGroupEpochRecord(oldByEpoch.get(incomingCurrent.epoch), incomingCurrent));
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
const incomingOldEpochs = Array.isArray(incoming.old_epochs)
|
|
1754
|
-
? incoming.old_epochs
|
|
1755
|
-
: [];
|
|
1756
|
-
for (const rawOld of incomingOldEpochs) {
|
|
1757
|
-
if (typeof rawOld.epoch !== 'number' || !this._isGroupEpochRecoverable(rawOld))
|
|
1758
|
-
continue;
|
|
1759
|
-
oldByEpoch.set(rawOld.epoch, this._preferNewerGroupEpochRecord(oldByEpoch.get(rawOld.epoch), rawOld));
|
|
1760
|
-
}
|
|
1761
|
-
const merged = current ? deepClone(current) : {};
|
|
1762
|
-
if (current && typeof current.epoch === 'number') {
|
|
1763
|
-
oldByEpoch.delete(current.epoch);
|
|
1764
|
-
}
|
|
1765
|
-
if (oldByEpoch.size > 0) {
|
|
1766
|
-
merged.old_epochs = [...oldByEpoch.entries()]
|
|
1767
|
-
.sort(([a], [b]) => a - b)
|
|
1768
|
-
.map(([, value]) => deepClone(value));
|
|
1769
|
-
}
|
|
1770
|
-
return merged;
|
|
1771
|
-
}
|
|
1772
|
-
_preferNewerGroupEpochRecord(existing, incoming) {
|
|
1773
|
-
if (!existing)
|
|
1774
|
-
return deepClone(incoming);
|
|
1775
|
-
return Number(incoming.updated_at ?? 0) > Number(existing.updated_at ?? 0)
|
|
1776
|
-
? deepClone(incoming)
|
|
1777
|
-
: deepClone(existing);
|
|
1778
|
-
}
|
|
1779
|
-
_isUnexpiredRecord(record, fallbackKey) {
|
|
1780
|
-
const expiresAt = typeof record.expires_at === 'number' ? record.expires_at : null;
|
|
1781
|
-
if (expiresAt !== null)
|
|
1782
|
-
return expiresAt >= Date.now();
|
|
1783
|
-
const marker = typeof record[fallbackKey] === 'number' ? record[fallbackKey] : null;
|
|
1784
|
-
return marker !== null && marker + STRUCTURED_RECOVERY_RETENTION_MS >= Date.now();
|
|
1785
|
-
}
|
|
1786
|
-
_isPrekeyRecoverable(record) {
|
|
1787
|
-
return this._isUnexpiredRecord(record, 'created_at');
|
|
1788
|
-
}
|
|
1789
|
-
_isGroupEpochRecoverable(record) {
|
|
1790
|
-
// 空字符串 secret 不视为可恢复的有效记录
|
|
1791
|
-
const secret = record.secret;
|
|
1792
|
-
if (!secret || (typeof secret === 'string' && secret.length === 0))
|
|
1793
|
-
return false;
|
|
1794
|
-
return this._isUnexpiredRecord(record, 'updated_at');
|
|
1795
|
-
}
|
|
1796
|
-
// ── Sessions 内部辅助 ─────────────────────────────────
|
|
1797
|
-
async _loadSessionsUnlocked(aid) {
|
|
1798
|
-
const items = await idbGetAllByPrefix(STORE_SESSIONS, sessionPrefix(aid));
|
|
1799
|
-
const result = [];
|
|
1800
|
-
for (const item of items) {
|
|
1801
|
-
if (!isRecord(item.value))
|
|
1802
|
-
continue;
|
|
1803
|
-
result.push(deepClone(item.value));
|
|
1804
|
-
}
|
|
1805
|
-
return result;
|
|
1806
|
-
}
|
|
1807
|
-
async _replaceSessionsUnlocked(aid, sessions) {
|
|
1808
|
-
const desired = new Set();
|
|
1809
|
-
for (const session of sessions) {
|
|
1810
|
-
const sid = typeof session.session_id === 'string' ? session.session_id : '';
|
|
1811
|
-
if (!sid)
|
|
1812
|
-
continue;
|
|
1813
|
-
desired.add(sid);
|
|
1814
|
-
const record = deepClone(session);
|
|
1815
|
-
record.session_id = sid;
|
|
1816
|
-
await idbPut(STORE_SESSIONS, sessionStoreKey(aid, sid), record);
|
|
1817
|
-
}
|
|
1818
|
-
for (const item of await idbGetAllByPrefix(STORE_SESSIONS, sessionPrefix(aid))) {
|
|
1819
|
-
if (!isRecord(item.value))
|
|
1820
|
-
continue;
|
|
1821
|
-
const sid = typeof item.value.session_id === 'string'
|
|
1822
|
-
? item.value.session_id
|
|
1823
|
-
: item.key.slice(sessionPrefix(aid).length);
|
|
1824
|
-
if (!desired.has(sid)) {
|
|
1825
|
-
await idbDelete(STORE_SESSIONS, item.key);
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
async _migrateLegacySessionsUnlocked(aid) {
|
|
1830
|
-
const metadataOnly = (await this._loadMetadataOnlyUnlocked(aid)) ?? {};
|
|
1831
|
-
if (!Array.isArray(metadataOnly.e2ee_sessions) || metadataOnly.e2ee_sessions.length === 0) {
|
|
1832
|
-
return;
|
|
1833
|
-
}
|
|
1834
|
-
for (const session of metadataOnly.e2ee_sessions) {
|
|
1835
|
-
const sid = typeof session.session_id === 'string' ? session.session_id : '';
|
|
1836
|
-
if (!sid)
|
|
1837
|
-
continue;
|
|
1838
|
-
const record = deepClone(session);
|
|
1839
|
-
record.session_id = sid;
|
|
1840
|
-
await idbPut(STORE_SESSIONS, sessionStoreKey(aid, sid), record);
|
|
1841
|
-
}
|
|
1842
|
-
const cleaned = deepClone(metadataOnly);
|
|
1843
|
-
delete cleaned.e2ee_sessions;
|
|
1844
|
-
await this._saveMetadataOnlyUnlocked(aid, cleaned);
|
|
1845
|
-
}
|
|
1846
|
-
// ── Seq Tracker ─────────────────────────────────────────
|
|
1847
|
-
async saveSeq(aid, deviceId, slotId, namespace, contiguousSeq) {
|
|
1848
|
-
const key = seqTrackerStoreKey(aid, deviceId, slotId, namespace);
|
|
1849
|
-
await idbPut(STORE_INSTANCE_STATE, key, { contiguous_seq: contiguousSeq, updated_at: Date.now() });
|
|
1850
|
-
}
|
|
1851
|
-
async loadSeq(aid, deviceId, slotId, namespace) {
|
|
1852
|
-
const key = seqTrackerStoreKey(aid, deviceId, slotId, namespace);
|
|
1853
|
-
const data = await idbGet(STORE_INSTANCE_STATE, key);
|
|
1854
|
-
return data?.contiguous_seq ?? 0;
|
|
1855
|
-
}
|
|
1856
|
-
async loadAllSeqs(aid, deviceId, slotId) {
|
|
1857
|
-
const prefix = seqTrackerPrefix(aid, deviceId, slotId);
|
|
1858
|
-
const items = await idbGetAllByPrefix(STORE_INSTANCE_STATE, prefix);
|
|
1859
|
-
const result = {};
|
|
1860
|
-
for (const item of items) {
|
|
1861
|
-
if (!item.key.startsWith(prefix))
|
|
1862
|
-
continue;
|
|
1863
|
-
const ns = decodeURIComponent(item.key.slice(prefix.length));
|
|
1864
|
-
if (ns && typeof item.value?.contiguous_seq === 'number') {
|
|
1865
|
-
result[ns] = item.value.contiguous_seq;
|
|
1866
|
-
}
|
|
1867
|
-
}
|
|
1868
|
-
return result;
|
|
1869
|
-
}
|
|
1870
|
-
async deleteSeq(aid, deviceId, slotId, namespace) {
|
|
1871
|
-
const key = seqTrackerStoreKey(aid, deviceId, slotId, namespace);
|
|
1872
|
-
await idbDelete(STORE_INSTANCE_STATE, key);
|
|
1873
|
-
}
|
|
1874
|
-
// ── agent.md Cache ───────────────────────────────────────
|
|
1875
|
-
async loadAgentMdCache(ownerAid, targetAid) {
|
|
1876
|
-
const owner = String(ownerAid ?? '').trim();
|
|
1877
|
-
const target = String(targetAid ?? '').trim();
|
|
1878
|
-
if (!owner || !target)
|
|
1879
|
-
return null;
|
|
1880
|
-
const data = await idbGet(STORE_AGENT_MD_CACHE, agentMdCacheStoreKey(owner, target));
|
|
1881
|
-
const record = normalizeAgentMdCacheRecord(target, data);
|
|
1882
|
-
return record ? deepClone(record) : null;
|
|
1883
|
-
}
|
|
1884
|
-
async upsertAgentMdCache(ownerAid, targetAid, fields) {
|
|
1885
|
-
const owner = String(ownerAid ?? '').trim();
|
|
1886
|
-
const target = String(targetAid ?? '').trim();
|
|
1887
|
-
if (!owner || !target) {
|
|
1888
|
-
throw new Error('upsertAgentMdCache requires ownerAid and targetAid');
|
|
1889
|
-
}
|
|
1890
|
-
const current = await this.loadAgentMdCache(owner, target);
|
|
1891
|
-
const record = mergeAgentMdCacheRecord(target, current, fields ?? {});
|
|
1892
|
-
await idbPut(STORE_AGENT_MD_CACHE, agentMdCacheStoreKey(owner, target), record);
|
|
1893
|
-
return deepClone(record);
|
|
1894
|
-
}
|
|
1895
|
-
async listAgentMdContentAids(agentMdPath) {
|
|
1896
|
-
const root = String(agentMdPath ?? '').trim();
|
|
1897
|
-
if (!root)
|
|
1898
|
-
return [];
|
|
1899
|
-
const prefix = agentMdCachePrefix(root);
|
|
1900
|
-
const suffix = '/agent.md';
|
|
1901
|
-
const aids = new Set();
|
|
1902
|
-
for (const item of await idbGetAllByPrefix(STORE_AGENT_MD_CACHE, prefix)) {
|
|
1903
|
-
const encodedTarget = item.key.slice(prefix.length);
|
|
1904
|
-
let target = '';
|
|
1905
|
-
try {
|
|
1906
|
-
target = decodeURIComponent(encodedTarget);
|
|
1907
|
-
}
|
|
1908
|
-
catch {
|
|
1909
|
-
target = encodedTarget;
|
|
1910
|
-
}
|
|
1911
|
-
if (!target.endsWith(suffix))
|
|
1912
|
-
continue;
|
|
1913
|
-
const aid = target.slice(0, -suffix.length).trim();
|
|
1914
|
-
if (aid)
|
|
1915
|
-
aids.add(aid);
|
|
1916
|
-
}
|
|
1917
|
-
return [...aids].sort();
|
|
1918
|
-
}
|
|
1919
|
-
// ── Group State(群组状态快照) ─────────────────────────────
|
|
1920
|
-
async saveGroupState(groupId, state) {
|
|
1921
|
-
const key = encodePart(groupId);
|
|
1922
|
-
await idbPut(STORE_GROUP_STATE, key, {
|
|
1923
|
-
group_id: groupId,
|
|
1924
|
-
state_version: state.state_version,
|
|
1925
|
-
state_hash: state.state_hash,
|
|
1926
|
-
key_epoch: state.key_epoch,
|
|
1927
|
-
membership_json: state.membership_json,
|
|
1928
|
-
policy_json: state.policy_json,
|
|
1929
|
-
updated_at: state.updated_at ?? Date.now(),
|
|
1930
|
-
});
|
|
1931
|
-
}
|
|
1932
|
-
async loadGroupState(groupId) {
|
|
1933
|
-
const key = encodePart(groupId);
|
|
1934
|
-
const data = await idbGet(STORE_GROUP_STATE, key);
|
|
1935
|
-
if (!data || typeof data.state_version !== 'number')
|
|
1936
|
-
return null;
|
|
1937
|
-
return {
|
|
1938
|
-
group_id: groupId,
|
|
1939
|
-
state_version: data.state_version,
|
|
1940
|
-
state_hash: data.state_hash ?? '',
|
|
1941
|
-
key_epoch: data.key_epoch ?? 0,
|
|
1942
|
-
membership_json: data.membership_json ?? '[]',
|
|
1943
|
-
policy_json: data.policy_json ?? '{}',
|
|
1944
|
-
updated_at: data.updated_at ?? 0,
|
|
1945
|
-
};
|
|
1946
|
-
}
|
|
1947
|
-
// ── Trust Root Storage(信任根证书存储) ─────────────────
|
|
1948
|
-
// 浏览器环境无文件系统,统一存入 STORE_INSTANCE_STATE 并以特定前缀的 key 区分。
|
|
1949
|
-
// 对外返回的"路径"为虚拟标识(indexeddb://...),仅用于日志/兼容字段。
|
|
1950
|
-
/** 信任根列表 JSON 在 IndexedDB 中的 key */
|
|
1951
|
-
static _TRUST_LIST_KEY = '__trust_roots:list';
|
|
1952
|
-
/** 信任根 bundle PEM 在 IndexedDB 中的 key */
|
|
1953
|
-
static _TRUST_BUNDLE_KEY = '__trust_roots:bundle';
|
|
1954
|
-
/** 单个 root cert 在 IndexedDB 中的 key 前缀 */
|
|
1955
|
-
static _TRUST_CERT_PREFIX = '__trust_roots:cert:';
|
|
1956
|
-
/** 单个 issuer root cert 在 IndexedDB 中的 key 前缀 */
|
|
1957
|
-
static _TRUST_ISSUER_PREFIX = '__trust_roots:issuer:';
|
|
1958
|
-
/** 计算 PEM 证书的 SHA-256 指纹(hex,无冒号) */
|
|
1959
|
-
async _pemFingerprint(pem) {
|
|
1960
|
-
try {
|
|
1961
|
-
const der = new Uint8Array(pemToArrayBuffer(pem));
|
|
1962
|
-
const hash = await crypto.subtle.digest('SHA-256', der);
|
|
1963
|
-
return Array.from(new Uint8Array(hash))
|
|
1964
|
-
.map(b => b.toString(16).padStart(2, '0'))
|
|
1965
|
-
.join('');
|
|
1966
|
-
}
|
|
1967
|
-
catch {
|
|
1968
|
-
// 解析失败时退化为对 PEM 文本本身做哈希(仅用于去重,不暴露)
|
|
1969
|
-
const enc = new TextEncoder().encode(pem);
|
|
1970
|
-
const hash = await crypto.subtle.digest('SHA-256', enc);
|
|
1971
|
-
return Array.from(new Uint8Array(hash))
|
|
1972
|
-
.map(b => b.toString(16).padStart(2, '0'))
|
|
1973
|
-
.join('');
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
/** 拆分 bundle PEM 文本为单个 PEM 证书数组 */
|
|
1977
|
-
_splitPemBundle(bundleText) {
|
|
1978
|
-
return bundleText
|
|
1979
|
-
.split(/(?<=-----END CERTIFICATE-----)\s*/)
|
|
1980
|
-
.map(s => s.trim())
|
|
1981
|
-
.filter(s => s.startsWith('-----BEGIN CERTIFICATE-----'));
|
|
1982
|
-
}
|
|
1983
|
-
async saveTrustRoots(trustList, rootCerts) {
|
|
1984
|
-
// 保存每个根证书到独立 key
|
|
1985
|
-
for (let i = 0; i < rootCerts.length; i++) {
|
|
1986
|
-
const item = rootCerts[i];
|
|
1987
|
-
const certId = item.id || item.fingerprint_sha256 || `root-${i + 1}`;
|
|
1988
|
-
const safeName = certId.replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 120);
|
|
1989
|
-
await idbPut(STORE_INSTANCE_STATE, IndexedDBKeyStore._TRUST_CERT_PREFIX + safeName, item.cert_pem);
|
|
1990
|
-
}
|
|
1991
|
-
// 生成合并 bundle
|
|
1992
|
-
const bundle = rootCerts.map(i => i.cert_pem.trim()).join('\n') + '\n';
|
|
1993
|
-
await idbPut(STORE_INSTANCE_STATE, IndexedDBKeyStore._TRUST_BUNDLE_KEY, bundle);
|
|
1994
|
-
// 保存元数据 JSON
|
|
1995
|
-
await idbPut(STORE_INSTANCE_STATE, IndexedDBKeyStore._TRUST_LIST_KEY, deepClone(trustList));
|
|
1996
|
-
return 'indexeddb://trust-roots/bundle';
|
|
1997
|
-
}
|
|
1998
|
-
async saveIssuerRootCert(issuer, certPem, fingerprintSha256 = '') {
|
|
1999
|
-
const safeIssuer = (issuer || 'issuer').replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 120);
|
|
2000
|
-
const certKey = IndexedDBKeyStore._TRUST_ISSUER_PREFIX + safeIssuer;
|
|
2001
|
-
const normalizedPem = certPem.trim() + '\n';
|
|
2002
|
-
// 保存 issuer 根证书
|
|
2003
|
-
await idbPut(STORE_INSTANCE_STATE, certKey, normalizedPem);
|
|
2004
|
-
// 读取已有 bundle 并合并(按指纹去重)
|
|
2005
|
-
const existingPems = new Map();
|
|
2006
|
-
const existingBundle = await idbGet(STORE_INSTANCE_STATE, IndexedDBKeyStore._TRUST_BUNDLE_KEY);
|
|
2007
|
-
if (typeof existingBundle === 'string' && existingBundle) {
|
|
2008
|
-
for (const pem of this._splitPemBundle(existingBundle)) {
|
|
2009
|
-
const fp = await this._pemFingerprint(pem);
|
|
2010
|
-
existingPems.set(fp, pem);
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
// 新证书的指纹
|
|
2014
|
-
let newFp = await this._pemFingerprint(normalizedPem);
|
|
2015
|
-
if (fingerprintSha256) {
|
|
2016
|
-
newFp = fingerprintSha256.toLowerCase().replace(/^sha256:/, '');
|
|
2017
|
-
}
|
|
2018
|
-
existingPems.set(newFp, normalizedPem);
|
|
2019
|
-
// 重写 bundle
|
|
2020
|
-
const merged = Array.from(existingPems.values()).map(p => p.trim()).join('\n') + '\n';
|
|
2021
|
-
await idbPut(STORE_INSTANCE_STATE, IndexedDBKeyStore._TRUST_BUNDLE_KEY, merged);
|
|
2022
|
-
return [`indexeddb://trust-roots/issuers/${safeIssuer}`, 'indexeddb://trust-roots/bundle'];
|
|
2023
|
-
}
|
|
2024
|
-
async loadTrustRoots() {
|
|
2025
|
-
const data = await idbGet(STORE_INSTANCE_STATE, IndexedDBKeyStore._TRUST_LIST_KEY);
|
|
2026
|
-
if (!isRecord(data))
|
|
2027
|
-
return null;
|
|
2028
|
-
return deepClone(data);
|
|
2029
|
-
}
|
|
2030
|
-
}
|
|
2031
|
-
//# sourceMappingURL=indexeddb.js.map
|