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