@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.
- package/dist/mls/deliveryService.fake.d.ts +96 -0
- package/dist/mls/deliveryService.fake.js +413 -0
- package/dist/mls/dmDriver.d.ts +1 -1
- package/dist/mls/dmDriver.js +95 -2
- package/dist/mls/interfaces.d.ts +12 -3
- package/dist/pairing/approver.js +13 -1
- package/dist/pairing/interfaces.d.ts +15 -2
- package/dist/pairing/newDevice.js +7 -2
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +3 -0
- package/package.json +6 -2
|
@@ -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
|
+
}
|
package/dist/mls/dmDriver.d.ts
CHANGED
|
@@ -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. */
|
package/dist/mls/dmDriver.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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,
|
package/dist/mls/interfaces.d.ts
CHANGED
|
@@ -51,8 +51,8 @@ export interface MlsClaimedKeyPackage extends MlsDeviceRef {
|
|
|
51
51
|
lastResort: boolean;
|
|
52
52
|
}
|
|
53
53
|
/**
|
|
54
|
-
* One method per mls:* request
|
|
55
|
-
*
|
|
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
|
-
/**
|
|
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
|
}
|
package/dist/pairing/approver.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
package/dist/testing.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gryt/core",
|
|
3
|
-
"version": "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.
|
|
42
|
+
"@gryt/crypto": "0.9.0",
|
|
39
43
|
"@noble/hashes": "^2.3.0"
|
|
40
44
|
}
|
|
41
45
|
}
|