@gryt/core 0.14.0 → 0.15.0

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.
@@ -0,0 +1,96 @@
1
+ import { type IdentityScope } from "@gryt/crypto";
2
+ import type { MlsDmDriver, MlsGroupRecord, MlsKeyPackageRecord, MlsDeviceRecord, MlsLogEntry, MlsStateStore, MlsTransport, MlsWelcomeDelivery } from "./interfaces.js";
3
+ interface KeyPackageRow {
4
+ ref: string;
5
+ serverUserId: string;
6
+ deviceId: string;
7
+ data: Uint8Array;
8
+ lastResort: boolean;
9
+ claimed: boolean;
10
+ }
11
+ interface GroupRow {
12
+ conversationId: string;
13
+ groupId: string;
14
+ epoch: number;
15
+ headSeq: number;
16
+ log: MlsLogEntry[];
17
+ }
18
+ interface Socket {
19
+ serverUserId: string;
20
+ driver: MlsDmDriver | null;
21
+ online: boolean;
22
+ /** False: every call answers `offline` without reaching the server, as an app's transport does. */
23
+ reachable: boolean;
24
+ /** The next call to each method either never arrives or has its answer lost. Both answer `timeout`. */
25
+ lose: Map<keyof MlsTransport, "request" | "answer">;
26
+ }
27
+ export declare class FakeDeliveryService {
28
+ scope: IdentityScope;
29
+ conversations: Map<string, string[]>;
30
+ devices: Map<string, string[]>;
31
+ /** Ids their owner removed, as `${serverUserId}:${deviceId}`. Anything naming one is refused (GRYT-1555). */
32
+ removed: Set<string>;
33
+ /** Every call that named a device, in order, by device id. */
34
+ callsBy: Map<string, string[]>;
35
+ /** When each device id first published, as `mls:devices` reports it. */
36
+ deviceAddedAt: Map<string, string>;
37
+ keyPackages: KeyPackageRow[];
38
+ groups: Map<string, GroupRow>;
39
+ welcomes: (MlsWelcomeDelivery & {
40
+ serverUserId: string;
41
+ })[];
42
+ sockets: Socket[];
43
+ stats: {
44
+ commits: number;
45
+ staleEpoch: number;
46
+ groupExists: number;
47
+ claims: number;
48
+ publishes: number;
49
+ mostBinaryParts: number;
50
+ };
51
+ /** A cap on Welcomes per sync, for a server that pages them (GRYT-1528). server#241 has none. */
52
+ syncWelcomeLimit: number;
53
+ /** What each `mls:send` said it carried, by seq. */
54
+ attachmentIds: Map<number, string[] | undefined>;
55
+ private inFlight;
56
+ private held;
57
+ private nextWelcome;
58
+ constructor(scope: IdentityScope);
59
+ dm(a: string, b: string): string;
60
+ /** Commits wait until `count` of them are in, then go through in the order they came. */
61
+ holdCommits(count: number): void;
62
+ /** Waits until every push has been handled, including what those handlers set off. */
63
+ settle(): Promise<void>;
64
+ connect(serverUserId: string): {
65
+ transport: MlsTransport;
66
+ socket: Socket;
67
+ };
68
+ dropLogThrough(conversationId: string, seq: number): void;
69
+ private push;
70
+ private peopleSharing;
71
+ private devicesOf;
72
+ private isDevice;
73
+ private groupView;
74
+ private transportFor;
75
+ private checkParts;
76
+ private counts;
77
+ private append;
78
+ private commit;
79
+ }
80
+ /** What an app's store does, kept in maps and copied on the way in and out. */
81
+ export declare class MemoryMlsStore implements MlsStateStore {
82
+ device: MlsDeviceRecord | null;
83
+ keyPackages: Map<string, MlsKeyPackageRecord>;
84
+ groups: Map<string, MlsGroupRecord>;
85
+ loadDevice(): Promise<MlsDeviceRecord | null>;
86
+ saveDevice(device: MlsDeviceRecord): Promise<void>;
87
+ putKeyPackages(records: MlsKeyPackageRecord[]): Promise<void>;
88
+ listKeyPackages(): Promise<MlsKeyPackageRecord[]>;
89
+ getKeyPackage(ref: string): Promise<MlsKeyPackageRecord | null>;
90
+ deleteKeyPackage(ref: string): Promise<void>;
91
+ loadGroup(conversationId: string): Promise<MlsGroupRecord | null>;
92
+ listGroups(): Promise<MlsGroupRecord[]>;
93
+ saveGroup(record: MlsGroupRecord): Promise<void>;
94
+ deleteGroup(conversationId: string): Promise<void>;
95
+ }
96
+ export {};
@@ -0,0 +1,413 @@
1
+ /* An in-memory stand-in for the delivery service in Gryt-chat/server#241, for tests only.
2
+ Same answers and pushes; no auth, blocks, rate limits or member checks on adds. */
3
+ import { base64UrlDecode, inspectMlsMessage, mlsWelcomeRefs, readMlsKeyPackage } from "@gryt/crypto";
4
+ const MAX_DEVICES = 5;
5
+ const MAX_KEY_PACKAGES = 20;
6
+ const MAX_LOG_PAGE = 200;
7
+ const DUPLICATE_WINDOW = 100;
8
+ const fail = (error, message, extra = {}) => ({ ok: false, error, message, ...extra });
9
+ const hexOf = (b64) => Array.from(base64UrlDecode(b64), (b) => b.toString(16).padStart(2, "0")).join("");
10
+ function same(a, b) {
11
+ return a.length === b.length && a.every((x, i) => x === b[i]);
12
+ }
13
+ export class FakeDeliveryService {
14
+ scope;
15
+ conversations = new Map();
16
+ devices = new Map();
17
+ /** Ids their owner removed, as `${serverUserId}:${deviceId}`. Anything naming one is refused (GRYT-1555). */
18
+ removed = new Set();
19
+ /** Every call that named a device, in order, by device id. */
20
+ callsBy = new Map();
21
+ /** When each device id first published, as `mls:devices` reports it. */
22
+ deviceAddedAt = new Map();
23
+ keyPackages = [];
24
+ groups = new Map();
25
+ welcomes = [];
26
+ sockets = [];
27
+ stats = { commits: 0, staleEpoch: 0, groupExists: 0, claims: 0, publishes: 0, mostBinaryParts: 0 };
28
+ /** A cap on Welcomes per sync, for a server that pages them (GRYT-1528). server#241 has none. */
29
+ syncWelcomeLimit = Infinity;
30
+ /** What each `mls:send` said it carried, by seq. */
31
+ attachmentIds = new Map();
32
+ inFlight = new Set();
33
+ held = null;
34
+ nextWelcome = 1;
35
+ constructor(scope) {
36
+ this.scope = scope;
37
+ }
38
+ dm(a, b) {
39
+ const id = `dm:${[a, b].sort().join(":")}`;
40
+ this.conversations.set(id, [a, b]);
41
+ return id;
42
+ }
43
+ /** Commits wait until `count` of them are in, then go through in the order they came. */
44
+ holdCommits(count) {
45
+ this.held = { count, waiting: [] };
46
+ }
47
+ /** Waits until every push has been handled, including what those handlers set off. */
48
+ async settle() {
49
+ while (this.inFlight.size > 0)
50
+ await Promise.allSettled([...this.inFlight]);
51
+ }
52
+ connect(serverUserId) {
53
+ const socket = { serverUserId, driver: null, online: true, reachable: true, lose: new Map() };
54
+ this.sockets.push(socket);
55
+ return { transport: this.transportFor(socket), socket };
56
+ }
57
+ dropLogThrough(conversationId, seq) {
58
+ const g = this.groups.get(conversationId);
59
+ if (g)
60
+ g.log = g.log.filter((e) => e.seq > seq);
61
+ }
62
+ push(to, deliver) {
63
+ for (const s of this.sockets) {
64
+ if (!s.online || !s.reachable || !s.driver || !to.includes(s.serverUserId))
65
+ continue;
66
+ const driver = s.driver;
67
+ const p = new Promise((resolve) => setTimeout(resolve, 0)).then(() => deliver(driver));
68
+ const tracked = p.catch(() => undefined).finally(() => this.inFlight.delete(tracked));
69
+ this.inFlight.add(tracked);
70
+ }
71
+ }
72
+ peopleSharing(serverUserId) {
73
+ const out = new Set([serverUserId]);
74
+ for (const members of this.conversations.values())
75
+ if (members.includes(serverUserId))
76
+ members.forEach((m) => out.add(m));
77
+ return [...out];
78
+ }
79
+ devicesOf(ids) {
80
+ return ids.flatMap((serverUserId) => (this.devices.get(serverUserId) ?? []).map((deviceId) => ({ serverUserId, deviceId })));
81
+ }
82
+ isDevice(serverUserId, deviceId) {
83
+ return (this.devices.get(serverUserId) ?? []).includes(deviceId);
84
+ }
85
+ groupView(g) {
86
+ return { conversationId: g.conversationId, groupId: g.groupId, epoch: g.epoch, headSeq: g.headSeq };
87
+ }
88
+ transportFor(socket) {
89
+ const me = socket.serverUserId;
90
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
91
+ const member = (conversationId) => this.conversations.get(conversationId)?.includes(me) ?? false;
92
+ const methods = {
93
+ publishKeyPackages: async ({ deviceId, keyPackages, lastResort }) => {
94
+ await tick();
95
+ this.stats.publishes += 1;
96
+ const isNew = !this.isDevice(me, deviceId);
97
+ if (isNew && (this.devices.get(me) ?? []).length >= MAX_DEVICES) {
98
+ return fail("too_many_devices", "You have five devices using encrypted messages here.");
99
+ }
100
+ const raw = [...keyPackages.map((b) => ({ b, lastResort: false }))];
101
+ if (lastResort)
102
+ raw.push({ b: lastResort, lastResort: true });
103
+ const rows = [];
104
+ for (const { b, lastResort: lr } of raw) {
105
+ const { ref } = await readMlsKeyPackage(b, this.scope);
106
+ rows.push({ ref, serverUserId: me, deviceId, data: b.slice(), lastResort: lr, claimed: false });
107
+ }
108
+ if (isNew) {
109
+ this.devices.set(me, [...(this.devices.get(me) ?? []), deviceId]);
110
+ this.deviceAddedAt.set(deviceId, new Date().toISOString());
111
+ }
112
+ let stored = 0;
113
+ for (const row of rows) {
114
+ const unclaimed = this.keyPackages.filter((k) => k.deviceId === deviceId && !k.lastResort && !k.claimed).length;
115
+ if (!row.lastResort && unclaimed >= MAX_KEY_PACKAGES)
116
+ continue;
117
+ if (row.lastResort) {
118
+ for (const k of this.keyPackages)
119
+ if (k.deviceId === deviceId && k.lastResort)
120
+ k.claimed = true;
121
+ }
122
+ this.keyPackages.push(row);
123
+ stored += 1;
124
+ }
125
+ if (isNew)
126
+ this.push(this.peopleSharing(me), (d) => d.handleDevicesChanged({ serverUserId: me }));
127
+ return { ok: true, stored, ...this.counts(me, deviceId) };
128
+ },
129
+ claimKeyPackages: async ({ conversationId, deviceId, devices }) => {
130
+ await tick();
131
+ if (!member(conversationId))
132
+ return fail("not_found", "No such conversation.");
133
+ this.stats.claims += 1;
134
+ let targets = this.devicesOf(this.conversations.get(conversationId)).filter((d) => !(d.serverUserId === me && d.deviceId === deviceId));
135
+ if (devices)
136
+ targets = targets.filter((t) => devices.some((d) => d.serverUserId === t.serverUserId && d.deviceId === t.deviceId));
137
+ const keyPackages = [];
138
+ const missing = [];
139
+ for (const t of targets) {
140
+ const mine = this.keyPackages.filter((k) => k.serverUserId === t.serverUserId && k.deviceId === t.deviceId && !k.claimed);
141
+ const fresh = mine.find((k) => !k.lastResort);
142
+ const kp = fresh ?? mine.find((k) => k.lastResort);
143
+ if (!kp) {
144
+ missing.push(t);
145
+ continue;
146
+ }
147
+ if (fresh)
148
+ fresh.claimed = true;
149
+ keyPackages.push({ ...t, keyPackage: kp.data.slice(), lastResort: kp.lastResort });
150
+ }
151
+ return { ok: true, keyPackages, missing };
152
+ },
153
+ listDevices: async ({ conversationId }) => {
154
+ await tick();
155
+ if (conversationId === undefined) {
156
+ const devices = this.devicesOf([me]).map((d) => {
157
+ const at = this.deviceAddedAt.get(d.deviceId);
158
+ return at ? { ...d, createdAt: at, lastSeenAt: at } : d;
159
+ });
160
+ return { ok: true, devices };
161
+ }
162
+ if (!member(conversationId))
163
+ return fail("not_found", "No such conversation.");
164
+ return { ok: true, devices: this.devicesOf(this.conversations.get(conversationId)) };
165
+ },
166
+ removeDevice: async ({ deviceId }) => {
167
+ await tick();
168
+ if (!this.isDevice(me, deviceId))
169
+ return fail("unknown_device", "Publish KeyPackages from this device first.");
170
+ this.devices.set(me, (this.devices.get(me) ?? []).filter((d) => d !== deviceId));
171
+ this.removed.add(`${me}:${deviceId}`);
172
+ this.keyPackages = this.keyPackages.filter((k) => !(k.serverUserId === me && k.deviceId === deviceId));
173
+ this.welcomes = this.welcomes.filter((w) => !(w.serverUserId === me && w.deviceId === deviceId));
174
+ this.push(this.peopleSharing(me), (d) => d.handleDevicesChanged({ serverUserId: me }));
175
+ return { ok: true };
176
+ },
177
+ createGroup: async ({ conversationId, groupId }) => {
178
+ await tick();
179
+ if (!member(conversationId))
180
+ return fail("not_found", "No such conversation.");
181
+ const existing = this.groups.get(conversationId);
182
+ if (existing) {
183
+ this.stats.groupExists += 1;
184
+ return fail("group_exists", "This conversation already has a group.", { group: this.groupView(existing) });
185
+ }
186
+ const g = { conversationId, groupId, epoch: 0, headSeq: 0, log: [] };
187
+ this.groups.set(conversationId, g);
188
+ return { ok: true, group: this.groupView(g) };
189
+ },
190
+ commit: async ({ conversationId, deviceId, commit, welcome }) => {
191
+ await tick();
192
+ if (this.held) {
193
+ const held = this.held;
194
+ await new Promise((resolve) => {
195
+ held.waiting.push(resolve);
196
+ if (held.waiting.length >= held.count) {
197
+ this.held = null;
198
+ held.waiting.forEach((go) => go());
199
+ }
200
+ });
201
+ }
202
+ return this.commit(me, conversationId, deviceId, commit, welcome);
203
+ },
204
+ send: async ({ conversationId, deviceId, message, attachmentIds }) => {
205
+ await tick();
206
+ if (!member(conversationId))
207
+ return fail("not_found", "No such conversation.");
208
+ const g = this.groups.get(conversationId);
209
+ if (!g)
210
+ return fail("no_group", "This conversation has no group yet.");
211
+ const info = inspectMlsMessage(message);
212
+ if (info.contentType === "commit")
213
+ return fail("use_commit", "Send commits with mls:commit.");
214
+ if (hexOf(info.groupId) !== g.groupId)
215
+ return fail("wrong_group", "That message is for another group.");
216
+ const epoch = Number(info.epoch);
217
+ if (epoch > g.epoch)
218
+ return fail("future_epoch", "That epoch hasn't happened yet.", { epoch: g.epoch });
219
+ const dup = g.log.find((e) => e.seq > g.headSeq - DUPLICATE_WINDOW && same(e.data, message));
220
+ if (dup)
221
+ return { ok: true, seq: dup.seq };
222
+ const kind = info.contentType === "application" ? "application" : "proposal";
223
+ const entry = this.append(g, kind, epoch, me, deviceId, message);
224
+ this.attachmentIds.set(entry.seq, attachmentIds);
225
+ this.push(this.conversations.get(conversationId), (d) => d.handleMessage(entry));
226
+ return { ok: true, seq: entry.seq };
227
+ },
228
+ fetchLog: async ({ conversationId, after, limit }) => {
229
+ await tick();
230
+ if (!member(conversationId))
231
+ return fail("not_found", "No such conversation.");
232
+ const g = this.groups.get(conversationId);
233
+ if (!g)
234
+ return { ok: true, group: null, entries: [], nextCursor: after, hasMore: false, gap: false };
235
+ const n = Math.min(limit ?? 100, MAX_LOG_PAGE);
236
+ const rows = g.log.filter((e) => e.seq > after).slice(0, n + 1);
237
+ const page = rows.slice(0, n);
238
+ const oldest = g.log.length ? g.log[0].seq : null;
239
+ const gap = after < g.headSeq && (oldest === null || oldest > after + 1);
240
+ return {
241
+ ok: true,
242
+ group: this.groupView(g),
243
+ entries: page.map((e) => ({ ...e, data: e.data.slice() })),
244
+ nextCursor: page.length ? page[page.length - 1].seq : Math.max(after, gap ? g.headSeq : after),
245
+ hasMore: rows.length > n,
246
+ gap,
247
+ };
248
+ },
249
+ sync: async ({ deviceId }) => {
250
+ await tick();
251
+ const registered = this.isDevice(me, deviceId);
252
+ const groups = [...this.groups.values()]
253
+ .filter((g) => member(g.conversationId))
254
+ .map((g) => ({ ...this.groupView(g), oldestSeq: g.log.length ? g.log[0].seq : null }));
255
+ const welcomes = registered
256
+ ? this.welcomes.filter((w) => w.serverUserId === me && w.deviceId === deviceId).slice(0, this.syncWelcomeLimit)
257
+ : [];
258
+ return { ok: true, registered, groups, welcomes, keyPackages: { ...this.counts(me, deviceId), target: MAX_KEY_PACKAGES } };
259
+ },
260
+ ackWelcomes: async ({ deviceId, welcomeIds }) => {
261
+ await tick();
262
+ const before = this.welcomes.length;
263
+ this.welcomes = this.welcomes.filter((w) => !(w.serverUserId === me && w.deviceId === deviceId && welcomeIds.includes(w.welcomeId)));
264
+ return { ok: true, deleted: before - this.welcomes.length };
265
+ },
266
+ };
267
+ // socket.io-parser closes the connection on a packet with more than ten binary parts.
268
+ const guarded = {};
269
+ for (const [name, method] of Object.entries(methods)) {
270
+ guarded[name] = async (req) => {
271
+ if (!socket.reachable)
272
+ return fail("offline", "Not connected to this server.");
273
+ const lost = socket.lose.get(name);
274
+ socket.lose.delete(name);
275
+ if (lost === "request")
276
+ return fail("timeout", `No answer to ${name}.`);
277
+ if (lost === "answer") {
278
+ await guarded[name](req).catch(() => undefined);
279
+ return fail("timeout", `No answer to ${name}.`);
280
+ }
281
+ this.checkParts(socket, name, req);
282
+ const deviceId = req?.deviceId;
283
+ if (typeof deviceId === "string") {
284
+ this.callsBy.set(deviceId, [...(this.callsBy.get(deviceId) ?? []), name]);
285
+ if (this.removed.has(`${me}:${deviceId}`)) {
286
+ await tick();
287
+ return fail("device_removed", "You removed this device from encrypted messages here.");
288
+ }
289
+ }
290
+ const reply = await method(req);
291
+ this.checkParts(socket, name, reply);
292
+ return reply;
293
+ };
294
+ }
295
+ return guarded;
296
+ }
297
+ checkParts(socket, event, value) {
298
+ const count = (v) => v instanceof Uint8Array ? 1 : Array.isArray(v) ? v.reduce((n, x) => n + count(x), 0)
299
+ : v && typeof v === "object" ? Object.values(v).reduce((n, x) => n + count(x), 0) : 0;
300
+ const parts = count(value);
301
+ this.stats.mostBinaryParts = Math.max(this.stats.mostBinaryParts, parts);
302
+ if (parts > 10) {
303
+ socket.online = false;
304
+ throw new Error(`${event}: ${parts} binary parts, so the socket was closed ("too many attachments").`);
305
+ }
306
+ }
307
+ counts(serverUserId, deviceId) {
308
+ const mine = this.keyPackages.filter((k) => k.serverUserId === serverUserId && k.deviceId === deviceId && !k.claimed);
309
+ return { unclaimed: mine.filter((k) => !k.lastResort).length, lastResort: mine.some((k) => k.lastResort) };
310
+ }
311
+ append(g, kind, epoch, sender, deviceId, data) {
312
+ g.headSeq += 1;
313
+ const entry = {
314
+ conversationId: g.conversationId,
315
+ groupId: g.groupId,
316
+ seq: g.headSeq,
317
+ kind,
318
+ epoch,
319
+ senderServerUserId: sender,
320
+ senderDeviceId: deviceId,
321
+ data: data.slice(),
322
+ createdAt: new Date().toISOString(),
323
+ };
324
+ g.log.push(entry);
325
+ return entry;
326
+ }
327
+ async commit(me, conversationId, deviceId, commit, welcome) {
328
+ if (!this.conversations.get(conversationId)?.includes(me))
329
+ return fail("not_found", "No such conversation.");
330
+ if (!this.isDevice(me, deviceId))
331
+ return fail("unknown_device", "Publish KeyPackages from this device first.");
332
+ const g = this.groups.get(conversationId);
333
+ if (!g)
334
+ return fail("no_group", "This conversation has no group yet.");
335
+ const info = inspectMlsMessage(commit);
336
+ if (info.wireformat !== "mls_public_message" || info.contentType !== "commit")
337
+ return fail("not_a_commit", "Not a commit.");
338
+ if (hexOf(info.groupId) !== g.groupId)
339
+ return fail("wrong_group", "That commit is for another group.");
340
+ const dup = g.log.find((e) => e.seq > g.headSeq - DUPLICATE_WINDOW && same(e.data, commit));
341
+ if (dup)
342
+ return { ok: true, seq: dup.seq, epoch: dup.epoch + 1 };
343
+ const epoch = Number(info.epoch);
344
+ if (epoch !== g.epoch) {
345
+ this.stats.staleEpoch += 1;
346
+ return fail("stale_epoch", "Another commit got there first.", { epoch: g.epoch, headSeq: g.headSeq });
347
+ }
348
+ const recipients = [];
349
+ if (welcome) {
350
+ for (const ref of mlsWelcomeRefs(welcome)) {
351
+ const owner = this.keyPackages.find((k) => k.ref === ref);
352
+ if (!owner)
353
+ return fail("not_a_member_device", "That Welcome is for somebody outside the conversation.");
354
+ recipients.push({ serverUserId: owner.serverUserId, deviceId: owner.deviceId });
355
+ }
356
+ }
357
+ this.stats.commits += 1;
358
+ const entry = this.append(g, "commit", epoch, me, deviceId, commit);
359
+ g.epoch += 1;
360
+ this.push(this.conversations.get(conversationId), (d) => d.handleMessage(entry));
361
+ for (const r of recipients) {
362
+ const w = {
363
+ welcomeId: `w${this.nextWelcome++}`,
364
+ conversationId,
365
+ groupId: g.groupId,
366
+ deviceId: r.deviceId,
367
+ data: welcome.slice(),
368
+ createdAt: entry.createdAt,
369
+ };
370
+ this.welcomes.push({ ...w, serverUserId: r.serverUserId });
371
+ this.push([r.serverUserId], (d) => d.handleWelcome(w));
372
+ }
373
+ return { ok: true, seq: entry.seq, epoch: g.epoch };
374
+ }
375
+ }
376
+ /** What an app's store does, kept in maps and copied on the way in and out. */
377
+ export class MemoryMlsStore {
378
+ device = null;
379
+ keyPackages = new Map();
380
+ groups = new Map();
381
+ async loadDevice() {
382
+ return this.device;
383
+ }
384
+ async saveDevice(device) {
385
+ this.device = device;
386
+ }
387
+ async putKeyPackages(records) {
388
+ for (const r of records)
389
+ this.keyPackages.set(r.ref, r);
390
+ }
391
+ async listKeyPackages() {
392
+ return [...this.keyPackages.values()];
393
+ }
394
+ async getKeyPackage(ref) {
395
+ return this.keyPackages.get(ref) ?? null;
396
+ }
397
+ async deleteKeyPackage(ref) {
398
+ this.keyPackages.delete(ref);
399
+ }
400
+ async loadGroup(conversationId) {
401
+ const r = this.groups.get(conversationId);
402
+ return r ? structuredClone(r) : null;
403
+ }
404
+ async listGroups() {
405
+ return [...this.groups.values()].map((r) => structuredClone(r));
406
+ }
407
+ async saveGroup(record) {
408
+ this.groups.set(record.conversationId, structuredClone(record));
409
+ }
410
+ async deleteGroup(conversationId) {
411
+ this.groups.delete(conversationId);
412
+ }
413
+ }
@@ -1,5 +1,5 @@
1
1
  import type { MlsDmDriver, MlsDmDriverOptions, MlsRefusal } from "./interfaces.js";
