@gryt/core 0.14.0 → 0.16.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/README.md +10 -1
- package/dist/index.d.ts +1 -1
- 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.d.ts +16 -1
- package/dist/pairing/approver.js +75 -4
- package/dist/pairing/channel.d.ts +1 -1
- package/dist/pairing/channel.js +2 -1
- package/dist/pairing/historyReceiver.d.ts +34 -0
- package/dist/pairing/historyReceiver.js +139 -0
- package/dist/pairing/historySender.d.ts +65 -0
- package/dist/pairing/historySender.js +182 -0
- package/dist/pairing/historySnapshot.d.ts +7 -0
- package/dist/pairing/historySnapshot.js +45 -0
- package/dist/pairing/interfaces.d.ts +71 -4
- package/dist/pairing/newDevice.d.ts +6 -1
- package/dist/pairing/newDevice.js +58 -7
- package/dist/pairing/relayClient.d.ts +8 -1
- package/dist/pairing/relayClient.js +16 -1
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +3 -0
- package/package.json +6 -2
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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type PairingEmoji, type PairingEnvelope } from "@gryt/crypto";
|
|
2
2
|
import type { MlsOwnDeviceAdd } from "../mls/interfaces.js";
|
|
3
|
-
import type { OwnDeviceAdder, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch } from "./interfaces.js";
|
|
3
|
+
import type { HistoryArchive, HistoryNotedMessage, HistoryProgress, OwnDeviceAdder, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch } from "./interfaces.js";
|
|
4
4
|
import type { PairingRelay } from "./relayClient.js";
|
|
5
5
|
export type ApproverState = {
|
|
6
6
|
phase: "idle";
|
|
@@ -34,6 +34,12 @@ export type ApproverState = {
|
|
|
34
34
|
host: string;
|
|
35
35
|
done: number;
|
|
36
36
|
total: number;
|
|
37
|
+
}
|
|
38
|
+
/** N is in every conversation; the history's tail is still going. Progress is on `history`. */
|
|
39
|
+
| {
|
|
40
|
+
phase: "sending";
|
|
41
|
+
device: PairingDeviceInfo;
|
|
42
|
+
added: Record<string, MlsOwnDeviceAdd[]>;
|
|
37
43
|
} | {
|
|
38
44
|
phase: "done";
|
|
39
45
|
device: PairingDeviceInfo;
|
|
@@ -51,10 +57,19 @@ export interface ApproverOptions {
|
|
|
51
57
|
devices: OwnDeviceAdder;
|
|
52
58
|
clock?: PairingClock;
|
|
53
59
|
approvalMs?: number;
|
|
60
|
+
/** A's archive. Without it, no history goes across. */
|
|
61
|
+
history?: HistoryArchive;
|
|
62
|
+
/** How long after the adds A keeps collecting old-epoch messages for the last tail. */
|
|
63
|
+
lateWindowMs?: number;
|
|
54
64
|
}
|
|
55
65
|
export interface ApproverPairing {
|
|
56
66
|
readonly state: ApproverState;
|
|
57
67
|
subscribe(listener: (state: ApproverState) => void): () => void;
|
|
68
|
+
/** Null until Approve, or with no archive. */
|
|
69
|
+
readonly history: HistoryProgress | null;
|
|
70
|
+
subscribeHistory(listener: (progress: HistoryProgress) => void): () => void;
|
|
71
|
+
/** Every MLS message A archives while this runs, received or sent, for the tail. */
|
|
72
|
+
noteMessage(message: HistoryNotedMessage): void;
|
|
58
73
|
/** A scanned QR, or a typed code. */
|
|
59
74
|
claim(input: {
|
|
60
75
|
qr: string;
|
package/dist/pairing/approver.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { base64Url, base64UrlDecode, createPairingKey, decodePairingSessionId, encodePairingEnvelope, encodePairingSessionId, parsePairingCode, parsePairingQr, startApproverSession, } from "@gryt/crypto";
|
|
2
|
-
import { mailbox, openJson, PairingEnded, reasonFor, systemClock } from "./channel.js";
|
|
2
|
+
import { mailbox, openJson, PairingEnded, reasonFor, sealJson, systemClock } from "./channel.js";
|
|
3
|
+
import { createHistorySender, createTailRecorder } from "./historySender.js";
|
|
3
4
|
import { PAIRING_APPROVAL_MS } from "./newDevice.js";
|
|
4
5
|
const NOT_LISTED_RETRIES = 5;
|
|
6
|
+
/** How long Approve may spend sealing history before the envelope goes, so it lands in N's window. */
|
|
7
|
+
const ENVELOPE_BUDGET_MS = 5_000;
|
|
8
|
+
const HISTORY_LATE_WINDOW_MS = 60_000;
|
|
9
|
+
/** auth#46's codes that get a named reason. Anything else it can answer still falls
|
|
10
|
+
through to the generic `approve:<code>`. */
|
|
11
|
+
const APPROVE_ERROR_REASONS = {
|
|
12
|
+
code_used: "code_used",
|
|
13
|
+
unknown_code: "code_expired",
|
|
14
|
+
expired_code: "code_expired",
|
|
15
|
+
code_not_pending: "code_expired",
|
|
16
|
+
required_actions: "required_actions",
|
|
17
|
+
stale_token: "stale_token",
|
|
18
|
+
rate_limited: "rate_limited",
|
|
19
|
+
};
|
|
5
20
|
/** A: claims the session, shows N and the emoji, and on Approve seals the envelope, signs N in and adds it. */
|
|
6
21
|
export function createApproverPairing(options) {
|
|
7
22
|
const { relay, fetch, devices } = options;
|
|
@@ -13,6 +28,15 @@ export function createApproverPairing(options) {
|
|
|
13
28
|
let current = null;
|
|
14
29
|
let device = null;
|
|
15
30
|
let confirming = null;
|
|
31
|
+
let deadline = 0;
|
|
32
|
+
let history = null;
|
|
33
|
+
const historyListeners = new Set();
|
|
34
|
+
const tail = createTailRecorder();
|
|
35
|
+
const setHistory = (progress) => {
|
|
36
|
+
history = progress;
|
|
37
|
+
for (const l of historyListeners)
|
|
38
|
+
l(progress);
|
|
39
|
+
};
|
|
16
40
|
const set = (next) => {
|
|
17
41
|
if (state.phase === "ended" || state.phase === "done")
|
|
18
42
|
return;
|
|
@@ -75,7 +99,7 @@ export function createApproverPairing(options) {
|
|
|
75
99
|
const hello = openJson(current.session, await box.next(abort.signal, clock.now() + approvalMs), "hello");
|
|
76
100
|
const text = (v) => (typeof v === "string" ? v.slice(0, 100) : "");
|
|
77
101
|
device = { name: text(hello.name), app: text(hello.app), platform: text(hello.platform) };
|
|
78
|
-
|
|
102
|
+
deadline = clock.now() + approvalMs;
|
|
79
103
|
set({ phase: "confirming", device, location: claimed.location, yourLocation: claimed.yourLocation, emoji: current.session.emoji, deadline });
|
|
80
104
|
// N sends nothing more until A approves, so a message or a close here means something's off.
|
|
81
105
|
confirming = new AbortController();
|
|
@@ -107,7 +131,8 @@ export function createApproverPairing(options) {
|
|
|
107
131
|
if (res.status === 404)
|
|
108
132
|
return false;
|
|
109
133
|
const body = (await res.json().catch(() => null));
|
|
110
|
-
|
|
134
|
+
const code = typeof body?.error === "string" ? body.error : String(res.status);
|
|
135
|
+
throw new PairingEnded(APPROVE_ERROR_REASONS[code] ?? `approve:${code}`);
|
|
111
136
|
}
|
|
112
137
|
async function addEverywhere(ready) {
|
|
113
138
|
const added = {};
|
|
@@ -132,9 +157,37 @@ export function createApproverPairing(options) {
|
|
|
132
157
|
}
|
|
133
158
|
return added;
|
|
134
159
|
}
|
|
160
|
+
/** Each group's cursor on every server in the envelope: the snapshot covers up to there. */
|
|
161
|
+
async function positions(envelope) {
|
|
162
|
+
const snap = new Map();
|
|
163
|
+
for (const { host } of envelope.servers) {
|
|
164
|
+
const driver = devices(host);
|
|
165
|
+
if (driver)
|
|
166
|
+
snap.set(host, new Map((await driver.groupPositions()).map((p) => [p.conversationId, p.seq])));
|
|
167
|
+
}
|
|
168
|
+
return snap;
|
|
169
|
+
}
|
|
170
|
+
function startHistory(archive) {
|
|
171
|
+
const { id, token, session } = current;
|
|
172
|
+
return createHistorySender({
|
|
173
|
+
relay, id, token, archive, clock, signal: abort.signal,
|
|
174
|
+
post: (message) => relay.post(id, token, { type: "sealed", body: sealJson(session, message) }),
|
|
175
|
+
onProgress: setHistory,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
135
178
|
async function approve(envelope, accessToken) {
|
|
136
179
|
const { id, token, box, session } = current;
|
|
137
180
|
set({ phase: envelope.account ? "signing_in" : "waiting_ready", device: device });
|
|
181
|
+
let sender = null;
|
|
182
|
+
let snap = new Map();
|
|
183
|
+
if (options.history) {
|
|
184
|
+
// Recording starts before the positions are read, so nothing lands between the two unseen.
|
|
185
|
+
tail.start();
|
|
186
|
+
snap = await positions(envelope);
|
|
187
|
+
sender = startHistory(options.history);
|
|
188
|
+
const chunks = await sender.forEnvelope(Math.min(clock.now() + ENVELOPE_BUDGET_MS, deadline - 10_000));
|
|
189
|
+
envelope = { ...envelope, history: { key: sender.key, manifest: { v: 1, chunks } } };
|
|
190
|
+
}
|
|
138
191
|
await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(envelope))) });
|
|
139
192
|
if (envelope.account && accessToken) {
|
|
140
193
|
const message = await box.next(abort.signal, clock.now() + approvalMs);
|
|
@@ -151,10 +204,20 @@ export function createApproverPairing(options) {
|
|
|
151
204
|
const ready = openJson(session, await box.next(abort.signal), "ready");
|
|
152
205
|
const list = Array.isArray(ready.devices) ? ready.devices : [];
|
|
153
206
|
const named = list.filter((d) => typeof d?.host === "string" && typeof d?.deviceId === "string");
|
|
207
|
+
sender?.startPaging();
|
|
154
208
|
const added = await addEverywhere(named);
|
|
209
|
+
if (!sender) {
|
|
210
|
+
set({ phase: "done", device: device, added });
|
|
211
|
+
session.close();
|
|
212
|
+
return void (await closeQuietly());
|
|
213
|
+
}
|
|
214
|
+
set({ phase: "sending", device: device, added });
|
|
215
|
+
await sender.send(tail.take(snap, added), false);
|
|
216
|
+
await clock.sleep(options.lateWindowMs ?? HISTORY_LATE_WINDOW_MS, abort.signal);
|
|
217
|
+
await sender.send(tail.take(snap, added), true);
|
|
218
|
+
// N closes the session once it has every chunk; closing here would delete them.
|
|
155
219
|
set({ phase: "done", device: device, added });
|
|
156
220
|
session.close();
|
|
157
|
-
await closeQuietly();
|
|
158
221
|
}
|
|
159
222
|
return {
|
|
160
223
|
get state() {
|
|
@@ -164,6 +227,14 @@ export function createApproverPairing(options) {
|
|
|
164
227
|
listeners.add(listener);
|
|
165
228
|
return () => void listeners.delete(listener);
|
|
166
229
|
},
|
|
230
|
+
get history() {
|
|
231
|
+
return history;
|
|
232
|
+
},
|
|
233
|
+
subscribeHistory(listener) {
|
|
234
|
+
historyListeners.add(listener);
|
|
235
|
+
return () => void historyListeners.delete(listener);
|
|
236
|
+
},
|
|
237
|
+
noteMessage: (message) => tail.note(message),
|
|
167
238
|
claim(input) {
|
|
168
239
|
if (state.phase !== "idle")
|
|
169
240
|
throw new Error("This pairing has claimed a session already.");
|
|
@@ -10,7 +10,7 @@ export declare const systemClock: PairingClock;
|
|
|
10
10
|
export declare function reasonFor(error: unknown): PairingEndReason;
|
|
11
11
|
export declare function sealJson(session: PairingSession, value: Record<string, unknown>): string;
|
|
12
12
|
/** The next sealed message as JSON with a `t`, or a throw that ends the pairing as tampered. */
|
|
13
|
-
export declare function openJson(session: PairingSession, message: RelayMessage | null, t
|
|
13
|
+
export declare function openJson(session: PairingSession, message: RelayMessage | null, t?: string): Record<string, unknown>;
|
|
14
14
|
/** The other side's messages in order, long-polling the relay. `next` gives null once `deadline` passes. */
|
|
15
15
|
export declare function mailbox(relay: PairingRelay, id: string, token: string, clock: PairingClock): {
|
|
16
16
|
next(signal: AbortSignal, deadline?: number): Promise<RelayMessage | null>;
|
package/dist/pairing/channel.js
CHANGED
|
@@ -41,7 +41,8 @@ export function openJson(session, message, t) {
|
|
|
41
41
|
if (message?.type !== "sealed" || !message.body)
|
|
42
42
|
throw new Error();
|
|
43
43
|
const value = JSON.parse(new TextDecoder().decode(session.open(base64UrlDecode(message.body))));
|
|
44
|
-
|
|
44
|
+
const got = value && typeof value === "object" ? value.t : undefined;
|
|
45
|
+
if (typeof got === "string" && (t === undefined || got === t))
|
|
45
46
|
return value;
|
|
46
47
|
}
|
|
47
48
|
catch {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type HistoryChunkEntry } from "@gryt/crypto";
|
|
2
|
+
import type { HistoryProgress, HistorySink, PairingClock } from "./interfaces.js";
|
|
3
|
+
import { type PairingRelay } from "./relayClient.ts.js";
|
|
4
|
+
export interface HistoryReceiverOptions {
|
|
5
|
+
relay: PairingRelay;
|
|
6
|
+
id: string;
|
|
7
|
+
token: string;
|
|
8
|
+
key: Uint8Array;
|
|
9
|
+
sink: HistorySink;
|
|
10
|
+
clock: PairingClock;
|
|
11
|
+
signal: AbortSignal;
|
|
12
|
+
onProgress(progress: HistoryProgress): void;
|
|
13
|
+
}
|
|
14
|
+
/** N: fetches each listed chunk newest first, checks it against its entry, and stores each message once. */
|
|
15
|
+
export declare function createHistoryReceiver(options: HistoryReceiverOptions): {
|
|
16
|
+
done: Promise<void>;
|
|
17
|
+
progress: () => {
|
|
18
|
+
messages: number;
|
|
19
|
+
total: number | null;
|
|
20
|
+
chunks: number;
|
|
21
|
+
listed: number;
|
|
22
|
+
missing: number;
|
|
23
|
+
refused: number;
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
oldest: number | null;
|
|
26
|
+
complete: boolean;
|
|
27
|
+
};
|
|
28
|
+
/** The envelope's manifest: slots 0 onwards, in order. */
|
|
29
|
+
listManifest(chunks: readonly HistoryChunkEntry[]): void;
|
|
30
|
+
/** One of A's history messages. Throws `tampered` for anything off. */
|
|
31
|
+
list(message: Record<string, unknown>): void;
|
|
32
|
+
/** The session is gone: whatever is still listed won't arrive. */
|
|
33
|
+
abandon: () => void;
|
|
34
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { openHistoryChunk, parseHistoryChunkEntry } from "@gryt/crypto";
|
|
2
|
+
import { PairingEnded } from "./channel.js";
|
|
3
|
+
import { emptyProgress } from "./historySender.js";
|
|
4
|
+
import { PairingRelayError } from "./relayClient.js";
|
|
5
|
+
const MAX_NETWORK_RETRIES = 5;
|
|
6
|
+
const messageKey = (r) => JSON.stringify([r.scope, r.conversationId, r.messageId]);
|
|
7
|
+
/** N: fetches each listed chunk newest first, checks it against its entry, and stores each message once. */
|
|
8
|
+
export function createHistoryReceiver(options) {
|
|
9
|
+
const { relay, id, token, key, sink, clock, signal } = options;
|
|
10
|
+
const progress = emptyProgress();
|
|
11
|
+
const pending = new Map();
|
|
12
|
+
const slots = new Set();
|
|
13
|
+
const chunkIds = new Set();
|
|
14
|
+
const stored = new Set();
|
|
15
|
+
let final = false;
|
|
16
|
+
let stopped = false;
|
|
17
|
+
let wake = null;
|
|
18
|
+
const report = () => options.onProgress({ ...progress });
|
|
19
|
+
const nudge = () => (wake?.(), (wake = null));
|
|
20
|
+
function add(n, value) {
|
|
21
|
+
let entry;
|
|
22
|
+
try {
|
|
23
|
+
entry = parseHistoryChunkEntry(value);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new PairingEnded("tampered");
|
|
27
|
+
}
|
|
28
|
+
// A slot or chunk listed twice is either a replay or a bug; neither gets a second look.
|
|
29
|
+
if (!Number.isSafeInteger(n) || n < 0 || slots.has(n) || chunkIds.has(entry.id))
|
|
30
|
+
throw new PairingEnded("tampered");
|
|
31
|
+
slots.add(n);
|
|
32
|
+
chunkIds.add(entry.id);
|
|
33
|
+
pending.set(n, entry);
|
|
34
|
+
progress.listed++;
|
|
35
|
+
}
|
|
36
|
+
async function fetchChunk(n) {
|
|
37
|
+
for (let failures = 0;;) {
|
|
38
|
+
try {
|
|
39
|
+
return await relay.getChunk(id, token, n, signal);
|
|
40
|
+
}
|
|
41
|
+
catch (e) {
|
|
42
|
+
if (e instanceof PairingRelayError && e.status === 404)
|
|
43
|
+
return "missing";
|
|
44
|
+
if (e instanceof PairingRelayError && e.status === 410)
|
|
45
|
+
return "gone";
|
|
46
|
+
if (!(e instanceof PairingRelayError && e.code === "network") || ++failures > MAX_NETWORK_RETRIES)
|
|
47
|
+
throw e;
|
|
48
|
+
await clock.sleep(2000, signal);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** The newest listed chunk, so recent conversations fill in first. */
|
|
53
|
+
function next() {
|
|
54
|
+
let best = null;
|
|
55
|
+
for (const item of pending)
|
|
56
|
+
if (!best || item[1].last > best[1].last)
|
|
57
|
+
best = item;
|
|
58
|
+
return best;
|
|
59
|
+
}
|
|
60
|
+
function abandon() {
|
|
61
|
+
progress.missing += pending.size;
|
|
62
|
+
pending.clear();
|
|
63
|
+
stopped = true;
|
|
64
|
+
report();
|
|
65
|
+
nudge();
|
|
66
|
+
}
|
|
67
|
+
async function run() {
|
|
68
|
+
for (;;) {
|
|
69
|
+
if (signal.aborted || stopped)
|
|
70
|
+
return;
|
|
71
|
+
const item = next();
|
|
72
|
+
if (!item) {
|
|
73
|
+
if (final)
|
|
74
|
+
return void ((progress.complete = true), report());
|
|
75
|
+
await new Promise((resolve) => (wake = resolve));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const [n, entry] = item;
|
|
79
|
+
const sealed = await fetchChunk(n);
|
|
80
|
+
if (sealed === "gone")
|
|
81
|
+
return abandon();
|
|
82
|
+
pending.delete(n);
|
|
83
|
+
if (sealed === "missing") {
|
|
84
|
+
progress.missing++;
|
|
85
|
+
report();
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
let records;
|
|
89
|
+
try {
|
|
90
|
+
records = openHistoryChunk(key, entry, sealed);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
progress.refused++;
|
|
94
|
+
report();
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const fresh = records.filter((r) => !stored.has(messageKey(r)));
|
|
98
|
+
if (fresh.length > 0)
|
|
99
|
+
await sink.put(fresh);
|
|
100
|
+
for (const r of fresh)
|
|
101
|
+
stored.add(messageKey(r));
|
|
102
|
+
progress.chunks++;
|
|
103
|
+
progress.messages += fresh.length;
|
|
104
|
+
progress.oldest = Math.min(progress.oldest ?? entry.first, entry.first);
|
|
105
|
+
report();
|
|
106
|
+
relay.deleteChunk(id, token, n).catch(() => undefined);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
signal.addEventListener("abort", nudge, { once: true });
|
|
110
|
+
const done = run();
|
|
111
|
+
done.catch(() => undefined);
|
|
112
|
+
return {
|
|
113
|
+
done,
|
|
114
|
+
progress: () => ({ ...progress }),
|
|
115
|
+
/** The envelope's manifest: slots 0 onwards, in order. */
|
|
116
|
+
listManifest(chunks) {
|
|
117
|
+
chunks.forEach((entry, n) => add(n, entry));
|
|
118
|
+
report();
|
|
119
|
+
nudge();
|
|
120
|
+
},
|
|
121
|
+
/** One of A's history messages. Throws `tampered` for anything off. */
|
|
122
|
+
list(message) {
|
|
123
|
+
if (final || !Array.isArray(message.chunks))
|
|
124
|
+
throw new PairingEnded("tampered");
|
|
125
|
+
for (const chunk of message.chunks)
|
|
126
|
+
add(chunk?.n, chunk);
|
|
127
|
+
if (Number.isSafeInteger(message.total))
|
|
128
|
+
progress.total = message.total;
|
|
129
|
+
if (message.truncated === true)
|
|
130
|
+
progress.truncated = true;
|
|
131
|
+
if (message.final === true)
|
|
132
|
+
final = true;
|
|
133
|
+
report();
|
|
134
|
+
nudge();
|
|
135
|
+
},
|
|
136
|
+
/** The session is gone: whatever is still listed won't arrive. */
|
|
137
|
+
abandon,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type HistoryChunkEntry, type HistoryRecord } from "@gryt/crypto";
|
|
2
|
+
import type { MlsOwnDeviceAdd } from "../mls/interfaces.js";
|
|
3
|
+
import type { HistoryArchive, HistoryNotedMessage, HistoryProgress, PairingClock } from "./interfaces.js";
|
|
4
|
+
import { type PairingRelay } from "./relayClient.ts.js";
|
|
5
|
+
/** The relay's 256 MiB per session, less room for the tail. */
|
|
6
|
+
export declare const HISTORY_SNAPSHOT_MAX_BYTES: number;
|
|
7
|
+
/** Entries in the envelope's manifest and in each later message, which keep under 256 and 64 KiB. */
|
|
8
|
+
export declare const HISTORY_ENVELOPE_ENTRIES = 400;
|
|
9
|
+
export declare const HISTORY_PAGE_ENTRIES = 180;
|
|
10
|
+
/** One chunk as a history message lists it: its relay slot and its manifest entry. */
|
|
11
|
+
export interface ListedChunk extends HistoryChunkEntry {
|
|
12
|
+
n: number;
|
|
13
|
+
}
|
|
14
|
+
/** What A posts after the envelope, sealed. `final` says nothing more is coming. */
|
|
15
|
+
export interface HistoryMessage {
|
|
16
|
+
t: "history";
|
|
17
|
+
chunks: ListedChunk[];
|
|
18
|
+
total?: number;
|
|
19
|
+
truncated?: boolean;
|
|
20
|
+
final?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare const emptyProgress: () => HistoryProgress;
|
|
23
|
+
export interface HistorySenderOptions {
|
|
24
|
+
relay: PairingRelay;
|
|
25
|
+
id: string;
|
|
26
|
+
token: string;
|
|
27
|
+
archive: HistoryArchive;
|
|
28
|
+
clock: PairingClock;
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
/** Seals and posts one message. The sender calls it one at a time, in order. */
|
|
31
|
+
post(message: HistoryMessage): Promise<void>;
|
|
32
|
+
onProgress(progress: HistoryProgress): void;
|
|
33
|
+
maxBytes?: number;
|
|
34
|
+
}
|
|
35
|
+
/** A: seals the archive into chunks newest first, uploads each, then lists it to N. */
|
|
36
|
+
export declare function createHistorySender(options: HistorySenderOptions): {
|
|
37
|
+
key: Uint8Array<ArrayBufferLike>;
|
|
38
|
+
progress: () => {
|
|
39
|
+
messages: number;
|
|
40
|
+
total: number | null;
|
|
41
|
+
chunks: number;
|
|
42
|
+
listed: number;
|
|
43
|
+
missing: number;
|
|
44
|
+
refused: number;
|
|
45
|
+
truncated: boolean;
|
|
46
|
+
oldest: number | null;
|
|
47
|
+
complete: boolean;
|
|
48
|
+
};
|
|
49
|
+
/** Waits until `until` or the end of the snapshot, whichever is first, and hands over what's up. */
|
|
50
|
+
forEnvelope(until: number): Promise<HistoryChunkEntry[]>;
|
|
51
|
+
/** After N's "ready": list what's waiting, and keep listing as pages fill. */
|
|
52
|
+
startPaging(): void;
|
|
53
|
+
/** The tail: records past the snapshot. `final` waits for the snapshot and says that's all. */
|
|
54
|
+
send(records: HistoryRecord[], final: boolean): Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* A's record of MLS messages it archived during the transfer. The tail is every one past the
|
|
58
|
+
* snapshot that N can't read: before N's add, or sent in an older epoch that landed after it.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createTailRecorder(): {
|
|
61
|
+
start: () => undefined;
|
|
62
|
+
note(message: HistoryNotedMessage): void;
|
|
63
|
+
/** Everything noted since the last call that N needs. `snap` and `added` are per host. */
|
|
64
|
+
take(snap: Map<string, Map<string, number>>, added: Record<string, MlsOwnDeviceAdd[]>): HistoryRecord[];
|
|
65
|
+
};
|