@gryt/core 0.13.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/README.md +18 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- 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 +222 -2
- package/dist/mls/interfaces.d.ts +54 -3
- package/dist/pairing/approver.d.ts +71 -0
- package/dist/pairing/approver.js +198 -0
- package/dist/pairing/channel.d.ts +17 -0
- package/dist/pairing/channel.js +81 -0
- package/dist/pairing/interfaces.d.ts +87 -0
- package/dist/pairing/interfaces.js +1 -0
- package/dist/pairing/newDevice.d.ts +62 -0
- package/dist/pairing/newDevice.js +209 -0
- package/dist/pairing/relayClient.d.ts +45 -0
- package/dist/pairing/relayClient.js +48 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +3 -0
- package/package.json +7 -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;
|
|
@@ -138,6 +176,20 @@ export function createMlsDmDriver(options) {
|
|
|
138
176
|
open.rec = { ...open.rec, state: encodeMlsGroupState(open.state), cursor };
|
|
139
177
|
await store.saveGroup(open.rec);
|
|
140
178
|
}
|
|
179
|
+
/** Where each device went into each group, as this driver saw it happen. Memory only. */
|
|
180
|
+
const joins = new Map();
|
|
181
|
+
function noteJoins(conversationId, before, after, seq, byThisDevice) {
|
|
182
|
+
if (before === after)
|
|
183
|
+
return;
|
|
184
|
+
const had = new Set(mlsGroupMembers(before, scope).map((m) => m.certificate.deviceId));
|
|
185
|
+
const here = joins.get(conversationId) ?? new Map();
|
|
186
|
+
for (const { certificate } of mlsGroupMembers(after, scope)) {
|
|
187
|
+
if (!had.has(certificate.deviceId))
|
|
188
|
+
here.set(certificate.deviceId, { seq, epoch: epochOf(after), byThisDevice });
|
|
189
|
+
}
|
|
190
|
+
if (here.size)
|
|
191
|
+
joins.set(conversationId, here);
|
|
192
|
+
}
|
|
141
193
|
async function load(conversationId) {
|
|
142
194
|
const rec = await store.loadGroup(conversationId);
|
|
143
195
|
return rec ? { rec, state: decodeMlsGroupState(rec.state, trustFor(conversationId)) } : null;
|
|
@@ -200,6 +252,7 @@ export function createMlsDmDriver(options) {
|
|
|
200
252
|
const d = await ownDevice();
|
|
201
253
|
const current = epochOf(open.state);
|
|
202
254
|
if (e.kind === "commit") {
|
|
255
|
+
const before = open.state;
|
|
203
256
|
const pending = open.rec.pending;
|
|
204
257
|
if (pending && sameBytes(pending.commit, e.data)) {
|
|
205
258
|
open.state = decodeMlsGroupState(pending.state, trustFor(open.rec.conversationId));
|
|
@@ -227,6 +280,7 @@ export function createMlsDmDriver(options) {
|
|
|
227
280
|
}
|
|
228
281
|
}
|
|
229
282
|
open.rec = { ...open.rec, pending: undefined };
|
|
283
|
+
noteJoins(open.rec.conversationId, before, open.state, e.seq, !!pending && sameBytes(pending.commit, e.data));
|
|
230
284
|
await notePeople(open.rec.conversationId, open.state);
|
|
231
285
|
}
|
|
232
286
|
else if (e.kind === "proposal") {
|
|
@@ -239,7 +293,14 @@ export function createMlsDmDriver(options) {
|
|
|
239
293
|
}
|
|
240
294
|
}
|
|
241
295
|
}
|
|
242
|
-
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) {
|
|
243
304
|
let plaintext = null;
|
|
244
305
|
try {
|
|
245
306
|
const r = await processMlsMessage(open.state, e.data);
|
|
@@ -255,6 +316,7 @@ export function createMlsDmDriver(options) {
|
|
|
255
316
|
await events.onMessage({
|
|
256
317
|
conversationId: open.rec.conversationId,
|
|
257
318
|
seq: e.seq,
|
|
319
|
+
epoch: e.epoch,
|
|
258
320
|
senderServerUserId: e.senderServerUserId,
|
|
259
321
|
senderDeviceId: e.senderDeviceId,
|
|
260
322
|
plaintext,
|
|
@@ -305,6 +367,7 @@ export function createMlsDmDriver(options) {
|
|
|
305
367
|
welcome: c.welcome,
|
|
306
368
|
});
|
|
307
369
|
if (r.ok) {
|
|
370
|
+
noteJoins(open.rec.conversationId, open.state, c.state, r.seq, true);
|
|
308
371
|
open.state = c.state;
|
|
309
372
|
forgetMlsSecrets(c.consumed);
|
|
310
373
|
open.rec = { ...open.rec, pending: undefined };
|
|
@@ -551,12 +614,50 @@ export function createMlsDmDriver(options) {
|
|
|
551
614
|
}
|
|
552
615
|
if (!registered)
|
|
553
616
|
throw new MlsDriverError("no_device", "This device isn't registered for MLS here. Call start() first.");
|
|
554
|
-
|
|
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);
|
|
555
648
|
return run(conversationId, async () => {
|
|
556
649
|
const d = await ownDevice();
|
|
557
650
|
const open = await load(conversationId);
|
|
558
651
|
if (!open || !(await catchUp(open)))
|
|
559
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
|
+
}
|
|
560
661
|
if (!reconciled.has(conversationId))
|
|
561
662
|
await reconcile(open, peer, true);
|
|
562
663
|
const people = await peopleIn(conversationId, open.state);
|
|
@@ -571,6 +672,7 @@ export function createMlsDmDriver(options) {
|
|
|
571
672
|
open.state = out.state;
|
|
572
673
|
// The generation is used up whether or not the send arrives, so the state is saved first.
|
|
573
674
|
await save(open);
|
|
675
|
+
flight.message = out.message;
|
|
574
676
|
const r = await transport.send({ conversationId, deviceId: d.deviceId, message: out.message, attachmentIds });
|
|
575
677
|
if (!r.ok)
|
|
576
678
|
throw refused("Sending", r);
|
|
@@ -667,6 +769,113 @@ export function createMlsDmDriver(options) {
|
|
|
667
769
|
thisDevice: x.deviceId === d.deviceId,
|
|
668
770
|
}));
|
|
669
771
|
}
|
|
772
|
+
// ── Pairing ─────────────────────────────────────────────────────────
|
|
773
|
+
async function groupPositions() {
|
|
774
|
+
const out = [];
|
|
775
|
+
for (const rec of await store.listGroups()) {
|
|
776
|
+
const pos = await run(rec.conversationId, async () => {
|
|
777
|
+
const open = await load(rec.conversationId);
|
|
778
|
+
return open && { conversationId: open.rec.conversationId, groupId: open.rec.groupId, seq: open.rec.cursor, epoch: epochOf(open.state) };
|
|
779
|
+
});
|
|
780
|
+
if (pos)
|
|
781
|
+
out.push(pos);
|
|
782
|
+
}
|
|
783
|
+
return out;
|
|
784
|
+
}
|
|
785
|
+
/** A claimed package for `deviceId`, checked to be yours under your own person key. Null when the server has none. */
|
|
786
|
+
async function claimOwn(conversationId, deviceId, ownPersonKey) {
|
|
787
|
+
const d = await ownDevice();
|
|
788
|
+
// One more claim when the first package won't read, as with a peer's.
|
|
789
|
+
for (let round = 0; round < 2; round++) {
|
|
790
|
+
const claim = await transport.claimKeyPackages({ conversationId, deviceId: d.deviceId, devices: [{ serverUserId: self, deviceId }] });
|
|
791
|
+
if (!claim.ok)
|
|
792
|
+
throw refused("Claiming KeyPackages", claim);
|
|
793
|
+
const kp = claim.keyPackages.find((k) => k.serverUserId === self && k.deviceId === deviceId);
|
|
794
|
+
if (!kp)
|
|
795
|
+
return null;
|
|
796
|
+
let certificate;
|
|
797
|
+
try {
|
|
798
|
+
({ certificate } = await readMlsKeyPackage(kp.keyPackage, scope));
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (certificate.deviceId !== deviceId || !sameBytes(certificate.personPublicKey, ownPersonKey)) {
|
|
804
|
+
throw new MlsDriverError("not_own_device", "That device's certificate isn't signed by your person key.");
|
|
805
|
+
}
|
|
806
|
+
return kp.keyPackage;
|
|
807
|
+
}
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
async function addOwnDeviceTo(conversationId, deviceId, ownPersonKey) {
|
|
811
|
+
const open = await load(conversationId);
|
|
812
|
+
const result = (outcome, error) => {
|
|
813
|
+
const j = joins.get(conversationId)?.get(deviceId);
|
|
814
|
+
const add = outcome === "failed" || !j ? null : { seq: j.seq, epoch: j.epoch };
|
|
815
|
+
return { conversationId, groupId: open?.rec.groupId ?? "", outcome, add, ...(error ? { error } : {}) };
|
|
816
|
+
};
|
|
817
|
+
if (!open || !(await catchUp(open)))
|
|
818
|
+
return result("failed", "not_in_group");
|
|
819
|
+
const inTree = (s) => mlsGroupMembers(s, scope).some((m) => m.certificate.deviceId === deviceId);
|
|
820
|
+
const settled = () => result(joins.get(conversationId)?.get(deviceId)?.byThisDevice ? "added" : "added_by_other");
|
|
821
|
+
if (inTree(open.state))
|
|
822
|
+
return settled();
|
|
823
|
+
let full = false;
|
|
824
|
+
for (let round = 0; round < 2 && !inTree(open.state); round++) {
|
|
825
|
+
const keyPackage = await claimOwn(conversationId, deviceId, ownPersonKey);
|
|
826
|
+
if (!keyPackage)
|
|
827
|
+
return result("failed", "no_key_package");
|
|
828
|
+
let stale = false;
|
|
829
|
+
await commitWithRetry(open, async (s) => {
|
|
830
|
+
if (inTree(s))
|
|
831
|
+
return null;
|
|
832
|
+
const mine = mlsGroupMembers(s, scope).filter((m) => sameBytes(m.certificate.personPublicKey, ownPersonKey));
|
|
833
|
+
if ((full = mine.length >= MLS_MAX_DEVICES_PER_PERSON))
|
|
834
|
+
return null;
|
|
835
|
+
try {
|
|
836
|
+
return await addMlsMembers(s, [keyPackage]);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
stale = true;
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
if (!stale)
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
if (inTree(open.state))
|
|
847
|
+
return settled();
|
|
848
|
+
return result("failed", full ? "too_many_devices" : "no_key_package");
|
|
849
|
+
}
|
|
850
|
+
async function addOwnDevice(deviceId, options = {}) {
|
|
851
|
+
const d = await ownDevice();
|
|
852
|
+
const listed = await transport.listDevices({});
|
|
853
|
+
if (!listed.ok)
|
|
854
|
+
throw refused("Listing devices", listed);
|
|
855
|
+
if (deviceId === d.deviceId || !listed.devices.some((x) => x.serverUserId === self && x.deviceId === deviceId)) {
|
|
856
|
+
throw new MlsDriverError("not_own_device", "The server doesn't list that device as one of yours.");
|
|
857
|
+
}
|
|
858
|
+
const ownPersonKey = readDeviceCertificate(d.certificate, scope).personPublicKey;
|
|
859
|
+
const ids = (await store.listGroups()).map((r) => r.conversationId);
|
|
860
|
+
const first = (options.order ?? []).filter((id) => ids.includes(id));
|
|
861
|
+
const ordered = [...new Set([...first, ...ids])];
|
|
862
|
+
const results = [];
|
|
863
|
+
for (const conversationId of ordered) {
|
|
864
|
+
let result;
|
|
865
|
+
try {
|
|
866
|
+
result = await run(conversationId, () => addOwnDeviceTo(conversationId, deviceId, ownPersonKey));
|
|
867
|
+
}
|
|
868
|
+
catch (e) {
|
|
869
|
+
if (e instanceof MlsDriverError && e.code === "not_own_device")
|
|
870
|
+
throw e;
|
|
871
|
+
const groupId = (await store.loadGroup(conversationId))?.groupId ?? "";
|
|
872
|
+
result = { conversationId, groupId, outcome: "failed", add: null, error: e instanceof MlsDriverError ? e.code : "error" };
|
|
873
|
+
}
|
|
874
|
+
results.push(result);
|
|
875
|
+
options.onProgress?.({ done: results.length, total: ordered.length, result });
|
|
876
|
+
}
|
|
877
|
+
return results;
|
|
878
|
+
}
|
|
670
879
|
return {
|
|
671
880
|
async start() {
|
|
672
881
|
await ownDevice();
|
|
@@ -681,6 +890,15 @@ export function createMlsDmDriver(options) {
|
|
|
681
890
|
await catchUp(open);
|
|
682
891
|
});
|
|
683
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());
|
|
684
902
|
},
|
|
685
903
|
modeFor,
|
|
686
904
|
send,
|
|
@@ -694,5 +912,7 @@ export function createMlsDmDriver(options) {
|
|
|
694
912
|
throw refused("Removing the device", r);
|
|
695
913
|
await handleDevicesChanged({ serverUserId: self });
|
|
696
914
|
},
|
|
915
|
+
groupPositions,
|
|
916
|
+
addOwnDevice,
|
|
697
917
|
};
|
|
698
918
|
}
|
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: {
|
|
@@ -211,6 +211,8 @@ export interface MlsPins {
|
|
|
211
211
|
export interface MlsDecryptedMessage {
|
|
212
212
|
conversationId: string;
|
|
213
213
|
seq: number;
|
|
214
|
+
/** The epoch it was sent in. A pairing tail wants the old-epoch ones that land after an add. */
|
|
215
|
+
epoch: number;
|
|
214
216
|
senderServerUserId: string;
|
|
215
217
|
senderDeviceId: string;
|
|
216
218
|
plaintext: Uint8Array;
|
|
@@ -228,6 +230,8 @@ export interface MlsDmEvents {
|
|
|
228
230
|
seq: number;
|
|
229
231
|
reason: string;
|
|
230
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;
|
|
231
235
|
/** This device is out of the group: removed, or its state couldn't keep up. */
|
|
232
236
|
onGroupLost?(info: {
|
|
233
237
|
conversationId: string;
|
|
@@ -262,6 +266,39 @@ export interface MlsOwnDevice extends MlsDeviceRef {
|
|
|
262
266
|
lastSeenAt: string | null;
|
|
263
267
|
thisDevice: boolean;
|
|
264
268
|
}
|
|
269
|
+
/** A place in one group's log: a seq, and the epoch this device is in there. */
|
|
270
|
+
export interface MlsGroupPosition {
|
|
271
|
+
conversationId: string;
|
|
272
|
+
groupId: string;
|
|
273
|
+
seq: number;
|
|
274
|
+
epoch: number;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Where one of your devices joined one group. `add` is the commit's seq and the epoch the device
|
|
278
|
+
* starts in, or null when it didn't go in, or went in before this driver was running.
|
|
279
|
+
*/
|
|
280
|
+
export interface MlsOwnDeviceAdd {
|
|
281
|
+
conversationId: string;
|
|
282
|
+
groupId: string;
|
|
283
|
+
/** "added_by_other" when another member's commit put it in first. */
|
|
284
|
+
outcome: "added" | "added_by_other" | "failed";
|
|
285
|
+
add: {
|
|
286
|
+
seq: number;
|
|
287
|
+
epoch: number;
|
|
288
|
+
} | null;
|
|
289
|
+
/** Why it failed: an MlsDriverError code, or "too_many_devices", "no_key_package" or "error". */
|
|
290
|
+
error?: string;
|
|
291
|
+
}
|
|
292
|
+
export interface MlsAddOwnDeviceOptions {
|
|
293
|
+
/** Conversation ids to do first, most recently active first. The rest follow. */
|
|
294
|
+
order?: readonly string[];
|
|
295
|
+
/** After each group, with how many are done. */
|
|
296
|
+
onProgress?(progress: {
|
|
297
|
+
done: number;
|
|
298
|
+
total: number;
|
|
299
|
+
result: MlsOwnDeviceAdd;
|
|
300
|
+
}): void;
|
|
301
|
+
}
|
|
265
302
|
export interface MlsDmDriverOptions {
|
|
266
303
|
transport: MlsTransport;
|
|
267
304
|
store: MlsStateStore;
|
|
@@ -274,13 +311,18 @@ export interface MlsDmDriverOptions {
|
|
|
274
311
|
capability: MlsServerCapability | null;
|
|
275
312
|
/** The first time only: `createMlsDevice` from @gryt/crypto, so the seed stays in the app. */
|
|
276
313
|
newDevice(): MlsDeviceRecord | Promise<MlsDeviceRecord>;
|
|
314
|
+
/** How long a send waits for the server before it fails with `offline`. Five minutes. */
|
|
315
|
+
sendGiveUpMs?: number;
|
|
277
316
|
}
|
|
278
317
|
/** One per server. Calls for one conversation run one at a time, in order. */
|
|
279
318
|
export interface MlsDmDriver {
|
|
280
319
|
/** On connect: register, top up KeyPackages, take waiting Welcomes, catch up every group. */
|
|
281
320
|
start(): Promise<void>;
|
|
282
321
|
modeFor(conversationId: string, peerServerUserId: string): Promise<DmSealingMode>;
|
|
283
|
-
/**
|
|
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
|
+
*/
|
|
284
326
|
send(conversationId: string, peerServerUserId: string, plaintext: Uint8Array, options?: {
|
|
285
327
|
attachmentIds?: readonly string[];
|
|
286
328
|
}): Promise<{
|
|
@@ -295,4 +337,13 @@ export interface MlsDmDriver {
|
|
|
295
337
|
ownDevices(): Promise<MlsOwnDevice[]>;
|
|
296
338
|
/** Your own devices only. Every group you share drops it on the next pass. */
|
|
297
339
|
removeOwnDevice(deviceId: string): Promise<void>;
|
|
340
|
+
/** Pairing: each group's cursor now, which is what the history snapshot covers. */
|
|
341
|
+
groupPositions(): Promise<MlsGroupPosition[]>;
|
|
342
|
+
/**
|
|
343
|
+
* Pairing: adds one of your own new devices to every group now, and says where it went in.
|
|
344
|
+
* Throws `not_own_device` for a device the server doesn't list as yours, or not under your person key.
|
|
345
|
+
*/
|
|
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;
|
|
298
349
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { type PairingEmoji, type PairingEnvelope } from "@gryt/crypto";
|
|
2
|
+
import type { MlsOwnDeviceAdd } from "../mls/interfaces.js";
|
|
3
|
+
import type { OwnDeviceAdder, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch } from "./interfaces.js";
|
|
4
|
+
import type { PairingRelay } from "./relayClient.js";
|
|
5
|
+
export type ApproverState = {
|
|
6
|
+
phase: "idle";
|
|
7
|
+
} | {
|
|
8
|
+
phase: "claiming";
|
|
9
|
+
} | {
|
|
10
|
+
phase: "waiting";
|
|
11
|
+
} | {
|
|
12
|
+
phase: "confirming";
|
|
13
|
+
device: PairingDeviceInfo;
|
|
14
|
+
/** Where the relay puts N, and A, from their IPs. Null when it can't say. */
|
|
15
|
+
location: string | null;
|
|
16
|
+
yourLocation: string | null;
|
|
17
|
+
emoji: readonly PairingEmoji[];
|
|
18
|
+
deadline: number;
|
|
19
|
+
} | {
|
|
20
|
+
phase: "signing_in";
|
|
21
|
+
device: PairingDeviceInfo;
|
|
22
|
+
}
|
|
23
|
+
/** No extension on this Keycloak: the app opens `url`, Keycloak's own device page, for the same code. */
|
|
24
|
+
| {
|
|
25
|
+
phase: "browser";
|
|
26
|
+
device: PairingDeviceInfo;
|
|
27
|
+
url: string;
|
|
28
|
+
} | {
|
|
29
|
+
phase: "waiting_ready";
|
|
30
|
+
device: PairingDeviceInfo;
|
|
31
|
+
} | {
|
|
32
|
+
phase: "adding";
|
|
33
|
+
device: PairingDeviceInfo;
|
|
34
|
+
host: string;
|
|
35
|
+
done: number;
|
|
36
|
+
total: number;
|
|
37
|
+
} | {
|
|
38
|
+
phase: "done";
|
|
39
|
+
device: PairingDeviceInfo;
|
|
40
|
+
added: Record<string, MlsOwnDeviceAdd[]>;
|
|
41
|
+
} | {
|
|
42
|
+
phase: "ended";
|
|
43
|
+
reason: PairingEndReason;
|
|
44
|
+
};
|
|
45
|
+
export interface ApproverOptions {
|
|
46
|
+
relay: PairingRelay;
|
|
47
|
+
/** A's own relay origin. A QR naming any other is refused. */
|
|
48
|
+
relayOrigin: string;
|
|
49
|
+
/** For the extension call. */
|
|
50
|
+
fetch: PairingFetch;
|
|
51
|
+
devices: OwnDeviceAdder;
|
|
52
|
+
clock?: PairingClock;
|
|
53
|
+
approvalMs?: number;
|
|
54
|
+
}
|
|
55
|
+
export interface ApproverPairing {
|
|
56
|
+
readonly state: ApproverState;
|
|
57
|
+
subscribe(listener: (state: ApproverState) => void): () => void;
|
|
58
|
+
/** A scanned QR, or a typed code. */
|
|
59
|
+
claim(input: {
|
|
60
|
+
qr: string;
|
|
61
|
+
} | {
|
|
62
|
+
code: string;
|
|
63
|
+
}): void;
|
|
64
|
+
/** For an account, `accessToken` refreshes first: the extension refuses a token over 60 seconds old. */
|
|
65
|
+
approve(envelope: PairingEnvelope, accessToken?: () => Promise<string>): void;
|
|
66
|
+
deny(): Promise<void>;
|
|
67
|
+
mismatch(): Promise<void>;
|
|
68
|
+
cancel(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
/** A: claims the session, shows N and the emoji, and on Approve seals the envelope, signs N in and adds it. */
|
|
71
|
+
export declare function createApproverPairing(options: ApproverOptions): ApproverPairing;
|