@tavrn/e2ee 0.0.0-stage → 0.2.1
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/LICENSE +373 -0
- package/README.md +269 -2
- package/package.json +39 -3
- package/src/account.ts +457 -0
- package/src/cleanup.ts +336 -0
- package/src/commands.ts +95 -0
- package/src/components.ts +155 -0
- package/src/content.ts +152 -0
- package/src/embeds.ts +148 -0
- package/src/errors.ts +31 -0
- package/src/files.ts +346 -0
- package/src/history.ts +710 -0
- package/src/identity.ts +216 -0
- package/src/index.ts +75 -0
- package/src/keystore.ts +126 -0
- package/src/lru.ts +113 -0
- package/src/mls.ts +2617 -0
- package/src/node.ts +3 -0
- package/src/plaintext.ts +141 -0
- package/src/poll.ts +434 -0
- package/src/sealed.ts +64 -0
- package/src/search/backend.ts +82 -0
- package/src/search/contract.ts +126 -0
- package/src/search/index.ts +6 -0
- package/src/search/keys.ts +108 -0
- package/src/search/searchIndex.ts +844 -0
- package/src/search/tokenize.ts +294 -0
- package/src/session.ts +172 -0
- package/src/storage/file.ts +273 -0
- package/src/storage/memory.ts +95 -0
- package/src/tabs.ts +367 -0
- package/src/trust.ts +129 -0
- package/src/wasm.ts +38 -0
package/src/mls.ts
ADDED
|
@@ -0,0 +1,2617 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* MLS session manager (ENCRYPTION.md §4–§7), shared by the web client and bots: one `Client` per account and device,
|
|
4
|
+
* persisted after every mutating call. It joins conversations (create, external commit, Welcome),
|
|
5
|
+
* catches up with the handshake log, runs the two-phase commit protocol (§4.3), encrypts and decrypts
|
|
6
|
+
* message content (§5) with replay detection, checks the roster for ghost devices (§4.6), verifies
|
|
7
|
+
* device attestations against pinned identity keys (§3.3) and keeps the history backup (§7) in sync.
|
|
8
|
+
* It also signs and verifies plaintext messages with the device key and keeps the verified contacts
|
|
9
|
+
* (FEDERATION.md §10).
|
|
10
|
+
*
|
|
11
|
+
* Across nodes (FEDERATION.md §3.5, §3.6, §6) every conversation is addressed by its **qualified id**
|
|
12
|
+
* `<node domain>/<ulid>`, the domain being the node that hosts it: group info, commits, the handshake
|
|
13
|
+
* log, Welcomes and KeyPackage claims go to that node (with the federated session there); identity keys
|
|
14
|
+
* and device attestations of any user always come from that user's own home node; backups stay on this
|
|
15
|
+
* user's home node.
|
|
16
|
+
*
|
|
17
|
+
* The node API is injected ({@link MlsApi}) so that tests can run several clients against in-memory
|
|
18
|
+
* delivery services of several nodes; persistence is injected too ({@link MlsDeps}, or `openMlsManager`
|
|
19
|
+
* for a `KeyStore`).
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
Client,
|
|
23
|
+
decryptContentWithKey,
|
|
24
|
+
safetyNumber,
|
|
25
|
+
verifyDeviceAttestation,
|
|
26
|
+
verifyDeviceShareKey,
|
|
27
|
+
verifyPayload,
|
|
28
|
+
verifyPayloadMatches,
|
|
29
|
+
verifyPlaintext,
|
|
30
|
+
type CommitOutput,
|
|
31
|
+
} from "@tavrn/crypto";
|
|
32
|
+
import {
|
|
33
|
+
TavrnApiError,
|
|
34
|
+
b64urlDecode,
|
|
35
|
+
b64urlEncode,
|
|
36
|
+
jcs,
|
|
37
|
+
type BackupList,
|
|
38
|
+
type ClaimedKeyPackages,
|
|
39
|
+
type CommitRequest,
|
|
40
|
+
type EpochResponse,
|
|
41
|
+
type GroupInfoResponse,
|
|
42
|
+
type LogPage,
|
|
43
|
+
type UserKeys,
|
|
44
|
+
type WelcomeList,
|
|
45
|
+
} from "@tavrn/sdk";
|
|
46
|
+
import { chainKeys, chainReaches } from "./identity";
|
|
47
|
+
import { LruMap, fingerprint } from "./lru";
|
|
48
|
+
import { envelopeFields, envelopeSnapshot, sameSnapshot, signatureNonce, signedInputDigest, type PlainEnvelope, type PlainStatus, type SignedFields } from "./plaintext";
|
|
49
|
+
import { assertEmbeds, withValidEmbeds } from "./embeds";
|
|
50
|
+
import { sealedContent, webhookPayload, type SealedBy, type SealedContent } from "./sealed";
|
|
51
|
+
import { TRUST_BACKUP_ULID, mergeTrust, openTrust, sealTrust, type TrustRecord } from "./trust";
|
|
52
|
+
|
|
53
|
+
/** Delivery-service routes of the node that hosts a conversation (ENCRYPTION.md §4.4); ids are bare ULIDs. */
|
|
54
|
+
export interface MlsNodeApi {
|
|
55
|
+
groupInfo(conversationId: string): Promise<GroupInfoResponse>;
|
|
56
|
+
createGroup(conversationId: string, groupInfo: string): Promise<EpochResponse>;
|
|
57
|
+
postCommit(conversationId: string, req: CommitRequest): Promise<EpochResponse>;
|
|
58
|
+
log(conversationId: string, sinceEpoch: number): Promise<LogPage>;
|
|
59
|
+
/** Welcomes for this device that wait on this node. */
|
|
60
|
+
welcomes(): Promise<WelcomeList>;
|
|
61
|
+
deleteWelcome(id: string): Promise<void>;
|
|
62
|
+
/** KeyPackages of `address`, claimed through this node (its claim proxy for users of other nodes, §3.5). */
|
|
63
|
+
claim(address: string): Promise<ClaimedKeyPackages>;
|
|
64
|
+
/** `PUT …/inbox-key` (write-only webhooks, ENCRYPTION.md §5b); without it no inbox keys are published. */
|
|
65
|
+
publishInboxKey?(conversationId: string, req: { epoch: number; public_key: string; signature: string }): Promise<unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The routes the manager needs (see {@link mlsApiOf}). */
|
|
69
|
+
export interface MlsApi {
|
|
70
|
+
/** The delivery service of node `domain`, with this user's session on that node. */
|
|
71
|
+
node(domain: string): MlsNodeApi;
|
|
72
|
+
/**
|
|
73
|
+
* Public keys of `address`, fetched from that user's **home node** (FEDERATION.md §3.5: clients never
|
|
74
|
+
* rely on the view of the node that hosts a conversation).
|
|
75
|
+
*/
|
|
76
|
+
keys(address: string): Promise<UserKeys>;
|
|
77
|
+
/** Backups on this user's home node (§7). */
|
|
78
|
+
backupList(): Promise<BackupList>;
|
|
79
|
+
backupGet(domain: string, ulid: string): Promise<{ blob: Uint8Array; etag: string } | null>;
|
|
80
|
+
backupPut(domain: string, ulid: string, blob: Uint8Array, etag: string | null): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The parts of an SDK client that one node's delivery service needs. */
|
|
84
|
+
export interface MlsNodeClient {
|
|
85
|
+
mls: {
|
|
86
|
+
groupInfo: MlsNodeApi["groupInfo"];
|
|
87
|
+
createGroup: (c: string, r: { group_info: string }) => Promise<EpochResponse>;
|
|
88
|
+
postCommit: MlsNodeApi["postCommit"];
|
|
89
|
+
log: (c: string, s: number) => Promise<LogPage>;
|
|
90
|
+
welcomes: MlsNodeApi["welcomes"];
|
|
91
|
+
deleteWelcome: (id: string) => Promise<void>;
|
|
92
|
+
publishInboxKey?: (c: string, r: { epoch: number; public_key: string; signature: string }) => Promise<unknown>;
|
|
93
|
+
};
|
|
94
|
+
keys: { claim: (user: string) => Promise<ClaimedKeyPackages> };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Adapter from the SDK: `home` is the client of this user's home node (backups), `node(domain)` the
|
|
99
|
+
* client of any node (the home client or a federated session), `keys(address)` the public key lookup on
|
|
100
|
+
* the address's home node.
|
|
101
|
+
*/
|
|
102
|
+
export function mlsApiOf(deps: {
|
|
103
|
+
home: { backups: { list: MlsApi["backupList"]; get: MlsApi["backupGet"]; put: (d: string, u: string, b: Uint8Array, e: string | null) => Promise<unknown> } };
|
|
104
|
+
node: (domain: string) => MlsNodeClient;
|
|
105
|
+
keys: (address: string) => Promise<UserKeys>;
|
|
106
|
+
}): MlsApi {
|
|
107
|
+
return {
|
|
108
|
+
node: (domain) => {
|
|
109
|
+
const api = deps.node(domain);
|
|
110
|
+
return {
|
|
111
|
+
groupInfo: (c) => api.mls.groupInfo(c),
|
|
112
|
+
createGroup: (c, gi) => api.mls.createGroup(c, { group_info: gi }),
|
|
113
|
+
postCommit: (c, r) => api.mls.postCommit(c, r),
|
|
114
|
+
log: (c, s) => api.mls.log(c, s),
|
|
115
|
+
welcomes: () => api.mls.welcomes(),
|
|
116
|
+
deleteWelcome: (id) => api.mls.deleteWelcome(id),
|
|
117
|
+
claim: (address) => api.keys.claim(address),
|
|
118
|
+
publishInboxKey: (c, r) => (api.mls.publishInboxKey ? api.mls.publishInboxKey(c, r) : Promise.resolve(undefined)),
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
keys: (address) => deps.keys(address),
|
|
122
|
+
backupList: () => deps.home.backups.list(),
|
|
123
|
+
backupGet: (d, u) => deps.home.backups.get(d, u),
|
|
124
|
+
backupPut: async (d, u, b, e) => {
|
|
125
|
+
await deps.home.backups.put(d, u, b, e);
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
131
|
+
const DOMAIN = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The leaf of a serialized KeyPackage (an `MLSMessage` with wire format `mls_key_package`, RFC 9420 §6, §10): its
|
|
135
|
+
* basic credential's identity and its signature key. Null for anything else. Read before an Add so that a claimed
|
|
136
|
+
* KeyPackage can be held against the attested device it claims to be (§3.3); the KeyPackage's own signature is
|
|
137
|
+
* checked by the MLS library when it is added.
|
|
138
|
+
*/
|
|
139
|
+
export function keyPackageLeaf(bytes: Uint8Array): { identity: string; signatureKey: Uint8Array } | null {
|
|
140
|
+
let i = 0;
|
|
141
|
+
const u16 = (): number => {
|
|
142
|
+
if (i + 2 > bytes.length) throw new RangeError("short");
|
|
143
|
+
const v = (bytes[i]! << 8) | bytes[i + 1]!;
|
|
144
|
+
i += 2;
|
|
145
|
+
return v;
|
|
146
|
+
};
|
|
147
|
+
const vec = (): Uint8Array => {
|
|
148
|
+
if (i >= bytes.length) throw new RangeError("short");
|
|
149
|
+
const prefix = bytes[i]! >> 6;
|
|
150
|
+
if (prefix > 2) throw new RangeError("varint");
|
|
151
|
+
const n = 1 << prefix;
|
|
152
|
+
if (i + n > bytes.length) throw new RangeError("short");
|
|
153
|
+
let len = bytes[i]! & 0x3f;
|
|
154
|
+
for (let k = 1; k < n; k++) len = len * 256 + bytes[i + k]!;
|
|
155
|
+
i += n;
|
|
156
|
+
if (i + len > bytes.length) throw new RangeError("short");
|
|
157
|
+
const out = bytes.slice(i, i + len);
|
|
158
|
+
i += len;
|
|
159
|
+
return out;
|
|
160
|
+
};
|
|
161
|
+
try {
|
|
162
|
+
if (u16() !== 1 || u16() !== 5) return null; // mls10, mls_key_package
|
|
163
|
+
if (u16() !== 1) return null; // KeyPackage.version
|
|
164
|
+
u16(); // cipher suite
|
|
165
|
+
vec(); // init_key
|
|
166
|
+
vec(); // leaf_node.encryption_key
|
|
167
|
+
const signatureKey = vec();
|
|
168
|
+
if (u16() !== 1) return null; // credential type basic
|
|
169
|
+
const identity = new TextDecoder("utf-8", { fatal: true }).decode(vec());
|
|
170
|
+
return { identity, signatureKey };
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Qualified conversation id `<domain>/<ulid>` (ENCRYPTION.md §0.1), also the MLS group id. */
|
|
177
|
+
export function qualify(domain: string, conversationId: string): string {
|
|
178
|
+
return `${domain}/${conversationId}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Splits a qualified conversation id; malformed ids are rejected (§0.1). */
|
|
182
|
+
export function splitQualified(qualified: string): { domain: string; id: string } {
|
|
183
|
+
const slash = qualified.indexOf("/");
|
|
184
|
+
const domain = qualified.slice(0, slash);
|
|
185
|
+
const id = qualified.slice(slash + 1);
|
|
186
|
+
if (slash < 1 || !DOMAIN.test(domain) || !ULID.test(id)) throw new MlsError("invalid_id");
|
|
187
|
+
return { domain, id };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A pinned identity key (TOFU, §3.3); `changed` until the user acknowledged the "security key changed" warning,
|
|
192
|
+
* `rotated` until the soft notice of a rotation with a valid continuity statement was dismissed (§9a).
|
|
193
|
+
*/
|
|
194
|
+
export interface Pin {
|
|
195
|
+
key: string;
|
|
196
|
+
changed?: boolean;
|
|
197
|
+
rotated?: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* A contact whose safety number was compared (FEDERATION.md §10.2), stored per device in the encrypted
|
|
202
|
+
* local store next to the pins.
|
|
203
|
+
*/
|
|
204
|
+
export interface VerifiedContact {
|
|
205
|
+
address: string;
|
|
206
|
+
/** The identity key (base64url) that was verified. */
|
|
207
|
+
identity_public_key: string;
|
|
208
|
+
verified_at: string;
|
|
209
|
+
method: "qr" | "numeric";
|
|
210
|
+
/** The key the verified one was rotated to with a valid continuity statement (ENCRYPTION.md §9a). */
|
|
211
|
+
rotated_to?: string;
|
|
212
|
+
/** The user dismissed the "verification needs a refresh" notice. */
|
|
213
|
+
refresh_dismissed?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `verified`: the pinned key is the verified one; `refresh`: the verified key was rotated with a valid continuity
|
|
218
|
+
* statement (not blocking, verify again); `broken`: the key changed after the verification without one.
|
|
219
|
+
*/
|
|
220
|
+
export type VerificationState = "verified" | "refresh" | "broken" | "unverified";
|
|
221
|
+
|
|
222
|
+
/** Small per-account bookkeeping, stored encrypted next to the client state. */
|
|
223
|
+
export interface MlsMeta {
|
|
224
|
+
/** Backups of all conversations were downloaded on this device (§8 step 3). */
|
|
225
|
+
restored?: boolean;
|
|
226
|
+
/** Per qualified conversation id: last own commit (rotation) and whether a rotation is due after a removal. */
|
|
227
|
+
convs: Record<string, { rotatedAt: number; rotate?: boolean }>;
|
|
228
|
+
/** Conversations with epoch keys not uploaded yet (survives a reload before the debounced upload). */
|
|
229
|
+
pendingBackups?: string[];
|
|
230
|
+
/**
|
|
231
|
+
* Own devices this account attested (device id → base64url device key): this device and every own device whose
|
|
232
|
+
* attestation verified under the own identity key, merged with the trust record (`trust.ts`). Only these keys are
|
|
233
|
+
* ever attested again as retired (ENCRYPTION.md §4.7).
|
|
234
|
+
*/
|
|
235
|
+
ownDevices?: Record<string, string>;
|
|
236
|
+
/** The trust record was synced with the home node once on this device (first-use pins wait for it). */
|
|
237
|
+
trustSynced?: boolean;
|
|
238
|
+
/** Users pinned on first use before that first sync: their pins are checked against the record (§7). */
|
|
239
|
+
unconfirmedPins?: string[];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Older versions kept bare conversation ids of the home node in the meta data: qualify them (in place).
|
|
244
|
+
*/
|
|
245
|
+
export function normalizeMeta(meta: MlsMeta, homeDomain: string): MlsMeta {
|
|
246
|
+
const q = (id: string) => (id.includes("/") ? id : qualify(homeDomain, id));
|
|
247
|
+
meta.convs = Object.fromEntries(Object.entries(meta.convs ?? {}).map(([k, v]) => [q(k), v]));
|
|
248
|
+
if (meta.pendingBackups) meta.pendingBackups = [...new Set(meta.pendingBackups.map(q))];
|
|
249
|
+
return meta;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface MlsDeps {
|
|
253
|
+
api: MlsApi;
|
|
254
|
+
client: Client;
|
|
255
|
+
/** Own user address `name@domain`. */
|
|
256
|
+
address: string;
|
|
257
|
+
deviceId: string;
|
|
258
|
+
/** Domain of this user's home node. */
|
|
259
|
+
homeDomain: string;
|
|
260
|
+
/** Persists the client state (call after every mutation). */
|
|
261
|
+
persist(client: Client): Promise<void>;
|
|
262
|
+
/** The 32-byte backup key (fresh copy; the manager zeroes it after use). */
|
|
263
|
+
backupKey(): Uint8Array;
|
|
264
|
+
/** Own identity public key (base64url): own devices are verified against it instead of TOFU. */
|
|
265
|
+
ownIdentityKey(): string;
|
|
266
|
+
pins: Record<string, Pin>;
|
|
267
|
+
savePins(pins: Record<string, Pin>): Promise<void>;
|
|
268
|
+
meta: MlsMeta;
|
|
269
|
+
saveMeta(meta: MlsMeta): Promise<void>;
|
|
270
|
+
/** Verified contacts by address (§10.2); empty if omitted. */
|
|
271
|
+
verified?: Record<string, VerifiedContact>;
|
|
272
|
+
saveVerified?(verified: Record<string, VerifiedContact>): Promise<void>;
|
|
273
|
+
/** Notifications for the UI. */
|
|
274
|
+
onEvent?(e: MlsEvent): void;
|
|
275
|
+
/**
|
|
276
|
+
* A background write of the bookkeeping failed ({@link MlsBackgroundError}); nothing waits for these writes, so this
|
|
277
|
+
* is where they are reported. Without it they are logged with `console.error`.
|
|
278
|
+
*/
|
|
279
|
+
onError?(e: MlsBackgroundError): void;
|
|
280
|
+
/** Debounce of the backup upload in milliseconds (default 1000). */
|
|
281
|
+
backupDelayMs?: number;
|
|
282
|
+
/** Clock (tests). */
|
|
283
|
+
now?(): number;
|
|
284
|
+
/**
|
|
285
|
+
* Coordination with other tabs of the same browser profile (see `tabs.ts`, wired by `openMlsManager`): while
|
|
286
|
+
* another tab owns the state, every state-changing operation runs there; this manager reads from a copy.
|
|
287
|
+
*/
|
|
288
|
+
tabs?: MlsTabLink;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Operations a tab forwards to the tab that owns the MLS state (everything that changes the client state). */
|
|
292
|
+
export const FORWARDED_OPERATIONS = [
|
|
293
|
+
"ensureJoined",
|
|
294
|
+
"processWelcomes",
|
|
295
|
+
"catchUp",
|
|
296
|
+
"selfUpdate",
|
|
297
|
+
"removeLeaves",
|
|
298
|
+
"addMissingDevices",
|
|
299
|
+
"encrypt",
|
|
300
|
+
"acknowledgeGhosts",
|
|
301
|
+
"encryptHistory",
|
|
302
|
+
"importHistoryKeys",
|
|
303
|
+
"publishInboxKey",
|
|
304
|
+
"setInboxWanted",
|
|
305
|
+
"flushBackups",
|
|
306
|
+
"restoreBackups",
|
|
307
|
+
"restoreBackup",
|
|
308
|
+
"createKeyPackages",
|
|
309
|
+
] as const;
|
|
310
|
+
export type ForwardedOperation = (typeof FORWARDED_OPERATIONS)[number];
|
|
311
|
+
|
|
312
|
+
/** The manager's side of the tab coordination. */
|
|
313
|
+
export interface MlsTabLink {
|
|
314
|
+
/** Whether this manager runs state-changing operations itself (it owns the state and has loaded it). */
|
|
315
|
+
local(): boolean;
|
|
316
|
+
/** Runs the operation in the owning tab; resolves once this tab's copy of the state is current. */
|
|
317
|
+
forward(operation: ForwardedOperation, args: unknown[]): Promise<unknown>;
|
|
318
|
+
/** Leaves the coordination (dispose). */
|
|
319
|
+
close(): void;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* A failed background write: `save_meta` (the bookkeeping, {@link MlsMeta}). `consecutive` counts the failures in a row
|
|
324
|
+
* (a later success resets it): a caller that cannot run without its state stops after a few.
|
|
325
|
+
*/
|
|
326
|
+
export interface MlsBackgroundError {
|
|
327
|
+
operation: "save_meta";
|
|
328
|
+
error: unknown;
|
|
329
|
+
consecutive: number;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Conversations are named by their qualified id. */
|
|
333
|
+
export type MlsEvent =
|
|
334
|
+
| { type: "epoch"; conversationId: string }
|
|
335
|
+
| { type: "rejoined"; conversationId: string }
|
|
336
|
+
/** This device committed Adds of these users' devices (history sharing, ENCRYPTION.md §7b). */
|
|
337
|
+
| { type: "added"; conversationId: string; addresses: string[] }
|
|
338
|
+
| { type: "removed"; conversationId: string }
|
|
339
|
+
| { type: "key_changed"; address: string }
|
|
340
|
+
/** The key was rotated with a valid continuity statement and pinned (soft notice, ENCRYPTION.md §9a). */
|
|
341
|
+
| { type: "key_rotated"; address: string }
|
|
342
|
+
| { type: "verification"; address: string };
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Why a message could not be shown (rendered as a neutral placeholder, never as content). `retry`: the author's
|
|
346
|
+
* devices could not be looked up (network, the author's node unreachable, a rate limit); not cached, the next
|
|
347
|
+
* decrypt tries again.
|
|
348
|
+
*/
|
|
349
|
+
export type DecryptFailure = "missing_key" | "bad_signature" | "unknown_device" | "replay" | "invalid" | "locked" | "envelope_mismatch" | "retry";
|
|
350
|
+
/**
|
|
351
|
+
* `retired`: sent from a device that was deleted later; verified with its retired attestation (ENCRYPTION.md §5.2).
|
|
352
|
+
* `sealedBy`: a sealed webhook message (APPLICATIONS.md §6.1a) with the per-message `webhookName` from inside the box.
|
|
353
|
+
*/
|
|
354
|
+
export type Decrypted =
|
|
355
|
+
| { ok: true; content: Record<string, unknown>; retired?: true; sealedBy?: SealedBy; webhookName?: string }
|
|
356
|
+
| { ok: false; reason: DecryptFailure };
|
|
357
|
+
|
|
358
|
+
/** `type` of an encrypted thread title (PROTOCOL.md §7e). */
|
|
359
|
+
const THREAD_TITLE_TYPE = "thread.title";
|
|
360
|
+
/** `type` of a poll vote record (PROTOCOL.md §7g): never a message, never anything but a vote. */
|
|
361
|
+
const POLL_VOTE_TYPE = "poll.vote";
|
|
362
|
+
/** Content types that are never a message or an event: history shares (§7b) travel only through their own route. */
|
|
363
|
+
const NOT_CONTENT_TYPES = new Set(["history_keys"]);
|
|
364
|
+
|
|
365
|
+
/** Longest thread title in characters (PROTOCOL.md §7e). */
|
|
366
|
+
export const THREAD_TITLE_MAX = 100;
|
|
367
|
+
|
|
368
|
+
/** Messages of a retired device count until this long after its deletion (clock skew, messages in flight). */
|
|
369
|
+
export const RETIRED_SLACK_MS = 5 * 60 * 1000;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* `removed`: an `unattested` leaf whose device the node reports as deleted (cleanup pending, ENCRYPTION.md §4.7).
|
|
373
|
+
* `bot`: a leaf of a bot (or of an account whose `kind` this client does not know) in a conversation whose effective
|
|
374
|
+
* "no bots" flag is set (APPLICATIONS.md §3.5, ENCRYPTION.md §4.6).
|
|
375
|
+
* `key_changed`: a leaf attested under a changed identity key whose warning the user has not acknowledged yet (§3.3).
|
|
376
|
+
*/
|
|
377
|
+
export type GhostReason = "not_member" | "unattested" | "duplicate" | "removed" | "bot" | "key_changed";
|
|
378
|
+
|
|
379
|
+
/** Options of {@link MlsManager.ghostCheck}. */
|
|
380
|
+
export interface GhostCheckOptions {
|
|
381
|
+
/** Look the users' devices up again before judging (before acting on a violation). */
|
|
382
|
+
fresh?: boolean;
|
|
383
|
+
/** The conversation's effective `no_bots` flag (channels; DMs never have it): bot leaves are ghosts. */
|
|
384
|
+
noBots?: boolean;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Whether a `kind` of a keys document counts as a bot: `bot` and every kind this client does not know (§1.5). */
|
|
388
|
+
export function isBotKind(kind: string | null | undefined): boolean {
|
|
389
|
+
return kind != null && kind !== "user";
|
|
390
|
+
}
|
|
391
|
+
export interface GhostViolation {
|
|
392
|
+
identity: string;
|
|
393
|
+
reason: GhostReason;
|
|
394
|
+
/**
|
|
395
|
+
* The leaf's signature key (base64url): an acknowledgement covers this leaf only, so a later leaf under the same
|
|
396
|
+
* device id with another key warns again (§4.6).
|
|
397
|
+
*/
|
|
398
|
+
key: string;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** A leaf to acknowledge: a violation with its key, or an identity (its leaves in the roster at that moment). */
|
|
402
|
+
export type GhostLeafRef = string | Pick<GhostViolation, "identity" | "key">;
|
|
403
|
+
|
|
404
|
+
/** The parts of a message envelope the codec needs. */
|
|
405
|
+
export interface EncryptedEnvelope {
|
|
406
|
+
id: string;
|
|
407
|
+
author: string;
|
|
408
|
+
device_id?: string | null;
|
|
409
|
+
epoch?: number | null;
|
|
410
|
+
content: unknown;
|
|
411
|
+
/** Server time of the message: messages of retired devices are accepted only from before their deletion. */
|
|
412
|
+
created_at?: string | null;
|
|
413
|
+
/** Set on webhook messages (sealed ones carry `content.type = "webhook.sealed"`). */
|
|
414
|
+
webhook?: unknown;
|
|
415
|
+
/**
|
|
416
|
+
* The envelope's thread and reply (null = none). Compared with the signed context of the content (ENCRYPTION.md
|
|
417
|
+
* §5.2); `undefined` (a caller that does not know the envelope) is not compared.
|
|
418
|
+
*/
|
|
419
|
+
thread_id?: string | null;
|
|
420
|
+
reply_to_id?: string | null;
|
|
421
|
+
/**
|
|
422
|
+
* The interaction an application's answer belongs to (`interaction.id` of a stored message, the `interaction_id` of
|
|
423
|
+
* an ephemeral reply; null = none), compared with `ctx.interaction` (APPLICATIONS.md §5.7); `undefined`: unknown.
|
|
424
|
+
*/
|
|
425
|
+
interaction?: { id: string } | null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The envelope fields a message's content signs as `ctx` (ENCRYPTION.md §5.2, "Signed context"). */
|
|
429
|
+
export interface MessageContext {
|
|
430
|
+
thread_id: string | null;
|
|
431
|
+
reply_to: string | null;
|
|
432
|
+
/** Answers of applications: the interaction they answer (APPLICATIONS.md §5.7). */
|
|
433
|
+
interaction?: string;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Options of {@link MlsManager.encrypt}. */
|
|
437
|
+
export interface EncryptOptions {
|
|
438
|
+
/** A message's signed context: the thread and the reply the envelope will carry. */
|
|
439
|
+
ctx?: MessageContext;
|
|
440
|
+
/**
|
|
441
|
+
* The ghost check after the catch-up (§4.6): the conversation's members as the caller knows them and its effective
|
|
442
|
+
* "no bots" flag. Without them the members of this manager's last {@link MlsManager.ghostCheck} are used, and
|
|
443
|
+
* without such a check every leaf's user counts as a member (only attestation is judged).
|
|
444
|
+
*/
|
|
445
|
+
members?: string[];
|
|
446
|
+
noBots?: boolean;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const ENVELOPE_MISMATCH: Decrypted = Object.freeze({ ok: false, reason: "envelope_mismatch" }) as Decrypted;
|
|
450
|
+
|
|
451
|
+
/** A cached decryption result and the envelope fields it was decrypted for. */
|
|
452
|
+
interface DecryptCacheEntry {
|
|
453
|
+
q: string;
|
|
454
|
+
author: string;
|
|
455
|
+
deviceId: string | null;
|
|
456
|
+
epoch: number | null;
|
|
457
|
+
fp: string;
|
|
458
|
+
result: Decrypted;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The decrypt cache key: purpose, conversation, message id and the sender fields the ciphertext is bound to (author,
|
|
463
|
+
* device, epoch), so the result of one envelope never answers for an envelope that names another sender under the
|
|
464
|
+
* same id. JSON keeps the parts apart whatever characters they contain.
|
|
465
|
+
*/
|
|
466
|
+
function decryptCacheKey(purpose: string, q: string, m: EncryptedEnvelope): string {
|
|
467
|
+
return JSON.stringify([purpose, q, m.id, m.author, m.device_id ?? null, m.epoch ?? null]);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function cacheEntry(q: string, m: EncryptedEnvelope, fp: string, result: Decrypted): DecryptCacheEntry {
|
|
471
|
+
return { q, author: m.author, deviceId: m.device_id ?? null, epoch: m.epoch ?? null, fp, result };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** A result that stays: not a missing key (it may arrive) and not a failed device lookup (it may work next time). */
|
|
475
|
+
function isFinal(r: Decrypted): boolean {
|
|
476
|
+
return r.ok || (r.reason !== "missing_key" && r.reason !== "retry");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The `ctx` of a content object: null when it is not exactly `{ thread_id, reply_to }` of strings or nulls, plus the
|
|
481
|
+
* string `interaction` on answers of applications.
|
|
482
|
+
*/
|
|
483
|
+
function parseContext(v: unknown): MessageContext | null {
|
|
484
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
|
|
485
|
+
const o = v as Record<string, unknown>;
|
|
486
|
+
const keys = Object.keys(o);
|
|
487
|
+
const ok = (x: unknown) => x === null || typeof x === "string";
|
|
488
|
+
const withInteraction = "interaction" in o;
|
|
489
|
+
if (withInteraction && typeof o.interaction !== "string") return null;
|
|
490
|
+
if (keys.length !== (withInteraction ? 3 : 2) || !("thread_id" in o) || !("reply_to" in o) || !ok(o.thread_id) || !ok(o.reply_to)) return null;
|
|
491
|
+
const ctx: MessageContext = { thread_id: o.thread_id as string | null, reply_to: o.reply_to as string | null };
|
|
492
|
+
if (withInteraction) ctx.interaction = o.interaction as string;
|
|
493
|
+
return ctx;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** The `ctx` of a thread title: `thread_id` when it is exactly `{ thread_id: string }`, null otherwise (§5.2). */
|
|
497
|
+
function parseTitleContext(v: unknown): string | null {
|
|
498
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return null;
|
|
499
|
+
const o = v as Record<string, unknown>;
|
|
500
|
+
return Object.keys(o).length === 1 && typeof o.thread_id === "string" ? o.thread_id : null;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export class MlsError extends Error {
|
|
504
|
+
/** `owner_unavailable`: the tab that owns the state did not answer in time (or left twice), see `tabs.ts`. */
|
|
505
|
+
/**
|
|
506
|
+
* `catch_up_failed`: the handshake log could not be processed before encrypting, nothing was sent (retry).
|
|
507
|
+
* `ghost`: commits processed before encrypting brought a ghost member the user was not shown yet (§4.6).
|
|
508
|
+
*/
|
|
509
|
+
constructor(readonly code: "no_group" | "conflict" | "not_joined" | "invalid_id" | "owner_unavailable" | "catch_up_failed" | "ghost") {
|
|
510
|
+
super(code);
|
|
511
|
+
this.name = "MlsError";
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** A GroupInfo request started ahead of time (`ensureJoined`): its answer or its error. */
|
|
516
|
+
type GroupInfoAnswer = { ok: GroupInfoResponse } | { error: unknown };
|
|
517
|
+
|
|
518
|
+
const ROTATE_AFTER_MS = 7 * 24 * 3600 * 1000;
|
|
519
|
+
/** Most KeyPackages {@link MlsManager.createKeyPackages} creates at once. */
|
|
520
|
+
export const MAX_KEY_PACKAGES = 100;
|
|
521
|
+
const MAX_TRIES = 3;
|
|
522
|
+
/**
|
|
523
|
+
* A self update right after a complete catch-up (a voice join: `ensureJoined`, then `selfUpdate`) does not fetch the
|
|
524
|
+
* handshake log again within this time; a commit that missed one still lands after its epoch conflict.
|
|
525
|
+
*/
|
|
526
|
+
export const CAUGHT_UP_REUSE_MS = 3_000;
|
|
527
|
+
/** A forced lookup of a user's devices reuses a result younger than this. */
|
|
528
|
+
const KEY_REFETCH_MS = 2_000;
|
|
529
|
+
/** Device lists are looked up again after this (the key cache TTL of nodes, FEDERATION.md §3.5). */
|
|
530
|
+
export const KEY_TTL_MS = 3600 * 1000;
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Bounds of a keys document (`GET /users/{username}/keys`) the client checks: attested devices (the node's own cap
|
|
534
|
+
* for other nodes' users), retired devices (what a node lists at most) and continuity statements (a key is rotated
|
|
535
|
+
* rarely). Only a hostile home node serves more; every entry costs signature checks, so such a document is refused as
|
|
536
|
+
* a whole ({@link KeysDocumentError}) before anything in it is checked.
|
|
537
|
+
*/
|
|
538
|
+
export const KEYS_MAX_DEVICES = 100;
|
|
539
|
+
export const KEYS_MAX_RETIRED = 1000;
|
|
540
|
+
export const KEYS_MAX_CONTINUITY = 100;
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* A keys document beyond {@link KEYS_MAX_DEVICES}, {@link KEYS_MAX_RETIRED} or {@link KEYS_MAX_CONTINUITY}: none of
|
|
544
|
+
* its devices is verified. The refusal is kept like a lookup ({@link KEY_TTL_MS}), so the document is not fetched and
|
|
545
|
+
* refused again for every message.
|
|
546
|
+
*/
|
|
547
|
+
export class KeysDocumentError extends Error {
|
|
548
|
+
constructor(
|
|
549
|
+
readonly address: string,
|
|
550
|
+
readonly detail: string,
|
|
551
|
+
) {
|
|
552
|
+
super(`The keys document of ${address} was refused: ${detail}`);
|
|
553
|
+
this.name = "KeysDocumentError";
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/** A failed trust sync before a first-use pin is tried again after this. */
|
|
557
|
+
export const TRUST_RETRY_MS = 60_000;
|
|
558
|
+
/** Ephemeral events whose nonces are remembered for replay detection. */
|
|
559
|
+
export const EPHEMERAL_REPLAY_WINDOW = 4096;
|
|
560
|
+
/** Decrypted messages (and checked plaintext signatures) kept for the session, the least recently used go first. */
|
|
561
|
+
export const DECRYPT_CACHE_LIMIT = 5000;
|
|
562
|
+
/**
|
|
563
|
+
* Message nonces remembered for replay detection (§5.2, FEDERATION.md §10.3), the least recently used go first: far
|
|
564
|
+
* more than a session shows at once, so a replayed copy is caught next to its original.
|
|
565
|
+
*/
|
|
566
|
+
export const NONCE_WINDOW = 50_000;
|
|
567
|
+
|
|
568
|
+
function apiCode(e: unknown): string | undefined {
|
|
569
|
+
return e instanceof TavrnApiError ? e.code : undefined;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function cryptoCode(e: unknown): string {
|
|
573
|
+
return e instanceof Error ? e.name : "unknown";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
577
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Splits a device identity `user@domain/DEVICE` into address and device id. */
|
|
581
|
+
export function splitIdentity(identity: string): { address: string; deviceId: string } {
|
|
582
|
+
const i = identity.lastIndexOf("/");
|
|
583
|
+
return { address: identity.slice(0, i), deviceId: identity.slice(i + 1) };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Shortest content value: version, epoch, nonce, device signature, GCM tag (101 bytes = 135 base64url characters). */
|
|
587
|
+
const CONTENT_MIN_CHARS = 135;
|
|
588
|
+
const B64URL = /^[A-Za-z0-9_-]*$/;
|
|
589
|
+
const B64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Header epoch and nonce of an encrypted `content` value (§5.2), or null if malformed (not base64url, shorter than a
|
|
593
|
+
* content value, another version, an epoch beyond 2^53). Reads the header characters only: `0x01 || u64 epoch` are
|
|
594
|
+
* the first 12 base64url characters, the 12-byte nonce the next 16 (both on 3-byte boundaries).
|
|
595
|
+
*/
|
|
596
|
+
export function contentHeader(content: string): { epoch: number; nonce: string } | null {
|
|
597
|
+
if (content.length < CONTENT_MIN_CHARS || content.length % 4 === 1 || !B64URL.test(content)) return null;
|
|
598
|
+
// Canonical base64url, as the core decodes it: the unused low bits of the last character are zero.
|
|
599
|
+
const rest = content.length % 4;
|
|
600
|
+
if (rest && B64URL_ALPHABET.indexOf(content[content.length - 1]!) & (rest === 2 ? 0x0f : 0x03)) return null;
|
|
601
|
+
let head: Uint8Array;
|
|
602
|
+
try {
|
|
603
|
+
head = b64urlDecode(content.slice(0, 12));
|
|
604
|
+
} catch {
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
if (head.length !== 9 || head[0] !== 1) return null;
|
|
608
|
+
const view = new DataView(head.buffer, head.byteOffset, head.byteLength);
|
|
609
|
+
const high = view.getUint32(1);
|
|
610
|
+
// Epochs are JavaScript numbers: below 2^53.
|
|
611
|
+
if (high >= 0x200000) return null;
|
|
612
|
+
return { epoch: high * 0x1_0000_0000 + view.getUint32(5), nonce: content.slice(12, 28) };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** {@link EncryptOptions} forwarded from another tab, checked field by field. */
|
|
616
|
+
function encryptOptions(v: unknown): EncryptOptions {
|
|
617
|
+
if (v === undefined) return {};
|
|
618
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) throw new MlsError("invalid_id");
|
|
619
|
+
const o = v as Record<string, unknown>;
|
|
620
|
+
const strings = (x: unknown): string[] | undefined => {
|
|
621
|
+
if (x === undefined) return undefined;
|
|
622
|
+
if (!Array.isArray(x) || !x.every((i) => typeof i === "string")) throw new MlsError("invalid_id");
|
|
623
|
+
return x as string[];
|
|
624
|
+
};
|
|
625
|
+
const out: EncryptOptions = {};
|
|
626
|
+
if (o.ctx !== undefined) {
|
|
627
|
+
const ctx = parseContext(o.ctx);
|
|
628
|
+
if (!ctx) throw new MlsError("invalid_id");
|
|
629
|
+
out.ctx = ctx;
|
|
630
|
+
}
|
|
631
|
+
const members = strings(o.members);
|
|
632
|
+
if (members) out.members = members;
|
|
633
|
+
if (o.noBots !== undefined) out.noBots = o.noBots === true;
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export class MlsManager {
|
|
638
|
+
private client: Client;
|
|
639
|
+
/**
|
|
640
|
+
* Operations waiting for the client, in two lanes: `priority` (a voice join, somebody waits for it) runs before the
|
|
641
|
+
* others; within a lane in order. One operation runs at a time ({@link run}).
|
|
642
|
+
*/
|
|
643
|
+
private readonly lanes: { priority: Array<() => void>; normal: Array<() => void> } = { priority: [], normal: [] };
|
|
644
|
+
private busy = false;
|
|
645
|
+
private readonly dirty = new Set<string>();
|
|
646
|
+
private backupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
647
|
+
private readonly keyCache = new Map<string, { at: number; pending: boolean; devices: Promise<Map<string, Uint8Array> | null> }>();
|
|
648
|
+
/** Retired devices per address (verified like active ones, filled by the same lookup): key and deletion time. */
|
|
649
|
+
private readonly retiredKeys = new Map<string, Map<string, { key: Uint8Array; deletedAt: number }>>();
|
|
650
|
+
/** Verified share keys per address and device (ENCRYPTION.md §3.1), filled by the device lookup. */
|
|
651
|
+
private readonly shareKeyMap = new Map<string, Map<string, Uint8Array>>();
|
|
652
|
+
/** `kind` of every user whose keys were looked up (from the user's home node, APPLICATIONS.md §3.5). */
|
|
653
|
+
private readonly kinds = new Map<string, string>();
|
|
654
|
+
/**
|
|
655
|
+
* Results by purpose, conversation, message id, author, device and epoch ({@link decryptCacheKey}), with the
|
|
656
|
+
* fingerprint of the ciphertext they belong to (a changed ciphertext of the same message is decrypted again); a hit
|
|
657
|
+
* also compares author, device and epoch, so a result is never answered for an envelope that names another sender;
|
|
658
|
+
* bounded ({@link DECRYPT_CACHE_LIMIT}).
|
|
659
|
+
*/
|
|
660
|
+
private readonly decrypted = new LruMap<string, DecryptCacheEntry>(DECRYPT_CACHE_LIMIT);
|
|
661
|
+
/** The signed context of decrypted messages (stripped from their content), "malformed" when invalid. */
|
|
662
|
+
private readonly contexts = new WeakMap<Decrypted, MessageContext | "malformed">();
|
|
663
|
+
/** The members of the last ghost check per conversation (§4.6), used by the checks before encrypting. */
|
|
664
|
+
private readonly lastGhosts = new Map<string, { members: string[]; noBots: boolean }>();
|
|
665
|
+
/** Ghost leaves the user acknowledged per conversation ({@link acknowledgeGhosts}); nothing else lets content out. */
|
|
666
|
+
private readonly acknowledged = new Map<string, Set<string>>();
|
|
667
|
+
/** The violations of the last check per conversation and the epoch it ran in (ephemeral events reuse it). */
|
|
668
|
+
private readonly ghostCache = new Map<string, { epoch: number; found: GhostViolation[] }>();
|
|
669
|
+
/** The epoch each group was last known to be current at (a complete catch-up or an own commit), and when. */
|
|
670
|
+
private readonly caughtUp = new Map<string, { epoch: number; at: number }>();
|
|
671
|
+
/** `conversation|epoch|nonce` → the message id that used it first (§5.2 replay rule). */
|
|
672
|
+
private readonly nonces = new LruMap<string, string>(NONCE_WINDOW);
|
|
673
|
+
private readonly backupTried = new Set<string>();
|
|
674
|
+
/**
|
|
675
|
+
* Plaintext signature checks by message id and signature, with the SHA-256 of the exact signed input
|
|
676
|
+
* ({@link signedInputDigest}): an envelope changed under the same id and signature is checked again.
|
|
677
|
+
*/
|
|
678
|
+
private readonly plainChecked = new LruMap<string, { digest: string; status: PlainStatus }>(DECRYPT_CACHE_LIMIT);
|
|
679
|
+
/**
|
|
680
|
+
* The same checks by envelope object (a re-render passes the same objects): no canonicalization on a hit, which
|
|
681
|
+
* also requires the object's values to be unchanged since ({@link envelopeSnapshot}).
|
|
682
|
+
*/
|
|
683
|
+
private plainByEnvelope = new WeakMap<PlainEnvelope, { q: string; snapshot: unknown[]; status: PlainStatus }>();
|
|
684
|
+
private readonly plainNonces = new LruMap<string, string>(NONCE_WINDOW);
|
|
685
|
+
/** Consecutive failed background writes of the bookkeeping ({@link MlsBackgroundError}). */
|
|
686
|
+
private saveFailures = 0;
|
|
687
|
+
/** Recent `(conversation, epoch, nonce)` of ephemeral events (replay window, see decryptEphemeral). */
|
|
688
|
+
private readonly ephemeralNonces = new Set<string>();
|
|
689
|
+
/** Encrypted channels with webhooks (ENCRYPTION.md §5b) and the epoch whose inbox key this session published. */
|
|
690
|
+
private readonly inboxWanted = new Set<string>();
|
|
691
|
+
private readonly inboxSent = new Map<string, number>();
|
|
692
|
+
private readonly verified: Record<string, VerifiedContact>;
|
|
693
|
+
private disposed = false;
|
|
694
|
+
|
|
695
|
+
constructor(private readonly deps: MlsDeps) {
|
|
696
|
+
this.client = deps.client;
|
|
697
|
+
this.verified = deps.verified ?? {};
|
|
698
|
+
normalizeMeta(deps.meta, deps.homeDomain);
|
|
699
|
+
// This device belongs to the account's own devices (the trust record, §4.7).
|
|
700
|
+
void this.recordOwnDevices([[deps.deviceId, deps.client.devicePublicKey]]).catch(() => undefined);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** The delivery service of the node hosting the conversation `q` (qualified id). */
|
|
704
|
+
private ds(q: string): { api: MlsNodeApi; id: string } {
|
|
705
|
+
const { domain, id } = splitQualified(q);
|
|
706
|
+
return { api: this.deps.api.node(domain), id };
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
get address(): string {
|
|
710
|
+
return this.deps.address;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private now(): number {
|
|
714
|
+
return this.deps.now?.() ?? Date.now();
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** The forwarded call when another tab owns the state, otherwise null (run it here). */
|
|
718
|
+
private forwarded<T>(operation: ForwardedOperation, args: unknown[]): Promise<T> | null {
|
|
719
|
+
const tabs = this.deps.tabs;
|
|
720
|
+
return tabs && !tabs.local() ? (tabs.forward(operation, args) as Promise<T>) : null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Tab coordination, in the tab that owns the state: runs an operation another tab forwarded. Only the operations
|
|
725
|
+
* of {@link FORWARDED_OPERATIONS}, with their arguments checked.
|
|
726
|
+
*/
|
|
727
|
+
serveForwarded(operation: string, args: unknown[]): Promise<unknown> {
|
|
728
|
+
const str = (v: unknown): string => {
|
|
729
|
+
if (typeof v !== "string") throw new MlsError("invalid_id");
|
|
730
|
+
return v;
|
|
731
|
+
};
|
|
732
|
+
const strs = (v: unknown): string[] => {
|
|
733
|
+
if (!Array.isArray(v)) throw new MlsError("invalid_id");
|
|
734
|
+
return v.map(str);
|
|
735
|
+
};
|
|
736
|
+
const obj = (v: unknown): Record<string, unknown> => {
|
|
737
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) throw new MlsError("invalid_id");
|
|
738
|
+
return v as Record<string, unknown>;
|
|
739
|
+
};
|
|
740
|
+
try {
|
|
741
|
+
const [a, b] = args;
|
|
742
|
+
switch (operation as ForwardedOperation) {
|
|
743
|
+
case "ensureJoined":
|
|
744
|
+
return this.ensureJoined(str(a), { canCreate: obj(b).canCreate === true, priority: obj(b).priority === true });
|
|
745
|
+
case "processWelcomes":
|
|
746
|
+
return this.processWelcomes(str(a));
|
|
747
|
+
case "catchUp":
|
|
748
|
+
return this.catchUp(str(a));
|
|
749
|
+
case "selfUpdate":
|
|
750
|
+
return this.selfUpdate(str(a), { priority: b !== undefined && obj(b).priority === true });
|
|
751
|
+
case "removeLeaves": {
|
|
752
|
+
const keys = args[2];
|
|
753
|
+
if (keys === undefined) return this.removeLeaves(str(a), strs(b));
|
|
754
|
+
if (!Array.isArray(keys) || !keys.every((k) => k instanceof Uint8Array)) throw new MlsError("invalid_id");
|
|
755
|
+
return this.removeLeaves(str(a), strs(b), keys as Uint8Array[]);
|
|
756
|
+
}
|
|
757
|
+
case "addMissingDevices":
|
|
758
|
+
return this.addMissingDevices(str(a), strs(b));
|
|
759
|
+
case "encrypt":
|
|
760
|
+
return this.encrypt(str(a), obj(b), encryptOptions(args[2]));
|
|
761
|
+
case "acknowledgeGhosts": {
|
|
762
|
+
if (!Array.isArray(b)) throw new MlsError("invalid_id");
|
|
763
|
+
const refs = b.map((r) => {
|
|
764
|
+
const o = obj(r);
|
|
765
|
+
return { identity: str(o.identity), key: str(o.key) };
|
|
766
|
+
});
|
|
767
|
+
return this.acknowledgeGhosts(str(a), refs);
|
|
768
|
+
}
|
|
769
|
+
case "encryptHistory": {
|
|
770
|
+
const keys = args[2];
|
|
771
|
+
if (!keys || typeof keys !== "object" || Array.isArray(keys) || !Object.values(keys).every((k) => k instanceof Uint8Array)) throw new MlsError("invalid_id");
|
|
772
|
+
return this.encryptHistory(str(a), str(b), keys as Record<string, Uint8Array>);
|
|
773
|
+
}
|
|
774
|
+
case "importHistoryKeys": {
|
|
775
|
+
if (!Array.isArray(b) || !b.every((k) => Array.isArray(k) && typeof k[0] === "number" && k[1] instanceof Uint8Array)) throw new MlsError("invalid_id");
|
|
776
|
+
return this.importHistoryKeys(str(a), b as [number, Uint8Array][]);
|
|
777
|
+
}
|
|
778
|
+
case "publishInboxKey":
|
|
779
|
+
return this.publishInboxKey(str(a));
|
|
780
|
+
case "setInboxWanted":
|
|
781
|
+
this.setInboxWanted(str(a), b === true);
|
|
782
|
+
return Promise.resolve(undefined);
|
|
783
|
+
case "flushBackups":
|
|
784
|
+
return this.flushBackups();
|
|
785
|
+
case "restoreBackups":
|
|
786
|
+
return this.restoreBackups();
|
|
787
|
+
case "createKeyPackages":
|
|
788
|
+
if (typeof a !== "number") throw new MlsError("invalid_id");
|
|
789
|
+
return this.createKeyPackages(a);
|
|
790
|
+
case "restoreBackup": {
|
|
791
|
+
const q = str(a);
|
|
792
|
+
splitQualified(q);
|
|
793
|
+
return this.run(() => this.restoreOne(q));
|
|
794
|
+
}
|
|
795
|
+
default:
|
|
796
|
+
return Promise.reject(new MlsError("invalid_id"));
|
|
797
|
+
}
|
|
798
|
+
} catch (e) {
|
|
799
|
+
return Promise.reject(e);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Tab coordination: replaces the client state and the bookkeeping with the stored ones (the owning tab wrote
|
|
805
|
+
* them, or this tab takes the state over), after every earlier operation. `load` returns null to keep the
|
|
806
|
+
* current state; `then` runs inside the same step (before any later operation).
|
|
807
|
+
*/
|
|
808
|
+
reloadState(load: () => Promise<{ client: Client; meta: MlsMeta } | null>, then?: () => void): Promise<void> {
|
|
809
|
+
return this.run(async () => {
|
|
810
|
+
if (this.disposed) return;
|
|
811
|
+
const next = await load();
|
|
812
|
+
if (next && !this.disposed) {
|
|
813
|
+
const old = this.client;
|
|
814
|
+
this.client = next.client;
|
|
815
|
+
old.free();
|
|
816
|
+
this.caughtUp.clear();
|
|
817
|
+
const meta = this.deps.meta as unknown as Record<string, unknown>;
|
|
818
|
+
for (const k of Object.keys(meta)) delete meta[k];
|
|
819
|
+
Object.assign(meta, normalizeMeta(next.meta, this.deps.homeDomain));
|
|
820
|
+
// Keys may have arrived with the new state: failed messages are tried again.
|
|
821
|
+
this.decrypted.deleteWhere((v) => !v.result.ok);
|
|
822
|
+
} else {
|
|
823
|
+
next?.client.free();
|
|
824
|
+
}
|
|
825
|
+
then?.();
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Runs `fn` after every earlier operation of its lane (a `priority` one also before the waiting normal ones, not
|
|
831
|
+
* before the one running): the client is mutated by one operation at a time.
|
|
832
|
+
*/
|
|
833
|
+
private run<T>(fn: () => Promise<T>, priority = false): Promise<T> {
|
|
834
|
+
return new Promise<T>((resolve, reject) => {
|
|
835
|
+
(priority ? this.lanes.priority : this.lanes.normal).push(() => {
|
|
836
|
+
let done: Promise<T>;
|
|
837
|
+
try {
|
|
838
|
+
done = fn();
|
|
839
|
+
} catch (e) {
|
|
840
|
+
done = Promise.reject(e);
|
|
841
|
+
}
|
|
842
|
+
done.then(resolve, reject).finally(() => {
|
|
843
|
+
this.busy = false;
|
|
844
|
+
this.pump();
|
|
845
|
+
});
|
|
846
|
+
});
|
|
847
|
+
this.pump();
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private pump(): void {
|
|
852
|
+
if (this.busy) return;
|
|
853
|
+
const next = this.lanes.priority.shift() ?? this.lanes.normal.shift();
|
|
854
|
+
if (!next) return;
|
|
855
|
+
this.busy = true;
|
|
856
|
+
next();
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
private async persist(): Promise<void> {
|
|
860
|
+
await this.deps.persist(this.client);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Runs a client call; restores the previous state if `check` rejects the result. */
|
|
864
|
+
private async guarded<T>(call: () => T, check: (r: T) => boolean): Promise<T | null> {
|
|
865
|
+
const snapshot = this.client.exportState();
|
|
866
|
+
try {
|
|
867
|
+
const r = call();
|
|
868
|
+
if (check(r)) return r;
|
|
869
|
+
this.client.free();
|
|
870
|
+
this.client = Client.importState(snapshot);
|
|
871
|
+
return null;
|
|
872
|
+
} finally {
|
|
873
|
+
snapshot.fill(0);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** All methods below take qualified conversation ids (`<hosting node>/<ulid>`). */
|
|
878
|
+
hasGroup(q: string): boolean {
|
|
879
|
+
return this.client.hasGroup(q);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
epoch(q: string): number | null {
|
|
883
|
+
return this.client.hasGroup(q) ? this.client.currentEpoch(q) : null;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// ---- Joining ------------------------------------------------------------------------------
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* Makes this device a member of the conversation's group: pending Welcomes on the hosting node first,
|
|
890
|
+
* then catch-up of an existing local group, otherwise an external join, or the group creation if none
|
|
891
|
+
* exists and `canCreate` (the user may post). Returns `no_group` when there is nothing to join yet.
|
|
892
|
+
*/
|
|
893
|
+
ensureJoined(q: string, opts: { canCreate: boolean; priority?: boolean }): Promise<"joined" | "no_group"> {
|
|
894
|
+
const remote = this.forwarded<"joined" | "no_group">("ensureJoined", [q, { canCreate: opts.canCreate, priority: !!opts.priority }]);
|
|
895
|
+
if (remote) return remote;
|
|
896
|
+
return this.run(async () => {
|
|
897
|
+
const { domain } = splitQualified(q);
|
|
898
|
+
let info: Promise<GroupInfoAnswer> | undefined;
|
|
899
|
+
if (!this.client.hasGroup(q)) {
|
|
900
|
+
// The Welcomes and the group's GroupInfo at once (one round trip instead of two); the GroupInfo is used only
|
|
901
|
+
// when no Welcome brought the group (a stale one ends in an epoch conflict and is fetched again).
|
|
902
|
+
const ds = this.ds(q);
|
|
903
|
+
info = ds.api.groupInfo(ds.id).then(
|
|
904
|
+
(ok) => ({ ok }),
|
|
905
|
+
(error: unknown) => ({ error }),
|
|
906
|
+
);
|
|
907
|
+
await this.processWelcomesInner(domain);
|
|
908
|
+
}
|
|
909
|
+
if (this.client.hasGroup(q)) {
|
|
910
|
+
await this.catchUpInner(q);
|
|
911
|
+
if (this.client.hasGroup(q)) return "joined";
|
|
912
|
+
info = undefined;
|
|
913
|
+
}
|
|
914
|
+
return this.joinInner(q, opts.canCreate, info);
|
|
915
|
+
}, !!opts.priority);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
private noteJoined(q: string) {
|
|
919
|
+
this.caughtUp.set(q, { epoch: this.client.currentEpoch(q), at: this.now() });
|
|
920
|
+
const meta = this.deps.meta;
|
|
921
|
+
meta.convs[q] = { rotatedAt: this.now() };
|
|
922
|
+
this.saveMetaInBackground();
|
|
923
|
+
this.markDirty(q);
|
|
924
|
+
this.deps.onEvent?.({ type: "epoch", conversationId: q });
|
|
925
|
+
this.afterOwnCommit(q);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Writes the bookkeeping without waiting for it (it is written again with the next change); a failure is reported
|
|
930
|
+
* through `onError` ({@link MlsBackgroundError}), never left as an unhandled rejection.
|
|
931
|
+
*/
|
|
932
|
+
private saveMetaInBackground(): void {
|
|
933
|
+
let done: Promise<void>;
|
|
934
|
+
try {
|
|
935
|
+
done = this.deps.saveMeta(this.deps.meta);
|
|
936
|
+
} catch (e) {
|
|
937
|
+
done = Promise.reject(e);
|
|
938
|
+
}
|
|
939
|
+
done.then(
|
|
940
|
+
() => {
|
|
941
|
+
this.saveFailures = 0;
|
|
942
|
+
},
|
|
943
|
+
(error: unknown) => {
|
|
944
|
+
const e: MlsBackgroundError = { operation: "save_meta", error, consecutive: ++this.saveFailures };
|
|
945
|
+
if (this.deps.onError) {
|
|
946
|
+
try {
|
|
947
|
+
this.deps.onError(e);
|
|
948
|
+
} catch {
|
|
949
|
+
/* a failing reporter changes nothing */
|
|
950
|
+
}
|
|
951
|
+
} else {
|
|
952
|
+
console.error("tavrn e2ee: the encryption bookkeeping could not be saved", error);
|
|
953
|
+
}
|
|
954
|
+
},
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
private async joinInner(q: string, canCreate: boolean, first?: Promise<GroupInfoAnswer>): Promise<"joined" | "no_group"> {
|
|
959
|
+
const ds = this.ds(q);
|
|
960
|
+
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
|
961
|
+
let info: GroupInfoResponse;
|
|
962
|
+
try {
|
|
963
|
+
const answer = attempt === 0 && first ? await first : null;
|
|
964
|
+
if (answer && "error" in answer) throw answer.error;
|
|
965
|
+
info = answer ? answer.ok : await ds.api.groupInfo(ds.id);
|
|
966
|
+
} catch (e) {
|
|
967
|
+
if (apiCode(e) !== "mls.no_group") throw e;
|
|
968
|
+
if (!canCreate) return "no_group";
|
|
969
|
+
// Create the group; roll back if another device was first (its epoch 0 key differs from ours).
|
|
970
|
+
const snapshot = this.client.exportState();
|
|
971
|
+
try {
|
|
972
|
+
const groupInfo = this.client.createGroup(q);
|
|
973
|
+
await ds.api.createGroup(ds.id, b64urlEncode(groupInfo));
|
|
974
|
+
await this.persist();
|
|
975
|
+
this.noteJoined(q);
|
|
976
|
+
return "joined";
|
|
977
|
+
} catch (e2) {
|
|
978
|
+
this.client.free();
|
|
979
|
+
this.client = Client.importState(snapshot);
|
|
980
|
+
if (apiCode(e2) === "mls.group_exists") continue;
|
|
981
|
+
throw e2;
|
|
982
|
+
} finally {
|
|
983
|
+
snapshot.fill(0);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const out = this.client.joinByExternalCommit(q, b64urlDecode(info.group_info));
|
|
987
|
+
try {
|
|
988
|
+
await ds.api.postCommit(ds.id, commitRequest(out));
|
|
989
|
+
} catch (e) {
|
|
990
|
+
this.client.discardPendingCommit(q);
|
|
991
|
+
await this.persist();
|
|
992
|
+
if (apiCode(e) === "mls.epoch_conflict") continue;
|
|
993
|
+
throw e;
|
|
994
|
+
} finally {
|
|
995
|
+
out.free();
|
|
996
|
+
}
|
|
997
|
+
this.client.mergePendingCommit(q);
|
|
998
|
+
await this.persist();
|
|
999
|
+
this.noteJoined(q);
|
|
1000
|
+
return "joined";
|
|
1001
|
+
}
|
|
1002
|
+
throw new MlsError("conflict");
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Fetches and applies the Welcomes addressed to this device that wait on node `domain` (`mls.welcome`
|
|
1007
|
+
* of that node's socket, or after connecting to it). Returns the qualified ids joined.
|
|
1008
|
+
*/
|
|
1009
|
+
processWelcomes(domain: string): Promise<string[]> {
|
|
1010
|
+
const remote = this.forwarded<string[]>("processWelcomes", [domain]);
|
|
1011
|
+
if (remote) return remote;
|
|
1012
|
+
return this.run(() => this.processWelcomesInner(domain));
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
private async processWelcomesInner(domain: string): Promise<string[]> {
|
|
1016
|
+
const joined: string[] = [];
|
|
1017
|
+
const api = this.deps.api.node(domain);
|
|
1018
|
+
const { items } = await api.welcomes();
|
|
1019
|
+
for (const w of items) {
|
|
1020
|
+
// §4.4: the Welcome must name the node that delivered it and the conversation it was delivered
|
|
1021
|
+
// for, so that no node can plant a group for a conversation of another node.
|
|
1022
|
+
const expected = qualify(domain, w.conversation_id);
|
|
1023
|
+
try {
|
|
1024
|
+
splitQualified(expected);
|
|
1025
|
+
const ok = await this.guarded(
|
|
1026
|
+
() => {
|
|
1027
|
+
const j = this.client.joinByWelcome(b64urlDecode(w.welcome));
|
|
1028
|
+
const conversation = j.conversation;
|
|
1029
|
+
j.free();
|
|
1030
|
+
return conversation;
|
|
1031
|
+
},
|
|
1032
|
+
(conversation) => conversation === expected,
|
|
1033
|
+
);
|
|
1034
|
+
if (ok) {
|
|
1035
|
+
await this.persist();
|
|
1036
|
+
this.noteJoined(expected);
|
|
1037
|
+
joined.push(expected);
|
|
1038
|
+
}
|
|
1039
|
+
} catch {
|
|
1040
|
+
/* already a member (joined otherwise) or invalid: drop it */
|
|
1041
|
+
}
|
|
1042
|
+
await api.deleteWelcome(w.id).catch(() => undefined);
|
|
1043
|
+
}
|
|
1044
|
+
for (const c of joined) await this.catchUpInner(c);
|
|
1045
|
+
return joined;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// ---- Handshake log --------------------------------------------------------------------------
|
|
1049
|
+
|
|
1050
|
+
/** Processes the handshake log from the local epoch (after `mls.commit`). No-op without a local group. */
|
|
1051
|
+
catchUp(q: string): Promise<void> {
|
|
1052
|
+
const remote = this.forwarded<void>("catchUp", [q]);
|
|
1053
|
+
if (remote) return remote;
|
|
1054
|
+
return this.run(() => this.catchUpInner(q)).then(() => undefined);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/** True when commits were processed (the roster may have changed). */
|
|
1058
|
+
private async catchUpInner(q: string): Promise<boolean> {
|
|
1059
|
+
if (!this.client.hasGroup(q)) return false;
|
|
1060
|
+
let changed = false;
|
|
1061
|
+
const ds = this.ds(q);
|
|
1062
|
+
for (let page = 0; page < 1000; page++) {
|
|
1063
|
+
const since = this.client.currentEpoch(q);
|
|
1064
|
+
const log = await ds.api.log(ds.id, since);
|
|
1065
|
+
let progressed = false;
|
|
1066
|
+
for (const item of log.items) {
|
|
1067
|
+
let outcome;
|
|
1068
|
+
try {
|
|
1069
|
+
outcome = this.client.processHandshake(q, b64urlDecode(item.commit));
|
|
1070
|
+
} catch (e) {
|
|
1071
|
+
if (cryptoCode(e) === "stale_epoch") continue;
|
|
1072
|
+
if (cryptoCode(e) === "not_member") {
|
|
1073
|
+
this.client.deleteGroup(q);
|
|
1074
|
+
await this.persist();
|
|
1075
|
+
this.deps.onEvent?.({ type: "removed", conversationId: q });
|
|
1076
|
+
return true;
|
|
1077
|
+
}
|
|
1078
|
+
// §4.3: a commit that fails validation stops processing; rejoin and warn.
|
|
1079
|
+
this.client.deleteGroup(q);
|
|
1080
|
+
await this.persist();
|
|
1081
|
+
await this.joinInner(q, false).catch(() => "no_group");
|
|
1082
|
+
this.deps.onEvent?.({ type: "rejoined", conversationId: q });
|
|
1083
|
+
return true;
|
|
1084
|
+
}
|
|
1085
|
+
progressed = true;
|
|
1086
|
+
const selfRemoved = outcome.selfRemoved;
|
|
1087
|
+
const removedByOther = outcome.removed.length > 0 && outcome.committer !== this.client.identity;
|
|
1088
|
+
outcome.free();
|
|
1089
|
+
if (selfRemoved) {
|
|
1090
|
+
this.client.deleteGroup(q);
|
|
1091
|
+
await this.persist();
|
|
1092
|
+
this.deps.onEvent?.({ type: "removed", conversationId: q });
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
if (removedByOther) {
|
|
1096
|
+
const meta = this.deps.meta;
|
|
1097
|
+
meta.convs[q] = { rotatedAt: meta.convs[q]?.rotatedAt ?? this.now(), rotate: true };
|
|
1098
|
+
this.saveMetaInBackground();
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
if (progressed) {
|
|
1102
|
+
changed = true;
|
|
1103
|
+
await this.persist();
|
|
1104
|
+
this.markDirty(q);
|
|
1105
|
+
this.deps.onEvent?.({ type: "epoch", conversationId: q });
|
|
1106
|
+
}
|
|
1107
|
+
if (log.next == null || !progressed) {
|
|
1108
|
+
if (log.next == null && this.client.hasGroup(q)) this.caughtUp.set(q, { epoch: this.client.currentEpoch(q), at: this.now() });
|
|
1109
|
+
return changed;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return changed;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Two-phase commit (§4.3): `make` builds the commit from the current state (null = nothing to do);
|
|
1117
|
+
* on `409 mls.epoch_conflict` the pending commit is discarded, the log processed and `make` asked again.
|
|
1118
|
+
*/
|
|
1119
|
+
private async commitInner(q: string, make: () => CommitOutput | null): Promise<boolean> {
|
|
1120
|
+
const ds = this.ds(q);
|
|
1121
|
+
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
|
1122
|
+
const out = make();
|
|
1123
|
+
if (!out) return false;
|
|
1124
|
+
try {
|
|
1125
|
+
await ds.api.postCommit(ds.id, commitRequest(out));
|
|
1126
|
+
} catch (e) {
|
|
1127
|
+
this.client.discardPendingCommit(q);
|
|
1128
|
+
await this.persist();
|
|
1129
|
+
if (apiCode(e) === "mls.epoch_conflict") {
|
|
1130
|
+
await this.catchUpInner(q);
|
|
1131
|
+
if (!this.client.hasGroup(q)) return false;
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
throw e;
|
|
1135
|
+
} finally {
|
|
1136
|
+
out.free();
|
|
1137
|
+
}
|
|
1138
|
+
this.client.mergePendingCommit(q);
|
|
1139
|
+
await this.persist();
|
|
1140
|
+
this.caughtUp.set(q, { epoch: this.client.currentEpoch(q), at: this.now() });
|
|
1141
|
+
const meta = this.deps.meta;
|
|
1142
|
+
meta.convs[q] = { rotatedAt: this.now() };
|
|
1143
|
+
this.saveMetaInBackground();
|
|
1144
|
+
this.markDirty(q);
|
|
1145
|
+
this.deps.onEvent?.({ type: "epoch", conversationId: q });
|
|
1146
|
+
this.afterOwnCommit(q);
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
throw new MlsError("conflict");
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// ---- Inbox keys of write-only webhooks (ENCRYPTION.md §5b) ----------------------------------------
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Marks an encrypted channel as having webhooks (its `has_webhooks`): this device then publishes the inbox key of
|
|
1156
|
+
* every epoch it commits. Turning it on also publishes the current epoch's key once, so a webhook waiting for a
|
|
1157
|
+
* key gets one. Channels without webhooks cost nothing (no derivation, no request).
|
|
1158
|
+
*/
|
|
1159
|
+
setInboxWanted(q: string, wanted: boolean): void {
|
|
1160
|
+
if (this.deps.tabs && !this.deps.tabs.local()) {
|
|
1161
|
+
// Remembered here too (for a take-over); the owner publishes.
|
|
1162
|
+
if (wanted) this.inboxWanted.add(q);
|
|
1163
|
+
else this.inboxWanted.delete(q);
|
|
1164
|
+
void this.deps.tabs.forward("setInboxWanted", [q, wanted]).catch(() => undefined);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
if (!wanted) {
|
|
1168
|
+
this.inboxWanted.delete(q);
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (this.inboxWanted.has(q)) return;
|
|
1172
|
+
this.inboxWanted.add(q);
|
|
1173
|
+
void this.publishInboxKey(q).catch(() => undefined);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/**
|
|
1177
|
+
* Publishes the inbox key of the current epoch (for example right after creating a webhook); once per epoch and
|
|
1178
|
+
* session. False without a local group (the webhook then waits until a member publishes).
|
|
1179
|
+
*/
|
|
1180
|
+
async publishInboxKey(q: string): Promise<boolean> {
|
|
1181
|
+
const remote = this.forwarded<boolean>("publishInboxKey", [q]);
|
|
1182
|
+
if (remote) return remote;
|
|
1183
|
+
if (!this.client.hasGroup(q)) return false;
|
|
1184
|
+
const ds = this.ds(q);
|
|
1185
|
+
if (!ds.api.publishInboxKey) return false;
|
|
1186
|
+
const epoch = this.client.currentEpoch(q);
|
|
1187
|
+
if (this.inboxSent.get(q) === epoch) return true;
|
|
1188
|
+
const key = this.client.inboxKey(q, epoch);
|
|
1189
|
+
const body = { epoch, public_key: b64urlEncode(key.publicKey), signature: b64urlEncode(key.signature) };
|
|
1190
|
+
key.free();
|
|
1191
|
+
try {
|
|
1192
|
+
await ds.api.publishInboxKey(ds.id, body);
|
|
1193
|
+
} catch (e) {
|
|
1194
|
+
// A newer epoch is already published (another member was faster): nothing to do.
|
|
1195
|
+
if (apiCode(e) !== "inbox_key.stale") throw e;
|
|
1196
|
+
}
|
|
1197
|
+
this.inboxSent.set(q, epoch);
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private afterOwnCommit(q: string) {
|
|
1202
|
+
if (this.inboxWanted.has(q)) void this.publishInboxKey(q).catch(() => undefined);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Commits a self update (fresh leaf key, new epoch). Voice joins do this so that every voice session
|
|
1207
|
+
* starts on a fresh frame key (ENCRYPTION.md §5a). `false` without a local group.
|
|
1208
|
+
*/
|
|
1209
|
+
selfUpdate(q: string, opts: { priority?: boolean } = {}): Promise<boolean> {
|
|
1210
|
+
const remote = this.forwarded<boolean>("selfUpdate", [q, { priority: !!opts.priority }]);
|
|
1211
|
+
if (remote) return remote;
|
|
1212
|
+
return this.run(async () => {
|
|
1213
|
+
if (!this.client.hasGroup(q)) return false;
|
|
1214
|
+
// Right after a catch-up (a voice join's `ensureJoined`) the log is not fetched again (CAUGHT_UP_REUSE_MS).
|
|
1215
|
+
const known = this.caughtUp.get(q);
|
|
1216
|
+
if (!known || known.epoch !== this.client.currentEpoch(q) || this.now() - known.at > CAUGHT_UP_REUSE_MS) await this.catchUpInner(q);
|
|
1217
|
+
return this.commitInner(q, () => (this.client.hasGroup(q) ? this.client.selfUpdate(q) : null));
|
|
1218
|
+
}, !!opts.priority);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Voice frame key of the group's current epoch (ENCRYPTION.md §5a): 32 bytes of HKDF key material for
|
|
1223
|
+
* LiveKit's key provider. Never stored or sent anywhere. Null without a local group.
|
|
1224
|
+
*/
|
|
1225
|
+
voiceKey(q: string): { epoch: number; key: Uint8Array } | null {
|
|
1226
|
+
if (!this.client.hasGroup(q)) return null;
|
|
1227
|
+
const epoch = this.client.currentEpoch(q);
|
|
1228
|
+
return { epoch, key: this.client.exportVoiceKey(q, epoch) };
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* `mls.remove_needed` and the ghost cleanup: removes the listed leaves that are still in the group (callers check
|
|
1233
|
+
* rights). With `keys` (the leaves' signature keys, one per identity) exactly those leaves go, also when another
|
|
1234
|
+
* leaf names the same device (a planted or disguised leaf next to the genuine one); without, every leaf of each
|
|
1235
|
+
* identity. This device's own leaf is never removed.
|
|
1236
|
+
*/
|
|
1237
|
+
removeLeaves(q: string, identities: string[], keys?: Uint8Array[]): Promise<boolean> {
|
|
1238
|
+
if (keys && keys.length !== identities.length) return Promise.reject(new MlsError("invalid_id"));
|
|
1239
|
+
const remote = this.forwarded<boolean>("removeLeaves", keys ? [q, identities, keys] : [q, identities]);
|
|
1240
|
+
if (remote) return remote;
|
|
1241
|
+
return this.run(async () => {
|
|
1242
|
+
if (!this.client.hasGroup(q)) return false;
|
|
1243
|
+
await this.catchUpInner(q);
|
|
1244
|
+
try {
|
|
1245
|
+
return await this.commitInner(q, () => {
|
|
1246
|
+
if (!this.client.hasGroup(q)) return null;
|
|
1247
|
+
const own = this.client.devicePublicKey;
|
|
1248
|
+
const isOwnLeaf = (identity: string, key: Uint8Array) => identity === this.client.identity && sameBytes(key, own);
|
|
1249
|
+
if (keys) {
|
|
1250
|
+
const present = this.rosterEntries(q);
|
|
1251
|
+
const targets = identities
|
|
1252
|
+
.map((identity, n) => ({ identity, key: keys[n]! }))
|
|
1253
|
+
.filter((t) => !isOwnLeaf(t.identity, t.key) && present.some((e) => e.identity === t.identity && sameBytes(e.key, t.key)));
|
|
1254
|
+
return targets.length ? this.client.removeLeaves(q, targets.map((t) => t.identity), targets.map((t) => t.key)) : null;
|
|
1255
|
+
}
|
|
1256
|
+
const present = new Set(this.rosterIdentities(q));
|
|
1257
|
+
const targets = identities.filter((i) => present.has(i) && i !== this.client.identity);
|
|
1258
|
+
return targets.length ? this.client.removeMembers(q, targets) : null;
|
|
1259
|
+
});
|
|
1260
|
+
} catch (e) {
|
|
1261
|
+
// 403 mls.forbidden: the node still considers the leaves entitled (e.g. re-attested after a reset).
|
|
1262
|
+
if (apiCode(e) === "mls.forbidden") return false;
|
|
1263
|
+
throw e;
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* DMs and group DMs (§4.2): adds every attested device of the members (addresses, of any node) that is
|
|
1270
|
+
* not in the group yet, via KeyPackages claimed through the hosting node and a Welcome. Devices are
|
|
1271
|
+
* the ones attested on the members' home nodes. Returns the other members without any device.
|
|
1272
|
+
*/
|
|
1273
|
+
addMissingDevices(q: string, members: string[]): Promise<string[]> {
|
|
1274
|
+
const remote = this.forwarded<string[]>("addMissingDevices", [q, members]);
|
|
1275
|
+
if (remote) return remote;
|
|
1276
|
+
return this.run(async () => {
|
|
1277
|
+
const waiting: string[] = [];
|
|
1278
|
+
if (!this.client.hasGroup(q)) return waiting;
|
|
1279
|
+
const ds = this.ds(q);
|
|
1280
|
+
const own = this.client.identity;
|
|
1281
|
+
const candidates: { identity: string; keyPackage: Uint8Array }[] = [];
|
|
1282
|
+
const roster = new Set(this.rosterIdentities(q));
|
|
1283
|
+
for (const address of members) {
|
|
1284
|
+
try {
|
|
1285
|
+
const devices = await this.verifiedDevices(address);
|
|
1286
|
+
if (!devices || devices.size === 0) {
|
|
1287
|
+
if (address !== this.deps.address) waiting.push(address);
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
// §3.3: devices under a changed key wait until the user acknowledged the warning.
|
|
1291
|
+
if (this.deps.pins[address]?.changed) continue;
|
|
1292
|
+
const missing = [...devices.keys()].map((d) => `${address}/${d}`).filter((i) => i !== own && !roster.has(i));
|
|
1293
|
+
if (!missing.length) continue;
|
|
1294
|
+
const claimed = await ds.api.claim(address);
|
|
1295
|
+
for (const kp of claimed.key_packages) {
|
|
1296
|
+
const identity = `${address}/${kp.device_id}`;
|
|
1297
|
+
if (!missing.includes(identity)) continue;
|
|
1298
|
+
// The claim passes through the hosting node: only a KeyPackage whose leaf is exactly the attested device
|
|
1299
|
+
// (its identity and its attested key) is added; anything else would be a ghost leaf (§3.3, §4.6).
|
|
1300
|
+
const keyPackage = b64urlDecode(kp.key_package);
|
|
1301
|
+
const leaf = keyPackageLeaf(keyPackage);
|
|
1302
|
+
const attested = devices.get(kp.device_id);
|
|
1303
|
+
if (!leaf || !attested || leaf.identity !== identity || !sameBytes(leaf.signatureKey, attested)) continue;
|
|
1304
|
+
candidates.push({ identity, keyPackage });
|
|
1305
|
+
}
|
|
1306
|
+
} catch {
|
|
1307
|
+
/* unknown user or lookup failed: try again next time */
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
if (candidates.length) {
|
|
1311
|
+
const added = await this.commitInner(q, () => {
|
|
1312
|
+
const present = new Set(this.rosterIdentities(q));
|
|
1313
|
+
const kps = candidates.filter((c) => !present.has(c.identity)).map((c) => c.keyPackage);
|
|
1314
|
+
return kps.length ? this.client.addMembers(q, kps) : null;
|
|
1315
|
+
});
|
|
1316
|
+
if (added) this.deps.onEvent?.({ type: "added", conversationId: q, addresses: [...new Set(candidates.map((c) => splitIdentity(c.identity).address))] });
|
|
1317
|
+
}
|
|
1318
|
+
return waiting;
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Creates up to `count` KeyPackages (base64url, at most {@link MAX_KEY_PACKAGES}) in this device's client state and
|
|
1324
|
+
* persists it. `ensureDevice` tops up through it while a manager is open (its `keyPackages` option), so that the
|
|
1325
|
+
* private keys end up in the state that is written from now on (the owning tab's).
|
|
1326
|
+
*/
|
|
1327
|
+
createKeyPackages(count: number): Promise<string[]> {
|
|
1328
|
+
const remote = this.forwarded<string[]>("createKeyPackages", [count]);
|
|
1329
|
+
if (remote) return remote;
|
|
1330
|
+
return this.run(async () => {
|
|
1331
|
+
const n = Math.max(0, Math.min(MAX_KEY_PACKAGES, Math.floor(count)));
|
|
1332
|
+
const out = Array.from({ length: n }, () => b64urlEncode(this.client.createKeyPackage()));
|
|
1333
|
+
if (n) await this.persist();
|
|
1334
|
+
return out;
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** Leaf identities of the local group (empty without one). */
|
|
1339
|
+
roster(q: string): string[] {
|
|
1340
|
+
return this.client.hasGroup(q) ? this.rosterIdentities(q) : [];
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** This device's identity `<address>/<device id>`. */
|
|
1344
|
+
get identity(): string {
|
|
1345
|
+
return this.client.identity;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
private rosterIdentities(q: string): string[] {
|
|
1349
|
+
return this.client.roster(q).map((e) => {
|
|
1350
|
+
const id = e.identity;
|
|
1351
|
+
e.free();
|
|
1352
|
+
return id;
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// ---- Content --------------------------------------------------------------------------------
|
|
1357
|
+
|
|
1358
|
+
private rotationDue(conversationId: string): boolean {
|
|
1359
|
+
const c = this.deps.meta.convs[conversationId];
|
|
1360
|
+
return !c || !!c.rotate || this.now() - c.rotatedAt > ROTATE_AFTER_MS;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Encrypts a content object for the current epoch (§5.2). Rotates the content key first when a
|
|
1365
|
+
* self update is due (weekly, or after a removal committed by someone else, §4.2).
|
|
1366
|
+
*/
|
|
1367
|
+
encrypt(conversationId: string, content: Record<string, unknown>, opts: EncryptOptions = {}): Promise<{ content: string; epoch: number }> {
|
|
1368
|
+
// The node cannot see encrypted embeds: the sender checks them (APPLICATIONS.md §6.4).
|
|
1369
|
+
try {
|
|
1370
|
+
assertEmbeds(content);
|
|
1371
|
+
} catch (e) {
|
|
1372
|
+
return Promise.reject(e);
|
|
1373
|
+
}
|
|
1374
|
+
// Forwarded to the tab that owns the state: this tab's last ghost check names the members, so the owner never
|
|
1375
|
+
// falls back to "every leaf's user is a member" for a caller without a member list (§4.6).
|
|
1376
|
+
const last = this.lastGhosts.get(conversationId);
|
|
1377
|
+
const forwardOpts = !opts.members && last ? { ...opts, members: last.members, noBots: last.noBots } : opts;
|
|
1378
|
+
const remote = this.forwarded<{ content: string; epoch: number }>("encrypt", [conversationId, content, forwardOpts]);
|
|
1379
|
+
if (remote) return remote;
|
|
1380
|
+
return this.run(async () => {
|
|
1381
|
+
const q = conversationId;
|
|
1382
|
+
// Senders use the current epoch (§5.2): pick up commits whose events may still be on the way. Without them the
|
|
1383
|
+
// message would go out under an epoch the node may refuse, or one that removed members still hold: no sending.
|
|
1384
|
+
try {
|
|
1385
|
+
await this.catchUpInner(conversationId);
|
|
1386
|
+
} catch {
|
|
1387
|
+
throw new MlsError("catch_up_failed");
|
|
1388
|
+
}
|
|
1389
|
+
if (!this.client.hasGroup(q)) throw new MlsError("not_joined");
|
|
1390
|
+
await this.assertNoGhosts(q, opts);
|
|
1391
|
+
if (this.rotationDue(conversationId)) {
|
|
1392
|
+
try {
|
|
1393
|
+
await this.commitInner(conversationId, () => (this.client.hasGroup(q) ? this.client.selfUpdate(q) : null));
|
|
1394
|
+
} catch {
|
|
1395
|
+
/* rotation is best effort; the message still goes out under the current key */
|
|
1396
|
+
}
|
|
1397
|
+
if (!this.client.hasGroup(q)) throw new MlsError("not_joined");
|
|
1398
|
+
}
|
|
1399
|
+
const ctx = opts.ctx && { thread_id: opts.ctx.thread_id, reply_to: opts.ctx.reply_to, ...(opts.ctx.interaction !== undefined ? { interaction: opts.ctx.interaction } : {}) };
|
|
1400
|
+
const json = ctx ? { ...content, ctx } : content;
|
|
1401
|
+
const out = this.client.encryptContentWithEpoch(q, JSON.stringify(json));
|
|
1402
|
+
try {
|
|
1403
|
+
return { content: out.content, epoch: out.epoch };
|
|
1404
|
+
} finally {
|
|
1405
|
+
out.free();
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* §4.6 before any content leaves this device (fail closed): the leaves are checked again after the catch-up, and a
|
|
1412
|
+
* violation the user did not acknowledge ({@link acknowledgeGhosts}) refuses the content with `MlsError("ghost")`.
|
|
1413
|
+
* A view that never showed the warning therefore cannot send to a ghost. Members: passed, from the last
|
|
1414
|
+
* {@link ghostCheck}, or every leaf's user (then only attestation and duplicates are judged).
|
|
1415
|
+
*/
|
|
1416
|
+
private async assertNoGhosts(q: string, opts: { members?: string[]; noBots?: boolean } = {}): Promise<void> {
|
|
1417
|
+
const found = await this.checkGhostsNow(q, opts);
|
|
1418
|
+
if (this.unacknowledged(q, found).length) throw new MlsError("ghost");
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
private async checkGhostsNow(q: string, opts: { members?: string[]; noBots?: boolean } = {}): Promise<GhostViolation[]> {
|
|
1422
|
+
const last = this.lastGhosts.get(q);
|
|
1423
|
+
const members = opts.members ?? last?.members ?? this.rosterIdentities(q).map((i) => splitIdentity(i).address);
|
|
1424
|
+
const noBots = opts.members ? !!opts.noBots : (last?.noBots ?? !!opts.noBots);
|
|
1425
|
+
let found = await this.ghostCheckInner(q, members, { noBots });
|
|
1426
|
+
// An unattested leaf nobody acknowledged may be a device that was attested a moment ago (its user's devices were
|
|
1427
|
+
// looked up just before): look those users up once more before refusing.
|
|
1428
|
+
const stale = new Set(this.unacknowledged(q, found).filter((v) => v.reason === "unattested").map((v) => splitIdentity(v.identity).address));
|
|
1429
|
+
if (stale.size) {
|
|
1430
|
+
for (const a of stale) if (!this.keyCache.get(a)?.pending) this.keyCache.delete(a);
|
|
1431
|
+
found = await this.ghostCheckInner(q, members, { noBots });
|
|
1432
|
+
}
|
|
1433
|
+
if (this.client.hasGroup(q)) this.ghostCache.set(q, { epoch: this.client.currentEpoch(q), found });
|
|
1434
|
+
return found;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
private unacknowledged(q: string, found: GhostViolation[]): GhostViolation[] {
|
|
1438
|
+
const acked = this.acknowledged.get(q);
|
|
1439
|
+
return found.filter((v) => !acked?.has(`${v.identity}|${v.key}`));
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* The user saw the ghost warning of `q` and chose to continue anyway (§4.6): content may go out to these leaves
|
|
1444
|
+
* from now on. Called by the warning's button only; a ghost check alone never acknowledges anything. Applied in
|
|
1445
|
+
* this tab and in the tab that owns the state (which encrypts).
|
|
1446
|
+
*/
|
|
1447
|
+
async acknowledgeGhosts(q: string, leaves: GhostLeafRef[]): Promise<void> {
|
|
1448
|
+
// An identity stands for its leaves in this tab's roster now; a leaf that appears later is not covered.
|
|
1449
|
+
const entries = leaves.some((l) => typeof l === "string") ? this.rosterEntries(q) : [];
|
|
1450
|
+
const refs = leaves.flatMap((l) =>
|
|
1451
|
+
typeof l === "string" ? entries.filter((e) => e.identity === l).map((e) => ({ identity: l, key: b64urlEncode(e.key) })) : [{ identity: l.identity, key: l.key }],
|
|
1452
|
+
);
|
|
1453
|
+
const set = this.acknowledged.get(q) ?? new Set<string>();
|
|
1454
|
+
for (const r of refs) set.add(`${r.identity}|${r.key}`);
|
|
1455
|
+
this.acknowledged.set(q, set);
|
|
1456
|
+
const remote = this.forwarded<void>("acknowledgeGhosts", [q, refs]);
|
|
1457
|
+
if (remote) await remote;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/** Ghost leaves of `q` found by the last check that the user has not acknowledged yet. */
|
|
1461
|
+
unacknowledgedGhosts(q: string): GhostViolation[] {
|
|
1462
|
+
return this.unacknowledged(q, this.ghostCache.get(q)?.found ?? []);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Decrypts and verifies an encrypted envelope; the result is cached for the session (not persisted). Content of
|
|
1467
|
+
* another purpose in the same format (a thread title, a history share) is `invalid` here: the node could otherwise
|
|
1468
|
+
* serve it as a message of its author, since format and AAD are the same (§5.2, §7b).
|
|
1469
|
+
*/
|
|
1470
|
+
decrypt(conversationId: string, m: EncryptedEnvelope): Promise<Decrypted> {
|
|
1471
|
+
return this.decryptEnvelope(conversationId, m, "message");
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
private async decryptEnvelope(conversationId: string, m: EncryptedEnvelope, purpose: "message" | "title" | "vote"): Promise<Decrypted> {
|
|
1475
|
+
const sealed = m.webhook ? sealedContent(m.content) : null;
|
|
1476
|
+
if (sealed) return purpose === "message" ? this.decryptSealed(conversationId, m, sealed) : { ok: false, reason: "invalid" };
|
|
1477
|
+
if (typeof m.content !== "string" || !m.device_id) return { ok: false, reason: "invalid" };
|
|
1478
|
+
const cacheKey = decryptCacheKey(purpose, conversationId, m);
|
|
1479
|
+
const fp = fingerprint(m.content);
|
|
1480
|
+
const cached = this.cachedResult(cacheKey, m, fp);
|
|
1481
|
+
if (cached) return this.matchContext(cached, m);
|
|
1482
|
+
const sentAt = m.created_at ? Date.parse(m.created_at) : NaN;
|
|
1483
|
+
let result = await this.decryptUncached(conversationId, m.id, m.author, m.device_id, m.epoch ?? null, m.content, Number.isFinite(sentAt) ? sentAt : null);
|
|
1484
|
+
if (result.ok && (purpose === "title") !== (result.content.type === THREAD_TITLE_TYPE)) result = { ok: false, reason: "invalid" };
|
|
1485
|
+
if (result.ok && (purpose === "vote") !== (result.content.type === POLL_VOTE_TYPE)) result = { ok: false, reason: "invalid" };
|
|
1486
|
+
if (result.ok && NOT_CONTENT_TYPES.has(String(result.content.type))) result = { ok: false, reason: "invalid" };
|
|
1487
|
+
if (!result.ok && result.reason === "invalid") this.forgetNonce(conversationId, m.content, m.id);
|
|
1488
|
+
if (result.ok && purpose === "message" && "ctx" in result.content) {
|
|
1489
|
+
// §5.2: the signed context is checked against every envelope this content is served in, never shown.
|
|
1490
|
+
const { ctx, ...content } = result.content;
|
|
1491
|
+
const stripped: Decrypted = { ...result, content };
|
|
1492
|
+
this.contexts.set(stripped, parseContext(ctx) ?? "malformed");
|
|
1493
|
+
result = stripped;
|
|
1494
|
+
}
|
|
1495
|
+
// Missing keys may arrive later (catch-up, backup), a failed device lookup may work next time: only final results
|
|
1496
|
+
// are cached.
|
|
1497
|
+
if (isFinal(result)) this.decrypted.set(cacheKey, cacheEntry(conversationId, m, fp, result));
|
|
1498
|
+
return this.matchContext(result, m);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* The cached result of `key` when it belongs to exactly this envelope: the same author, device, epoch and
|
|
1503
|
+
* ciphertext fingerprint (the key names them too; the comparison keeps that true whatever the key looks like).
|
|
1504
|
+
*/
|
|
1505
|
+
private cachedResult(key: string, m: EncryptedEnvelope, fp: string): Decrypted | undefined {
|
|
1506
|
+
const e = this.decrypted.get(key);
|
|
1507
|
+
if (!e || e.fp !== fp || e.author !== m.author || e.deviceId !== (m.device_id ?? null) || e.epoch !== (m.epoch ?? null)) return undefined;
|
|
1508
|
+
return e.result;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** `envelope_mismatch` when the signed context differs from the envelope's (known) thread and reply (§5.2). */
|
|
1512
|
+
private matchContext(r: Decrypted, m: EncryptedEnvelope): Decrypted {
|
|
1513
|
+
const ctx = r.ok ? this.contexts.get(r) : undefined;
|
|
1514
|
+
if (!ctx) return r;
|
|
1515
|
+
if (ctx === "malformed") return ENVELOPE_MISMATCH;
|
|
1516
|
+
if (m.thread_id !== undefined && (m.thread_id ?? null) !== ctx.thread_id) return ENVELOPE_MISMATCH;
|
|
1517
|
+
if (m.reply_to_id !== undefined && (m.reply_to_id ?? null) !== ctx.reply_to) return ENVELOPE_MISMATCH;
|
|
1518
|
+
// Answers of applications: the interaction they were sent for (an older bot's answer carries none).
|
|
1519
|
+
if (ctx.interaction !== undefined && m.interaction !== undefined && (m.interaction?.id ?? null) !== ctx.interaction) return ENVELOPE_MISMATCH;
|
|
1520
|
+
return r;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* A sealed webhook message (APPLICATIONS.md §6.1a): opened with the inbox key of its epoch, derived from the
|
|
1525
|
+
* epoch's content key (current state, history backup or history share), then checked like a webhook payload.
|
|
1526
|
+
*/
|
|
1527
|
+
private async decryptSealed(q: string, m: EncryptedEnvelope, s: SealedContent): Promise<Decrypted> {
|
|
1528
|
+
const cacheKey = decryptCacheKey("sealed", q, m);
|
|
1529
|
+
const fp = fingerprint(s.sealed);
|
|
1530
|
+
const cached = this.cachedResult(cacheKey, m, fp);
|
|
1531
|
+
if (cached) return cached;
|
|
1532
|
+
if (m.device_id || m.epoch !== s.epoch) return { ok: false, reason: "invalid" };
|
|
1533
|
+
let bytes: Uint8Array;
|
|
1534
|
+
try {
|
|
1535
|
+
bytes = b64urlDecode(s.sealed);
|
|
1536
|
+
} catch {
|
|
1537
|
+
return { ok: false, reason: "invalid" };
|
|
1538
|
+
}
|
|
1539
|
+
for (let round = 0; round < 3; round++) {
|
|
1540
|
+
let result: Decrypted;
|
|
1541
|
+
try {
|
|
1542
|
+
const payload = webhookPayload(this.client.openSealed(q, s.epoch, bytes));
|
|
1543
|
+
result = payload ? { ok: true, content: payload.content, sealedBy: s.sealedBy, ...(payload.username ? { webhookName: payload.username } : {}) } : { ok: false, reason: "invalid" };
|
|
1544
|
+
} catch (e) {
|
|
1545
|
+
if (cryptoCode(e) === "unknown_epoch" && (await this.fetchMissingKey(q, s.epoch, round))) continue;
|
|
1546
|
+
result = { ok: false, reason: cryptoCode(e) === "unknown_epoch" ? "missing_key" : "invalid" };
|
|
1547
|
+
}
|
|
1548
|
+
if (isFinal(result)) this.decrypted.set(cacheKey, cacheEntry(q, m, fp, result));
|
|
1549
|
+
return result;
|
|
1550
|
+
}
|
|
1551
|
+
return { ok: false, reason: "missing_key" };
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* After `unknown_epoch`: catch up when the epoch is newer than the local one (first round), else restore the
|
|
1556
|
+
* history backup once per conversation and session. False when the key stays missing.
|
|
1557
|
+
*/
|
|
1558
|
+
private async fetchMissingKey(q: string, epoch: number, round: number): Promise<boolean> {
|
|
1559
|
+
const local = this.epoch(q);
|
|
1560
|
+
if (round === 0 && local !== null && epoch > local) {
|
|
1561
|
+
await this.catchUp(q).catch(() => undefined);
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1564
|
+
if (this.backupTried.has(q)) return false;
|
|
1565
|
+
this.backupTried.add(q);
|
|
1566
|
+
await (this.forwarded<void>("restoreBackup", [q]) ?? this.run(() => this.restoreOne(q))).catch(() => undefined);
|
|
1567
|
+
return true;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Encrypts a thread title for an encrypted channel (PROTOCOL.md §7e): content format 1 of
|
|
1572
|
+
* `{ "type": "thread.title", "body", "ctx"?: { "thread_id" } }` under the current epoch, sent as `title` with
|
|
1573
|
+
* `title_epoch`. With `threadId` (a rename, or a new thread whose id the client chose) the title is bound to its
|
|
1574
|
+
* thread (ENCRYPTION.md §5.2).
|
|
1575
|
+
*/
|
|
1576
|
+
async encryptThreadTitle(conversationId: string, title: string, threadId?: string): Promise<{ title: string; title_epoch: number }> {
|
|
1577
|
+
const body = title.trim();
|
|
1578
|
+
if (!body || Array.from(body).length > THREAD_TITLE_MAX) throw new RangeError(`thread title: 1 to ${THREAD_TITLE_MAX} characters`);
|
|
1579
|
+
const { content, epoch } = await this.encrypt(conversationId, { type: "thread.title", body, ...(threadId ? { ctx: { thread_id: threadId } } : {}) });
|
|
1580
|
+
return { title: content, title_epoch: epoch };
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Decrypts and verifies the title of a thread in an encrypted channel with its author and device. Anything that is
|
|
1585
|
+
* not a `thread.title` object with a body of 1–100 characters is `invalid` (the node cannot pass off message
|
|
1586
|
+
* content as a title: the type differs, and the same ciphertext seen twice is a replay).
|
|
1587
|
+
*/
|
|
1588
|
+
async decryptThreadTitle(
|
|
1589
|
+
conversationId: string,
|
|
1590
|
+
t: { id: string; title: string; title_epoch?: number | null; title_author: string; title_device_id?: string | null; created_at?: string | null },
|
|
1591
|
+
): Promise<{ ok: true; title: string } | { ok: false; reason: DecryptFailure }> {
|
|
1592
|
+
const r = await this.decryptEnvelope(conversationId, { id: `thread-title:${t.id}`, author: t.title_author, device_id: t.title_device_id ?? null, epoch: t.title_epoch ?? null, content: t.title, created_at: t.created_at ?? null }, "title");
|
|
1593
|
+
if (!r.ok) return r;
|
|
1594
|
+
// §5.2: a title bound to another thread (or with a malformed context) is a mismatch; one without ctx is accepted.
|
|
1595
|
+
if ("ctx" in r.content && parseTitleContext(r.content.ctx) !== t.id) {
|
|
1596
|
+
// Not this thread's title: the genuine thread's use of the ciphertext is no replay.
|
|
1597
|
+
this.forgetNonce(conversationId, t.title, `thread-title:${t.id}`);
|
|
1598
|
+
return { ok: false, reason: "envelope_mismatch" };
|
|
1599
|
+
}
|
|
1600
|
+
const body = r.content.body;
|
|
1601
|
+
if (r.content.type !== "thread.title" || typeof body !== "string" || !body.trim() || Array.from(body).length > THREAD_TITLE_MAX) return { ok: false, reason: "invalid" };
|
|
1602
|
+
return { ok: true, title: body.trim() };
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Decrypts and verifies an encrypted poll vote record (PROTOCOL.md §7g) with its voter and device. Anything that is
|
|
1607
|
+
* not `poll.vote` content is `invalid` (and vote content is never shown as a message: {@link decrypt} refuses it).
|
|
1608
|
+
* The content keeps its `ctx` (`poll`, `poll_hash`) for `parsePollVote`, which compares it with the poll.
|
|
1609
|
+
*/
|
|
1610
|
+
decryptPollVote(conversationId: string, r: { user: string; device_id?: string | null; epoch?: number | null; content: unknown; updated_at?: string | null }): Promise<Decrypted> {
|
|
1611
|
+
return this.decryptEnvelope(conversationId, { id: `poll-vote:${r.user}`, author: r.user, device_id: r.device_id ?? null, epoch: r.epoch ?? null, content: r.content, created_at: r.updated_at ?? null }, "vote");
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/** Synchronous lookup of an already decrypted message (pins panel, edits). */
|
|
1615
|
+
peek(conversationId: string, m: EncryptedEnvelope): Decrypted | undefined {
|
|
1616
|
+
const sealed = m.webhook ? sealedContent(m.content) : null;
|
|
1617
|
+
if (sealed) return this.cachedResult(decryptCacheKey("sealed", conversationId, m), m, fingerprint(sealed.sealed));
|
|
1618
|
+
const r = this.cachedResult(decryptCacheKey("message", conversationId, m), m, fingerprint(String(m.content)));
|
|
1619
|
+
return r ? this.matchContext(r, m) : undefined;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* Encrypts the content of an ephemeral event (PROTOCOL.md §6, whiteboard and cursors) exactly like a
|
|
1624
|
+
* message (§5.2), but under the local epoch without catching up or rotating first: ephemeral events
|
|
1625
|
+
* are frequent and short-lived, the node accepts any epoch it knows, and receivers catch up themselves.
|
|
1626
|
+
*/
|
|
1627
|
+
encryptEphemeral(conversationId: string, content: Record<string, unknown>): Promise<string> {
|
|
1628
|
+
return this.run(async () => {
|
|
1629
|
+
const q = conversationId;
|
|
1630
|
+
if (!this.client.hasGroup(q)) throw new MlsError("not_joined");
|
|
1631
|
+
// The ghost check of this epoch (§4.6, fail closed): run once per epoch, not per event.
|
|
1632
|
+
const cached = this.ghostCache.get(q);
|
|
1633
|
+
const found = cached && cached.epoch === this.client.currentEpoch(q) ? cached.found : await this.checkGhostsNow(q);
|
|
1634
|
+
if (this.unacknowledged(q, found).length) throw new MlsError("ghost");
|
|
1635
|
+
if (!this.client.hasGroup(q)) throw new MlsError("not_joined");
|
|
1636
|
+
return this.client.encryptContent(q, JSON.stringify(content));
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Decrypts and verifies the content of an ephemeral event sent by `author`/`deviceId`. Nothing is cached
|
|
1642
|
+
* (there may be dozens per second); a repeated `(epoch, nonce)` within the recent window is a replay.
|
|
1643
|
+
*/
|
|
1644
|
+
async decryptEphemeral(conversationId: string, author: string, deviceId: string, content: unknown): Promise<Decrypted> {
|
|
1645
|
+
if (typeof content !== "string" || !deviceId) return { ok: false, reason: "invalid" };
|
|
1646
|
+
const header = contentHeader(content);
|
|
1647
|
+
if (!header) return { ok: false, reason: "invalid" };
|
|
1648
|
+
const replayKey = `${conversationId}|${header.epoch}|${header.nonce}`;
|
|
1649
|
+
if (this.ephemeralNonces.has(replayKey)) return { ok: false, reason: "replay" };
|
|
1650
|
+
let result = await this.decryptUncached(conversationId, `eph:${replayKey}`, author, deviceId, header.epoch, content);
|
|
1651
|
+
if (result.ok && (result.content.type === THREAD_TITLE_TYPE || result.content.type === POLL_VOTE_TYPE || NOT_CONTENT_TYPES.has(String(result.content.type)))) {
|
|
1652
|
+
this.nonces.delete(replayKey);
|
|
1653
|
+
return { ok: false, reason: "invalid" };
|
|
1654
|
+
}
|
|
1655
|
+
if (result.ok) {
|
|
1656
|
+
this.ephemeralNonces.add(replayKey);
|
|
1657
|
+
// A bounded window: the oldest nonces are forgotten first (Set keeps insertion order).
|
|
1658
|
+
if (this.ephemeralNonces.size > EPHEMERAL_REPLAY_WINDOW) {
|
|
1659
|
+
const oldest = this.ephemeralNonces.values().next().value;
|
|
1660
|
+
if (oldest !== undefined) this.ephemeralNonces.delete(oldest);
|
|
1661
|
+
}
|
|
1662
|
+
// decryptUncached remembers message nonces for good; ephemeral ones live only in the window above.
|
|
1663
|
+
this.nonces.delete(replayKey);
|
|
1664
|
+
}
|
|
1665
|
+
return result;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/** A ciphertext refused for its purpose does not count as seen: the genuine use of it is not a replay. */
|
|
1669
|
+
private forgetNonce(q: string, content: string, id: string) {
|
|
1670
|
+
const h = contentHeader(content);
|
|
1671
|
+
const key = h ? `${q}|${h.epoch}|${h.nonce}` : null;
|
|
1672
|
+
if (key && this.nonces.get(key) === id) this.nonces.delete(key);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
private async decryptUncached(conversationId: string, id: string, author: string, deviceId: string, epoch: number | null, content: string, sentAt: number | null = null): Promise<Decrypted> {
|
|
1676
|
+
const header = contentHeader(content);
|
|
1677
|
+
// §5.2: the header epoch must equal the envelope epoch.
|
|
1678
|
+
if (!header || header.epoch !== epoch) return { ok: false, reason: "invalid" };
|
|
1679
|
+
const replayKey = `${conversationId}|${header.epoch}|${header.nonce}`;
|
|
1680
|
+
const q = conversationId;
|
|
1681
|
+
|
|
1682
|
+
const attempt = async (): Promise<Decrypted> => {
|
|
1683
|
+
// §3.3: the author device must be attested under the pinned identity key; if it is in the
|
|
1684
|
+
// roster, its leaf key must be the attested key (checked by decryptContent: `unknown_sender`).
|
|
1685
|
+
let deviceKey: Uint8Array | undefined;
|
|
1686
|
+
try {
|
|
1687
|
+
deviceKey = (await this.verifiedDevices(author))?.get(deviceId);
|
|
1688
|
+
if (!deviceKey) deviceKey = (await this.verifiedDevices(author, true))?.get(deviceId);
|
|
1689
|
+
} catch {
|
|
1690
|
+
// The lookup failed (network, the author's node, a rate limit): not a verdict on the message.
|
|
1691
|
+
return { ok: false, reason: "retry" };
|
|
1692
|
+
}
|
|
1693
|
+
// §5.2: a deleted device's history stays readable with its retired attestation, for what it sent before
|
|
1694
|
+
// its deletion (server time, with slack). Ephemeral events (no time) never come from retired devices.
|
|
1695
|
+
let retired = false;
|
|
1696
|
+
if (!deviceKey && sentAt !== null) {
|
|
1697
|
+
const r = this.retiredDevice(author, deviceId);
|
|
1698
|
+
if (r && sentAt <= r.deletedAt + RETIRED_SLACK_MS) {
|
|
1699
|
+
deviceKey = r.key;
|
|
1700
|
+
retired = true;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
if (!deviceKey) return { ok: false, reason: "unknown_device" };
|
|
1704
|
+
const json = this.client.decryptContent(q, author, deviceId, content, deviceKey);
|
|
1705
|
+
const parsed: unknown = JSON.parse(json);
|
|
1706
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ok: false, reason: "invalid" };
|
|
1707
|
+
// Invalid embeds of another member are dropped, never shown (APPLICATIONS.md §6.4).
|
|
1708
|
+
const checked = withValidEmbeds(parsed as Record<string, unknown>);
|
|
1709
|
+
return retired ? { ok: true, content: checked, retired: true } : { ok: true, content: checked };
|
|
1710
|
+
};
|
|
1711
|
+
|
|
1712
|
+
for (let round = 0; round < 3; round++) {
|
|
1713
|
+
try {
|
|
1714
|
+
const r = await attempt();
|
|
1715
|
+
if (!r.ok) return r;
|
|
1716
|
+
// §5.2: a second message with the same (epoch, nonce) is a replay.
|
|
1717
|
+
const seen = this.nonces.get(replayKey);
|
|
1718
|
+
if (seen && seen !== id) return { ok: false, reason: "replay" };
|
|
1719
|
+
this.nonces.set(replayKey, id);
|
|
1720
|
+
return r;
|
|
1721
|
+
} catch (e) {
|
|
1722
|
+
const code = cryptoCode(e);
|
|
1723
|
+
if (code === "bad_signature") return { ok: false, reason: "bad_signature" };
|
|
1724
|
+
if (code === "unknown_sender") return { ok: false, reason: "unknown_device" };
|
|
1725
|
+
if (code !== "unknown_epoch") return { ok: false, reason: "invalid" };
|
|
1726
|
+
if (!(await this.fetchMissingKey(conversationId, header.epoch, round))) return { ok: false, reason: "missing_key" };
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
return { ok: false, reason: "missing_key" };
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// ---- Plaintext signatures (FEDERATION.md §10.3) ----------------------------------------------
|
|
1733
|
+
|
|
1734
|
+
/**
|
|
1735
|
+
* Signature of a plaintext message sent by this device: the covered envelope fields as they will be
|
|
1736
|
+
* stored (mentions normalized, attachments sorted by id) and the content object. Null if signing is
|
|
1737
|
+
* not possible (best effort until `content.signed1` is announced).
|
|
1738
|
+
*/
|
|
1739
|
+
signPlaintext(conversationId: string, fields: Omit<SignedFields, "conversation" | "author" | "device_id">, content: Record<string, unknown>): string | null {
|
|
1740
|
+
try {
|
|
1741
|
+
const signed: SignedFields = { ...fields, conversation: conversationId, author: this.deps.address, device_id: this.deps.deviceId };
|
|
1742
|
+
return this.client.signPlaintext(JSON.stringify(signed), jcs(content));
|
|
1743
|
+
} catch {
|
|
1744
|
+
return null;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Checks the device signature of a plaintext message against the author's attested devices
|
|
1750
|
+
* (verified against the pinned identity key). Results are cached for the session.
|
|
1751
|
+
*/
|
|
1752
|
+
async verifyPlaintext(conversationId: string, m: PlainEnvelope): Promise<PlainStatus> {
|
|
1753
|
+
if (!m.signature) return "unsigned";
|
|
1754
|
+
// The same, unchanged envelope object again (a re-render): its last result, without canonicalizing it again.
|
|
1755
|
+
const snapshot = envelopeSnapshot(m);
|
|
1756
|
+
const seen = this.plainByEnvelope.get(m);
|
|
1757
|
+
if (seen?.q === conversationId && sameSnapshot(seen.snapshot, snapshot)) return seen.status;
|
|
1758
|
+
const fields = envelopeFields(conversationId, m);
|
|
1759
|
+
const nonce = signatureNonce(m.signature);
|
|
1760
|
+
if (!fields || !nonce || !m.content || typeof m.content !== "object" || Array.isArray(m.content)) return "invalid";
|
|
1761
|
+
// Cached by a SHA-256 of the signed input: an envelope changed under the same id and signature is checked again.
|
|
1762
|
+
// The input is serialized once, before any await: the digest and the check below cover the same bytes.
|
|
1763
|
+
const fieldsJson = JSON.stringify(fields);
|
|
1764
|
+
const contentJcs = jcs(m.content);
|
|
1765
|
+
const signature = m.signature;
|
|
1766
|
+
const id = m.id;
|
|
1767
|
+
const cacheKey = `${id}|${signature}`;
|
|
1768
|
+
const digest = await signedInputDigest(fieldsJson, contentJcs, signature);
|
|
1769
|
+
const cached = this.plainChecked.get(cacheKey);
|
|
1770
|
+
if (cached?.digest === digest) {
|
|
1771
|
+
this.plainByEnvelope.set(m, { q: conversationId, snapshot, status: cached.status });
|
|
1772
|
+
return cached.status;
|
|
1773
|
+
}
|
|
1774
|
+
let key: Uint8Array | undefined;
|
|
1775
|
+
try {
|
|
1776
|
+
key = (await this.verifiedDevices(fields.author))?.get(fields.device_id) ?? (await this.verifiedDevices(fields.author, true))?.get(fields.device_id);
|
|
1777
|
+
} catch {
|
|
1778
|
+
key = undefined;
|
|
1779
|
+
}
|
|
1780
|
+
// Unknown device key: may become known later (not cached).
|
|
1781
|
+
if (!key) return "unverifiable";
|
|
1782
|
+
let status: PlainStatus;
|
|
1783
|
+
try {
|
|
1784
|
+
status = verifyPlaintext(fieldsJson, contentJcs, signature, key) ? "valid" : "invalid";
|
|
1785
|
+
} catch {
|
|
1786
|
+
return "unverifiable";
|
|
1787
|
+
}
|
|
1788
|
+
if (status === "valid") {
|
|
1789
|
+
// A second message with the same nonce in a conversation is a replay (§10.3).
|
|
1790
|
+
const replayKey = `${conversationId}|${nonce}`;
|
|
1791
|
+
const seen = this.plainNonces.get(replayKey);
|
|
1792
|
+
if (seen && seen !== id) status = "replay";
|
|
1793
|
+
else this.plainNonces.set(replayKey, id);
|
|
1794
|
+
}
|
|
1795
|
+
this.plainChecked.set(cacheKey, { digest, status });
|
|
1796
|
+
this.plainByEnvelope.set(m, { q: conversationId, snapshot, status });
|
|
1797
|
+
return status;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// ---- Verified contacts (FEDERATION.md §10.1, §10.2) -------------------------------------------
|
|
1801
|
+
|
|
1802
|
+
/** Verification state of a contact: compares the verified key with the currently pinned key. */
|
|
1803
|
+
verification(address: string): VerificationState {
|
|
1804
|
+
const v = this.verified[address];
|
|
1805
|
+
if (!v) return "unverified";
|
|
1806
|
+
const pinned = this.deps.pins[address]?.key;
|
|
1807
|
+
if (pinned === v.identity_public_key) return "verified";
|
|
1808
|
+
if (v.rotated_to && pinned === v.rotated_to) return "refresh";
|
|
1809
|
+
return "broken";
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/** Verified, broken and to-be-refreshed contacts (for the UI store); a dismissed refresh notice is left out. */
|
|
1813
|
+
verificationStates(): Record<string, "verified" | "broken" | "refresh"> {
|
|
1814
|
+
const out: Record<string, "verified" | "broken" | "refresh"> = {};
|
|
1815
|
+
for (const address of Object.keys(this.verified)) {
|
|
1816
|
+
const state = this.verification(address);
|
|
1817
|
+
if (state === "refresh" && this.verified[address]?.refresh_dismissed) continue;
|
|
1818
|
+
if (state !== "unverified") out[address] = state;
|
|
1819
|
+
}
|
|
1820
|
+
return out;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/** Hides the "verification needs a refresh" notice of a contact (the safety number can be compared any time). */
|
|
1824
|
+
async dismissVerificationRefresh(address: string): Promise<void> {
|
|
1825
|
+
const v = this.verified[address];
|
|
1826
|
+
if (!v || this.verification(address) !== "refresh") return;
|
|
1827
|
+
v.refresh_dismissed = true;
|
|
1828
|
+
await this.saveVerified();
|
|
1829
|
+
this.deps.onEvent?.({ type: "verification", address });
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
verifiedContact(address: string): VerifiedContact | undefined {
|
|
1833
|
+
return this.verified[address];
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
/** The contact's pinned identity key (pinning it on first use); null if none is known. */
|
|
1837
|
+
private async contactKey(address: string): Promise<string | null> {
|
|
1838
|
+
if (address === this.deps.address) return null;
|
|
1839
|
+
if (!this.deps.pins[address]) await this.verifiedDevices(address).catch(() => null);
|
|
1840
|
+
return this.deps.pins[address]?.key ?? null;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/** Safety number (60 digits) and QR payload of this user and `address`; null without a pinned key. */
|
|
1844
|
+
async safetyNumber(address: string): Promise<{ number: string; payload: string } | null> {
|
|
1845
|
+
const contact = await this.contactKey(address);
|
|
1846
|
+
if (!contact) return null;
|
|
1847
|
+
const own = b64urlDecode(this.deps.ownIdentityKey());
|
|
1848
|
+
const theirs = b64urlDecode(contact);
|
|
1849
|
+
return { number: safetyNumber(this.deps.address, own, address, theirs), payload: verifyPayload(this.deps.address, own, address, theirs) };
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/** Scanning rule (§10.1): the payload must name this user with the own key and the contact with the pin. */
|
|
1853
|
+
comparePayload(address: string, payload: string): boolean {
|
|
1854
|
+
const pin = this.deps.pins[address];
|
|
1855
|
+
if (!pin || address === this.deps.address) return false;
|
|
1856
|
+
try {
|
|
1857
|
+
return verifyPayloadMatches(payload, this.deps.address, b64urlDecode(this.deps.ownIdentityKey()), address, b64urlDecode(pin.key));
|
|
1858
|
+
} catch {
|
|
1859
|
+
return false;
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/** Marks the contact's currently pinned key as verified; also acknowledges a pending key change. */
|
|
1864
|
+
async markVerified(address: string, method: VerifiedContact["method"]): Promise<boolean> {
|
|
1865
|
+
const key = await this.contactKey(address);
|
|
1866
|
+
if (!key) return false;
|
|
1867
|
+
this.verified[address] = { address, identity_public_key: key, verified_at: new Date(this.now()).toISOString(), method };
|
|
1868
|
+
await this.saveVerified();
|
|
1869
|
+
await this.acknowledgeKeyChange(address);
|
|
1870
|
+
this.deps.onEvent?.({ type: "verification", address });
|
|
1871
|
+
return true;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
async removeVerification(address: string): Promise<void> {
|
|
1875
|
+
if (!this.verified[address]) return;
|
|
1876
|
+
delete this.verified[address];
|
|
1877
|
+
this.removedVerified.add(address);
|
|
1878
|
+
await this.saveVerified();
|
|
1879
|
+
this.deps.onEvent?.({ type: "verification", address });
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/** "I trust the new key": accepts a changed key without verification (the contact becomes unverified). */
|
|
1883
|
+
async acceptNewKey(address: string): Promise<void> {
|
|
1884
|
+
await this.removeVerification(address);
|
|
1885
|
+
await this.acknowledgeKeyChange(address);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// ---- Devices, pins, ghosts ------------------------------------------------------------------
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Attested devices of `address` (device id → key), as published by the user's home node and verified
|
|
1892
|
+
* against the pinned identity key; null for malformed addresses. Pins on first use; a changed key is
|
|
1893
|
+
* re-pinned with `changed` set (the "security key changed" notice) and reported. Lookups are cached
|
|
1894
|
+
* for {@link KEY_TTL_MS} (devices added or removed elsewhere show up after that at the latest).
|
|
1895
|
+
*/
|
|
1896
|
+
verifiedDevices(address: string, refresh = false): Promise<Map<string, Uint8Array> | null> {
|
|
1897
|
+
const cached = this.keyCache.get(address);
|
|
1898
|
+
// A refresh reuses a lookup that is still running or just finished (several misses at once).
|
|
1899
|
+
if (cached && (cached.pending || this.now() - cached.at < (refresh ? KEY_REFETCH_MS : KEY_TTL_MS))) return cached.devices;
|
|
1900
|
+
const entry = { at: this.now(), pending: true, devices: Promise.resolve<Map<string, Uint8Array> | null>(null) };
|
|
1901
|
+
entry.devices = this.loadDevices(address).then(
|
|
1902
|
+
(d) => {
|
|
1903
|
+
entry.pending = false;
|
|
1904
|
+
entry.at = this.now();
|
|
1905
|
+
return d;
|
|
1906
|
+
},
|
|
1907
|
+
(e: unknown) => {
|
|
1908
|
+
if (e instanceof KeysDocumentError) {
|
|
1909
|
+
// Not transient: the refusal stands for the lookup's lifetime.
|
|
1910
|
+
entry.pending = false;
|
|
1911
|
+
entry.at = this.now();
|
|
1912
|
+
} else if (this.keyCache.get(address) === entry) this.keyCache.delete(address);
|
|
1913
|
+
throw e;
|
|
1914
|
+
},
|
|
1915
|
+
);
|
|
1916
|
+
this.keyCache.set(address, entry);
|
|
1917
|
+
return entry.devices;
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
private async loadDevices(address: string): Promise<Map<string, Uint8Array> | null> {
|
|
1921
|
+
if (address.indexOf("@") < 1) return null;
|
|
1922
|
+
// Always the user's home node (FEDERATION.md §3.5), whichever node hosts the conversation.
|
|
1923
|
+
const keys = await this.deps.api.keys(address);
|
|
1924
|
+
const out = new Map<string, Uint8Array>();
|
|
1925
|
+
if (keys.user !== address) return out;
|
|
1926
|
+
const counted = (list: unknown) => (Array.isArray(list) ? list.length : 0);
|
|
1927
|
+
if (counted(keys.devices) > KEYS_MAX_DEVICES) throw new KeysDocumentError(address, `${counted(keys.devices)} devices, at most ${KEYS_MAX_DEVICES} accepted`);
|
|
1928
|
+
if (counted(keys.retired) > KEYS_MAX_RETIRED) throw new KeysDocumentError(address, `${counted(keys.retired)} retired devices, at most ${KEYS_MAX_RETIRED} accepted`);
|
|
1929
|
+
if (counted(keys.continuity) > KEYS_MAX_CONTINUITY) throw new KeysDocumentError(address, `${counted(keys.continuity)} continuity statements, at most ${KEYS_MAX_CONTINUITY} accepted`);
|
|
1930
|
+
// Older nodes do not send `kind`: their accounts count as users.
|
|
1931
|
+
this.kinds.set(address, typeof keys.kind === "string" ? keys.kind : "user");
|
|
1932
|
+
if (!keys.identity_public_key) return out;
|
|
1933
|
+
const published = keys.identity_public_key;
|
|
1934
|
+
let trusted = published;
|
|
1935
|
+
if (address === this.deps.address) {
|
|
1936
|
+
trusted = this.deps.ownIdentityKey();
|
|
1937
|
+
} else {
|
|
1938
|
+
// First use: the trust record may hold a pin from before a sign-out (§7); it is synced before anything is pinned.
|
|
1939
|
+
if (!this.deps.pins[address]) await this.trustBeforePinning();
|
|
1940
|
+
const pin = this.deps.pins[address];
|
|
1941
|
+
if (!pin) {
|
|
1942
|
+
this.deps.pins[address] = { key: published };
|
|
1943
|
+
// Pinned before the trust record could be synced: checked against it at the first sync (§7).
|
|
1944
|
+
if (!this.deps.meta.trustSynced) {
|
|
1945
|
+
this.deps.meta.unconfirmedPins = [...new Set([...(this.deps.meta.unconfirmedPins ?? []), address])];
|
|
1946
|
+
await this.deps.saveMeta(this.deps.meta);
|
|
1947
|
+
}
|
|
1948
|
+
await this.savePins();
|
|
1949
|
+
} else if (pin.key !== published && chainReaches(keys.continuity, pin.key, published)) {
|
|
1950
|
+
// Rotation with continuity (§9a): signed by the pinned key, pinned at once with a soft notice. An earlier,
|
|
1951
|
+
// unacknowledged change keeps its warning. A verified contact needs a fresh verification (not blocking).
|
|
1952
|
+
this.deps.pins[address] = pin.changed ? { key: published, changed: true, rotated: true } : { key: published, rotated: true };
|
|
1953
|
+
await this.savePins();
|
|
1954
|
+
const v = this.verified[address];
|
|
1955
|
+
if (v && (v.identity_public_key === pin.key || v.rotated_to === pin.key)) {
|
|
1956
|
+
v.rotated_to = published;
|
|
1957
|
+
delete v.refresh_dismissed;
|
|
1958
|
+
await this.saveVerified();
|
|
1959
|
+
}
|
|
1960
|
+
this.deps.onEvent?.({ type: "key_rotated", address });
|
|
1961
|
+
} else if (pin.key !== published) {
|
|
1962
|
+
this.deps.pins[address] = { key: published, changed: true };
|
|
1963
|
+
await this.savePins();
|
|
1964
|
+
this.deps.onEvent?.({ type: "key_changed", address });
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
if (published !== trusted) return out;
|
|
1968
|
+
const identityKey = b64urlDecode(trusted);
|
|
1969
|
+
// Keys before the current one on the valid chain: retired devices attested under them stay verifiable (§3.3).
|
|
1970
|
+
const previous = keys.continuity?.length ? chainKeys(keys.continuity, trusted) : null;
|
|
1971
|
+
const shareKeys = new Map<string, Uint8Array>();
|
|
1972
|
+
for (const d of keys.devices) {
|
|
1973
|
+
try {
|
|
1974
|
+
const pk = b64urlDecode(d.device_public_key);
|
|
1975
|
+
if (!verifyDeviceAttestation(address, d.device_id, pk, b64urlDecode(d.identity_signature), identityKey)) continue;
|
|
1976
|
+
out.set(d.device_id, pk);
|
|
1977
|
+
// §3.1: a share key counts only with a valid signature of the attested device key.
|
|
1978
|
+
if (d.share_public_key && d.share_key_signature) {
|
|
1979
|
+
const share = b64urlDecode(d.share_public_key);
|
|
1980
|
+
if (verifyDeviceShareKey(d.device_id, pk, share, b64urlDecode(d.share_key_signature))) shareKeys.set(d.device_id, share);
|
|
1981
|
+
}
|
|
1982
|
+
} catch {
|
|
1983
|
+
/* malformed entry: not verified */
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
this.shareKeyMap.set(address, shareKeys);
|
|
1987
|
+
// Own devices attested under the own identity key: evidence for a later retired re-attestation (§4.7).
|
|
1988
|
+
if (address === this.deps.address) await this.recordOwnDevices(out).catch(() => undefined);
|
|
1989
|
+
// Retired devices (§2.3): verified under the same pinned key; only used for history (§5.2).
|
|
1990
|
+
const retired = new Map<string, { key: Uint8Array; deletedAt: number }>();
|
|
1991
|
+
for (const d of keys.retired ?? []) {
|
|
1992
|
+
try {
|
|
1993
|
+
const pk = b64urlDecode(d.device_public_key);
|
|
1994
|
+
const deletedAt = Date.parse(d.deleted_at);
|
|
1995
|
+
if (out.has(d.device_id) || !Number.isFinite(deletedAt)) continue;
|
|
1996
|
+
const under = d.attested_identity_key && previous?.has(d.attested_identity_key) ? b64urlDecode(d.attested_identity_key) : identityKey;
|
|
1997
|
+
if (verifyDeviceAttestation(address, d.device_id, pk, b64urlDecode(d.identity_signature), under)) retired.set(d.device_id, { key: pk, deletedAt });
|
|
1998
|
+
} catch {
|
|
1999
|
+
/* malformed entry: not verified */
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
this.retiredKeys.set(address, retired);
|
|
2003
|
+
return out;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/**
|
|
2007
|
+
* The verified share keys of `address`'s attested devices (device id → X25519 public key, ENCRYPTION.md §3.1) from
|
|
2008
|
+
* the device lookup ({@link verifiedDevices}); devices without one (or with an invalid signature) are missing.
|
|
2009
|
+
*/
|
|
2010
|
+
async verifiedShareKeys(address: string, refresh = false): Promise<Map<string, Uint8Array>> {
|
|
2011
|
+
await this.verifiedDevices(address, refresh);
|
|
2012
|
+
return new Map(this.shareKeyMap.get(address) ?? []);
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/** This device's share public key (ENCRYPTION.md §3.1). */
|
|
2016
|
+
ownShareKey(): Uint8Array {
|
|
2017
|
+
const k = this.client.shareKey();
|
|
2018
|
+
try {
|
|
2019
|
+
return k.publicKey;
|
|
2020
|
+
} finally {
|
|
2021
|
+
k.free();
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
/**
|
|
2026
|
+
* The verified retired attestation of `address`/`deviceId` from the last device lookup (call
|
|
2027
|
+
* {@link verifiedDevices} first), or undefined.
|
|
2028
|
+
*/
|
|
2029
|
+
retiredDevice(address: string, deviceId: string): { key: Uint8Array; deletedAt: number } | undefined {
|
|
2030
|
+
return this.retiredKeys.get(address)?.get(deviceId);
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/** Leaves of the local group with their signature keys (empty without one). */
|
|
2034
|
+
rosterEntries(q: string): { identity: string; key: Uint8Array }[] {
|
|
2035
|
+
if (!this.client.hasGroup(q)) return [];
|
|
2036
|
+
return this.client.roster(q).map((e) => {
|
|
2037
|
+
const r = { identity: e.identity, key: e.signatureKey };
|
|
2038
|
+
e.free();
|
|
2039
|
+
return r;
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/**
|
|
2044
|
+
* Drops the cached devices of a user (devices changed, a device re-attested as retired); the next lookup asks the
|
|
2045
|
+
* node again, and failed results of that user's messages are tried again.
|
|
2046
|
+
*/
|
|
2047
|
+
forgetDevices(address: string): void {
|
|
2048
|
+
this.keyCache.delete(address);
|
|
2049
|
+
this.decrypted.deleteWhere((v) => !v.result.ok);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/** `identity.changed` of another user: drop cached devices so the next lookup re-pins (with notice). */
|
|
2053
|
+
identityChanged(address: string): void {
|
|
2054
|
+
this.keyCache.delete(address);
|
|
2055
|
+
this.decrypted.deleteWhere((v) => !v.result.ok);
|
|
2056
|
+
this.plainChecked.deleteWhere((v) => v.status !== "valid");
|
|
2057
|
+
// The per-object results cannot be filtered: they are dropped (the cache above answers the valid ones again).
|
|
2058
|
+
this.plainByEnvelope = new WeakMap();
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
/** Users whose identity key changed and whose notice is not acknowledged yet. */
|
|
2062
|
+
changedKeys(): string[] {
|
|
2063
|
+
return Object.entries(this.deps.pins)
|
|
2064
|
+
.filter(([, p]) => p.changed)
|
|
2065
|
+
.map(([a]) => a);
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/** Users whose key was rotated with continuity and whose soft notice is not dismissed yet (and nothing worse). */
|
|
2069
|
+
rotatedKeys(): string[] {
|
|
2070
|
+
return Object.entries(this.deps.pins)
|
|
2071
|
+
.filter(([, p]) => p.rotated && !p.changed)
|
|
2072
|
+
.map(([a]) => a);
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
/** Acknowledges the warning of a changed key and dismisses the notice of a rotated one. */
|
|
2076
|
+
async acknowledgeKeyChange(address: string): Promise<void> {
|
|
2077
|
+
const pin = this.deps.pins[address];
|
|
2078
|
+
if (!pin?.changed && !pin?.rotated) return;
|
|
2079
|
+
this.deps.pins[address] = { key: pin.key };
|
|
2080
|
+
await this.savePins();
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
/**
|
|
2084
|
+
* `kind` of `address` as its home node published it with its keys (`user`, `bot`, …), or undefined before the
|
|
2085
|
+
* first lookup ({@link verifiedDevices}).
|
|
2086
|
+
*/
|
|
2087
|
+
kindOf(address: string): string | undefined {
|
|
2088
|
+
return this.kinds.get(address);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/**
|
|
2092
|
+
* Ghost member check (§4.6): every leaf's user must be in `members` (addresses), every leaf must
|
|
2093
|
+
* carry an attested key under the pinned identity key, and no identity may appear twice. With `noBots`
|
|
2094
|
+
* (the conversation's effective "no bots" flag) a leaf of a bot is a ghost as well, whatever the member
|
|
2095
|
+
* list says: `kind` comes from the bot's home node, so a lying room node cannot disguise it (APPLICATIONS.md §3.5).
|
|
2096
|
+
*/
|
|
2097
|
+
async ghostCheck(q: string, members: Iterable<string>, opts: GhostCheckOptions = {}): Promise<GhostViolation[]> {
|
|
2098
|
+
const list = [...members];
|
|
2099
|
+
const out = await this.ghostCheckInner(q, list, opts);
|
|
2100
|
+
// The members guard the checks before encrypting; the violations stay unacknowledged until acknowledgeGhosts.
|
|
2101
|
+
this.lastGhosts.set(q, { members: list, noBots: !!opts.noBots });
|
|
2102
|
+
if (this.client.hasGroup(q)) this.ghostCache.set(q, { epoch: this.client.currentEpoch(q), found: out });
|
|
2103
|
+
return out;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
private async ghostCheckInner(q: string, members: Iterable<string>, opts: GhostCheckOptions = {}): Promise<GhostViolation[]> {
|
|
2107
|
+
if (!this.client.hasGroup(q)) return [];
|
|
2108
|
+
const entries = this.client.roster(q).map((e) => {
|
|
2109
|
+
const r = { identity: e.identity, key: e.signatureKey };
|
|
2110
|
+
e.free();
|
|
2111
|
+
return r;
|
|
2112
|
+
});
|
|
2113
|
+
const allowed = new Set(members);
|
|
2114
|
+
/** Identities whose attested leaf was seen: a second leaf with the attested key is a duplicate. */
|
|
2115
|
+
const matched = new Set<string>();
|
|
2116
|
+
const out: GhostViolation[] = [];
|
|
2117
|
+
const ownKey = this.client.devicePublicKey;
|
|
2118
|
+
const isOwnLeaf = (e: { identity: string; key: Uint8Array }) => e.identity === this.client.identity && sameBytes(e.key, ownKey);
|
|
2119
|
+
// Every member's devices are looked up at once (one round trip instead of one per user), then a second time, also
|
|
2120
|
+
// at once, for the users whose leaf names a device the first answer did not list (a device added since).
|
|
2121
|
+
const lookups = new Map<string, Promise<Map<string, Uint8Array> | null | undefined>>();
|
|
2122
|
+
for (const e of entries) {
|
|
2123
|
+
const { address } = splitIdentity(e.identity);
|
|
2124
|
+
if (isOwnLeaf(e) || !allowed.has(address) || lookups.has(address)) continue;
|
|
2125
|
+
// Before acting on a violation: look the user's devices up again.
|
|
2126
|
+
if (opts.fresh && !this.keyCache.get(address)?.pending) this.keyCache.delete(address);
|
|
2127
|
+
lookups.set(address, this.verifiedDevices(address).catch(() => undefined));
|
|
2128
|
+
}
|
|
2129
|
+
const refreshes = new Map<string, Promise<Map<string, Uint8Array> | null | undefined>>();
|
|
2130
|
+
for (const e of entries) {
|
|
2131
|
+
const { address, deviceId } = splitIdentity(e.identity);
|
|
2132
|
+
const first = lookups.get(address);
|
|
2133
|
+
if (!first || refreshes.has(address)) continue;
|
|
2134
|
+
const devices = await first;
|
|
2135
|
+
// A failed lookup is not repeated (the leaf counts as unattested, as before).
|
|
2136
|
+
if (devices === undefined || devices?.get(deviceId)) continue;
|
|
2137
|
+
refreshes.set(address, this.verifiedDevices(address, true).catch(() => undefined));
|
|
2138
|
+
}
|
|
2139
|
+
for (const e of entries) {
|
|
2140
|
+
const { address, deviceId } = splitIdentity(e.identity);
|
|
2141
|
+
// This device's own leaf is never a ghost (another leaf under its name is judged like any other).
|
|
2142
|
+
if (isOwnLeaf(e) && !matched.has(e.identity)) {
|
|
2143
|
+
matched.add(e.identity);
|
|
2144
|
+
continue;
|
|
2145
|
+
}
|
|
2146
|
+
if (!allowed.has(address)) {
|
|
2147
|
+
out.push({ identity: e.identity, reason: "not_member", key: b64urlEncode(e.key) });
|
|
2148
|
+
continue;
|
|
2149
|
+
}
|
|
2150
|
+
let key: Uint8Array | undefined;
|
|
2151
|
+
const first = await (lookups.get(address) ?? this.verifiedDevices(address).catch(() => undefined));
|
|
2152
|
+
if (first !== undefined) key = first?.get(deviceId) ?? (await (refreshes.get(address) ?? this.verifiedDevices(address, true).catch(() => undefined)))?.get(deviceId);
|
|
2153
|
+
if (opts.noBots && isBotKind(this.kinds.get(address))) {
|
|
2154
|
+
out.push({ identity: e.identity, reason: "bot", key: b64urlEncode(e.key) });
|
|
2155
|
+
continue;
|
|
2156
|
+
}
|
|
2157
|
+
// A leaf naming an attested device with another key (planted or disguised) is unattested, whichever comes first.
|
|
2158
|
+
if (!key || !sameBytes(key, e.key)) out.push({ identity: e.identity, reason: "unattested", key: b64urlEncode(e.key) });
|
|
2159
|
+
else if (matched.has(e.identity)) out.push({ identity: e.identity, reason: "duplicate", key: b64urlEncode(e.key) });
|
|
2160
|
+
else if (this.deps.pins[address]?.changed) {
|
|
2161
|
+
matched.add(e.identity);
|
|
2162
|
+
out.push({ identity: e.identity, reason: "key_changed", key: b64urlEncode(e.key) });
|
|
2163
|
+
} else matched.add(e.identity);
|
|
2164
|
+
}
|
|
2165
|
+
return out;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
// ---- Trust record (ENCRYPTION.md §7 "Trust record", trust.ts) --------------------------------
|
|
2169
|
+
|
|
2170
|
+
private trustTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2171
|
+
private trustRun: Promise<void> | null = null;
|
|
2172
|
+
/** The last failed trust sync before a first-use pin (retried at most every {@link TRUST_RETRY_MS}). */
|
|
2173
|
+
private trustFailedAt = 0;
|
|
2174
|
+
|
|
2175
|
+
/**
|
|
2176
|
+
* Holds first-use pinning until the trust record was synced once on this device (a restored pin then counts instead
|
|
2177
|
+
* of whatever key the node serves first). A sync that fails is retried at most every minute; meanwhile pins are taken
|
|
2178
|
+
* on first use and checked against the record at the next sync (conflicts count as changed keys).
|
|
2179
|
+
*/
|
|
2180
|
+
private async trustBeforePinning(): Promise<void> {
|
|
2181
|
+
if (this.deps.meta.trustSynced || this.now() - this.trustFailedAt < TRUST_RETRY_MS) return;
|
|
2182
|
+
try {
|
|
2183
|
+
await this.syncTrust();
|
|
2184
|
+
} catch {
|
|
2185
|
+
this.trustFailedAt = this.now();
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/** Verified contacts removed on this device since the last upload (not taken back from the stored record). */
|
|
2190
|
+
private readonly removedVerified = new Set<string>();
|
|
2191
|
+
|
|
2192
|
+
private get trustConversation(): string {
|
|
2193
|
+
return qualify(this.deps.homeDomain, TRUST_BACKUP_ULID);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
private async savePins(): Promise<void> {
|
|
2197
|
+
await this.deps.savePins(this.deps.pins);
|
|
2198
|
+
this.trustChanged();
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
private async saveVerified(): Promise<void> {
|
|
2202
|
+
await this.deps.saveVerified?.(this.verified);
|
|
2203
|
+
this.trustChanged();
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
private trustChanged(): void {
|
|
2207
|
+
if (this.disposed) return;
|
|
2208
|
+
if (this.trustTimer) clearTimeout(this.trustTimer);
|
|
2209
|
+
this.trustTimer = setTimeout(() => {
|
|
2210
|
+
this.trustTimer = null;
|
|
2211
|
+
void this.syncTrust().catch(() => undefined);
|
|
2212
|
+
}, this.deps.backupDelayMs ?? 1000);
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
private flushTrust(): Promise<void> {
|
|
2216
|
+
if (this.trustTimer) clearTimeout(this.trustTimer);
|
|
2217
|
+
this.trustTimer = null;
|
|
2218
|
+
return this.syncTrust().catch(() => undefined);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
/** Adds own devices to the record (an id that is already recorded keeps its key). */
|
|
2222
|
+
private async recordOwnDevices(devices: Iterable<[string, Uint8Array]>): Promise<void> {
|
|
2223
|
+
const record = (this.deps.meta.ownDevices ??= {});
|
|
2224
|
+
let added = false;
|
|
2225
|
+
for (const [deviceId, key] of devices) {
|
|
2226
|
+
if (record[deviceId]) continue;
|
|
2227
|
+
record[deviceId] = b64urlEncode(key);
|
|
2228
|
+
added = true;
|
|
2229
|
+
}
|
|
2230
|
+
if (!added) return;
|
|
2231
|
+
await this.deps.saveMeta(this.deps.meta);
|
|
2232
|
+
this.trustChanged();
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
/**
|
|
2236
|
+
* Whether `deviceId` with exactly `key` is one of the account's own devices (ENCRYPTION.md §4.7): recorded here or
|
|
2237
|
+
* in the trust record on the home node, which is fetched again when the local record does not know it. Nothing the
|
|
2238
|
+
* node says outside the encrypted record counts: a leaf it planted under the user's address is never vouched for.
|
|
2239
|
+
*/
|
|
2240
|
+
async knownOwnDevice(deviceId: string, key: Uint8Array): Promise<boolean> {
|
|
2241
|
+
const has = () => this.deps.meta.ownDevices?.[deviceId] === b64urlEncode(key);
|
|
2242
|
+
if (has()) return true;
|
|
2243
|
+
await this.syncTrust().catch(() => undefined);
|
|
2244
|
+
return has();
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* Merges the trust record with the one stored on the home node in both directions: entries this device lacks are
|
|
2249
|
+
* taken over, and the stored record is replaced (If-Match) when it lacks something this device has. Requires an
|
|
2250
|
+
* unlocked store (the backup key). Concurrent calls share one run.
|
|
2251
|
+
*/
|
|
2252
|
+
syncTrust(): Promise<void> {
|
|
2253
|
+
if (this.trustRun) return this.trustRun;
|
|
2254
|
+
const run = this.syncTrustInner().finally(() => {
|
|
2255
|
+
this.trustRun = null;
|
|
2256
|
+
});
|
|
2257
|
+
this.trustRun = run;
|
|
2258
|
+
return run;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
private async trustDone(): Promise<void> {
|
|
2262
|
+
if (this.deps.meta.trustSynced) return;
|
|
2263
|
+
this.deps.meta.trustSynced = true;
|
|
2264
|
+
delete this.deps.meta.unconfirmedPins;
|
|
2265
|
+
await this.deps.saveMeta(this.deps.meta);
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
/**
|
|
2269
|
+
* A pin taken here on first use before the first sync and another key in the stored record for the same user: the
|
|
2270
|
+
* stored key was seen first (before a sign-out, or on another device). A continuity chain from the stored key to this one is a rotation (kept); one from
|
|
2271
|
+
* this key to the stored one means the record is newer (taken, as a rotation); anything else is a changed key the
|
|
2272
|
+
* user has to acknowledge (`changed`, §3.3). True when a pin changed.
|
|
2273
|
+
*/
|
|
2274
|
+
private async resolvePinConflicts(pins: Record<string, Pin>, stored: Record<string, Pin>): Promise<boolean> {
|
|
2275
|
+
let changed = false;
|
|
2276
|
+
for (const [address, theirs] of Object.entries(stored)) {
|
|
2277
|
+
const ours = pins[address];
|
|
2278
|
+
// Only pins taken on first use before the record was synced; a key the user accepted here is a decision.
|
|
2279
|
+
if (!ours || ours.key === theirs.key || !this.deps.meta.unconfirmedPins?.includes(address)) continue;
|
|
2280
|
+
const chain = await this.deps.api
|
|
2281
|
+
.keys(address)
|
|
2282
|
+
.then((k) => (k.user === address ? k.continuity : null))
|
|
2283
|
+
.catch(() => null);
|
|
2284
|
+
if (chainReaches(chain, theirs.key, ours.key)) continue;
|
|
2285
|
+
if (chainReaches(chain, ours.key, theirs.key)) {
|
|
2286
|
+
pins[address] = { ...theirs, rotated: true };
|
|
2287
|
+
} else {
|
|
2288
|
+
if (ours.changed) continue;
|
|
2289
|
+
pins[address] = { key: ours.key, changed: true };
|
|
2290
|
+
this.deps.onEvent?.({ type: "key_changed", address });
|
|
2291
|
+
}
|
|
2292
|
+
this.keyCache.delete(address);
|
|
2293
|
+
changed = true;
|
|
2294
|
+
}
|
|
2295
|
+
return changed;
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
private async syncTrustInner(): Promise<void> {
|
|
2299
|
+
const home = this.deps.homeDomain;
|
|
2300
|
+
const key = this.deps.backupKey();
|
|
2301
|
+
try {
|
|
2302
|
+
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
|
2303
|
+
const remote = await this.deps.api.backupGet(home, TRUST_BACKUP_ULID);
|
|
2304
|
+
const stored = remote ? await openTrust(key, this.deps.address, remote.blob) : null;
|
|
2305
|
+
const local: TrustRecord = { pins: this.deps.pins, verified: this.verified, devices: (this.deps.meta.ownDevices ??= {}) };
|
|
2306
|
+
const resolved = stored ? await this.resolvePinConflicts(local.pins, stored.pins) : false;
|
|
2307
|
+
if (stored && (mergeTrust(local, stored, this.removedVerified) || resolved)) {
|
|
2308
|
+
await this.deps.savePins(this.deps.pins);
|
|
2309
|
+
await this.deps.saveVerified?.(this.verified);
|
|
2310
|
+
await this.deps.saveMeta(this.deps.meta);
|
|
2311
|
+
}
|
|
2312
|
+
// What this device has wins; the stored record keeps what only it has (other devices' entries).
|
|
2313
|
+
const upload: TrustRecord = { pins: { ...local.pins }, verified: { ...local.verified }, devices: { ...local.devices } };
|
|
2314
|
+
if (stored) mergeTrust(upload, stored, this.removedVerified);
|
|
2315
|
+
if (stored && jcs(upload) === jcs(stored)) {
|
|
2316
|
+
this.removedVerified.clear();
|
|
2317
|
+
await this.trustDone();
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
const blob = await sealTrust(key, this.deps.address, upload);
|
|
2321
|
+
try {
|
|
2322
|
+
await this.deps.api.backupPut(home, TRUST_BACKUP_ULID, blob, remote?.etag ?? null);
|
|
2323
|
+
this.removedVerified.clear();
|
|
2324
|
+
await this.trustDone();
|
|
2325
|
+
return;
|
|
2326
|
+
} catch (e) {
|
|
2327
|
+
if (apiCode(e) !== "backup.etag_mismatch") throw e;
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
} finally {
|
|
2331
|
+
key.fill(0);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// ---- Backup ---------------------------------------------------------------------------------
|
|
2336
|
+
|
|
2337
|
+
private markDirty(conversationId: string) {
|
|
2338
|
+
if (this.disposed) return;
|
|
2339
|
+
this.dirty.add(conversationId);
|
|
2340
|
+
this.savePending();
|
|
2341
|
+
if (this.backupTimer) clearTimeout(this.backupTimer);
|
|
2342
|
+
this.backupTimer = setTimeout(() => {
|
|
2343
|
+
this.backupTimer = null;
|
|
2344
|
+
void this.flushBackups().catch(() => undefined);
|
|
2345
|
+
}, this.deps.backupDelayMs ?? 1000);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
private savePending() {
|
|
2349
|
+
this.deps.meta.pendingBackups = [...this.dirty];
|
|
2350
|
+
this.saveMetaInBackground();
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/** Whether epoch keys of some conversation still wait for their backup upload (e.g. after a restart). */
|
|
2354
|
+
hasPendingBackups(): boolean {
|
|
2355
|
+
return this.dirty.size > 0 || !!this.deps.meta.pendingBackups?.length;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
/** Uploads the backups of conversations with new epoch keys (merge with the stored blob, If-Match). */
|
|
2359
|
+
flushBackups(): Promise<void> {
|
|
2360
|
+
// The trust record is merged and uploaded by whichever tab changed it (If-Match keeps concurrent writers apart).
|
|
2361
|
+
const trust = this.trustTimer ? this.flushTrust() : Promise.resolve();
|
|
2362
|
+
const remote = this.forwarded<void>("flushBackups", []);
|
|
2363
|
+
if (remote) return trust.then(() => remote);
|
|
2364
|
+
return trust.then(() => this.run(async () => {
|
|
2365
|
+
for (const c of this.deps.meta.pendingBackups ?? []) this.dirty.add(c);
|
|
2366
|
+
for (const c of [...this.dirty]) {
|
|
2367
|
+
this.dirty.delete(c);
|
|
2368
|
+
try {
|
|
2369
|
+
await this.backupOne(c);
|
|
2370
|
+
} catch {
|
|
2371
|
+
this.dirty.add(c); // retried with the next change or start
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
this.savePending();
|
|
2375
|
+
}));
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/** Backups live on the home node under the hosting node's domain and the conversation ULID (§7). */
|
|
2379
|
+
private async backupOne(q: string): Promise<void> {
|
|
2380
|
+
const { domain, id } = splitQualified(q);
|
|
2381
|
+
const key = this.deps.backupKey();
|
|
2382
|
+
try {
|
|
2383
|
+
for (let attempt = 0; attempt < MAX_TRIES; attempt++) {
|
|
2384
|
+
const remote = await this.deps.api.backupGet(domain, id);
|
|
2385
|
+
if (remote) {
|
|
2386
|
+
try {
|
|
2387
|
+
this.client.importBackup(q, key, remote.blob); // keys are never overwritten
|
|
2388
|
+
} catch {
|
|
2389
|
+
/* unreadable (older identity): replaced by ours */
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
const blob = this.client.exportBackup(q, key);
|
|
2393
|
+
try {
|
|
2394
|
+
await this.deps.api.backupPut(domain, id, blob, remote?.etag ?? null);
|
|
2395
|
+
await this.persist();
|
|
2396
|
+
return;
|
|
2397
|
+
} catch (e) {
|
|
2398
|
+
if (apiCode(e) !== "backup.etag_mismatch") throw e;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
} finally {
|
|
2402
|
+
key.fill(0);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
private async restoreOne(q: string): Promise<void> {
|
|
2407
|
+
const { domain, id } = splitQualified(q);
|
|
2408
|
+
const remote = await this.deps.api.backupGet(domain, id);
|
|
2409
|
+
if (!remote) return;
|
|
2410
|
+
const key = this.deps.backupKey();
|
|
2411
|
+
try {
|
|
2412
|
+
this.client.importBackup(q, key, remote.blob);
|
|
2413
|
+
await this.persist();
|
|
2414
|
+
} finally {
|
|
2415
|
+
key.fill(0);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
/** New device (§8 step 3): imports every backup of the account once. */
|
|
2420
|
+
restoreBackups(): Promise<number> {
|
|
2421
|
+
const remote = this.forwarded<number>("restoreBackups", []);
|
|
2422
|
+
if (remote) return remote;
|
|
2423
|
+
return this.run(async () => {
|
|
2424
|
+
if (this.deps.meta.restored) return 0;
|
|
2425
|
+
// The trust record first (pins, verified contacts, own devices, §7): a failure keeps `restored` false (retried).
|
|
2426
|
+
let trusted = true;
|
|
2427
|
+
await this.syncTrust().catch(() => {
|
|
2428
|
+
trusted = false;
|
|
2429
|
+
});
|
|
2430
|
+
const { items } = await this.deps.api.backupList();
|
|
2431
|
+
const key = this.deps.backupKey();
|
|
2432
|
+
let n = 0;
|
|
2433
|
+
try {
|
|
2434
|
+
for (const item of items) {
|
|
2435
|
+
if (item.conversation === this.trustConversation) continue;
|
|
2436
|
+
const slash = item.conversation.indexOf("/");
|
|
2437
|
+
const domain = item.conversation.slice(0, slash);
|
|
2438
|
+
const ulid = item.conversation.slice(slash + 1);
|
|
2439
|
+
try {
|
|
2440
|
+
const remote = await this.deps.api.backupGet(domain, ulid);
|
|
2441
|
+
if (remote) n += this.client.importBackup(item.conversation, key, remote.blob);
|
|
2442
|
+
} catch {
|
|
2443
|
+
/* one unreadable backup does not stop the others */
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
} finally {
|
|
2447
|
+
key.fill(0);
|
|
2448
|
+
}
|
|
2449
|
+
await this.persist();
|
|
2450
|
+
if (!trusted) return n;
|
|
2451
|
+
this.deps.meta.restored = true;
|
|
2452
|
+
await this.deps.saveMeta(this.deps.meta);
|
|
2453
|
+
return n;
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
// ---- History keys for new members (ENCRYPTION.md §7b) ------------------------------------------
|
|
2458
|
+
|
|
2459
|
+
/** Epochs whose content key this device holds, ascending. */
|
|
2460
|
+
knownEpochs(q: string): number[] {
|
|
2461
|
+
return [...this.client.knownEpochs(q)].sort((a, b) => a - b);
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2464
|
+
/** Copies of the content keys of `epochs` this device holds; the caller zeroes them after use. */
|
|
2465
|
+
exportEpochKeys(q: string, epochs: number[]): [number, Uint8Array][] {
|
|
2466
|
+
const known = new Set(this.client.knownEpochs(q));
|
|
2467
|
+
return epochs.filter((e) => known.has(e)).map((e) => [e, this.client.exportEpochKey(q, e)]);
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
/**
|
|
2471
|
+
* Encrypts a history share payload (canonical JSON) like message content under the group's current epoch, after
|
|
2472
|
+
* catching up, without rotating, in format 2 (`shareKeys`: device id → verified share public key of each recipient
|
|
2473
|
+
* device, ENCRYPTION.md §7b): under a fresh key, sealed to every given device in `boxes`; the key never leaves this
|
|
2474
|
+
* call. Format 1 (under the epoch's content key, readable by every leaf) is not sent any more.
|
|
2475
|
+
*/
|
|
2476
|
+
encryptHistory(q: string, json: string, shareKeys: Record<string, Uint8Array>): Promise<{ content: string; epoch: number; boxes: Record<string, string> }> {
|
|
2477
|
+
// Format 2 only (§7b): format 1 under the epoch key would be readable by every leaf, unattested ones included.
|
|
2478
|
+
if (!shareKeys || typeof shareKeys !== "object" || Object.keys(shareKeys).length === 0) return Promise.reject(new MlsError("invalid_id"));
|
|
2479
|
+
const remote = this.forwarded<{ content: string; epoch: number; boxes: Record<string, string> }>("encryptHistory", [q, json, shareKeys]);
|
|
2480
|
+
if (remote) return remote;
|
|
2481
|
+
return this.run(async () => {
|
|
2482
|
+
try {
|
|
2483
|
+
await this.catchUpInner(q);
|
|
2484
|
+
} catch {
|
|
2485
|
+
throw new MlsError("catch_up_failed");
|
|
2486
|
+
}
|
|
2487
|
+
if (!this.client.hasGroup(q)) throw new MlsError("not_joined");
|
|
2488
|
+
const devices = Object.entries(shareKeys);
|
|
2489
|
+
const share = this.client.encryptShare(q, json, devices.map(([, pk]) => pk));
|
|
2490
|
+
try {
|
|
2491
|
+
const sealed = share.boxes;
|
|
2492
|
+
const boxes = Object.fromEntries(devices.map(([deviceId], i) => [deviceId, b64urlEncode(sealed[i]!)]));
|
|
2493
|
+
return { content: share.content, epoch: share.epoch, boxes };
|
|
2494
|
+
} finally {
|
|
2495
|
+
share.free();
|
|
2496
|
+
}
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
/**
|
|
2501
|
+
* Decrypts a history share sent by `author`/`deviceId` and verifies the device signature against the
|
|
2502
|
+
* author's attested device (pinned identity key). Nothing is cached (shares never enter the message cache).
|
|
2503
|
+
*/
|
|
2504
|
+
async decryptHistory(q: string, shareId: string, author: string, deviceId: string, content: string, box?: string): Promise<Decrypted> {
|
|
2505
|
+
const header = contentHeader(content);
|
|
2506
|
+
if (!header) return { ok: false, reason: "invalid" };
|
|
2507
|
+
if (box === undefined) return this.decryptUncached(q, `history:${shareId}`, author, deviceId, header.epoch, content);
|
|
2508
|
+
// Format 2 (§7b): the box must open with this device's share key; its key replaces the epoch's content key.
|
|
2509
|
+
let deviceKey: Uint8Array | undefined;
|
|
2510
|
+
try {
|
|
2511
|
+
deviceKey = (await this.verifiedDevices(author))?.get(deviceId) ?? (await this.verifiedDevices(author, true))?.get(deviceId);
|
|
2512
|
+
} catch {
|
|
2513
|
+
deviceKey = undefined;
|
|
2514
|
+
}
|
|
2515
|
+
if (!deviceKey) return { ok: false, reason: "unknown_device" };
|
|
2516
|
+
if (this.client.hasGroup(q)) {
|
|
2517
|
+
const leaf = this.client.roster(q).find((e) => e.identity === `${author}/${deviceId}`);
|
|
2518
|
+
if (leaf && !sameBytes(leaf.signatureKey, deviceKey)) return { ok: false, reason: "unknown_device" };
|
|
2519
|
+
}
|
|
2520
|
+
let key: Uint8Array;
|
|
2521
|
+
try {
|
|
2522
|
+
key = this.client.openShareBox(b64urlDecode(box));
|
|
2523
|
+
} catch {
|
|
2524
|
+
return { ok: false, reason: "invalid" };
|
|
2525
|
+
}
|
|
2526
|
+
try {
|
|
2527
|
+
const parsed: unknown = JSON.parse(decryptContentWithKey(key, q, author, deviceId, content, deviceKey));
|
|
2528
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { ok: false, reason: "invalid" };
|
|
2529
|
+
return { ok: true, content: parsed as Record<string, unknown> };
|
|
2530
|
+
} catch (e) {
|
|
2531
|
+
return { ok: false, reason: cryptoCode(e) === "bad_signature" ? "bad_signature" : "invalid" };
|
|
2532
|
+
} finally {
|
|
2533
|
+
key.fill(0);
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
/**
|
|
2538
|
+
* Whether a candidate content key of `epoch` opens `sample` (a stored message of that epoch) **and** the plaintext
|
|
2539
|
+
* carries a valid signature of the sample author's attested device (§7b step 4, §5.2). The AES-GCM tag alone can be
|
|
2540
|
+
* produced by anyone who knows the key, so a sharer colluding with the node could otherwise "prove" a key of its
|
|
2541
|
+
* choice with a message the node made up. Nothing is stored or cached.
|
|
2542
|
+
*/
|
|
2543
|
+
async sampleProvesKey(q: string, epoch: number, key: Uint8Array, sample: { author: string; device_id: string; content: string }): Promise<boolean> {
|
|
2544
|
+
return (await this.checkSample(q, epoch, key, sample)) === "proven";
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/**
|
|
2548
|
+
* {@link sampleProvesKey} with the reason of a failure: `unknown_device` when the sample's author device is neither
|
|
2549
|
+
* attested nor known as retired (nothing can be told from it, for example the recipient's own device from before an
|
|
2550
|
+
* identity reset), `invalid` when the key does not open it or the signature does not verify.
|
|
2551
|
+
*/
|
|
2552
|
+
async checkSample(q: string, epoch: number, key: Uint8Array, sample: { author: string; device_id: string; content: string }): Promise<"proven" | "unknown_device" | "invalid"> {
|
|
2553
|
+
let deviceKey: Uint8Array | undefined;
|
|
2554
|
+
try {
|
|
2555
|
+
deviceKey = (await this.verifiedDevices(sample.author))?.get(sample.device_id) ?? (await this.verifiedDevices(sample.author, true))?.get(sample.device_id) ?? this.retiredDevice(sample.author, sample.device_id)?.key;
|
|
2556
|
+
} catch {
|
|
2557
|
+
deviceKey = undefined;
|
|
2558
|
+
}
|
|
2559
|
+
if (!deviceKey) return "unknown_device";
|
|
2560
|
+
const header = contentHeader(sample.content);
|
|
2561
|
+
if (!header || header.epoch !== epoch) return "invalid";
|
|
2562
|
+
try {
|
|
2563
|
+
// AES-GCM under the candidate key and the device signature, in one call without a client.
|
|
2564
|
+
decryptContentWithKey(key, q, sample.author, sample.device_id, sample.content, deviceKey);
|
|
2565
|
+
return "proven";
|
|
2566
|
+
} catch {
|
|
2567
|
+
return "invalid";
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
/**
|
|
2572
|
+
* Stores accepted history keys (validated by the caller) in the client state, exactly like own epoch keys:
|
|
2573
|
+
* persisted in the encrypted key store and included in the history backup. Keys never overwrite known
|
|
2574
|
+
* ones. Returns the number of new keys.
|
|
2575
|
+
*/
|
|
2576
|
+
importHistoryKeys(q: string, keys: [number, Uint8Array][]): Promise<number> {
|
|
2577
|
+
const remote = this.forwarded<number>("importHistoryKeys", [q, keys]);
|
|
2578
|
+
if (remote) return remote;
|
|
2579
|
+
return this.run(async () => {
|
|
2580
|
+
const known = new Set(this.client.knownEpochs(q));
|
|
2581
|
+
let n = 0;
|
|
2582
|
+
for (const [epoch, key] of keys) {
|
|
2583
|
+
if (known.has(epoch)) continue;
|
|
2584
|
+
this.client.importEpochKey(q, epoch, key);
|
|
2585
|
+
known.add(epoch);
|
|
2586
|
+
n++;
|
|
2587
|
+
}
|
|
2588
|
+
if (n === 0) return 0;
|
|
2589
|
+
await this.persist();
|
|
2590
|
+
// Messages that failed with a missing key are decrypted again.
|
|
2591
|
+
this.decrypted.deleteWhere((v) => !v.result.ok && v.q === q);
|
|
2592
|
+
this.markDirty(q);
|
|
2593
|
+
this.deps.onEvent?.({ type: "epoch", conversationId: q });
|
|
2594
|
+
return n;
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
/** Stops timers and frees the client (sign-out, identity change). */
|
|
2599
|
+
dispose(): void {
|
|
2600
|
+
this.disposed = true;
|
|
2601
|
+
this.deps.tabs?.close();
|
|
2602
|
+
if (this.backupTimer) clearTimeout(this.backupTimer);
|
|
2603
|
+
if (this.trustTimer) clearTimeout(this.trustTimer);
|
|
2604
|
+
this.decrypted.clear();
|
|
2605
|
+
this.plainChecked.clear();
|
|
2606
|
+
this.plainByEnvelope = new WeakMap();
|
|
2607
|
+
this.nonces.clear();
|
|
2608
|
+
this.plainNonces.clear();
|
|
2609
|
+
this.ephemeralNonces.clear();
|
|
2610
|
+
void this.run(async () => this.client.free());
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
function commitRequest(out: CommitOutput): CommitRequest {
|
|
2615
|
+
const welcome = out.welcome;
|
|
2616
|
+
return { epoch: out.epoch, commit: b64urlEncode(out.commit), group_info: b64urlEncode(out.groupInfo), welcome: welcome ? b64urlEncode(welcome) : null };
|
|
2617
|
+
}
|