viveworker 0.7.0 → 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.
Files changed (34) hide show
  1. package/README.md +33 -4
  2. package/package.json +7 -3
  3. package/scripts/lib/remote-pairing/README.md +164 -0
  4. package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
  5. package/scripts/lib/remote-pairing/audit.mjs +122 -0
  6. package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
  7. package/scripts/lib/remote-pairing/control.mjs +156 -0
  8. package/scripts/lib/remote-pairing/envelope.mjs +224 -0
  9. package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
  10. package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
  11. package/scripts/lib/remote-pairing/keys.mjs +181 -0
  12. package/scripts/lib/remote-pairing/noise.mjs +436 -0
  13. package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
  14. package/scripts/lib/remote-pairing/pairings.mjs +446 -0
  15. package/scripts/lib/remote-pairing/rpc.mjs +381 -0
  16. package/scripts/moltbook-scout-auto.sh +16 -8
  17. package/scripts/share-cli.mjs +14 -130
  18. package/scripts/viveworker-bridge.mjs +1324 -103
  19. package/scripts/viveworker.mjs +27 -6
  20. package/web/app.css +634 -9
  21. package/web/app.js +1731 -187
  22. package/web/i18n.js +187 -9
  23. package/web/index.html +32 -2
  24. package/web/remote-pairing/api-router.js +873 -0
  25. package/web/remote-pairing/keys.js +237 -0
  26. package/web/remote-pairing/pairing-state.js +313 -0
  27. package/web/remote-pairing/rpc-client.js +765 -0
  28. package/web/remote-pairing/transport.js +804 -0
  29. package/web/remote-pairing/wake.js +149 -0
  30. package/web/remote-pairing-test.html +400 -0
  31. package/web/remote-pairing.bundle.js +3 -0
  32. package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
  33. package/web/remote-pairing.bundle.js.map +7 -0
  34. package/web/sw.js +190 -20
