@gryt/core 0.13.0 → 0.14.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 CHANGED
@@ -49,15 +49,30 @@ goes missing **and** when one appears that nobody listed.
49
49
  space on the phone, which is a key one of them could collide on.
50
50
  - **`mls`** — the DM half of MLS stage 1. It keeps a device's KeyPackages
51
51
  topped up, opens a DM group, joins from a Welcome, sends, and reads the group
52
- log in order. Each app hands it the socket, the storage and the pins through
52
+ log in order. For linking a device, `addOwnDevice` puts a new device of yours
53
+ in every DM straight away and says where in each group's log it went in.
54
+ `groupPositions` says where each log stood when the history snapshot was
55
+ taken. Each app hands it the socket, the storage and the pins through
53
56
  the interfaces in `src/mls/interfaces.ts`. `mlsPinsFromPeerPins` builds the
54
57
  pins from the peer pins `@gryt/crypto` already keeps.
55
58
  `encodeMlsDmContent` and `decodeMlsDmContent` write and read what goes inside
56
59
  each message, so both apps use the same JSON: a message, an edit, a delete or
57
60
  a reaction. `readMlsDmContent` also says when something came from a newer app,
58
61
  so it can be skipped quietly rather than counted as broken. `applyMlsReaction` puts a reaction on a message in the archive,
59
- since the server never sees one. It's the one module with a
60
- dependency, `@gryt/crypto`, pinned exactly the way the apps pin it.
62
+ since the server never sees one. It depends on `@gryt/crypto`, pinned
63
+ exactly the way the apps pin it.
64
+ - **`pairing`** — linking a new device by QR code or short code, as
65
+ `docs/pairing-design.md` in `@gryt/crypto` lays it out. There's one state
66
+ machine per side. `createNewDevicePairing` shows the QR and code, checks the
67
+ emoji, opens the envelope and, for an account, signs in with the device grant
68
+ and checks the ID token is for the right person. It writes nothing until all
69
+ of that passes. `createApproverPairing` claims the session, shows who's
70
+ asking, seals the envelope, approves the sign-in through the Keycloak
71
+ extension, and adds the new device to every DM. If the extension isn't there,
72
+ it hands back Keycloak's own device page to open instead. The app passes in
73
+ `fetch`, a clock, storage and the OIDC calls through
74
+ `src/pairing/interfaces.ts`. History isn't in it yet. It uses
75
+ `@noble/hashes` for the PKCE challenge, which `@gryt/crypto` already pulls in.
61
76
 
62
77
  ## What deliberately isn't
63
78
 
package/dist/index.d.ts CHANGED
@@ -14,4 +14,8 @@ export { createMlsDmDriver, MlsDriverError, type MlsDriverErrorCode } from "./ml
14
14
  export { mlsPinsFromPeerPins } from "./mls/pins.js";
15
15
  export { decodeMlsDmContent, encodeMlsDmContent, MLS_DM_CONTENT_VERSION, type MlsDmContent, readMlsDmContent, } from "./mls/content.js";
16
16
  export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
17
- export type { DmSealingMode, MlsClaimedKeyPackage, MlsDecryptedMessage, MlsDeviceRecord, MlsDeviceRef, MlsDmDriver, MlsDmDriverOptions, MlsDmEvents, MlsGroupRecord, MlsGroupView, MlsKeyPackageRecord, MlsListedDevice, MlsLogEntry, MlsOwnDevice, MlsPins, MlsRefusal, MlsReply, MlsServerCapability, MlsStateStore, MlsTransport, MlsWelcomeDelivery, } from "./mls/interfaces.js";
17
+ export type { DmSealingMode, MlsAddOwnDeviceOptions, MlsClaimedKeyPackage, MlsDecryptedMessage, MlsDeviceRecord, MlsDeviceRef, MlsDmDriver, MlsDmDriverOptions, MlsDmEvents, MlsGroupPosition, MlsGroupRecord, MlsGroupView, MlsKeyPackageRecord, MlsListedDevice, MlsLogEntry, MlsOwnDevice, MlsOwnDeviceAdd, MlsPins, MlsRefusal, MlsReply, MlsServerCapability, MlsStateStore, MlsTransport, MlsWelcomeDelivery, } from "./mls/interfaces.js";
18
+ export { createApproverPairing, type ApproverOptions, type ApproverPairing, type ApproverState, } from "./pairing/approver.js";
19
+ export { createNewDevicePairing, PAIRING_APPROVAL_MS, type NewDeviceOptions, type NewDevicePairing, type NewDeviceState, } from "./pairing/newDevice.js";
20
+ export { createPairingRelay, PairingRelayError, type PairingRelay } from "./pairing/relayClient.js";
21
+ export type { DeviceAuthorization, DeviceTokenPoll, OwnDeviceAdder, PairedServerDevice, PairingClock, PairingDeviceInfo, PairingEndReason, PairingFetch, PairingOidc, PairingStorage, PairingTokens, } from "./pairing/interfaces.js";
package/dist/index.js CHANGED
@@ -16,3 +16,6 @@ export { createMlsDmDriver, MlsDriverError } from "./mls/dmDriver.js";
16
16
  export { mlsPinsFromPeerPins } from "./mls/pins.js";
