@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/README.md CHANGED
@@ -1,3 +1,517 @@
1
- # Temporary Holding Version
1
+ <h1 align="center">@byokit/link</h1>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/@byokit/link"><img alt="npm" src="https://img.shields.io/npm/v/@byokit/link?style=flat&label=npm" /></a>
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
+ <a href="LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
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" />
8
+ </p>
9
+
10
+ <p align="center"><strong>Scan a code to pair a phone or browser with the home computer.</strong><br/>
11
+ One encrypted link between the two. The computer (the <strong>host</strong>) keeps every credential; a device holds
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
+ phone or browser.</p>
14
+
15
+ <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 &quot;$(command -v herdr)&quot; --via lan --name 'Kitchen computer', showing a large pairing QR code, then: On the phone, scan this. If its page is already open there, type K3J8-CJZ7-SE4R instead. Codes last five minutes. Press Enter for new ones. Connecting to Herdr… Connected to Herdr. (stand-in Herdr)" />
17
+ <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
+ </p>
19
+ <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>
20
+
21
+ Noise IK over WebSocket (`noise-handshake` + libsodium; frames sealed by `@noble/ciphers`), in Node, browsers/PWAs
22
+ and React Native. The main entry has no listener, files or environment access: the app hands the host its sockets
23
+ and stores. Threat model and review checklist: [SECURITY.md](SECURITY.md).
24
+
25
+ Host policy and streams are available; relay routing is in [`@byokit/relay`](../relay).
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ npm install @byokit/link
31
+ ```
32
+
33
+ [![npm](https://img.shields.io/npm/v/@byokit/link?style=flat&label=)](https://www.npmjs.com/package/@byokit/link) · [Latest release](https://github.com/umeranjum17/byokit/releases?q=link-v) · [All releases](https://github.com/umeranjum17/byokit/releases)
34
+
35
+ ## Quickstart
36
+
37
+ ```sh
38
+ npm install @byokit/link ws
39
+ ```
40
+
41
+ `ws` is only the WebSocket server for this example; any server works. A host and a device in one file, on loopback:
42
+
43
+ ```ts
44
+ import { WebSocketServer } from 'ws';
45
+ import { DeviceLink, Host, keyPair, pairWithOffer } from '@byokit/link';
46
+
47
+ // The computer: it keeps the credentials and answers requests.
48
+ const host = await Host.open({
49
+ keys: keyPair(), // keep it with hostKeyFile() from '@byokit/link/node' in a real app
50
+ name: 'Kitchen computer',
51
+ confirm: ({ name, words }) => { console.log(`host: pair ${name}? it shows "${words}"`); return true; },
52
+ handle: (req, device) => ({ echo: req.args, from: device.name }),
53
+ });
54
+ const wss = new WebSocketServer({ host: '127.0.0.1', port: 7300 });
55
+ wss.on('connection', (ws) => host.accept(ws));
56
+
57
+ // The offer is what the QR code shows.
58
+ const { text } = host.compactOffer({ role: 'control', urls: ['ws://127.0.0.1:7300/link'] });
59
+
60
+ // The phone or browser: scan, compare the words, then ask.
61
+ const grant = await pairWithOffer(text, { name: 'Pixel 9', onWords: (w) => console.log(`device: check "${w}"`) });
62
+ const link = new DeviceLink(grant, { onStatus: (s) => console.log(`device: ${s}`) });
63
+ console.log('answer:', await link.request('say.hello', { text: 'hi' }));
64
+
65
+ link.stop(); host.close(); wss.close();
66
+ ```
67
+
68
+ Output (the two words differ on every run):
69
+
70
+ ```text
71
+ device: check "rose oak"
72
+ host: pair Pixel 9? it shows "rose oak"
73
+ device: online
74
+ answer: { echo: { text: 'hi' }, from: 'Pixel 9' }
75
+ ```
76
+
77
+ ## API at a glance
78
+
79
+ | Export | What it does |
80
+ |---|---|
81
+ | `Host` | The computer's end: `Host.open(options)`, then `accept`/`relay` sockets, `offer`/`compactOffer`/`code`/`shortCode`/`enrol` to pair, `devices`, `setMeta`, `revoke`, `broadcast`, `close` |
82
+ | `pairWithOffer`, `pairWithCode` | Pair a device from a scanned offer or a typed code; returns its `DeviceGrant` |
83
+ | `parseOffer`, `offerText`, `cleanName` | Read a QR or link, build terminal-to-browser links, and clean a displayed name |
84
+ | `encodeOffer`, `decodeOffer` | Complete offline offer in typeable groups, with a transcription checksum |
85
+ | `pendingGrant` | A grant saved before pairing, for crash-safe pairing |
86
+ | `check` | Probe URLs for a link host before pairing, without touching the offer |
87
+ | `DeviceLink` | A device's live link: `request`, `stream`, `addUrl`, `rekey`, `unpair`, `retry`, `stop` |
88
+ | `LinkStream`, `WINDOW` | A duplex byte stream and its per-direction window (256 KB) |
89
+ | `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
+ | `secureDeviceStore`, `browserDeviceStore` | Where a device keeps its grant on a phone or in a browser |
91
+ | `keyPair`, `keyPairFrom`, `hostId`, `b64url`, `unb64url`, `normalizeCode` | Keys, the host id, base64url and typed-code helpers |
92
+ | `hostKeyFile`, `fileDeviceStore` (`@byokit/link/node`) | Node only: the host's key file, and a computer's device store |
93
+
94
+ ## Host
95
+
96
+ ```ts
97
+ import { Host } from '@byokit/link';
98
+ import { hostKeyFile } from '@byokit/link/node';
99
+
100
+ const host = await Host.open({
101
+ keys: hostKeyFile('./link-secret/host.key'), // made once in its own 0700 folder; or use your keychain
102
+ name: 'Kitchen computer',
103
+ grants: { load: () => db.grants(), save: (g) => db.setGrants(g) },
104
+ confirm: ({ name, words, role }) => ui.ask(`Pair ${name}? Check it shows “${words}”.`),
105
+ canView: (req) => req.op.startsWith('get.'), // what view-only devices may ask; default nothing
106
+ handle: (req, device) => app.run(req.op, req.args, device),
107
+ });
108
+ wss.on('connection', (ws) => host.accept(ws)); // any WebSocket server; bind it to loopback/tailnet by default
109
+
110
+ const { text } = host.compactOffer({ role: 'control', urls: ['ws://192.168.1.20:7300/link'] }); // show as a QR
111
+ const { code } = host.code({ role: 'view' }); // or a code to type: "7KQ4-M2XP-9RTH"
112
+ host.devices(); await host.revoke(id); host.broadcast(event);
113
+ ```
114
+
115
+ `hostKeyFile(path)` from `@byokit/link/node` wants a path in its own private (`0700`) folder; it refuses an
116
+ existing folder with another mode rather than changing its permissions.
117
+
118
+ More host policy, all optional:
119
+
120
+ - `offer`/`code`/`enrol` take `kind` (e.g. `'browser'`, `'peer'`), `lifetime` (ms; access then ends like a removal)
121
+ and `meta`. The offer carries `role` and `lifetime`, so the device can show what it is agreeing to.
122
+ - `allow: (req, device) => boolean` decides when asked (e.g. from capabilities kept in `meta`); without it,
123
+ control devices may do anything and view-only ones what `canView` allows. Do not mutate a grant's `meta` in place:
124
+ use `await host.setMeta(id, newMeta)` or revoke it to withdraw access. Work already started is not rolled back.
125
+ - `caps: { peer: 16 }` limits devices per kind; `maxDevices` limits them all.
126
+ - `answers: { get, put, drop }` keeps completed answers across host restarts. `req.key` is stable per device and retry;
127
+ handlers with transactional effects can store it alongside the effect to avoid repeating work after a crash between
128
+ the handler and `answers.put`.
129
+ - `handshakes: { perMinute, perPeer }` limits new handshakes; pass `host.accept(ws, { peer: ip })` to count per source.
130
+
131
+ `compactOffer` is the QR text: the same secret a typed code carries (same single use, life, words and
132
+ approval) plus the addresses to try, packed binary, so a standard offer fits a QR version 4 or lower.
133
+ `pairWithOffer` scans both compact and version 1 offers; version 1 keeps parsing for one release.
134
+ `offer({ base: 'https://app.example/pair' })` makes a link a browser can open instead of a bare QR text.
135
+ Handler errors are logged on the host (`onError` can receive them). Devices see “Your computer couldn't do that.”
136
+ unless the handler explicitly throws `new PublicLinkError('A message safe to show.')`.
137
+
138
+ ## Browser pairing
139
+
140
+ Apps should offer browser pairing as **view-only by default**: `role: 'view'`, `kind: 'browser'`, a short
141
+ `lifetime`, and a narrow `canView` policy. `control` is offered only when the app explicitly opts in.
142
+ [SECURITY limit 7](SECURITY.md#known-limits) applies: IndexedDB sealing protects stored records, but a live
143
+ XSS on the app's origin can still use an unlocked key. View-only limits that access; keep untrusted scripts off
144
+ that origin. An `allow` policy, when supplied, must enforce the chosen role itself.
145
+
146
+ ```ts
147
+ const { text } = host.offer({
148
+ role: 'view', kind: 'browser', lifetime: 15 * 60_000,
149
+ urls: ['wss://relay.example/link/v1/' + host.id],
150
+ base: 'https://app.example/pair.html',
151
+ });
152
+ console.log(text); // terminal-to-browser deep link; the offer stays after #
153
+ ```
154
+
155
+ `offerText(parseOffer(text), 'https://app.example/pair.html')` makes the same deep link from an existing offer.
156
+ 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.
158
+
159
+ For an offline, typeable alternative, `encodeOffer(parseOffer(text))` holds the **entire offer**, including
160
+ all direct and relay addresses. `decodeOffer` also reads old compact direct codes for migration, preserving
161
+ their key, ticket, role and second-resolution expiry; re-encoding uses the current complete format.
162
+ `decodeOffer(typed)` returns a `PairOffer` and checks expiry without contacting
163
+ any service; pass `offerText(decoded)` to `pairWithOffer`. Groups of five characters may be separated by spaces
164
+ or dashes; case is ignored, O means 0 and I/L mean 1. Other mistakes fail the checksum. The checksum catches
165
+ transcription errors, not tampering: Noise authenticates the host and the person still approves the two words.
166
+ This envelope is longer than the 12-character `host.code()` PSK code, which uses `pairWithCode` and a known
167
+ host address. Both obey the same single-use, at-most-five-minute pairing window.
168
+
169
+ The [PWA pairing page](../../examples/pwa/pair.html) accepts a deep link or pasted envelope, compares words,
170
+ keeps the grant in `browserDeviceStore`, and requests `get.summary`. Run `npm run build`, then
171
+ `node examples/pwa/serve.ts 8080` and open `http://127.0.0.1:8080/pair.html`. The host must expose a reachable
172
+ WebSocket, permit `get.summary` with `canView`, and handle that request. Use HTTPS and `wss:` when deploying.
173
+
174
+ ### Presence, durable grant changes and app metadata
175
+
176
+ `onConnection(grant, online)` on `Host.open` reports the first authenticated socket up and the last down for
177
+ that grant, including direct/relayed disconnects, revoke, expiry and host close. Duplicate sockets do not flap
178
+ presence. A failed removal keeps presence unchanged. Pairing itself briefly opens a socket; it counts too.
179
+ Callback exceptions reach `onError` and do not interrupt cleanup.
180
+
181
+ `await host.reload()` re-reads `grants.load()` in the same queue as grant changes. It never writes the loaded
182
+ snapshot back. Missing grants close their live sockets and discard cached answers; changed keys/roles close
183
+ old connections, and changed expiry reschedules access. A failed load rejects and leaves memory intact.
184
+ A `GrantStore` may provide `subscribe(changed): unsubscribe`; the host reloads automatically on notification,
185
+ reports reload failures through `onError`, and unsubscribes on `close()`.
186
+
187
+ Node apps can use `fileGrantStore(path)` from `@byokit/link/node`. It touches the app's chosen file, temporary
188
+ files and a sibling `.lock` only, writes atomically at 0600 in a 0700 folder, rejects symlinks/nonprivate files on read, and polls
189
+ for cross-process replace/remove (default 100 ms; second argument changes the interval). Cooperating writers
190
+ use an exclusive lock and reject a stale loaded snapshot instead of overwriting an external revoke. Load again
191
+ before retrying a failed save. A process that crashes holding the lock requires the app to resolve that abandoned
192
+ lock; the kit never breaks it. Other file writers must use this backend too to get the write-conflict protection.
193
+ Notifications are eventual, not a transaction across running hosts: apps needing instantaneous admission against
194
+ an external authority must also check it in `allow`.
195
+
196
+ `deviceMeta: (grant) => ({ appVersion, scope: (grant.meta as AppMeta)?.scope })` opts in to app metadata in the sealed `ready`
197
+ payload. Project only data the device may see: the host never copies grant metadata implicitly. `DeviceGrant.meta`
198
+ contains it after either pairing flow, and `DeviceLink.grant.meta` refreshes on reconnect (and clears if a newer
199
+ ready omits it). It travels inside Noise, including through a relay.
200
+
201
+ ### Short-lived peer invitations
202
+
203
+ Compose invitations with the host's existing request handler, pending offer metadata and confirmation callback.
204
+ The **handle** step authorizes the already-paired inviter, **offer** records the host-chosen invitation scope,
205
+ then **confirm** checks that metadata before approving the new peer:
206
+
207
+ ```ts
208
+ import { Host, keyPair, PublicLinkError } from '@byokit/link';
209
+
210
+ // Supply the app's human approval UI.
211
+ declare const ui: { ask(message: string): Promise<boolean> };
212
+ const urls = ['wss://relay.example/link/v1/your-host-id'];
213
+ const host: Host = await Host.open({
214
+ keys: keyPair(), name: 'Kitchen computer',
215
+ canView: (req) => req.op === 'get.summary',
216
+ handle: (req, inviter) => {
217
+ if (req.op === 'peer.invite' && inviter.role === 'control') {
218
+ return host.offer({ role: 'view', kind: 'peer', urls,
219
+ meta: { invitedBy: inviter.id, scope: 'summary' } });
220
+ }
221
+ if (req.op === 'get.summary') return { text: 'Ready to read.' };
222
+ throw new PublicLinkError('That action is unavailable.');
223
+ },
224
+ confirm: async ({ kind, meta, name, words }) => {
225
+ const invitation = meta as { invitedBy?: string; scope?: string } | undefined;
226
+ if (kind !== 'peer' || invitation?.scope !== 'summary' ||
227
+ !host.devices().some((g) => g.id === invitation.invitedBy && g.role === 'control' &&
228
+ (g.expires === undefined || g.expires > Date.now()))) return false;
229
+ const approved = await ui.ask(`Pair ${name} to read the summary? Check “${words}”.`);
230
+ return approved && host.devices().some((g) => g.id === invitation.invitedBy &&
231
+ g.role === 'control' && (g.expires === undefined || g.expires > Date.now()));
232
+ },
233
+ });
234
+ ```
235
+
236
+ Import `Host`, `keyPair` and `PublicLinkError` from `@byokit/link`; `ui.ask` is the app's approval UI. The app
237
+ chooses metadata on the host, never from an untrusted invitee. This composition is delegated pairing:
238
+ the approved control device requests a constrained invitation; a peer cannot widen its terms. Check the
239
+ inviter's current grant, including expiry, in both `handle` and `confirm`. Apply capability scope in `allow`
240
+ for every peer request or stream; `meta` alone does not enforce it. Revoking the inviter cancels redemption
241
+ when `confirm` checks it; already-paired peers are independent grants unless the app revokes them too. No grant exists until `confirm` says yes. This is
242
+ an online invitation: single-use and at most five minutes, with the host present for pairing. It is not a
243
+ long-lived or host-offline signed invitation.
244
+
245
+ ## Device
246
+
247
+ To keep several paired computers, use `secureDeviceStores(secureStore, prefix?)` or
248
+ `browserDeviceStores(databaseName?)` from `@byokit/link`. Both offer `list()` (entry names), `load(name)`,
249
+ `save(name, grant)`, `remove(name)`, and `store(name)` (the one-entry adapter for `DeviceLink`). Names use
250
+ letters, digits, dots, underscores and dashes, up to 120 characters. A secure collection uses
251
+ `<prefix>.index` and `<prefix>.grant.<name>`; existing single-entry stores stay independent. Calls sharing
252
+ one secure module/prefix are serialized within the JS runtime; the native API supplies no cross-process
253
+ transactions. Missing records from interrupted saves are omitted from the index listing.
254
+ The browser collection enumerates existing `browserDeviceStore` entries in its database, preserving their
255
+ non-extractable wrapping keys and forgotten-grant protection; removed tombstones stay out of `list()`.
256
+
257
+ ```ts
258
+ import { DeviceLink, secureDeviceStores, type DeviceGrant, type SecureStoreLike } from '@byokit/link';
259
+
260
+ // Supply the platform secure-storage module and a grant returned by pairing.
261
+ declare const SecureStore: SecureStoreLike;
262
+ declare const grant: DeviceGrant;
263
+ const computers = secureDeviceStores(SecureStore);
264
+ await computers.save('kitchen', grant);
265
+ const link = new DeviceLink((await computers.load('kitchen'))!, { store: computers.store('kitchen') });
266
+ const pairedNames = await computers.list();
267
+ // Local forgetting only; link.unpair() first when the host should remove its grant too.
268
+ await computers.remove('kitchen');
269
+ ```
270
+
271
+ ```ts
272
+ import { DeviceLink, pairWithOffer, pairWithCode } from '@byokit/link';
273
+
274
+ const grant = await pairWithOffer(scanned, { name: 'Pixel 9', onWords: (w) => show(w) });
275
+ // Or pairWithCode(url, typed, { name: 'Pixel 9', onWords: (w) => show(w) });
276
+ await secureStore.save(grant); // it holds this device's secret key
277
+ const link = new DeviceLink(grant, { store: secureStore, onStatus, onEvent });
278
+ await link.request('send.message', { text: 'hi' }); // waits through reconnects
279
+ await link.request('send.message', { text: 'hi' }, { timeoutMs: 20_000, notValidAfter: Date.now() + 60_000 });
280
+ ```
281
+
282
+ - For crash-safe pairing, make a `keyPair()`, save `pendingGrant(scanned, { name, key })` before pairing, then pass
283
+ that same `key` to `pairWithOffer(scanned, { name, key, onWords })`. If the app dies while the person decides,
284
+ a `DeviceLink` made from the saved pending grant retries until approval (up to five minutes after the offer expires),
285
+ then forgets it if the host still has not approved.
286
+ - `resolve: (url) => …` runs before each dial (e.g. open an SSH tunnel and return `ws://127.0.0.1:<port>/…`);
287
+ `link.addUrl(url)` adds an address found later (a wrong host there just fails its handshake).
288
+ - A quiet connection is pinged (`pingMs`, default 20 s) and redialled when the host stops answering; at most
289
+ `maxPending` requests (default 1000) wait at once.
290
+ - `link.rekey()` moves the device to a fresh key without a moment where no key works; `link.unpair()` asks the
291
+ computer to remove the grant and forgets it locally only after confirmation. Offline or failed removal keeps the grant.
292
+
293
+ Statuses:
294
+
295
+ - `connecting`, `online`.
296
+ - `offline`: it keeps retrying.
297
+ - `refused`: every address answered with another host key; the grant is kept, `retry()` or pair again.
298
+ - `removed`: the host removed this device; the grant is forgotten.
299
+
300
+ Every failure is a `LinkError` with a plain `message` and a `code`. `stop()` rejects unanswered and new requests until
301
+ `retry()`. Retry guarantees and their limits: [SECURITY.md](SECURITY.md#known-limits).
302
+
303
+ ### Where a device keeps its grant
304
+
305
+ One store per paired computer, each with `load()` for the next start:
306
+
307
+ | Where | Store | Notes |
308
+ |---|---|---|
309
+ | 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
+ | 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/link/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
+
313
+ ```ts
314
+ const store = secureDeviceStore(SecureStore, 'byokit.link.home');
315
+ const grant = (await store.load()) ?? await pairWithOffer(scanned, { name: 'Pixel 9', onWords: show });
316
+ await store.save(grant);
317
+ const link = new DeviceLink(grant, { store, onStatus });
318
+ ```
319
+
320
+ ### React Native
321
+
322
+ Install a `crypto.getRandomValues` polyfill such as `react-native-get-random-values` (or use `expo-crypto`) and
323
+ import it **before** `@byokit/link`. Metro resolves `sodium-universal` to `sodium-javascript` through its browser
324
+ field. The device uses the platform's WebSocket and needs no Node globals. See [`examples/expo`](../../examples/expo).
325
+
326
+ ## Streams
327
+
328
+ For what doesn't fit a request: a terminal pane, a tunnelled TCP connection, a call's audio. A stream is duplex and
329
+ carries bytes; each direction has a 256 KB window, so a slow reader holds the writer back instead of filling memory.
330
+
331
+ ```ts
332
+ // Host: take streams devices open. `device` is the authenticated grant (one controller per pane…). The stream
333
+ // opens first; a thrown `PublicLinkError` ends it with its message, while other errors go to `onError` and end it with `failed`.
334
+ const host = await Host.open({ ...options, stream: (s, req, device) => {
335
+ const pty = panes.attach((req.args as { pane: string }).pane, device.id); // your code
336
+ s.onData = (keys) => pty.write(keys); // return a promise to hold the device back
337
+ s.onEnd = () => pty.detach();
338
+ pty.onOutput((bytes) => s.write(bytes)); // have the producer await this promise for backpressure
339
+ } });
340
+
341
+ // Device: only while online. A stream ends with its socket (`onEnd('unreachable')`); open it again on `online`.
342
+ const s = await link.stream('terminal', { pane: 'p1' });
343
+ s.onData = (bytes) => term.write(bytes);
344
+ await s.write('ls\r');
345
+ s.end();
346
+ ```
347
+
348
+ View-only devices open only the streams `allow` permits (or `canView` when `allow` is absent). A host without `stream`
349
+ tells devices so (`LinkError` `not-supported`), and so does a host older than streams.
350
+
351
+ On a direct socket, stream bytes go as binary WebSocket messages (without base64 overhead); through a relay, whose
352
+ host wrapper is text, the same frames go as base64 text. The host says which in `ready`; everything else stays text.
353
+
354
+ Speed, measured with Hermes (the CLI, v0.13, on a desktop CPU; `bench/hermes.sh`, not a phone measurement): the device
355
+ path opens about 4.8 MB/s of stream bytes on a direct socket and 3.9 MB/s through a relay, where muxr's tweetnacl
356
+ opens its binary preview tunnel at about 4.6 MB/s. Frames are sealed with `@noble/ciphers`; sodium-javascript's
357
+ ChaCha20 managed about 2.5.
358
+
359
+ ## Through a relay
360
+
361
+ Frames are the same bytes on every path; a relay only routes them and cannot read them.
362
+
363
+ - A device dials `wss://<relay>/link/v1/<host id>` (`host.id`, a hash of the host key) and sends and receives bare
364
+ frames, exactly as on a direct socket. Put that address in the offer's `urls`.
365
+ - The host keeps one socket to the relay and passes it to `host.relay(ws)`. Each message on it is
366
+ `{"c": "<connection id>", "f": "<frame>"}`, or `{"c": "…", "end": <code>}` when either side closes.
367
+ - The relay authenticates the host's socket and limits abuse its own way. Link routing needs no device id: the host
368
+ checks devices itself on every handshake.
369
+
370
+ `@byokit/link` does not include a relay server; use [`@byokit/relay`](../relay) for the standalone server,
371
+ reconnecting host client and device code lookup.
372
+
373
+ ## Migrating pre-kit phone pairings
374
+
375
+ For a pre-kit Crewhouse phone pairing, call `migrateGrant(raw, { format: 'crewhouse-v0' })`
376
+ and save the returned `DeviceGrant` in the app's device store. `raw` is a parsed object
377
+ or JSON with exactly `{ sk, crewdPk, fp, urls, device: { id, name, role } }`, with no
378
+ version field. Both keys are canonical padded base64 for 32 bytes; `fp` is the first
379
+ eight bytes of BLAKE2b-128 of `crewdPk`, in lowercase hex groups of four digits
380
+ separated by spaces. Addresses must be nonempty WebSocket URLs without credentials
381
+ or fragments, and the role is `control` or `view`. Names, IDs and addresses are preserved;
382
+ the host name becomes `your computer`. Database device rows are not this phone format.
383
+
384
+ Invalid input throws `GrantMigrationError` with `code: 'invalid-grant'`; an unknown
385
+ format throws it with `code: 'unsupported-format'`. Migration neither logs nor persists
386
+ anything. The fingerprint detects an inconsistent host key; it is not a signature.
387
+ The host still verifies the device key and supplies its current permissions on connection.
388
+
389
+ ## Moving already-paired devices (muxr)
390
+
391
+ muxr's existing X25519 box keys can be used as link static keys without an exchange or re-pairing:
392
+
393
+ 1. Run `Host` beside the old transport with `keys: keyPairFrom(machineBoxSecretKey)`. Enrol each existing
394
+ `devicePublicKey` with `host.enrol({ key, name, role, meta })`: `observe` becomes `view`, `control` stays
395
+ `control`, and `meta` keeps the muxr device id and kind. No device-to-host key exchange is needed.
396
+ 2. Route link sockets through the relay's `/link/v1/<hostId>` path or an existing Tailscale/SSH route.
397
+ 3. On app update, build a `DeviceGrant` using the **stored** device box secret key and machine box public key
398
+ (base64 to base64url only), the route and a placeholder device id. `DeviceLink` authenticates with the same
399
+ keys; `ready` fills the enrolled id, name and role. No re-pairing is needed.
400
+ 4. Once a device connects over link, refuse its old transport. Turn the old transport off when all devices have
401
+ moved; keep enrolments so offline devices can migrate whenever they return. Revoke both transports during cutover.
402
+
403
+ The same X25519 key serves nacl.box and Noise while both transports run; a later rekey can rotate it. Muxr phones
404
+ share one key across machines. Muxr parity beyond this migration is tracked separately.
405
+
406
+ ## Checking reachability before pairing
407
+
408
+ ```ts
409
+ import { check, parseOffer } from '@byokit/link';
410
+
411
+ // Before asking the person to approve, the phone probes the offer's URLs (the host probes its own advertised
412
+ // URLs the same way, as a self-check only). Results come back in input order.
413
+ export async function precheck(scanned: string): Promise<void> {
414
+ const { urls } = parseOffer(scanned);
415
+ const results = await check(urls, { timeoutMs: 5000 });
416
+ for (const r of results) {
417
+ if (!r.ok) console.log(`${r.url}: ${r.message}`); // r.code is a LinkProblem: unreachable, timeout, wrong-host
418
+ }
419
+ }
420
+ ```
421
+
422
+ Probed four at a time by default (`concurrency` changes that). The probe answers read-only: it sends no ticket,
423
+ so a checked offer still pairs afterwards, and the reply carries nothing but the fact a link host is there. It
424
+ works in Node, browsers and React Native, and through a relay with no relay change (devices speak bare frames
425
+ there).
426
+
427
+ ## Typed pairing on a small terminal
428
+
429
+ `host.shortCode(terms)` returns `{ code, expires }`. Feed the **whole** code to the existing
430
+ `pairWithCode(url, code, options)` on the phone or browser. This is a typed alternative to the QR,
431
+ not a smaller QR: its 57 printed characters fit on one line in an 80-column terminal. The device
432
+ must already know an address (a host-served page can use its own address).
433
+
434
+ ### Security design
435
+
436
+ The code format is `K1-<12 random characters>-<32 hexadecimal characters>`, grouped in fours.
437
+ The random part uses the existing unbiased 31-symbol alphabet: `12 × log2(31) = 59.45` bits.
438
+ The hexadecimal part is a **128-bit machine-key commitment**, BLAKE2b-128 of
439
+ `byokit-link-short-key-v1` followed by the host's 32-byte X25519 public key. Case, whitespace and
440
+ dashes are ignored; the version, lengths and alphabets are validated before dialing. A malformed
441
+ commitment never falls back to the legacy 12-character code.
442
+
443
+ Pairing reuses the pinned Noise XXpsk0 implementation and libsodium hash, with the random part
444
+ as the existing PSK input. After authenticating Noise message 2, the device checks the responder's
445
+ static public key against the commitment **before sending its own static key/name in message 3**,
446
+ displaying words, or accepting a grant. Noise proves possession of the committed key's secret;
447
+ a network attacker or malicious relay cannot substitute another key even if it knows the random
448
+ part. Finding another key for this fixed commitment requires a targeted 128-bit preimage search.
449
+ The commitment is public and adds no secret entropy. No new cryptographic primitive or PAKE is
450
+ implemented. This intentionally keeps more characters than an eight-character password: XXpsk0
451
+ permits offline guessing from a captured handshake even after expiry; the 59.45-bit random part
452
+ is still the defense ([security limit 1](SECURITY.md#known-limits)). A shorter secret needs a
453
+ reviewed PAKE and is outside this API.
454
+
455
+ The host consumes the random code on its first valid presentation, including declined pairing.
456
+ Five wrong code attempts withdraw **all** open codes and QR tickets; handshake limits remain
457
+ 300/minute globally and 30/minute per source when the app supplies `peer`. Codes expire after
458
+ `pairMs` (at most five minutes), checked on presentation and through grant persistence. Replay,
459
+ late approval and expiry never create a grant. Malicious routing can still deny service or consume
460
+ a code by forwarding a live attempt. Anyone who photographs the full code can race to pair with
461
+ the real host, so **both screens still show the same two words and host approval defaults to No**.
462
+ Only an explicit affirmative response after matching words should return `true` from `confirm`.
463
+ The kit has no approval UI; exceptions and confirmation timeouts decline.
464
+
465
+ For relay discovery, display the separate six-character `RelayClient.code()` lookup alongside
466
+ the full `host.shortCode()` pairing code. The device calls `findHost(relay, lookup)` from
467
+ `@byokit/relay`, then `pairWithCode(url, fullPairingCode, options)`. Send **only the lookup** to
468
+ `findHost`; never send the pairing secret to an HTTP lookup service. The lookup supplies an
469
+ untrusted address, never a trusted machine key. A malicious lookup pointing at another host
470
+ fails the commitment check. QR offers and legacy `host.code()` remain compatible; callers of
471
+ the latter do not get this additional machine-key pinning.
472
+
473
+ Runnable loopback example (Node 22.18+, `npm install @byokit/link ws`; save as `pair.ts`, run
474
+ `node pair.ts`). In an app, show the code on the computer and let the phone type it:
475
+
476
+ ```ts
477
+ import { createInterface } from 'node:readline/promises';
478
+ import { WebSocketServer } from 'ws';
479
+ import { DeviceLink, Host, keyPair, pairWithCode } from '@byokit/link';
480
+
481
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
482
+ const host = await Host.open({
483
+ keys: keyPair(), name: 'Umer’s computer',
484
+ confirm: async ({ name, words }) => {
485
+ console.log(`${name} shows “${words}”. Compare both screens.`);
486
+ return (await terminal.question('Do the words match? Approve? (y/N) ')).trim().toLowerCase() === 'y';
487
+ },
488
+ handle: () => ({ message: 'Hello, Umer' }),
489
+ });
490
+ const server = new WebSocketServer({ host: '127.0.0.1', port: 7300 });
491
+ await new Promise<void>((resolve) => server.once('listening', resolve));
492
+ server.on('connection', (ws) => host.accept(ws));
493
+ const { code, expires } = host.shortCode({ role: 'control' });
494
+ console.log(`Type ${code} before ${new Date(expires).toISOString()}`);
495
+ try {
496
+ const grant = await pairWithCode('ws://127.0.0.1:7300/link', code, {
497
+ name: 'Umer’s phone', onWords: (words) => console.log(`Phone: “${words}”`),
498
+ });
499
+ const device = new DeviceLink(grant);
500
+ try { console.log(await device.request('hello')); } finally { device.stop(); }
501
+ } finally {
502
+ terminal.close(); host.close(); server.close();
503
+ }
504
+ ```
505
+
506
+ ## Links
507
+
508
+ - [byokit](../../README.md): every package and example.
509
+ - [SECURITY.md](SECURITY.md): threat model, review checklist and known limits.
510
+ - [CHANGELOG.md](CHANGELOG.md).
511
+ - [`@byokit/relay`](../relay): relay server, host client and code lookup.
512
+ - Examples: [`examples/herdr-kit`](../../examples/herdr-kit) (pairing a phone browser with Herdr on the computer) and
513
+ [`examples/expo`](../../examples/expo) (React Native, iOS and Android).
514
+
515
+ ## License
516
+
517
+ Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](https://github.com/umeranjum17/byokit/blob/main/NOTICE).