@byokit/pair 0.8.0 → 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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.9.0 (2026-10-09)
6
+
7
+ - FIX: a phone killed while the person at the computer decides now resumes against the pinned computer, for compact and version 1 QRs alike. `pairWithOffer` takes `onPending(grant)`, which hands over the pending grant to keep before the computer can approve this device, and `pendingGrant` accepts a compact QR given the authenticated `host` key, and `parseOffer` reads a compact QR for inspection (expiry, addresses, name, role) without one. Before, `pendingGrant` refused compact QRs, and even a version 1 pending grant never came online after a real kill. A socket that drops once the computer has taken the code now ends pairing with `unreachable` instead of trying the next address, where the spent code or ticket could only be refused as `wrong-code` or `expired`. A socket that closes while `onPending` is still saving ends pairing at once with that close; a version 1 `onPending` that never settles times out. The compact QR is unchanged: 109 characters for one relay address and the name Umer.
8
+ - SECURITY: pinning and approval semantics. A compact QR's pending grant pins the key the code handshake authenticated, kept after Noise message 2 and before the device's identity goes out in message 3; if saving it fails, pairing stops before the computer learns the device. A device using `onPending` says so inside the handshake, and only then does the computer keep a yes given after its socket dropped, granting only the device key that handshake authenticated; any other device that leaves still gets no grant. A computer with another key at the same address is still refused, and a version 1 `pendingGrant` refuses a `host` that differs from its QR.
9
+ - `parseOffer` now returns `PairOffer | CompactOffer`, so a compact QR parses for inspection. Code that reads `.host` or `.ticket`, or passes the result to `offerText` or `encodeOffer`, narrows with `'host' in offer`, or calls the new `parseV1Offer` for the old version 1-only type.
10
+ - FIX: the pairing picture's words match what the host now prints: scan the code, or type the shown code where the phone page asks for it, with no raw address to type.
11
+ - FIX: the browser pairing test closes only the Chromium processes it spawned — over that browser's own private CDP pipe — instead of signalling a whole process group, so a killed or timed-out test can no longer take out or leave behind the wrong process tree. `BYOKIT_CHROME` is the single documented key that selects the test browser.
12
+
5
13
  ## 0.8.0 (2026-10-07)
6
14
 
7
15
  - Add redacted startup, page/module and link-stage diagnostics to the browser pairing test; the intermittent CI timeout's root cause remains unknown.