17
17
  export { decodeMlsDmContent, encodeMlsDmContent, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
18
18
  export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
19
+ export { createApproverPairing, } from "./pairing/approver.js";
20
+ export { createNewDevicePairing, PAIRING_APPROVAL_MS, } from "./pairing/newDevice.js";
21
+ export { createPairingRelay, PairingRelayError } from "./pairing/relayClient.js";
@@ -1,5 +1,5 @@
1
1
  import type { MlsDmDriver, MlsDmDriverOptions, MlsRefusal } from "./interfaces.js";
2
- export type MlsDriverErrorCode = "refused" | "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";
3
3
  export declare class MlsDriverError extends Error {
4
4
  code: MlsDriverErrorCode;
5
5
  /** The server's refusal, when there was one. */
@@ -138,6 +138,20 @@ export function createMlsDmDriver(options) {
138
138
  open.rec = { ...open.rec, state: encodeMlsGroupState(open.state), cursor };
139
139
  await store.saveGroup(open.rec);
140
140
  }
141
+ /** Where each device went into each group, as this driver saw it happen. Memory only. */
142
+ const joins = new Map();
143
+ function noteJoins(conversationId, before, after, seq, byThisDevice) {
144
+ if (before === after)
145
+ return;
146
+ const had = new Set(mlsGroupMembers(before, scope).map((m) => m.certificate.deviceId));
147
+ const here = joins.get(conversationId) ?? new Map();
148
+ for (const { certificate } of mlsGroupMembers(after, scope)) {
149
+ if (!had.has(certificate.deviceId))
150
+ here.set(certificate.deviceId, { seq, epoch: epochOf(after), byThisDevice });
151
+ }
152
+ if (here.size)
153
+ joins.set(conversationId, here);
154
+ }
141
155
  async function load(conversationId) {
142
156
  const rec = await store.loadGroup(conversationId);
143
157
  return rec ? { rec, state: decodeMlsGroupState(rec.state, trustFor(conversationId)) } : null;
@@ -200,6 +214,7 @@ export function createMlsDmDriver(options) {
200
214
  const d = await ownDevice();
201
215
  const current = epochOf(open.state);
202
216
  if (e.kind === "commit") {
217
+ const before = open.state;
203
218
  const pending = open.rec.pending;
204
219
  if (pending && sameBytes(pending.commit, e.data)) {
205
220
  open.state = decodeMlsGroupState(pending.state, trustFor(open.rec.conversationId));
@@ -227,6 +242,7 @@ export function createMlsDmDriver(options) {
227
242
  }
228
243
  }
229
244
  open.rec = { ...open.rec, pending: undefined };
245
+ noteJoins(open.rec.conversationId, before, open.state, e.seq, !!pending && sameBytes(pending.commit, e.data));
230
246
  await notePeople(open.rec.conversationId, open.state);
231
247
  }
232
248
  else if (e.kind === "proposal") {
@@ -255,6 +271,7 @@ export function createMlsDmDriver(options) {
255
271
  await events.onMessage({
256
272
  conversationId: open.rec.conversationId,
257
273
  seq: e.seq,
274
+ epoch: e.epoch,
258
275
  senderServerUserId: e.senderServerUserId,
259
276
  senderDeviceId: e.senderDeviceId,
260
277
  plaintext,
@@ -305,6 +322,7 @@ export function createMlsDmDriver(options) {
305
322
  welcome: c.welcome,
306
323
  });
307
324
  if (r.ok) {
325
+ noteJoins(open.rec.conversationId, open.state, c.state, r.seq, true);
308
326
  open.state = c.state;
309
327
  forgetMlsSecrets(c.consumed);
310
328
  open.rec = { ...open.rec, pending: undefined };
@@ -667,6 +685,113 @@ export function createMlsDmDriver(options) {
667
685
  thisDevice: x.deviceId === d.deviceId,
668
686
  }));
669
687
  }
688
+ // ── Pairing ─────────────────────────────────────────────────────────
689
+ async function groupPositions() {
690
+ const out = [];
691
+ for (const rec of await store.listGroups()) {
692
+ const pos = await run(rec.conversationId, async () => {
693
+ const open = await load(rec.conversationId);
694
+ return open && { conversationId: open.rec.conversationId, groupId: open.rec.groupId, seq: open.rec.cursor, epoch: epochOf(open.state) };
695
+ });
696
+ if (pos)
697
+ out.push(pos);
698
+ }
699
+ return out;
700
+ }
701
+ /** A claimed package for `deviceId`, checked to be yours under your own person key. Null when the server has none. */
702
+ async function claimOwn(conversationId, deviceId, ownPersonKey) {
703
+ const d = await ownDevice();
704
+ // One more claim when the first package won't read, as with a peer's.
705
+ for (let round = 0; round < 2; round++) {
706
+ const claim = await transport.claimKeyPackages({ conversationId, deviceId: d.deviceId, devices: [{ serverUserId: self, deviceId }] });
707
+ if (!claim.ok)
708
+ throw refused("Claiming KeyPackages", claim);
709
+ const kp = claim.keyPackages.find((k) => k.serverUserId === self && k.deviceId === deviceId);
710
+ if (!kp)
711
+ return null;
712
+ let certificate;
713
+ try {
714
+ ({ certificate } = await readMlsKeyPackage(kp.keyPackage, scope));
715
+ }
716
+ catch {
717
+ continue;
718
+ }
719
+ if (certificate.deviceId !== deviceId || !sameBytes(certificate.personPublicKey, ownPersonKey)) {
720
+ throw new MlsDriverError("not_own_device", "That device's certificate isn't signed by your person key.");
721
+ }
722
+ return kp.keyPackage;
723
+ }
724
+ return null;
725
+ }
726
+ async function addOwnDeviceTo(conversationId, deviceId, ownPersonKey) {
727
+ const open = await load(conversationId);
728
+ const result = (outcome, error) => {
729
+ const j = joins.get(conversationId)?.get(deviceId);
730
+ const add = outcome === "failed" || !j ? null : { seq: j.seq, epoch: j.epoch };
731
+ return { conversationId, groupId: open?.rec.groupId ?? "", outcome, add, ...(error ? { error } : {}) };
732
+ };
733
+ if (!open || !(await catchUp(open)))
734
+ return result("failed", "not_in_group");
735
+ const inTree = (s) => mlsGroupMembers(s, scope).some((m) => m.certificate.deviceId === deviceId);
736
+ const settled = () => result(joins.get(conversationId)?.get(deviceId)?.byThisDevice ? "added" : "added_by_other");
737
+ if (inTree(open.state))
738
+ return settled();
739
+ let full = false;
740
+ for (let round = 0; round < 2 && !inTree(open.state); round++) {
741
+ const keyPackage = await claimOwn(conversationId, deviceId, ownPersonKey);
742
+ if (!keyPackage)
743
+ return result("failed", "no_key_package");
744
+ let stale = false;
745
+ await commitWithRetry(open, async (s) => {
746
+ if (inTree(s))
747
+ return null;
748
+ const mine = mlsGroupMembers(s, scope).filter((m) => sameBytes(m.certificate.personPublicKey, ownPersonKey));
749
+ if ((full = mine.length >= MLS_MAX_DEVICES_PER_PERSON))
750
+ return null;
751
+ try {
752
+ return await addMlsMembers(s, [keyPackage]);
753
+ }
754
+ catch {
755
+ stale = true;
756
+ return null;
757
+ }
758
+ });
759
+ if (!stale)
760
+ break;
761
+ }
762
+ if (inTree(open.state))
763
+ return settled();
764
+ return result("failed", full ? "too_many_devices" : "no_key_package");
765
+ }
766
+ async function addOwnDevice(deviceId, options = {}) {
767
+ const d = await ownDevice();
768
+ const listed = await transport.listDevices({});
769
+ if (!listed.ok)
770
+ throw refused("Listing devices", listed);
771
+ if (deviceId === d.deviceId || !listed.devices.some((x) => x.serverUserId === self && x.deviceId === deviceId)) {
772
+ throw new MlsDriverError("not_own_device", "The server doesn't list that device as one of yours.");
773
+ }
774
+ const ownPersonKey = readDeviceCertificate(d.certificate, scope).personPublicKey;
775
+ const ids = (await store.listGroups()).map((r) => r.conversationId);
776
+ const first = (options.order ?? []).filter((id) => ids.includes(id));
777
+ const ordered = [...new Set([...first, ...ids])];
778
+ const results = [];
779
+ for (const conversationId of ordered) {
780
+ let result;
781
+ try {
782
+ result = await run(conversationId, () => addOwnDeviceTo(conversationId, deviceId, ownPersonKey));
783
+ }
784
+ catch (e) {
785
+ if (e instanceof MlsDriverError && e.code === "not_own_device")
786
+ throw e;
787
+ const groupId = (await store.loadGroup(conversationId))?.groupId ?? "";
788
+ result = { conversationId, groupId, outcome: "failed", add: null, error: e instanceof MlsDriverError ? e.code : "error" };
789
+ }
790
+ results.push(result);
791
+ options.onProgress?.({ done: results.length, total: ordered.length, result });
792
+ }
793
+ return results;
794
+ }
670
795
  return {
671
796
  async start() {
672
797
  await ownDevice();
@@ -694,5 +819,7 @@ export function createMlsDmDriver(options) {
694
819
  throw refused("Removing the device", r);
695
820
  await handleDevicesChanged({ serverUserId: self });
696
821
  },
822
+ groupPositions,
823
+ addOwnDevice,
697
824
  };
698
825
  }
@@ -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;
@@ -262,6 +264,39 @@ export interface MlsOwnDevice extends MlsDeviceRef {
262
264
  lastSeenAt: string | null;
263
265
  thisDevice: boolean;
264
266
  }
267
+ /** A place in one group's log: a seq, and the epoch this device is in there. */
268
+ export interface MlsGroupPosition {
269
+ conversationId: string;
270
+ groupId: string;
271
+ seq: number;
272
+ epoch: number;
273
+ }
274
+ /**
275
+ * Where one of your devices joined one group. `add` is the commit's seq and the epoch the device
276
+ * starts in, or null when it didn't go in, or went in before this driver was running.
277
+ */
278
+ export interface MlsOwnDeviceAdd {
279
+ conversationId: string;
280
+ groupId: string;
281
+ /** "added_by_other" when another member's commit put it in first. */
282
+ outcome: "added" | "added_by_other" | "failed";
283
+ add: {
284
+ seq: number;
285
+ epoch: number;
286
+ } | null;
287
+ /** Why it failed: an MlsDriverError code, or "too_many_devices", "no_key_package" or "error". */
288
+ error?: string;
289
+ }
290
+ export interface MlsAddOwnDeviceOptions {
291
+ /** Conversation ids to do first, most recently active first. The rest follow. */
292
+ order?: readonly string[];
293
+ /** After each group, with how many are done. */
294
+ onProgress?(progress: {
295
+ done: number;
296
+ total: number;
297
+ result: MlsOwnDeviceAdd;
298
+ }): void;
299
+ }
265
300
  export interface MlsDmDriverOptions {
266
301
  transport: MlsTransport;
267
302
  store: MlsStateStore;
@@ -295,4 +330,11 @@ export interface MlsDmDriver {
295
330
  ownDevices(): Promise<MlsOwnDevice[]>;
296
331
  /** Your own devices only. Every group you share drops it on the next pass. */
297
332
  removeOwnDevice(deviceId: string): Promise<void>;
333
+ /** Pairing: each group's cursor now, which is what the history snapshot covers. */
334
+ groupPositions(): Promise<MlsGroupPosition[]>;
335
+ /**
336
+ * Pairing: adds one of your own new devices to every group now, and says where it went in.
337
+ * Throws `not_own_device` for a device the server doesn't list as yours, or not under your person key.
338
+ */
339
+ addOwnDevice(deviceId: string, options?: MlsAddOwnDeviceOptions): Promise<MlsOwnDeviceAdd[]>;
298
340
  }
@@ -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;
@@ -0,0 +1,186 @@
1
+ import { base64Url, base64UrlDecode, createPairingKey, decodePairingSessionId, encodePairingEnvelope, encodePairingSessionId, parsePairingCode, parsePairingQr, startApproverSession, } from "@gryt/crypto";
2
+ import { mailbox, openJson, PairingEnded, reasonFor, systemClock } from "./channel.js";
3
+ import { PAIRING_APPROVAL_MS } from "./newDevice.js";
4
+ const NOT_LISTED_RETRIES = 5;
5
+ /** A: claims the session, shows N and the emoji, and on Approve seals the envelope, signs N in and adds it. */
6
+ export function createApproverPairing(options) {
7
+ const { relay, fetch, devices } = options;
8
+ const clock = options.clock ?? systemClock;
9
+ const approvalMs = options.approvalMs ?? PAIRING_APPROVAL_MS;
10
+ const listeners = new Set();
11
+ const abort = new AbortController();
12
+ let state = { phase: "idle" };
13
+ let current = null;
14
+ let device = null;
15
+ let confirming = null;
16
+ const set = (next) => {
17
+ if (state.phase === "ended" || state.phase === "done")
18
+ return;
19
+ state = next;
20
+ for (const l of listeners)
21
+ l(state);
22
+ };
23
+ const closeQuietly = () => (current ? relay.close(current.id, current.token).catch(() => undefined) : undefined);
24
+ const end = async (reason) => {
25
+ if (state.phase === "ended" || state.phase === "done")
26
+ return;
27
+ set({ phase: "ended", reason });
28
+ abort.abort();
29
+ current?.session?.close();
30
+ await closeQuietly();
31
+ };
32
+ const fail = (e) => (abort.signal.aborted ? undefined : end(reasonFor(e)));
33
+ async function claim(input) {
34
+ let by;
35
+ let scanned;
36
+ if ("qr" in input) {
37
+ const qr = parsePairingQr(input.qr);
38
+ if (!qr.ok)
39
+ throw new PairingEnded(qr.reason === "newer-version" ? "newer_version" : "not_pairing");
40
+ if (qr.relayOrigin !== undefined && qr.relayOrigin !== options.relayOrigin.toLowerCase().replace(/\/+$/, "")) {
41
+ throw new PairingEnded("wrong_relay");
42
+ }
43
+ by = { id: encodePairingSessionId(qr.sessionId) };
44
+ scanned = qr.publicKey;
45
+ }
46
+ else {
47
+ const code = parsePairingCode(input.code);
48
+ if (!code)
49
+ throw new PairingEnded("unknown_code");
50
+ by = { code };
51
+ }
52
+ set({ phase: "claiming" });
53
+ const key = createPairingKey();
54
+ const claimed = await relay.claim(by, base64Url(key.publicKey));
55
+ const box = mailbox(relay, claimed.id, claimed.token, clock);
56
+ current = { id: claimed.id, token: claimed.token, box };
57
+ const sessionId = decodePairingSessionId(claimed.id);
58
+ set({ phase: "waiting" });
59
+ const reveal = await box.next(abort.signal, clock.now() + approvalMs);
60
+ if (!reveal)
61
+ throw new PairingEnded("timed_out");
62
+ if (reveal.type !== "reveal" || !reveal.pkN || !sessionId)
63
+ throw new PairingEnded("tampered");
64
+ try {
65
+ current.session = startApproverSession(key, {
66
+ sessionId,
67
+ commitment: base64UrlDecode(claimed.commit),
68
+ newDevicePublicKey: base64UrlDecode(reveal.pkN),
69
+ scannedPublicKey: scanned,
70
+ });
71
+ }
72
+ catch {
73
+ throw new PairingEnded("tampered");
74
+ }
75
+ const hello = openJson(current.session, await box.next(abort.signal, clock.now() + approvalMs), "hello");
76
+ const text = (v) => (typeof v === "string" ? v.slice(0, 100) : "");
77
+ device = { name: text(hello.name), app: text(hello.app), platform: text(hello.platform) };
78
+ const deadline = clock.now() + approvalMs;
79
+ set({ phase: "confirming", device, location: claimed.location, yourLocation: claimed.yourLocation, emoji: current.session.emoji, deadline });
80
+ // N sends nothing more until A approves, so a message or a close here means something's off.
81
+ confirming = new AbortController();
82
+ abort.signal.addEventListener("abort", () => confirming?.abort(), { once: true });
83
+ try {
84
+ if (await box.next(confirming.signal, deadline))
85
+ throw new PairingEnded("tampered");
86
+ }
87
+ catch (e) {
88
+ if (confirming.signal.aborted)
89
+ return;
90
+ // N's clock started a moment before ours, so its timeout close can land just before ours fires.
91
+ if (reasonFor(e) !== "cancelled_by_other" || clock.now() < deadline - 5000)
92
+ throw e;
93
+ }
94
+ throw new PairingEnded("timed_out");
95
+ }
96
+ async function approveExtension(issuer, userCode, accessToken) {
97
+ const token = await accessToken();
98
+ const res = await fetch(`${issuer.replace(/\/+$/, "")}/gryt-pairing/approve`, {
99
+ method: "POST",
100
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
101
+ body: JSON.stringify({ user_code: userCode, binding: current.session.keycloakNonce }),
102
+ }).catch(() => {
103
+ throw new PairingEnded("approve:network");
104
+ });
105
+ if (res.status === 204)
106
+ return true;
107
+ if (res.status === 404)
108
+ return false;
109
+ const body = (await res.json().catch(() => null));
110
+ throw new PairingEnded(`approve:${typeof body?.error === "string" ? body.error : res.status}`);
111
+ }
112
+ async function addEverywhere(ready) {
113
+ const added = {};
114
+ for (const { host, deviceId } of ready) {
115
+ const driver = devices(host);
116
+ if (!driver)
117
+ continue;
118
+ for (let tries = 0;; tries++) {
119
+ try {
120
+ added[host] = await driver.addOwnDevice(deviceId, {
121
+ onProgress: ({ done, total }) => set({ phase: "adding", device: device, host, done, total }),
122
+ });
123
+ break;
124
+ }
125
+ catch (e) {
126
+ // The server lists N's device once its KeyPackages are in, which can trail N's "ready".
127
+ if (e.code !== "not_own_device" || tries >= NOT_LISTED_RETRIES)
128
+ throw e;
129
+ await clock.sleep(2000, abort.signal);
130
+ }
131
+ }
132
+ }
133
+ return added;
134
+ }
135
+ async function approve(envelope, accessToken) {
136
+ const { id, token, box, session } = current;
137
+ set({ phase: envelope.account ? "signing_in" : "waiting_ready", device: device });
138
+ await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(envelope))) });
139
+ if (envelope.account && accessToken) {
140
+ const message = await box.next(abort.signal, clock.now() + approvalMs);
141
+ if (!message)
142
+ throw new PairingEnded("timed_out");
143
+ const userCode = String(openJson(session, message, "user_code").userCode ?? "");
144
+ if (!(await approveExtension(envelope.account.issuer, userCode, accessToken))) {
145
+ const url = `${envelope.account.issuer.replace(/\/+$/, "")}/device?user_code=${encodeURIComponent(userCode)}`;
146
+ set({ phase: "browser", device: device, url });
147
+ }
148
+ }
149
+ if (state.phase === "signing_in")
150
+ set({ phase: "waiting_ready", device: device });
151
+ const ready = openJson(session, await box.next(abort.signal), "ready");
152
+ const list = Array.isArray(ready.devices) ? ready.devices : [];
153
+ const named = list.filter((d) => typeof d?.host === "string" && typeof d?.deviceId === "string");
154
+ const added = await addEverywhere(named);
155
+ set({ phase: "done", device: device, added });
156
+ session.close();
157
+ await closeQuietly();
158
+ }
159
+ return {
160
+ get state() {
161
+ return state;
162
+ },
163
+ subscribe(listener) {
164
+ listeners.add(listener);
165
+ return () => void listeners.delete(listener);
166
+ },
167
+ claim(input) {
168
+ if (state.phase !== "idle")
169
+ throw new Error("This pairing has claimed a session already.");
170
+ claim(input).catch(fail);
171
+ },
172
+ approve(envelope, accessToken) {
173
+ if (state.phase !== "confirming")
174
+ throw new Error("Nothing to approve.");
175
+ if (envelope.account && !accessToken)
176
+ throw new Error("An account needs an access token to approve with.");
177
+ if (clock.now() > state.deadline)
178
+ return void end("timed_out");
179
+ confirming?.abort();
180
+ approve(envelope, accessToken).catch(fail);
181
+ },
182
+ deny: () => end("cancelled"),
183
+ mismatch: () => end("mismatch"),
184
+ cancel: () => end("cancelled"),
185
+ };
186
+ }
@@ -0,0 +1,17 @@
1
+ import { type PairingSession } from "@gryt/crypto";
2
+ import type { PairingClock, PairingEndReason } from "./interfaces.js";
3
+ import type { PairingRelay, RelayMessage } from "./relayClient.js";
4
+ /** Thrown inside a pairing to stop it with a reason the screen can show. */
5
+ export declare class PairingEnded extends Error {
6
+ readonly reason: PairingEndReason;
7
+ constructor(reason: PairingEndReason);
8
+ }
9
+ export declare const systemClock: PairingClock;
10
+ export declare function reasonFor(error: unknown): PairingEndReason;
11
+ export declare function sealJson(session: PairingSession, value: Record<string, unknown>): string;
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: string): Record<string, unknown>;
14
+ /** The other side's messages in order, long-polling the relay. `next` gives null once `deadline` passes. */
15
+ export declare function mailbox(relay: PairingRelay, id: string, token: string, clock: PairingClock): {
16
+ next(signal: AbortSignal, deadline?: number): Promise<RelayMessage | null>;
17
+ };
@@ -0,0 +1,81 @@
1
+ import { base64Url, base64UrlDecode } from "@gryt/crypto";
2
+ import { PairingRelayError } from "./relayClient.js";
3
+ /** Thrown inside a pairing to stop it with a reason the screen can show. */
4
+ export class PairingEnded extends Error {
5
+ reason;
6
+ constructor(reason) {
7
+ super(`Pairing ended: ${reason}`);
8
+ this.reason = reason;
9
+ }
10
+ }
11
+ export const systemClock = {
12
+ now: () => Date.now(),
13
+ sleep: (ms, signal) => new Promise((resolve, reject) => {
14
+ if (signal?.aborted)
15
+ return reject(signal.reason);
16
+ const timer = setTimeout(resolve, ms);
17
+ signal?.addEventListener("abort", () => (clearTimeout(timer), reject(signal.reason)), { once: true });
18
+ }),
19
+ };
20
+ export function reasonFor(error) {
21
+ if (error instanceof PairingEnded)
22
+ return error.reason;
23
+ if (!(error instanceof PairingRelayError))
24
+ return "relay_error";
25
+ if (error.status === 410)
26
+ return error.code === "closed" ? "cancelled_by_other" : "expired";
27
+ if (error.code === "already_claimed")
28
+ return "already_claimed";
29
+ if (error.status === 404)
30
+ return "unknown_code";
31
+ if (error.status === 429)
32
+ return "rate_limited";
33
+ return "relay_error";
34
+ }
35
+ export function sealJson(session, value) {
36
+ return base64Url(session.seal(new TextEncoder().encode(JSON.stringify(value))));
37
+ }
38
+ /** The next sealed message as JSON with a `t`, or a throw that ends the pairing as tampered. */
39
+ export function openJson(session, message, t) {
40
+ try {
41
+ if (message?.type !== "sealed" || !message.body)
42
+ throw new Error();
43
+ const value = JSON.parse(new TextDecoder().decode(session.open(base64UrlDecode(message.body))));
44
+ if (value && typeof value === "object" && value.t === t)
45
+ return value;
46
+ }
47
+ catch {
48
+ // Falls through: whatever went wrong, the other side can't be trusted with the rest.
49
+ }
50
+ throw new PairingEnded("tampered");
51
+ }
52
+ const MAX_NETWORK_RETRIES = 5;
53
+ /** The other side's messages in order, long-polling the relay. `next` gives null once `deadline` passes. */
54
+ export function mailbox(relay, id, token, clock) {
55
+ let after = 0;
56
+ const queue = [];
57
+ return {
58
+ async next(signal, deadline) {
59
+ for (let failures = 0;;) {
60
+ const queued = queue.shift();
61
+ if (queued)
62
+ return queued;
63
+ const left = deadline === undefined ? 25_000 : deadline - clock.now();
64
+ if (left <= 0)
65
+ return null;
66
+ try {
67
+ for (const m of await relay.poll(id, token, after, Math.min(25, Math.ceil(left / 1000)), signal)) {
68
+ if (m.seq > after)
69
+ (queue.push(m), (after = m.seq));
70
+ }
71
+ failures = 0;
72
+ }
73
+ catch (e) {
74
+ if (!(e instanceof PairingRelayError && e.code === "network") || ++failures > MAX_NETWORK_RETRIES)
75
+ throw e;
76
+ await clock.sleep(2000, signal);
77
+ }
78
+ }
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,74 @@
1
+ import type { PairingEnvelope } from "@gryt/crypto";
2
+ import type { MlsAddOwnDeviceOptions, MlsOwnDeviceAdd } from "../mls/interfaces.js";
3
+ /** `fetch`, cut down to what pairing uses. The browser's, Node's and React Native's all fit. */
4
+ export type PairingFetch = (url: string, init: {
5
+ method: string;
6
+ headers?: Record<string, string>;
7
+ body?: string;
8
+ signal?: AbortSignal;
9
+ }) => Promise<{
10
+ status: number;
11
+ json(): Promise<unknown>;
12
+ }>;
13
+ export interface PairingClock {
14
+ now(): number;
15
+ /** Resolves after `ms`, or rejects once `signal` aborts. */
16
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
17
+ }
18
+ /** What N tells A about itself, shown on the approval screen. N chooses it, so A treats it as a claim. */
19
+ export interface PairingDeviceInfo {
20
+ name: string;
21
+ app: string;
22
+ platform: string;
23
+ }
24
+ /** Tokens from the device grant. The ID token has already been checked against the envelope. */
25
+ export interface PairingTokens {
26
+ idToken: string;
27
+ accessToken: string;
28
+ refreshToken?: string;
29
+ expiresIn?: number;
30
+ }
31
+ export interface DeviceAuthorization {
32
+ deviceCode: string;
33
+ userCode: string;
34
+ expiresIn: number;
35
+ interval?: number;
36
+ }
37
+ export type DeviceTokenPoll = {
38
+ status: "pending" | "slow_down" | "denied" | "expired";
39
+ } | {
40
+ status: "ok";
41
+ tokens: PairingTokens;
42
+ };
43
+ /** The two RFC 8628 calls, against `{issuer}/protocol/openid-connect/...`. Core makes the PKCE and nonce. */
44
+ export interface PairingOidc {
45
+ deviceAuthorization(req: {
46
+ issuer: string;
47
+ clientId: string;
48
+ scope: string;
49
+ codeChallenge: string;
50
+ codeChallengeMethod: "S256";
51
+ nonce: string;
52
+ }): Promise<DeviceAuthorization>;
53
+ deviceToken(req: {
54
+ issuer: string;
55
+ clientId: string;
56
+ deviceCode: string;
57
+ codeVerifier: string;
58
+ }): Promise<DeviceTokenPoll>;
59
+ }
60
+ /** N's secure storage. Called once, only after every check has passed, with everything to keep. */
61
+ export interface PairingStorage {
62
+ commit(envelope: PairingEnvelope, tokens: PairingTokens | null): Promise<void>;
63
+ }
64
+ /** N's MLS device on one server, sent to A in "ready" once its KeyPackages are published. */
65
+ export interface PairedServerDevice {
66
+ host: string;
67
+ deviceId: string;
68
+ }
69
+ /** A's DM driver on one server, or undefined when A isn't on it. */
70
+ export type OwnDeviceAdder = (host: string) => {
71
+ addOwnDevice(deviceId: string, options?: MlsAddOwnDeviceOptions): Promise<MlsOwnDeviceAdd[]>;
72
+ } | undefined;
73
+ /** Why a pairing stopped. `approve:<error>` carries the extension's own error code. */
74
+ export type PairingEndReason = "cancelled" | "cancelled_by_other" | "mismatch" | "timed_out" | "expired" | "already_claimed" | "unknown_code" | "wrong_relay" | "newer_version" | "not_pairing" | "rate_limited" | "tampered" | "wrong_account" | "sign_in_failed" | "relay_error" | `approve:${string}`;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ import { type PairingEmoji, type PairingServer } from "@gryt/crypto";
2
+ import type { PairedServerDevice, PairingClock, PairingDeviceInfo, PairingEndReason, PairingOidc, PairingStorage } from "./interfaces.js";
3
+ import type { PairingRelay } from "./relayClient.js";
4
+ export type NewDeviceState = {
5
+ phase: "idle";
6
+ } | {
7
+ phase: "opening";
8
+ }
9
+ /** `renewed` says why this is a fresh QR: the last one ran out, or A never approved. */
10
+ | {
11
+ phase: "showing";
12
+ qr: string;
13
+ code: string;
14
+ expiresAt: number;
15
+ renewed?: "expired" | "timed_out";
16
+ } | {
17
+ phase: "comparing";
18
+ emoji: readonly PairingEmoji[];
19
+ deadline: number;
20
+ } | {
21
+ phase: "signing_in";
22
+ username: string;
23
+ from: string;
24
+ }
25
+ /** Everything is written. The app joins `servers`, publishes KeyPackages, then calls `ready`. */
26
+ | {
27
+ phase: "joining";
28
+ servers: PairingServer[];
29
+ from: string;
30
+ } | {
31
+ phase: "linked";
32
+ from: string;
33
+ } | {
34
+ phase: "done";
35
+ from: string;
36
+ } | {
37
+ phase: "ended";
38
+ reason: PairingEndReason;
39
+ };
40
+ export interface NewDeviceOptions {
41
+ relay: PairingRelay;
42
+ device: PairingDeviceInfo;
43
+ storage: PairingStorage;
44
+ oidc: PairingOidc;
45
+ clock?: PairingClock;
46
+ /** Only for a server with its own auth server: goes in the QR for A to check against its own. */
47
+ relayOrigin?: string;
48
+ approvalMs?: number;
49
+ }
50
+ export interface NewDevicePairing {
51
+ readonly state: NewDeviceState;
52
+ subscribe(listener: (state: NewDeviceState) => void): () => void;
53
+ start(): void;
54
+ /** In `joining`: N's device on each server, once its KeyPackages are up. */
55
+ ready(devices: PairedServerDevice[]): Promise<void>;
56
+ cancel(): Promise<void>;
57
+ /** "They don't match": the same as cancel, with a reason that says the link may be tampered with. */
58
+ mismatch(): Promise<void>;
59
+ }
60
+ export declare const PAIRING_APPROVAL_MS = 60000;
61
+ /** N: shows the QR and code, checks the emoji, opens the envelope, signs in, then says ready. */
62
+ export declare function createNewDevicePairing(options: NewDeviceOptions): NewDevicePairing;
@@ -0,0 +1,204 @@
1
+ import { base64Url, base64UrlDecode, createPairingKey, decodePairingEnvelope, decodePairingSessionId, formatPairingCode, formatPairingQr, pairingCommitment, startNewDeviceSession, } from "@gryt/crypto";
2
+ import { sha256 } from "@noble/hashes/sha2.js";
3
+ import { randomBytes } from "@noble/hashes/utils.js";
4
+ import { mailbox, PairingEnded, reasonFor, sealJson, systemClock } from "./channel.js";
5
+ export const PAIRING_APPROVAL_MS = 60_000;
6
+ const PAIRING_OIDC_SCOPE = "openid profile email offline_access";
7
+ /** N: shows the QR and code, checks the emoji, opens the envelope, signs in, then says ready. */
8
+ export function createNewDevicePairing(options) {
9
+ const { relay, device, storage, oidc } = options;
10
+ const clock = options.clock ?? systemClock;
11
+ const approvalMs = options.approvalMs ?? PAIRING_APPROVAL_MS;
12
+ const listeners = new Set();
13
+ const abort = new AbortController();
14
+ let state = { phase: "idle" };
15
+ let current = null;
16
+ const set = (next) => {
17
+ if (state.phase === "ended" || state.phase === "done")
18
+ return;
19
+ state = next;
20
+ for (const l of listeners)
21
+ l(state);
22
+ };
23
+ const closeQuietly = () => (current ? relay.close(current.id, current.token).catch(() => undefined) : undefined);
24
+ const end = async (reason) => {
25
+ if (state.phase === "ended" || state.phase === "done")
26
+ return;
27
+ set({ phase: "ended", reason });
28
+ abort.abort();
29
+ current?.session?.close();
30
+ await closeQuietly();
31
+ };
32
+ async function attempt(renewed) {
33
+ set({ phase: "opening" });
34
+ const key = createPairingKey();
35
+ const created = await relay.create(base64Url(pairingCommitment(key.publicKey)));
36
+ const sessionId = decodePairingSessionId(created.id);
37
+ if (!sessionId)
38
+ throw new PairingEnded("relay_error");
39
+ const box = mailbox(relay, created.id, created.token, clock);
40
+ current = { id: created.id, token: created.token, box };
41
+ if (abort.signal.aborted) {
42
+ await closeQuietly();
43
+ throw new PairingEnded("cancelled");
44
+ }
45
+ const expiresAt = Date.parse(created.expiresAt);
46
+ const qr = formatPairingQr({ sessionId, publicKey: key.publicKey, relayOrigin: options.relayOrigin });
47
+ set({ phase: "showing", qr, code: formatPairingCode(created.code), expiresAt, ...(renewed ? { renewed } : {}) });
48
+ // No local deadline: the relay's clock decides, and its 410 is what renews the QR.
49
+ const claim = await box.next(abort.signal).catch((e) => {
50
+ if (reasonFor(e) === "expired")
51
+ return null;
52
+ throw e;
53
+ });
54
+ if (!claim)
55
+ return "expired";
56
+ if (claim.type !== "claim" || !claim.pkA)
57
+ throw new PairingEnded("tampered");
58
+ let session;
59
+ try {
60
+ session = current.session = startNewDeviceSession(key, { sessionId, approverPublicKey: base64UrlDecode(claim.pkA) });
61
+ }
62
+ catch {
63
+ throw new PairingEnded("tampered");
64
+ }
65
+ await relay.post(created.id, created.token, { type: "reveal", pkN: base64Url(key.publicKey) });
66
+ const deadline = clock.now() + approvalMs;
67
+ await relay.post(created.id, created.token, { type: "sealed", body: sealJson(session, { t: "hello", ...device, deadline }) });
68
+ set({ phase: "comparing", emoji: session.emoji, deadline });
69
+ const sealed = await box.next(abort.signal, deadline);
70
+ if (!sealed || clock.now() > deadline) {
71
+ session.close();
72
+ await closeQuietly();
73
+ return "timed_out";
74
+ }
75
+ try {
76
+ if (sealed.type !== "sealed" || !sealed.body)
77
+ throw new Error();
78
+ return decodePairingEnvelope(session.open(base64UrlDecode(sealed.body)));
79
+ }
80
+ catch (e) {
81
+ throw new PairingEnded(e instanceof Error && /newer version/.test(e.message) ? "newer_version" : "tampered");
82
+ }
83
+ }
84
+ async function signIn(account, from) {
85
+ const { id, token, box, session } = current;
86
+ set({ phase: "signing_in", username: account.username, from });
87
+ const verifier = base64Url(randomBytes(32));
88
+ const auth = await oidc.deviceAuthorization({
89
+ issuer: account.issuer,
90
+ clientId: account.clientId,
91
+ scope: PAIRING_OIDC_SCOPE,
92
+ codeChallenge: base64Url(sha256(new TextEncoder().encode(verifier))),
93
+ codeChallengeMethod: "S256",
94
+ nonce: session.keycloakNonce,
95
+ });
96
+ await relay.post(id, token, { type: "sealed", body: sealJson(session, { t: "user_code", userCode: auth.userCode }) });
97
+ // A sends nothing more here, so anything from the relay (a close, most likely) stops the polling.
98
+ const stop = new AbortController();
99
+ const onAbort = () => stop.abort();
100
+ abort.signal.addEventListener("abort", onAbort, { once: true });
101
+ let failure = null;
102
+ box.next(stop.signal).then(() => ((failure = new PairingEnded("tampered")), stop.abort()), (e) => (stop.signal.aborted ? undefined : ((failure = e), stop.abort())));
103
+ try {
104
+ let interval = (auth.interval ?? 5) * 1000;
105
+ for (const until = clock.now() + auth.expiresIn * 1000; clock.now() < until;) {
106
+ await clock.sleep(interval, stop.signal).catch(() => {
107
+ throw failure ?? new PairingEnded("cancelled");
108
+ });
109
+ const polled = await oidc.deviceToken({
110
+ issuer: account.issuer,
111
+ clientId: account.clientId,
112
+ deviceCode: auth.deviceCode,
113
+ codeVerifier: verifier,
114
+ });
115
+ if (failure)
116
+ throw failure;
117
+ if (polled.status === "ok")
118
+ return checkIdToken(polled.tokens, account, session.keycloakNonce);
119
+ if (polled.status === "slow_down")
120
+ interval += 5000;
121
+ else if (polled.status !== "pending")
122
+ break;
123
+ }
124
+ throw new PairingEnded("sign_in_failed");
125
+ }
126
+ finally {
127
+ stop.abort();
128
+ abort.signal.removeEventListener("abort", onAbort);
129
+ }
130
+ }
131
+ async function run() {
132
+ try {
133
+ let outcome = await attempt();
134
+ while (typeof outcome === "string")
135
+ outcome = await attempt(outcome);
136
+ const envelope = outcome;
137
+ const tokens = envelope.account ? await signIn(envelope.account, envelope.from) : null;
138
+ await storage.commit(envelope, tokens);
139
+ set({ phase: "joining", servers: envelope.servers, from: envelope.from });
140
+ }
141
+ catch (e) {
142
+ if (!abort.signal.aborted)
143
+ await end(reasonFor(e));
144
+ }
145
+ }
146
+ async function linked(from) {
147
+ try {
148
+ // History from A lands here in a later version. Until then, A closing is the finish.
149
+ while (await current.box.next(abort.signal))
150
+ ;
151
+ }
152
+ catch (e) {
153
+ if (abort.signal.aborted)
154
+ return;
155
+ const reason = reasonFor(e);
156
+ if (reason !== "cancelled_by_other" && reason !== "expired")
157
+ return end(reason);
158
+ set({ phase: "done", from });
159
+ current?.session?.close();
160
+ }
161
+ }
162
+ return {
163
+ get state() {
164
+ return state;
165
+ },
166
+ subscribe(listener) {
167
+ listeners.add(listener);
168
+ return () => void listeners.delete(listener);
169
+ },
170
+ start() {
171
+ if (state.phase !== "idle")
172
+ throw new Error("This pairing has started already.");
173
+ void run();
174
+ },
175
+ async ready(devices) {
176
+ if (state.phase !== "joining" || !current?.session)
177
+ throw new Error("Not waiting to join.");
178
+ const { from } = state;
179
+ const body = sealJson(current.session, { t: "ready", devices: devices.map(({ host, deviceId }) => ({ host, deviceId })) });
180
+ await relay.post(current.id, current.token, { type: "sealed", body });
181
+ set({ phase: "linked", from });
182
+ void linked(from);
183
+ },
184
+ cancel: () => end("cancelled"),
185
+ mismatch: () => end("mismatch"),
186
+ };
187
+ }
188
+ /** Straight from the token endpoint over TLS, so the claims are read, not the signature (OIDC Core 3.1.3.7). */
189
+ function checkIdToken(tokens, account, nonce) {
190
+ let claims;
191
+ try {
192
+ claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(tokens.idToken.split(".")[1] ?? "")));
193
+ }
194
+ catch {
195
+ throw new PairingEnded("sign_in_failed");
196
+ }
197
+ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
198
+ if (claims.nonce !== nonce || claims.iss !== account.issuer || !aud.includes(account.clientId)) {
199
+ throw new PairingEnded("tampered");
200
+ }
201
+ if (claims.sub !== account.sub)
202
+ throw new PairingEnded("wrong_account");
203
+ return tokens;
204
+ }
@@ -0,0 +1,45 @@
1
+ import type { PairingFetch } from "./interfaces.js";
2
+ /** A refusal from the relay: its status and the `error` it sent, or "network" when nothing came back. */
3
+ export declare class PairingRelayError extends Error {
4
+ readonly status: number;
5
+ readonly code: string;
6
+ constructor(status: number, code: string);
7
+ }
8
+ export interface RelayMessage {
9
+ seq: number;
10
+ type: "claim" | "reveal" | "sealed";
11
+ pkA?: string;
12
+ pkN?: string;
13
+ body?: string;
14
+ }
15
+ export interface PairingRelay {
16
+ create(commit: string): Promise<{
17
+ id: string;
18
+ code: string;
19
+ token: string;
20
+ expiresAt: string;
21
+ }>;
22
+ claim(by: {
23
+ id: string;
24
+ } | {
25
+ code: string;
26
+ }, pkA: string): Promise<{
27
+ id: string;
28
+ token: string;
29
+ commit: string;
30
+ location: string | null;
31
+ yourLocation: string | null;
32
+ }>;
33
+ post(id: string, token: string, message: {
34
+ type: "reveal";
35
+ pkN: string;
36
+ } | {
37
+ type: "sealed";
38
+ body: string;
39
+ }): Promise<void>;
40
+ /** The other side's messages after `after`, waiting up to `waitSeconds`. Asking past one acks it. */
41
+ poll(id: string, token: string, after: number, waitSeconds: number, signal?: AbortSignal): Promise<RelayMessage[]>;
42
+ close(id: string, token: string): Promise<void>;
43
+ }
44
+ /** The relay's HTTP API as auth#45 built it, under `{origin}/api/v1/pairing`. */
45
+ export declare function createPairingRelay(origin: string, fetch: PairingFetch): PairingRelay;
@@ -0,0 +1,48 @@
1
+ /** A refusal from the relay: its status and the `error` it sent, or "network" when nothing came back. */
2
+ export class PairingRelayError extends Error {
3
+ status;
4
+ code;
5
+ constructor(status, code) {
6
+ super(`Pairing relay: ${status} ${code}`);
7
+ this.name = "PairingRelayError";
8
+ this.status = status;
9
+ this.code = code;
10
+ }
11
+ }
12
+ /** The relay's HTTP API as auth#45 built it, under `{origin}/api/v1/pairing`. */
13
+ export function createPairingRelay(origin, fetch) {
14
+ const base = `${origin.replace(/\/+$/, "")}/api/v1/pairing/sessions`;
15
+ async function call(method, path, body, token, signal) {
16
+ const headers = {};
17
+ if (body !== undefined)
18
+ headers["content-type"] = "application/json";
19
+ if (token)
20
+ headers.authorization = `Bearer ${token}`;
21
+ let res;
22
+ try {
23
+ res = await fetch(`${base}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal });
24
+ }
25
+ catch (e) {
26
+ if (signal?.aborted)
27
+ throw e;
28
+ throw new PairingRelayError(0, "network");
29
+ }
30
+ if (res.status === 204)
31
+ return {};
32
+ const json = (await res.json().catch(() => null));
33
+ if (res.status >= 400 || !json)
34
+ throw new PairingRelayError(res.status, String(json?.error ?? "unknown"));
35
+ return json;
36
+ }
37
+ const path = (id) => `/${encodeURIComponent(id)}`;
38
+ return {
39
+ create: async (commit) => (await call("POST", "", { commit })),
40
+ claim: async (by, pkA) => (await call("POST", "/claim", { ...by, pkA })),
41
+ post: async (id, token, message) => void (await call("POST", `${path(id)}/messages`, message, token)),
42
+ poll: async (id, token, after, waitSeconds, signal) => {
43
+ const res = await call("GET", `${path(id)}/messages?after=${after}&wait=${waitSeconds}`, undefined, token, signal);
44
+ return (Array.isArray(res.messages) ? res.messages : []);
45
+ },
46
+ close: async (id, token) => void (await call("DELETE", path(id), undefined, token)),
47
+ };
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.13.0",
3
+ "version": "0.14.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": {
@@ -35,6 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@gryt/crypto": "0.7.0"
38
+ "@gryt/crypto": "0.8.0",
39
+ "@noble/hashes": "^2.3.0"
39
40
  }
40
41
  }