@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/src/history.ts ADDED
@@ -0,0 +1,710 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * History for new members of encrypted channels (ENCRYPTION.md §7b, design note 4.13 item 11).
4
+ *
5
+ * Members hand the content keys of past epochs to a member who joined later, as far as the channel's policy
6
+ * allows; under every policy that includes the epochs between the member's room join and its device's first leaf
7
+ * (the device enters a channel's group only when the channel is opened or the room loaded). A share is never a chat message: it is the canonical JSON payload
8
+ * `{type: "history_keys", v, conversation, to, epochs: [[epoch, key], …]}` encrypted and signed like
9
+ * message content (§5.2), posted to `…/history-keys` of the node hosting the conversation. Format 2 (node capability
10
+ * `history.shares2`): under a fresh key sealed to the verified share key of every device of the recipient, so only
11
+ * those devices can read it. Format 1 (under the conversation's current epoch, readable by every leaf) is never sent;
12
+ * it is still read for shares older clients left on the node. The recipient verifies the signature against an attested device in the group,
13
+ * checks every key against a stored message of its epoch, signed by its author's attested device (no poisoned keys) and keeps accepted keys only
14
+ * in the key store (the MLS client state, which is also what the history backup carries).
15
+ *
16
+ * The pure parts (policy, windows, chunking, sharer election, payload codec, key check) are exported for
17
+ * tests; {@link HistorySync} runs the flows of one device.
18
+ */
19
+ import { b64urlDecode, b64urlEncode, jcs, type HistoryPendingList, type HistoryShareList, type CreateHistoryShareRequest, type Message } from "@tavrn/sdk";
20
+ import { splitIdentity, splitQualified, type MlsManager } from "./mls";
21
+
22
+ export const HISTORY_KEYS_TYPE = "history_keys";
23
+ /** Boxes per format 2 share accepted by nodes (devices of the recipient). */
24
+ export const MAX_SHARE_BOXES = 20;
25
+ /** Epochs per share accepted by nodes (`epoch_to - epoch_from + 1`). */
26
+ export const MAX_EPOCHS_PER_SHARE = 1000;
27
+ /** Epochs per share sent by this client: keeps a share well below the node's 64 KiB limit. */
28
+ export const SHARE_CHUNK = 500;
29
+ /** Members that are not the elected sharer wait this long (times their rank) before sharing themselves. */
30
+ export const SHARER_BACKOFF_MS = 60_000;
31
+ const DAY_MS = 24 * 3600 * 1000;
32
+
33
+ export type HistoryPolicy = { kind: "since_join" } | { kind: "days"; days: number } | { kind: "all" };
34
+
35
+ /** Parses a channel's `history_for_new_members`; anything unknown is `since_join` (the safe default). */
36
+ export function parseHistoryPolicy(value: string | null | undefined): HistoryPolicy {
37
+ if (value === "all") return { kind: "all" };
38
+ const m = /^days:([1-9][0-9]{0,3})$/.exec(value ?? "");
39
+ if (m) {
40
+ const days = Number(m[1]);
41
+ if (days >= 1 && days <= 3650) return { kind: "days", days };
42
+ }
43
+ return { kind: "since_join" };
44
+ }
45
+
46
+ /**
47
+ * Does the policy give new members anything from before their join? Members wait for shares under every policy (the
48
+ * messages since their join that were posted before their device entered the group), this only tells the settings.
49
+ */
50
+ export function grantsHistory(p: HistoryPolicy): boolean {
51
+ return p.kind !== "since_join";
52
+ }
53
+
54
+ /**
55
+ * Start of the history a member gets (milliseconds): the join for `since_join`, `n` days before it for
56
+ * `days:n`, null (everything) for `all`. Same rule as the node's cut of `GET …/messages`.
57
+ */
58
+ export function historyWindowStart(p: HistoryPolicy, joinedAtMs: number): number | null {
59
+ if (p.kind === "all") return null;
60
+ if (p.kind === "days") return joinedAtMs - p.days * DAY_MS;
61
+ return joinedAtMs;
62
+ }
63
+
64
+ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
65
+
66
+ /** The smallest ULID of millisecond `ms` (message ids are ULIDs; `after` cursors use it). */
67
+ export function ulidFloor(ms: number): string {
68
+ let t = Math.max(0, Math.floor(ms));
69
+ let head = "";
70
+ for (let i = 0; i < 10; i++) {
71
+ head = CROCKFORD[t % 32] + head;
72
+ t = Math.floor(t / 32);
73
+ }
74
+ return `${head}0000000000000000`;
75
+ }
76
+
77
+ /**
78
+ * Epochs to share with a new member: the device's known epochs older than the current one (the member has
79
+ * the current epoch itself), limited to `fromEpoch` (the oldest epoch with a message in the window; null: none) and
80
+ * later for a day window and for `since_join` (the window starts at the join).
81
+ */
82
+ export function epochsToShare(p: HistoryPolicy, known: number[], current: number, fromEpoch: number | null): number[] {
83
+ const lower = p.kind === "all" ? 0 : (fromEpoch ?? Number.POSITIVE_INFINITY);
84
+ return [...new Set(known)].filter((e) => Number.isSafeInteger(e) && e >= lower && e < current).sort((a, b) => a - b);
85
+ }
86
+
87
+ /**
88
+ * Epochs to share automatically with a member whose key was reset (ENCRYPTION.md §7b "History after a key reset"):
89
+ * the device's known epochs older than the current one, from the oldest epoch with content after the reset
90
+ * (`fromEpoch`, null: nothing since the reset) and never below `minEpoch` (the reset epoch + 1). Everything older is
91
+ * restored only by an approved request.
92
+ */
93
+ export function epochsSinceReset(known: number[], current: number, fromEpoch: number | null, minEpoch: number | null): number[] {
94
+ if (fromEpoch === null) return [];
95
+ const lower = Math.max(fromEpoch, minEpoch ?? fromEpoch);
96
+ return [...new Set(known)].filter((e) => Number.isSafeInteger(e) && e >= lower && e < current).sort((a, b) => a - b);
97
+ }
98
+
99
+ /**
100
+ * The longest gap between a member's room join and its first leaf that automatic shares fill (the lifetime of a
101
+ * pending mark, §7b): a `since` the node reports earlier than this before this device saw the member's first leaf is
102
+ * moved up to it.
103
+ */
104
+ export const MAX_JOIN_GAP_MS = 30 * 24 * 3600 * 1000;
105
+
106
+ /** The join used for a waiting member's window: the node's `since`, bounded by when this device saw the member. */
107
+ export function boundedJoin(sinceMs: number, seenAtMs: number): number {
108
+ const floor = seenAtMs - MAX_JOIN_GAP_MS;
109
+ return Number.isFinite(sinceMs) ? Math.max(sinceMs, floor) : seenAtMs;
110
+ }
111
+
112
+ /**
113
+ * Splits ascending epochs into shares of at most `max` epochs whose range (`last - first + 1`) stays within
114
+ * {@link MAX_EPOCHS_PER_SHARE}.
115
+ */
116
+ export function chunkEpochs(epochs: number[], max = SHARE_CHUNK): number[][] {
117
+ const sorted = [...new Set(epochs)].sort((a, b) => a - b);
118
+ const out: number[][] = [];
119
+ let cur: number[] = [];
120
+ for (const e of sorted) {
121
+ if (cur.length && (cur.length >= max || e - cur[0]! >= MAX_EPOCHS_PER_SHARE)) {
122
+ out.push(cur);
123
+ cur = [];
124
+ }
125
+ cur.push(e);
126
+ }
127
+ if (cur.length) out.push(cur);
128
+ return out;
129
+ }
130
+
131
+ /**
132
+ * Deterministic choice of the sharing device: the lowest device identity among `candidates` (online members'
133
+ * devices in the group, this one included) shares at once, the others wait `backoff × rank`. The device that
134
+ * committed the member's Add (`preferred`) shares at once.
135
+ */
136
+ export function sharerDelay(self: string, candidates: string[], preferred = false, backoff = SHARER_BACKOFF_MS): number {
137
+ if (preferred) return 0;
138
+ const sorted = [...new Set([...candidates, self])].sort();
139
+ return sorted.indexOf(self) * backoff;
140
+ }
141
+
142
+ export interface HistoryPayload {
143
+ type: typeof HISTORY_KEYS_TYPE;
144
+ /** Share format: 1 (under the epoch key) or 2 (sealed per recipient device). */
145
+ v: 1 | 2;
146
+ /** Qualified conversation id. */
147
+ conversation: string;
148
+ /** Recipient address. */
149
+ to: string;
150
+ /** `[epoch, base64url content key]`, ascending. */
151
+ epochs: [number, string][];
152
+ }
153
+
154
+ /** The canonical JSON of a share payload (the bytes the device signature covers). */
155
+ export function encodeHistoryPayload(conversation: string, to: string, keys: [number, Uint8Array][], v: 1 | 2 = 1): string {
156
+ const payload: HistoryPayload = { type: HISTORY_KEYS_TYPE, v, conversation, to, epochs: keys.map(([e, k]) => [e, b64urlEncode(k)]) };
157
+ return jcs(payload);
158
+ }
159
+
160
+ /**
161
+ * Checks a decrypted share payload against what the share claims: type and version, conversation, recipient,
162
+ * every epoch an integer within `[epochFrom, epochTo]` at most once, every key 32 bytes. Returns the keys
163
+ * (caller zeroes them) or null when anything is off (the whole share is rejected).
164
+ */
165
+ export function parseHistoryPayload(
166
+ content: Record<string, unknown>,
167
+ expected: { conversation: string; to: string; epochFrom: number; epochTo: number; v?: 1 | 2 },
168
+ ): [number, Uint8Array][] | null {
169
+ const keys = new Set(Object.keys(content));
170
+ if (keys.size !== 5 || !["type", "v", "conversation", "to", "epochs"].every((k) => keys.has(k))) return null;
171
+ if (content.type !== HISTORY_KEYS_TYPE || content.v !== (expected.v ?? 1)) return null;
172
+ if (content.conversation !== expected.conversation || content.to !== expected.to) return null;
173
+ if (!Array.isArray(content.epochs) || content.epochs.length > MAX_EPOCHS_PER_SHARE) return null;
174
+ const out: [number, Uint8Array][] = [];
175
+ const seen = new Set<number>();
176
+ for (const entry of content.epochs as unknown[]) {
177
+ if (!Array.isArray(entry) || entry.length !== 2) return null;
178
+ const [epoch, key] = entry as [unknown, unknown];
179
+ if (typeof epoch !== "number" || !Number.isSafeInteger(epoch) || epoch < expected.epochFrom || epoch > expected.epochTo || seen.has(epoch)) return null;
180
+ if (typeof key !== "string") return null;
181
+ let bytes: Uint8Array;
182
+ try {
183
+ bytes = b64urlDecode(key);
184
+ } catch {
185
+ return null;
186
+ }
187
+ if (bytes.length !== 32) return null;
188
+ seen.add(epoch);
189
+ out.push([epoch, bytes]);
190
+ }
191
+ return out;
192
+ }
193
+
194
+ function str16(s: string): Uint8Array {
195
+ const b = new TextEncoder().encode(s);
196
+ const out = new Uint8Array(2 + b.length);
197
+ out[0] = (b.length >> 8) & 0xff;
198
+ out[1] = b.length & 0xff;
199
+ out.set(b, 2);
200
+ return out;
201
+ }
202
+
203
+ function u64(n: number): Uint8Array {
204
+ const out = new Uint8Array(8);
205
+ new DataView(out.buffer).setBigUint64(0, BigInt(n));
206
+ return out;
207
+ }
208
+
209
+ function concat(...parts: Uint8Array[]): Uint8Array<ArrayBuffer> {
210
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
211
+ let i = 0;
212
+ for (const p of parts) {
213
+ out.set(p, i);
214
+ i += p.length;
215
+ }
216
+ return out;
217
+ }
218
+
219
+ /** History keys checked at once while a share is received (each check fetches the samples of its epoch). */
220
+ export const KEY_CHECK_CONCURRENCY = 6;
221
+
222
+ /** Runs `fn` for every item, at most `limit` at a time. */
223
+ async function eachLimited<T>(items: readonly T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
224
+ let next = 0;
225
+ const worker = async () => {
226
+ while (next < items.length) await fn(items[next++]!);
227
+ };
228
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
229
+ }
230
+
231
+ /** A stored message used to check a key: author, device, epoch and the `content` value (§5.2). */
232
+ export interface KeySample {
233
+ author: string;
234
+ device_id: string;
235
+ epoch: number;
236
+ content: string;
237
+ }
238
+
239
+ /**
240
+ * Does `key` open the AES-256-GCM ciphertext of `sample` (§5.2: same AAD, nonce and tag)? A key that does
241
+ * not reproduce the authentication tag of a real message of its epoch is rejected.
242
+ */
243
+ export async function epochKeyOpens(key: Uint8Array, conversation: string, sample: KeySample): Promise<boolean> {
244
+ let bytes: Uint8Array;
245
+ try {
246
+ bytes = b64urlDecode(sample.content);
247
+ } catch {
248
+ return false;
249
+ }
250
+ if (key.length !== 32 || bytes.length < 1 + 8 + 12 + 16 + 64 || bytes[0] !== 1) return false;
251
+ const epoch = Number(new DataView(bytes.buffer, bytes.byteOffset + 1, 8).getBigUint64(0));
252
+ if (epoch !== sample.epoch) return false;
253
+ const aad = concat(new Uint8Array([1]), str16(conversation), u64(epoch), str16(sample.author), str16(sample.device_id));
254
+ try {
255
+ const k = await crypto.subtle.importKey("raw", concat(key), "AES-GCM", false, ["decrypt"]);
256
+ await crypto.subtle.decrypt({ name: "AES-GCM", iv: concat(bytes.subarray(9, 21)), additionalData: aad, tagLength: 128 }, k, concat(bytes.subarray(21)));
257
+ return true;
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * The messages that can check a key of `epoch`, in the order they are tried: encrypted messages of that epoch, those
265
+ * not written by the sharer first (a sharer could plant a message under a forged key and then "prove" that key with
266
+ * it), the sharer's own last.
267
+ */
268
+ export function pickSamples(messages: Pick<Message, "author" | "device_id" | "epoch" | "content" | "encrypted">[], epoch: number, sharer: string): KeySample[] {
269
+ const usable = messages.filter((m) => m.encrypted && m.epoch === epoch && typeof m.content === "string" && !!m.device_id);
270
+ const ordered = [...usable.filter((x) => x.author !== sharer), ...usable.filter((x) => x.author === sharer)];
271
+ return ordered.map((m) => ({ author: m.author, device_id: m.device_id!, epoch, content: m.content as string }));
272
+ }
273
+
274
+ /** The first message of {@link pickSamples}: the one that checks a key of `epoch` when its author device is known. */
275
+ export function pickSample(messages: Pick<Message, "author" | "device_id" | "epoch" | "content" | "encrypted">[], epoch: number, sharer: string): KeySample | null {
276
+ return pickSamples(messages, epoch, sharer)[0] ?? null;
277
+ }
278
+
279
+ // ---- Flows ---------------------------------------------------------------------------------------
280
+
281
+ /** The routes of the node hosting a conversation (bare conversation ids). */
282
+ export interface HistoryApi {
283
+ share(conversationId: string, req: CreateHistoryShareRequest): Promise<unknown>;
284
+ shares(conversationId: string): Promise<HistoryShareList>;
285
+ ack(conversationId: string, shareId: string): Promise<unknown>;
286
+ pending(conversationId: string): Promise<HistoryPendingList>;
287
+ /**
288
+ * `GET …/messages`; `threads: "all"` includes the messages of the channel's threads (forum posts), which the window
289
+ * lookups need: a thread message is content of the channel like any other.
290
+ */
291
+ messages(conversationId: string, query: { limit?: number; after?: string; before?: string; epoch?: number; threads?: "all" }): Promise<Message[]>;
292
+ }
293
+
294
+ /** Channel kinds whose content is messages (others, such as files channels, keep records, FILES.md §8.7). */
295
+ const MESSAGE_KINDS = new Set(["text", "announcement", "forum"]);
296
+
297
+ /** One encrypted channel of a restore request (`HistoryRequest.channels`), times in milliseconds. */
298
+ export interface RestoreChannel {
299
+ kind: string;
300
+ /** The requester's normal window start; null = everything. */
301
+ windowStart: number | null;
302
+ }
303
+
304
+ /** What this device can restore in one channel: the epochs it would send and how many of the window it lacks. */
305
+ export interface RestorePlan {
306
+ epochs: number[];
307
+ /** Epochs of the window this device does not hold (or cannot tell: `unknown`). */
308
+ missing: number;
309
+ unknown: boolean;
310
+ }
311
+
312
+ export interface HistorySyncDeps {
313
+ mls: MlsManager;
314
+ /** Own address and device id. */
315
+ address: string;
316
+ deviceId: string;
317
+ /** Routes of node `domain`. */
318
+ api(domain: string): HistoryApi;
319
+ /** Is `address` online (presence)? This user counts as online. */
320
+ isOnline(address: string): boolean;
321
+ /**
322
+ * The time of `address`'s last identity reset from its keys document (`identity_reset_at`, ENCRYPTION.md §2.3),
323
+ * null if none: with nodes that do not report `key_reset_at`, a recipient reset after its join gets automatic shares
324
+ * only from the reset on.
325
+ */
326
+ resetAt?(address: string): Promise<number | null>;
327
+ /**
328
+ * The oldest epoch of a non-message channel's encrypted content after `sinceMs` (files channels: `GET
329
+ * records?since=`, FILES.md §8.7); null when there is none. Without it such channels are restored only for a window
330
+ * without start.
331
+ */
332
+ firstEpoch?(conversation: string, kind: string, sinceMs: number): Promise<number | null>;
333
+ /**
334
+ * Whether node `domain` announces `history.shares2` (ENCRYPTION.md §7b "Format v2"); without it (or false) no share
335
+ * is sent at all (format 1 is not sent any more).
336
+ */
337
+ shares2?(domain: string): boolean | Promise<boolean>;
338
+ /**
339
+ * Something went wrong with a share, or a share was not sent (`share_skipped`: no format 2 possible, the node lacks
340
+ * `history.shares2` or a device of the recipient has no verified share key); never carries keys or payloads.
341
+ */
342
+ onLog?(event: "share_rejected" | "share_failed" | "keys_rejected" | "share_skipped", conversation: string): void;
343
+ backoffMs?: number;
344
+ setTimer?(fn: () => void, ms: number): unknown;
345
+ clearTimer?(t: unknown): void;
346
+ }
347
+
348
+ /**
349
+ * The history-sharing flows of one device: as a sharer (members marked `history_pending`, an Add this
350
+ * device committed) and as a recipient (`history.keys_available`, channel open). Conversations are
351
+ * qualified ids; `policy` is the channel's current `history_for_new_members`.
352
+ */
353
+ export class HistorySync {
354
+ /** When this device first saw a leaf of a waiting member in a group (`q|address` → ms): bounds the node's `since`. */
355
+ private readonly firstSeen = new Map<string, number>();
356
+ private readonly shared = new Set<string>();
357
+ private readonly rejected = new Set<string>();
358
+ private readonly timers = new Map<string, unknown>();
359
+ private readonly preferred = new Map<string, Set<string>>();
360
+ private readonly receiving = new Map<string, Promise<number>>();
361
+ private readonly receiveQueued = new Map<string, Promise<number>>();
362
+ private disposed = false;
363
+
364
+ constructor(private readonly deps: HistorySyncDeps) {}
365
+
366
+ private api(q: string): { api: HistoryApi; id: string } {
367
+ const { domain, id } = splitQualified(q);
368
+ return { api: this.deps.api(domain), id };
369
+ }
370
+
371
+ /** This device committed the Add of these users (MlsEvent `added`): it shares at once when they are pending. */
372
+ noteAdded(q: string, addresses: string[]): void {
373
+ const set = this.preferred.get(q) ?? new Set<string>();
374
+ for (const a of addresses) {
375
+ set.add(a);
376
+ this.seen(q, a);
377
+ }
378
+ this.preferred.set(q, set);
379
+ }
380
+
381
+ /**
382
+ * Members of `q` who wait for keys: share now when this device is elected, otherwise after the back-off
383
+ * and only if they still wait.
384
+ */
385
+ async checkPending(q: string, policy: HistoryPolicy): Promise<void> {
386
+ if (this.disposed || !this.deps.mls.hasGroup(q)) return;
387
+ const { api, id } = this.api(q);
388
+ const { items } = await api.pending(id);
389
+ if (!items.length) return;
390
+ // The member's join commit may still be on the way (`mls.commit` arrives with `history.pending`).
391
+ await this.deps.mls.catchUp(q).catch(() => undefined);
392
+ const self = `${this.deps.address}/${this.deps.deviceId}`;
393
+ for (const entry of items) {
394
+ if (entry.user === this.deps.address) continue;
395
+ const key = `${q}|${entry.user}|${entry.since}`;
396
+ if (this.shared.has(key) || this.timers.has(key)) continue;
397
+ const roster = this.deps.mls.roster(q);
398
+ // Without a device of the recipient in the group nobody can encrypt for it yet.
399
+ if (!roster.some((i) => splitIdentity(i).address === entry.user)) continue;
400
+ const candidates = roster.filter((i) => {
401
+ const a = splitIdentity(i).address;
402
+ return a !== entry.user && (a === this.deps.address || this.deps.isOnline(a));
403
+ });
404
+ const delay = sharerDelay(self, candidates, !!this.preferred.get(q)?.has(entry.user), this.deps.backoffMs ?? SHARER_BACKOFF_MS);
405
+ // The join as the node reports it, but never earlier than this device's own knowledge allows (§7b step 2).
406
+ const joinedAt = boundedJoin(Date.parse(entry.since), this.seen(q, entry.user));
407
+ const reset = await this.resetOf(entry);
408
+ const go = async () => {
409
+ this.timers.delete(key);
410
+ if (this.disposed) return;
411
+ if (delay > 0) {
412
+ const again = await api.pending(id).catch(() => null);
413
+ if (!again?.items.some((i) => i.user === entry.user && i.since === entry.since)) return;
414
+ }
415
+ this.shared.add(key);
416
+ try {
417
+ if (reset) await this.shareSinceReset(q, entry.user, reset.at, reset.minEpoch);
418
+ else await this.shareWith(q, entry.user, joinedAt, policy);
419
+ } catch {
420
+ this.deps.onLog?.("share_failed", q);
421
+ }
422
+ };
423
+ if (delay === 0) await go();
424
+ else this.timers.set(key, (this.deps.setTimer ?? setTimeout)(() => void go(), delay));
425
+ }
426
+ }
427
+
428
+ /** When this device first saw `address` waiting in `q` with a leaf in the group (recorded now if not yet). */
429
+ private seen(q: string, address: string): number {
430
+ const k = `${q}|${address}`;
431
+ let at = this.firstSeen.get(k);
432
+ if (at === undefined) {
433
+ at = Date.now();
434
+ this.firstSeen.set(k, at);
435
+ }
436
+ return at;
437
+ }
438
+
439
+ /**
440
+ * The key reset of a waiting member: from the node (`key_reset_at`, `min_epoch`) or, with nodes that do not report
441
+ * it, from the member's keys document when the reset is later than the pending mark's `since`.
442
+ */
443
+ private async resetOf(entry: { user: string; since: string; key_reset_at?: string | null; min_epoch?: number | null }): Promise<{ at: number; minEpoch: number | null } | null> {
444
+ if (entry.key_reset_at) {
445
+ const at = Date.parse(entry.key_reset_at);
446
+ if (Number.isFinite(at)) return { at, minEpoch: entry.min_epoch ?? null };
447
+ }
448
+ const at = this.deps.resetAt ? await this.deps.resetAt(entry.user).catch(() => null) : null;
449
+ const since = Date.parse(entry.since);
450
+ return at !== null && Number.isFinite(at) && (!Number.isFinite(since) || at > since) ? { at, minEpoch: null } : null;
451
+ }
452
+
453
+ /**
454
+ * Automatic shares for a member whose key was reset: only epochs since the reset (ENCRYPTION.md §7b "History after a
455
+ * key reset"); without `minEpoch` from the node nothing up to the epoch of the newest message before the reset, and
456
+ * nothing at all when that cannot be read.
457
+ */
458
+ async shareSinceReset(q: string, to: string, resetAtMs: number, minEpoch: number | null): Promise<number> {
459
+ const { api, id } = this.api(q);
460
+ const mls = this.deps.mls;
461
+ const current = mls.epoch(q);
462
+ if (current === null) return 0;
463
+ const after = await api.messages(id, { after: ulidFloor(resetAtMs), limit: 20, threads: "all" });
464
+ const since = after.filter((m) => m.encrypted && typeof m.epoch === "number").map((m) => m.epoch as number);
465
+ let lower = minEpoch;
466
+ if (lower === null) {
467
+ const before = await api.messages(id, { before: ulidFloor(resetAtMs), limit: 1, threads: "all" });
468
+ const last = before.find((m) => m.encrypted && typeof m.epoch === "number");
469
+ lower = last ? (last.epoch as number) + 1 : 0;
470
+ }
471
+ const epochs = epochsSinceReset(mls.knownEpochs(q), current, since.length ? Math.min(...since) : null, lower);
472
+ return this.sendChunks(q, to, epochs);
473
+ }
474
+
475
+ /** The oldest epoch with encrypted content after `startMs` (null start: from the beginning). */
476
+ private async firstEpoch(q: string, channel: RestoreChannel): Promise<number | null | undefined> {
477
+ const { api, id } = this.api(q);
478
+ if (MESSAGE_KINDS.has(channel.kind)) {
479
+ const page = await api.messages(id, { after: ulidFloor(channel.windowStart ?? 0), limit: 20, threads: "all" });
480
+ const epochs = page.filter((m) => m.encrypted && typeof m.epoch === "number").map((m) => m.epoch as number);
481
+ return epochs.length ? Math.min(...epochs) : null;
482
+ }
483
+ if (channel.windowStart === null) return 0;
484
+ // Another kind with a window start: only its own listing knows (undefined = cannot tell).
485
+ return this.deps.firstEpoch ? this.deps.firstEpoch(q, channel.kind, channel.windowStart) : undefined;
486
+ }
487
+
488
+ /**
489
+ * What this device can restore of a requester's window in `q` (ENCRYPTION.md §7b): every epoch it holds from the
490
+ * window's oldest content up to the epoch before the current one, and how many of those it lacks.
491
+ */
492
+ async restorePlan(q: string, channel: RestoreChannel): Promise<RestorePlan> {
493
+ const current = this.deps.mls.epoch(q);
494
+ if (current === null || !this.deps.mls.hasGroup(q)) return { epochs: [], missing: 0, unknown: true };
495
+ const from = await this.firstEpoch(q, channel);
496
+ if (from === undefined) return { epochs: [], missing: 0, unknown: true };
497
+ if (from === null) return { epochs: [], missing: 0, unknown: false };
498
+ const epochs = [...new Set(this.deps.mls.knownEpochs(q))].filter((e) => Number.isSafeInteger(e) && e >= from && e < current).sort((a, b) => a - b);
499
+ return { epochs, missing: Math.max(0, current - from - epochs.length), unknown: false };
500
+ }
501
+
502
+ /**
503
+ * After approving a restore request: sends the keys of the requester's window in `q` from this device (restore
504
+ * shares, which the node accepts only from members with `restore_history` while the restore is valid). Nothing is
505
+ * sent while no device of the requester is in the group.
506
+ */
507
+ async restore(q: string, to: string, channel: RestoreChannel): Promise<RestorePlan & { sent: number; skipped: boolean }> {
508
+ if (this.disposed || !this.deps.mls.hasGroup(q)) return { epochs: [], missing: 0, unknown: true, sent: 0, skipped: true };
509
+ await this.deps.mls.catchUp(q).catch(() => undefined);
510
+ // A device of the requester attested under its current key (old leaves from before the reset do not count).
511
+ const attested = await this.deps.mls.verifiedDevices(to, true).catch(() => null);
512
+ const present = this.deps.mls.roster(q).some((i) => {
513
+ const { address, deviceId } = splitIdentity(i);
514
+ return address === to && (!attested || attested.has(deviceId));
515
+ });
516
+ if (!present) return { epochs: [], missing: 0, unknown: true, sent: 0, skipped: true };
517
+ const plan = await this.restorePlan(q, channel);
518
+ const sent = plan.epochs.length ? await this.sendChunks(q, to, plan.epochs) : 0;
519
+ return { ...plan, sent, skipped: false };
520
+ }
521
+
522
+ /**
523
+ * Sends the keys of the policy's window to `to` (chunked); an empty share if there is nothing to send. The window
524
+ * starts `n` days before the join (`days:n`), at the join (`since_join`: the messages posted before the member's
525
+ * device entered the group) or nowhere (`all`).
526
+ */
527
+ async shareWith(q: string, to: string, joinedAtMs: number, policy: HistoryPolicy): Promise<number> {
528
+ const { api, id } = this.api(q);
529
+ const mls = this.deps.mls;
530
+ const current = mls.epoch(q);
531
+ if (current === null) return 0;
532
+ let fromEpoch: number | null = null;
533
+ if (policy.kind !== "all") {
534
+ // The oldest epoch with a message in the window (ascending page after the window start).
535
+ const start = historyWindowStart(policy, Number.isFinite(joinedAtMs) ? joinedAtMs : Date.now()) ?? 0;
536
+ const page = await api.messages(id, { after: ulidFloor(start), limit: 20, threads: "all" });
537
+ const epochs = page.filter((m) => m.encrypted && typeof m.epoch === "number").map((m) => m.epoch as number);
538
+ fromEpoch = epochs.length ? Math.min(...epochs) : null;
539
+ }
540
+ const epochs = epochsToShare(policy, mls.knownEpochs(q), current, fromEpoch);
541
+ return this.sendChunks(q, to, epochs);
542
+ }
543
+
544
+ /**
545
+ * The share keys to seal a format 2 share to (ENCRYPTION.md §7b "Sharing" step 0): when the hosting node announces
546
+ * `history.shares2` and every attested device of `to` in the current roster has a verified share key, device id →
547
+ * share key; otherwise null (format 1). Leaves of `to` that are not attested (old leaves after a key reset) cannot
548
+ * receive keys anyway and do not count.
549
+ */
550
+ private async shareKeysFor(q: string, to: string): Promise<Record<string, Uint8Array> | null> {
551
+ if (!(await this.deps.shares2?.(splitQualified(q).domain))) return null;
552
+ const mls = this.deps.mls;
553
+ await mls.catchUp(q).catch(() => undefined);
554
+ const inRoster = [...new Set(mls.roster(q).map(splitIdentity).filter((i) => i.address === to).map((i) => i.deviceId))];
555
+ let attested = await mls.verifiedDevices(to).catch(() => null);
556
+ // A device the cached lookup does not know yet (it joined since): look the user's devices up again.
557
+ const refresh = !attested || inRoster.some((d) => !attested!.has(d));
558
+ if (refresh) {
559
+ mls.forgetDevices(to);
560
+ attested = await mls.verifiedDevices(to).catch(() => null);
561
+ }
562
+ const keys = await mls.verifiedShareKeys(to).catch(() => null);
563
+ if (!attested || !keys) return null;
564
+ const devices = inRoster.filter((d) => attested!.has(d));
565
+ if (!devices.length || devices.length > MAX_SHARE_BOXES || !devices.every((d) => keys.has(d))) return null;
566
+ return Object.fromEntries(devices.map((d) => [d, keys.get(d)!]));
567
+ }
568
+
569
+ /** Encrypts and posts `epochs` for `to` in chunks; one empty share when there are none (it clears a pending mark). */
570
+ private async sendChunks(q: string, to: string, epochs: number[]): Promise<number> {
571
+ const { api, id } = this.api(q);
572
+ const mls = this.deps.mls;
573
+ const chunks = epochs.length ? chunkEpochs(epochs) : [[]];
574
+ const shareKeys = await this.shareKeysFor(q, to);
575
+ if (!shareKeys) {
576
+ // Format 1 would be readable by every leaf of the group, unattested ones included: nothing is sent.
577
+ this.deps.onLog?.("share_skipped", q);
578
+ return 0;
579
+ }
580
+ let sent = 0;
581
+ for (const chunk of chunks) {
582
+ const keys = mls.exportEpochKeys(q, chunk);
583
+ let json: string;
584
+ try {
585
+ json = encodeHistoryPayload(q, to, keys, 2);
586
+ } finally {
587
+ for (const [, k] of keys) k.fill(0);
588
+ }
589
+ const { content, epoch, boxes } = await mls.encryptHistory(q, json, shareKeys);
590
+ const from = chunk[0] ?? epoch;
591
+ const last = chunk[chunk.length - 1] ?? epoch;
592
+ await api.share(id, { to_user: to, epoch_from: from, epoch_to: last, ciphertext: content, v: 2, boxes });
593
+ sent += keys.length;
594
+ }
595
+ return sent;
596
+ }
597
+
598
+ /**
599
+ * Fetches, checks and stores the shares addressed to this user; returns the number of new keys. A call while a pass
600
+ * runs (`history.keys_available` arriving after that pass read the list) queues one more pass after it, shared by
601
+ * every call meanwhile: the running pass may have read the list before the announced share existed.
602
+ */
603
+ receive(q: string): Promise<number> {
604
+ const running = this.receiving.get(q);
605
+ if (!running) return this.startReceive(q);
606
+ const queued = this.receiveQueued.get(q);
607
+ if (queued) return queued;
608
+ const next = running
609
+ .catch(() => 0)
610
+ .then(() => {
611
+ this.receiveQueued.delete(q);
612
+ // A pass started since the running one ended began after this call: it sees what this call was about.
613
+ return this.receiving.get(q) ?? this.startReceive(q);
614
+ });
615
+ this.receiveQueued.set(q, next);
616
+ return next;
617
+ }
618
+
619
+ private startReceive(q: string): Promise<number> {
620
+ const p = this.receiveInner(q).finally(() => this.receiving.delete(q));
621
+ this.receiving.set(q, p);
622
+ return p;
623
+ }
624
+
625
+ private async receiveInner(q: string): Promise<number> {
626
+ if (this.disposed || !this.deps.mls.hasGroup(q)) return 0;
627
+ const mls = this.deps.mls;
628
+ const { api, id } = this.api(q);
629
+ const { items } = await api.shares(id);
630
+ let total = 0;
631
+ for (const share of items) {
632
+ if (this.rejected.has(share.id)) continue;
633
+ // The sharing device must be in the group; its signature is checked against its attestation.
634
+ if (!mls.roster(q).includes(`${share.from_user}/${share.from_device}`)) {
635
+ this.reject(q, share.id);
636
+ continue;
637
+ }
638
+ // Format 2 (§7b): only with this device's own box; its key takes the place of the epoch key.
639
+ const v = share.v === 2 ? 2 : 1;
640
+ if (share.v !== undefined && share.v !== 1 && share.v !== 2) {
641
+ this.reject(q, share.id);
642
+ continue;
643
+ }
644
+ if (v === 2 && !share.box) {
645
+ this.reject(q, share.id);
646
+ continue;
647
+ }
648
+ const dec = await mls.decryptHistory(q, share.id, share.from_user, share.from_device, share.ciphertext, v === 2 ? share.box! : undefined);
649
+ if (!dec.ok) {
650
+ // A key that is still missing may arrive with the catch-up: try again later.
651
+ if (dec.reason !== "missing_key") this.reject(q, share.id);
652
+ continue;
653
+ }
654
+ const keys = parseHistoryPayload(dec.content, { conversation: q, to: this.deps.address, epochFrom: share.epoch_from, epochTo: share.epoch_to, v });
655
+ if (!keys) {
656
+ this.reject(q, share.id);
657
+ continue;
658
+ }
659
+ const known = new Set(mls.knownEpochs(q));
660
+ const accepted: [number, Uint8Array][] = [];
661
+ let dropped = 0;
662
+ try {
663
+ // One request per epoch (§7b step 4), a few at a time.
664
+ await eachLimited(
665
+ keys.filter(([epoch]) => !known.has(epoch)),
666
+ KEY_CHECK_CONCURRENCY,
667
+ async ([epoch, key]) => {
668
+ const samples = pickSamples(await api.messages(id, { epoch, limit: 10 }).catch(() => []), epoch, share.from_user);
669
+ if (await this.keyProven(q, key, epoch, samples)) accepted.push([epoch, key]);
670
+ else dropped++;
671
+ },
672
+ );
673
+ accepted.sort((a, b) => a[0] - b[0]);
674
+ total += await mls.importHistoryKeys(q, accepted);
675
+ } finally {
676
+ for (const [, k] of keys) k.fill(0);
677
+ }
678
+ if (dropped) this.deps.onLog?.("keys_rejected", q);
679
+ await api.ack(id, share.id).catch(() => undefined);
680
+ }
681
+ return total;
682
+ }
683
+
684
+ /**
685
+ * §7b step 4: the key must open a sample of its epoch whose signature verifies with the author's device. A sample
686
+ * the key does not open, or whose signature fails, drops the key; one whose author device cannot be verified at all
687
+ * (such as the recipient's own device from before an identity reset, whose attestation went with the old key) tells
688
+ * nothing and is passed over for the next one. The sharer's own messages come last.
689
+ */
690
+ private async keyProven(q: string, key: Uint8Array, epoch: number, samples: KeySample[]): Promise<boolean> {
691
+ for (const sample of samples) {
692
+ // Tag and signature in one decryption; the tag alone only decides for a sample of an unverifiable device.
693
+ const check = await this.deps.mls.checkSample(q, epoch, key, sample);
694
+ if (check !== "unknown_device") return check === "proven";
695
+ if (!(await epochKeyOpens(key, q, sample))) return false;
696
+ }
697
+ return false;
698
+ }
699
+
700
+ private reject(q: string, shareId: string) {
701
+ this.rejected.add(shareId);
702
+ this.deps.onLog?.("share_rejected", q);
703
+ }
704
+
705
+ dispose(): void {
706
+ this.disposed = true;
707
+ for (const t of this.timers.values()) (this.deps.clearTimer ?? clearTimeout)(t as ReturnType<typeof setTimeout>);
708
+ this.timers.clear();
709
+ }
710
+ }