package/README.md CHANGED
@@ -152,11 +152,13 @@ const { text } = host.offer({
152
152
  console.log(text); // terminal-to-browser deep link; the offer stays after #
153
153
  ```
154
154
 
155
- `offerText(parseOffer(text), 'https://app.example/pair.html')` makes the same deep link from an existing offer.
155
+ `offerText(parseV1Offer(text), 'https://app.example/pair.html')` makes the same deep link from an existing version 1 offer.
156
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.
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`.
158
160
 
159
- For an offline, typeable alternative, `encodeOffer(parseOffer(text))` holds the **entire offer**, including
161
+ For an offline, typeable alternative, `encodeOffer(parseV1Offer(text))` holds the **entire offer**, including
160
162
  all direct and relay addresses. `decodeOffer` also reads old compact direct codes for migration, preserving
161
163
  their key, ticket, role and second-resolution expiry; re-encoding uses the current complete format.
162
164
  `decodeOffer(typed)` returns a `PairOffer` and checks expiry without contacting
@@ -279,10 +281,17 @@ await link.request('send.message', { text: 'hi' }); // waits through reconnects
279
281
  await link.request('send.message', { text: 'hi' }, { timeoutMs: 20_000, notValidAfter: Date.now() + 60_000 });
280
282
  ```
281
283
 
282
- - For crash-safe pairing, make a `keyPair()`, save `pendingGrant(scanned, { name, key })` before pairing, then pass
283
- that same `key` to `pairWithOffer(scanned, { name, key, onWords })`. If the app dies while the person decides,
284
- a `DeviceLink` made from the saved pending grant retries until approval (up to five minutes after the offer expires),
285
- then forgets it if the host still has not approved.
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`.
286
295
  - `resolve: (url) => …` runs before each dial (e.g. open an SSH tunnel and return `ws://127.0.0.1:<port>/…`);
287
296
  `link.addUrl(url)` adds an address found later (a wrong host there just fails its handshake).
288
297
  - A quiet connection is pinged (`pingMs`, default 20 s) and redialled when the host stops answering; at most
package/SECURITY.md CHANGED
@@ -27,7 +27,7 @@ A home computer (the **host**) holds AI sign-ins and other credentials. Phones,
27
27
  | QR / pairing link | `byokit-link:1:<base64url JSON>`: `{v, host (X25519 public key), name, urls, ticket (128 bits), expires, role?, lifetime?}`. The optional role and access lifetime let the device show the terms; older parsers ignore them. As a link, it rides after `#`, which browsers never send to a server. |
28
28
  | Offline offer envelope | Versioned base32 of the complete validated offer plus a 32-bit FNV-1a transcription checksum. Case, whitespace, dashes and O/0, I/L/1 aliases are normalized; noncanonical padding is refused. The checksum is not authentication. The same QR ticket, host key, expiry and approval rules apply; relay addresses and grant terms are preserved. |
29
29
  | Typed code | 12 characters from a 31-character unambiguous alphabet (about 59 bits), `XXXX-XXXX-XXXX`. |
30
- | Pairing rules | Tickets and codes are single use (burned at first presentation, even when expired or refused), live 5 minutes through grant creation, and 5 wrong tries withdraw every open ticket and code. Nothing is stored until the person at the host approves; both screens show the same **two confirmation words**, derived from the handshake hash. Optional device cap. |
30
+ | Pairing rules | Tickets and codes are single use (burned at first presentation, even when expired or refused), live 5 minutes through grant creation, and 5 wrong tries withdraw every open ticket and code. Nothing is stored until the person at the host approves; both screens show the same **two confirmation words**, derived from the handshake hash. Optional device cap. An approval holds after the device's socket dropped only when the device said, inside the handshake (`pending: 1` in its sealed `pair` request or typed-code message 3), that it kept a pending grant; the grant names only the device key that handshake authenticated. Any other device that leaves gets no grant, so no slot is held by a key nobody has. A compact QR carries no host key; the device keeps the key the code handshake authenticated (after message 2, before its identity goes out in message 3) as its pending grant's pin, so a resumed link is pinned exactly as from a version 1 QR. |
31
31
  | Grants | Held by the host with a device key and role; optional `kind`, `expires`, `nextKey` and `meta` support caps, expiry, rekey and app policy. Every handshake and request checks the grant. `allow` decides access when supplied; otherwise control is allowed and view-only uses `canView` (default: none). |
32
32
  | Presence and reload | Presence counts authenticated live sockets per grant and fires only on first/last. Store notifications trigger a serialized `Host.reload` with no write-back; removed/key-changed/role-changed grants close live sockets, and changed expiry reschedules access. A failed read preserves the last known authority and reports the failure: notifications are eventual, so an app needing instantaneous external-authority checks must enforce them in `allow`. The Node file backend uses a cooperating-writer lock and rejects stale snapshots; it polls only the app's chosen path and stops when the host closes. |
33
33
  | Ready metadata | Only the opt-in `deviceMeta(grant)` projection is sent to the device, inside the sealed `ready`. Host grant metadata is private by default. The app must not project credentials. |
@@ -115,9 +115,9 @@ A home computer (the **host**) holds AI sign-ins and other credentials. Phones,
115
115
  - [x] Host policy is enforced before the app handler; without `allow`, view-only defaults closed. Only an explicit `PublicLinkError` discloses its chosen message; other handler failures are logged on the host and return plain `failed`.
116
116
  - [x] A device forgets its grant only on a sealed `revoked`, `ended` or `not-paired` (or when a pending pairing expires).
117
117
  - [x] Names shown to people are stripped of control and direction-flipping characters and capped at 60.
118
- - [x] `parseOffer` accepts only `ws:`/`wss:` addresses without credentials, at most 8, a 32-byte key and 16-byte
118
+ - [x] `parseV1Offer` accepts only `ws:`/`wss:` addresses without credentials, at most 8, a 32-byte key and 16-byte
119
119
  ticket.
120
- - [x] Offline offer decoding checks the checksum, version, canonical padding, bounds and all `parseOffer` 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.
120
+ - [x] Offline offer decoding checks the checksum, version, canonical padding, bounds and all `parseV1Offer` rules, including expiry; inspecting with `now = 0` never renews a ticket. Legacy compact offers are accepted only on the migration read path, with their original pinned key, ticket, role and second-resolution expiry; they cannot change new encodings.
121
121
  - [x] The channel refuses frames past 2³² − 1 and messages over 16 MB.
122
122
  - [x] Stream opens re-check the grant and policy before the app's `stream` handler; data past a stream's window,
123
123
  or stream data that isn't a binary inner message, drops the socket; streams end on disconnect and revoke.
package/dist/device.d.ts CHANGED
@@ -78,15 +78,24 @@ type PairOptions = Dial & {
78
78
  };
79
79
  /** The grant a scanned code will become, to keep in secure storage *before* pairing. If the app dies while the person
80
80
  * at the host decides, a `DeviceLink` made from it later connects once the host has said yes (or learns it said no),
81
- * instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`. */
81
+ * instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`, or let
82
+ * `pairWithOffer`'s `onPending` hand it over. A compact offer carries no host key: it needs `host`, the key the code
83
+ * handshake authenticated (`onPending` passes it). For a version 1 offer, a different `host` is refused. */
82
84
  export declare function pendingGrant(scanned: string, o: {
83
85
  name: string;
84
86
  key?: KeyPair;
87
+ host?: string;
85
88
  }): DeviceGrant;
89
+ type OfferOptions = PairOptions & {
90
+ /** Keep this pending grant before the host can approve this device (see `pendingGrant`). A compact offer calls it
91
+ * once the host proved it holds the code, before this device's key and name reach it; a throw stops pairing there. */
92
+ onPending?: (g: DeviceGrant) => void | Promise<void>;
93
+ };
86
94
  /** A scanned QR (or opened pairing link) in, a grant out, once the person at the host says yes. `onWords` gets the
87
95
  * two words to show while they decide. Tries each address in the code until one answers. A compact offer pairs
88
- * like a typed code over the packed addresses: same single use, life, words and approval, no host key pinned. */
89
- export declare function pairWithOffer(scanned: string, o: PairOptions): Promise<DeviceGrant>;
96
+ * like a typed code over the packed addresses: same single use, life, words and approval; the host key it pins is
97
+ * the one the code handshake authenticated, handed to `onPending` before the host learns this device. */
98
+ export declare function pairWithOffer(scanned: string, o: OfferOptions): Promise<DeviceGrant>;
90
99
  /** A typed code in, a grant out. `Host.shortCode` also pins the machine key before sharing the device identity.
91
100
  * `url` is an untrusted address, e.g. from relay `findHost`; only the full code authenticates the host. */
92
101
  export declare function pairWithCode(url: string, typed: string, o: PairOptions): Promise<DeviceGrant>;
package/dist/device.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // its requests survive a reconnect: each carries a key, so a retried tap runs once. Uses only the platform's
4
4
  // WebSocket, so the same code runs in browsers, React Native and Node.
5
5
  import { Handshake, b64, b64url, keyPair, keyPairFrom, random, unb64url } from "./channel.js";
6
- import { cleanName, codeKey, decodeCompactOffer, parseCode, parseOffer, shortKey, COMPACT_TAG } from "./pairing.js";
6
+ import { cleanName, codeKey, decodeCompactOffer, parseCode, parseOffer, parseV1Offer, shortKey, COMPACT_TAG } from "./pairing.js";
7
7
  import { Streams } from "./stream.js";
8
8
  /** Every way the link can fail, in words a person can act on. */
9
9
  export const LINK_WORDS = {
@@ -43,6 +43,10 @@ export class PublicLinkError extends LinkError {
43
43
  const known = (why) => typeof why === 'string' && Object.hasOwn(LINK_WORDS, why);
44
44
  const problem = (why) => (known(why) ? why : 'unreachable');
45
45
  const later = (ms, fn) => { const t = setTimeout(fn, ms); t.unref?.(); return t; };
46
+ const within = (ms, work) => new Promise((resolve, reject) => {
47
+ const t = later(ms, () => reject(new LinkError('timeout')));
48
+ work.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
49
+ });
46
50
  /** One socket: the handshake, the first request (`auth` or `pair`), and the host's `ready`. */
47
51
  async function dial(url, me, host, hello, o, on = { message: () => { }, close: () => { } }) {
48
52
  let target = url;
@@ -107,16 +111,33 @@ async function dial(url, me, host, hello, o, on = { message: () => { }, close: (
107
111
  } // only the right host can answer
108
112
  if (host.commitment && shortKey(hs.remoteKey) !== host.commitment)
109
113
  return fail(new LinkError('wrong-host'));
110
- if (hello.t === 'code')
111
- ws.send(hs.write({ name: hello.name }));
112
- ch = hs.channel();
113
- if (hello.t !== 'code')
114
- send(hello);
115
- if (pairing) { // now a person at the host decides; give them time
116
- o.onWords?.(hs.words);
117
- clearTimeout(timer);
118
- timer = later(6 * 60_000, () => fail(new LinkError('timeout')));
119
- }
114
+ const go = () => {
115
+ if (hello.t === 'code')
116
+ ws.send(hs.write({ name: hello.name, ...(hello.pending ? { pending: 1 } : {}) }));
117
+ ch = hs.channel();
118
+ if (hello.t !== 'code')
119
+ send(hello);
120
+ if (pairing) { // now a person at the host decides; give them time
121
+ o.onWords?.(hs.words);
122
+ clearTimeout(timer);
123
+ timer = later(6 * 60_000, () => fail(new LinkError('timeout')));
124
+ }
125
+ };
126
+ if (!host.pin)
127
+ return go();
128
+ // The host proved it holds the code; the app keeps its key before this device is disclosed in message 3. A save
129
+ // that never settles times out, or ends sooner when the host drops the socket.
130
+ clearTimeout(timer);
131
+ within(o.timeoutMs ?? 8000, Promise.resolve().then(() => host.pin(hs.remoteKey))).then(() => {
132
+ if (settled)
133
+ return;
134
+ try {
135
+ go();
136
+ }
137
+ catch {
138
+ fail(new LinkError('unreachable'));
139
+ }
140
+ }, fail);
120
141
  return;
121
142
  }
122
143
  const m = ch.open(typeof ev.data === 'string' ? ev.data : new Uint8Array(ev.data)); // throws unless it is the host's next authentic frame
@@ -146,47 +167,72 @@ function granted(me, hostKey, url, urls, ready) {
146
167
  }
147
168
  /** The grant a scanned code will become, to keep in secure storage *before* pairing. If the app dies while the person
148
169
  * at the host decides, a `DeviceLink` made from it later connects once the host has said yes (or learns it said no),
149
- * instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`. */
170
+ * instead of leaving the host holding a grant for a key nobody has. Pass its key to `pairWithOffer`, or let
171
+ * `pairWithOffer`'s `onPending` hand it over. A compact offer carries no host key: it needs `host`, the key the code
172
+ * handshake authenticated (`onPending` passes it). For a version 1 offer, a different `host` is refused. */
150
173
  export function pendingGrant(scanned, o) {
151
174
  const offer = parseOffer(scanned);
175
+ let host = 'host' in offer ? offer.host : undefined;
176
+ if (o.host !== undefined) {
177
+ let given;
178
+ try {
179
+ given = unb64url(o.host);
180
+ }
181
+ catch {
182
+ throw new LinkError('wrong-host');
183
+ }
184
+ if (given.length !== 32 || (host !== undefined && host !== b64url(given)))
185
+ throw new LinkError('wrong-host');
186
+ host = b64url(given);
187
+ }
188
+ if (host === undefined)
189
+ throw new Error("A compact pairing code doesn't carry the computer's key: use pairWithOffer's onPending.");
152
190
  const me = o.key ?? keyPair();
153
- return { v: 1, secretKey: b64url(me.secretKey), pendingUntil: offer.expires + 300_000, host: offer.host, hostName: offer.name, urls: offer.urls,
191
+ return { v: 1, secretKey: b64url(me.secretKey), pendingUntil: offer.expires + 300_000, host, hostName: offer.name, urls: offer.urls,
154
192
  device: { id: '', name: cleanName(o.name, 'Device'), role: offer.role ?? 'view' } };
155
193
  }
156
194
  /** A scanned QR (or opened pairing link) in, a grant out, once the person at the host says yes. `onWords` gets the
157
195
  * two words to show while they decide. Tries each address in the code until one answers. A compact offer pairs
158
- * like a typed code over the packed addresses: same single use, life, words and approval, no host key pinned. */
196
+ * like a typed code over the packed addresses: same single use, life, words and approval; the host key it pins is
197
+ * the one the code handshake authenticated, handed to `onPending` before the host learns this device. */
159
198
  export async function pairWithOffer(scanned, o) {
199
+ let taken = false; // the host has the code (it read the ticket or the code's first message): no other address can answer
200
+ const words = { ...o, onWords: (w) => { taken = true; o.onWords(w); } };
160
201
  if (scanned.indexOf(COMPACT_TAG) >= 0) {
161
202
  const compact = decodeCompactOffer(scanned);
162
203
  const me = o.key ?? keyPair();
204
+ const pin = o.onPending && ((key) => { taken = true; return o.onPending(pendingGrant(scanned, { name: o.name, key: me, host: b64url(key) })); });
163
205
  let last = new LinkError('unreachable');
164
206
  for (const url of compact.urls) {
165
207
  try {
166
- const l = await dial(url, me, { psk: codeKey(compact.code) }, { t: 'code', name: o.name }, o);
208
+ const l = await dial(url, me, { psk: codeKey(compact.code), pin }, { t: 'code', name: o.name, ...(pin ? { pending: 1 } : {}) }, words);
167
209
  l.close();
168
210
  return granted(me, b64url(l.hostKey), url, compact.urls, l.ready);
169
211
  }
170
212
  catch (e) {
171
- last = e instanceof LinkError ? e : new LinkError('unreachable');
172
- if (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host')
213
+ if (!(e instanceof LinkError))
214
+ throw e; // the app's own onPending failure, as it said it
215
+ last = e;
216
+ if (taken || (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host'))
173
217
  break;
174
218
  }
175
219
  }
176
220
  throw last;
177
221
  }
178
- const offer = parseOffer(scanned);
222
+ const offer = parseV1Offer(scanned);
179
223
  const me = o.key ?? keyPair();
224
+ if (o.onPending)
225
+ await within(o.timeoutMs ?? 8000, Promise.resolve(o.onPending(pendingGrant(scanned, { name: o.name, key: me }))));
180
226
  let last = new LinkError('unreachable');
181
227
  for (const url of offer.urls) {
182
228
  try {
183
- const l = await dial(url, me, { key: unb64url(offer.host) }, { t: 'pair', ticket: offer.ticket, name: o.name }, o);
229
+ const l = await dial(url, me, { key: unb64url(offer.host) }, { t: 'pair', ticket: offer.ticket, name: o.name, ...(o.onPending ? { pending: 1 } : {}) }, words);
184
230
  l.close();
185
231
  return granted(me, offer.host, url, offer.urls, l.ready);
186
232
  }
187
233
  catch (e) {
188
234
  last = e instanceof LinkError ? e : new LinkError('unreachable');
189
- if (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host')
235
+ if (taken || (last.code !== 'unreachable' && last.code !== 'timeout' && last.code !== 'wrong-host'))
190
236
  break;
191
237
  }
192
238
  }
package/dist/host.js CHANGED
@@ -428,7 +428,7 @@ export class Host {
428
428
  const end = (c, why) => { if (!gone)
429
429
  conn.close(c, why); };
430
430
  const refuse = (why) => { this.sealed(conn, ch, { t: 'refused', why }); later(1000, () => end(4401, why)); };
431
- const pair = async (key, name, p, how) => {
431
+ const pair = async (key, name, p, how, kept) => {
432
432
  const k = b64url(key);
433
433
  busy = true;
434
434
  clearTimeout(timer);
@@ -438,10 +438,15 @@ export class Host {
438
438
  Promise.resolve().then(() => this.opts.confirm(req)).catch(() => false),
439
439
  new Promise((r) => later(this.pairMs, () => r(false))),
440
440
  ]);
441
- if (gone)
441
+ // A device that said it kept a pending grant resumes from it after being killed while the person decided, so
442
+ // its yes outlives the socket; any other gone device could never use the grant, so it gets none.
443
+ if (gone && kept !== 1)
444
+ return;
445
+ if (!yes) {
446
+ if (!gone)
447
+ refuse('declined');
442
448
  return;
443
- if (!yes)
444
- return refuse('declined');
449
+ }
445
450
  try {
446
451
  const added = await this.grant(k, req.name, p, p.expires);
447
452
  if (gone)
@@ -581,7 +586,7 @@ export class Host {
581
586
  }
582
587
  const hello = hs.read(text); // the typed-code handshake's last message: the device's key and name
583
588
  ch = hs.channel();
584
- void pair(hs.remoteKey, hello.name, code, 'code');
589
+ void pair(hs.remoteKey, hello.name, code, 'code', hello.pending);
585
590
  };
586
591
  const inner = (d, m) => {
587
592
  if (m.t === 'req')
@@ -674,7 +679,7 @@ export class Host {
674
679
  this.wrong();
675
680
  return refuse('expired');
676
681
  }
677
- return void pair(hs.remoteKey, m.name, p, 'scan');
682
+ return void pair(hs.remoteKey, m.name, p, 'scan', m.pending);
678
683
  }
679
684
  end(4400, 'unexpected');
680
685
  }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { hostId, keyPair, keyPairFrom, b64url, unb64url, type KeyPair } from './channel.ts';
2
2
  export { Host, type AnswerStore, type Grant, type GrantStore, type GrantTerms, type HostOptions, type LinkRequest, type PairRequest, type Role, type Socket, } from './host.ts';
3
3
  export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, type DeviceGrant, type DeviceStore, type Dial, type LinkOptions, type LinkProblem, type LinkStatus, type RequestOptions, } from './device.ts';
4
- export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, COMPACT_TAG } from './pairing.ts';
4
+ export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from './pairing.ts';
5
5
  export { LinkStream, WINDOW } from './stream.ts';
6
6
  export { check, LINK_PROBE, LINK_PROBE_OK, type CheckOptions, type CheckResult } from './check.ts';
7
7
  export type { CompactOffer, PairOffer } from './pairing.ts';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { hostId, keyPair, keyPairFrom, b64url, unb64url } from "./channel.js";
2
2
  export { Host, } from "./host.js";
3
3
  export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, } from "./device.js";
4
- export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, COMPACT_TAG } from "./pairing.js";
4
+ export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from "./pairing.js";
5
5
  export { LinkStream, WINDOW } from "./stream.js";
6
6
  export { check, LINK_PROBE, LINK_PROBE_OK } from "./check.js";
7
7
  export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores } from "./stores.js";
package/dist/pairing.d.ts CHANGED
@@ -18,14 +18,17 @@ export declare const cleanName: (name: unknown, fallback: string) => string;
18
18
  * the part after `#`, which browsers never send to a server. */
19
19
  export declare function offerText(offer: PairOffer, base?: string): string;
20
20
  /** Scan (or pasted link) in, offer out; throws a plain sentence for anything that is not a live byokit pairing code.
21
+ * A compact offer comes back without `host` or `ticket` (it has neither: `pairWithOffer` learns the host key).
21
22
  * Pass `0` as `now` to inspect an expired offer without connecting. */
22
- export declare function parseOffer(scanned: string, now?: number): PairOffer;
23
+ export declare function parseOffer(scanned: string, now?: number): PairOffer | CompactOffer;
24
+ /** A version 1 offer only; `parseOffer` for anything else. */
25
+ export declare function parseV1Offer(scanned: string, now?: number): PairOffer;
23
26
  /** A complete offline offer in groups of five base32 characters, with a transcription checksum. This carries the
24
27
  * same single-use ticket as the QR; it neither extends expiry nor replaces host approval. */
25
28
  export declare function encodeOffer(offer: PairOffer): string;
26
29
  /** Read an offline offer without network access. Case, spaces and dashes are ignored; O means 0 and I/L mean 1.
27
30
  * Old compact direct offers remain readable for migration; new offers always use the current format.
28
- * Other typos fail the checksum. Expiry and addresses follow `parseOffer`; `now = 0` permits inspection only. */
31
+ * Other typos fail the checksum. Expiry and addresses follow `parseV1Offer`; `now = 0` permits inspection only. */
29
32
  export declare function decodeOffer(text: string, now?: number): PairOffer;
30
33
  /** Typed codes use letters and numbers nobody mixes up (no 0/O, 1/I/L). Twelve of them are about 59 bits: too many
31
34
  * to guess, even offline, in the few minutes a code lives. */
package/dist/pairing.js CHANGED
@@ -12,8 +12,13 @@ export function offerText(offer, base) {
12
12
  return base ? `${base}#${body}` : body;
13
13
  }
14
14
  /** Scan (or pasted link) in, offer out; throws a plain sentence for anything that is not a live byokit pairing code.
15
+ * A compact offer comes back without `host` or `ticket` (it has neither: `pairWithOffer` learns the host key).
15
16
  * Pass `0` as `now` to inspect an expired offer without connecting. */
16
17
  export function parseOffer(scanned, now = Date.now()) {
18
+ return scanned.indexOf(COMPACT_TAG) >= 0 ? decodeCompactOffer(scanned, now) : parseV1Offer(scanned, now);
19
+ }
20
+ /** A version 1 offer only; `parseOffer` for anything else. */
21
+ export function parseV1Offer(scanned, now = Date.now()) {
17
22
  const at = scanned.indexOf(TAG);
18
23
  let o;
19
24
  try {
@@ -63,7 +68,7 @@ function checksum(bytes) {
63
68
  /** A complete offline offer in groups of five base32 characters, with a transcription checksum. This carries the
64
69
  * same single-use ticket as the QR; it neither extends expiry nor replaces host approval. */
65
70
  export function encodeOffer(offer) {
66
- const checked = parseOffer(offerText(offer), 0);
71
+ const checked = parseV1Offer(offerText(offer), 0);
67
72
  const body = b4a.from(JSON.stringify(checked));
68
73
  const bytes = new Uint8Array(1 + body.length + 4);
69
74
  bytes[0] = 1;
@@ -84,7 +89,7 @@ export function encodeOffer(offer) {
84
89
  }
85
90
  /** Read an offline offer without network access. Case, spaces and dashes are ignored; O means 0 and I/L mean 1.
86
91
  * Old compact direct offers remain readable for migration; new offers always use the current format.
87
- * Other typos fail the checksum. Expiry and addresses follow `parseOffer`; `now = 0` permits inspection only. */
92
+ * Other typos fail the checksum. Expiry and addresses follow `parseV1Offer`; `now = 0` permits inspection only. */
88
93
  export function decodeOffer(text, now = Date.now()) {
89
94
  if (text.length > MAX_TYPED_OFFER)
90
95
  throw badOffer();
@@ -111,7 +116,7 @@ export function decodeOffer(text, now = Date.now()) {
111
116
  const b = Uint8Array.from(bytes);
112
117
  if (b.length < 6 || b[0] !== 1 || checksum(b.subarray(0, -4)) !== new DataView(b.buffer).getUint32(b.length - 4))
113
118
  throw badOffer();
114
- return parseOffer(TAG + b64url(b.subarray(1, -4)), now);
119
+ return parseV1Offer(TAG + b64url(b.subarray(1, -4)), now);
115
120
  }
116
121
  // Migration read path only. The old direct envelope used seconds, a role byte and no name/lifetime.
117
122
  function decodeLegacyOffer(s, now) {
@@ -169,7 +174,7 @@ function decodeLegacyOffer(s, now) {
169
174
  }
170
175
  if (at !== end || urls.some((url) => !url.startsWith('ws:') || !wsUrl(url)))
171
176
  throw badOffer();
172
- return parseOffer(offerText({
177
+ return parseV1Offer(offerText({
173
178
  v: 1, host: b64url(b.subarray(2, 34)), ticket: b64url(b.subarray(34, 50)),
174
179
  expires: view.getUint32(50) * 1000, name: 'your computer', role: b[1] ? 'control' : 'view', urls,
175
180
  }), now);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/pair",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Scan a code to pair a phone or browser with the home computer over one encrypted link (Noise IK over WebSocket).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",