@gryt/core 0.12.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 +18 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/mls/dmDriver.d.ts +1 -1
- package/dist/mls/dmDriver.js +204 -8
- package/dist/mls/interfaces.d.ts +64 -2
- package/dist/pairing/approver.d.ts +71 -0
- package/dist/pairing/approver.js +186 -0
- package/dist/pairing/channel.d.ts +17 -0
- package/dist/pairing/channel.js +81 -0
- package/dist/pairing/interfaces.d.ts +74 -0
- package/dist/pairing/interfaces.js +1 -0
- package/dist/pairing/newDevice.d.ts +62 -0
- package/dist/pairing/newDevice.js +204 -0
- package/dist/pairing/relayClient.d.ts +45 -0
- package/dist/pairing/relayClient.js +48 -0
- package/package.json +3 -2
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.
|
|
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
|
|
60
|
-
|
|
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, MlsLogEntry, 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";
|
package/dist/mls/dmDriver.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MlsDmDriver, MlsDmDriverOptions, MlsRefusal } from "./interfaces.js";
|
|
2
|
-
export type MlsDriverErrorCode = "refused" | "no_device" | "waiting_for_welcome" | "not_in_group" | "unexpected_member" | "peer_unverified" | "too_many_attachments" | "commit_retries";
|
|
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. */
|
package/dist/mls/dmDriver.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { addMlsMembers, asIdentityScope, base64UrlDecode, createMlsGroup, decodeMlsGroupState, encodeMlsGroupState, encryptMlsMessage, forgetMlsSecrets, generateMlsKeyPackage, joinMlsGroup, mlsGroupInfo, mlsGroupMembers, mlsWelcomeRefs, processMlsMessage, readMlsKeyPackage, removeMlsMembers, } from "@gryt/crypto";
|
|
1
|
+
import { addMlsMembers, asIdentityScope, base64UrlDecode, createMlsGroup, decodeMlsGroupState, encodeMlsGroupState, encryptMlsMessage, forgetMlsSecrets, generateMlsKeyPackage, joinMlsGroup, mlsGroupInfo, mlsGroupMembers, mlsWelcomeRefs, processMlsMessage, readDeviceCertificate, readMlsKeyPackage, removeMlsMembers, } from "@gryt/crypto";
|
|
2
2
|
/** Five devices per person per server (design, section 1). The server holds the same line. */
|
|
3
3
|
const MLS_MAX_DEVICES_PER_PERSON = 5;
|
|
4
4
|
const COMMIT_ATTEMPTS = 5;
|
|
@@ -55,7 +55,39 @@ function keyedQueue() {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
export function createMlsDmDriver(options) {
|
|
58
|
-
const {
|
|
58
|
+
const { store, pins, events, serverUserId: self, capability } = options;
|
|
59
|
+
/* Once the server says `device_removed`, nothing goes to it again from this driver:
|
|
60
|
+
no sync, and above all no publish that could register this device back. */
|
|
61
|
+
let removed = false;
|
|
62
|
+
const removedError = (r) => new MlsDriverError("device_removed", "This device was removed from encrypted messages on this server.", r);
|
|
63
|
+
function stopOnRemoval(call) {
|
|
64
|
+
return async (req) => {
|
|
65
|
+
if (removed)
|
|
66
|
+
throw removedError();
|
|
67
|
+
const r = await call(req);
|
|
68
|
+
if (!r.ok && r.error === "device_removed") {
|
|
69
|
+
if (!removed) {
|
|
70
|
+
removed = true;
|
|
71
|
+
events.onDeviceRemoved?.();
|
|
72
|
+
}
|
|
73
|
+
throw removedError(r);
|
|
74
|
+
}
|
|
75
|
+
return r;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const raw = options.transport;
|
|
79
|
+
const transport = {
|
|
80
|
+
publishKeyPackages: stopOnRemoval((req) => raw.publishKeyPackages(req)),
|
|
81
|
+
claimKeyPackages: stopOnRemoval((req) => raw.claimKeyPackages(req)),
|
|
82
|
+
listDevices: stopOnRemoval((req) => raw.listDevices(req)),
|
|
83
|
+
removeDevice: stopOnRemoval((req) => raw.removeDevice(req)),
|
|
84
|
+
createGroup: stopOnRemoval((req) => raw.createGroup(req)),
|
|
85
|
+
commit: stopOnRemoval((req) => raw.commit(req)),
|
|
86
|
+
send: stopOnRemoval((req) => raw.send(req)),
|
|
87
|
+
fetchLog: stopOnRemoval((req) => raw.fetchLog(req)),
|
|
88
|
+
sync: stopOnRemoval((req) => raw.sync(req)),
|
|
89
|
+
ackWelcomes: stopOnRemoval((req) => raw.ackWelcomes(req)),
|
|
90
|
+
};
|
|
59
91
|
const scope = asIdentityScope(options.scope);
|
|
60
92
|
const trusts = new Map();
|
|
61
93
|
/** What the engine asks about every leaf: is it somebody in this conversation, by pin. */
|
|
@@ -106,6 +138,20 @@ export function createMlsDmDriver(options) {
|
|
|
106
138
|
open.rec = { ...open.rec, state: encodeMlsGroupState(open.state), cursor };
|
|
107
139
|
await store.saveGroup(open.rec);
|
|
108
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
|
+
}
|
|
109
155
|
async function load(conversationId) {
|
|
110
156
|
const rec = await store.loadGroup(conversationId);
|
|
111
157
|
return rec ? { rec, state: decodeMlsGroupState(rec.state, trustFor(conversationId)) } : null;
|
|
@@ -168,6 +214,7 @@ export function createMlsDmDriver(options) {
|
|
|
168
214
|
const d = await ownDevice();
|
|
169
215
|
const current = epochOf(open.state);
|
|
170
216
|
if (e.kind === "commit") {
|
|
217
|
+
const before = open.state;
|
|
171
218
|
const pending = open.rec.pending;
|
|
172
219
|
if (pending && sameBytes(pending.commit, e.data)) {
|
|
173
220
|
open.state = decodeMlsGroupState(pending.state, trustFor(open.rec.conversationId));
|
|
@@ -195,6 +242,7 @@ export function createMlsDmDriver(options) {
|
|
|
195
242
|
}
|
|
196
243
|
}
|
|
197
244
|
open.rec = { ...open.rec, pending: undefined };
|
|
245
|
+
noteJoins(open.rec.conversationId, before, open.state, e.seq, !!pending && sameBytes(pending.commit, e.data));
|
|
198
246
|
await notePeople(open.rec.conversationId, open.state);
|
|
199
247
|
}
|
|
200
248
|
else if (e.kind === "proposal") {
|
|
@@ -223,6 +271,7 @@ export function createMlsDmDriver(options) {
|
|
|
223
271
|
await events.onMessage({
|
|
224
272
|
conversationId: open.rec.conversationId,
|
|
225
273
|
seq: e.seq,
|
|
274
|
+
epoch: e.epoch,
|
|
226
275
|
senderServerUserId: e.senderServerUserId,
|
|
227
276
|
senderDeviceId: e.senderDeviceId,
|
|
228
277
|
plaintext,
|
|
@@ -273,6 +322,7 @@ export function createMlsDmDriver(options) {
|
|
|
273
322
|
welcome: c.welcome,
|
|
274
323
|
});
|
|
275
324
|
if (r.ok) {
|
|
325
|
+
noteJoins(open.rec.conversationId, open.state, c.state, r.seq, true);
|
|
276
326
|
open.state = c.state;
|
|
277
327
|
forgetMlsSecrets(c.consumed);
|
|
278
328
|
open.rec = { ...open.rec, pending: undefined };
|
|
@@ -579,6 +629,9 @@ export function createMlsDmDriver(options) {
|
|
|
579
629
|
/** Adds wait for the next send; a device that's gone is removed straight away. */
|
|
580
630
|
async function handleDevicesChanged(push) {
|
|
581
631
|
reconciled.clear();
|
|
632
|
+
// One of yours changed, and it may be this one: a sync is how the server says so.
|
|
633
|
+
if (push.serverUserId === self && registered)
|
|
634
|
+
await transport.sync({ deviceId: (await ownDevice()).deviceId });
|
|
582
635
|
for (const rec of await store.listGroups()) {
|
|
583
636
|
await run(rec.conversationId, async () => {
|
|
584
637
|
const open = await load(rec.conversationId);
|
|
@@ -593,6 +646,152 @@ export function createMlsDmDriver(options) {
|
|
|
593
646
|
});
|
|
594
647
|
}
|
|
595
648
|
}
|
|
649
|
+
/** Names from certificates under your own person key: this device's, then your leaves in each group. */
|
|
650
|
+
async function ownDeviceNames(d, wanted) {
|
|
651
|
+
const mine = readDeviceCertificate(d.certificate, scope);
|
|
652
|
+
const names = new Map([[mine.deviceId, mine.deviceName]]);
|
|
653
|
+
const unnamed = () => [...wanted].some((id) => !names.has(id));
|
|
654
|
+
for (const rec of await store.listGroups()) {
|
|
655
|
+
if (!unnamed())
|
|
656
|
+
break;
|
|
657
|
+
let members;
|
|
658
|
+
try {
|
|
659
|
+
members = mlsGroupMembers(decodeMlsGroupState(rec.state, trustFor(rec.conversationId)), scope);
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
for (const { certificate: c } of members) {
|
|
665
|
+
if (wanted.has(c.deviceId) && !names.has(c.deviceId) && sameBytes(c.personPublicKey, mine.personPublicKey)) {
|
|
666
|
+
names.set(c.deviceId, c.deviceName);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return names;
|
|
671
|
+
}
|
|
672
|
+
async function ownDevices() {
|
|
673
|
+
const r = await transport.listDevices({});
|
|
674
|
+
if (!r.ok)
|
|
675
|
+
throw refused("Listing devices", r);
|
|
676
|
+
const d = await ownDevice();
|
|
677
|
+
const listed = r.devices.filter((x) => x.serverUserId === self);
|
|
678
|
+
const names = await ownDeviceNames(d, new Set(listed.map((x) => x.deviceId)));
|
|
679
|
+
return listed.map((x) => ({
|
|
680
|
+
serverUserId: x.serverUserId,
|
|
681
|
+
deviceId: x.deviceId,
|
|
682
|
+
name: names.get(x.deviceId) ?? null,
|
|
683
|
+
addedAt: x.createdAt ?? null,
|
|
684
|
+
lastSeenAt: x.lastSeenAt ?? null,
|
|
685
|
+
thisDevice: x.deviceId === d.deviceId,
|
|
686
|
+
}));
|
|
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
|
+
}
|
|
596
795
|
return {
|
|
597
796
|
async start() {
|
|
598
797
|
await ownDevice();
|
|
@@ -613,17 +812,14 @@ export function createMlsDmDriver(options) {
|
|
|
613
812
|
handleMessage,
|
|
614
813
|
handleWelcome,
|
|
615
814
|
handleDevicesChanged,
|
|
616
|
-
|
|
617
|
-
const r = await transport.listDevices({});
|
|
618
|
-
if (!r.ok)
|
|
619
|
-
throw refused("Listing devices", r);
|
|
620
|
-
return r.devices;
|
|
621
|
-
},
|
|
815
|
+
ownDevices,
|
|
622
816
|
async removeOwnDevice(deviceId) {
|
|
623
817
|
const r = await transport.removeDevice({ deviceId });
|
|
624
818
|
if (!r.ok)
|
|
625
819
|
throw refused("Removing the device", r);
|
|
626
820
|
await handleDevicesChanged({ serverUserId: self });
|
|
627
821
|
},
|
|
822
|
+
groupPositions,
|
|
823
|
+
addOwnDevice,
|
|
628
824
|
};
|
|
629
825
|
}
|
package/dist/mls/interfaces.d.ts
CHANGED
|
@@ -13,6 +13,11 @@ export interface MlsDeviceRef {
|
|
|
13
13
|
serverUserId: string;
|
|
14
14
|
deviceId: string;
|
|
15
15
|
}
|
|
16
|
+
/** A row of `mls:devices`. Your own rows say when the server first and last saw each one. */
|
|
17
|
+
export interface MlsListedDevice extends MlsDeviceRef {
|
|
18
|
+
createdAt?: string;
|
|
19
|
+
lastSeenAt?: string;
|
|
20
|
+
}
|
|
16
21
|
/** The server's view of a group. `groupId` is lower-case hex. */
|
|
17
22
|
export interface MlsGroupView {
|
|
18
23
|
conversationId: string;
|
|
@@ -71,7 +76,7 @@ export interface MlsTransport {
|
|
|
71
76
|
listDevices(req: {
|
|
72
77
|
conversationId?: string;
|
|
73
78
|
}): Promise<MlsReply<{
|
|
74
|
-
devices:
|
|
79
|
+
devices: MlsListedDevice[];
|
|
75
80
|
}>>;
|
|
76
81
|
removeDevice(req: {
|
|
77
82
|
deviceId: string;
|
|
@@ -206,6 +211,8 @@ export interface MlsPins {
|
|
|
206
211
|
export interface MlsDecryptedMessage {
|
|
207
212
|
conversationId: string;
|
|
208
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;
|
|
209
216
|
senderServerUserId: string;
|
|
210
217
|
senderDeviceId: string;
|
|
211
218
|
plaintext: Uint8Array;
|
|
@@ -232,6 +239,11 @@ export interface MlsDmEvents {
|
|
|
232
239
|
conversationId: string;
|
|
233
240
|
groupId: string;
|
|
234
241
|
}): void;
|
|
242
|
+
/**
|
|
243
|
+
* The server says this device was removed here (`device_removed`). The driver has stopped for
|
|
244
|
+
* good. Wipe this server's MLS state, and don't make a new device until the person asks.
|
|
245
|
+
*/
|
|
246
|
+
onDeviceRemoved?(): void;
|
|
235
247
|
}
|
|
236
248
|
/** Decision 4, per DM. `refused` never falls back to version 1. */
|
|
237
249
|
export type DmSealingMode = {
|
|
@@ -243,6 +255,48 @@ export type DmSealingMode = {
|
|
|
243
255
|
kind: "refused";
|
|
244
256
|
reason: "peer_left_mls" | "server_dropped_mls" | "no_own_device";
|
|
245
257
|
};
|
|
258
|
+
/** One of your own devices on this server, for a device list. */
|
|
259
|
+
export interface MlsOwnDevice extends MlsDeviceRef {
|
|
260
|
+
/** From its device certificate, found in this device or a group you share. Null when none shows it. */
|
|
261
|
+
name: string | null;
|
|
262
|
+
/** ISO times from the server, or null from a server that doesn't send them. */
|
|
263
|
+
addedAt: string | null;
|
|
264
|
+
lastSeenAt: string | null;
|
|
265
|
+
thisDevice: boolean;
|
|
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
|
+
}
|
|
246
300
|
export interface MlsDmDriverOptions {
|
|
247
301
|
transport: MlsTransport;
|
|
248
302
|
store: MlsStateStore;
|
|
@@ -272,7 +326,15 @@ export interface MlsDmDriver {
|
|
|
272
326
|
handleDevicesChanged(push: {
|
|
273
327
|
serverUserId: string;
|
|
274
328
|
}): Promise<void>;
|
|
275
|
-
|
|
329
|
+
/** Oldest first, as the server lists them. */
|
|
330
|
+
ownDevices(): Promise<MlsOwnDevice[]>;
|
|
276
331
|
/** Your own devices only. Every group you share drops it on the next pass. */
|
|
277
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[]>;
|
|
278
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.
|
|
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.
|
|
38
|
+
"@gryt/crypto": "0.8.0",
|
|
39
|
+
"@noble/hashes": "^2.3.0"
|
|
39
40
|
}
|
|
40
41
|
}
|