@byokit/pair 0.8.0 → 0.10.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 +14 -0
- package/README.md +71 -33
- package/SECURITY.md +5 -5
- package/dist/check.d.ts +3 -3
- package/dist/check.js +4 -4
- package/dist/device.d.ts +14 -4
- package/dist/device.js +76 -23
- package/dist/host.js +11 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/pairing.d.ts +5 -2
- package/dist/pairing.js +9 -4
- package/dist/stream.d.ts +23 -0
- package/dist/stream.js +73 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.10.0 (2026-10-12)
|
|
6
|
+
|
|
7
|
+
- DOCS: the "Checking reachability before pairing" section now shows both sides: setup self-checks its own advertised URLs with `check()` before printing the tick, and the phone re-checks the scanned offer before consent. The probe still spends nothing, so a checked offer pairs afterwards.
|
|
8
|
+
- ADD: `ndjson(stream)` sends and receives whole JSON messages over a pair stream, one per `\n`-terminated line, through the existing credit window. Chunks split at any byte boundary reassemble before decoding, a line past `NDJSON_MAX_LINE` (1 MiB, configurable per stream) ends the stream with `line-too-long` on both ends, and a non-JSON line ends it with `failed`.
|
|
9
|
+
- FIX: every wait in the link end-to-end suite is bounded: each test carries a one-minute deadline, so a request stalled past its reconnect window (twice it parked the whole full-suite run at 0% CPU until killed) fails by its test name instead of hanging every lane and release behind the shared test lock. The file still passes in about 10 s alone; nothing about the protocol changed.
|
|
10
|
+
|
|
11
|
+
## 0.9.0 (2026-10-09)
|
|
12
|
+
|
|
13
|
+
- FIX: a phone killed while the person at the computer decides now resumes against the pinned computer, for compact and version 1 QRs alike. `pairWithOffer` takes `onPending(grant)`, which hands over the pending grant to keep before the computer can approve this device, and `pendingGrant` accepts a compact QR given the authenticated `host` key, and `parseOffer` reads a compact QR for inspection (expiry, addresses, name, role) without one. Before, `pendingGrant` refused compact QRs, and even a version 1 pending grant never came online after a real kill. A socket that drops once the computer has taken the code now ends pairing with `unreachable` instead of trying the next address, where the spent code or ticket could only be refused as `wrong-code` or `expired`. A socket that closes while `onPending` is still saving ends pairing at once with that close; a version 1 `onPending` that never settles times out. The compact QR is unchanged: 109 characters for one relay address and the name Umer.
|
|
14
|
+
- SECURITY: pinning and approval semantics. A compact QR's pending grant pins the key the code handshake authenticated, kept after Noise message 2 and before the device's identity goes out in message 3; if saving it fails, pairing stops before the computer learns the device. A device using `onPending` says so inside the handshake, and only then does the computer keep a yes given after its socket dropped, granting only the device key that handshake authenticated; any other device that leaves still gets no grant. A computer with another key at the same address is still refused, and a version 1 `pendingGrant` refuses a `host` that differs from its QR.
|
|
15
|
+
- `parseOffer` now returns `PairOffer | CompactOffer`, so a compact QR parses for inspection. Code that reads `.host` or `.ticket`, or passes the result to `offerText` or `encodeOffer`, narrows with `'host' in offer`, or calls the new `parseV1Offer` for the old version 1-only type.
|
|
16
|
+
- FIX: the pairing picture's words match what the host now prints: scan the code, or type the shown code where the phone page asks for it, with no raw address to type.
|
|
17
|
+
- FIX: the browser pairing test closes only the Chromium processes it spawned — over that browser's own private CDP pipe — instead of signalling a whole process group, so a killed or timed-out test can no longer take out or leave behind the wrong process tree. `BYOKIT_CHROME` is the single documented key that selects the test browser.
|
|
18
|
+
|
|
5
19
|
## 0.8.0 (2026-10-07)
|
|
6
20
|
|
|
7
21
|
- Add redacted startup, page/module and link-stage diagnostics to the browser pairing test; the intermittent CI timeout's root cause remains unknown.
|
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
<h1 align="center">@byokit/
|
|
1
|
+
<h1 align="center">@byokit/pair</h1>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<a href="https://www.npmjs.com/package/@byokit/
|
|
4
|
+
<a href="https://www.npmjs.com/package/@byokit/pair"><img alt="npm" src="https://img.shields.io/npm/v/@byokit/pair?style=flat&label=npm" /></a>
|
|
5
5
|
<a href="https://github.com/umeranjum17/byokit/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/umeranjum17/byokit/ci.yml?style=flat&branch=main" /></a>
|
|
6
6
|
<a href="LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
|
|
7
7
|
<img alt="Node | browsers | React Native" src="https://img.shields.io/badge/platform-Node%20%7C%20browsers%20%7C%20React%20Native-666?style=flat" />
|
|
@@ -12,8 +12,11 @@ One encrypted link between the two. The computer (the <strong>host</strong>) kee
|
|
|
12
12
|
only its own key and a grant, and asks the host to do things. For apps that reach a person's own computer from their
|
|
13
13
|
phone or browser.</p>
|
|
14
14
|
|
|
15
|
+
This package was published as `@byokit/link` up to 0.8.x. `@byokit/link` still re-exports it unchanged through
|
|
16
|
+
0.8.x and is removed in 0.9.0; new installs use `@byokit/pair`.
|
|
17
|
+
|
|
15
18
|
<p align="center">
|
|
16
|
-
<img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/herdr-kit-host.png" width="420" alt="A terminal running npm start -- --herdr "$(command -v herdr)" --via lan --name 'Kitchen computer', showing a large pairing QR code, then: On the phone, scan this
|
|
19
|
+
<img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/herdr-kit-host.png" width="420" alt="A terminal running npm start -- --herdr "$(command -v herdr)" --via lan --name 'Kitchen computer', showing a large pairing QR code, then: On the phone, scan this, or open http://192.168.1.144:7310/ and type K3J8-CJZ7-SE4R. Codes last five minutes. Press Enter for new ones. Connecting to Herdr… Connected to Herdr. (stand-in Herdr)" />
|
|
17
20
|
<img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/examples/herdr-kit/docs/2-compare.png" width="240" alt="A phone page titled Agents, under Pair this phone: Check your computer shows these two words, then say yes there. The words are coast comet." />
|
|
18
21
|
</p>
|
|
19
22
|
<p align="center"><sub>Left: the host terminal of <a href="https://github.com/umeranjum17/byokit/tree/main/examples/herdr-kit">examples/herdr-kit</a> (<code>npm start -- --herdr "$(command -v herdr)" --via lan --name 'Kitchen computer'</code>, pictured against the kit's stand-in Herdr, <code>BYOKIT_EXAMPLE_FAKE=1</code>) showing the QR and the typed code. Right: the phone in the same example showing the two words to compare.</sub></p>
|
|
@@ -27,26 +30,26 @@ Host policy and streams are available; relay routing is in [`@byokit/relay`](../
|
|
|
27
30
|
## Install
|
|
28
31
|
|
|
29
32
|
```sh
|
|
30
|
-
npm install @byokit/
|
|
33
|
+
npm install @byokit/pair
|
|
31
34
|
```
|
|
32
35
|
|
|
33
|
-
[](https://www.npmjs.com/package/@byokit/pair) · [Latest release](https://github.com/umeranjum17/byokit/releases?q=pair-v) · [All releases](https://github.com/umeranjum17/byokit/releases)
|
|
34
37
|
|
|
35
38
|
## Quickstart
|
|
36
39
|
|
|
37
40
|
```sh
|
|
38
|
-
npm install @byokit/
|
|
41
|
+
npm install @byokit/pair ws
|
|
39
42
|
```
|
|
40
43
|
|
|
41
44
|
`ws` is only the WebSocket server for this example; any server works. A host and a device in one file, on loopback:
|
|
42
45
|
|
|
43
46
|
```ts
|
|
44
47
|
import { WebSocketServer } from 'ws';
|
|
45
|
-
import { DeviceLink, Host, keyPair, pairWithOffer } from '@byokit/
|
|
48
|
+
import { DeviceLink, Host, keyPair, pairWithOffer } from '@byokit/pair';
|
|
46
49
|
|
|
47
50
|
// The computer: it keeps the credentials and answers requests.
|
|
48
51
|
const host = await Host.open({
|
|
49
|
-
keys: keyPair(), // keep it with hostKeyFile() from '@byokit/
|
|
52
|
+
keys: keyPair(), // keep it with hostKeyFile() from '@byokit/pair/node' in a real app
|
|
50
53
|
name: 'Kitchen computer',
|
|
51
54
|
confirm: ({ name, words }) => { console.log(`host: pair ${name}? it shows "${words}"`); return true; },
|
|
52
55
|
handle: (req, device) => ({ echo: req.args, from: device.name }),
|
|
@@ -89,13 +92,13 @@ answer: { echo: { text: 'hi' }, from: 'Pixel 9' }
|
|
|
89
92
|
| `LinkError`, `PublicLinkError`, `LINK_WORDS` | Failures with a plain `message` and a `code`; a handler error safe to show; the plain message for each failure code |
|
|
90
93
|
| `secureDeviceStore`, `browserDeviceStore` | Where a device keeps its grant on a phone or in a browser |
|
|
91
94
|
| `keyPair`, `keyPairFrom`, `hostId`, `b64url`, `unb64url`, `normalizeCode` | Keys, the host id, base64url and typed-code helpers |
|
|
92
|
-
| `hostKeyFile`, `fileDeviceStore` (`@byokit/
|
|
95
|
+
| `hostKeyFile`, `fileDeviceStore` (`@byokit/pair/node`) | Node only: the host's key file, and a computer's device store |
|
|
93
96
|
|
|
94
97
|
## Host
|
|
95
98
|
|
|
96
99
|
```ts
|
|
97
|
-
import { Host } from '@byokit/
|
|
98
|
-
import { hostKeyFile } from '@byokit/
|
|
100
|
+
import { Host } from '@byokit/pair';
|
|
101
|
+
import { hostKeyFile } from '@byokit/pair/node';
|
|
99
102
|
|
|
100
103
|
const host = await Host.open({
|
|
101
104
|
keys: hostKeyFile('./link-secret/host.key'), // made once in its own 0700 folder; or use your keychain
|
|
@@ -112,7 +115,7 @@ const { code } = host.code({ role: 'view' }); // or a code to type: "7KQ4-M2
|
|
|
112
115
|
host.devices(); await host.revoke(id); host.broadcast(event);
|
|
113
116
|
```
|
|
114
117
|
|
|
115
|
-
`hostKeyFile(path)` from `@byokit/
|
|
118
|
+
`hostKeyFile(path)` from `@byokit/pair/node` wants a path in its own private (`0700`) folder; it refuses an
|
|
116
119
|
existing folder with another mode rather than changing its permissions.
|
|
117
120
|
|
|
118
121
|
More host policy, all optional:
|
|
@@ -127,6 +130,9 @@ More host policy, all optional:
|
|
|
127
130
|
handlers with transactional effects can store it alongside the effect to avoid repeating work after a crash between
|
|
128
131
|
the handler and `answers.put`.
|
|
129
132
|
- `handshakes: { perMinute, perPeer }` limits new handshakes; pass `host.accept(ws, { peer: ip })` to count per source.
|
|
133
|
+
- `onGrantRemoved(grant, why)` runs at the one boundary where a grant is durably gone — `revoked`, `expired` or
|
|
134
|
+
`changed` — so an app can delete its copy. Access is already withdrawn when it runs, a socket drop does not
|
|
135
|
+
trigger it, and a failing cleanup never restores access.
|
|
130
136
|
|
|
131
137
|
`compactOffer` is the QR text: the same secret a typed code carries (same single use, life, words and
|
|
132
138
|
approval) plus the addresses to try, packed binary, so a standard offer fits a QR version 4 or lower.
|
|
@@ -152,11 +158,13 @@ const { text } = host.offer({
|
|
|
152
158
|
console.log(text); // terminal-to-browser deep link; the offer stays after #
|
|
153
159
|
```
|
|
154
160
|
|
|
155
|
-
`offerText(
|
|
161
|
+
`offerText(parseV1Offer(text), 'https://app.example/pair.html')` makes the same deep link from an existing version 1 offer.
|
|
156
162
|
There is no separate `offerLink`. Use `parseOffer(text, 0)` to inspect an expired offer; this does not renew it,
|
|
157
|
-
and pairing still refuses an expired ticket.
|
|
163
|
+
and pairing still refuses an expired ticket. `parseOffer` reads a compact QR too, returning a `CompactOffer` (expiry,
|
|
164
|
+
addresses, name, role) with no `host` or `ticket`: narrow with `'host' in offer` before reading a version 1 offer's
|
|
165
|
+
fields. `parseV1Offer` is the old version 1-only parse, for `offerText` and `encodeOffer`, which take a `PairOffer`.
|
|
158
166
|
|
|
159
|
-
For an offline, typeable alternative, `encodeOffer(
|
|
167
|
+
For an offline, typeable alternative, `encodeOffer(parseV1Offer(text))` holds the **entire offer**, including
|
|
160
168
|
all direct and relay addresses. `decodeOffer` also reads old compact direct codes for migration, preserving
|
|
161
169
|
their key, ticket, role and second-resolution expiry; re-encoding uses the current complete format.
|
|
162
170
|
`decodeOffer(typed)` returns a `PairOffer` and checks expiry without contacting
|
|
@@ -184,7 +192,7 @@ old connections, and changed expiry reschedules access. A failed load rejects an
|
|
|
184
192
|
A `GrantStore` may provide `subscribe(changed): unsubscribe`; the host reloads automatically on notification,
|
|
185
193
|
reports reload failures through `onError`, and unsubscribes on `close()`.
|
|
186
194
|
|
|
187
|
-
Node apps can use `fileGrantStore(path)` from `@byokit/
|
|
195
|
+
Node apps can use `fileGrantStore(path)` from `@byokit/pair/node`. It touches the app's chosen file, temporary
|
|
188
196
|
files and a sibling `.lock` only, writes atomically at 0600 in a 0700 folder, rejects symlinks/nonprivate files on read, and polls
|
|
189
197
|
for cross-process replace/remove (default 100 ms; second argument changes the interval). Cooperating writers
|
|
190
198
|
use an exclusive lock and reject a stale loaded snapshot instead of overwriting an external revoke. Load again
|
|
@@ -205,7 +213,7 @@ The **handle** step authorizes the already-paired inviter, **offer** records the
|
|
|
205
213
|
then **confirm** checks that metadata before approving the new peer:
|
|
206
214
|
|
|
207
215
|
```ts
|
|
208
|
-
import { Host, keyPair, PublicLinkError } from '@byokit/
|
|
216
|
+
import { Host, keyPair, PublicLinkError } from '@byokit/pair';
|
|
209
217
|
|
|
210
218
|
// Supply the app's human approval UI.
|
|
211
219
|
declare const ui: { ask(message: string): Promise<boolean> };
|
|
@@ -233,7 +241,7 @@ const host: Host = await Host.open({
|
|
|
233
241
|
});
|
|
234
242
|
```
|
|
235
243
|
|
|
236
|
-
Import `Host`, `keyPair` and `PublicLinkError` from `@byokit/
|
|
244
|
+
Import `Host`, `keyPair` and `PublicLinkError` from `@byokit/pair`; `ui.ask` is the app's approval UI. The app
|
|
237
245
|
chooses metadata on the host, never from an untrusted invitee. This composition is delegated pairing:
|
|
238
246
|
the approved control device requests a constrained invitation; a peer cannot widen its terms. Check the
|
|
239
247
|
inviter's current grant, including expiry, in both `handle` and `confirm`. Apply capability scope in `allow`
|
|
@@ -245,7 +253,7 @@ long-lived or host-offline signed invitation.
|
|
|
245
253
|
## Device
|
|
246
254
|
|
|
247
255
|
To keep several paired computers, use `secureDeviceStores(secureStore, prefix?)` or
|
|
248
|
-
`browserDeviceStores(databaseName?)` from `@byokit/
|
|
256
|
+
`browserDeviceStores(databaseName?)` from `@byokit/pair`. Both offer `list()` (entry names), `load(name)`,
|
|
249
257
|
`save(name, grant)`, `remove(name)`, and `store(name)` (the one-entry adapter for `DeviceLink`). Names use
|
|
250
258
|
letters, digits, dots, underscores and dashes, up to 120 characters. A secure collection uses
|
|
251
259
|
`<prefix>.index` and `<prefix>.grant.<name>`; existing single-entry stores stay independent. Calls sharing
|
|
@@ -255,7 +263,7 @@ The browser collection enumerates existing `browserDeviceStore` entries in its d
|
|
|
255
263
|
non-extractable wrapping keys and forgotten-grant protection; removed tombstones stay out of `list()`.
|
|
256
264
|
|
|
257
265
|
```ts
|
|
258
|
-
import { DeviceLink, secureDeviceStores, type DeviceGrant, type SecureStoreLike } from '@byokit/
|
|
266
|
+
import { DeviceLink, secureDeviceStores, type DeviceGrant, type SecureStoreLike } from '@byokit/pair';
|
|
259
267
|
|
|
260
268
|
// Supply the platform secure-storage module and a grant returned by pairing.
|
|
261
269
|
declare const SecureStore: SecureStoreLike;
|
|
@@ -269,7 +277,7 @@ await computers.remove('kitchen');
|
|
|
269
277
|
```
|
|
270
278
|
|
|
271
279
|
```ts
|
|
272
|
-
import { DeviceLink, pairWithOffer, pairWithCode } from '@byokit/
|
|
280
|
+
import { DeviceLink, pairWithOffer, pairWithCode } from '@byokit/pair';
|
|
273
281
|
|
|
274
282
|
const grant = await pairWithOffer(scanned, { name: 'Pixel 9', onWords: (w) => show(w) });
|
|
275
283
|
// Or pairWithCode(url, typed, { name: 'Pixel 9', onWords: (w) => show(w) });
|
|
@@ -279,10 +287,17 @@ await link.request('send.message', { text: 'hi' }); // waits through reconnects
|
|
|
279
287
|
await link.request('send.message', { text: 'hi' }, { timeoutMs: 20_000, notValidAfter: Date.now() + 60_000 });
|
|
280
288
|
```
|
|
281
289
|
|
|
282
|
-
- For crash-safe pairing,
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
290
|
+
- For crash-safe pairing, pass `onPending: (g) => secureStore.save(g)` to `pairWithOffer`. It gets the pending grant
|
|
291
|
+
before the host can approve this device: for a compact offer, once the host proved it holds the code and before
|
|
292
|
+
this device's key and name reach it (so the grant pins the host key the handshake authenticated); for a version 1
|
|
293
|
+
offer, before dialling. If the app dies while the person decides, a `DeviceLink` made from the saved pending grant
|
|
294
|
+
retries until approval (up to five minutes after the offer expires), then forgets it if the host still has not
|
|
295
|
+
approved; a host with another key there is `refused`. Because the device tells the host it kept a pending grant, a yes
|
|
296
|
+
given after it went away still counts; a device that kept none gets no grant it could never use. So when
|
|
297
|
+
`pairWithOffer` rejects after `onPending` ran without a sealed refusal (`e.sealed` false, e.g. `unreachable` once the
|
|
298
|
+
computer has taken the code), keep the pending grant and make a `DeviceLink` from it: it comes online on a yes and forgets
|
|
299
|
+
itself on a no.
|
|
300
|
+
`pendingGrant(scanned, { name, key, host })` builds the same grant; a compact offer needs `host`.
|
|
286
301
|
- `resolve: (url) => …` runs before each dial (e.g. open an SSH tunnel and return `ws://127.0.0.1:<port>/…`);
|
|
287
302
|
`link.addUrl(url)` adds an address found later (a wrong host there just fails its handshake).
|
|
288
303
|
- A quiet connection is pinged (`pingMs`, default 20 s) and redialled when the host stops answering; at most
|
|
@@ -308,7 +323,7 @@ One store per paired computer, each with `load()` for the next start:
|
|
|
308
323
|
|---|---|---|
|
|
309
324
|
| Phone | `secureDeviceStore(SecureStore, 'byokit.link.home')` with `expo-secure-store` | Keychain on iOS, Keystore on Android; the grant is about 300 bytes, one value. |
|
|
310
325
|
| Browser or PWA | `browserDeviceStore('home')` | IndexedDB, sealed with AES-GCM by a non-extractable browser key. The stored record alone opens nothing, but scripts running on the same origin can still use the key to decrypt it; keep untrusted scripts off the page. |
|
|
311
|
-
| Computer (Node, Electron's main process) | `fileDeviceStore(path, safeStorage?)` from `@byokit/
|
|
326
|
+
| Computer (Node, Electron's main process) | `fileDeviceStore(path, safeStorage?)` from `@byokit/pair/node` | A 0600 file in a newly created 0700 folder (an existing folder keeps its permissions), sealed with Electron's `safeStorage` when given. Without `safeStorage`, the file contains the grant in plaintext. |
|
|
312
327
|
|
|
313
328
|
```ts
|
|
314
329
|
const store = secureDeviceStore(SecureStore, 'byokit.link.home');
|
|
@@ -320,7 +335,7 @@ const link = new DeviceLink(grant, { store, onStatus });
|
|
|
320
335
|
### React Native
|
|
321
336
|
|
|
322
337
|
Install a `crypto.getRandomValues` polyfill such as `react-native-get-random-values` (or use `expo-crypto`) and
|
|
323
|
-
import it **before** `@byokit/
|
|
338
|
+
import it **before** `@byokit/pair`. Metro resolves `sodium-universal` to `sodium-javascript` through its browser
|
|
324
339
|
field. The device uses the platform's WebSocket and needs no Node globals. See [`examples/expo`](../../examples/expo).
|
|
325
340
|
|
|
326
341
|
## Streams
|
|
@@ -348,6 +363,13 @@ s.end();
|
|
|
348
363
|
View-only devices open only the streams `allow` permits (or `canView` when `allow` is absent). A host without `stream`
|
|
349
364
|
tells devices so (`LinkError` `not-supported`), and so does a host older than streams.
|
|
350
365
|
|
|
366
|
+
For whole JSON messages instead of bytes, `ndjson(stream)` sends one object per `\n`-terminated line and delivers
|
|
367
|
+
each complete message in order: `const j = ndjson(s); await j.send({ text: 'hi' }); j.onMessage((m) => …)`.
|
|
368
|
+
Messages split at any byte boundary arrive whole, including a multi-byte character split across two chunks, and a
|
|
369
|
+
returned handler promise holds the sender back through the same credit window. A line past `NDJSON_MAX_LINE`
|
|
370
|
+
(1 MiB, `ndjson(s, { maxLine })` changes it) ends the stream with `'line-too-long'` on both ends and is never
|
|
371
|
+
delivered, so nothing is buffered without bound; a line that is not JSON ends it with `failed`.
|
|
372
|
+
|
|
351
373
|
On a direct socket, stream bytes go as binary WebSocket messages (without base64 overhead); through a relay, whose
|
|
352
374
|
host wrapper is text, the same frames go as base64 text. The host says which in `ready`; everything else stays text.
|
|
353
375
|
|
|
@@ -367,7 +389,7 @@ Frames are the same bytes on every path; a relay only routes them and cannot rea
|
|
|
367
389
|
- The relay authenticates the host's socket and limits abuse its own way. Link routing needs no device id: the host
|
|
368
390
|
checks devices itself on every handshake.
|
|
369
391
|
|
|
370
|
-
`@byokit/
|
|
392
|
+
`@byokit/pair` does not include a relay server; use [`@byokit/relay`](../relay) for the standalone server,
|
|
371
393
|
reconnecting host client and device code lookup.
|
|
372
394
|
|
|
373
395
|
## Migrating pre-kit phone pairings
|
|
@@ -406,10 +428,10 @@ share one key across machines. Muxr parity beyond this migration is tracked sepa
|
|
|
406
428
|
## Checking reachability before pairing
|
|
407
429
|
|
|
408
430
|
```ts
|
|
409
|
-
import { check, parseOffer } from '@byokit/
|
|
431
|
+
import { check, parseOffer } from '@byokit/pair';
|
|
410
432
|
|
|
411
|
-
//
|
|
412
|
-
//
|
|
433
|
+
// Phone: before asking the person to approve, probe the scanned offer's URLs.
|
|
434
|
+
// Results come back in input order.
|
|
413
435
|
export async function precheck(scanned: string): Promise<void> {
|
|
414
436
|
const { urls } = parseOffer(scanned);
|
|
415
437
|
const results = await check(urls, { timeoutMs: 5000 });
|
|
@@ -419,6 +441,22 @@ export async function precheck(scanned: string): Promise<void> {
|
|
|
419
441
|
}
|
|
420
442
|
```
|
|
421
443
|
|
|
444
|
+
Setup runs the same probe on its own advertised URLs before printing the tick, so a dead
|
|
445
|
+
address never becomes a QR. The phone runs it again after scanning, before consent:
|
|
446
|
+
|
|
447
|
+
```ts
|
|
448
|
+
import { check } from '@byokit/pair';
|
|
449
|
+
|
|
450
|
+
// Setup: self-check the offer's URLs first; only print the tick when one answers.
|
|
451
|
+
export async function selfcheck(urls: string[]): Promise<boolean> {
|
|
452
|
+
const results = await check(urls, { timeoutMs: 5000 });
|
|
453
|
+
for (const r of results) {
|
|
454
|
+
if (!r.ok) console.log(`${r.url}: ${r.message}`);
|
|
455
|
+
}
|
|
456
|
+
return results.some((r) => r.ok);
|
|
457
|
+
}
|
|
458
|
+
```
|
|
459
|
+
|
|
422
460
|
Probed four at a time by default (`concurrency` changes that). The probe answers read-only: it sends no ticket,
|
|
423
461
|
so a checked offer still pairs afterwards, and the reply carries nothing but the fact a link host is there. It
|
|
424
462
|
works in Node, browsers and React Native, and through a relay with no relay change (devices speak bare frames
|
|
@@ -470,13 +508,13 @@ untrusted address, never a trusted machine key. A malicious lookup pointing at a
|
|
|
470
508
|
fails the commitment check. QR offers and legacy `host.code()` remain compatible; callers of
|
|
471
509
|
the latter do not get this additional machine-key pinning.
|
|
472
510
|
|
|
473
|
-
Runnable loopback example (Node 22.18+, `npm install @byokit/
|
|
511
|
+
Runnable loopback example (Node 22.18+, `npm install @byokit/pair ws`; save as `pair.ts`, run
|
|
474
512
|
`node pair.ts`). In an app, show the code on the computer and let the phone type it:
|
|
475
513
|
|
|
476
514
|
```ts
|
|
477
515
|
import { createInterface } from 'node:readline/promises';
|
|
478
516
|
import { WebSocketServer } from 'ws';
|
|
479
|
-
import { DeviceLink, Host, keyPair, pairWithCode } from '@byokit/
|
|
517
|
+
import { DeviceLink, Host, keyPair, pairWithCode } from '@byokit/pair';
|
|
480
518
|
|
|
481
519
|
const terminal = createInterface({ input: process.stdin, output: process.stdout });
|
|
482
520
|
const host = await Host.open({
|
package/SECURITY.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @byokit/
|
|
1
|
+
# @byokit/pair: threat model and review checklist
|
|
2
2
|
|
|
3
3
|
Muxr parity is tracked separately.
|
|
4
4
|
|
|
@@ -27,7 +27,7 @@ A home computer (the **host**) holds AI sign-ins and other credentials. Phones,
|
|
|
27
27
|
| QR / pairing link | `byokit-link:1:<base64url JSON>`: `{v, host (X25519 public key), name, urls, ticket (128 bits), expires, role?, lifetime?}`. The optional role and access lifetime let the device show the terms; older parsers ignore them. As a link, it rides after `#`, which browsers never send to a server. |
|
|
28
28
|
| Offline offer envelope | Versioned base32 of the complete validated offer plus a 32-bit FNV-1a transcription checksum. Case, whitespace, dashes and O/0, I/L/1 aliases are normalized; noncanonical padding is refused. The checksum is not authentication. The same QR ticket, host key, expiry and approval rules apply; relay addresses and grant terms are preserved. |
|
|
29
29
|
| Typed code | 12 characters from a 31-character unambiguous alphabet (about 59 bits), `XXXX-XXXX-XXXX`. |
|
|
30
|
-
| Pairing rules | Tickets and codes are single use (burned at first presentation, even when expired or refused), live 5 minutes through grant creation, and 5 wrong tries withdraw every open ticket and code. Nothing is stored until the person at the host approves; both screens show the same **two confirmation words**, derived from the handshake hash. Optional device cap. |
|
|
30
|
+
| Pairing rules | Tickets and codes are single use (burned at first presentation, even when expired or refused), live 5 minutes through grant creation, and 5 wrong tries withdraw every open ticket and code. Nothing is stored until the person at the host approves; both screens show the same **two confirmation words**, derived from the handshake hash. Optional device cap. An approval holds after the device's socket dropped only when the device said, inside the handshake (`pending: 1` in its sealed `pair` request or typed-code message 3), that it kept a pending grant; the grant names only the device key that handshake authenticated. Any other device that leaves gets no grant, so no slot is held by a key nobody has. A compact QR carries no host key; the device keeps the key the code handshake authenticated (after message 2, before its identity goes out in message 3) as its pending grant's pin, so a resumed link is pinned exactly as from a version 1 QR. |
|
|
31
31
|
| Grants | Held by the host with a device key and role; optional `kind`, `expires`, `nextKey` and `meta` support caps, expiry, rekey and app policy. Every handshake and request checks the grant. `allow` decides access when supplied; otherwise control is allowed and view-only uses `canView` (default: none). |
|
|
32
32
|
| Presence and reload | Presence counts authenticated live sockets per grant and fires only on first/last. Store notifications trigger a serialized `Host.reload` with no write-back; removed/key-changed/role-changed grants close live sockets, and changed expiry reschedules access. A failed read preserves the last known authority and reports the failure: notifications are eventual, so an app needing instantaneous external-authority checks must enforce them in `allow`. The Node file backend uses a cooperating-writer lock and rejects stale snapshots; it polls only the app's chosen path and stops when the host closes. |
|
|
33
33
|
| Ready metadata | Only the opt-in `deviceMeta(grant)` projection is sent to the device, inside the sealed `ready`. Host grant metadata is private by default. The app must not project credentials. |
|
|
@@ -115,9 +115,9 @@ A home computer (the **host**) holds AI sign-ins and other credentials. Phones,
|
|
|
115
115
|
- [x] Host policy is enforced before the app handler; without `allow`, view-only defaults closed. Only an explicit `PublicLinkError` discloses its chosen message; other handler failures are logged on the host and return plain `failed`.
|
|
116
116
|
- [x] A device forgets its grant only on a sealed `revoked`, `ended` or `not-paired` (or when a pending pairing expires).
|
|
117
117
|
- [x] Names shown to people are stripped of control and direction-flipping characters and capped at 60.
|
|
118
|
-
- [x] `
|
|
118
|
+
- [x] `parseV1Offer` accepts only `ws:`/`wss:` addresses without credentials, at most 8, a 32-byte key and 16-byte
|
|
119
119
|
ticket.
|
|
120
|
-
- [x] Offline offer decoding checks the checksum, version, canonical padding, bounds and all `
|
|
120
|
+
- [x] Offline offer decoding checks the checksum, version, canonical padding, bounds and all `parseV1Offer` rules, including expiry; inspecting with `now = 0` never renews a ticket. Legacy compact offers are accepted only on the migration read path, with their original pinned key, ticket, role and second-resolution expiry; they cannot change new encodings.
|
|
121
121
|
- [x] The channel refuses frames past 2³² − 1 and messages over 16 MB.
|
|
122
122
|
- [x] Stream opens re-check the grant and policy before the app's `stream` handler; data past a stream's window,
|
|
123
123
|
or stream data that isn't a binary inner message, drops the socket; streams end on disconnect and revoke.
|
|
@@ -132,7 +132,7 @@ A home computer (the **host**) holds AI sign-ins and other credentials. Phones,
|
|
|
132
132
|
authenticates with it.
|
|
133
133
|
- [x] The handshake limiter runs before any Diffie-Hellman.
|
|
134
134
|
- [x] Nothing in the main entry reads or writes files, environment variables or other programs.
|
|
135
|
-
`@byokit/
|
|
135
|
+
`@byokit/pair/node` `hostKeyFile(path)` touches only that path: 0600 in 0700, atomic, never replaces a file it
|
|
136
136
|
can't read, refuses symlinks.
|
|
137
137
|
|
|
138
138
|
- [ ] Presence fires once on first/last sockets, including failed/successful unpair, reload, expiry and close.
|
package/dist/check.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type CheckOptions = {
|
|
|
28
28
|
onerror: unknown;
|
|
29
29
|
};
|
|
30
30
|
};
|
|
31
|
-
/** Probe each URL for a link host, in input order. Only `unreachable` (nothing there
|
|
32
|
-
* that never answered) and `wrong-host` (something else answered) can
|
|
33
|
-
*
|
|
31
|
+
/** Probe each URL for a link host, in input order. Only `unreachable` (nothing there: same network or firewall),
|
|
32
|
+
* `slow` (something there that never answered: wait and try again) and `wrong-host` (something else answered) can
|
|
33
|
+
* come back, each with its LINK_WORDS sentence. Never sends a ticket, so offers stay usable. */
|
|
34
34
|
export declare function check(urls: string[], o?: CheckOptions): Promise<CheckResult[]>;
|
package/dist/check.js
CHANGED
|
@@ -24,7 +24,7 @@ function probe(url, o, timeoutMs) {
|
|
|
24
24
|
catch { }
|
|
25
25
|
resolve(r);
|
|
26
26
|
};
|
|
27
|
-
const timer = setTimeout(() => finish(fail(url, opened ? '
|
|
27
|
+
const timer = setTimeout(() => finish(fail(url, opened ? 'slow' : 'unreachable')), timeoutMs);
|
|
28
28
|
timer.unref?.();
|
|
29
29
|
try {
|
|
30
30
|
const WS = o.WebSocket ?? globalThis.WebSocket;
|
|
@@ -55,9 +55,9 @@ function probe(url, o, timeoutMs) {
|
|
|
55
55
|
};
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
|
-
/** Probe each URL for a link host, in input order. Only `unreachable` (nothing there
|
|
59
|
-
* that never answered) and `wrong-host` (something else answered) can
|
|
60
|
-
*
|
|
58
|
+
/** Probe each URL for a link host, in input order. Only `unreachable` (nothing there: same network or firewall),
|
|
59
|
+
* `slow` (something there that never answered: wait and try again) and `wrong-host` (something else answered) can
|
|
60
|
+
* come back, each with its LINK_WORDS sentence. Never sends a ticket, so offers stay usable. */
|
|
61
61
|
export async function check(urls, o = {}) {
|
|
62
62
|
const timeoutMs = o.timeoutMs ?? 8000;
|
|
63
63
|
const out = new Array(urls.length);
|
package/dist/device.d.ts
CHANGED
|
@@ -41,7 +41,7 @@ export type Dial = {
|
|
|
41
41
|
};
|
|
42
42
|
/** Every way the link can fail, in words a person can act on. */
|
|
43
43
|
export declare const LINK_WORDS: {
|
|
44
|
-
readonly unreachable: "Can't reach your computer. Check
|
|
44
|
+
readonly unreachable: "Can't reach your computer. Check the phone and computer are on the same network, and that no firewall is blocking the connection.";
|
|
45
45
|
readonly 'wrong-host': "Something other than your computer answered, so this device didn't connect.";
|
|
46
46
|
readonly 'wrong-code': "That code didn't match. Check it, or show a new one on your computer.";
|
|
47
47
|
readonly expired: "That pairing code has run out. Show a new one on your computer.";
|
|
@@ -51,6 +51,7 @@ export declare const LINK_WORDS: {
|
|
|
51
51
|
readonly removed: "This device was removed on your computer.";
|
|
52
52
|
readonly 'view-only': "This device can watch but not make changes.";
|
|
53
53
|
readonly timeout: "Your computer didn't answer in time.";
|
|
54
|
+
readonly slow: "Your computer is slow to answer. Wait a moment and try again.";
|
|
54
55
|
readonly failed: "Your computer couldn't do that.";
|
|
55
56
|
readonly busy: "Your computer is still catching up. Try again in a moment.";
|
|
56
57
|
readonly stopped: "This link is stopped. Try connecting again.";
|
|
@@ -78,15 +79,24 @@ type PairOptions = Dial & {
|
|
|
78
79
|
};
|
|
79
80
|
/** The grant a scanned code will become, to keep in secure storage *before* pairing. If the app dies while the person
|
|
80
81
|
* at the host decides, a `DeviceLink` made from it later connects once the host has said yes (or learns it said no),
|
|
81
|
-
* instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer
|
|
82
|
+
* instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`, or let
|
|
83
|
+
* `pairWithOffer`'s `onPending` hand it over. A compact offer carries no host key: it needs `host`, the key the code
|
|
84
|
+
* handshake authenticated (`onPending` passes it). For a version 1 offer, a different `host` is refused. */
|
|
82
85
|
export declare function pendingGrant(scanned: string, o: {
|
|
83
86
|
name: string;
|
|
84
87
|
key?: KeyPair;
|
|
88
|
+
host?: string;
|
|
85
89
|
}): DeviceGrant;
|
|
90
|
+
type OfferOptions = PairOptions & {
|
|
91
|
+
/** Keep this pending grant before the host can approve this device (see `pendingGrant`). A compact offer calls it
|
|
92
|
+
* once the host proved it holds the code, before this device's key and name reach it; a throw stops pairing there. */
|
|
93
|
+
onPending?: (g: DeviceGrant) => void | Promise<void>;
|
|
94
|
+
};
|
|
86
95
|
/** A scanned QR (or opened pairing link) in, a grant out, once the person at the host says yes. `onWords` gets the
|
|
87
96
|
* two words to show while they decide. Tries each address in the code until one answers. A compact offer pairs
|
|
88
|
-
* like a typed code over the packed addresses: same single use, life, words and approval
|
|
89
|
-
|
|
97
|
+
* like a typed code over the packed addresses: same single use, life, words and approval; the host key it pins is
|
|
98
|
+
* the one the code handshake authenticated, handed to `onPending` before the host learns this device. */
|
|
99
|
+
export declare function pairWithOffer(scanned: string, o: OfferOptions): Promise<DeviceGrant>;
|
|
90
100
|
/** A typed code in, a grant out. `Host.shortCode` also pins the machine key before sharing the device identity.
|
|
91
101
|
* `url` is an untrusted address, e.g. from relay `findHost`; only the full code authenticates the host. */
|
|
92
102
|
export declare function pairWithCode(url: string, typed: string, o: PairOptions): Promise<DeviceGrant>;
|
package/dist/device.js
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// its requests survive a reconnect: each carries a key, so a retried tap runs once. Uses only the platform's
|
|
4
4
|
// WebSocket, so the same code runs in browsers, React Native and Node.
|
|
5
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";
|
|
6
|
+
import { cleanName, codeKey, decodeCompactOffer, parseCode, parseOffer, parseV1Offer, shortKey, COMPACT_TAG } from "./pairing.js";
|
|
7
7
|
import { Streams } from "./stream.js";
|
|
8
8
|
/** Every way the link can fail, in words a person can act on. */
|
|
9
9
|
export const LINK_WORDS = {
|
|
10
|
-
unreachable: "Can't reach your computer. Check
|
|
10
|
+
unreachable: "Can't reach your computer. Check the phone and computer are on the same network, and that no firewall is blocking the connection.",
|
|
11
11
|
'wrong-host': "Something other than your computer answered, so this device didn't connect.",
|
|
12
12
|
'wrong-code': "That code didn't match. Check it, or show a new one on your computer.",
|
|
13
13
|
expired: 'That pairing code has run out. Show a new one on your computer.',
|
|
@@ -17,6 +17,7 @@ export const LINK_WORDS = {
|
|
|
17
17
|
removed: 'This device was removed on your computer.',
|
|
18
18
|
'view-only': 'This device can watch but not make changes.',
|
|
19
19
|
timeout: "Your computer didn't answer in time.",
|
|
20
|
+
slow: 'Your computer is slow to answer. Wait a moment and try again.',
|
|
20
21
|
failed: "Your computer couldn't do that.",
|
|
21
22
|
busy: 'Your computer is still catching up. Try again in a moment.',
|
|
22
23
|
stopped: 'This link is stopped. Try connecting again.',
|
|
@@ -43,6 +44,10 @@ export class PublicLinkError extends LinkError {
|
|
|
43
44
|
const known = (why) => typeof why === 'string' && Object.hasOwn(LINK_WORDS, why);
|
|
44
45
|
const problem = (why) => (known(why) ? why : 'unreachable');
|
|
45
46
|
const later = (ms, fn) => { const t = setTimeout(fn, ms); t.unref?.(); return t; };
|
|
47
|
+
const within = (ms, work) => new Promise((resolve, reject) => {
|
|
48
|
+
const t = later(ms, () => reject(new LinkError('timeout')));
|
|
49
|
+
work.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
|
|
50
|
+
});
|
|
46
51
|
/** One socket: the handshake, the first request (`auth` or `pair`), and the host's `ready`. */
|
|
47
52
|
async function dial(url, me, host, hello, o, on = { message: () => { }, close: () => { } }) {
|
|
48
53
|
let target = url;
|
|
@@ -75,7 +80,8 @@ async function dial(url, me, host, hello, o, on = { message: () => { }, close: (
|
|
|
75
80
|
}
|
|
76
81
|
catch { }
|
|
77
82
|
};
|
|
78
|
-
let
|
|
83
|
+
let opened = false;
|
|
84
|
+
let timer = later(o.timeoutMs ?? 8000, () => fail(new LinkError(opened ? 'slow' : 'unreachable')));
|
|
79
85
|
try {
|
|
80
86
|
ws = new WS(target);
|
|
81
87
|
ws.binaryType = 'arraybuffer';
|
|
@@ -88,7 +94,12 @@ async function dial(url, me, host, hello, o, on = { message: () => { }, close: (
|
|
|
88
94
|
ws.send(f); };
|
|
89
95
|
const data = (s, d) => { for (const f of ch.sealData(s, d))
|
|
90
96
|
ws.send(binary ? f : b64(f)); };
|
|
91
|
-
ws.onopen = () =>
|
|
97
|
+
ws.onopen = () => { opened = true; try {
|
|
98
|
+
ws.send(hs.write(mode === 'ik' ? { v: 1 } : {}));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
fail(new LinkError('unreachable'));
|
|
102
|
+
} };
|
|
92
103
|
ws.onerror = () => fail(new LinkError('unreachable'));
|
|
93
104
|
// Before the handshake finishes nothing is authenticated, so these only choose what to say, never what to forget.
|
|
94
105
|
ws.onclose = (e) => fail(new LinkError(ch ? 'unreachable' : e?.code === 4401 && mode === 'code' ? 'wrong-code' : e?.code === 4403 ? 'wrong-host' : 'unreachable'));
|
|
@@ -107,16 +118,33 @@ async function dial(url, me, host, hello, o, on = { message: () => { }, close: (
|
|
|
107
118
|
} // only the right host can answer
|
|
108
119
|
if (host.commitment && shortKey(hs.remoteKey) !== host.commitment)
|
|
109
120
|
return fail(new LinkError('wrong-host'));
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
121
|
+
const go = () => {
|
|
122
|
+
if (hello.t === 'code')
|
|
123
|
+
ws.send(hs.write({ name: hello.name, ...(hello.pending ? { pending: 1 } : {}) }));
|
|
124
|
+
ch = hs.channel();
|
|
125
|
+
if (hello.t !== 'code')
|
|
126
|
+
send(hello);
|
|
127
|
+
if (pairing) { // now a person at the host decides; give them time
|
|
128
|
+
o.onWords?.(hs.words);
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
timer = later(6 * 60_000, () => fail(new LinkError('slow')));
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
if (!host.pin)
|
|
134
|
+
return go();
|
|
135
|
+
// The host proved it holds the code; the app keeps its key before this device is disclosed in message 3. A save
|
|
136
|
+
// that never settles times out, or ends sooner when the host drops the socket.
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
within(o.timeoutMs ?? 8000, Promise.resolve().then(() => host.pin(hs.remoteKey))).then(() => {
|
|
139
|
+
if (settled)
|
|
140
|
+
return;
|
|
141
|
+
try {
|
|
142
|
+
go();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
fail(new LinkError('unreachable'));
|
|
146
|
+
}
|
|
147
|
+
}, fail);
|
|
120
148
|
return;
|
|
121
149
|
}
|
|
122
150
|
const m = ch.open(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data)); // throws unless it is the host's next authentic frame
|
|
@@ -146,47 +174,72 @@ function granted(me, hostKey, url, urls, ready) {
|
|
|
146
174
|
}
|
|
147
175
|
/** The grant a scanned code will become, to keep in secure storage *before* pairing. If the app dies while the person
|
|
148
176
|
* 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
|
|
177
|
+
* instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`, or let
|
|
178
|
+
* `pairWithOffer`'s `onPending` hand it over. A compact offer carries no host key: it needs `host`, the key the code
|
|
179
|
+
* handshake authenticated (`onPending` passes it). For a version 1 offer, a different `host` is refused. */
|
|
150
180
|
export function pendingGrant(scanned, o) {
|
|
151
181
|
const offer = parseOffer(scanned);
|
|
182
|
+
let host = 'host' in offer ? offer.host : undefined;
|
|
183
|
+
if (o.host !== undefined) {
|
|
184
|
+
let given;
|
|
185
|
+
try {
|
|
186
|
+
given = unb64url(o.host);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
throw new LinkError('wrong-host');
|
|
190
|
+
}
|
|
191
|
+
if (given.length !== 32 || (host !== undefined && host !== b64url(given)))
|
|
192
|
+
throw new LinkError('wrong-host');
|
|
193
|
+
host = b64url(given);
|
|
194
|
+
}
|
|
195
|
+
if (host === undefined)
|
|
196
|
+
throw new Error("A compact pairing code doesn't carry the computer's key: use pairWithOffer's onPending.");
|
|
152
197
|
const me = o.key ?? keyPair();
|
|
153
|
-
return { v: 1, secretKey: b64url(me.secretKey), pendingUntil: offer.expires + 300_000, host
|
|
198
|
+
return { v: 1, secretKey: b64url(me.secretKey), pendingUntil: offer.expires + 300_000, host, hostName: offer.name, urls: offer.urls,
|
|
154
199
|
device: { id: '', name: cleanName(o.name, 'Device'), role: offer.role ?? 'view' } };
|
|
155
200
|
}
|
|
156
201
|
/** A scanned QR (or opened pairing link) in, a grant out, once the person at the host says yes. `onWords` gets the
|
|
157
202
|
* 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
|
|
203
|
+
* like a typed code over the packed addresses: same single use, life, words and approval; the host key it pins is
|
|
204
|
+
* the one the code handshake authenticated, handed to `onPending` before the host learns this device. */
|
|
159
205
|
export async function pairWithOffer(scanned, o) {
|
|
206
|
+
let taken = false; // the host has the code (it read the ticket or the code's first message): no other address can answer
|
|
207
|
+
const words = { ...o, onWords: (w) => { taken = true; o.onWords(w); } };
|
|
160
208
|
if (scanned.indexOf(COMPACT_TAG) >= 0) {
|
|
161
209
|
const compact = decodeCompactOffer(scanned);
|
|
162
210
|
const me = o.key ?? keyPair();
|
|
211
|
+
const pin = o.onPending && ((key) => { taken = true; return o.onPending(pendingGrant(scanned, { name: o.name, key: me, host: b64url(key) })); });
|
|
163
212
|
let last = new LinkError('unreachable');
|
|
164
213
|
for (const url of compact.urls) {
|
|
165
214
|
try {
|
|
166
|
-
const l = await dial(url, me, { psk: codeKey(compact.code) }, { t: 'code', name: o.name },
|
|
215
|
+
const l = await dial(url, me, { psk: codeKey(compact.code), pin }, { t: 'code', name: o.name, ...(pin ? { pending: 1 } : {}) }, words);
|
|
167
216
|
l.close();
|
|
168
217
|
return granted(me, b64url(l.hostKey), url, compact.urls, l.ready);
|
|
169
218
|
}
|
|
170
219
|
catch (e) {
|
|
171
|
-
|
|
172
|
-
|
|
220
|
+
if (!(e instanceof LinkError))
|
|
221
|
+
throw e; // the app's own onPending failure, as it said it
|
|
222
|
+
last = e;
|
|
223
|
+
if (taken || (last.code !== 'unreachable' && last.code !== 'slow' && last.code !== 'timeout' && last.code !== 'wrong-host'))
|
|
173
224
|
break;
|
|
174
225
|
}
|
|
175
226
|
}
|
|
176
227
|
throw last;
|
|
177
228
|
}
|
|
178
|
-
const offer =
|
|
229
|
+
const offer = parseV1Offer(scanned);
|
|
179
230
|
const me = o.key ?? keyPair();
|
|
231
|
+
if (o.onPending)
|
|
232
|
+
await within(o.timeoutMs ?? 8000, Promise.resolve(o.onPending(pendingGrant(scanned, { name: o.name, key: me }))));
|
|
180
233
|
let last = new LinkError('unreachable');
|
|
181
234
|
for (const url of offer.urls) {
|
|
182
235
|
try {
|
|
183
|
-
const l = await dial(url, me, { key: unb64url(offer.host) }, { t: 'pair', ticket: offer.ticket, name: o.name },
|
|
236
|
+
const l = await dial(url, me, { key: unb64url(offer.host) }, { t: 'pair', ticket: offer.ticket, name: o.name, ...(o.onPending ? { pending: 1 } : {}) }, words);
|
|
184
237
|
l.close();
|
|
185
238
|
return granted(me, offer.host, url, offer.urls, l.ready);
|
|
186
239
|
}
|
|
187
240
|
catch (e) {
|
|
188
241
|
last = e instanceof LinkError ? e : new LinkError('unreachable');
|
|
189
|
-
if (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host')
|
|
242
|
+
if (taken || (last.code !== 'unreachable' && last.code !== 'slow' && last.code !== 'timeout' && last.code !== 'wrong-host'))
|
|
190
243
|
break;
|
|
191
244
|
}
|
|
192
245
|
}
|
package/dist/host.js
CHANGED
|
@@ -428,7 +428,7 @@ export class Host {
|
|
|
428
428
|
const end = (c, why) => { if (!gone)
|
|
429
429
|
conn.close(c, why); };
|
|
430
430
|
const refuse = (why) => { this.sealed(conn, ch, { t: 'refused', why }); later(1000, () => end(4401, why)); };
|
|
431
|
-
const pair = async (key, name, p, how) => {
|
|
431
|
+
const pair = async (key, name, p, how, kept) => {
|
|
432
432
|
const k = b64url(key);
|
|
433
433
|
busy = true;
|
|
434
434
|
clearTimeout(timer);
|
|
@@ -438,10 +438,15 @@ export class Host {
|
|
|
438
438
|
Promise.resolve().then(() => this.opts.confirm(req)).catch(() => false),
|
|
439
439
|
new Promise((r) => later(this.pairMs, () => r(false))),
|
|
440
440
|
]);
|
|
441
|
-
|
|
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');
|
|
442
448
|
return;
|
|
443
|
-
|
|
444
|
-
return refuse('declined');
|
|
449
|
+
}
|
|
445
450
|
try {
|
|
446
451
|
const added = await this.grant(k, req.name, p, p.expires);
|
|
447
452
|
if (gone)
|
|
@@ -581,7 +586,7 @@ export class Host {
|
|
|
581
586
|
}
|
|
582
587
|
const hello = hs.read(text); // the typed-code handshake's last message: the device's key and name
|
|
583
588
|
ch = hs.channel();
|
|
584
|
-
void pair(hs.remoteKey, hello.name, code, 'code');
|
|
589
|
+
void pair(hs.remoteKey, hello.name, code, 'code', hello.pending);
|
|
585
590
|
};
|
|
586
591
|
const inner = (d, m) => {
|
|
587
592
|
if (m.t === 'req')
|
|
@@ -674,7 +679,7 @@ export class Host {
|
|
|
674
679
|
this.wrong();
|
|
675
680
|
return refuse('expired');
|
|
676
681
|
}
|
|
677
|
-
return void pair(hs.remoteKey, m.name, p, 'scan');
|
|
682
|
+
return void pair(hs.remoteKey, m.name, p, 'scan', m.pending);
|
|
678
683
|
}
|
|
679
684
|
end(4400, 'unexpected');
|
|
680
685
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export { hostId, keyPair, keyPairFrom, b64url, unb64url, type KeyPair } from './channel.ts';
|
|
2
2
|
export { Host, type AnswerStore, type Grant, type GrantStore, type GrantTerms, type HostOptions, type LinkRequest, type PairRequest, type Role, type Socket, } from './host.ts';
|
|
3
3
|
export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, type DeviceGrant, type DeviceStore, type Dial, type LinkOptions, type LinkProblem, type LinkStatus, type RequestOptions, } from './device.ts';
|
|
4
|
-
export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, COMPACT_TAG } from './pairing.ts';
|
|
5
|
-
export { LinkStream, WINDOW } from './stream.ts';
|
|
4
|
+
export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from './pairing.ts';
|
|
5
|
+
export { LinkStream, NDJSON_LINE_TOO_LONG, NDJSON_MAX_LINE, WINDOW, ndjson, type NdjsonOptions, type NdjsonStream } from './stream.ts';
|
|
6
6
|
export { check, LINK_PROBE, LINK_PROBE_OK, type CheckOptions, type CheckResult } from './check.ts';
|
|
7
7
|
export type { CompactOffer, PairOffer } from './pairing.ts';
|
|
8
8
|
export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores, type DeviceStores, type KeptDevice, type SecureStoreLike } from './stores.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export { hostId, keyPair, keyPairFrom, b64url, unb64url } from "./channel.js";
|
|
2
2
|
export { Host, } from "./host.js";
|
|
3
3
|
export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, } from "./device.js";
|
|
4
|
-
export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, COMPACT_TAG } from "./pairing.js";
|
|
5
|
-
export { LinkStream, WINDOW } from "./stream.js";
|
|
4
|
+
export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from "./pairing.js";
|
|
5
|
+
export { LinkStream, NDJSON_LINE_TOO_LONG, NDJSON_MAX_LINE, WINDOW, ndjson } from "./stream.js";
|
|
6
6
|
export { check, LINK_PROBE, LINK_PROBE_OK } from "./check.js";
|
|
7
7
|
export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores } from "./stores.js";
|
|
8
8
|
export { migrateGrant, GrantMigrationError } from "./migrate.js";
|
package/dist/pairing.d.ts
CHANGED
|
@@ -18,14 +18,17 @@ export declare const cleanName: (name: unknown, fallback: string) => string;
|
|
|
18
18
|
* the part after `#`, which browsers never send to a server. */
|
|
19
19
|
export declare function offerText(offer: PairOffer, base?: string): string;
|
|
20
20
|
/** Scan (or pasted link) in, offer out; throws a plain sentence for anything that is not a live byokit pairing code.
|
|
21
|
+
* A compact offer comes back without `host` or `ticket` (it has neither: `pairWithOffer` learns the host key).
|
|
21
22
|
* Pass `0` as `now` to inspect an expired offer without connecting. */
|
|
22
|
-
export declare function parseOffer(scanned: string, now?: number): PairOffer;
|
|
23
|
+
export declare function parseOffer(scanned: string, now?: number): PairOffer | CompactOffer;
|
|
24
|
+
/** A version 1 offer only; `parseOffer` for anything else. */
|
|
25
|
+
export declare function parseV1Offer(scanned: string, now?: number): PairOffer;
|
|
23
26
|
/** A complete offline offer in groups of five base32 characters, with a transcription checksum. This carries the
|
|
24
27
|
* same single-use ticket as the QR; it neither extends expiry nor replaces host approval. */
|
|
25
28
|
export declare function encodeOffer(offer: PairOffer): string;
|
|
26
29
|
/** Read an offline offer without network access. Case, spaces and dashes are ignored; O means 0 and I/L mean 1.
|
|
27
30
|
* Old compact direct offers remain readable for migration; new offers always use the current format.
|
|
28
|
-
* Other typos fail the checksum. Expiry and addresses follow `
|
|
31
|
+
* Other typos fail the checksum. Expiry and addresses follow `parseV1Offer`; `now = 0` permits inspection only. */
|
|
29
32
|
export declare function decodeOffer(text: string, now?: number): PairOffer;
|
|
30
33
|
/** Typed codes use letters and numbers nobody mixes up (no 0/O, 1/I/L). Twelve of them are about 59 bits: too many
|
|
31
34
|
* to guess, even offline, in the few minutes a code lives. */
|
package/dist/pairing.js
CHANGED
|
@@ -12,8 +12,13 @@ export function offerText(offer, base) {
|
|
|
12
12
|
return base ? `${base}#${body}` : body;
|
|
13
13
|
}
|
|
14
14
|
/** Scan (or pasted link) in, offer out; throws a plain sentence for anything that is not a live byokit pairing code.
|
|
15
|
+
* A compact offer comes back without `host` or `ticket` (it has neither: `pairWithOffer` learns the host key).
|
|
15
16
|
* Pass `0` as `now` to inspect an expired offer without connecting. */
|
|
16
17
|
export function parseOffer(scanned, now = Date.now()) {
|
|
18
|
+
return scanned.indexOf(COMPACT_TAG) >= 0 ? decodeCompactOffer(scanned, now) : parseV1Offer(scanned, now);
|
|
19
|
+
}
|
|
20
|
+
/** A version 1 offer only; `parseOffer` for anything else. */
|
|
21
|
+
export function parseV1Offer(scanned, now = Date.now()) {
|
|
17
22
|
const at = scanned.indexOf(TAG);
|
|
18
23
|
let o;
|
|
19
24
|
try {
|
|
@@ -63,7 +68,7 @@ function checksum(bytes) {
|
|
|
63
68
|
/** A complete offline offer in groups of five base32 characters, with a transcription checksum. This carries the
|
|
64
69
|
* same single-use ticket as the QR; it neither extends expiry nor replaces host approval. */
|
|
65
70
|
export function encodeOffer(offer) {
|
|
66
|
-
const checked =
|
|
71
|
+
const checked = parseV1Offer(offerText(offer), 0);
|
|
67
72
|
const body = b4a.from(JSON.stringify(checked));
|
|
68
73
|
const bytes = new Uint8Array(1 + body.length + 4);
|
|
69
74
|
bytes[0] = 1;
|
|
@@ -84,7 +89,7 @@ export function encodeOffer(offer) {
|
|
|
84
89
|
}
|
|
85
90
|
/** Read an offline offer without network access. Case, spaces and dashes are ignored; O means 0 and I/L mean 1.
|
|
86
91
|
* Old compact direct offers remain readable for migration; new offers always use the current format.
|
|
87
|
-
* Other typos fail the checksum. Expiry and addresses follow `
|
|
92
|
+
* Other typos fail the checksum. Expiry and addresses follow `parseV1Offer`; `now = 0` permits inspection only. */
|
|
88
93
|
export function decodeOffer(text, now = Date.now()) {
|
|
89
94
|
if (text.length > MAX_TYPED_OFFER)
|
|
90
95
|
throw badOffer();
|
|
@@ -111,7 +116,7 @@ export function decodeOffer(text, now = Date.now()) {
|
|
|
111
116
|
const b = Uint8Array.from(bytes);
|
|
112
117
|
if (b.length < 6 || b[0] !== 1 || checksum(b.subarray(0, -4)) !== new DataView(b.buffer).getUint32(b.length - 4))
|
|
113
118
|
throw badOffer();
|
|
114
|
-
return
|
|
119
|
+
return parseV1Offer(TAG + b64url(b.subarray(1, -4)), now);
|
|
115
120
|
}
|
|
116
121
|
// Migration read path only. The old direct envelope used seconds, a role byte and no name/lifetime.
|
|
117
122
|
function decodeLegacyOffer(s, now) {
|
|
@@ -169,7 +174,7 @@ function decodeLegacyOffer(s, now) {
|
|
|
169
174
|
}
|
|
170
175
|
if (at !== end || urls.some((url) => !url.startsWith('ws:') || !wsUrl(url)))
|
|
171
176
|
throw badOffer();
|
|
172
|
-
return
|
|
177
|
+
return parseV1Offer(offerText({
|
|
173
178
|
v: 1, host: b64url(b.subarray(2, 34)), ticket: b64url(b.subarray(34, 50)),
|
|
174
179
|
expires: view.getUint32(50) * 1000, name: 'your computer', role: b[1] ? 'control' : 'view', urls,
|
|
175
180
|
}), now);
|
package/dist/stream.d.ts
CHANGED
|
@@ -53,6 +53,29 @@ export declare class LinkStream {
|
|
|
53
53
|
ended(error?: string): void;
|
|
54
54
|
waitOpened(): Promise<LinkStream>;
|
|
55
55
|
}
|
|
56
|
+
/** The longest one JSON line `ndjson` buffers, in bytes (a line is its UTF-8 bytes up to `\n`). */
|
|
57
|
+
export declare const NDJSON_MAX_LINE: number;
|
|
58
|
+
/** The stream error an `ndjson` reader ends its stream with when a line runs past its cap. Both sides see it. */
|
|
59
|
+
export declare const NDJSON_LINE_TOO_LONG = "line-too-long";
|
|
60
|
+
export type NdjsonOptions = {
|
|
61
|
+
/** The longest one line buffered before the stream ends with `NDJSON_LINE_TOO_LONG`. Defaults to `NDJSON_MAX_LINE`. */
|
|
62
|
+
maxLine?: number;
|
|
63
|
+
};
|
|
64
|
+
export type NdjsonStream = {
|
|
65
|
+
/** The byte stream underneath, for `onEnd`, `end` and anything else not about JSON. */
|
|
66
|
+
readonly stream: LinkStream;
|
|
67
|
+
/** Sends one JSON message (`JSON.stringify` plus `\n`) through the stream's credit window. */
|
|
68
|
+
send(message: unknown): Promise<void>;
|
|
69
|
+
/** Each whole JSON message, in order. Returning a promise holds the sender back until it settles. */
|
|
70
|
+
onMessage(fn: (message: unknown) => void | Promise<void>): void;
|
|
71
|
+
/** Ends the stream both ways: after the sends already made, or at once with an error. */
|
|
72
|
+
end(error?: string): void;
|
|
73
|
+
};
|
|
74
|
+
/** Whole JSON messages over a byte `LinkStream`, one `\n`-terminated line each. Chunks split at any byte
|
|
75
|
+
* boundary reassemble before decoding, so a multi-byte character split across two chunks still arrives whole.
|
|
76
|
+
* A line past the cap ends the stream with `NDJSON_LINE_TOO_LONG` and is never delivered, so nothing is buffered
|
|
77
|
+
* without bound; a line that is not JSON ends it with `failed` through the stream's usual handler-error path. */
|
|
78
|
+
export declare function ndjson(stream: LinkStream, opts?: NdjsonOptions): NdjsonStream;
|
|
56
79
|
/** One connection's streams. */
|
|
57
80
|
export declare class Streams {
|
|
58
81
|
private all;
|
package/dist/stream.js
CHANGED
|
@@ -178,6 +178,79 @@ export class LinkStream {
|
|
|
178
178
|
return new Promise((resolve, reject) => { this.settle = { resolve, reject: (e) => reject(e) }; });
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
+
/** The longest one JSON line `ndjson` buffers, in bytes (a line is its UTF-8 bytes up to `\n`). */
|
|
182
|
+
export const NDJSON_MAX_LINE = 1024 * 1024;
|
|
183
|
+
/** The stream error an `ndjson` reader ends its stream with when a line runs past its cap. Both sides see it. */
|
|
184
|
+
export const NDJSON_LINE_TOO_LONG = 'line-too-long';
|
|
185
|
+
/** Whole JSON messages over a byte `LinkStream`, one `\n`-terminated line each. Chunks split at any byte
|
|
186
|
+
* boundary reassemble before decoding, so a multi-byte character split across two chunks still arrives whole.
|
|
187
|
+
* A line past the cap ends the stream with `NDJSON_LINE_TOO_LONG` and is never delivered, so nothing is buffered
|
|
188
|
+
* without bound; a line that is not JSON ends it with `failed` through the stream's usual handler-error path. */
|
|
189
|
+
export function ndjson(stream, opts = {}) {
|
|
190
|
+
const maxLine = opts.maxLine ?? NDJSON_MAX_LINE;
|
|
191
|
+
if (!Number.isInteger(maxLine) || maxLine < 1)
|
|
192
|
+
throw new Error('bad ndjson maxLine');
|
|
193
|
+
const decode = new TextDecoder();
|
|
194
|
+
let parts = [];
|
|
195
|
+
let buffered = 0;
|
|
196
|
+
let failed = false;
|
|
197
|
+
const untilNewline = () => {
|
|
198
|
+
let at = 0;
|
|
199
|
+
for (const p of parts) {
|
|
200
|
+
const i = p.indexOf(10);
|
|
201
|
+
if (i !== -1)
|
|
202
|
+
return at + i;
|
|
203
|
+
at += p.byteLength;
|
|
204
|
+
}
|
|
205
|
+
return -1;
|
|
206
|
+
};
|
|
207
|
+
const take = (n) => {
|
|
208
|
+
const out = new Uint8Array(n);
|
|
209
|
+
for (let at = 0; at < n;) {
|
|
210
|
+
const p = parts[0];
|
|
211
|
+
const k = Math.min(p.byteLength, n - at);
|
|
212
|
+
out.set(p.subarray(0, k), at);
|
|
213
|
+
at += k;
|
|
214
|
+
parts = k === p.byteLength ? parts.slice(1) : [p.subarray(k), ...parts.slice(1)];
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
};
|
|
218
|
+
return {
|
|
219
|
+
stream,
|
|
220
|
+
send(message) {
|
|
221
|
+
const line = JSON.stringify(message);
|
|
222
|
+
if (line === undefined)
|
|
223
|
+
throw new Error('ndjson cannot send undefined');
|
|
224
|
+
return stream.write(`${line}\n`);
|
|
225
|
+
},
|
|
226
|
+
onMessage(fn) {
|
|
227
|
+
stream.onData = async (chunk) => {
|
|
228
|
+
if (failed)
|
|
229
|
+
return;
|
|
230
|
+
parts.push(chunk);
|
|
231
|
+
buffered += chunk.byteLength;
|
|
232
|
+
for (;;) {
|
|
233
|
+
const at = untilNewline();
|
|
234
|
+
if (at === -1) {
|
|
235
|
+
if (buffered > maxLine) {
|
|
236
|
+
failed = true;
|
|
237
|
+
parts = [];
|
|
238
|
+
buffered = 0;
|
|
239
|
+
stream.end(NDJSON_LINE_TOO_LONG);
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const line = decode.decode(take(at));
|
|
244
|
+
take(1); // the '\n'
|
|
245
|
+
buffered -= at + 1;
|
|
246
|
+
if (line.length > 0)
|
|
247
|
+
await fn(JSON.parse(line));
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
end(error) { stream.end(error); },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
181
254
|
const count = (n, max) => {
|
|
182
255
|
if (!Number.isInteger(n) || n < 0 || n > max)
|
|
183
256
|
throw new Error('bad stream message');
|
package/package.json
CHANGED