@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.
@@ -0,0 +1,9 @@
1
+ export { hostId, keyPair, keyPairFrom, b64url, unb64url, type KeyPair } from './channel.ts';
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
+ 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, parseV1Offer, COMPACT_TAG } from './pairing.ts';
5
+ export { LinkStream, WINDOW } from './stream.ts';
6
+ export { check, LINK_PROBE, LINK_PROBE_OK, type CheckOptions, type CheckResult } from './check.ts';
7
+ export type { CompactOffer, PairOffer } from './pairing.ts';
8
+ export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores, type DeviceStores, type KeptDevice, type SecureStoreLike } from './stores.ts';
9
+ export { migrateGrant, GrantMigrationError, type GrantMigrationProblem } from './migrate.ts';
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { hostId, keyPair, keyPairFrom, b64url, unb64url } from "./channel.js";
2
+ export { Host, } from "./host.js";
3
+ export { DeviceLink, LINK_WORDS, LinkError, PublicLinkError, pairWithCode, pairWithOffer, pendingGrant, } from "./device.js";
4
+ export { cleanName, decodeCompactOffer, decodeOffer, encodeCompactOffer, encodeOffer, normalizeCode, offerText, parseOffer, parseV1Offer, COMPACT_TAG } from "./pairing.js";
5
+ export { LinkStream, WINDOW } from "./stream.js";
6
+ export { check, LINK_PROBE, LINK_PROBE_OK } from "./check.js";
7
+ export { browserDeviceStore, browserDeviceStores, secureDeviceStore, secureDeviceStores } from "./stores.js";
8
+ export { migrateGrant, GrantMigrationError } from "./migrate.js";
@@ -0,0 +1,14 @@
1
+ import type { DeviceGrant } from './device.ts';
2
+ export type GrantMigrationProblem = 'unsupported-format' | 'invalid-grant';
3
+ /** A migration failure. Neither the message nor the error retains the input or its keys. */
4
+ export declare class GrantMigrationError extends Error {
5
+ readonly code: GrantMigrationProblem;
6
+ constructor(code: GrantMigrationProblem);
7
+ }
8
+ /** Converts the pre-kit Crewhouse phone grant (no `v`, padded base64 `sk`/`crewdPk`,
9
+ * `fp`, `urls`, `device`) from a parsed object or its JSON. The fingerprint checks
10
+ * host-key consistency, not authenticity: only a connection to the host verifies
11
+ * the device's access. No storage, network access or logging is performed. */
12
+ export declare function migrateGrant(raw: unknown, options: {
13
+ format: 'crewhouse-v0';
14
+ }): DeviceGrant;
@@ -0,0 +1,62 @@
1
+ import { b64, b64url, hash, unb64 } from "./channel.js";
2
+ /** A migration failure. Neither the message nor the error retains the input or its keys. */
3
+ export class GrantMigrationError extends Error {
4
+ code;
5
+ constructor(code) {
6
+ super(code === 'unsupported-format' ? 'This saved pairing needs a different update.' : 'This saved pairing cannot be read. Pair this device again.');
7
+ this.name = 'GrantMigrationError';
8
+ this.code = code;
9
+ }
10
+ }
11
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ const text = (value) => typeof value === 'string' && value.trim().length > 0;
13
+ const bad = () => { throw new GrantMigrationError('invalid-grant'); };
14
+ const key = (value) => {
15
+ if (typeof value !== 'string' || !/^[A-Za-z0-9+/]{43}=$/.test(value))
16
+ return bad();
17
+ const bytes = unb64(value);
18
+ if (bytes.length !== 32 || b64(bytes) !== value)
19
+ return bad();
20
+ return bytes;
21
+ };
22
+ const wsUrl = (value) => {
23
+ if (typeof value !== 'string' || !/^wss?:\/\//.test(value) || /\s/.test(value))
24
+ return false;
25
+ const url = new URL(value);
26
+ return !!url.hostname && !url.username && !url.password && !url.hash;
27
+ };
28
+ /** Converts the pre-kit Crewhouse phone grant (no `v`, padded base64 `sk`/`crewdPk`,
29
+ * `fp`, `urls`, `device`) from a parsed object or its JSON. The fingerprint checks
30
+ * host-key consistency, not authenticity: only a connection to the host verifies
31
+ * the device's access. No storage, network access or logging is performed. */
32
+ export function migrateGrant(raw, options) {
33
+ let supported = false;
34
+ try {
35
+ supported = options?.format === 'crewhouse-v0';
36
+ }
37
+ catch { }
38
+ if (!supported)
39
+ throw new GrantMigrationError('unsupported-format');
40
+ try {
41
+ const g = typeof raw === 'string' ? JSON.parse(raw) : raw;
42
+ if (!record(g) || Object.keys(g).length !== 5 || Object.keys(g).some((k) => !['sk', 'crewdPk', 'fp', 'urls', 'device'].includes(k)))
43
+ return bad();
44
+ const secret = key(g.sk), host = key(g.crewdPk);
45
+ const fp = Array.from(hash(16, host).subarray(0, 8), (b) => b.toString(16).padStart(2, '0')).join('').match(/.{4}/g).join(' ');
46
+ if (g.fp !== fp || !Array.isArray(g.urls) || !g.urls.length)
47
+ return bad();
48
+ const urls = Array.from(g.urls);
49
+ if (!urls.every(wsUrl))
50
+ return bad();
51
+ const d = g.device;
52
+ if (!record(d) || Object.keys(d).length !== 3 || Object.keys(d).some((k) => !['id', 'name', 'role'].includes(k)) ||
53
+ !text(d.id) || !text(d.name) || (d.role !== 'control' && d.role !== 'view'))
54
+ return bad();
55
+ return { v: 1, secretKey: b64url(secret), host: b64url(host), hostName: 'your computer',
56
+ urls: urls, device: { id: d.id, name: d.name, role: d.role } };
57
+ }
58
+ catch {
59
+ // JSON, URL and crypto errors can carry input text; expose only a fixed typed failure.
60
+ throw new GrantMigrationError('invalid-grant');
61
+ }
62
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { GrantStore } from './host.ts';
2
+ import { type KeyPair } from './channel.ts';
3
+ import type { KeptDevice } from './stores.ts';
4
+ /** The host's key pair, kept in `path` (0600, in a 0700 folder, written atomically). The first call makes it.
5
+ * A file that exists but can't be read as a key is never replaced: a new key would silently cut off every paired
6
+ * device, so it throws and a person decides. */
7
+ export declare function hostKeyFile(path: string): KeyPair;
8
+ /** The parts of Electron's `safeStorage` this uses; pass `safeStorage` from 'electron' (main process, after `ready`). */
9
+ export type SafeStorageLike = {
10
+ encryptString(text: string): Uint8Array;
11
+ decryptString(data: Buffer): string;
12
+ };
13
+ export declare function fileDeviceStore(path: string, safeStorage?: SafeStorageLike): KeptDevice;
14
+ /** Host grants at the app's path: atomic 0600 writes and notifications across processes, including rename/unlink.
15
+ * Cooperating writers lock and reject stale snapshots; notifications are advisory and Host.reload reads the authority.
16
+ * Polling avoids platform-dependent rename watcher loss. No keys or application files are discovered. */
17
+ export declare function fileGrantStore(path: string, intervalMs?: number): GrantStore;
package/dist/node.js ADDED
@@ -0,0 +1,210 @@
1
+ // Node only (`@byokit/pair/node`): host key and device grant files. Kept out of the main entry for browsers and React Native.
2
+ import { closeSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, unwatchFile, watchFile, writeFileSync, writeSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { b64url, keyPair, keyPairFrom, random, unb64url } from "./channel.js";
5
+ /** The host's key pair, kept in `path` (0600, in a 0700 folder, written atomically). The first call makes it.
6
+ * A file that exists but can't be read as a key is never replaced: a new key would silently cut off every paired
7
+ * device, so it throws and a person decides. */
8
+ export function hostKeyFile(path) {
9
+ const folder = dirname(path);
10
+ mkdirSync(folder, { recursive: true, mode: 0o700 });
11
+ if ((lstatSync(folder).mode & 0o777) !== 0o700)
12
+ throw new Error(`${folder} must be a private 0700 folder for the link key`);
13
+ const existing = () => {
14
+ const st = lstatSync(path);
15
+ if (!st.isFile())
16
+ throw new Error(`${path} is not a plain file; refusing to use it as the link key`);
17
+ if (st.mode & 0o077)
18
+ throw new Error(`${path} allows others to read or write; refusing to use it as the link key`);
19
+ const o = JSON.parse(readFileSync(path, 'utf8'));
20
+ const secret = typeof o?.secretKey === 'string' ? unb64url(o.secretKey) : new Uint8Array();
21
+ if (o?.v !== 1 || secret.length !== 32)
22
+ throw new Error(`${path} does not hold a link key; refusing to replace it`);
23
+ return keyPairFrom(secret);
24
+ };
25
+ try {
26
+ return existing();
27
+ }
28
+ catch (e) {
29
+ if (e?.code !== 'ENOENT')
30
+ throw e;
31
+ }
32
+ const keys = keyPair();
33
+ const tmp = `${path}.${process.pid}.${b64url(random(6))}.tmp`;
34
+ let created = false;
35
+ try {
36
+ const fd = openSync(tmp, 'wx', 0o600);
37
+ created = true;
38
+ try {
39
+ writeSync(fd, JSON.stringify({ v: 1, secretKey: b64url(keys.secretKey) }));
40
+ fsyncSync(fd);
41
+ }
42
+ finally {
43
+ closeSync(fd);
44
+ }
45
+ try {
46
+ linkSync(tmp, path);
47
+ }
48
+ catch (e) {
49
+ if (e?.code === 'EEXIST')
50
+ return existing();
51
+ throw e;
52
+ }
53
+ return keys;
54
+ }
55
+ finally {
56
+ if (created)
57
+ try {
58
+ unlinkSync(tmp);
59
+ }
60
+ catch (e) {
61
+ if (e?.code !== 'ENOENT')
62
+ throw e;
63
+ }
64
+ }
65
+ }
66
+ export function fileDeviceStore(path, safeStorage) {
67
+ return {
68
+ async load() {
69
+ let raw;
70
+ try {
71
+ raw = readFileSync(path);
72
+ }
73
+ catch (e) {
74
+ if (e?.code === 'ENOENT')
75
+ return null;
76
+ throw e;
77
+ }
78
+ const g = JSON.parse(safeStorage ? safeStorage.decryptString(raw) : raw.toString('utf8'));
79
+ return g?.v === 1 ? g : null;
80
+ },
81
+ save(g) {
82
+ const text = JSON.stringify(g);
83
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
84
+ const tmp = `${path}.${process.pid}.${b64url(random(6))}.tmp`;
85
+ let created = false;
86
+ try {
87
+ const fd = openSync(tmp, 'wx', 0o600);
88
+ created = true;
89
+ try {
90
+ writeFileSync(fd, safeStorage ? safeStorage.encryptString(text) : text);
91
+ fsyncSync(fd);
92
+ }
93
+ finally {
94
+ closeSync(fd);
95
+ }
96
+ renameSync(tmp, path);
97
+ }
98
+ finally {
99
+ if (created)
100
+ try {
101
+ unlinkSync(tmp);
102
+ }
103
+ catch (e) {
104
+ if (e?.code !== 'ENOENT')
105
+ throw e;
106
+ }
107
+ }
108
+ },
109
+ clear() { rmSync(path, { force: true }); },
110
+ };
111
+ }
112
+ /** Host grants at the app's path: atomic 0600 writes and notifications across processes, including rename/unlink.
113
+ * Cooperating writers lock and reject stale snapshots; notifications are advisory and Host.reload reads the authority.
114
+ * Polling avoids platform-dependent rename watcher loss. No keys or application files are discovered. */
115
+ export function fileGrantStore(path, intervalMs = 100) {
116
+ if (!Number.isSafeInteger(intervalMs) || intervalMs < 1)
117
+ throw new Error('A watch interval must be a positive whole number of milliseconds.');
118
+ let loaded;
119
+ const read = () => {
120
+ try {
121
+ const st = lstatSync(path);
122
+ if (!st.isFile() || st.mode & 0o077)
123
+ throw new Error('Link grants must be a private plain file.');
124
+ const folder = lstatSync(dirname(path));
125
+ if (!folder.isDirectory() || (folder.mode & 0o777) !== 0o700)
126
+ throw new Error('Link grants require a private 0700 folder.');
127
+ return readFileSync(path, 'utf8');
128
+ }
129
+ catch (e) {
130
+ if (e?.code === 'ENOENT')
131
+ return null;
132
+ throw e;
133
+ }
134
+ };
135
+ return {
136
+ load() {
137
+ const text = read();
138
+ if (text === null) {
139
+ loaded = null;
140
+ return [];
141
+ }
142
+ const grants = JSON.parse(text);
143
+ if (!Array.isArray(grants) || grants.some((g) => !g || typeof g.id !== 'string' || typeof g.key !== 'string'
144
+ || typeof g.name !== 'string' || !['view', 'control'].includes(g.role) || !Number.isFinite(g.created)
145
+ || (g.expires !== undefined && !Number.isFinite(g.expires)))
146
+ || new Set(grants.map((g) => g.id)).size !== grants.length)
147
+ throw new Error('Invalid link grants.');
148
+ loaded = text;
149
+ return grants;
150
+ },
151
+ async save(grants) {
152
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
153
+ const folder = lstatSync(dirname(path));
154
+ if (!folder.isDirectory() || (folder.mode & 0o777) !== 0o700)
155
+ throw new Error('Link grants require a private 0700 folder.');
156
+ // Cooperating writers use the same exclusive lock. Never erase an abandoned lock automatically.
157
+ const lock = `${path}.lock`;
158
+ let fd;
159
+ const until = Date.now() + 5000;
160
+ while (fd === undefined) {
161
+ try {
162
+ fd = openSync(lock, 'wx', 0o600);
163
+ }
164
+ catch (e) {
165
+ if (e?.code !== 'EEXIST' || Date.now() >= until)
166
+ throw e;
167
+ await new Promise((r) => setTimeout(r, 10));
168
+ }
169
+ }
170
+ const tmp = `${path}.${process.pid}.${b64url(random(6))}.tmp`;
171
+ let created = false;
172
+ try {
173
+ const current = read();
174
+ if (loaded !== undefined && current !== loaded)
175
+ throw new Error('Link grants changed in another process; reload before saving.');
176
+ const text = JSON.stringify(grants);
177
+ const file = openSync(tmp, 'wx', 0o600);
178
+ created = true;
179
+ try {
180
+ writeFileSync(file, text);
181
+ fsyncSync(file);
182
+ }
183
+ finally {
184
+ closeSync(file);
185
+ }
186
+ renameSync(tmp, path);
187
+ loaded = text;
188
+ }
189
+ finally {
190
+ try {
191
+ if (created)
192
+ unlinkSync(tmp);
193
+ }
194
+ catch (e) {
195
+ if (e?.code !== 'ENOENT')
196
+ throw e;
197
+ }
198
+ finally {
199
+ closeSync(fd);
200
+ unlinkSync(lock);
201
+ }
202
+ }
203
+ },
204
+ subscribe(changed) {
205
+ const listener = () => changed();
206
+ watchFile(path, { interval: intervalMs, persistent: false }, listener);
207
+ return () => unwatchFile(path, listener);
208
+ },
209
+ };
210
+ }
@@ -0,0 +1,71 @@
1
+ import type { Role } from './host.ts';
2
+ /** What a scanned QR (or opened pairing link) holds. The ticket is single-use and short-lived. `role` and `lifetime`
3
+ * (how long the device's access lasts, in ms; absent means until removed) let the device say what it is agreeing to
4
+ * before it connects; 0.1 parsers ignore them, and the host's own records decide. */
5
+ export type PairOffer = {
6
+ v: 1;
7
+ host: string;
8
+ name: string;
9
+ urls: string[];
10
+ ticket: string;
11
+ expires: number;
12
+ role?: Role;
13
+ lifetime?: number;
14
+ };
15
+ /** A device or host name as a person should see it: no hidden characters, at most 60 of them. */
16
+ export declare const cleanName: (name: unknown, fallback: string) => string;
17
+ /** The QR's text. Give a web address (`https://…/pair`) to get a link a browser can open instead: the offer rides in
18
+ * the part after `#`, which browsers never send to a server. */
19
+ export declare function offerText(offer: PairOffer, base?: string): string;
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).
22
+ * Pass `0` as `now` to inspect an expired offer without connecting. */
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;
26
+ /** A complete offline offer in groups of five base32 characters, with a transcription checksum. This carries the
27
+ * same single-use ticket as the QR; it neither extends expiry nor replaces host approval. */
28
+ export declare function encodeOffer(offer: PairOffer): string;
29
+ /** Read an offline offer without network access. Case, spaces and dashes are ignored; O means 0 and I/L mean 1.
30
+ * Old compact direct offers remain readable for migration; new offers always use the current format.
31
+ * Other typos fail the checksum. Expiry and addresses follow `parseV1Offer`; `now = 0` permits inspection only. */
32
+ export declare function decodeOffer(text: string, now?: number): PairOffer;
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
34
+ * to guess, even offline, in the few minutes a code lives. */
35
+ export declare const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
36
+ export declare function newCode(): string;
37
+ /** A legacy secret or machine-bound short code, normalized or null. Case, spaces and dashes don't matter. */
38
+ export declare function normalizeCode(typed: string): string | null;
39
+ /** Public machine-key commitment; the secret's entropy still comes only from `newCode`. */
40
+ export declare const shortKey: (hostKey: Uint8Array) => string;
41
+ export declare const bindCode: (code: string, hostKey: Uint8Array) => string;
42
+ /** Accept a bound code or the legacy secret; malformed bound codes never downgrade. */
43
+ export declare function parseCode(typed: string): {
44
+ secret: string;
45
+ commitment?: string;
46
+ } | null;
47
+ /** The Noise pre-shared key a typed code stands for. */
48
+ export declare const codeKey: (code: string) => Uint8Array;
49
+ export declare const COMPACT_TAG = "BYOKIT-LINK:2:";
50
+ export type CompactOffer = {
51
+ code: string;
52
+ urls: string[];
53
+ expires: number;
54
+ name: string;
55
+ role: Role;
56
+ lifetime?: number;
57
+ };
58
+ /** Pack a code secret, its addresses and terms into QR-sized text. Throws a plain sentence for anything that would
59
+ * not survive the trip (bad addresses, absurd lengths); the host calls this before storing the code, so a throw
60
+ * leaves no open entry behind. */
61
+ export declare function encodeCompactOffer(o: {
62
+ code: string;
63
+ urls: string[];
64
+ expires: number;
65
+ name: string;
66
+ role: Role;
67
+ lifetime?: number;
68
+ }): string;
69
+ /** Read a compact offer. Case, spaces and dashes are forgiven; anything else that is not a compact offer throws a
70
+ * plain sentence. Expiry follows `parseOffer`; `now = 0` permits inspection only. */
71
+ export declare function decodeCompactOffer(scanned: string, now?: number): CompactOffer;