@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/SECURITY.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# @byokit/link: threat model and review checklist
|
|
2
|
+
|
|
3
|
+
Muxr parity is tracked separately.
|
|
4
|
+
|
|
5
|
+
## What it protects
|
|
6
|
+
|
|
7
|
+
A home computer (the **host**) holds AI sign-ins and other credentials. Phones, browsers and other computers
|
|
8
|
+
(**devices**) pair with it once and then ask it to do things. The link must make sure that:
|
|
9
|
+
|
|
10
|
+
1. only a device a person at the host approved can make requests;
|
|
11
|
+
2. nobody on the path (home network, tailnet, relay, public Wi-Fi) can read or change the traffic, replay it, or
|
|
12
|
+
pretend to be either end;
|
|
13
|
+
3. a device gets answers, never the host's credentials;
|
|
14
|
+
4. removing a device takes effect immediately, including on its open connections;
|
|
15
|
+
5. a device can make only requests the host app's policy permits.
|
|
16
|
+
|
|
17
|
+
## Design in one screen
|
|
18
|
+
|
|
19
|
+
| Piece | Choice |
|
|
20
|
+
|---|---|
|
|
21
|
+
| Handshake, every connection | `Noise_IK_25519_ChaChaPoly_BLAKE2b`, prologue `byokit-link-v1`. The device knows the host's static key (from the QR, then its grant); its own static key travels encrypted in message 1. Fresh ephemerals per connection: forward secrecy. |
|
|
22
|
+
| Handshake, typed code | `Noise_XXpsk0_25519_ChaChaPoly_BLAKE2b`, PSK = BLAKE2b-256(`byokit-link-code-v1` ‖ code). Only a holder of the code can finish; each side learns the other's static key. |
|
|
23
|
+
| Machine-bound typed code | `Host.shortCode()` prepends `K1` to the existing 59.45-bit secret and appends BLAKE2b-128(`byokit-link-short-key-v1` ‖ host public key) in hex. The device verifies that public commitment after Noise message 2, before revealing its identity in message 3. Untrusted address lookup cannot replace the pinned machine key, even with the secret. Full [design and example](README.md#typed-pairing-on-a-small-terminal). |
|
|
24
|
+
| Library | Handshakes: `noise-handshake` 4.2.0 (Holepunch, Apache-2.0) over libsodium: `sodium-native` in Node, `sodium-javascript` 0.8.0 in browsers and React Native. Frames after the handshake: ChaCha20-Poly1305 from `@noble/ciphers` 2.4.0 (Paul Miller, MIT, audited, no dependencies) on every platform, because sodium-javascript's ChaCha20 was slower in a Hermes CLI benchmark on a desktop CPU (`bench/hermes.sh`); this is not a phone measurement. Same cipher, same Noise nonce, byte-identical frames: no wire change. Pinned exactly. No crypto of our own. |
|
|
25
|
+
| Conformance | `noise-handshake` is checked against the cacophony vectors for IK, XX and NNpsk0, which together cover every token of both handshakes; `@noble/ciphers` against the RFC 8439 AEAD vectors; and the transport against noise-handshake's own CipherState (sodium-native) and sodium-javascript, sealing and opening both ways (`test/channel.test.ts`). |
|
|
26
|
+
| Frames | One Noise transport message per WebSocket frame. JSON control messages use base64 text; see Streams below for data frames. Per-direction CipherStates; the implicit nonce counter rejects any replayed, dropped, reordered or reflected frame, and any bad frame closes the socket. Messages over 60 KB are chunked; a reassembled message over 16 MB closes the socket. |
|
|
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
|
+
| 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
|
+
| 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. 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
|
+
| 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
|
+
| 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
|
+
| 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. |
|
|
34
|
+
| Device collections | Secure-store grants have a private index and serialized per-runtime writes; interrupted saves leave filterable missing entries rather than orphaned grants. IndexedDB collections enumerate sealed entries and preserve per-entry non-extractable keys and clear/save tombstones. This is one device's computers, not a hosted multi-tenant credential service. |
|
|
35
|
+
| Delegated pairing | An authenticated inviter asks `handle` for a host-issued, constrained offer; `confirm` validates host-chosen scope and the inviter's current authority before approving it. The existing single-use/five-minute/approval rules apply. Apps enforce scope in `allow`; no offline or long-lived bearer invitation is added. Paired peers are independent grants, so cascading revoke is an app policy. |
|
|
36
|
+
| Revoke | Deletes the grant, sends a sealed `revoked` to its live sockets, then closes them. Optional `onGrantRemoved` runs after access is invalidated, including offline removal, expiry and authority changes; removal awaits its privacy cleanup and a failure never restores access. Ordinary connection drops do not call it. No key rotation is needed: there are no shared keys. |
|
|
37
|
+
| Refusals | A host that can read the device's handshake answers refusals (`not-paired`, `expired`, `ended`, `declined`, `full`) inside the channel, so the device can trust them (e.g. forget its grant). Anything unauthenticated (a plaintext close) only changes what the device *says*, never what it deletes. |
|
|
38
|
+
| Requests | `{t:'req', id, key, session, ack, op, args, notValidAfter?}`. The device sends its highest contiguous received reply id on each request and reconnect authentication. The host retains replies until acknowledged across reconnects; a fresh app session clears abandoned replies. At 1000 unacknowledged replies it refuses new requests with `busy`, without evicting answers. The cache is in memory unless the app supplies an `answers` store. |
|
|
39
|
+
| Streams | Opened by the device (`{t:'open', s, op, args, credit}`), answered `{t:'opened', s, credit}` or `{t:'end', s, error}`; then `{t:'credit', s, n}` and `{t:'end', s, error?}` either way, and the bytes as a binary inner message (frame flag 2/3: 4-byte stream id, then raw bytes), all sealed in the same channel and nonce sequence. On a direct socket the host offers `binary: 1` in `ready` and stream frames then go as binary WebSocket messages (the same Noise message, not base64); through a relay they stay base64 text. A binary message before the handshake closes the socket. Offered only by a host that says `streams: 1` in `ready`, so an older peer never gets one. Opening re-checks the grant and policy (`allow`, or `canView` for view-only when `allow` is absent) before sending `opened` and credit, then runs the app's `stream` handler with the grant; handler failures end an already-opened stream (only `PublicLinkError` exposes its message). A side that sends past the window it was granted (256 KB per stream, at most 64 streams per connection) is cut off. Streams end with their socket and on revoke. |
|
|
40
|
+
| Relay | Routes by URL (`<relay>/link/v1/<host id>`, host id = BLAKE2b-128(`byokit-link-host-id-v1` ‖ host key)) and, host-side, by a `{c, f}` / `{c, end}` wrapper. Frames pass through byte for byte. The separate [`@byokit/relay`](../relay) package supplies the relay server; its push-content exception and store boundaries are in [its security guide](../relay/SECURITY.md). |
|
|
41
|
+
|
|
42
|
+
## Adversaries and what stops them
|
|
43
|
+
|
|
44
|
+
| Adversary | Can | Cannot, because |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| **Passive network observer** (LAN, tailnet, relay operator) | See that a device talks to a host, when, how much; the host id; for typed codes, message 1 | Read plaintext payloads: IK hides the device's static key; everything after is AEAD. A captured typed-code handshake permits offline guessing even after expiry, but the code has about 59 bits of entropy. |
|
|
47
|
+
| **Active network attacker** (ARP/DNS spoofing, malicious relay) | Drop, delay, reorder traffic; close sockets; forge plaintext closes | Impersonate the host (needs the host's static secret; the device pins the key from the QR/grant). Impersonate a device (needs its static secret). Replay or splice frames (nonces). Make a device forget its grant (only sealed refusals do that). Turn a stolen QR ticket into a grant without host-side approval: the QR also contains the host's public key, so its secrecy is not a defense. |
|
|
48
|
+
| **Someone who sees or photographs the QR / code** | Race the real device to pair in the 5 minutes | Get in unnoticed: the person at the host must approve, sees the device name and the two words, and a later attempt with the same ticket or code is refused. |
|
|
49
|
+
| **A lost or stolen device** | Everything its grant allows, until revoked or expired | Anything after revoke or expiry. It never held the host's credentials. |
|
|
50
|
+
| **A paired view-only device** | Requests permitted by the app's `allow`, or by `canView` when `allow` is absent | Requests denied by that policy: refused by the host before the app's handler sees them. |
|
|
51
|
+
| **A malicious relay** | Deny service; learn metadata above | Read, change or inject traffic; pair itself (tickets and codes are sealed or PSK-bound end to end). An impostor host registration only causes denial of service: devices' handshakes fail. |
|
|
52
|
+
|
|
53
|
+
## Known limits
|
|
54
|
+
|
|
55
|
+
1. **Typed-code offline guessing.** XXpsk0 puts the PSK in message 1, so an observer can test guesses against it
|
|
56
|
+
offline even after the code expires. The 59-bit search space is the defense; expiry limits pairing, not
|
|
57
|
+
captured-handshake guessing. A shorter code would weaken it. PSK at message 3 (XXpsk3) would limit the oracle to
|
|
58
|
+
an active attacker, but `noise-handshake` does not implement it. **Upgrade path: a PAKE
|
|
59
|
+
(CPace) for typed codes**, which also makes shorter codes safe.
|
|
60
|
+
2. **The two words are 16 bits.** They confirm "this is the device in my hand", not the channel: an active attacker
|
|
61
|
+
who already holds a live code could grind 16 bits. The host key in the QR (IK) and the code (PSK) carry the real
|
|
62
|
+
authentication.
|
|
63
|
+
3. **32-bit nonce counter.** For compatibility with `noise-handshake`'s 32-bit counter, the channel refuses to send
|
|
64
|
+
or receive past 2³² − 1 frames per direction (the socket must reconnect); it never wraps.
|
|
65
|
+
4. **Library activity.** `noise-handshake` (pushed 2025-12) and `sodium-javascript` (2022) are low-activity; the
|
|
66
|
+
Noise pattern set is frozen and the vectors pin behaviour. If the handshake implementation is abandoned, a
|
|
67
|
+
reviewed replacement must preserve the authenticated protocol and existing wire format.
|
|
68
|
+
5. **Denial of service.** New handshakes are limited before any key work: 300 a minute in all and 30 per `peer`
|
|
69
|
+
(the app passes the source address to `accept`; relayed connections count only toward the total). That caps CPU,
|
|
70
|
+
not a flood of sockets: apps still expose the link only on loopback/tailnet/LAN by default, or behind a relay that
|
|
71
|
+
limits connections.
|
|
72
|
+
6. **Idempotency survives reconnects within one app session, and host restarts only with an `answers` store.**
|
|
73
|
+
Without one, the answer cache is in memory. A store that fails to read refuses the request rather than risk running
|
|
74
|
+
it twice; stored answers are bound to the original operation and JSON-encoded arguments, and a mismatched or
|
|
75
|
+
malformed record is refused without running the handler. `answers.put` happens after the handler, so a host crash
|
|
76
|
+
between its effect and the put can repeat the request unless the handler records `req.key` in the same transaction
|
|
77
|
+
as its effect. A failed put is reported and the answer is still sent. A request can also carry `notValidAfter` so a
|
|
78
|
+
stale retry is never started. A fresh authenticated app session discards the previous session's abandoned replies.
|
|
79
|
+
A device with 1000 unacknowledged replies receives `busy` for new requests until it acknowledges earlier replies;
|
|
80
|
+
no reply is evicted during the same session.
|
|
81
|
+
7. **Key storage is the app's job.** Host key: OS keychain (Electron `safeStorage`) or a 0600 file. React Native:
|
|
82
|
+
`expo-secure-store`. Browser: IndexedDB, wrapped by a non-extractable WebCrypto AES-GCM key (muxr decision 0003);
|
|
83
|
+
a live XSS can still use an unlocked key, so browsers should default to view-only. Android native: X25519 wrapped
|
|
84
|
+
by a Keystore key (not hardware-bound; plan decision D3).
|
|
85
|
+
8. **Grant expiry is opt-in.** Grants last until revoked unless made with a `lifetime` (muxr decision 0001; short
|
|
86
|
+
browser grants per decision 0003). An expired grant is removed like a revoke: refused at `auth`, on the next
|
|
87
|
+
request, and on a timer for open sockets. Expiry uses the host's clock (`now`, which exists for tests); an app
|
|
88
|
+
that freezes it keeps codes and grants alive.
|
|
89
|
+
9. **Rekey keeps two keys valid for a moment.** After `rekey`, the host accepts the old key and the staged new one
|
|
90
|
+
until the device first connects with the new one; from then on only the new key works. A device that loses the
|
|
91
|
+
staged key before that point keeps using the old key.
|
|
92
|
+
10. **`unpair` needs the host.** Offline, against a 0.1 host, or when saving removal fails, the device keeps its
|
|
93
|
+
grant and reports failure. It forgets locally only after the host confirms removal.
|
|
94
|
+
11. **Policy decisions are point-in-time.** `allow` decides when asked; work already started is not rolled back.
|
|
95
|
+
Replace a grant's metadata with `host.setMeta(id, newMeta)` or revoke it to withdraw access. Do not mutate
|
|
96
|
+
`meta` in place: the post-policy identity check requires a replacement grant object.
|
|
97
|
+
12. **Metadata.** The relay sees the host id, timing, sizes, device IP addresses and whether a first message is a
|
|
98
|
+
typed-code attempt (`code:` prefix).
|
|
99
|
+
|
|
100
|
+
## Review checklist
|
|
101
|
+
|
|
102
|
+
- [x] The pinned versions of `noise-handshake`, `@noble/ciphers`, `sodium-universal`, `sodium-native`, `sodium-javascript`, `b4a` are
|
|
103
|
+
the ones reviewed; `package-lock.json` integrity hashes match npm.
|
|
104
|
+
- [x] `test/channel.test.ts` vectors pass: `noise-handshake` matches cacophony for IK, XX, NNpsk0.
|
|
105
|
+
- [x] `Handshake` uses IK with the remote static key pre-set on the initiator, and XXpsk0 only with a PSK; the
|
|
106
|
+
prologue is `byokit-link-v1`.
|
|
107
|
+
- [x] The host sends nothing but a plaintext close before a handshake it can verify; no request is served before
|
|
108
|
+
`ready`.
|
|
109
|
+
- [x] Tickets and codes: 128-bit and 59-bit, single use (deleted on first presentation), 5-minute expiry checked at
|
|
110
|
+
presentation and before persisting the grant, 5 wrong tries withdraw all open tickets and codes.
|
|
111
|
+
- [x] Nothing is stored before `confirm` returns true; `confirm` failing or timing out means no.
|
|
112
|
+
- [x] Bound typed codes check the 128-bit machine-key commitment before message 3, displaying words or receiving a grant; malformed codes never downgrade to legacy pairing. Relay lookup receives only its separate routing code.
|
|
113
|
+
- [x] Every connection's device key is checked against the grants at `auth`; every request re-checks the grant;
|
|
114
|
+
`revoke` removes the grant before closing sockets.
|
|
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
|
+
- [x] A device forgets its grant only on a sealed `revoked`, `ended` or `not-paired` (or when a pending pairing expires).
|
|
117
|
+
- [x] Names shown to people are stripped of control and direction-flipping characters and capped at 60.
|
|
118
|
+
- [x] `parseV1Offer` accepts only `ws:`/`wss:` addresses without credentials, at most 8, a 32-byte key and 16-byte
|
|
119
|
+
ticket.
|
|
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
|
+
- [x] The channel refuses frames past 2³² − 1 and messages over 16 MB.
|
|
122
|
+
- [x] Stream opens re-check the grant and policy before the app's `stream` handler; data past a stream's window,
|
|
123
|
+
or stream data that isn't a binary inner message, drops the socket; streams end on disconnect and revoke.
|
|
124
|
+
- [x] The link relay test shows no plaintext, device name, request or device id in routed link frames (push metadata has a separate [boundary](../relay/SECURITY.md)).
|
|
125
|
+
- [x] The browser bundle has no Node built-ins, and the headless-browser test pairs and makes requests.
|
|
126
|
+
- [x] Grant changes (pair, enrol, revoke, expiry, unpair, rekey, last seen) all go through one serialized
|
|
127
|
+
transition that saves before memory changes; expiry closes live sockets even if the removal save fails.
|
|
128
|
+
- [x] `allow` (or, without it, role plus `canView`) runs before `handle`; a throwing policy refuses.
|
|
129
|
+
- [x] Expired grants are refused at `auth` and on requests, and live sockets close when access runs out.
|
|
130
|
+
- [x] Per-kind caps and `maxDevices` are checked inside the grant transition, so concurrent approvals can't exceed them.
|
|
131
|
+
- [x] A rekey's new key is stored on the device before the host hears it; the host promotes it only when the device
|
|
132
|
+
authenticates with it.
|
|
133
|
+
- [x] The handshake limiter runs before any Diffie-Hellman.
|
|
134
|
+
- [x] Nothing in the main entry reads or writes files, environment variables or other programs.
|
|
135
|
+
`@byokit/link/node` `hostKeyFile(path)` touches only that path: 0600 in 0700, atomic, never replaces a file it
|
|
136
|
+
can't read, refuses symlinks.
|
|
137
|
+
|
|
138
|
+
- [ ] Presence fires once on first/last sockets, including failed/successful unpair, reload, expiry and close.
|
|
139
|
+
- [ ] A grant reload never saves its snapshot back; failed reads preserve memory; Node stale writes reject.
|
|
140
|
+
- [ ] Ready metadata is opt-in, sealed, refreshed on reconnect, and never implicitly includes Grant.meta.
|
|
141
|
+
- [ ] Device collection removal preserves neighbours, excludes tombstones and retains browser key protection.
|
|
142
|
+
- [ ] Delegated invitation scope is host-chosen, redemption checks the current inviter, and peer requests remain policy-checked.
|
|
143
|
+
|
|
144
|
+
## Recorded review — 2026-09-30
|
|
145
|
+
|
|
146
|
+
This implementation review covers all 23 checklist rows in order on the branch based on `9ee1846`.
|
|
147
|
+
It is a source and executable protocol review, not an independent cryptographic audit. Dependency
|
|
148
|
+
versions did not change. `npm ci` reported zero vulnerabilities; for each of the six dependencies
|
|
149
|
+
below, `npm view <name>@<version> dist.integrity` matched `package-lock.json`:
|
|
150
|
+
`noise-handshake@4.2.0`, `@noble/ciphers@2.4.0`, `sodium-universal@5.0.1`,
|
|
151
|
+
`sodium-native@5.1.0`, `sodium-javascript@0.8.0`, `b4a@1.9.0`.
|
|
152
|
+
|
|
153
|
+
The checked boxes mean the implementation and named evidence passed this review. They do not
|
|
154
|
+
remove the known limits above. Run the evidence offline after `npm ci` and `npm run build`:
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
TMPDIR=/tmp sh scripts/test.sh 'packages/pair/test/*.test.ts' 'packages/relay/test/*.test.ts'
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
| Row | Result and evidence (paths relative to this package) |
|
|
161
|
+
|---|---|
|
|
162
|
+
| 1 | PASS: exact pins in `package.json`; registry integrity comparison above; installed lock via `npm ci`. |
|
|
163
|
+
| 2 | PASS: `test/channel.test.ts`, cacophony IK/XX/NNpsk0, RFC 8439 and both sodium implementations. |
|
|
164
|
+
| 3 | PASS: `src/channel.ts` constructor pins the remote IK key and prologue; typed-code right/wrong PSK test in `test/channel.test.ts`. |
|
|
165
|
+
| 4 | PASS: `src/host.ts` connection state gates auth/pair before requests; `test/link.test.ts` security-review pre-ready request closes with no handler or grant. Before verification only a close is sent; after a verified IK message only the handshake answer precedes auth. |
|
|
166
|
+
| 5 | PASS: `src/pairing.ts` rejection-sampled alphabet, `src/host.ts` ticket randomness/take/wrong; `test/link.test.ts` single-use QR/code, five wrong tries, lifetime cap and slow approval; `test/policy.test.ts` expiry during persistence. |
|
|
167
|
+
| 6 | PASS: `src/host.ts` pair races confirm with timeout; `test/link.test.ts` decline, throwing/timed-out confirm and concurrent-confirm save tests. |
|
|
168
|
+
| 7 | PASS: `test/link.test.ts` revoke/live socket, failed last-seen save and replacement grants; `test/policy.test.ts` auth/request checks after awaits. |
|
|
169
|
+
| 8 | PASS: `test/policy.test.ts` R5, throwing policy in `test/link.test.ts`; handler failures expose only PublicLinkError in `test/link.test.ts` and `test/stream.test.ts`. |
|
|
170
|
+
| 9 | PASS: `src/device.ts` sealed refusal gate; `test/link.test.ts` wrong-host response retains grant; `test/policy.test.ts` R4 and R9; plain closes never authorize forgetting. |
|
|
171
|
+
| 10 | PASS: `test/pairing.test.ts` and `test/offer-envelope.test.ts` sanitization; seeded offer round trips in `test/parser-fuzz.test.ts`. |
|
|
172
|
+
| 11 | PASS: `test/pairing.test.ts`, `test/offer-envelope.test.ts` and seed 0x3c02 cover key/ticket lengths, URL credentials/schemes/count and offer bounds. |
|
|
173
|
+
| 12 | PASS: `test/offer-envelope.test.ts` current/legacy checksum, padding, bounds and expiry; seed 0x3c02 round trips 250 offers, aliases, corrupted symbols and invalid fields. |
|
|
174
|
+
| 13 | PASS: `test/channel.test.ts` receive/send counter exhaustion and incoming/outgoing 16 MB limits. Found and fixed empty authenticated plaintext accepted as `{}`: `fixtures/conformance/link-frames-typescript.json`, `test/parser-fuzz.test.ts` and real socket close in `test/link.test.ts`. |
|
|
175
|
+
| 14 | PASS: `test/stream.test.ts` R5 policy, T4 window overflow/disconnect/revoke and malformed stream data; authenticated bad flags/truncated stream ids in `test/parser-fuzz.test.ts`. |
|
|
176
|
+
| 15 | PASS: `test/link.test.ts` blind relay and `../relay/test/relay.test.ts` assert no request, name, plaintext marker or device id in routed frames. Push metadata is explicitly outside this assertion. |
|
|
177
|
+
| 16 | PASS: `test/browser.test.ts` esbuild browser boundary and actual headless Chromium pair/request; `test/react-native.test.ts` runs without Node globals. |
|
|
178
|
+
| 17 | PASS: `src/host.ts` serialized transition; failed saves/concurrent confirmation in `test/link.test.ts`, R4 expiry closes despite failed save in `test/stream.test.ts`, R9 and X2a in `test/policy.test.ts`. |
|
|
179
|
+
| 18 | PASS: `test/policy.test.ts` R5 including concurrent withdrawal and cached replies; throwing canView in `test/link.test.ts`. |
|
|
180
|
+
| 19 | PASS: `test/policy.test.ts` R4 auth/requests/timer; `test/stream.test.ts` expired streams despite failed save/arriving frame. |
|
|
181
|
+
| 20 | PASS: `test/link.test.ts` concurrent confirmations respect maxDevices; `test/policy.test.ts` M3 per-kind caps; `src/host.ts` counts inside serialized grant transition. |
|
|
182
|
+
| 21 | PASS: `test/policy.test.ts` X2a failed staged-key save, promotion, queued old-key auth, retired sockets and staged-key enrolment. |
|
|
183
|
+
| 22 | PASS: `src/host.ts` handshake calls admit before firstFrame/Handshake construction; `test/policy.test.ts` T8. Relayed devices count toward the total, not an IP bucket. |
|
|
184
|
+
| 23 | PASS: `src/index.ts` portable import closure, browser/RN tests; `src/node.ts` uses exclusive random temp files and hard-link publication, lstat rejects final-path symlinks and unsafe permissions; K1 in `test/policy.test.ts` and temp-symlink checks in `test/stores.test.ts`. |
|
|
185
|
+
|
|
186
|
+
Seed 0x3c01 adds 300 deterministic stream/JSON round trips, corruption, truncation, replay and
|
|
187
|
+
invalid authenticated kind cases. Seeds are fixed in test names for reproducibility. Every parser
|
|
188
|
+
rejection test exercises the real parser; authenticated malformed bytes are sealed with Transport
|
|
189
|
+
so framing validation is reached rather than only AEAD rejection. The owning conformance fixture
|
|
190
|
+
states the missing-kind rule before the source change.
|
|
191
|
+
|
|
192
|
+
Residual file-system assumption: the app supplies a trusted path and trusted ancestor directories;
|
|
193
|
+
`hostKeyFile` checks the immediate parent and final file, not an adversarial ancestor rename race.
|
|
194
|
+
This review does not prove timing side-channel resistance or safety of a compromised host/device.
|
|
195
|
+
The typed-code PAKE upgrade, at-rest adapter choices and app handler transaction boundary remain
|
|
196
|
+
explicit host responsibilities described above.
|
|
197
|
+
|
|
198
|
+
## Machine-bound typed code review — 2026-09-30
|
|
199
|
+
|
|
200
|
+
The added checklist row is covered by `test/link.test.ts` (wrong code, replay, five-attempt
|
|
201
|
+
lockout, expiry, late approval, refusal, normalization and malformed commitments),
|
|
202
|
+
`../relay/test/relay.test.ts` (a substituted responder key fails even when it knows the secret,
|
|
203
|
+
before device identity or words), and the browser and React Native pair/request tests.
|
|
204
|
+
The Node README example also declines on Enter and approves only on `y`.
|
|
205
|
+
This extends the recorded protocol review above; no handshake library or dependency changed.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type KeyPair = {
|
|
2
|
+
publicKey: Uint8Array;
|
|
3
|
+
secretKey: Uint8Array;
|
|
4
|
+
};
|
|
5
|
+
export type Mode = 'ik' | 'code';
|
|
6
|
+
/** Mixed into every handshake, so a v2 of this protocol can never be confused with v1. */
|
|
7
|
+
export declare const PROLOGUE = "byokit-link-v1";
|
|
8
|
+
export declare const b64: (u: Uint8Array) => string;
|
|
9
|
+
export declare const unb64: (s: string) => Uint8Array;
|
|
10
|
+
export declare const b64url: (u: Uint8Array) => string;
|
|
11
|
+
export declare function unb64url(s: string): Uint8Array;
|
|
12
|
+
export declare const keyPair: () => KeyPair;
|
|
13
|
+
export declare const keyPairFrom: (secretKey: Uint8Array) => KeyPair;
|
|
14
|
+
export declare function hash(bytes: number, ...parts: (Uint8Array | string)[]): Uint8Array;
|
|
15
|
+
export declare function random(bytes: number): Uint8Array;
|
|
16
|
+
/** The host's address on a relay: a hash of its public key, so the relay needs nothing else. */
|
|
17
|
+
export declare const hostId: (hostKey: Uint8Array) => string;
|
|
18
|
+
export declare function messageBytes(msg: unknown): Uint8Array;
|
|
19
|
+
/** One handshake. The first frame an initiator writes carries its mode in the clear (`ik:` or `code:`), so the host
|
|
20
|
+
* knows which handshake to run; everything after that is bare base64. */
|
|
21
|
+
export declare class Handshake {
|
|
22
|
+
readonly mode: Mode;
|
|
23
|
+
private hs;
|
|
24
|
+
private first;
|
|
25
|
+
constructor(mode: Mode, initiator: boolean, me: KeyPair, opt?: {
|
|
26
|
+
remote?: Uint8Array;
|
|
27
|
+
psk?: Uint8Array;
|
|
28
|
+
});
|
|
29
|
+
write(payload?: unknown): string;
|
|
30
|
+
/** The payload, or a throw unless this is the next authentic handshake message. */
|
|
31
|
+
read(frame: string): any;
|
|
32
|
+
get done(): boolean;
|
|
33
|
+
/** The other side's static public key, once the handshake has revealed it. */
|
|
34
|
+
get remoteKey(): Uint8Array;
|
|
35
|
+
/** The same two words on both screens: they match only if both ends ran this very handshake. */
|
|
36
|
+
get words(): string;
|
|
37
|
+
channel(): Channel;
|
|
38
|
+
}
|
|
39
|
+
/** Splits an initiator's first frame into its mode and the Noise message. */
|
|
40
|
+
export declare function firstFrame(frame: string): {
|
|
41
|
+
mode: Mode;
|
|
42
|
+
body: string;
|
|
43
|
+
};
|
|
44
|
+
/** Noise's CipherState for one direction: ChaCha20-Poly1305 with the key the handshake gave it, a nonce of four zero
|
|
45
|
+
* bytes and the 64-bit little-endian frame counter, and no associated data. The same bytes as noise-handshake's own
|
|
46
|
+
* (test/channel.test.ts checks both ways); a frame that fails to open doesn't move the counter. */
|
|
47
|
+
export declare class Transport {
|
|
48
|
+
nonce: number;
|
|
49
|
+
private key;
|
|
50
|
+
constructor(key: Uint8Array);
|
|
51
|
+
private iv;
|
|
52
|
+
encrypt(plain: Uint8Array): Uint8Array;
|
|
53
|
+
/** Throws unless `sealed` is the next authentic frame. */
|
|
54
|
+
decrypt(sealed: Uint8Array): Uint8Array;
|
|
55
|
+
}
|
|
56
|
+
/** One socket's two Noise CipherStates. Frames are base64 text, so every WebSocket and every relay carries them; a
|
|
57
|
+
* stream's bytes may also go as the same frame in a binary WebSocket message, where both ends are direct.
|
|
58
|
+
* Inside, each frame's first byte says what it carries: 0 or 1 a JSON message (1: more frames follow), 2 or 3 a
|
|
59
|
+
* stream's bytes (a 4-byte stream id, then raw bytes), so a stream chunk isn't base64 inside JSON as well. */
|
|
60
|
+
export declare class Channel {
|
|
61
|
+
private tx;
|
|
62
|
+
private rx;
|
|
63
|
+
private parts;
|
|
64
|
+
private size;
|
|
65
|
+
private kind;
|
|
66
|
+
constructor(hs: any);
|
|
67
|
+
/** One or more frames; send them in order. */
|
|
68
|
+
seal(msg: unknown): string[];
|
|
69
|
+
/** A stream's bytes as raw frames: send each as a binary WebSocket message, or `b64` it where only text goes (a
|
|
70
|
+
* relay). `open` gives them back as `{ t: 'data', s, d }`. Only for a peer that speaks streams. */
|
|
71
|
+
sealData(s: number, data: Uint8Array): Uint8Array[];
|
|
72
|
+
private frames;
|
|
73
|
+
/** The whole message once its last frame arrives, else undefined. Throws on any frame that is not the next
|
|
74
|
+
* authentic one from the other end, and on a message that grows past 16 MB. */
|
|
75
|
+
open(frame: string | Uint8Array): any;
|
|
76
|
+
}
|
package/dist/channel.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The link's crypto. None of it is ours: the handshakes are Noise from noise-handshake over libsodium (sodium-native in
|
|
2
|
+
// Node, sodium-javascript in browsers and React Native), and after the handshake each frame is sealed with
|
|
3
|
+
// ChaCha20-Poly1305 from @noble/ciphers, which is twice as fast as sodium-javascript on a phone. See SECURITY.md.
|
|
4
|
+
//
|
|
5
|
+
// - Scanned code, and every later connection: Noise_IK_25519_ChaChaPoly_BLAKE2b. The device knows the host's static
|
|
6
|
+
// key (from the QR, then its grant) and sends its own static key encrypted in the first message.
|
|
7
|
+
// - Typed code: Noise_XXpsk0_25519_ChaChaPoly_BLAKE2b, the code as the pre-shared key. Neither side knows the other's
|
|
8
|
+
// key yet; only someone who holds the code can finish the handshake.
|
|
9
|
+
//
|
|
10
|
+
// After the handshake each direction has its own CipherState, whose nonce counter refuses any replayed, dropped,
|
|
11
|
+
// reordered or reflected frame.
|
|
12
|
+
import b4a from 'b4a';
|
|
13
|
+
import Noise from 'noise-handshake';
|
|
14
|
+
import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
|
|
15
|
+
import dh from 'noise-handshake/dh.js';
|
|
16
|
+
import sodium from 'sodium-universal';
|
|
17
|
+
import { CONFIRM_WORDS } from "./confirm-words.js";
|
|
18
|
+
/** Mixed into every handshake, so a v2 of this protocol can never be confused with v1. */
|
|
19
|
+
export const PROLOGUE = 'byokit-link-v1';
|
|
20
|
+
const CHUNK = 60_000; // a Noise message is at most 65535 bytes; larger payloads go in pieces
|
|
21
|
+
const MAX_MESSAGE = 16 << 20; // what one reassembled message may grow to before the socket is dropped
|
|
22
|
+
// ponytail: noise-handshake writes a 32-bit nonce counter, so a socket stops at 2^32 frames; rekey if one ever gets close.
|
|
23
|
+
const MAX_FRAMES = 2 ** 32 - 1;
|
|
24
|
+
export const b64 = (u) => b4a.toString(b4a.from(u), 'base64');
|
|
25
|
+
export const unb64 = (s) => b4a.from(s, 'base64');
|
|
26
|
+
export const b64url = (u) => b64(u).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
27
|
+
export function unb64url(s) {
|
|
28
|
+
if (!/^[A-Za-z0-9_-]*$/.test(s))
|
|
29
|
+
throw new Error('not base64url');
|
|
30
|
+
return unb64(s.replace(/-/g, '+').replace(/_/g, '/'));
|
|
31
|
+
}
|
|
32
|
+
export const keyPair = () => dh.generateKeyPair();
|
|
33
|
+
export const keyPairFrom = (secretKey) => dh.generateKeyPair(b4a.from(secretKey));
|
|
34
|
+
export function hash(bytes, ...parts) {
|
|
35
|
+
const out = b4a.alloc(bytes);
|
|
36
|
+
sodium.crypto_generichash(out, b4a.concat(parts.map((p) => typeof p === 'string' ? b4a.from(p) : b4a.from(p))));
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
export function random(bytes) {
|
|
40
|
+
const out = b4a.alloc(bytes);
|
|
41
|
+
sodium.randombytes_buf(out);
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** The host's address on a relay: a hash of its public key, so the relay needs nothing else. */
|
|
45
|
+
export const hostId = (hostKey) => b64url(hash(16, 'byokit-link-host-id-v1', hostKey));
|
|
46
|
+
export function messageBytes(msg) {
|
|
47
|
+
const body = b4a.from(JSON.stringify(msg));
|
|
48
|
+
if (body.byteLength > MAX_MESSAGE)
|
|
49
|
+
throw new Error('message too large');
|
|
50
|
+
return body;
|
|
51
|
+
}
|
|
52
|
+
const decode = (bytes) => (bytes.byteLength ? JSON.parse(b4a.toString(b4a.from(bytes))) : {});
|
|
53
|
+
/** One handshake. The first frame an initiator writes carries its mode in the clear (`ik:` or `code:`), so the host
|
|
54
|
+
* knows which handshake to run; everything after that is bare base64. */
|
|
55
|
+
export class Handshake {
|
|
56
|
+
mode;
|
|
57
|
+
hs;
|
|
58
|
+
first;
|
|
59
|
+
constructor(mode, initiator, me, opt = {}) {
|
|
60
|
+
this.mode = mode;
|
|
61
|
+
this.first = initiator;
|
|
62
|
+
this.hs = new Noise(mode === 'ik' ? 'IK' : 'XXpsk0', initiator, { publicKey: b4a.from(me.publicKey), secretKey: b4a.from(me.secretKey) }, mode === 'code' ? { psk: b4a.from(opt.psk) } : undefined);
|
|
63
|
+
this.hs.initialise(b4a.from(PROLOGUE), opt.remote ? b4a.from(opt.remote) : undefined);
|
|
64
|
+
}
|
|
65
|
+
write(payload = {}) {
|
|
66
|
+
const frame = b64(this.hs.send(messageBytes(payload)));
|
|
67
|
+
if (!this.first)
|
|
68
|
+
return frame;
|
|
69
|
+
this.first = false;
|
|
70
|
+
return `${this.mode}:${frame}`;
|
|
71
|
+
}
|
|
72
|
+
/** The payload, or a throw unless this is the next authentic handshake message. */
|
|
73
|
+
read(frame) {
|
|
74
|
+
return decode(this.hs.recv(unb64(frame)));
|
|
75
|
+
}
|
|
76
|
+
get done() { return this.hs.complete; }
|
|
77
|
+
/** The other side's static public key, once the handshake has revealed it. */
|
|
78
|
+
get remoteKey() { return new Uint8Array(this.hs.rs); }
|
|
79
|
+
/** The same two words on both screens: they match only if both ends ran this very handshake. */
|
|
80
|
+
get words() { return confirmWords(this.hs.hash); }
|
|
81
|
+
channel() { return new Channel(this.hs); }
|
|
82
|
+
}
|
|
83
|
+
/** Splits an initiator's first frame into its mode and the Noise message. */
|
|
84
|
+
export function firstFrame(frame) {
|
|
85
|
+
const m = /^(ik|code):(.*)$/s.exec(frame);
|
|
86
|
+
if (!m)
|
|
87
|
+
throw new Error('not a link handshake');
|
|
88
|
+
return { mode: m[1], body: m[2] };
|
|
89
|
+
}
|
|
90
|
+
function confirmWords(handshakeHash) {
|
|
91
|
+
const h = hash(16, 'byokit-link-words-v1', handshakeHash); // BLAKE2b gives at least 16 bytes; two are used
|
|
92
|
+
return `${CONFIRM_WORDS[h[0]]} ${CONFIRM_WORDS[h[1]]}`;
|
|
93
|
+
}
|
|
94
|
+
/** Noise's CipherState for one direction: ChaCha20-Poly1305 with the key the handshake gave it, a nonce of four zero
|
|
95
|
+
* bytes and the 64-bit little-endian frame counter, and no associated data. The same bytes as noise-handshake's own
|
|
96
|
+
* (test/channel.test.ts checks both ways); a frame that fails to open doesn't move the counter. */
|
|
97
|
+
export class Transport {
|
|
98
|
+
nonce = 0;
|
|
99
|
+
key;
|
|
100
|
+
constructor(key) { this.key = new Uint8Array(key); }
|
|
101
|
+
iv() {
|
|
102
|
+
const iv = new Uint8Array(12);
|
|
103
|
+
const v = new DataView(iv.buffer);
|
|
104
|
+
v.setUint32(4, this.nonce >>> 0, true);
|
|
105
|
+
v.setUint32(8, Math.floor(this.nonce / 2 ** 32), true);
|
|
106
|
+
return iv;
|
|
107
|
+
}
|
|
108
|
+
encrypt(plain) {
|
|
109
|
+
const sealed = chacha20poly1305(this.key, this.iv()).encrypt(plain);
|
|
110
|
+
this.nonce++;
|
|
111
|
+
return sealed;
|
|
112
|
+
}
|
|
113
|
+
/** Throws unless `sealed` is the next authentic frame. */
|
|
114
|
+
decrypt(sealed) {
|
|
115
|
+
if (sealed.byteLength > 65535)
|
|
116
|
+
throw new Error('frame too large'); // Noise's limit
|
|
117
|
+
const plain = chacha20poly1305(this.key, this.iv()).decrypt(sealed);
|
|
118
|
+
this.nonce++;
|
|
119
|
+
return plain;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** One socket's two Noise CipherStates. Frames are base64 text, so every WebSocket and every relay carries them; a
|
|
123
|
+
* stream's bytes may also go as the same frame in a binary WebSocket message, where both ends are direct.
|
|
124
|
+
* Inside, each frame's first byte says what it carries: 0 or 1 a JSON message (1: more frames follow), 2 or 3 a
|
|
125
|
+
* stream's bytes (a 4-byte stream id, then raw bytes), so a stream chunk isn't base64 inside JSON as well. */
|
|
126
|
+
export class Channel {
|
|
127
|
+
tx;
|
|
128
|
+
rx;
|
|
129
|
+
parts = [];
|
|
130
|
+
size = 0;
|
|
131
|
+
kind = 0; // 0 JSON, 2 stream bytes: what the message being reassembled is
|
|
132
|
+
constructor(hs) {
|
|
133
|
+
this.tx = new Transport(hs.tx);
|
|
134
|
+
this.rx = new Transport(hs.rx);
|
|
135
|
+
}
|
|
136
|
+
/** One or more frames; send them in order. */
|
|
137
|
+
seal(msg) {
|
|
138
|
+
return this.frames(messageBytes(msg), 0).map(b64);
|
|
139
|
+
}
|
|
140
|
+
/** A stream's bytes as raw frames: send each as a binary WebSocket message, or `b64` it where only text goes (a
|
|
141
|
+
* relay). `open` gives them back as `{ t: 'data', s, d }`. Only for a peer that speaks streams. */
|
|
142
|
+
sealData(s, data) {
|
|
143
|
+
const head = b4a.alloc(4);
|
|
144
|
+
new DataView(head.buffer, head.byteOffset, 4).setUint32(0, s);
|
|
145
|
+
return this.frames(b4a.concat([head, b4a.from(data)]), 2);
|
|
146
|
+
}
|
|
147
|
+
frames(body, kind) {
|
|
148
|
+
const out = [];
|
|
149
|
+
for (let at = 0; at === 0 || at < body.length; at += CHUNK) {
|
|
150
|
+
if (this.tx.nonce >= MAX_FRAMES)
|
|
151
|
+
throw new Error('this connection has carried all it can; reconnect');
|
|
152
|
+
const more = at + CHUNK < body.length ? 1 : 0;
|
|
153
|
+
out.push(this.tx.encrypt(b4a.concat([b4a.from([kind | more]), body.subarray(at, at + CHUNK)])));
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
/** The whole message once its last frame arrives, else undefined. Throws on any frame that is not the next
|
|
158
|
+
* authentic one from the other end, and on a message that grows past 16 MB. */
|
|
159
|
+
open(frame) {
|
|
160
|
+
if (this.rx.nonce >= MAX_FRAMES)
|
|
161
|
+
throw new Error('this connection has carried all it can; reconnect');
|
|
162
|
+
const plain = this.rx.decrypt(typeof frame === 'string' ? unb64(frame) : frame);
|
|
163
|
+
const flag = plain[0];
|
|
164
|
+
if (!plain.byteLength || flag > 3 || (this.parts.length && (flag & 2) !== this.kind))
|
|
165
|
+
throw new Error('bad frame');
|
|
166
|
+
this.kind = flag & 2;
|
|
167
|
+
this.size += plain.byteLength - 1;
|
|
168
|
+
if (this.size > MAX_MESSAGE)
|
|
169
|
+
throw new Error('message too large');
|
|
170
|
+
this.parts.push(plain.subarray(1));
|
|
171
|
+
if (flag & 1)
|
|
172
|
+
return undefined;
|
|
173
|
+
const whole = b4a.concat(this.parts);
|
|
174
|
+
this.parts = [];
|
|
175
|
+
this.size = 0;
|
|
176
|
+
if (!this.kind)
|
|
177
|
+
return decode(whole);
|
|
178
|
+
if (whole.byteLength < 4)
|
|
179
|
+
throw new Error('bad frame');
|
|
180
|
+
return { t: 'data', s: new DataView(whole.buffer, whole.byteOffset, 4).getUint32(0), d: whole.subarray(4) };
|
|
181
|
+
}
|
|
182
|
+
}
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type LinkProblem } from './device.ts';
|
|
2
|
+
/** The first frame a probe sends. The host answers with LINK_PROBE_OK and closes; it touches no ticket, code,
|
|
3
|
+
// grant or counter, so a checked offer still pairs afterwards. Relayed devices speak bare frames, so the same
|
|
4
|
+
// exchange works through a relay with no relay change. */
|
|
5
|
+
export declare const LINK_PROBE = "byokit-link-probe-v1";
|
|
6
|
+
export declare const LINK_PROBE_OK = "byokit-link-probe-v1:ok";
|
|
7
|
+
export type CheckResult = {
|
|
8
|
+
url: string;
|
|
9
|
+
ok: true;
|
|
10
|
+
} | {
|
|
11
|
+
url: string;
|
|
12
|
+
ok: false;
|
|
13
|
+
code: LinkProblem;
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
export type CheckOptions = {
|
|
17
|
+
/** Each URL gets this long (default 8000, like pairing). */
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
/** How many URLs to probe at once (default 4). */
|
|
20
|
+
concurrency?: number;
|
|
21
|
+
/** Override the WebSocket (tests, or a platform without a global). */
|
|
22
|
+
WebSocket?: new (url: string) => {
|
|
23
|
+
send(data: string): void;
|
|
24
|
+
close(code?: number, reason?: string): void;
|
|
25
|
+
onopen: unknown;
|
|
26
|
+
onmessage: unknown;
|
|
27
|
+
onclose: unknown;
|
|
28
|
+
onerror: unknown;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
/** Probe each URL for a link host, in input order. Only `unreachable` (nothing there), `timeout` (something there
|
|
32
|
+
* that never answered) and `wrong-host` (something else answered) can come back, each with its LINK_WORDS sentence;
|
|
33
|
+
* no new codes were needed. Never sends a ticket, so offers stay usable. */
|
|
34
|
+
export declare function check(urls: string[], o?: CheckOptions): Promise<CheckResult[]>;
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// A per-URL reachability probe for onboarding: before pairing, the phone checks the offer's URLs (and the host
|
|
2
|
+
// checks its own advertised URL) without claiming or burning the one-time offer. No platform APIs: the same code
|
|
3
|
+
// runs in Node, browsers and React Native on the platform WebSocket, like `dial` in device.ts.
|
|
4
|
+
import { LINK_WORDS } from "./device.js";
|
|
5
|
+
/** The first frame a probe sends. The host answers with LINK_PROBE_OK and closes; it touches no ticket, code,
|
|
6
|
+
// grant or counter, so a checked offer still pairs afterwards. Relayed devices speak bare frames, so the same
|
|
7
|
+
// exchange works through a relay with no relay change. */
|
|
8
|
+
export const LINK_PROBE = 'byokit-link-probe-v1';
|
|
9
|
+
export const LINK_PROBE_OK = 'byokit-link-probe-v1:ok';
|
|
10
|
+
const fail = (url, code) => ({ url, ok: false, code, message: LINK_WORDS[code] });
|
|
11
|
+
function probe(url, o, timeoutMs) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let ws;
|
|
14
|
+
let done = false;
|
|
15
|
+
let opened = false;
|
|
16
|
+
const finish = (r) => {
|
|
17
|
+
if (done)
|
|
18
|
+
return;
|
|
19
|
+
done = true;
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
try {
|
|
22
|
+
ws.close();
|
|
23
|
+
}
|
|
24
|
+
catch { }
|
|
25
|
+
resolve(r);
|
|
26
|
+
};
|
|
27
|
+
const timer = setTimeout(() => finish(fail(url, opened ? 'timeout' : 'unreachable')), timeoutMs);
|
|
28
|
+
timer.unref?.();
|
|
29
|
+
try {
|
|
30
|
+
const WS = o.WebSocket ?? globalThis.WebSocket;
|
|
31
|
+
ws = new WS(url);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return finish(fail(url, 'unreachable'));
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
ws.binaryType = 'arraybuffer';
|
|
38
|
+
}
|
|
39
|
+
catch { }
|
|
40
|
+
ws.onopen = () => { opened = true; try {
|
|
41
|
+
ws.send(LINK_PROBE);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
finish(fail(url, 'unreachable'));
|
|
45
|
+
} };
|
|
46
|
+
ws.onerror = () => finish(fail(url, 'unreachable'));
|
|
47
|
+
ws.onmessage = (ev) => {
|
|
48
|
+
const data = typeof ev?.data === 'string' ? ev.data : null;
|
|
49
|
+
finish(data === LINK_PROBE_OK ? { url, ok: true } : fail(url, 'wrong-host'));
|
|
50
|
+
};
|
|
51
|
+
ws.onclose = (e) => {
|
|
52
|
+
// A host from before the probe route still proves it is there by rejecting the probe as a bad handshake.
|
|
53
|
+
if (!done)
|
|
54
|
+
finish(e?.code === 4400 ? { url, ok: true } : fail(url, opened ? 'wrong-host' : 'unreachable'));
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** Probe each URL for a link host, in input order. Only `unreachable` (nothing there), `timeout` (something there
|
|
59
|
+
* that never answered) and `wrong-host` (something else answered) can come back, each with its LINK_WORDS sentence;
|
|
60
|
+
* no new codes were needed. Never sends a ticket, so offers stay usable. */
|
|
61
|
+
export async function check(urls, o = {}) {
|
|
62
|
+
const timeoutMs = o.timeoutMs ?? 8000;
|
|
63
|
+
const out = new Array(urls.length);
|
|
64
|
+
let next = 0;
|
|
65
|
+
await Promise.all(Array.from({ length: Math.min(Math.max(1, o.concurrency ?? 4), urls.length) }, async () => {
|
|
66
|
+
while (next < urls.length) {
|
|
67
|
+
const i = next++;
|
|
68
|
+
out[i] = await probe(urls[i], o, timeoutMs);
|
|
69
|
+
}
|
|
70
|
+
}));
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const CONFIRM_WORDS: readonly string[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The two confirmation words come from this list: 256 plain words, one per byte. It is Ownvoice's list (its phone app
|
|
2
|
+
// and computer helper show the same words), so every byokit product and mirror shows a person the same kind of words.
|
|
3
|
+
export const CONFIRM_WORDS = [
|
|
4
|
+
'apple', 'arrow', 'autumn', 'bamboo', 'banana', 'basket', 'beach', 'berry', 'bicycle', 'blanket', 'bloom', 'boat',
|
|
5
|
+
'bottle', 'branch', 'bread', 'breeze', 'brick', 'bridge', 'brook', 'brush', 'bubble', 'bucket', 'butter', 'button',
|
|
6
|
+
'cabin', 'cactus', 'camel', 'candle', 'canoe', 'canyon', 'carpet', 'carrot', 'castle', 'cedar', 'chalk', 'cherry',
|
|
7
|
+
'chess', 'cliff', 'clock', 'cloud', 'clover', 'coast', 'cocoa', 'comet', 'copper', 'coral', 'cotton', 'cradle',
|
|
8
|
+
'crane', 'crayon', 'creek', 'cricket', 'crown', 'crystal', 'cup', 'daisy', 'dawn', 'desert', 'diamond', 'dolphin',
|
|
9
|
+
'donkey', 'dragon', 'drum', 'eagle', 'earth', 'echo', 'elbow', 'ember', 'engine', 'falcon', 'feather', 'fern',
|
|
10
|
+
'ferry', 'field', 'fig', 'flag', 'flame', 'flute', 'forest', 'fossil', 'fountain', 'fox', 'frost', 'garden',
|
|
11
|
+
'garlic', 'giant', 'ginger', 'glacier', 'glove', 'goat', 'gold', 'grape', 'grass', 'guitar', 'hammer', 'harbor',
|
|
12
|
+
'harp', 'hazel', 'helmet', 'hill', 'honey', 'horizon', 'horse', 'island', 'ivory', 'jacket', 'jasmine', 'jelly',
|
|
13
|
+
'jungle', 'kettle', 'kite', 'koala', 'ladder', 'lagoon', 'lake', 'lamp', 'lantern', 'lemon', 'lily', 'lion',
|
|
14
|
+
'lizard', 'lotus', 'magnet', 'mango', 'maple', 'marble', 'meadow', 'melon', 'meteor', 'mint', 'mirror', 'mitten',
|
|
15
|
+
'moon', 'moss', 'mountain', 'mushroom', 'needle', 'nest', 'night', 'noodle', 'oak', 'ocean', 'olive', 'onion',
|
|
16
|
+
'orange', 'orbit', 'otter', 'owl', 'paddle', 'palm', 'panda', 'paper', 'parrot', 'peach', 'peanut', 'pearl',
|
|
17
|
+
'pebble', 'pencil', 'pepper', 'piano', 'pillow', 'pine', 'planet', 'plum', 'pocket', 'pond', 'poppy', 'potato',
|
|
18
|
+
'puzzle', 'quartz', 'quilt', 'rabbit', 'radio', 'rain', 'raven', 'reef', 'ribbon', 'river', 'robin', 'rocket',
|
|
19
|
+
'rose', 'saddle', 'sail', 'salmon', 'sand', 'saturn', 'shell', 'silver', 'sky', 'sled', 'snow', 'sock', 'spark',
|
|
20
|
+
'spider', 'spoon', 'spring', 'squash', 'star', 'stone', 'storm', 'straw', 'sugar', 'summer', 'sun', 'swan',
|
|
21
|
+
'table', 'tiger', 'toast', 'tomato', 'torch', 'tower', 'train', 'tulip', 'tunnel', 'turtle', 'umbrella', 'valley',
|
|
22
|
+
'velvet', 'violet', 'volcano', 'wagon', 'walnut', 'water', 'whale', 'wheat', 'whistle', 'willow', 'window',
|
|
23
|
+
'winter', 'wolf', 'wool', 'yarn', 'zebra', 'acorn', 'anchor', 'badger', 'beacon', 'birch', 'bison', 'cobalt',
|
|
24
|
+
'compass', 'dune', 'elm', 'fiddle', 'galaxy', 'geyser', 'gravel', 'heron', 'iris', 'juniper', 'kayak', 'lava',
|
|
25
|
+
'lemur', 'linen', 'lynx', 'yogurt',
|
|
26
|
+
];
|