@@ -0,0 +1,446 @@
1
+ /**
2
+ * pairings.mjs — On-disk allowlist of phones the bridge will accept over the
3
+ * remote-pairing relay.
4
+ *
5
+ * The bridge's static keypair (`keys.mjs`) defines its OWN identity. This
6
+ * module tracks the OTHER side: which phone pubkeys the LAN-pairing flow
7
+ * has approved + what relay slot (`pairingId`) each one connects through.
8
+ *
9
+ * Why a separate file from `remote-pairing.env`:
10
+ * - The env is single-record (the bridge's keypair) and rarely changes.
11
+ * - Pairings are multi-record, mutable (add when LAN pairs, remove when
12
+ * the user revokes a device), and we want structured fields (label,
13
+ * timestamps, fingerprint) without inventing an ad-hoc env grammar.
14
+ * - JSON keeps the data introspectable (`cat ~/.viveworker/remote-pairings.json`)
15
+ * while still being mode 0o600 so other users can't read it.
16
+ *
17
+ * File format (`~/.viveworker/remote-pairings.json`):
18
+ *
19
+ * {
20
+ * "version": 1,
21
+ * "pairings": [
22
+ * {
23
+ * "pairingId": "<relay slot, opaque string>",
24
+ * "relayToken": "<relay capability token>",
25
+ * "phonePub": "<lowercase hex 32 bytes — phone's X25519 static pub>",
26
+ * "phoneFingerprint": "ABCD-EF12-3456",
27
+ * "deviceId": "<LAN trusted-device id, when enrolled from an authenticated browser>",
28
+ * "label": "iPhone (LAN-paired 2026-04-25)",
29
+ * "addedAtMs": 1714000000000,
30
+ * "relayTokenUpdatedAtMs": 1714000000000,
31
+ * "lastSeenAtMs": null
32
+ * }
33
+ * ]
34
+ * }
35
+ *
36
+ * Older files may include a "lastSeenChannelBinding" field (debug-only,
37
+ * never read by the runtime). It's ignored on load and dropped on the
38
+ * next save — see normalizePairing().
39
+ *
40
+ * Trust model:
41
+ * The presence of a phone pubkey in this list means "the user explicitly
42
+ * approved this device on the LAN side". The bridge MUST verify post-
43
+ * handshake that `transport.session.remoteStatic` equals one of the
44
+ * `phonePub` entries before serving any RPC. The Noise IK handshake itself
45
+ * only proves "the peer holds this private key" — it does not gate WHICH
46
+ * peers we want to talk to. That's this file's job.
47
+ *
48
+ * Concurrency:
49
+ * Atomic-rename writes via fs.writeFile + fs.rename so a crash mid-write
50
+ * doesn't leave the file truncated. The bridge is single-process today
51
+ * so we don't need cross-process locking.
52
+ */
53
+
54
+ import { createHash, randomBytes } from "node:crypto";
55
+ import { promises as fs } from "node:fs";
56
+ import os from "node:os";
57
+ import path from "node:path";
58
+
59
+ import { fingerprintIdentity, hexToBytes, bytesToHex, IDENTITY_KEY_BYTES } from "./keys-core.mjs";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Constants
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export const REMOTE_PAIRINGS_FILE = path.join(
66
+ os.homedir(),
67
+ ".viveworker",
68
+ "remote-pairings.json",
69
+ );
70
+
71
+ const FORMAT_VERSION = 1;
72
+
73
+ // Defensive cap on how many pairings we'll track. The bridge's per-pairing WS
74
+ // is cheap (one outbound connection apiece) but the user only physically
75
+ // owns a handful of devices. 200 matches MAX_PAIRED_DEVICES on the LAN side
76
+ // so the limits agree.
77
+ export const MAX_PAIRINGS = 200;
78
+ export const RELAY_TOKEN_VERSION = "v1";
79
+ export const RELAY_TOKEN_POW_BITS = 16;
80
+ export const RELAY_TOKEN_RE = /^v1\.[A-Za-z0-9_-]{32}\.[a-z0-9]{1,13}$/u;
81
+ const RELAY_TOKEN_DOMAIN = "viveworker-remote-pairing-relay-token";
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Pairing typedef
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * @typedef {Object} Pairing
89
+ * @property {string} pairingId relay slot identifier; opaque to us
90
+ * @property {string} relayToken bearer-ish relay capability for this slot
91
+ * @property {string} phonePub lowercase hex of the phone's X25519 pub
92
+ * @property {string} phoneFingerprint human-readable "ABCD-EF12-3456" of phonePub
93
+ * @property {string} [deviceId] LAN trusted-device id associated at enroll time
94
+ * @property {string} label user-visible label (e.g. "iPhone (LAN 2026-04-25)")
95
+ * @property {number} addedAtMs epoch ms at LAN pair time
96
+ * @property {number | null} relayTokenUpdatedAtMs epoch ms of most recent relay token rotation
97
+ * @property {number | null} lastSeenAtMs epoch ms of most recent successful relay handshake
98
+ */
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Read
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Load the pairings list from disk. Returns [] if the file is missing or
106
+ * empty. Throws on malformed JSON or wrong version (caller can decide
107
+ * whether to back the file up + start fresh).
108
+ *
109
+ * @param {string} [filePath]
110
+ * @returns {Promise<Pairing[]>}
111
+ */
112
+ export async function loadPairings(filePath = REMOTE_PAIRINGS_FILE) {
113
+ let text;
114
+ try {
115
+ text = await fs.readFile(filePath, "utf8");
116
+ } catch (err) {
117
+ if (err.code === "ENOENT") return [];
118
+ throw err;
119
+ }
120
+ if (!text.trim()) return [];
121
+
122
+ let obj;
123
+ try {
124
+ obj = JSON.parse(text);
125
+ } catch (err) {
126
+ throw new Error(`pairings file ${filePath}: malformed JSON (${err.message})`);
127
+ }
128
+ if (!obj || typeof obj !== "object") {
129
+ throw new Error(`pairings file ${filePath}: not a JSON object`);
130
+ }
131
+ if (obj.version !== FORMAT_VERSION) {
132
+ throw new Error(`pairings file ${filePath}: unsupported version ${obj.version} (expected ${FORMAT_VERSION})`);
133
+ }
134
+ if (!Array.isArray(obj.pairings)) {
135
+ throw new Error(`pairings file ${filePath}: "pairings" must be an array`);
136
+ }
137
+ return obj.pairings.map((entry, idx) => normalizePairing(entry, idx));
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Write — atomic
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * Write the pairings list to disk atomically (write-temp + rename).
146
+ *
147
+ * @param {Pairing[]} pairings
148
+ * @param {string} [filePath]
149
+ */
150
+ export async function savePairings(pairings, filePath = REMOTE_PAIRINGS_FILE) {
151
+ if (!Array.isArray(pairings)) throw new TypeError("pairings must be an array");
152
+ if (pairings.length > MAX_PAIRINGS) {
153
+ throw new RangeError(`too many pairings: ${pairings.length} > ${MAX_PAIRINGS}`);
154
+ }
155
+ const validated = pairings.map((p, i) => normalizePairing(p, i));
156
+
157
+ const dir = path.dirname(filePath);
158
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
159
+
160
+ const body = JSON.stringify(
161
+ { version: FORMAT_VERSION, pairings: validated },
162
+ null,
163
+ 2,
164
+ ) + "\n";
165
+
166
+ // Atomic write: temp file + rename on the same volume.
167
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}`;
168
+ await fs.writeFile(tmpPath, body, { mode: 0o600 });
169
+ await fs.rename(tmpPath, filePath);
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Add / remove / lookup
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Idempotently add (or update) a pairing. If a pairing with the same
178
+ * `phonePub` already exists, fields are merged (incoming wins on
179
+ * conflict, except addedAtMs which is preserved from the original).
180
+ *
181
+ * @param {Pairing[]} pairings
182
+ * @param {Pairing} entry
183
+ * @returns {Pairing[]} new array (caller should pass to savePairings)
184
+ */
185
+ export function addPairing(pairings, entry) {
186
+ const incoming = normalizePairing(entry, "incoming");
187
+ const out = pairings.slice();
188
+ const idx = out.findIndex((p) => p.phonePub === incoming.phonePub);
189
+ if (idx >= 0) {
190
+ const original = out[idx];
191
+ out[idx] = { ...original, ...incoming, addedAtMs: original.addedAtMs };
192
+ } else {
193
+ if (out.length >= MAX_PAIRINGS) {
194
+ throw new RangeError(`would exceed MAX_PAIRINGS=${MAX_PAIRINGS}`);
195
+ }
196
+ out.push(incoming);
197
+ }
198
+ return out;
199
+ }
200
+
201
+ /** @param {Pairing[]} pairings @param {string} phonePub */
202
+ export function removePairingByPub(pairings, phonePub) {
203
+ const norm = String(phonePub || "").toLowerCase();
204
+ return pairings.filter((p) => p.phonePub !== norm);
205
+ }
206
+
207
+ /** @param {Pairing[]} pairings @param {string} phonePub */
208
+ export function findByPub(pairings, phonePub) {
209
+ const norm = String(phonePub || "").toLowerCase();
210
+ return pairings.find((p) => p.phonePub === norm) ?? null;
211
+ }
212
+
213
+ /** @param {Pairing[]} pairings @param {string} pairingId */
214
+ export function findByPairingId(pairings, pairingId) {
215
+ return pairings.find((p) => p.pairingId === pairingId) ?? null;
216
+ }
217
+
218
+ /**
219
+ * Stamp last-seen metadata on a pairing in-place (pure function; returns
220
+ * a new array). Useful for "show last connect time in the settings UI".
221
+ *
222
+ * The legacy `channelBinding` argument is accepted for backward
223
+ * compatibility but ignored — channel bindings are handshake-scoped and
224
+ * persisting them gave a relay-traffic correlation handle for no benefit.
225
+ *
226
+ * @param {Pairing[]} pairings
227
+ * @param {string} phonePub
228
+ * @param {{ atMs: number, channelBinding?: Uint8Array | null }} info
229
+ */
230
+ export function markSeen(pairings, phonePub, { atMs }) {
231
+ const norm = String(phonePub || "").toLowerCase();
232
+ return pairings.map((p) => {
233
+ if (p.phonePub !== norm) return p;
234
+ return {
235
+ ...p,
236
+ lastSeenAtMs: atMs,
237
+ };
238
+ });
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Convenience: full read-modify-write helpers (for callers who don't
243
+ // want to manage the array themselves).
244
+ // ---------------------------------------------------------------------------
245
+
246
+ export async function addPairingPersisted(entry, filePath = REMOTE_PAIRINGS_FILE) {
247
+ const current = await loadPairings(filePath);
248
+ const next = addPairing(current, entry);
249
+ await savePairings(next, filePath);
250
+ return next;
251
+ }
252
+
253
+ export async function removePairingPersisted(phonePub, filePath = REMOTE_PAIRINGS_FILE) {
254
+ const current = await loadPairings(filePath);
255
+ const next = removePairingByPub(current, phonePub);
256
+ if (next.length === current.length) return current; // no-op
257
+ await savePairings(next, filePath);
258
+ return next;
259
+ }
260
+
261
+ export async function markSeenPersisted(phonePub, info, filePath = REMOTE_PAIRINGS_FILE) {
262
+ const current = await loadPairings(filePath);
263
+ const next = markSeen(current, phonePub, info);
264
+ const changed = next.some((p, index) => p.lastSeenAtMs !== current[index]?.lastSeenAtMs);
265
+ if (changed) {
266
+ await savePairings(next, filePath);
267
+ }
268
+ return next;
269
+ }
270
+
271
+ export async function rotateRelayTokenPersisted(phonePub, filePath = REMOTE_PAIRINGS_FILE) {
272
+ const current = await loadPairings(filePath);
273
+ const norm = String(phonePub || "").toLowerCase();
274
+ let rotated = null;
275
+ const next = current.map((p) => {
276
+ if (p.phonePub !== norm) return p;
277
+ rotated = {
278
+ ...p,
279
+ relayToken: generateRelayToken(p.pairingId),
280
+ relayTokenUpdatedAtMs: Date.now(),
281
+ };
282
+ return rotated;
283
+ });
284
+ if (!rotated) {
285
+ return { rotated: null, pairings: current };
286
+ }
287
+ await savePairings(next, filePath);
288
+ return { rotated, pairings: next };
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Helper used by clients to build an entry from a phone pubkey
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * Build a fresh Pairing record. Computes the fingerprint for you and
297
+ * stamps `addedAtMs`. The caller is expected to invent the `pairingId`
298
+ * (typically a UUID) and the `label`.
299
+ *
300
+ * @param {{ pairingId: string, relayToken?: string, relayTokenUpdatedAtMs?: number | null, phonePub: Uint8Array | string, label?: string, deviceId?: string }} input
301
+ * @returns {Pairing}
302
+ */
303
+ export function buildPairing({ pairingId, phonePub, label, relayToken, relayTokenUpdatedAtMs, deviceId }) {
304
+ const pubBytes = asU8(phonePub);
305
+ if (pubBytes.length !== IDENTITY_KEY_BYTES) {
306
+ throw new RangeError(`phonePub must be ${IDENTITY_KEY_BYTES} bytes, got ${pubBytes.length}`);
307
+ }
308
+ const id = String(pairingId);
309
+ const token = relayToken
310
+ ? normalizeRelayToken(id, relayToken)
311
+ : generateRelayToken(id);
312
+ const record = {
313
+ pairingId: id,
314
+ relayToken: token,
315
+ phonePub: bytesToHex(pubBytes),
316
+ phoneFingerprint: fingerprintIdentity(pubBytes),
317
+ label: label ? String(label) : "",
318
+ addedAtMs: Date.now(),
319
+ relayTokenUpdatedAtMs: numOrNull(relayTokenUpdatedAtMs) ?? Date.now(),
320
+ lastSeenAtMs: null,
321
+ };
322
+ const cleanDeviceId = normalizeOptionalText(deviceId);
323
+ if (cleanDeviceId) {
324
+ record.deviceId = cleanDeviceId;
325
+ }
326
+ return record;
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // Internal: normalization + parsing
331
+ // ---------------------------------------------------------------------------
332
+
333
+ function normalizePairing(raw, ctx) {
334
+ if (!raw || typeof raw !== "object") {
335
+ throw new TypeError(`pairing[${ctx}]: not an object`);
336
+ }
337
+ const pairingId = stringOrThrow(raw.pairingId, `pairing[${ctx}].pairingId`);
338
+ const relayToken = raw.relayToken == null || raw.relayToken === ""
339
+ ? generateRelayToken(pairingId)
340
+ : normalizeRelayToken(pairingId, raw.relayToken);
341
+ const phonePubRaw = stringOrThrow(raw.phonePub, `pairing[${ctx}].phonePub`).toLowerCase();
342
+ // Validate phonePub by parsing the hex — catches obvious garbage early.
343
+ let phonePubBytes;
344
+ try {
345
+ phonePubBytes = hexToBytes(phonePubRaw);
346
+ } catch (err) {
347
+ throw new Error(`pairing[${ctx}].phonePub: ${err.message}`);
348
+ }
349
+ if (phonePubBytes.length !== IDENTITY_KEY_BYTES) {
350
+ throw new RangeError(
351
+ `pairing[${ctx}].phonePub must be ${IDENTITY_KEY_BYTES} bytes (got ${phonePubBytes.length})`,
352
+ );
353
+ }
354
+ // Fingerprint is recoverable from phonePub; if missing, regenerate. If
355
+ // present and disagrees with the derived value, prefer derivation (matches
356
+ // keys.mjs's behaviour for the bridge's own keypair).
357
+ let fingerprint;
358
+ try {
359
+ fingerprint = fingerprintIdentity(phonePubBytes);
360
+ } catch (err) {
361
+ throw new Error(`pairing[${ctx}].phoneFingerprint: ${err.message}`);
362
+ }
363
+
364
+ // lastSeenChannelBinding from older schemas is intentionally dropped —
365
+ // see the file-level comment.
366
+ const out = {
367
+ pairingId,
368
+ relayToken,
369
+ phonePub: phonePubRaw,
370
+ phoneFingerprint: fingerprint,
371
+ label: raw.label != null ? String(raw.label) : "",
372
+ addedAtMs: numOrNull(raw.addedAtMs) ?? Date.now(),
373
+ relayTokenUpdatedAtMs: numOrNull(raw.relayTokenUpdatedAtMs),
374
+ lastSeenAtMs: numOrNull(raw.lastSeenAtMs),
375
+ };
376
+ const deviceId = normalizeOptionalText(raw.deviceId);
377
+ if (deviceId) {
378
+ out.deviceId = deviceId;
379
+ }
380
+ return out;
381
+ }
382
+
383
+ export function generateRelayToken(pairingId) {
384
+ const nonce = randomBytes(24).toString("base64url");
385
+ let counter = randomBytes(6).readUIntBE(0, 6);
386
+ for (;;) {
387
+ const token = `${RELAY_TOKEN_VERSION}.${nonce}.${counter.toString(36)}`;
388
+ if (verifyRelayToken(pairingId, token)) return token;
389
+ counter = (counter + 1) % Number.MAX_SAFE_INTEGER;
390
+ }
391
+ }
392
+
393
+ export function verifyRelayToken(pairingId, relayToken) {
394
+ const id = stringOrThrow(String(pairingId || ""), "pairingId");
395
+ const token = stringOrThrow(String(relayToken || ""), "relayToken");
396
+ if (!RELAY_TOKEN_RE.test(token)) return false;
397
+ const digest = createHash("sha256")
398
+ .update(`${RELAY_TOKEN_DOMAIN}:${id}:${token}`)
399
+ .digest();
400
+ return countLeadingZeroBits(digest) >= RELAY_TOKEN_POW_BITS;
401
+ }
402
+
403
+ function normalizeRelayToken(pairingId, relayToken) {
404
+ const token = stringOrThrow(String(relayToken || ""), "relayToken");
405
+ if (!verifyRelayToken(pairingId, token)) {
406
+ throw new Error("relayToken failed validation");
407
+ }
408
+ return token;
409
+ }
410
+
411
+ function countLeadingZeroBits(bytes) {
412
+ let bits = 0;
413
+ for (const b of bytes) {
414
+ if (b === 0) {
415
+ bits += 8;
416
+ continue;
417
+ }
418
+ for (let i = 7; i >= 0; i--) {
419
+ if ((b & (1 << i)) !== 0) return bits;
420
+ bits += 1;
421
+ }
422
+ }
423
+ return bits;
424
+ }
425
+
426
+ function stringOrThrow(v, name) {
427
+ if (typeof v !== "string" || v.length === 0) {
428
+ throw new TypeError(`${name} must be a non-empty string`);
429
+ }
430
+ return v;
431
+ }
432
+
433
+ function numOrNull(v) {
434
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
435
+ }
436
+
437
+ function normalizeOptionalText(v) {
438
+ if (v == null) return "";
439
+ return String(v).trim().slice(0, 200);
440
+ }
441
+
442
+ function asU8(v) {
443
+ if (v instanceof Uint8Array) return v;
444
+ if (typeof v === "string") return hexToBytes(v);
445
+ throw new TypeError("expected Uint8Array or hex string");
446
+ }