@byokit/pair 0.0.0-stage → 0.8.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 +64 -0
- package/LICENSE +202 -0
- package/README.md +516 -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 +168 -0
- package/dist/device.js +546 -0
- package/dist/host.d.ts +214 -0
- package/dist/host.js +861 -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 +68 -0
- package/dist/pairing.js +426 -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/device.js
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
// A device's end of the link: a phone app, a browser page or another computer. It holds its own key and a grant, never
|
|
2
|
+
// the host's credentials. It pairs once (scan or typed code), then keeps one socket open, reconnecting on its own, and
|
|
3
|
+
// its requests survive a reconnect: each carries a key, so a retried tap runs once. Uses only the platform's
|
|
4
|
+
// WebSocket, so the same code runs in browsers, React Native and Node.
|
|
5
|
+
import { Handshake, b64, b64url, keyPair, keyPairFrom, random, unb64url } from "./channel.js";
|
|
6
|
+
import { cleanName, codeKey, decodeCompactOffer, parseCode, parseOffer, shortKey, COMPACT_TAG } from "./pairing.js";
|
|
7
|
+
import { Streams } from "./stream.js";
|
|
8
|
+
/** Every way the link can fail, in words a person can act on. */
|
|
9
|
+
export const LINK_WORDS = {
|
|
10
|
+
unreachable: "Can't reach your computer. Check it's on and connected.",
|
|
11
|
+
'wrong-host': "Something other than your computer answered, so this device didn't connect.",
|
|
12
|
+
'wrong-code': "That code didn't match. Check it, or show a new one on your computer.",
|
|
13
|
+
expired: 'That pairing code has run out. Show a new one on your computer.',
|
|
14
|
+
declined: 'Your computer said no to this device.',
|
|
15
|
+
full: 'Your computer has all the devices it allows. Remove one there first.',
|
|
16
|
+
'not-paired': "Your computer doesn't know this device any more. Pair it again.",
|
|
17
|
+
removed: 'This device was removed on your computer.',
|
|
18
|
+
'view-only': 'This device can watch but not make changes.',
|
|
19
|
+
timeout: "Your computer didn't answer in time.",
|
|
20
|
+
failed: "Your computer couldn't do that.",
|
|
21
|
+
busy: 'Your computer is still catching up. Try again in a moment.',
|
|
22
|
+
stopped: 'This link is stopped. Try connecting again.',
|
|
23
|
+
'not-supported': "The app on your computer can't do this yet. Update it there.",
|
|
24
|
+
ended: "This device's access has run out. Pair it again on your computer.",
|
|
25
|
+
'not-allowed': "This device isn't allowed to do that.",
|
|
26
|
+
'too-late': "That reached your computer too late, so it wasn't done.",
|
|
27
|
+
unsupported: 'Your computer needs an update for that.',
|
|
28
|
+
};
|
|
29
|
+
/** A failure with a plain `message` and a `code` for the app. `sealed` means the host said it inside the encrypted
|
|
30
|
+
* channel, so it is really the host speaking; anything else could come from whoever is on the network. */
|
|
31
|
+
export class LinkError extends Error {
|
|
32
|
+
code;
|
|
33
|
+
sealed;
|
|
34
|
+
constructor(code, sealed = false) {
|
|
35
|
+
super(LINK_WORDS[code]);
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.sealed = sealed;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class PublicLinkError extends LinkError {
|
|
41
|
+
constructor(message) { super('failed', true); this.message = message; }
|
|
42
|
+
}
|
|
43
|
+
const known = (why) => typeof why === 'string' && Object.hasOwn(LINK_WORDS, why);
|
|
44
|
+
const problem = (why) => (known(why) ? why : 'unreachable');
|
|
45
|
+
const later = (ms, fn) => { const t = setTimeout(fn, ms); t.unref?.(); return t; };
|
|
46
|
+
/** One socket: the handshake, the first request (`auth` or `pair`), and the host's `ready`. */
|
|
47
|
+
async function dial(url, me, host, hello, o, on = { message: () => { }, close: () => { } }) {
|
|
48
|
+
let target = url;
|
|
49
|
+
try {
|
|
50
|
+
if (o.resolve)
|
|
51
|
+
target = await o.resolve(url);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new LinkError('unreachable');
|
|
55
|
+
}
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
const WS = o.WebSocket ?? globalThis.WebSocket;
|
|
58
|
+
const mode = hello.t === 'code' ? 'code' : 'ik';
|
|
59
|
+
const hs = new Handshake(mode, true, me, { remote: host.key, psk: host.psk });
|
|
60
|
+
const pairing = hello.t !== 'auth';
|
|
61
|
+
let ch = null;
|
|
62
|
+
let up = false;
|
|
63
|
+
let settled = false;
|
|
64
|
+
let ws;
|
|
65
|
+
const fail = (e) => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
if (up)
|
|
68
|
+
on.close(e);
|
|
69
|
+
else if (!settled)
|
|
70
|
+
reject(e);
|
|
71
|
+
up = false;
|
|
72
|
+
settled = true;
|
|
73
|
+
try {
|
|
74
|
+
ws.close();
|
|
75
|
+
}
|
|
76
|
+
catch { }
|
|
77
|
+
};
|
|
78
|
+
let timer = later(o.timeoutMs ?? 8000, () => fail(new LinkError('timeout')));
|
|
79
|
+
try {
|
|
80
|
+
ws = new WS(target);
|
|
81
|
+
ws.binaryType = 'arraybuffer';
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return fail(new LinkError('unreachable'));
|
|
85
|
+
}
|
|
86
|
+
let binary = false; // the host said this socket reaches it directly, so stream bytes may go as binary messages
|
|
87
|
+
const send = (m) => { for (const f of ch.seal(m))
|
|
88
|
+
ws.send(f); };
|
|
89
|
+
const data = (s, d) => { for (const f of ch.sealData(s, d))
|
|
90
|
+
ws.send(binary ? f : b64(f)); };
|
|
91
|
+
ws.onopen = () => ws.send(hs.write(mode === 'ik' ? { v: 1 } : {}));
|
|
92
|
+
ws.onerror = () => fail(new LinkError('unreachable'));
|
|
93
|
+
// Before the handshake finishes nothing is authenticated, so these only choose what to say, never what to forget.
|
|
94
|
+
ws.onclose = (e) => fail(new LinkError(ch ? 'unreachable' : e?.code === 4401 && mode === 'code' ? 'wrong-code' : e?.code === 4403 ? 'wrong-host' : 'unreachable'));
|
|
95
|
+
ws.onmessage = (ev) => {
|
|
96
|
+
if (settled && !up)
|
|
97
|
+
return;
|
|
98
|
+
try {
|
|
99
|
+
if (!ch) {
|
|
100
|
+
try {
|
|
101
|
+
if (typeof ev.data !== 'string')
|
|
102
|
+
throw new Error('binary');
|
|
103
|
+
hs.read(ev.data);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return fail(new LinkError('wrong-host'));
|
|
107
|
+
} // only the right host can answer
|
|
108
|
+
if (host.commitment && shortKey(hs.remoteKey) !== host.commitment)
|
|
109
|
+
return fail(new LinkError('wrong-host'));
|
|
110
|
+
if (hello.t === 'code')
|
|
111
|
+
ws.send(hs.write({ name: hello.name }));
|
|
112
|
+
ch = hs.channel();
|
|
113
|
+
if (hello.t !== 'code')
|
|
114
|
+
send(hello);
|
|
115
|
+
if (pairing) { // now a person at the host decides; give them time
|
|
116
|
+
o.onWords?.(hs.words);
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
timer = later(6 * 60_000, () => fail(new LinkError('timeout')));
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const m = ch.open(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data)); // throws unless it is the host's next authentic frame
|
|
123
|
+
if (m === undefined)
|
|
124
|
+
return;
|
|
125
|
+
if (!up && m.t === 'refused')
|
|
126
|
+
return fail(new LinkError(problem(m.why), true));
|
|
127
|
+
if (!up && m.t === 'ready') {
|
|
128
|
+
up = true;
|
|
129
|
+
settled = true;
|
|
130
|
+
binary = m.binary === 1;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
return resolve({ ready: m, hostKey: hs.remoteKey, send, data, close: () => { up = false; ws.close(); } });
|
|
133
|
+
}
|
|
134
|
+
if (up)
|
|
135
|
+
on.message(m);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
fail(new LinkError('unreachable'));
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
function granted(me, hostKey, url, urls, ready) {
|
|
144
|
+
return { v: 1, secretKey: b64url(me.secretKey), host: hostKey, hostName: cleanName(ready.host?.name, 'your computer'), urls: [url, ...urls.filter((u) => u !== url)],
|
|
145
|
+
device: ready.device, ...(ready.meta === undefined ? {} : { meta: ready.meta }) };
|
|
146
|
+
}
|
|
147
|
+
/** The grant a scanned code will become, to keep in secure storage *before* pairing. If the app dies while the person
|
|
148
|
+
* at the host decides, a `DeviceLink` made from it later connects once the host has said yes (or learns it said no),
|
|
149
|
+
* instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`. */
|
|
150
|
+
export function pendingGrant(scanned, o) {
|
|
151
|
+
const offer = parseOffer(scanned);
|
|
152
|
+
const me = o.key ?? keyPair();
|
|
153
|
+
return { v: 1, secretKey: b64url(me.secretKey), pendingUntil: offer.expires + 300_000, host: offer.host, hostName: offer.name, urls: offer.urls,
|
|
154
|
+
device: { id: '', name: cleanName(o.name, 'Device'), role: offer.role ?? 'view' } };
|
|
155
|
+
}
|
|
156
|
+
/** A scanned QR (or opened pairing link) in, a grant out, once the person at the host says yes. `onWords` gets the
|
|
157
|
+
* two words to show while they decide. Tries each address in the code until one answers. A compact offer pairs
|
|
158
|
+
* like a typed code over the packed addresses: same single use, life, words and approval, no host key pinned. */
|
|
159
|
+
export async function pairWithOffer(scanned, o) {
|
|
160
|
+
if (scanned.indexOf(COMPACT_TAG) >= 0) {
|
|
161
|
+
const compact = decodeCompactOffer(scanned);
|
|
162
|
+
const me = o.key ?? keyPair();
|
|
163
|
+
let last = new LinkError('unreachable');
|
|
164
|
+
for (const url of compact.urls) {
|
|
165
|
+
try {
|
|
166
|
+
const l = await dial(url, me, { psk: codeKey(compact.code) }, { t: 'code', name: o.name }, o);
|
|
167
|
+
l.close();
|
|
168
|
+
return granted(me, b64url(l.hostKey), url, compact.urls, l.ready);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
last = e instanceof LinkError ? e : new LinkError('unreachable');
|
|
172
|
+
if (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host')
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
throw last;
|
|
177
|
+
}
|
|
178
|
+
const offer = parseOffer(scanned);
|
|
179
|
+
const me = o.key ?? keyPair();
|
|
180
|
+
let last = new LinkError('unreachable');
|
|
181
|
+
for (const url of offer.urls) {
|
|
182
|
+
try {
|
|
183
|
+
const l = await dial(url, me, { key: unb64url(offer.host) }, { t: 'pair', ticket: offer.ticket, name: o.name }, o);
|
|
184
|
+
l.close();
|
|
185
|
+
return granted(me, offer.host, url, offer.urls, l.ready);
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
last = e instanceof LinkError ? e : new LinkError('unreachable');
|
|
189
|
+
if (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host')
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
throw last;
|
|
194
|
+
}
|
|
195
|
+
/** A typed code in, a grant out. `Host.shortCode` also pins the machine key before sharing the device identity.
|
|
196
|
+
* `url` is an untrusted address, e.g. from relay `findHost`; only the full code authenticates the host. */
|
|
197
|
+
export async function pairWithCode(url, typed, o) {
|
|
198
|
+
const code = parseCode(typed);
|
|
199
|
+
if (!code)
|
|
200
|
+
throw new LinkError('wrong-code');
|
|
201
|
+
const me = o.key ?? keyPair();
|
|
202
|
+
const l = await dial(url, me, { psk: codeKey(code.secret), commitment: code.commitment }, { t: 'code', name: o.name }, o);
|
|
203
|
+
const hostKey = b64url(l.hostKey);
|
|
204
|
+
l.close();
|
|
205
|
+
return granted(me, hostKey, url, [url], l.ready);
|
|
206
|
+
}
|
|
207
|
+
/** The device's live link: connects, reconnects forever with backoff, resends unanswered requests after a reconnect. */
|
|
208
|
+
export class DeviceLink {
|
|
209
|
+
grant;
|
|
210
|
+
status = 'connecting';
|
|
211
|
+
o;
|
|
212
|
+
conn = null;
|
|
213
|
+
streams = null;
|
|
214
|
+
features = [];
|
|
215
|
+
pending = new Map();
|
|
216
|
+
n = 0;
|
|
217
|
+
tries = 0;
|
|
218
|
+
stopped = false;
|
|
219
|
+
wake;
|
|
220
|
+
connecting = null;
|
|
221
|
+
session = b64url(random(12));
|
|
222
|
+
fresh = true;
|
|
223
|
+
ack = 0;
|
|
224
|
+
received = new Set();
|
|
225
|
+
storing = Promise.resolve();
|
|
226
|
+
waiting = new Map();
|
|
227
|
+
heard = 0;
|
|
228
|
+
pingId = 0;
|
|
229
|
+
constructor(grant, o = {}) {
|
|
230
|
+
this.grant = grant;
|
|
231
|
+
this.o = o;
|
|
232
|
+
void this.connect();
|
|
233
|
+
}
|
|
234
|
+
set(s) { if (s !== this.status) {
|
|
235
|
+
this.status = s;
|
|
236
|
+
this.o.onStatus?.(s);
|
|
237
|
+
} }
|
|
238
|
+
report(e) { try {
|
|
239
|
+
(this.o.onError ?? console.error)(e);
|
|
240
|
+
}
|
|
241
|
+
catch { } }
|
|
242
|
+
persist(action) {
|
|
243
|
+
const next = this.storing.then(action);
|
|
244
|
+
this.storing = next.catch((e) => this.report(e));
|
|
245
|
+
return next;
|
|
246
|
+
}
|
|
247
|
+
receivedReply(id) {
|
|
248
|
+
this.received.add(id);
|
|
249
|
+
while (this.received.delete(this.ack + 1))
|
|
250
|
+
this.ack++;
|
|
251
|
+
}
|
|
252
|
+
connect() {
|
|
253
|
+
if (this.stopped || this.conn)
|
|
254
|
+
return Promise.resolve();
|
|
255
|
+
// The status is set after the attempt is over, so an app can call retry() from onStatus.
|
|
256
|
+
this.connecting ??= this.attempt().then((after) => { this.connecting = null; after(); }, (e) => { this.connecting = null; this.report(e); });
|
|
257
|
+
return this.connecting;
|
|
258
|
+
}
|
|
259
|
+
async attempt() {
|
|
260
|
+
let wrongHosts = 0;
|
|
261
|
+
let online = false;
|
|
262
|
+
// Mid-rekey, the new key goes first; if the host never took it, the old one still works.
|
|
263
|
+
const keys = [this.grant.nextSecretKey, this.grant.secretKey].filter((k) => !!k);
|
|
264
|
+
urls: for (const url of this.grant.urls) {
|
|
265
|
+
for (const secret of keys) {
|
|
266
|
+
try {
|
|
267
|
+
let active;
|
|
268
|
+
const l = await dial(url, keyPairFrom(unb64url(secret)), { key: unb64url(this.grant.host) }, { t: 'auth', session: this.session, ack: this.ack, fresh: this.fresh }, this.o, {
|
|
269
|
+
message: (m) => { if (this.conn === active) {
|
|
270
|
+
if (m.t === 'pong' && m.n === this.pingId)
|
|
271
|
+
this.heard = Date.now();
|
|
272
|
+
this.message(m);
|
|
273
|
+
} },
|
|
274
|
+
close: () => { if (this.conn === active)
|
|
275
|
+
this.lost(); },
|
|
276
|
+
});
|
|
277
|
+
active = l;
|
|
278
|
+
if (this.stopped) {
|
|
279
|
+
l.close();
|
|
280
|
+
return () => { };
|
|
281
|
+
}
|
|
282
|
+
this.fresh = false;
|
|
283
|
+
this.conn = l;
|
|
284
|
+
this.streams = l.ready.streams === 1 ? new Streams({ send: l.send, data: l.data, reason: (e) => { this.report(e); return 'failed'; }, report: (e) => this.report(e) }) : null;
|
|
285
|
+
this.pingId = 0;
|
|
286
|
+
this.heard = Date.now();
|
|
287
|
+
this.features = Array.isArray(l.ready.features) ? l.ready.features : [];
|
|
288
|
+
this.tries = 0;
|
|
289
|
+
const { nextSecretKey, pendingUntil, meta, ...rest } = this.grant;
|
|
290
|
+
this.grant = { ...rest, secretKey: secret, urls: [url, ...this.grant.urls.filter((u) => u !== url)], device: l.ready.device, ...(l.ready.meta === undefined ? {} : { meta: l.ready.meta }) }; // what worked goes first
|
|
291
|
+
const current = this.grant;
|
|
292
|
+
void this.persist(() => this.o.store?.save(current)).catch(() => { });
|
|
293
|
+
for (const [id, p] of this.pending)
|
|
294
|
+
this.sendPending(l, id, p);
|
|
295
|
+
if (this.features.includes('ping'))
|
|
296
|
+
this.ping(l);
|
|
297
|
+
online = true;
|
|
298
|
+
break urls;
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
if (e?.sealed && (e.code === 'not-paired' || e.code === 'ended')) {
|
|
302
|
+
if (secret !== this.grant.secretKey)
|
|
303
|
+
continue; // the new key was never taken: try the old one
|
|
304
|
+
if (this.grant.pendingUntil && Date.now() < this.grant.pendingUntil)
|
|
305
|
+
break;
|
|
306
|
+
return () => this.removed();
|
|
307
|
+
}
|
|
308
|
+
if (e?.code === 'wrong-host')
|
|
309
|
+
wrongHosts++;
|
|
310
|
+
break; // this address is the problem, not the key
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return () => {
|
|
315
|
+
if (this.stopped)
|
|
316
|
+
return;
|
|
317
|
+
if (online && this.conn)
|
|
318
|
+
this.set('online');
|
|
319
|
+
else if (wrongHosts > 0 && wrongHosts === this.grant.urls.length) {
|
|
320
|
+
this.stopped = true;
|
|
321
|
+
this.set('refused');
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
this.again();
|
|
325
|
+
this.set('offline');
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
lost() {
|
|
330
|
+
this.drop('unreachable');
|
|
331
|
+
if (!this.stopped && !this.connecting) {
|
|
332
|
+
this.again();
|
|
333
|
+
this.set('offline');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** A half-open socket (the phone changed networks) looks alive forever; a ping it never answers shows it isn't.
|
|
337
|
+
* A tick that fires long after it was due means this device's own timers were frozen (an app in the background,
|
|
338
|
+
* a hidden tab), not that the host went quiet: no ping went out, so none came back. */
|
|
339
|
+
ping(l) {
|
|
340
|
+
const every = this.o.pingMs ?? 20_000;
|
|
341
|
+
let due = Date.now() + every;
|
|
342
|
+
const tick = () => {
|
|
343
|
+
if (this.conn !== l)
|
|
344
|
+
return;
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
// Count the silence from now; a socket that is really dead still misses the pings that follow.
|
|
347
|
+
if (now - due > every / 2)
|
|
348
|
+
this.heard = now;
|
|
349
|
+
else if (now - this.heard > 2 * every) {
|
|
350
|
+
l.close();
|
|
351
|
+
return this.lost();
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
l.send({ t: 'ping', n: ++this.pingId });
|
|
355
|
+
}
|
|
356
|
+
catch { }
|
|
357
|
+
due = now + every;
|
|
358
|
+
later(every, tick);
|
|
359
|
+
};
|
|
360
|
+
later(every, tick);
|
|
361
|
+
}
|
|
362
|
+
sendPending(l, id, p) {
|
|
363
|
+
try {
|
|
364
|
+
l.send(p.msg);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
this.settle(id);
|
|
368
|
+
p.reject(new Error('Your device could not send that request.'));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
settle(id) {
|
|
372
|
+
const p = this.pending.get(id);
|
|
373
|
+
clearTimeout(p?.timer);
|
|
374
|
+
this.pending.delete(id);
|
|
375
|
+
this.receivedReply(id);
|
|
376
|
+
return p;
|
|
377
|
+
}
|
|
378
|
+
again() {
|
|
379
|
+
const ms = Math.min(30_000, 1000 * 2 ** this.tries++) * (0.5 + Math.random() / 2);
|
|
380
|
+
this.wake = later(ms, () => void this.connect());
|
|
381
|
+
}
|
|
382
|
+
/** The socket is gone: so are its streams. */
|
|
383
|
+
drop(why) {
|
|
384
|
+
this.conn = null;
|
|
385
|
+
this.streams?.closeAll(why);
|
|
386
|
+
this.streams = null;
|
|
387
|
+
}
|
|
388
|
+
message(m) {
|
|
389
|
+
if (m.t === 'revoked')
|
|
390
|
+
return this.removed(); // sealed by the host, so it is really the host saying it
|
|
391
|
+
if (this.streams?.message(m))
|
|
392
|
+
return;
|
|
393
|
+
if (m.t === 'event')
|
|
394
|
+
return this.o.onEvent?.(m.e);
|
|
395
|
+
if (m.t === 'rekeyed' || m.t === 'unpaired')
|
|
396
|
+
return this.waiting.get(m.t)?.(true);
|
|
397
|
+
if (m.t === 'unpair-failed')
|
|
398
|
+
return this.waiting.get('unpaired')?.('failed');
|
|
399
|
+
const p = m.t === 'res' && this.pending.has(m.id) ? this.settle(m.id) : undefined;
|
|
400
|
+
if (!p)
|
|
401
|
+
return;
|
|
402
|
+
if (m.ok)
|
|
403
|
+
p.resolve(m.value);
|
|
404
|
+
else
|
|
405
|
+
p.reject(m.error === 'public' ? new PublicLinkError(String(m.message)) : new LinkError(problem(m.error), true));
|
|
406
|
+
}
|
|
407
|
+
removed() {
|
|
408
|
+
this.stopped = true;
|
|
409
|
+
this.conn?.close();
|
|
410
|
+
this.drop('removed');
|
|
411
|
+
for (const id of [...this.pending.keys()])
|
|
412
|
+
this.settle(id)?.reject(new LinkError('removed', true));
|
|
413
|
+
void this.persist(() => this.o.store?.clear()).catch(() => { });
|
|
414
|
+
this.set('removed');
|
|
415
|
+
}
|
|
416
|
+
/** Asks the host to do `op`. Waits through reconnects (or until `timeoutMs`); resolves only with the host's answer. */
|
|
417
|
+
request(op, args, o = {}) {
|
|
418
|
+
if (this.status === 'removed')
|
|
419
|
+
return Promise.reject(new LinkError('removed', true));
|
|
420
|
+
if (this.stopped)
|
|
421
|
+
return Promise.reject(new LinkError('stopped'));
|
|
422
|
+
if (this.pending.size >= (this.o.maxPending ?? 1000))
|
|
423
|
+
return Promise.reject(new LinkError('busy'));
|
|
424
|
+
return new Promise((resolve, reject) => {
|
|
425
|
+
const id = ++this.n;
|
|
426
|
+
const msg = { t: 'req', id, op, args, key: b64url(random(12)), session: this.session, ack: this.ack,
|
|
427
|
+
...(o.notValidAfter === undefined ? {} : { notValidAfter: o.notValidAfter }) };
|
|
428
|
+
const p = { msg, resolve, reject };
|
|
429
|
+
if (o.timeoutMs !== undefined)
|
|
430
|
+
p.timer = later(o.timeoutMs, () => { if (this.pending.get(id) === p) {
|
|
431
|
+
this.settle(id);
|
|
432
|
+
reject(new LinkError('timeout'));
|
|
433
|
+
} });
|
|
434
|
+
this.pending.set(id, p);
|
|
435
|
+
if (this.conn)
|
|
436
|
+
this.sendPending(this.conn, id, p);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
/** Opens a duplex stream that the host's `stream` handler takes: a terminal pane, a tunnelled connection, a call.
|
|
440
|
+
* Only while `online`. A stream ends when its connection drops (`onEnd('unreachable')`); open it again on the next
|
|
441
|
+
* `online`. Host permission refusals reject; errors from the app handler end an opened stream via `onEnd`. */
|
|
442
|
+
async stream(op, args) {
|
|
443
|
+
if (this.status === 'removed')
|
|
444
|
+
throw new LinkError('removed', true);
|
|
445
|
+
if (this.stopped)
|
|
446
|
+
throw new LinkError('stopped');
|
|
447
|
+
if (!this.conn)
|
|
448
|
+
throw new LinkError('unreachable');
|
|
449
|
+
if (!this.streams)
|
|
450
|
+
throw new LinkError('not-supported', true);
|
|
451
|
+
try {
|
|
452
|
+
return await this.streams.open(op, args);
|
|
453
|
+
}
|
|
454
|
+
catch (e) {
|
|
455
|
+
if (e instanceof Error)
|
|
456
|
+
throw e;
|
|
457
|
+
if (!known(e))
|
|
458
|
+
throw new PublicLinkError(String(e)); // the host app's own words for why not
|
|
459
|
+
throw new LinkError(e, e !== 'unreachable' && e !== 'stopped');
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
/** Another address to try, e.g. one found on the home network. A wrong host there simply fails its handshake. */
|
|
463
|
+
addUrl(url) {
|
|
464
|
+
if (this.grant.urls.includes(url))
|
|
465
|
+
return;
|
|
466
|
+
this.grant = { ...this.grant, urls: [...this.grant.urls, url] };
|
|
467
|
+
const current = this.grant;
|
|
468
|
+
void this.persist(() => this.o.store?.save(current)).catch(() => { });
|
|
469
|
+
if (this.status === 'offline')
|
|
470
|
+
this.retry();
|
|
471
|
+
}
|
|
472
|
+
answer(what, send, ms = 10_000) {
|
|
473
|
+
return new Promise((resolve) => {
|
|
474
|
+
const timer = later(ms, () => { this.waiting.delete(what); resolve(false); });
|
|
475
|
+
this.waiting.set(what, (result) => { clearTimeout(timer); this.waiting.delete(what); resolve(result); });
|
|
476
|
+
try {
|
|
477
|
+
send();
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
clearTimeout(timer);
|
|
481
|
+
this.waiting.delete(what);
|
|
482
|
+
resolve(false);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
/** Moves this device to a fresh key. The new key is stored before the host hears of it, and the old one keeps working
|
|
487
|
+
* until the new one has connected, so a crash at any point leaves a key that works. */
|
|
488
|
+
async rekey() {
|
|
489
|
+
const l = this.conn;
|
|
490
|
+
if (!l)
|
|
491
|
+
throw new LinkError('unreachable');
|
|
492
|
+
if (!this.features.includes('rekey'))
|
|
493
|
+
throw new LinkError('unsupported');
|
|
494
|
+
const next = keyPair();
|
|
495
|
+
this.grant = { ...this.grant, nextSecretKey: b64url(next.secretKey) };
|
|
496
|
+
const staged = this.grant;
|
|
497
|
+
await this.persist(() => this.o.store?.save(staged));
|
|
498
|
+
if (!(await this.answer('rekeyed', () => l.send({ t: 'rekey', key: b64url(next.publicKey) }))))
|
|
499
|
+
throw new LinkError('timeout');
|
|
500
|
+
if (this.conn === l) {
|
|
501
|
+
this.conn = null;
|
|
502
|
+
l.close();
|
|
503
|
+
}
|
|
504
|
+
await this.connect();
|
|
505
|
+
if (this.grant.nextSecretKey || this.grant.secretKey !== b64url(next.secretKey))
|
|
506
|
+
throw new LinkError('failed');
|
|
507
|
+
}
|
|
508
|
+
/** Asks the computer to forget this device, then forgets it locally once the computer confirms. */
|
|
509
|
+
async unpair() {
|
|
510
|
+
const l = this.conn;
|
|
511
|
+
if (!l)
|
|
512
|
+
throw new LinkError('unreachable');
|
|
513
|
+
if (!this.features.includes('unpair'))
|
|
514
|
+
throw new LinkError('unsupported');
|
|
515
|
+
const reply = await this.answer('unpaired', () => l.send({ t: 'unpair' }));
|
|
516
|
+
if (reply !== true)
|
|
517
|
+
throw new LinkError(reply === 'failed' ? 'failed' : 'timeout', reply === 'failed');
|
|
518
|
+
this.removed();
|
|
519
|
+
await this.storing;
|
|
520
|
+
}
|
|
521
|
+
/** After `refused` or `offline`: try again now. */
|
|
522
|
+
retry() {
|
|
523
|
+
if (this.status === 'removed' || this.conn)
|
|
524
|
+
return;
|
|
525
|
+
clearTimeout(this.wake);
|
|
526
|
+
this.stopped = false;
|
|
527
|
+
this.tries = 0;
|
|
528
|
+
this.set('connecting');
|
|
529
|
+
void this.connect();
|
|
530
|
+
}
|
|
531
|
+
stop() {
|
|
532
|
+
this.stopped = true;
|
|
533
|
+
this.fresh = true;
|
|
534
|
+
this.ack = this.n;
|
|
535
|
+
this.received.clear();
|
|
536
|
+
clearTimeout(this.wake);
|
|
537
|
+
const conn = this.conn;
|
|
538
|
+
this.drop('stopped');
|
|
539
|
+
conn?.close();
|
|
540
|
+
for (const [id, p] of [...this.pending]) {
|
|
541
|
+
clearTimeout(p.timer);
|
|
542
|
+
this.pending.delete(id);
|
|
543
|
+
p.reject(new LinkError('stopped'));
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|