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.
- package/README.md +33 -4
- package/package.json +7 -3
- package/scripts/lib/remote-pairing/README.md +164 -0
- package/scripts/lib/remote-pairing/_browser-bundle-entry.mjs +86 -0
- package/scripts/lib/remote-pairing/audit.mjs +122 -0
- package/scripts/lib/remote-pairing/bridge-relay-client.mjs +737 -0
- package/scripts/lib/remote-pairing/control.mjs +156 -0
- package/scripts/lib/remote-pairing/envelope.mjs +224 -0
- package/scripts/lib/remote-pairing/http-dispatch.mjs +530 -0
- package/scripts/lib/remote-pairing/keys-core.mjs +110 -0
- package/scripts/lib/remote-pairing/keys.mjs +181 -0
- package/scripts/lib/remote-pairing/noise.mjs +436 -0
- package/scripts/lib/remote-pairing/orchestrator.mjs +356 -0
- package/scripts/lib/remote-pairing/pairings.mjs +446 -0
- package/scripts/lib/remote-pairing/rpc.mjs +381 -0
- package/scripts/moltbook-scout-auto.sh +16 -8
- package/scripts/share-cli.mjs +14 -130
- package/scripts/viveworker-bridge.mjs +1324 -103
- package/scripts/viveworker.mjs +27 -6
- package/web/app.css +634 -9
- package/web/app.js +1731 -187
- package/web/i18n.js +187 -9
- package/web/index.html +32 -2
- package/web/remote-pairing/api-router.js +873 -0
- package/web/remote-pairing/keys.js +237 -0
- package/web/remote-pairing/pairing-state.js +313 -0
- package/web/remote-pairing/rpc-client.js +765 -0
- package/web/remote-pairing/transport.js +804 -0
- package/web/remote-pairing/wake.js +149 -0
- package/web/remote-pairing-test.html +400 -0
- package/web/remote-pairing.bundle.js +3 -0
- package/web/remote-pairing.bundle.js.LEGAL.txt +15 -0
- package/web/remote-pairing.bundle.js.map +7 -0
- 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
|
+
}
|