@byokit/pair 0.0.0-stage → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ // What pairing puts in front of a person: the QR's text (or a link that carries it), and the typed code.
2
+ import b4a from 'b4a';
3
+ import { b64url, hash, random, unb64url } from "./channel.js";
4
+ const TAG = 'byokit-link:1:';
5
+ const UNSAFE = /[\u0000-\u001f\u007f\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu; // control and direction-flipping characters
6
+ /** A device or host name as a person should see it: no hidden characters, at most 60 of them. */
7
+ export const cleanName = (name, fallback) => (typeof name === 'string' ? name.replace(UNSAFE, '').trim().slice(0, 60) : '') || fallback;
8
+ /** The QR's text. Give a web address (`https://…/pair`) to get a link a browser can open instead: the offer rides in
9
+ * the part after `#`, which browsers never send to a server. */
10
+ export function offerText(offer, base) {
11
+ const body = TAG + b64url(b4a.from(JSON.stringify(offer)));
12
+ return base ? `${base}#${body}` : body;
13
+ }
14
+ /** Scan (or pasted link) in, offer out; throws a plain sentence for anything that is not a live byokit pairing code.
15
+ * Pass `0` as `now` to inspect an expired offer without connecting. */
16
+ export function parseOffer(scanned, now = Date.now()) {
17
+ const at = scanned.indexOf(TAG);
18
+ let o;
19
+ try {
20
+ if (at < 0 || scanned.length > 8192)
21
+ throw new Error();
22
+ o = JSON.parse(b4a.toString(b4a.from(unb64url(scanned.slice(at + TAG.length).trim()))));
23
+ if (typeof o?.host !== 'string' || unb64url(o.host).length !== 32 ||
24
+ typeof o.ticket !== 'string' || unb64url(o.ticket).length !== 16)
25
+ throw new Error();
26
+ }
27
+ catch {
28
+ throw new Error("That isn't a pairing code.");
29
+ }
30
+ const ok = o?.v === 1 && Number.isFinite(o.expires)
31
+ && Array.isArray(o.urls) && o.urls.length > 0 && o.urls.length <= 8 && o.urls.every(wsUrl);
32
+ if (!ok)
33
+ throw new Error("That isn't a pairing code.");
34
+ if (o.expires < now)
35
+ throw new Error('That pairing code has run out. Show a new one.');
36
+ return { v: 1, host: o.host, name: cleanName(o.name, 'your computer'), urls: o.urls, ticket: o.ticket, expires: o.expires,
37
+ ...(o.role === 'control' || o.role === 'view' ? { role: o.role } : {}),
38
+ ...(Number.isSafeInteger(o.lifetime) && o.lifetime > 0 ? { lifetime: o.lifetime } : {}) };
39
+ }
40
+ function wsUrl(u) {
41
+ if (typeof u !== 'string' || u.length > 512)
42
+ return false;
43
+ try {
44
+ const p = new URL(u);
45
+ return (p.protocol === 'ws:' || p.protocol === 'wss:') && !p.username && !p.password;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ // A separate offline envelope, not the short PSK code. Preserve every offer field (including relay addresses and
52
+ // exact expiry) rather than borrowing the reference's direct-only, second-resolution compact representation.
53
+ const OFFER_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
54
+ const MAX_TYPED_OFFER = 14_000;
55
+ const badOffer = () => new Error("That code didn't match. Check it and try again.");
56
+ // FNV-1a detects transcription errors. It is not authentication; the Noise handshake pins the host key.
57
+ function checksum(bytes) {
58
+ let h = 2166136261;
59
+ for (const b of bytes)
60
+ h = Math.imul(h ^ b, 16777619) >>> 0;
61
+ return h;
62
+ }
63
+ /** A complete offline offer in groups of five base32 characters, with a transcription checksum. This carries the
64
+ * same single-use ticket as the QR; it neither extends expiry nor replaces host approval. */
65
+ export function encodeOffer(offer) {
66
+ const checked = parseOffer(offerText(offer), 0);
67
+ const body = b4a.from(JSON.stringify(checked));
68
+ const bytes = new Uint8Array(1 + body.length + 4);
69
+ bytes[0] = 1;
70
+ bytes.set(body, 1);
71
+ new DataView(bytes.buffer).setUint32(bytes.length - 4, checksum(bytes.subarray(0, -4)));
72
+ let bits = 0, value = 0, out = '';
73
+ for (const b of bytes) {
74
+ value = (value << 8 | b) & 0xffff;
75
+ bits += 8;
76
+ while (bits >= 5) {
77
+ bits -= 5;
78
+ out += OFFER_ALPHABET[(value >>> bits) & 31];
79
+ }
80
+ }
81
+ if (bits)
82
+ out += OFFER_ALPHABET[(value << (5 - bits)) & 31];
83
+ return out.match(/.{1,5}/g).join('-');
84
+ }
85
+ /** Read an offline offer without network access. Case, spaces and dashes are ignored; O means 0 and I/L mean 1.
86
+ * 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. */
88
+ export function decodeOffer(text, now = Date.now()) {
89
+ if (text.length > MAX_TYPED_OFFER)
90
+ throw badOffer();
91
+ const compact = text.toUpperCase().replace(/[\s-]/g, '');
92
+ // Legacy v1 + role starts with 26 in its own alphabet; current v1 starts with 0.
93
+ if (compact.startsWith('26'))
94
+ return decodeLegacyOffer(compact, now);
95
+ const s = compact.replace(/O/g, '0').replace(/[IL]/g, '1');
96
+ if (!s || [...s].some((c) => !OFFER_ALPHABET.includes(c)))
97
+ throw badOffer();
98
+ const bytes = [];
99
+ let bits = 0, value = 0;
100
+ for (const c of s) {
101
+ value = (value << 5 | OFFER_ALPHABET.indexOf(c)) & 0xffff;
102
+ bits += 5;
103
+ if (bits >= 8) {
104
+ bits -= 8;
105
+ bytes.push((value >>> bits) & 255);
106
+ }
107
+ }
108
+ // Reject extra zero symbols as well as non-zero padding: one byte sequence has one canonical encoding.
109
+ if (s.length !== Math.ceil(bytes.length * 8 / 5) || (bits && (value & ((1 << bits) - 1))))
110
+ throw badOffer();
111
+ const b = Uint8Array.from(bytes);
112
+ if (b.length < 6 || b[0] !== 1 || checksum(b.subarray(0, -4)) !== new DataView(b.buffer).getUint32(b.length - 4))
113
+ throw badOffer();
114
+ return parseOffer(TAG + b64url(b.subarray(1, -4)), now);
115
+ }
116
+ // Migration read path only. The old direct envelope used seconds, a role byte and no name/lifetime.
117
+ function decodeLegacyOffer(s, now) {
118
+ const alphabet = '23456789ABCDEFGHJKMNPQRSTUVWXYZ0';
119
+ if (s.length > 2048 || [...s].some((c) => !alphabet.includes(c)))
120
+ throw badOffer();
121
+ const bytes = [];
122
+ let bits = 0, value = 0;
123
+ for (const c of s) {
124
+ value = (value << 5 | alphabet.indexOf(c)) & 0xffff;
125
+ bits += 5;
126
+ if (bits >= 8) {
127
+ bits -= 8;
128
+ bytes.push((value >>> bits) & 255);
129
+ }
130
+ }
131
+ if (s.length !== Math.ceil(bytes.length * 8 / 5) || (bits && (value & ((1 << bits) - 1))))
132
+ throw badOffer();
133
+ const b = Uint8Array.from(bytes);
134
+ if (b.length < 59 || b[0] !== 1 || b[1] > 1)
135
+ throw badOffer();
136
+ const end = b.length - 4;
137
+ const view = new DataView(b.buffer);
138
+ if (checksum(b.subarray(0, end)) !== view.getUint32(end))
139
+ throw badOffer();
140
+ const count = b[54];
141
+ if (!count || count > 8)
142
+ throw badOffer();
143
+ let at = 55;
144
+ const urls = [];
145
+ for (let i = 0; i < count; i++) {
146
+ if (at >= end)
147
+ throw badOffer();
148
+ const length = b[at++];
149
+ if (length === 0) {
150
+ if (at + 6 > end)
151
+ throw badOffer();
152
+ const port = view.getUint16(at + 4);
153
+ if (!port)
154
+ throw badOffer();
155
+ urls.push(`ws://${[...b.subarray(at, at + 4)].join('.')}:${port}/link`);
156
+ at += 6;
157
+ }
158
+ else {
159
+ if (at + length > end)
160
+ throw badOffer();
161
+ try {
162
+ urls.push(new TextDecoder('utf-8', { fatal: true }).decode(b.subarray(at, at + length)));
163
+ }
164
+ catch {
165
+ throw badOffer();
166
+ }
167
+ at += length;
168
+ }
169
+ }
170
+ if (at !== end || urls.some((url) => !url.startsWith('ws:') || !wsUrl(url)))
171
+ throw badOffer();
172
+ return parseOffer(offerText({
173
+ v: 1, host: b64url(b.subarray(2, 34)), ticket: b64url(b.subarray(34, 50)),
174
+ expires: view.getUint32(50) * 1000, name: 'your computer', role: b[1] ? 'control' : 'view', urls,
175
+ }), now);
176
+ }
177
+ /** Typed codes use letters and numbers nobody mixes up (no 0/O, 1/I/L). Twelve of them are about 59 bits: too many
178
+ * to guess, even offline, in the few minutes a code lives. */
179
+ export const CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
180
+ const CODE_LENGTH = 12;
181
+ export function newCode() {
182
+ let code = '';
183
+ while (code.length < CODE_LENGTH) {
184
+ for (const b of random(32))
185
+ if (b < 248 && code.length < CODE_LENGTH)
186
+ code += CODE_ALPHABET[b % 31]; // 248 = 8 × 31, so no bias
187
+ }
188
+ return code.match(/.{4}/g).join('-');
189
+ }
190
+ /** A legacy secret or machine-bound short code, normalized or null. Case, spaces and dashes don't matter. */
191
+ export function normalizeCode(typed) {
192
+ if (typed.length > 256)
193
+ return null;
194
+ const c = typed.toUpperCase().replace(/[\s-]/g, '');
195
+ if (c.length === CODE_LENGTH && [...c].every((ch) => CODE_ALPHABET.includes(ch)))
196
+ return c;
197
+ return /^K1[2-9A-HJKMNP-Z]{12}[0-9A-F]{32}$/.test(c) ? c : null;
198
+ }
199
+ /** Public machine-key commitment; the secret's entropy still comes only from `newCode`. */
200
+ export const shortKey = (hostKey) => [...hash(16, 'byokit-link-short-key-v1', hostKey)].map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
201
+ export const bindCode = (code, hostKey) => `K1-${code}-${shortKey(hostKey).match(/.{4}/g).join('-')}`;
202
+ /** Accept a bound code or the legacy secret; malformed bound codes never downgrade. */
203
+ export function parseCode(typed) {
204
+ const compact = normalizeCode(typed);
205
+ if (!compact)
206
+ return null;
207
+ if (compact.length === CODE_LENGTH)
208
+ return { secret: compact };
209
+ return { secret: compact.slice(2, 14), commitment: compact.slice(14) };
210
+ }
211
+ /** The Noise pre-shared key a typed code stands for. */
212
+ export const codeKey = (code) => hash(32, 'byokit-link-code-v1', parseCode(code)?.secret ?? '');
213
+ // A compact pairing offer for the QR: the same secret a typed code carries (same single use, expiry, two words
214
+ // and host approval, through the same code entry), plus the addresses to try, packed binary. Scanned, never typed,
215
+ // so the QR's own error correction covers misreads and there is no transcription checksum. The tag is uppercase so
216
+ // the QR stays in its dense alphanumeric mode. Version 1 offers keep parsing unchanged.
217
+ export const COMPACT_TAG = 'BYOKIT-LINK:2:';
218
+ const COMPACT_VERSION = 2;
219
+ const COMPACT_ROLE = 1; // control; view is the default bit-off state
220
+ const COMPACT_LIFETIME = 2;
221
+ const badCompact = () => new Error("That isn't a pairing code.");
222
+ // Twelve base-31 digits need 63 bits, past float precision: BigInt throughout.
223
+ const secretValue = (secret) => {
224
+ let value = 0n;
225
+ for (const c of secret)
226
+ value = value * 31n + BigInt(CODE_ALPHABET.indexOf(c));
227
+ return value;
228
+ };
229
+ const secretChars = (value) => {
230
+ let out = '';
231
+ for (let i = 0; i < CODE_LENGTH; i++) {
232
+ out = CODE_ALPHABET[Number(value % 31n)] + out;
233
+ value /= 31n;
234
+ }
235
+ return out;
236
+ };
237
+ // One address, packed: a type byte (secure scheme, IPv4 or name, default `/link` path or custom), then the address,
238
+ // an explicit port (the scheme default when the URL leaves it out), and any custom path and query.
239
+ function packUrl(u) {
240
+ const p = new URL(u);
241
+ const secure = p.protocol === 'wss:';
242
+ const v4 = p.hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/)?.slice(1).map(Number);
243
+ const ip = v4 && v4.every((n) => n < 256) ? v4 : null;
244
+ const port = p.port ? Number(p.port) : secure ? 443 : 80;
245
+ // A bare authority (`ws://host:port`) keeps its empty path, so a decode is byte-stable; `/link` is still elided.
246
+ const path = p.pathname === '/' && !p.search && !p.hash ? '' : p.pathname + p.search + p.hash;
247
+ const textbook = path === '/link';
248
+ const host = new TextEncoder().encode(p.hostname);
249
+ const road = new TextEncoder().encode(path);
250
+ if (!(port >= 1 && port <= 65535) || host.length > 255 || road.length > 255)
251
+ throw new Error('That address is too long for a compact code.');
252
+ const out = [(secure ? 128 : 0) | (ip ? 0 : 2) | (textbook ? 0 : 1)];
253
+ out.push(...(ip ?? [host.length, ...host]));
254
+ out.push(port >> 8, port & 255);
255
+ if (!textbook)
256
+ out.push(road.length, ...road);
257
+ return out;
258
+ }
259
+ function unpackUrl(b, at) {
260
+ if (at.i >= b.length)
261
+ throw badCompact();
262
+ const type = b[at.i++];
263
+ const secure = (type & 128) !== 0;
264
+ const custom = (type & 1) !== 0;
265
+ let host;
266
+ if ((type & 2) === 0) {
267
+ if (at.i + 6 > b.length)
268
+ throw badCompact();
269
+ host = [...b.subarray(at.i, at.i + 4)].join('.');
270
+ at.i += 4;
271
+ }
272
+ else {
273
+ const length = b[at.i++];
274
+ if (at.i + length + 2 > b.length)
275
+ throw badCompact();
276
+ try {
277
+ host = new TextDecoder('utf-8', { fatal: true }).decode(b.subarray(at.i, at.i + length));
278
+ }
279
+ catch {
280
+ throw badCompact();
281
+ }
282
+ at.i += length;
283
+ }
284
+ const port = (b[at.i] << 8) | b[at.i + 1];
285
+ at.i += 2;
286
+ let path = '/link';
287
+ if (custom) {
288
+ const length = b[at.i++];
289
+ if (at.i + length > b.length)
290
+ throw badCompact();
291
+ try {
292
+ path = new TextDecoder('utf-8', { fatal: true }).decode(b.subarray(at.i, at.i + length));
293
+ }
294
+ catch {
295
+ throw badCompact();
296
+ }
297
+ at.i += length;
298
+ }
299
+ // The port is always written back: the envelope holds it explicitly, so the text has one canonical shape.
300
+ const url = `${secure ? 'wss' : 'ws'}://${host}:${port}${path}`;
301
+ if (!wsUrl(url))
302
+ throw badCompact();
303
+ return url;
304
+ }
305
+ /** Pack a code secret, its addresses and terms into QR-sized text. Throws a plain sentence for anything that would
306
+ * not survive the trip (bad addresses, absurd lengths); the host calls this before storing the code, so a throw
307
+ * leaves no open entry behind. */
308
+ export function encodeCompactOffer(o) {
309
+ const secret = o.code.toUpperCase().replace(/[\s-]/g, '');
310
+ if (secret.length !== CODE_LENGTH || [...secret].some((c) => !CODE_ALPHABET.includes(c)))
311
+ throw badCompact();
312
+ if (!Array.isArray(o.urls) || o.urls.length === 0 || o.urls.length > 8 || !o.urls.every(wsUrl))
313
+ throw badCompact();
314
+ if (!Number.isFinite(o.expires) || o.expires <= 0)
315
+ throw badCompact();
316
+ if (o.role !== 'control' && o.role !== 'view')
317
+ throw badCompact();
318
+ if (o.lifetime !== undefined && (!Number.isSafeInteger(o.lifetime) || o.lifetime <= 0 || o.lifetime > 4294967295 * 1000))
319
+ throw new Error('A lifetime must fit in a compact code.');
320
+ const bytes = [COMPACT_VERSION];
321
+ let value = secretValue(secret);
322
+ const word = new Array(8).fill(0);
323
+ for (let i = 7; i >= 0; i--) {
324
+ word[i] = Number(value & 255n);
325
+ value >>= 8n;
326
+ }
327
+ bytes.push(...word);
328
+ const minutes = Math.ceil(o.expires / 60000); // minute resolution; rounded up, so our own "run out" never fires early
329
+ bytes.push((minutes >>> 24) & 255, (minutes >>> 16) & 255, (minutes >>> 8) & 255, minutes & 255);
330
+ bytes.push((o.role === 'control' ? COMPACT_ROLE : 0) | (o.lifetime === undefined ? 0 : COMPACT_LIFETIME), o.urls.length);
331
+ for (const u of o.urls)
332
+ bytes.push(...packUrl(u));
333
+ const name = new TextEncoder().encode(cleanName(o.name, 'your computer'));
334
+ if (name.length > 255)
335
+ throw new Error('That name is too long for a compact code.');
336
+ bytes.push(name.length, ...name);
337
+ if (o.lifetime !== undefined) {
338
+ const seconds = Math.ceil(o.lifetime / 1000);
339
+ bytes.push((seconds >>> 24) & 255, (seconds >>> 16) & 255, (seconds >>> 8) & 255, seconds & 255);
340
+ }
341
+ let bits = 0, acc = 0, out = '';
342
+ for (const byte of bytes) {
343
+ acc = (acc << 8 | byte) & 0xffff;
344
+ bits += 8;
345
+ while (bits >= 5) {
346
+ bits -= 5;
347
+ out += OFFER_ALPHABET[(acc >>> bits) & 31];
348
+ }
349
+ }
350
+ if (bits)
351
+ out += OFFER_ALPHABET[(acc << (5 - bits)) & 31];
352
+ return COMPACT_TAG + out;
353
+ }
354
+ /** Read a compact offer. Case, spaces and dashes are forgiven; anything else that is not a compact offer throws a
355
+ * plain sentence. Expiry follows `parseOffer`; `now = 0` permits inspection only. */
356
+ export function decodeCompactOffer(scanned, now = Date.now()) {
357
+ const at = scanned.indexOf(COMPACT_TAG);
358
+ if (at < 0 || scanned.length > 2048)
359
+ throw badCompact();
360
+ const s = scanned.slice(at + COMPACT_TAG.length).toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1');
361
+ if (!s || [...s].some((c) => !OFFER_ALPHABET.includes(c)))
362
+ throw badCompact();
363
+ const raw = [];
364
+ let bits = 0, acc = 0;
365
+ for (const c of s) {
366
+ acc = (acc << 5 | OFFER_ALPHABET.indexOf(c)) & 0xffff;
367
+ bits += 5;
368
+ if (bits >= 8) {
369
+ bits -= 8;
370
+ raw.push((acc >>> bits) & 255);
371
+ }
372
+ }
373
+ // One byte sequence has one canonical encoding: reject extra symbols and non-zero padding, as typed offers do.
374
+ if (s.length !== Math.ceil(raw.length * 8 / 5) || (bits && (acc & ((1 << bits) - 1))))
375
+ throw badCompact();
376
+ const b = Uint8Array.from(raw);
377
+ const pos = { i: 0 };
378
+ const take = (n) => {
379
+ if (pos.i + n > b.length)
380
+ throw badCompact();
381
+ return b.subarray(pos.i, (pos.i += n));
382
+ };
383
+ if (take(1)[0] !== COMPACT_VERSION)
384
+ throw badCompact();
385
+ let secret = 0n;
386
+ for (const byte of take(8))
387
+ secret = secret * 256n + BigInt(byte);
388
+ if (secret >= 31n ** BigInt(CODE_LENGTH))
389
+ throw badCompact();
390
+ const code = secretChars(secret);
391
+ if (pos.i + 4 > b.length)
392
+ throw badCompact();
393
+ const minutes = new DataView(b.buffer, b.byteOffset + pos.i, 4).getUint32(0);
394
+ pos.i += 4;
395
+ const flags = take(1)[0];
396
+ if (flags & ~(COMPACT_ROLE | COMPACT_LIFETIME))
397
+ throw badCompact();
398
+ const count = take(1)[0];
399
+ if (!count || count > 8)
400
+ throw badCompact();
401
+ const urls = [];
402
+ for (let i = 0; i < count; i++)
403
+ urls.push(unpackUrl(b, pos));
404
+ const nameLength = take(1)[0];
405
+ let name;
406
+ try {
407
+ name = new TextDecoder('utf-8', { fatal: true }).decode(take(nameLength));
408
+ }
409
+ catch {
410
+ throw badCompact();
411
+ }
412
+ let lifetime;
413
+ if (flags & COMPACT_LIFETIME) {
414
+ if (pos.i + 4 > b.length)
415
+ throw badCompact();
416
+ lifetime = new DataView(b.buffer, b.byteOffset + pos.i, 4).getUint32(0) * 1000;
417
+ pos.i += 4;
418
+ }
419
+ if (pos.i !== b.length)
420
+ throw badCompact();
421
+ const expires = minutes * 60000;
422
+ if (expires < now)
423
+ throw new Error('That pairing code has run out. Show a new one.');
424
+ return { code, urls, expires, name: cleanName(name, 'your computer'),
425
+ role: flags & COMPACT_ROLE ? 'control' : 'view', ...(lifetime === undefined ? {} : { lifetime }) };
426
+ }
@@ -0,0 +1,30 @@
1
+ import type { DeviceGrant, DeviceStore } from './device.ts';
2
+ export type KeptDevice = DeviceStore & {
3
+ load(): Promise<DeviceGrant | null>;
4
+ };
5
+ /** The parts of `expo-secure-store` this uses (Keychain on iOS, Keystore-encrypted on Android); pass the module itself. */
6
+ export type SecureStoreLike = {
7
+ getItemAsync(key: string): Promise<string | null>;
8
+ setItemAsync(key: string, value: string): Promise<void>;
9
+ deleteItemAsync(key: string): Promise<void>;
10
+ };
11
+ /** A grant in the phone's secure storage under `name` (letters, digits, `.`, `-`, `_`): about 300 bytes, one value. */
12
+ export declare function secureDeviceStore(secure: SecureStoreLike, name: string): KeptDevice;
13
+ /** A grant in this browser's IndexedDB under `name`, sealed with AES-GCM by a key made here as non-extractable: the
14
+ * page can seal and open with it, but no script can read the key out, and the stored grant alone opens nothing. */
15
+ export declare function browserDeviceStore(name: string, db?: string): KeptDevice;
16
+ /** A device's collection of paired computers. Names are app-chosen, not host-supplied paths.
17
+ * `store(name)` adapts one entry to DeviceLink's save/clear interface. */
18
+ export type DeviceStores = {
19
+ list(): Promise<string[]>;
20
+ load(name: string): Promise<DeviceGrant | null>;
21
+ save(name: string, grant: DeviceGrant): Promise<void>;
22
+ remove(name: string): Promise<void>;
23
+ store(name: string): KeptDevice;
24
+ };
25
+ /** Multiple grants in platform secure storage, with a private index. Operations across instances sharing the
26
+ * same module and prefix are serialized. The native backend has no cross-process transaction API. */
27
+ export declare function secureDeviceStores(secure: SecureStoreLike, prefix?: string): DeviceStores;
28
+ /** Enumerates the existing sealed IndexedDB entries; each retains its own non-extractable wrapping key and
29
+ * clear/save generation protection. Tombstones are excluded from list. Compatible with browserDeviceStore. */
30
+ export declare function browserDeviceStores(db?: string): DeviceStores;
package/dist/stores.js ADDED
@@ -0,0 +1,184 @@
1
+ const grantOf = (text) => {
2
+ if (!text)
3
+ return null;
4
+ const g = JSON.parse(text);
5
+ return g?.v === 1 && typeof g.secretKey === 'string' && typeof g.host === 'string' ? g : null;
6
+ };
7
+ /** A grant in the phone's secure storage under `name` (letters, digits, `.`, `-`, `_`): about 300 bytes, one value. */
8
+ export function secureDeviceStore(secure, name) {
9
+ return {
10
+ load: async () => grantOf(await secure.getItemAsync(name)),
11
+ save: (g) => secure.setItemAsync(name, JSON.stringify(g)),
12
+ clear: () => secure.deleteItemAsync(name),
13
+ };
14
+ }
15
+ /** A grant in this browser's IndexedDB under `name`, sealed with AES-GCM by a key made here as non-extractable: the
16
+ * page can seal and open with it, but no script can read the key out, and the stored grant alone opens nothing. */
17
+ export function browserDeviceStore(name, db = 'byokit-link') {
18
+ const open = () => new Promise((resolve, reject) => {
19
+ const r = indexedDB.open(db, 1);
20
+ r.onupgradeneeded = () => { r.result.createObjectStore('keys'); r.result.createObjectStore('grants'); };
21
+ r.onsuccess = () => resolve(r.result);
22
+ r.onerror = () => reject(r.error);
23
+ });
24
+ const run = async (table, mode, fn) => {
25
+ const d = await open();
26
+ try {
27
+ return await new Promise((resolve, reject) => {
28
+ const t = d.transaction(table, mode);
29
+ const r = fn(t.objectStore(table));
30
+ t.oncomplete = () => resolve(r.result);
31
+ t.onabort = () => reject(t.error ?? r.error);
32
+ });
33
+ }
34
+ finally {
35
+ d.close();
36
+ }
37
+ };
38
+ let pending = Promise.resolve();
39
+ const locked = (fn) => {
40
+ if (typeof navigator !== 'undefined' && navigator.locks)
41
+ return navigator.locks.request(`byokit-link:${db}:${name}`, fn).then((value) => value);
42
+ const work = pending.then(fn);
43
+ pending = work.then(() => { }, () => { });
44
+ return work;
45
+ };
46
+ const generation = async () => (await run('grants', 'readonly', (s) => s.get(name)))?.generation ?? 0;
47
+ const key = async () => {
48
+ const kept = await run('keys', 'readonly', (s) => s.get(name));
49
+ if (kept)
50
+ return kept;
51
+ const made = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
52
+ try {
53
+ await run('keys', 'readwrite', (s) => s.add(made, name));
54
+ return made;
55
+ }
56
+ catch (e) {
57
+ if (e?.name !== 'ConstraintError')
58
+ throw e;
59
+ return (await run('keys', 'readonly', (s) => s.get(name)));
60
+ }
61
+ };
62
+ const read = async () => {
63
+ const sealed = await run('grants', 'readonly', (s) => s.get(name));
64
+ if (!sealed?.iv || !sealed.data)
65
+ return null;
66
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.iv }, await key(), sealed.data);
67
+ return grantOf(new TextDecoder().decode(plain));
68
+ };
69
+ return {
70
+ load: () => locked(read),
71
+ save(g) {
72
+ return locked(async () => {
73
+ const started = await generation();
74
+ const iv = crypto.getRandomValues(new Uint8Array(12));
75
+ const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await key(), new TextEncoder().encode(JSON.stringify(g)));
76
+ await run('grants', 'readwrite', (s) => {
77
+ const r = s.get(name);
78
+ r.onsuccess = () => { if ((r.result?.generation ?? 0) === started && r.result?.forgottenId !== g.device.id)
79
+ s.put({ iv, data, generation: started }, name); };
80
+ return r;
81
+ });
82
+ });
83
+ },
84
+ clear: () => locked(async () => {
85
+ const forgottenId = (await read().catch(() => null))?.device?.id;
86
+ await run('grants', 'readwrite', (s) => {
87
+ const r = s.get(name);
88
+ r.onsuccess = () => { s.put({ generation: (r.result?.generation ?? 0) + 1, forgottenId: forgottenId ?? r.result?.forgottenId }, name); };
89
+ return r;
90
+ });
91
+ }),
92
+ };
93
+ }
94
+ const checkedName = (name) => {
95
+ if (!/^[A-Za-z0-9_.-]{1,120}$/.test(name))
96
+ throw new Error('A store name must use letters, digits, dots, dashes or underscores.');
97
+ return name;
98
+ };
99
+ const secureWork = new WeakMap();
100
+ /** Multiple grants in platform secure storage, with a private index. Operations across instances sharing the
101
+ * same module and prefix are serialized. The native backend has no cross-process transaction API. */
102
+ export function secureDeviceStores(secure, prefix = 'byokit.link') {
103
+ checkedName(prefix);
104
+ let work = secureWork.get(secure);
105
+ if (!work) {
106
+ work = new Map();
107
+ secureWork.set(secure, work);
108
+ }
109
+ const locked = (fn) => {
110
+ const next = (work.get(prefix) ?? Promise.resolve()).then(fn);
111
+ work.set(prefix, next.then(() => { }, () => { }));
112
+ return next;
113
+ };
114
+ const index = `${prefix}.index`;
115
+ const entry = (name) => secureDeviceStore(secure, `${prefix}.grant.${checkedName(name)}`);
116
+ const names = async () => {
117
+ const raw = await secure.getItemAsync(index);
118
+ if (raw === null)
119
+ return [];
120
+ const names = JSON.parse(raw);
121
+ if (!Array.isArray(names) || names.some((n) => typeof n !== 'string' || !/^[A-Za-z0-9_.-]{1,120}$/.test(n)))
122
+ throw new Error('Invalid device store index.');
123
+ return [...new Set(names)];
124
+ };
125
+ const stores = {
126
+ list: () => locked(async () => {
127
+ const all = await names();
128
+ const kept = await Promise.all(all.map(async (n) => await entry(n).load() ? n : null));
129
+ return kept.filter((n) => n !== null);
130
+ }),
131
+ load: (name) => locked(() => entry(name).load()),
132
+ save: (name, grant) => locked(async () => {
133
+ const store = entry(name);
134
+ const all = await names();
135
+ // Index first: an interrupted write leaves a missing entry we can filter, never an undiscoverable secret.
136
+ if (!all.includes(name))
137
+ await secure.setItemAsync(index, JSON.stringify([...all, name]));
138
+ await store.save(grant);
139
+ }),
140
+ remove: (name) => locked(async () => {
141
+ await entry(name).clear();
142
+ await secure.setItemAsync(index, JSON.stringify((await names()).filter((n) => n !== name)));
143
+ }),
144
+ store(name) { checkedName(name); return { load: () => stores.load(name), save: (g) => stores.save(name, g), clear: () => stores.remove(name) }; },
145
+ };
146
+ return stores;
147
+ }
148
+ /** Enumerates the existing sealed IndexedDB entries; each retains its own non-extractable wrapping key and
149
+ * clear/save generation protection. Tombstones are excluded from list. Compatible with browserDeviceStore. */
150
+ export function browserDeviceStores(db = 'byokit-link') {
151
+ const entry = (name) => browserDeviceStore(checkedName(name), db);
152
+ const stores = {
153
+ async list() {
154
+ // Let the existing backend create the schema even when no entry has been saved yet.
155
+ await browserDeviceStore('', db).load();
156
+ const names = await new Promise((resolve, reject) => {
157
+ const r = indexedDB.open(db, 1);
158
+ r.onerror = () => reject(r.error);
159
+ r.onsuccess = () => {
160
+ const d = r.result;
161
+ const t = d.transaction('grants', 'readonly');
162
+ const q = t.objectStore('grants').openCursor();
163
+ const names = [];
164
+ q.onsuccess = () => {
165
+ const c = q.result;
166
+ if (!c)
167
+ return;
168
+ if (typeof c.key === 'string' && c.value?.iv && c.value?.data)
169
+ names.push(c.key);
170
+ c.continue();
171
+ };
172
+ t.oncomplete = () => { d.close(); resolve(names); };
173
+ t.onabort = () => { d.close(); reject(t.error ?? q.error); };
174
+ };
175
+ });
176
+ return names;
177
+ },
178
+ load: (name) => entry(name).load(),
179
+ save: async (name, grant) => { await entry(name).save(grant); },
180
+ remove: async (name) => { await entry(name).clear(); },
181
+ store: entry,
182
+ };
183
+ return stores;
184
+ }