@gryt/core 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -0
- package/dist/mls/deliveryService.fake.d.ts +96 -0
- package/dist/mls/deliveryService.fake.js +413 -0
- package/dist/mls/dmDriver.d.ts +1 -1
- package/dist/mls/dmDriver.js +222 -2
- package/dist/mls/interfaces.d.ts +54 -3
- package/dist/pairing/approver.d.ts +71 -0
- package/dist/pairing/approver.js +198 -0
- package/dist/pairing/channel.d.ts +17 -0
- package/dist/pairing/channel.js +81 -0
- package/dist/pairing/interfaces.d.ts +87 -0
- package/dist/pairing/interfaces.js +1 -0
- package/dist/pairing/newDevice.d.ts +62 -0
- package/dist/pairing/newDevice.js +209 -0
- package/dist/pairing/relayClient.d.ts +45 -0
- package/dist/pairing/relayClient.js +48 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +3 -0
- package/package.json +7 -2
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
/** auth#46's codes that get a named reason. Anything else it can answer still falls
|
|
6
|
+
through to the generic `approve:<code>`. */
|
|
7
|
+
const APPROVE_ERROR_REASONS = {
|
|
8
|
+
code_used: "code_used",
|
|
9
|
+
unknown_code: "code_expired",
|
|
10
|
+
expired_code: "code_expired",
|
|
11
|
+
code_not_pending: "code_expired",
|
|
12
|
+
required_actions: "required_actions",
|
|
13
|
+
stale_token: "stale_token",
|
|
14
|
+
rate_limited: "rate_limited",
|
|
15
|
+
};
|
|
16
|
+
/** A: claims the session, shows N and the emoji, and on Approve seals the envelope, signs N in and adds it. */
|
|
17
|
+
export function createApproverPairing(options) {
|
|
18
|
+
const { relay, fetch, devices } = options;
|
|
19
|
+
const clock = options.clock ?? systemClock;
|
|
20
|
+
const approvalMs = options.approvalMs ?? PAIRING_APPROVAL_MS;
|
|
21
|
+
const listeners = new Set();
|
|
22
|
+
const abort = new AbortController();
|
|
23
|
+
let state = { phase: "idle" };
|
|
24
|
+
let current = null;
|
|
25
|
+
let device = null;
|
|
26
|
+
let confirming = null;
|
|
27
|
+
const set = (next) => {
|
|
28
|
+
if (state.phase === "ended" || state.phase === "done")
|
|
29
|
+
return;
|
|
30
|
+
state = next;
|
|
31
|
+
for (const l of listeners)
|
|
32
|
+
l(state);
|
|
33
|
+
};
|
|
34
|
+
const closeQuietly = () => (current ? relay.close(current.id, current.token).catch(() => undefined) : undefined);
|
|
35
|
+
const end = async (reason) => {
|
|
36
|
+
if (state.phase === "ended" || state.phase === "done")
|
|
37
|
+
return;
|
|
38
|
+
set({ phase: "ended", reason });
|
|
39
|
+
abort.abort();
|
|
40
|
+
current?.session?.close();
|
|
41
|
+
await closeQuietly();
|
|
42
|
+
};
|
|
43
|
+
const fail = (e) => (abort.signal.aborted ? undefined : end(reasonFor(e)));
|
|
44
|
+
async function claim(input) {
|
|
45
|
+
let by;
|
|
46
|
+
let scanned;
|
|
47
|
+
if ("qr" in input) {
|
|
48
|
+
const qr = parsePairingQr(input.qr);
|
|
49
|
+
if (!qr.ok)
|
|
50
|
+
throw new PairingEnded(qr.reason === "newer-version" ? "newer_version" : "not_pairing");
|
|
51
|
+
if (qr.relayOrigin !== undefined && qr.relayOrigin !== options.relayOrigin.toLowerCase().replace(/\/+$/, "")) {
|
|
52
|
+
throw new PairingEnded("wrong_relay");
|
|
53
|
+
}
|
|
54
|
+
by = { id: encodePairingSessionId(qr.sessionId) };
|
|
55
|
+
scanned = qr.publicKey;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const code = parsePairingCode(input.code);
|
|
59
|
+
if (!code)
|
|
60
|
+
throw new PairingEnded("unknown_code");
|
|
61
|
+
by = { code };
|
|
62
|
+
}
|
|
63
|
+
set({ phase: "claiming" });
|
|
64
|
+
const key = createPairingKey();
|
|
65
|
+
const claimed = await relay.claim(by, base64Url(key.publicKey));
|
|
66
|
+
const box = mailbox(relay, claimed.id, claimed.token, clock);
|
|
67
|
+
current = { id: claimed.id, token: claimed.token, box };
|
|
68
|
+
const sessionId = decodePairingSessionId(claimed.id);
|
|
69
|
+
set({ phase: "waiting" });
|
|
70
|
+
const reveal = await box.next(abort.signal, clock.now() + approvalMs);
|
|
71
|
+
if (!reveal)
|
|
72
|
+
throw new PairingEnded("timed_out");
|
|
73
|
+
if (reveal.type !== "reveal" || !reveal.pkN || !sessionId)
|
|
74
|
+
throw new PairingEnded("tampered");
|
|
75
|
+
try {
|
|
76
|
+
current.session = startApproverSession(key, {
|
|
77
|
+
sessionId,
|
|
78
|
+
commitment: base64UrlDecode(claimed.commit),
|
|
79
|
+
newDevicePublicKey: base64UrlDecode(reveal.pkN),
|
|
80
|
+
scannedPublicKey: scanned,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new PairingEnded("tampered");
|
|
85
|
+
}
|
|
86
|
+
const hello = openJson(current.session, await box.next(abort.signal, clock.now() + approvalMs), "hello");
|
|
87
|
+
const text = (v) => (typeof v === "string" ? v.slice(0, 100) : "");
|
|
88
|
+
device = { name: text(hello.name), app: text(hello.app), platform: text(hello.platform) };
|
|
89
|
+
const deadline = clock.now() + approvalMs;
|
|
90
|
+
set({ phase: "confirming", device, location: claimed.location, yourLocation: claimed.yourLocation, emoji: current.session.emoji, deadline });
|
|
91
|
+
// N sends nothing more until A approves, so a message or a close here means something's off.
|
|
92
|
+
confirming = new AbortController();
|
|
93
|
+
abort.signal.addEventListener("abort", () => confirming?.abort(), { once: true });
|
|
94
|
+
try {
|
|
95
|
+
if (await box.next(confirming.signal, deadline))
|
|
96
|
+
throw new PairingEnded("tampered");
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
if (confirming.signal.aborted)
|
|
100
|
+
return;
|
|
101
|
+
// N's clock started a moment before ours, so its timeout close can land just before ours fires.
|
|
102
|
+
if (reasonFor(e) !== "cancelled_by_other" || clock.now() < deadline - 5000)
|
|
103
|
+
throw e;
|
|
104
|
+
}
|
|
105
|
+
throw new PairingEnded("timed_out");
|
|
106
|
+
}
|
|
107
|
+
async function approveExtension(issuer, userCode, accessToken) {
|
|
108
|
+
const token = await accessToken();
|
|
109
|
+
const res = await fetch(`${issuer.replace(/\/+$/, "")}/gryt-pairing/approve`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
112
|
+
body: JSON.stringify({ user_code: userCode, binding: current.session.keycloakNonce }),
|
|
113
|
+
}).catch(() => {
|
|
114
|
+
throw new PairingEnded("approve:network");
|
|
115
|
+
});
|
|
116
|
+
if (res.status === 204)
|
|
117
|
+
return true;
|
|
118
|
+
if (res.status === 404)
|
|
119
|
+
return false;
|
|
120
|
+
const body = (await res.json().catch(() => null));
|
|
121
|
+
const code = typeof body?.error === "string" ? body.error : String(res.status);
|
|
122
|
+
throw new PairingEnded(APPROVE_ERROR_REASONS[code] ?? `approve:${code}`);
|
|
123
|
+
}
|
|
124
|
+
async function addEverywhere(ready) {
|
|
125
|
+
const added = {};
|
|
126
|
+
for (const { host, deviceId } of ready) {
|
|
127
|
+
const driver = devices(host);
|
|
128
|
+
if (!driver)
|
|
129
|
+
continue;
|
|
130
|
+
for (let tries = 0;; tries++) {
|
|
131
|
+
try {
|
|
132
|
+
added[host] = await driver.addOwnDevice(deviceId, {
|
|
133
|
+
onProgress: ({ done, total }) => set({ phase: "adding", device: device, host, done, total }),
|
|
134
|
+
});
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
// The server lists N's device once its KeyPackages are in, which can trail N's "ready".
|
|
139
|
+
if (e.code !== "not_own_device" || tries >= NOT_LISTED_RETRIES)
|
|
140
|
+
throw e;
|
|
141
|
+
await clock.sleep(2000, abort.signal);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return added;
|
|
146
|
+
}
|
|
147
|
+
async function approve(envelope, accessToken) {
|
|
148
|
+
const { id, token, box, session } = current;
|
|
149
|
+
set({ phase: envelope.account ? "signing_in" : "waiting_ready", device: device });
|
|
150
|
+
await relay.post(id, token, { type: "sealed", body: base64Url(session.seal(encodePairingEnvelope(envelope))) });
|
|
151
|
+
if (envelope.account && accessToken) {
|
|
152
|
+
const message = await box.next(abort.signal, clock.now() + approvalMs);
|
|
153
|
+
if (!message)
|
|
154
|
+
throw new PairingEnded("timed_out");
|
|
155
|
+
const userCode = String(openJson(session, message, "user_code").userCode ?? "");
|
|
156
|
+
if (!(await approveExtension(envelope.account.issuer, userCode, accessToken))) {
|
|
157
|
+
const url = `${envelope.account.issuer.replace(/\/+$/, "")}/device?user_code=${encodeURIComponent(userCode)}`;
|
|
158
|
+
set({ phase: "browser", device: device, url });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (state.phase === "signing_in")
|
|
162
|
+
set({ phase: "waiting_ready", device: device });
|
|
163
|
+
const ready = openJson(session, await box.next(abort.signal), "ready");
|
|
164
|
+
const list = Array.isArray(ready.devices) ? ready.devices : [];
|
|
165
|
+
const named = list.filter((d) => typeof d?.host === "string" && typeof d?.deviceId === "string");
|
|
166
|
+
const added = await addEverywhere(named);
|
|
167
|
+
set({ phase: "done", device: device, added });
|
|
168
|
+
session.close();
|
|
169
|
+
await closeQuietly();
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
get state() {
|
|
173
|
+
return state;
|
|
174
|
+
},
|
|
175
|
+
subscribe(listener) {
|
|
176
|
+
listeners.add(listener);
|
|
177
|
+
return () => void listeners.delete(listener);
|
|
178
|
+
},
|
|
179
|
+
claim(input) {
|
|
180
|
+
if (state.phase !== "idle")
|
|
181
|
+
throw new Error("This pairing has claimed a session already.");
|
|
182
|
+
claim(input).catch(fail);
|
|
183
|
+
},
|
|
184
|
+
approve(envelope, accessToken) {
|
|
185
|
+
if (state.phase !== "confirming")
|
|
186
|
+
throw new Error("Nothing to approve.");
|
|
187
|
+
if (envelope.account && !accessToken)
|
|
188
|
+
throw new Error("An account needs an access token to approve with.");
|
|
189
|
+
if (clock.now() > state.deadline)
|
|
190
|
+
return void end("timed_out");
|
|
191
|
+
confirming?.abort();
|
|
192
|
+
approve(envelope, accessToken).catch(fail);
|
|
193
|
+
},
|
|
194
|
+
deny: () => end("cancelled"),
|
|
195
|
+
mismatch: () => end("mismatch"),
|
|
196
|
+
cancel: () => end("cancelled"),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -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,87 @@
|
|
|
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 for anything
|
|
74
|
+
below that isn't broken out on its own (GRYT-1578; auth#46 has the full list). */
|
|
75
|
+
export type PairingEndReason = "cancelled" | "cancelled_by_other" | "mismatch" | "timed_out" | "expired" | "already_claimed" | "unknown_code" | "wrong_relay" | "newer_version" | "not_pairing" | "rate_limited" | "tampered" | "wrong_account" | "sign_in_failed" | "relay_error"
|
|
76
|
+
/** The approve endpoint says this user_code already got its one answer. */
|
|
77
|
+
| "code_used"
|
|
78
|
+
/** The approve endpoint says the user_code isn't valid any more: unknown, expired, or not pending. */
|
|
79
|
+
| "code_expired"
|
|
80
|
+
/** The approve endpoint refuses because the account has something pending, like verifying an email. */
|
|
81
|
+
| "required_actions"
|
|
82
|
+
/** The approve endpoint refuses A's access token as too old; refresh it and try again. */
|
|
83
|
+
| "stale_token"
|
|
84
|
+
/** The device grant itself said no: A denied it, or the approve endpoint's refusal denied the code. */
|
|
85
|
+
| "access_denied"
|
|
86
|
+
/** The device grant's user_code ran out before anyone answered it. */
|
|
87
|
+
| "expired_token" | `approve:${string}`;
|
|
@@ -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,209 @@
|
|
|
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
|
+
// RFC 8628's own two terminal errors, plus what auth#46 leaves behind when A's approve
|
|
120
|
+
// endpoint refuses the code: either way N's device grant sees it as denied.
|
|
121
|
+
if (polled.status === "denied")
|
|
122
|
+
throw new PairingEnded("access_denied");
|
|
123
|
+
if (polled.status === "expired")
|
|
124
|
+
throw new PairingEnded("expired_token");
|
|
125
|
+
if (polled.status === "slow_down")
|
|
126
|
+
interval += 5000;
|
|
127
|
+
// Anything else is "pending": keep polling.
|
|
128
|
+
}
|
|
129
|
+
throw new PairingEnded("sign_in_failed");
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
stop.abort();
|
|
133
|
+
abort.signal.removeEventListener("abort", onAbort);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function run() {
|
|
137
|
+
try {
|
|
138
|
+
let outcome = await attempt();
|
|
139
|
+
while (typeof outcome === "string")
|
|
140
|
+
outcome = await attempt(outcome);
|
|
141
|
+
const envelope = outcome;
|
|
142
|
+
const tokens = envelope.account ? await signIn(envelope.account, envelope.from) : null;
|
|
143
|
+
await storage.commit(envelope, tokens);
|
|
144
|
+
set({ phase: "joining", servers: envelope.servers, from: envelope.from });
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
if (!abort.signal.aborted)
|
|
148
|
+
await end(reasonFor(e));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function linked(from) {
|
|
152
|
+
try {
|
|
153
|
+
// History from A lands here in a later version. Until then, A closing is the finish.
|
|
154
|
+
while (await current.box.next(abort.signal))
|
|
155
|
+
;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
if (abort.signal.aborted)
|
|
159
|
+
return;
|
|
160
|
+
const reason = reasonFor(e);
|
|
161
|
+
if (reason !== "cancelled_by_other" && reason !== "expired")
|
|
162
|
+
return end(reason);
|
|
163
|
+
set({ phase: "done", from });
|
|
164
|
+
current?.session?.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
get state() {
|
|
169
|
+
return state;
|
|
170
|
+
},
|
|
171
|
+
subscribe(listener) {
|
|
172
|
+
listeners.add(listener);
|
|
173
|
+
return () => void listeners.delete(listener);
|
|
174
|
+
},
|
|
175
|
+
start() {
|
|
176
|
+
if (state.phase !== "idle")
|
|
177
|
+
throw new Error("This pairing has started already.");
|
|
178
|
+
void run();
|
|
179
|
+
},
|
|
180
|
+
async ready(devices) {
|
|
181
|
+
if (state.phase !== "joining" || !current?.session)
|
|
182
|
+
throw new Error("Not waiting to join.");
|
|
183
|
+
const { from } = state;
|
|
184
|
+
const body = sealJson(current.session, { t: "ready", devices: devices.map(({ host, deviceId }) => ({ host, deviceId })) });
|
|
185
|
+
await relay.post(current.id, current.token, { type: "sealed", body });
|
|
186
|
+
set({ phase: "linked", from });
|
|
187
|
+
void linked(from);
|
|
188
|
+
},
|
|
189
|
+
cancel: () => end("cancelled"),
|
|
190
|
+
mismatch: () => end("mismatch"),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Straight from the token endpoint over TLS, so the claims are read, not the signature (OIDC Core 3.1.3.7). */
|
|
194
|
+
function checkIdToken(tokens, account, nonce) {
|
|
195
|
+
let claims;
|
|
196
|
+
try {
|
|
197
|
+
claims = JSON.parse(new TextDecoder().decode(base64UrlDecode(tokens.idToken.split(".")[1] ?? "")));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
throw new PairingEnded("sign_in_failed");
|
|
201
|
+
}
|
|
202
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
203
|
+
if (claims.nonce !== nonce || claims.iss !== account.issuer || !aud.includes(account.clientId)) {
|
|
204
|
+
throw new PairingEnded("tampered");
|
|
205
|
+
}
|
|
206
|
+
if (claims.sub !== account.sub)
|
|
207
|
+
throw new PairingEnded("wrong_account");
|
|
208
|
+
return tokens;
|
|
209
|
+
}
|