@byokit/pair 0.0.0-stage → 0.9.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/CHANGELOG.md +72 -0
- package/LICENSE +202 -0
- package/README.md +525 -2
- package/SECURITY.md +205 -0
- package/dist/channel.d.ts +76 -0
- package/dist/channel.js +182 -0
- package/dist/check.d.ts +34 -0
- package/dist/check.js +72 -0
- package/dist/confirm-words.d.ts +1 -0
- package/dist/confirm-words.js +26 -0
- package/dist/device.d.ts +177 -0
- package/dist/device.js +592 -0
- package/dist/host.d.ts +214 -0
- package/dist/host.js +866 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/migrate.d.ts +14 -0
- package/dist/migrate.js +62 -0
- package/dist/node.d.ts +17 -0
- package/dist/node.js +210 -0
- package/dist/pairing.d.ts +71 -0
- package/dist/pairing.js +431 -0
- package/dist/stores.d.ts +30 -0
- package/dist/stores.js +184 -0
- package/dist/stream.d.ts +73 -0
- package/dist/stream.js +235 -0
- package/package.json +42 -4
package/dist/host.js
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
// The host: the computer that owns the credentials. It pairs devices (scanned or typed code, then a person says yes),
|
|
2
|
+
// keeps one durable grant per device, answers their requests, and removes them. It never listens on anything itself:
|
|
3
|
+
// the app hands it WebSockets (`accept`), or one relay socket that carries many devices (`relay`).
|
|
4
|
+
import { Handshake, b64, b64url, firstFrame, hostId, messageBytes, random, unb64url } from "./channel.js";
|
|
5
|
+
import { bindCode, cleanName, codeKey, encodeCompactOffer, newCode, normalizeCode, offerText } from "./pairing.js";
|
|
6
|
+
import { PublicLinkError } from "./device.js";
|
|
7
|
+
import { LINK_PROBE, LINK_PROBE_OK } from "./check.js";
|
|
8
|
+
import { MAX_STREAMS, Streams } from "./stream.js";
|
|
9
|
+
const HANDSHAKE_MS = 15_000;
|
|
10
|
+
const MAX_TRIES = 5; // wrong codes before every open code is withdrawn
|
|
11
|
+
const MAX_ANSWERS = 1000;
|
|
12
|
+
const MAX_TIMER = 2 ** 31 - 1; // setTimeout's limit (about 24 days)
|
|
13
|
+
/** What a 0.3 host understands beyond 0.1, told to devices in `ready`. */
|
|
14
|
+
export const FEATURES = ['ping', 'unpair', 'rekey'];
|
|
15
|
+
class PairExpired extends Error {
|
|
16
|
+
}
|
|
17
|
+
const later = (ms, fn) => { const t = setTimeout(fn, ms); t.unref?.(); return t; };
|
|
18
|
+
export class Host {
|
|
19
|
+
keys;
|
|
20
|
+
/** This host's address on a relay. */
|
|
21
|
+
id;
|
|
22
|
+
opts;
|
|
23
|
+
grants;
|
|
24
|
+
store;
|
|
25
|
+
tickets = new Map();
|
|
26
|
+
codes = new Map();
|
|
27
|
+
tries = 0;
|
|
28
|
+
live = new Map();
|
|
29
|
+
expiryTimers = new Map();
|
|
30
|
+
answered = new Map();
|
|
31
|
+
answerWork = new Map();
|
|
32
|
+
changes = Promise.resolve();
|
|
33
|
+
unsubscribe;
|
|
34
|
+
stopped = false;
|
|
35
|
+
recent = new Map(); // handshake times, per peer and in all ('')
|
|
36
|
+
constructor(opts, grants) {
|
|
37
|
+
this.opts = opts;
|
|
38
|
+
this.keys = opts.keys;
|
|
39
|
+
this.id = hostId(opts.keys.publicKey);
|
|
40
|
+
this.store = opts.grants;
|
|
41
|
+
this.grants = grants;
|
|
42
|
+
}
|
|
43
|
+
static async open(opts) {
|
|
44
|
+
const host = new Host(opts, [...((await opts.grants?.load()) ?? [])]);
|
|
45
|
+
if (opts.grants?.subscribe) {
|
|
46
|
+
host.unsubscribe = opts.grants.subscribe(() => { void host.reload().catch((e) => host.report(e)); });
|
|
47
|
+
try {
|
|
48
|
+
await host.reload();
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
host.close();
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return host;
|
|
56
|
+
}
|
|
57
|
+
now() { return this.opts.now?.() ?? Date.now(); }
|
|
58
|
+
get pairMs() { return Math.min(this.opts.pairMs ?? 300_000, 300_000); }
|
|
59
|
+
ended(g) { return g.expires !== undefined && g.expires <= this.now(); }
|
|
60
|
+
currentGrant(grants, id, key) {
|
|
61
|
+
const g = grants.find((x) => x.id === id);
|
|
62
|
+
return g && !this.ended(g) && (g.key === key || g.nextKey === key) ? g : undefined;
|
|
63
|
+
}
|
|
64
|
+
admitGrant(g, key) {
|
|
65
|
+
const current = this.grants.find((x) => x.id === g.id);
|
|
66
|
+
if (!current || current.key !== g.key || current.role !== g.role)
|
|
67
|
+
return { error: 'removed' };
|
|
68
|
+
if (this.ended(current))
|
|
69
|
+
return { error: 'ended' };
|
|
70
|
+
if (!this.currentGrant(this.grants, g.id, key))
|
|
71
|
+
return { error: 'removed' };
|
|
72
|
+
return { grant: current };
|
|
73
|
+
}
|
|
74
|
+
async change(fn) {
|
|
75
|
+
const next = this.changes.then(fn);
|
|
76
|
+
this.changes = next.then(() => { }, () => { });
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
async save(grants) { await this.store?.save(grants.map((g) => ({ ...g }))); }
|
|
80
|
+
report(error) { try {
|
|
81
|
+
(this.opts.onError ?? console.error)(error);
|
|
82
|
+
}
|
|
83
|
+
catch { } }
|
|
84
|
+
answerTask(device, action) {
|
|
85
|
+
const next = (this.answerWork.get(device) ?? Promise.resolve()).then(action);
|
|
86
|
+
const tail = next.then(() => { }, () => { });
|
|
87
|
+
this.answerWork.set(device, tail);
|
|
88
|
+
void tail.then(() => { if (this.answerWork.get(device) === tail)
|
|
89
|
+
this.answerWork.delete(device); });
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
async drop(device, keys) {
|
|
93
|
+
if (!keys)
|
|
94
|
+
this.answered.delete(device);
|
|
95
|
+
if (this.opts.answers && (!keys || keys.length))
|
|
96
|
+
await this.answerTask(device, () => this.opts.answers.drop(device, keys));
|
|
97
|
+
}
|
|
98
|
+
presence(g, online) {
|
|
99
|
+
try {
|
|
100
|
+
this.opts.onConnection?.({ ...g }, online);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
this.report(e);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
addLive(conn, entry) {
|
|
107
|
+
const online = [...this.live.values()].some((s) => s.dev.id === entry.dev.id);
|
|
108
|
+
this.live.set(conn, entry);
|
|
109
|
+
if (!online)
|
|
110
|
+
this.presence(entry.dev, true);
|
|
111
|
+
}
|
|
112
|
+
removeLive(conn) {
|
|
113
|
+
const entry = this.live.get(conn);
|
|
114
|
+
if (!entry)
|
|
115
|
+
return;
|
|
116
|
+
this.live.delete(conn);
|
|
117
|
+
clearTimeout(this.expiryTimers.get(conn));
|
|
118
|
+
this.expiryTimers.delete(conn);
|
|
119
|
+
if (![...this.live.values()].some((s) => s.dev.id === entry.dev.id))
|
|
120
|
+
this.presence(entry.dev, false);
|
|
121
|
+
}
|
|
122
|
+
watchExpiry(conn) {
|
|
123
|
+
clearTimeout(this.expiryTimers.get(conn));
|
|
124
|
+
this.expiryTimers.delete(conn);
|
|
125
|
+
const g = this.live.get(conn)?.dev;
|
|
126
|
+
if (!g || g.expires === undefined)
|
|
127
|
+
return;
|
|
128
|
+
if (this.ended(g))
|
|
129
|
+
return void this.revoke(g.id, 'ended').catch((e) => this.report(e));
|
|
130
|
+
this.expiryTimers.set(conn, later(Math.min(g.expires - this.now(), MAX_TIMER), () => this.watchExpiry(conn)));
|
|
131
|
+
}
|
|
132
|
+
endLive(id, why, keep) {
|
|
133
|
+
for (const [conn, s] of this.live)
|
|
134
|
+
if (s.dev.id === id) {
|
|
135
|
+
this.removeLive(conn);
|
|
136
|
+
s.streams.closeAll('removed');
|
|
137
|
+
if (conn === keep)
|
|
138
|
+
continue;
|
|
139
|
+
this.sealed(conn, s.ch, { t: 'revoked', why });
|
|
140
|
+
later(1000, () => conn.close(4401, 'removed'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Every grant change goes through here, one at a time: store first, then memory, then the live sockets. */
|
|
144
|
+
transition(next, why, persist = true, keep) {
|
|
145
|
+
return this.change(async () => {
|
|
146
|
+
const updated = await next(this.grants);
|
|
147
|
+
if (!updated)
|
|
148
|
+
return null;
|
|
149
|
+
if (persist)
|
|
150
|
+
await this.save(updated);
|
|
151
|
+
const previous = this.grants;
|
|
152
|
+
this.grants = updated;
|
|
153
|
+
const cleanup = [];
|
|
154
|
+
for (const old of previous) {
|
|
155
|
+
const current = updated.find((g) => g.id === old.id);
|
|
156
|
+
if (current && !this.ended(current) && current.key === old.key && current.role === old.role) {
|
|
157
|
+
for (const [conn, s] of this.live)
|
|
158
|
+
if (s.dev.id === old.id) {
|
|
159
|
+
this.live.set(conn, { ...s, dev: current });
|
|
160
|
+
if (current.expires !== old.expires)
|
|
161
|
+
this.watchExpiry(conn);
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (!current)
|
|
166
|
+
void this.drop(old.id).catch((e) => this.report(e));
|
|
167
|
+
if (why)
|
|
168
|
+
this.endLive(old.id, current && this.ended(current) ? 'ended' : why, keep);
|
|
169
|
+
else
|
|
170
|
+
for (const [conn, s] of this.live)
|
|
171
|
+
if (s.dev.id === old.id) {
|
|
172
|
+
this.removeLive(conn);
|
|
173
|
+
s.streams.closeAll('unreachable');
|
|
174
|
+
try {
|
|
175
|
+
conn.close(1001, 'grant changed');
|
|
176
|
+
}
|
|
177
|
+
catch { }
|
|
178
|
+
}
|
|
179
|
+
// Invoke synchronously after invalidation; await only after every affected grant has lost access.
|
|
180
|
+
try {
|
|
181
|
+
cleanup.push(Promise.resolve(this.opts.onGrantRemoved?.({ ...old }, !current ? (why === 'ended' ? 'expired' : 'revoked') : this.ended(current) ? 'expired' : 'changed')));
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
cleanup.push(Promise.reject(error));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
await Promise.all(cleanup);
|
|
188
|
+
return updated;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/** Re-read the durable authority without writing it back. Removed/changed grants close their sockets.
|
|
192
|
+
* A failed read leaves memory untouched and rejects; subscribed failures also reach onError. */
|
|
193
|
+
async reload() {
|
|
194
|
+
if (!this.store || this.stopped)
|
|
195
|
+
return;
|
|
196
|
+
await this.transition(async () => {
|
|
197
|
+
const grants = await this.store.load();
|
|
198
|
+
return this.stopped ? null : grants.map((g) => {
|
|
199
|
+
const current = this.grants.find((old) => old.id === g.id);
|
|
200
|
+
return current && JSON.stringify(current) === JSON.stringify(g) ? current : { ...g };
|
|
201
|
+
});
|
|
202
|
+
}, 'revoked', false);
|
|
203
|
+
}
|
|
204
|
+
pending(o) {
|
|
205
|
+
this.checkLifetime(o.lifetime);
|
|
206
|
+
return { role: o.role, expires: this.now() + this.pairMs,
|
|
207
|
+
...(o.kind === undefined ? {} : { kind: String(o.kind) }), ...(o.lifetime === undefined ? {} : { lifetime: o.lifetime }), ...(o.meta === undefined ? {} : { meta: o.meta }) };
|
|
208
|
+
}
|
|
209
|
+
/** A QR's text for one device. `urls` are where a device can reach this host (direct `ws://`, or a relay's
|
|
210
|
+
* `wss://…/link/v1/<id>`); `base` makes it a link a browser can open. Single use, gone after `pairMs`. */
|
|
211
|
+
offer(o) {
|
|
212
|
+
const p = this.pending(o);
|
|
213
|
+
const ticket = b64url(random(16));
|
|
214
|
+
this.tickets.set(ticket, p);
|
|
215
|
+
this.tries = 0;
|
|
216
|
+
const offer = { v: 1, host: b64url(this.keys.publicKey), name: cleanName(this.opts.name, 'your computer'), urls: o.urls, ticket,
|
|
217
|
+
expires: p.expires, role: p.role, ...(p.lifetime === undefined ? {} : { lifetime: p.lifetime }) };
|
|
218
|
+
return { text: offerText(offer, o.base), expires: p.expires };
|
|
219
|
+
}
|
|
220
|
+
/** A QR-sized offer: the same secret a typed code carries (same single use, life, words and approval, through
|
|
221
|
+
* one code entry), plus the addresses to try, packed binary. No browser-link form: links keep `offer`. */
|
|
222
|
+
compactOffer(o) {
|
|
223
|
+
const p = this.pending(o);
|
|
224
|
+
const secret = normalizeCode(newCode());
|
|
225
|
+
const text = encodeCompactOffer({ code: secret, urls: o.urls, expires: p.expires,
|
|
226
|
+
name: cleanName(this.opts.name, 'your computer'), role: p.role,
|
|
227
|
+
...(p.lifetime === undefined ? {} : { lifetime: p.lifetime }) }); // throws before anything is stored
|
|
228
|
+
this.codes.set(secret, p);
|
|
229
|
+
this.tries = 0;
|
|
230
|
+
return { text, code: secret.replace(/(.{4})(?=.)/g, '$1-'), expires: p.expires };
|
|
231
|
+
}
|
|
232
|
+
/** A code a person types on the device instead of scanning. Single use, gone after `pairMs`. */
|
|
233
|
+
code(o) {
|
|
234
|
+
const code = newCode();
|
|
235
|
+
const p = this.pending(o);
|
|
236
|
+
this.codes.set(normalizeCode(code), p);
|
|
237
|
+
this.tries = 0;
|
|
238
|
+
return { code, expires: p.expires };
|
|
239
|
+
}
|
|
240
|
+
/** A 57-character typed offer that pins this machine's key. Use the whole code with `pairWithCode`;
|
|
241
|
+
* addresses come separately (e.g. relay `findHost`). Same approval, single use and expiry as `code`. */
|
|
242
|
+
shortCode(o) {
|
|
243
|
+
const { code, expires } = this.code(o);
|
|
244
|
+
return { code: bindCode(code, this.keys.publicKey), expires };
|
|
245
|
+
}
|
|
246
|
+
/** Withdraws every open QR and code. */
|
|
247
|
+
stopPairing() { this.tickets.clear(); this.codes.clear(); }
|
|
248
|
+
devices() {
|
|
249
|
+
const online = new Set([...this.live.values()].map((s) => s.dev.id));
|
|
250
|
+
return this.grants.map((g) => ({ ...g, online: online.has(g.id) }));
|
|
251
|
+
}
|
|
252
|
+
/** Adds a device this app already trusts some other way: for moving devices paired under an older protocol onto
|
|
253
|
+
* this one without pairing again. Only call it with a key that arrived over an authenticated channel. */
|
|
254
|
+
async enrol(g) {
|
|
255
|
+
const added = await this.grant(b64url(g.key), cleanName(g.name, 'Device'), g);
|
|
256
|
+
if (!added)
|
|
257
|
+
throw new Error('Your computer has all the devices it allows. Remove one there first.');
|
|
258
|
+
return added;
|
|
259
|
+
}
|
|
260
|
+
async setMeta(id, meta) {
|
|
261
|
+
const updated = await this.transition((grants) => grants.some((g) => g.id === id) ? grants.map((g) => g.id === id ? { ...g, meta } : g) : null);
|
|
262
|
+
if (!updated)
|
|
263
|
+
throw new Error('Device not found.');
|
|
264
|
+
}
|
|
265
|
+
/** Removes a device: its grant goes, its open connections close, and its key is refused from now on. */
|
|
266
|
+
async revoke(id, why = 'revoked') {
|
|
267
|
+
if (why === 'ended')
|
|
268
|
+
this.endLive(id, why);
|
|
269
|
+
await this.transition((grants) => grants.some((g) => g.id === id) ? grants.filter((g) => g.id !== id) : null, why);
|
|
270
|
+
}
|
|
271
|
+
/** An event for every connected device (or those `to` picks). */
|
|
272
|
+
broadcast(e, to = () => true) {
|
|
273
|
+
for (const [conn, s] of this.live)
|
|
274
|
+
if (to(s.dev) && !this.ended(s.dev))
|
|
275
|
+
this.sealed(conn, s.ch, { t: 'event', e });
|
|
276
|
+
}
|
|
277
|
+
/** One WebSocket, straight from a device. `peer` (e.g. its IP address) lets the host slow down one noisy source. */
|
|
278
|
+
accept(ws, o = {}) {
|
|
279
|
+
const h = this.connection({ send: (t) => ws.send(t), binary: (b) => ws.send(b), close: (c, r) => ws.close(c, r) }, o.peer);
|
|
280
|
+
ws.addEventListener('message', (e) => {
|
|
281
|
+
const d = e.data;
|
|
282
|
+
if (typeof d === 'string')
|
|
283
|
+
return h.message(d);
|
|
284
|
+
if (d instanceof ArrayBuffer)
|
|
285
|
+
return h.message(new Uint8Array(d));
|
|
286
|
+
if (ArrayBuffer.isView(d))
|
|
287
|
+
return h.message(new Uint8Array(d.buffer, d.byteOffset, d.byteLength)); // ws's Buffer
|
|
288
|
+
ws.close(4400, 'unexpected frame');
|
|
289
|
+
});
|
|
290
|
+
ws.addEventListener('close', () => h.closed());
|
|
291
|
+
}
|
|
292
|
+
/** One WebSocket to a relay that carries many devices' connections. Each relay message is `{c, f}` (connection id,
|
|
293
|
+
* one frame, exactly as a device sent it or should get it) or `{c, end: code}`. The relay sees only that. */
|
|
294
|
+
relay(ws) {
|
|
295
|
+
const conns = new Map();
|
|
296
|
+
ws.addEventListener('message', (e) => {
|
|
297
|
+
let m;
|
|
298
|
+
try {
|
|
299
|
+
m = JSON.parse(String(e.data));
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (typeof m?.c !== 'string' || m.c.length > 64)
|
|
305
|
+
return;
|
|
306
|
+
let h = conns.get(m.c);
|
|
307
|
+
if (m.end !== undefined) {
|
|
308
|
+
h?.closed();
|
|
309
|
+
conns.delete(m.c);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (typeof m.f !== 'string')
|
|
313
|
+
return;
|
|
314
|
+
if (!h) {
|
|
315
|
+
const c = m.c;
|
|
316
|
+
h = this.connection({
|
|
317
|
+
send: (f) => ws.send(JSON.stringify({ c, f })),
|
|
318
|
+
close: (code) => { if (conns.delete(c)) {
|
|
319
|
+
ws.send(JSON.stringify({ c, end: code }));
|
|
320
|
+
h.closed();
|
|
321
|
+
} },
|
|
322
|
+
});
|
|
323
|
+
conns.set(c, h);
|
|
324
|
+
}
|
|
325
|
+
h.message(m.f);
|
|
326
|
+
});
|
|
327
|
+
ws.addEventListener('close', () => { for (const h of conns.values())
|
|
328
|
+
h.closed(); conns.clear(); });
|
|
329
|
+
}
|
|
330
|
+
close() {
|
|
331
|
+
this.stopped = true;
|
|
332
|
+
this.unsubscribe?.();
|
|
333
|
+
this.unsubscribe = undefined;
|
|
334
|
+
for (const [conn, { streams }] of this.live) {
|
|
335
|
+
streams.closeAll('unreachable');
|
|
336
|
+
this.removeLive(conn);
|
|
337
|
+
conn.close(1001, 'host closing');
|
|
338
|
+
}
|
|
339
|
+
this.live.clear();
|
|
340
|
+
}
|
|
341
|
+
sealed(conn, ch, msg) { this.out(conn, () => ch.seal(msg)); }
|
|
342
|
+
out(conn, frames) {
|
|
343
|
+
try {
|
|
344
|
+
for (const f of frames())
|
|
345
|
+
conn.send(f);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
conn.close(4400, 'send failed');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/** What the device hears when the app's stream code throws: a `PublicLinkError`'s message, else `failed`. */
|
|
352
|
+
reason(e) {
|
|
353
|
+
if (e instanceof PublicLinkError)
|
|
354
|
+
return e.message.slice(0, 200);
|
|
355
|
+
this.report(e);
|
|
356
|
+
return 'failed';
|
|
357
|
+
}
|
|
358
|
+
take(map, key) {
|
|
359
|
+
const p = map.get(key);
|
|
360
|
+
map.delete(key); // single use, even when it has run out
|
|
361
|
+
return p && p.expires >= this.now() ? p : undefined;
|
|
362
|
+
}
|
|
363
|
+
wrong() {
|
|
364
|
+
if (++this.tries >= MAX_TRIES)
|
|
365
|
+
this.stopPairing();
|
|
366
|
+
}
|
|
367
|
+
/** A sliding one-minute window, in all and per peer; checked before any key agreement is spent on a stranger. */
|
|
368
|
+
admit(peer) {
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
const limits = [['', this.opts.handshakes?.perMinute ?? 300]];
|
|
371
|
+
if (peer)
|
|
372
|
+
limits.push([`peer:${peer}`, this.opts.handshakes?.perPeer ?? 30]);
|
|
373
|
+
for (const [k, times] of this.recent) {
|
|
374
|
+
const fresh = times.filter((t) => t > now - 60_000);
|
|
375
|
+
if (fresh.length)
|
|
376
|
+
this.recent.set(k, fresh);
|
|
377
|
+
else
|
|
378
|
+
this.recent.delete(k);
|
|
379
|
+
}
|
|
380
|
+
const windows = limits.map(([k, max]) => {
|
|
381
|
+
const w = this.recent.get(k) ?? [];
|
|
382
|
+
this.recent.set(k, w);
|
|
383
|
+
return [w, max];
|
|
384
|
+
});
|
|
385
|
+
if (windows.some(([w, max]) => w.length >= max))
|
|
386
|
+
return false;
|
|
387
|
+
for (const [w] of windows)
|
|
388
|
+
w.push(now);
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
checkLifetime(lifetime) {
|
|
392
|
+
if (lifetime !== undefined && (!Number.isSafeInteger(lifetime) || lifetime <= 0))
|
|
393
|
+
throw new Error('A lifetime must be a positive whole number of milliseconds.');
|
|
394
|
+
}
|
|
395
|
+
async grant(key, name, t, pairExpires) {
|
|
396
|
+
this.checkLifetime(t.lifetime);
|
|
397
|
+
let added = null;
|
|
398
|
+
await this.transition((grants) => {
|
|
399
|
+
if (pairExpires !== undefined && pairExpires < this.now())
|
|
400
|
+
throw new PairExpired();
|
|
401
|
+
const others = grants.filter((g) => g.key !== key && g.nextKey !== key); // pairing the same device again replaces its grant
|
|
402
|
+
if (this.opts.maxDevices && others.length >= this.opts.maxDevices)
|
|
403
|
+
return null;
|
|
404
|
+
const cap = t.kind === undefined ? undefined : this.opts.caps?.[t.kind];
|
|
405
|
+
if (cap !== undefined && others.filter((g) => g.kind === t.kind).length >= cap)
|
|
406
|
+
return null;
|
|
407
|
+
const now = this.now();
|
|
408
|
+
added = { id: b64url(random(9)), key, name, role: t.role, created: now, lastSeen: now,
|
|
409
|
+
...(t.kind === undefined ? {} : { kind: t.kind }), ...(t.lifetime === undefined ? {} : { expires: now + t.lifetime }), ...(t.meta === undefined ? {} : { meta: t.meta }) };
|
|
410
|
+
return [...others, added];
|
|
411
|
+
});
|
|
412
|
+
// A slow store can commit after the code ran out: the five-minute life holds through the save too.
|
|
413
|
+
if (added && pairExpires !== undefined && pairExpires < this.now()) {
|
|
414
|
+
await this.revoke(added.id);
|
|
415
|
+
throw new PairExpired();
|
|
416
|
+
}
|
|
417
|
+
return added;
|
|
418
|
+
}
|
|
419
|
+
connection(conn, peer) {
|
|
420
|
+
let hs = null;
|
|
421
|
+
let ch = null;
|
|
422
|
+
let dev = null;
|
|
423
|
+
let code;
|
|
424
|
+
let busy = false; // waiting for the person at the host
|
|
425
|
+
let gone = false;
|
|
426
|
+
const timer = later(HANDSHAKE_MS, () => { if (!dev && !busy)
|
|
427
|
+
end(4408, 'too slow'); });
|
|
428
|
+
const end = (c, why) => { if (!gone)
|
|
429
|
+
conn.close(c, why); };
|
|
430
|
+
const refuse = (why) => { this.sealed(conn, ch, { t: 'refused', why }); later(1000, () => end(4401, why)); };
|
|
431
|
+
const pair = async (key, name, p, how, kept) => {
|
|
432
|
+
const k = b64url(key);
|
|
433
|
+
busy = true;
|
|
434
|
+
clearTimeout(timer);
|
|
435
|
+
const req = { name: cleanName(name, 'Device'), role: p.role, words: hs.words, how,
|
|
436
|
+
...(p.kind === undefined ? {} : { kind: p.kind }), ...(p.lifetime ? { lifetime: p.lifetime } : {}), ...(p.meta === undefined ? {} : { meta: p.meta }) };
|
|
437
|
+
const yes = await Promise.race([
|
|
438
|
+
Promise.resolve().then(() => this.opts.confirm(req)).catch(() => false),
|
|
439
|
+
new Promise((r) => later(this.pairMs, () => r(false))),
|
|
440
|
+
]);
|
|
441
|
+
// A device that said it kept a pending grant resumes from it after being killed while the person decided, so
|
|
442
|
+
// its yes outlives the socket; any other gone device could never use the grant, so it gets none.
|
|
443
|
+
if (gone && kept !== 1)
|
|
444
|
+
return;
|
|
445
|
+
if (!yes) {
|
|
446
|
+
if (!gone)
|
|
447
|
+
refuse('declined');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
const added = await this.grant(k, req.name, p, p.expires);
|
|
452
|
+
if (gone)
|
|
453
|
+
return;
|
|
454
|
+
if (added)
|
|
455
|
+
await attach(added, true);
|
|
456
|
+
else
|
|
457
|
+
refuse('full');
|
|
458
|
+
}
|
|
459
|
+
catch (e) {
|
|
460
|
+
if (!gone)
|
|
461
|
+
refuse(e instanceof PairExpired ? 'expired' : 'failed');
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
const attach = async (g, paired = false, auth) => {
|
|
465
|
+
if (gone)
|
|
466
|
+
return;
|
|
467
|
+
busy = true;
|
|
468
|
+
const key = b64url(hs.remoteKey);
|
|
469
|
+
const admit = () => {
|
|
470
|
+
const current = this.grants.find((x) => x.id === g.id);
|
|
471
|
+
if (!current)
|
|
472
|
+
return 'not-paired';
|
|
473
|
+
if (this.ended(current))
|
|
474
|
+
return 'ended';
|
|
475
|
+
return this.currentGrant(this.grants, g.id, key) ?? 'not-paired';
|
|
476
|
+
};
|
|
477
|
+
const reject = (why) => {
|
|
478
|
+
if (why === 'ended')
|
|
479
|
+
void this.revoke(g.id, 'ended').catch((e) => this.report(e));
|
|
480
|
+
refuse(why);
|
|
481
|
+
};
|
|
482
|
+
try {
|
|
483
|
+
if (!paired) {
|
|
484
|
+
// A device that rekeyed proves its new key by using it; from then on only that key works.
|
|
485
|
+
const updated = await this.transition((grants) => {
|
|
486
|
+
const current = this.currentGrant(grants, g.id, key);
|
|
487
|
+
if (!current)
|
|
488
|
+
return null;
|
|
489
|
+
return grants.map((x) => {
|
|
490
|
+
if (x.id !== g.id)
|
|
491
|
+
return x;
|
|
492
|
+
const { nextKey, ...rest } = x;
|
|
493
|
+
return nextKey === key ? { ...rest, key, lastSeen: this.now() } : { ...x, lastSeen: this.now() };
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
if (!updated)
|
|
497
|
+
return refuse('not-paired');
|
|
498
|
+
}
|
|
499
|
+
if (gone || this.stopped)
|
|
500
|
+
return end(1001, 'host closing');
|
|
501
|
+
const before = admit();
|
|
502
|
+
if (typeof before === 'string')
|
|
503
|
+
return reject(before);
|
|
504
|
+
if (auth?.fresh === true) {
|
|
505
|
+
try {
|
|
506
|
+
await this.drop(before.id);
|
|
507
|
+
}
|
|
508
|
+
catch (e) {
|
|
509
|
+
this.report(e);
|
|
510
|
+
return refuse('failed');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
else if (auth)
|
|
514
|
+
this.acknowledge(before.id, auth.session, auth.ack);
|
|
515
|
+
if (gone)
|
|
516
|
+
return;
|
|
517
|
+
const current = admit();
|
|
518
|
+
if (typeof current === 'string')
|
|
519
|
+
return reject(current);
|
|
520
|
+
dev = g = current;
|
|
521
|
+
busy = false;
|
|
522
|
+
clearTimeout(timer);
|
|
523
|
+
const c = ch;
|
|
524
|
+
const data = conn.binary
|
|
525
|
+
? (s, d) => { try {
|
|
526
|
+
for (const f of c.sealData(s, d))
|
|
527
|
+
conn.binary(f);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
conn.close(4400, 'send failed');
|
|
531
|
+
} }
|
|
532
|
+
: (s, d) => this.out(conn, () => c.sealData(s, d).map(b64));
|
|
533
|
+
const streams = new Streams({ send: (m) => this.sealed(conn, c, m), data, reason: (e) => this.reason(e), report: (e) => this.report(e) });
|
|
534
|
+
const meta = this.opts.deviceMeta?.({ ...g });
|
|
535
|
+
this.addLive(conn, { dev, ch: c, streams });
|
|
536
|
+
this.sealed(conn, c, { t: 'ready', device: { id: g.id, name: g.name, role: g.role }, host: { name: this.opts.name }, features: FEATURES,
|
|
537
|
+
...(g.expires === undefined ? {} : { expires: g.expires }),
|
|
538
|
+
...(meta === undefined ? {} : { meta }),
|
|
539
|
+
...(this.opts.stream ? { streams: 1, ...(conn.binary ? { binary: 1 } : {}) } : {}) });
|
|
540
|
+
this.watchExpiry(conn);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
if (!gone)
|
|
544
|
+
refuse('failed');
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
const handshake = (text) => {
|
|
548
|
+
if (!hs) {
|
|
549
|
+
if (!this.admit(peer))
|
|
550
|
+
return end(4429, 'slow down');
|
|
551
|
+
const first = firstFrame(text);
|
|
552
|
+
if (first.mode === 'ik') {
|
|
553
|
+
hs = new Handshake('ik', false, this.keys);
|
|
554
|
+
let hello;
|
|
555
|
+
try {
|
|
556
|
+
hello = hs.read(first.body);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return end(4403, 'wrong host key');
|
|
560
|
+
} // dialled for another key
|
|
561
|
+
if (hello.v !== 1)
|
|
562
|
+
throw new Error('unknown version');
|
|
563
|
+
conn.send(hs.write());
|
|
564
|
+
ch = hs.channel();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
// A typed code: whichever open code finishes the handshake is the one this device holds.
|
|
568
|
+
for (const [c, p] of this.codes) {
|
|
569
|
+
const attempt = new Handshake('code', false, this.keys, { psk: codeKey(c) });
|
|
570
|
+
try {
|
|
571
|
+
attempt.read(first.body);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
this.codes.delete(c); // single use from the moment it is tried
|
|
577
|
+
if (p.expires < this.now())
|
|
578
|
+
break;
|
|
579
|
+
hs = attempt;
|
|
580
|
+
code = p;
|
|
581
|
+
conn.send(hs.write());
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
this.wrong();
|
|
585
|
+
return end(4401, 'wrong code');
|
|
586
|
+
}
|
|
587
|
+
const hello = hs.read(text); // the typed-code handshake's last message: the device's key and name
|
|
588
|
+
ch = hs.channel();
|
|
589
|
+
void pair(hs.remoteKey, hello.name, code, 'code', hello.pending);
|
|
590
|
+
};
|
|
591
|
+
const inner = (d, m) => {
|
|
592
|
+
if (m.t === 'req')
|
|
593
|
+
return void this.request(conn, d, m, b64url(hs.remoteKey));
|
|
594
|
+
if (m.t === 'ping')
|
|
595
|
+
return this.sealed(conn, ch, { t: 'pong', n: m.n });
|
|
596
|
+
if (m.t === 'unpair') { // the device forgets this computer, and asks it to forget the device too
|
|
597
|
+
return void this.transition((grants) => this.currentGrant(grants, d.id, b64url(hs.remoteKey)) ? grants.filter((g) => g.id !== d.id) : null, 'revoked', true, conn)
|
|
598
|
+
.then((ok) => { if (!ok)
|
|
599
|
+
return refuse('not-paired'); this.sealed(conn, ch, { t: 'unpaired' }); later(1000, () => end(1000, 'unpaired')); })
|
|
600
|
+
.catch((e) => {
|
|
601
|
+
this.report(e);
|
|
602
|
+
this.sealed(conn, ch, { t: 'unpair-failed' });
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
if (m.t === 'rekey') { // a fresh device key, valid alongside the old one until the device first uses it
|
|
606
|
+
let key;
|
|
607
|
+
try {
|
|
608
|
+
key = b64url(unb64url(String(m.key)));
|
|
609
|
+
if (unb64url(key).length !== 32)
|
|
610
|
+
throw new Error();
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
return end(4400, 'bad key');
|
|
614
|
+
}
|
|
615
|
+
return void this.transition((grants) => this.currentGrant(grants, d.id, b64url(hs.remoteKey)) ? grants.map((g) => g.id === d.id ? { ...g, nextKey: key } : g) : null)
|
|
616
|
+
.then((ok) => { if (ok)
|
|
617
|
+
this.sealed(conn, ch, { t: 'rekeyed' });
|
|
618
|
+
else
|
|
619
|
+
refuse('not-paired'); })
|
|
620
|
+
.catch((e) => { this.report(e); end(4400, 'failed'); });
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
return {
|
|
624
|
+
message: (text) => {
|
|
625
|
+
if (gone)
|
|
626
|
+
return;
|
|
627
|
+
try {
|
|
628
|
+
if (!ch) {
|
|
629
|
+
// A reachability probe (`check` in check.ts): answered read-only, before any handshake is spent. It
|
|
630
|
+
// touches no ticket, code, grant or counter, so a probed offer still pairs; the reply carries nothing
|
|
631
|
+
// but the fact a link host is here. Through a relay this rides the same bare frames as a handshake.
|
|
632
|
+
if (text === LINK_PROBE) {
|
|
633
|
+
conn.send(LINK_PROBE_OK);
|
|
634
|
+
return later(250, () => { if (!gone)
|
|
635
|
+
conn.close(1000, 'probe'); });
|
|
636
|
+
}
|
|
637
|
+
return typeof text === 'string' ? handshake(text) : end(4400, 'text frames only');
|
|
638
|
+
}
|
|
639
|
+
const m = ch.open(text); // throws unless this is the device's next authentic frame
|
|
640
|
+
if (m === undefined || busy)
|
|
641
|
+
return;
|
|
642
|
+
if (dev) {
|
|
643
|
+
const key = b64url(hs.remoteKey);
|
|
644
|
+
if (['open', 'data', 'credit', 'end', 'opened'].includes(m.t)) {
|
|
645
|
+
const admitted = this.admitGrant(dev, key);
|
|
646
|
+
if ('error' in admitted) {
|
|
647
|
+
if (admitted.error === 'ended')
|
|
648
|
+
void this.revoke(dev.id, 'ended').catch((e) => this.report(e));
|
|
649
|
+
else {
|
|
650
|
+
this.live.get(conn)?.streams.closeAll('removed');
|
|
651
|
+
this.removeLive(conn);
|
|
652
|
+
end(4401, 'not-paired');
|
|
653
|
+
}
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (m.t === 'open')
|
|
658
|
+
return void this.openStream(dev, this.live.get(conn).streams, m, key).catch(() => end(4400, 'bad stream message'));
|
|
659
|
+
if (this.live.get(conn)?.streams.message(m))
|
|
660
|
+
return;
|
|
661
|
+
return inner(dev, m);
|
|
662
|
+
}
|
|
663
|
+
if (m.t === 'auth') {
|
|
664
|
+
const k = b64url(hs.remoteKey);
|
|
665
|
+
const g = this.grants.find((x) => x.key === k || x.nextKey === k);
|
|
666
|
+
if (!g)
|
|
667
|
+
return refuse('not-paired');
|
|
668
|
+
if (this.ended(g)) {
|
|
669
|
+
void this.revoke(g.id, 'ended').catch((e) => this.report(e));
|
|
670
|
+
return refuse('ended');
|
|
671
|
+
}
|
|
672
|
+
if (!this.currentGrant(this.grants, g.id, k))
|
|
673
|
+
return refuse('not-paired');
|
|
674
|
+
return void attach(g, false, m);
|
|
675
|
+
}
|
|
676
|
+
if (m.t === 'pair' && hs.mode === 'ik') {
|
|
677
|
+
const p = typeof m.ticket === 'string' ? this.take(this.tickets, m.ticket) : undefined;
|
|
678
|
+
if (!p) {
|
|
679
|
+
this.wrong();
|
|
680
|
+
return refuse('expired');
|
|
681
|
+
}
|
|
682
|
+
return void pair(hs.remoteKey, m.name, p, 'scan', m.pending);
|
|
683
|
+
}
|
|
684
|
+
end(4400, 'unexpected');
|
|
685
|
+
}
|
|
686
|
+
catch {
|
|
687
|
+
end(4400, 'bad frame'); // a bad frame ends the socket; the device reconnects with a fresh handshake
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
closed: () => { gone = true; clearTimeout(timer); this.live.get(conn)?.streams.closeAll('unreachable'); this.removeLive(conn); },
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
async openStream(dev, streams, m, authenticatedKey) {
|
|
694
|
+
const id = m.s, credit = m.credit;
|
|
695
|
+
if (!Number.isInteger(id) || id < 1 || id > 2 ** 32 - 1 || streams.has(id) || !Number.isInteger(credit) || credit < 0)
|
|
696
|
+
throw new Error('bad stream message');
|
|
697
|
+
const s = streams.add(id, String(m.op ?? ''), m.args);
|
|
698
|
+
const req = { op: s.op, args: s.args };
|
|
699
|
+
if (!this.opts.stream)
|
|
700
|
+
return s.end('not-supported');
|
|
701
|
+
if (streams.size > MAX_STREAMS)
|
|
702
|
+
return s.end('busy');
|
|
703
|
+
const admitted = await this.admitRequest(req, dev, authenticatedKey);
|
|
704
|
+
if ('error' in admitted) {
|
|
705
|
+
if (admitted.error === 'ended')
|
|
706
|
+
void this.revoke(dev.id, 'ended').catch((e) => this.report(e));
|
|
707
|
+
return s.end(admitted.error);
|
|
708
|
+
}
|
|
709
|
+
if (!streams.has(id))
|
|
710
|
+
return;
|
|
711
|
+
s.accept(Math.min(credit, 2 ** 31));
|
|
712
|
+
try {
|
|
713
|
+
await this.opts.stream(s, req, admitted.grant);
|
|
714
|
+
}
|
|
715
|
+
catch (e) {
|
|
716
|
+
s.end(this.reason(e));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
acknowledge(device, session, ack) {
|
|
720
|
+
if (typeof session !== 'string' || !Number.isSafeInteger(ack) || ack < 0)
|
|
721
|
+
return;
|
|
722
|
+
const seen = this.answered.get(device);
|
|
723
|
+
const gone = [];
|
|
724
|
+
if (seen)
|
|
725
|
+
for (const [key, entry] of seen)
|
|
726
|
+
if (entry.session === session && entry.id <= ack) {
|
|
727
|
+
seen.delete(key);
|
|
728
|
+
gone.push(key);
|
|
729
|
+
}
|
|
730
|
+
void this.drop(device, gone).catch((e) => this.report(e));
|
|
731
|
+
}
|
|
732
|
+
async request(conn, dev, m, authenticatedKey) {
|
|
733
|
+
const id = m.id;
|
|
734
|
+
const answer = (r) => { const s = this.live.get(conn); if (s)
|
|
735
|
+
this.sealed(conn, s.ch, { t: 'res', id, ...r }); };
|
|
736
|
+
const g = this.grants.find((x) => x.id === dev.id);
|
|
737
|
+
if (!g || (!this.ended(g) && !this.currentGrant(this.grants, dev.id, authenticatedKey)))
|
|
738
|
+
return answer({ ok: false, error: 'removed' });
|
|
739
|
+
if (this.ended(g)) {
|
|
740
|
+
answer({ ok: false, error: 'ended' });
|
|
741
|
+
return void this.revoke(g.id, 'ended').catch((e) => this.report(e));
|
|
742
|
+
}
|
|
743
|
+
this.acknowledge(g.id, m.session, m.ack);
|
|
744
|
+
const seen = this.answered.get(g.id) ?? new Map();
|
|
745
|
+
this.answered.set(g.id, seen);
|
|
746
|
+
const key = typeof m.key === 'string' && m.key.length <= 80 ? m.key : '';
|
|
747
|
+
const session = typeof m.session === 'string' && m.session.length <= 80 ? m.session : '';
|
|
748
|
+
if (!key || !session || !Number.isSafeInteger(id) || id < 1)
|
|
749
|
+
return answer({ ok: false, error: 'failed' });
|
|
750
|
+
const req = { op: String(m.op ?? ''), args: m.args, key: `${g.id}:${key}` };
|
|
751
|
+
const args = JSON.stringify(req.args ?? null);
|
|
752
|
+
let entry = seen.get(key);
|
|
753
|
+
if (entry && (entry.op !== req.op || entry.args !== args))
|
|
754
|
+
return answer({ ok: false, error: 'failed' });
|
|
755
|
+
let cached = !!entry;
|
|
756
|
+
if (!entry) {
|
|
757
|
+
if (seen.size >= MAX_ANSWERS)
|
|
758
|
+
return answer({ ok: false, error: 'busy' });
|
|
759
|
+
const store = this.opts.answers;
|
|
760
|
+
const reply = Promise.resolve().then(async () => {
|
|
761
|
+
if (store) {
|
|
762
|
+
let kept;
|
|
763
|
+
try {
|
|
764
|
+
kept = await this.answerTask(g.id, () => store.get(g.id, key));
|
|
765
|
+
}
|
|
766
|
+
catch (e) {
|
|
767
|
+
this.report(e);
|
|
768
|
+
return { ok: false, error: 'failed' };
|
|
769
|
+
} // unknown: never run twice
|
|
770
|
+
if (kept != null) {
|
|
771
|
+
if (typeof kept !== 'object' || Array.isArray(kept))
|
|
772
|
+
return { ok: false, error: 'failed' };
|
|
773
|
+
const record = kept;
|
|
774
|
+
if (record.op !== req.op || record.args !== args || !record.reply || typeof record.reply !== 'object' || Array.isArray(record.reply))
|
|
775
|
+
return { ok: false, error: 'failed' };
|
|
776
|
+
const r = record.reply;
|
|
777
|
+
const fields = Object.keys(r);
|
|
778
|
+
if (r.ok === true && fields.includes('ok') && fields.every((f) => f === 'ok' || f === 'value')) {
|
|
779
|
+
cached = true;
|
|
780
|
+
return fields.includes('value') ? { ok: true, value: r.value } : { ok: true };
|
|
781
|
+
}
|
|
782
|
+
if (r.ok === false && typeof r.error === 'string' && fields.includes('ok') && fields.includes('error') && fields.every((f) => f === 'ok' || f === 'error' || f === 'message') && (fields.length === 2 || (fields.length === 3 && typeof r.message === 'string'))) {
|
|
783
|
+
cached = true;
|
|
784
|
+
return { ok: false, error: r.error, ...(fields.length === 3 ? { message: r.message } : {}) };
|
|
785
|
+
}
|
|
786
|
+
return { ok: false, error: 'failed' };
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const result = await this.run(req, g, m.notValidAfter, authenticatedKey);
|
|
790
|
+
if (store)
|
|
791
|
+
try {
|
|
792
|
+
await this.answerTask(g.id, () => store.put(g.id, key, { op: req.op, args, reply: result }));
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
this.report(e);
|
|
796
|
+
}
|
|
797
|
+
return result;
|
|
798
|
+
});
|
|
799
|
+
entry = { id, session, op: req.op, args, reply };
|
|
800
|
+
seen.set(key, entry);
|
|
801
|
+
}
|
|
802
|
+
const reply = await entry.reply;
|
|
803
|
+
const admitted = await this.admitRequest(req, g, authenticatedKey, cached);
|
|
804
|
+
if ('error' in admitted) {
|
|
805
|
+
answer({ ok: false, error: admitted.error });
|
|
806
|
+
if (admitted.error === 'ended')
|
|
807
|
+
void this.revoke(g.id, 'ended').catch((e) => this.report(e));
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
answer(reply);
|
|
811
|
+
}
|
|
812
|
+
async admitRequest(req, g, authenticatedKey, checkPolicy = true) {
|
|
813
|
+
const before = this.admitGrant(g, authenticatedKey);
|
|
814
|
+
if ('error' in before)
|
|
815
|
+
return before;
|
|
816
|
+
const current = before.grant;
|
|
817
|
+
if (!checkPolicy)
|
|
818
|
+
return before;
|
|
819
|
+
const denied = current.role === 'control' ? 'not-allowed' : 'view-only';
|
|
820
|
+
try {
|
|
821
|
+
const ok = this.opts.allow ? await this.opts.allow(req, current) : current.role === 'control' || (this.opts.canView?.(req) ?? false);
|
|
822
|
+
if (ok !== true)
|
|
823
|
+
return { error: denied };
|
|
824
|
+
}
|
|
825
|
+
catch (e) {
|
|
826
|
+
this.report(e);
|
|
827
|
+
return { error: denied };
|
|
828
|
+
}
|
|
829
|
+
const after = this.admitGrant(g, authenticatedKey);
|
|
830
|
+
if ('error' in after)
|
|
831
|
+
return after;
|
|
832
|
+
if (after.grant !== current)
|
|
833
|
+
return { error: denied };
|
|
834
|
+
return after;
|
|
835
|
+
}
|
|
836
|
+
async run(req, g, notValidAfter, authenticatedKey) {
|
|
837
|
+
const checked = (result) => {
|
|
838
|
+
const snapshot = JSON.parse(JSON.stringify(result));
|
|
839
|
+
messageBytes({ t: 'res', id: 0, ...snapshot });
|
|
840
|
+
return snapshot;
|
|
841
|
+
};
|
|
842
|
+
// A request that arrives after the moment its sender said it stops making sense is not run at all.
|
|
843
|
+
if (typeof notValidAfter === 'number' && this.now() > notValidAfter)
|
|
844
|
+
return { ok: false, error: 'too-late' };
|
|
845
|
+
const admitted = await this.admitRequest(req, g, authenticatedKey);
|
|
846
|
+
if ('error' in admitted)
|
|
847
|
+
return { ok: false, error: admitted.error };
|
|
848
|
+
if (typeof notValidAfter === 'number' && this.now() > notValidAfter)
|
|
849
|
+
return { ok: false, error: 'too-late' };
|
|
850
|
+
try {
|
|
851
|
+
return checked({ ok: true, value: await this.opts.handle(req, admitted.grant) });
|
|
852
|
+
}
|
|
853
|
+
catch (e) {
|
|
854
|
+
if (e instanceof PublicLinkError) {
|
|
855
|
+
try {
|
|
856
|
+
return checked({ ok: false, error: 'public', message: e.message });
|
|
857
|
+
}
|
|
858
|
+
catch (invalid) {
|
|
859
|
+
e = invalid;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
this.report(e);
|
|
863
|
+
return { ok: false, error: 'failed' };
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|