2
- export type MlsDriverErrorCode = "refused" | "not_own_device" | "no_device" | "waiting_for_welcome" | "not_in_group" | "unexpected_member" | "peer_unverified" | "too_many_attachments" | "commit_retries" | "device_removed";
2
+ export type MlsDriverErrorCode = "refused" | "not_own_device" | "no_device" | "waiting_for_welcome" | "not_in_group" | "unexpected_member" | "peer_unverified" | "too_many_attachments" | "commit_retries" | "device_removed" | "offline" | "stopped";
3
3
  export declare class MlsDriverError extends Error {
4
4
  code: MlsDriverErrorCode;
5
5
  /** The server's refusal, when there was one. */
@@ -9,6 +9,10 @@ const PUBLISH_BATCH = MAX_BINARY_PARTS - 1;
9
9
  /** The server's cap on files in one message. */
10
10
  const MAX_ATTACHMENT_IDS = 10;
11
11
  const WELCOME_WAIT_MS = 10_000;
12
+ /** The same cap as a message that isn't MLS: after this a send fails and the text goes back. */
13
+ const SEND_GIVE_UP_MS = 5 * 60_000;
14
+ /** A send with no answer goes again after this, or sooner if the connection comes back. */
15
+ const RESEND_AFTER_MS = 2_000;
12
16
  /** The server keeps Welcomes 30 days; a last-resort package is replaced with a week to go. */
13
17
  const WELCOME_KEEP_S = 30 * 86_400;
14
18
  const RENEW_BEFORE_S = 7 * 86_400;
@@ -26,6 +30,11 @@ export class MlsDriverError extends Error {
26
30
  function refused(what, r) {
27
31
  return new MlsDriverError("refused", `${what}: ${r.error} (${r.message})`, r);
28
32
  }
33
+ /** A refusal that says nothing about the request, only that the server couldn't be reached. */
34
+ function unreachable(r) {
35
+ const error = r instanceof MlsDriverError ? r.refusal?.error : r?.error;
36
+ return error === "offline" || error === "timeout" ? error : null;
37
+ }
29
38
  function toHex(bytes) {
30
39
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
31
40
  }
@@ -107,6 +116,35 @@ export function createMlsDmDriver(options) {
107
116
  const reconciled = new Set();
108
117
  const seen = new Set();
109
118
  const welcomeWaiters = new Map();
119
+ const flights = new Set();
120
+ let waitingForServer = false;
121
+ let stopped = false;
122
+ const paused = new Set();
123
+ const giveUpMs = options.sendGiveUpMs ?? SEND_GIVE_UP_MS;
124
+ function setWaiting(next) {
125
+ if (waitingForServer === next)
126
+ return;
127
+ waitingForServer = next;
128
+ events.onWaiting?.(next);
129
+ }
130
+ /** Until `start()` has caught up again, or `ms`. Fails at the deadline or on `stop()`. */
131
+ function pause(deadline, ms) {
132
+ return new Promise((resolve, reject) => {
133
+ const left = deadline - Date.now();
134
+ if (stopped || left <= 0)
135
+ return reject(stopped ? stoppedError() : gaveUpError());
136
+ const done = (then) => {
137
+ clearTimeout(timer);
138
+ paused.delete(entry);
139
+ then();
140
+ };
141
+ const entry = { resume: () => done(resolve), fail: (e) => done(() => reject(e)) };
142
+ const timer = setTimeout(() => (Date.now() >= deadline ? entry.fail(gaveUpError()) : entry.resume()), Math.min(left, ms));
143
+ paused.add(entry);
144
+ });
145
+ }
146
+ const gaveUpError = () => new MlsDriverError("offline", "The server didn't answer in time, so the message wasn't sent.");
147
+ const stoppedError = () => new MlsDriverError("stopped", "This connection has closed.");
110
148
  async function ownDevice() {
111
149
  if (device)
112
150
  return device;
@@ -255,7 +293,14 @@ export function createMlsDmDriver(options) {
255
293
  }
256
294
  }
257
295
  }
258
- else if (!(e.senderServerUserId === self && e.senderDeviceId === d.deviceId) && e.epoch >= open.rec.joinedEpoch) {
296
+ else if (e.senderServerUserId === self && e.senderDeviceId === d.deviceId) {
297
+ // A send whose answer never came: the log says it landed, so it isn't sent again.
298
+ for (const f of flights) {
299
+ if (f.conversationId === open.rec.conversationId && f.seq === null && f.message && sameBytes(f.message, e.data))
300
+ f.seq = e.seq;
301
+ }
302
+ }
303
+ else if (e.epoch >= open.rec.joinedEpoch) {
259
304
  let plaintext = null;
260
305
  try {
261
306
  const r = await processMlsMessage(open.state, e.data);
@@ -569,12 +614,50 @@ export function createMlsDmDriver(options) {
569
614
  }
570
615
  if (!registered)
571
616
  throw new MlsDriverError("no_device", "This device isn't registered for MLS here. Call start() first.");
572
- await ensureOpen(conversationId);
617
+ if (stopped)
618
+ throw stoppedError();
619
+ const deadline = Date.now() + giveUpMs;
620
+ const flight = { conversationId, message: null, seq: null };
621
+ flights.add(flight);
622
+ try {
623
+ for (;;) {
624
+ try {
625
+ return await sendOnce(flight, peer, plaintext, attachmentIds);
626
+ }
627
+ catch (e) {
628
+ const why = unreachable(e);
629
+ if (!why || stopped)
630
+ throw e;
631
+ // Offline waits for the next start(). A timeout goes again soon, as the same bytes.
632
+ if (why === "offline")
633
+ setWaiting(true);
634
+ await pause(deadline, why === "offline" ? Infinity : RESEND_AFTER_MS);
635
+ }
636
+ }
637
+ }
638
+ finally {
639
+ flights.delete(flight);
640
+ }
641
+ }
642
+ /* Encrypted at most once, and only once the group is caught up. Every later attempt sends
643
+ the same bytes, which the server takes as a repeat rather than a second message. */
644
+ async function sendOnce(flight, peer, plaintext, attachmentIds) {
645
+ const { conversationId } = flight;
646
+ if (!flight.message)
647
+ await ensureOpen(conversationId);
573
648
  return run(conversationId, async () => {
574
649
  const d = await ownDevice();
575
650
  const open = await load(conversationId);
576
651
  if (!open || !(await catchUp(open)))
577
652
  throw new MlsDriverError("not_in_group", "This device isn't in that group.");
653
+ if (flight.seq !== null)
654
+ return { seq: flight.seq };
655
+ if (flight.message) {
656
+ const r = await transport.send({ conversationId, deviceId: d.deviceId, message: flight.message, attachmentIds });
657
+ if (!r.ok)
658
+ throw refused("Sending", r);
659
+ return { seq: r.seq };
660
+ }
578
661
  if (!reconciled.has(conversationId))
579
662
  await reconcile(open, peer, true);
580
663
  const people = await peopleIn(conversationId, open.state);
@@ -589,6 +672,7 @@ export function createMlsDmDriver(options) {
589
672
  open.state = out.state;
590
673
  // The generation is used up whether or not the send arrives, so the state is saved first.
591
674
  await save(open);
675
+ flight.message = out.message;
592
676
  const r = await transport.send({ conversationId, deviceId: d.deviceId, message: out.message, attachmentIds });
593
677
  if (!r.ok)
594
678
  throw refused("Sending", r);
@@ -806,6 +890,15 @@ export function createMlsDmDriver(options) {
806
890
  await catchUp(open);
807
891
  });
808
892
  }
893
+ // Caught up, so the sends that waited go now, on top of that.
894
+ setWaiting(false);
895
+ for (const p of [...paused])
896
+ p.resume();
897
+ },
898
+ stop() {
899
+ stopped = true;
900
+ for (const p of [...paused])
901
+ p.fail(stoppedError());
809
902
  },
810
903
  modeFor,
811
904
  send,
@@ -51,8 +51,8 @@ export interface MlsClaimedKeyPackage extends MlsDeviceRef {
51
51
  lastResort: boolean;
52
52
  }
53
53
  /**
54
- * One method per mls:* request. The app adds `accessToken`, emits, and resolves with the ack,
55
- * turning ArrayBuffers into Uint8Arrays. Pushes go to the driver's `handle*` methods instead.
54
+ * One method per mls:* request: add `accessToken`, emit, resolve with the ack. Pushes go to `handle*`.
55
+ * Disconnected, answer `offline` without emitting; with no ack in time, answer `timeout`.
56
56
  */
57
57
  export interface MlsTransport {
58
58
  publishKeyPackages(req: {
@@ -230,6 +230,8 @@ export interface MlsDmEvents {
230
230
  seq: number;
231
231
  reason: string;
232
232
  }): void;
233
+ /** Sends are held until the connection is back and `start()` has caught up, or not any more. */
234
+ onWaiting?(waiting: boolean): void;
233
235
  /** This device is out of the group: removed, or its state couldn't keep up. */
234
236
  onGroupLost?(info: {
235
237
  conversationId: string;
@@ -309,13 +311,18 @@ export interface MlsDmDriverOptions {
309
311
  capability: MlsServerCapability | null;
310
312
  /** The first time only: `createMlsDevice` from @gryt/crypto, so the seed stays in the app. */
311
313
  newDevice(): MlsDeviceRecord | Promise<MlsDeviceRecord>;
314
+ /** How long a send waits for the server before it fails with `offline`. Five minutes. */
315
+ sendGiveUpMs?: number;
312
316
  }
313
317
  /** One per server. Calls for one conversation run one at a time, in order. */
314
318
  export interface MlsDmDriver {
315
319
  /** On connect: register, top up KeyPackages, take waiting Welcomes, catch up every group. */
316
320
  start(): Promise<void>;
317
321
  modeFor(conversationId: string, peerServerUserId: string): Promise<DmSealingMode>;
318
- /** Opens the group if need be, adds or removes devices that changed, then sends. */
322
+ /**
323
+ * Opens the group if need be, adds or removes devices that changed, then sends. Offline, it
324
+ * waits for the next `start()` and encrypts after that. It goes out once, or fails `offline`.
325
+ */
319
326
  send(conversationId: string, peerServerUserId: string, plaintext: Uint8Array, options?: {
320
327
  attachmentIds?: readonly string[];
321
328
  }): Promise<{
@@ -337,4 +344,6 @@ export interface MlsDmDriver {
337
344
  * Throws `not_own_device` for a device the server doesn't list as yours, or not under your person key.
338
345
  */
339
346
  addOwnDevice(deviceId: string, options?: MlsAddOwnDeviceOptions): Promise<MlsOwnDeviceAdd[]>;
347
+ /** Sends still waiting fail with `stopped`, and no more are taken. For a session going away. */
348
+ stop(): void;
340
349
  }
@@ -2,6 +2,17 @@ import { base64Url, base64UrlDecode, createPairingKey, decodePairingSessionId, e
2
2
  import { mailbox, openJson, PairingEnded, reasonFor, systemClock } from "./channel.js";
3
3
  import { PAIRING_APPROVAL_MS } from "./newDevice.js";
4
4
  const NOT_LISTED_RETRIES = 5;
5
+ /** auth#46's codes that get a named reason. Anything else it can answer still falls
6
+ through to the generic `approve:<code>`. */
7
+ const APPROVE_ERROR_REASONS = {
8
+ code_used: "code_used",
9
+ unknown_code: "code_expired",
10
+ expired_code: "code_expired",
11
+ code_not_pending: "code_expired",
12
+ required_actions: "required_actions",
13
+ stale_token: "stale_token",
14
+ rate_limited: "rate_limited",
15
+ };
5
16
  /** A: claims the session, shows N and the emoji, and on Approve seals the envelope, signs N in and adds it. */
6
17
  export function createApproverPairing(options) {
7
18
  const { relay, fetch, devices } = options;
@@ -107,7 +118,8 @@ export function createApproverPairing(options) {
107
118
  if (res.status === 404)
108
119
  return false;
109
120
  const body = (await res.json().catch(() => null));
110
- throw new PairingEnded(`approve:${typeof body?.error === "string" ? body.error : res.status}`);
121
+ const code = typeof body?.error === "string" ? body.error : String(res.status);
122
+ throw new PairingEnded(APPROVE_ERROR_REASONS[code] ?? `approve:${code}`);
111
123
  }
112
124
  async function addEverywhere(ready) {
113
125
  const added = {};
@@ -70,5 +70,18 @@ export interface PairedServerDevice {
70
70
  export type OwnDeviceAdder = (host: string) => {
71
71
  addOwnDevice(deviceId: string, options?: MlsAddOwnDeviceOptions): Promise<MlsOwnDeviceAdd[]>;
72
72
  } | undefined;
73
- /** Why a pairing stopped. `approve:<error>` carries the extension's own error code. */
74
- export type PairingEndReason = "cancelled" | "cancelled_by_other" | "mismatch" | "timed_out" | "expired" | "already_claimed" | "unknown_code" | "wrong_relay" | "newer_version" | "not_pairing" | "rate_limited" | "tampered" | "wrong_account" | "sign_in_failed" | "relay_error" | `approve:${string}`;
73
+ /** Why a pairing stopped. `approve:<error>` carries the extension's own error code for anything
74
+ below that isn't broken out on its own (GRYT-1578; auth#46 has the full list). */
75
+ export type PairingEndReason = "cancelled" | "cancelled_by_other" | "mismatch" | "timed_out" | "expired" | "already_claimed" | "unknown_code" | "wrong_relay" | "newer_version" | "not_pairing" | "rate_limited" | "tampered" | "wrong_account" | "sign_in_failed" | "relay_error"
76
+ /** The approve endpoint says this user_code already got its one answer. */
77
+ | "code_used"
78
+ /** The approve endpoint says the user_code isn't valid any more: unknown, expired, or not pending. */
79
+ | "code_expired"
80
+ /** The approve endpoint refuses because the account has something pending, like verifying an email. */
81
+ | "required_actions"
82
+ /** The approve endpoint refuses A's access token as too old; refresh it and try again. */
83
+ | "stale_token"
84
+ /** The device grant itself said no: A denied it, or the approve endpoint's refusal denied the code. */
85
+ | "access_denied"
86
+ /** The device grant's user_code ran out before anyone answered it. */
87
+ | "expired_token" | `approve:${string}`;
@@ -116,10 +116,15 @@ export function createNewDevicePairing(options) {
116
116
  throw failure;
117
117
  if (polled.status === "ok")
118
118
  return checkIdToken(polled.tokens, account, session.keycloakNonce);
119
+ // RFC 8628's own two terminal errors, plus what auth#46 leaves behind when A's approve
120
+ // endpoint refuses the code: either way N's device grant sees it as denied.
121
+ if (polled.status === "denied")
122
+ throw new PairingEnded("access_denied");
123
+ if (polled.status === "expired")
124
+ throw new PairingEnded("expired_token");
119
125
  if (polled.status === "slow_down")
120
126
  interval += 5000;
121
- else if (polled.status !== "pending")
122
- break;
127
+ // Anything else is "pending": keep polling.
123
128
  }
124
129
  throw new PairingEnded("sign_in_failed");
125
130
  }
@@ -0,0 +1 @@
1
+ export { FakeDeliveryService, MemoryMlsStore } from "./mls/deliveryService.fake.js";
@@ -0,0 +1,3 @@
1
+ /* For the apps' own tests: the in-memory delivery service and state store the driver's tests
2
+ use, so a session can run the real driver against it. Not for app code. */
3
+ export { FakeDeliveryService, MemoryMlsStore } from "./mls/deliveryService.fake.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Gryt's shared application logic: one implementation of the things the desktop and mobile apps both do.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
@@ -14,6 +14,10 @@
14
14
  ".": {
15
15
  "types": "./dist/index.d.ts",
16
16
  "default": "./dist/index.js"
17
+ },
18
+ "./testing": {
19
+ "types": "./dist/testing.d.ts",
20
+ "default": "./dist/testing.js"
17
21
  }
18
22
  },
19
23
  "files": [
@@ -35,7 +39,7 @@
35
39
  "access": "public"
36
40
  },
37
41
  "dependencies": {
38
- "@gryt/crypto": "0.8.0",
42
+ "@gryt/crypto": "0.9.0",
39
43
  "@noble/hashes": "^2.3.0"
40
44
  }
41
45
  }