viveworker 0.7.0-beta.3 → 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 +1696 -223
- package/scripts/viveworker.mjs +27 -6
- package/web/app.css +727 -9
- package/web/app.js +1810 -232
- package/web/i18n.js +207 -17
- package/web/index.html +115 -1
- 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,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web/remote-pairing/keys.js — IndexedDB-backed identity key store (browser).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the Node-side `scripts/lib/remote-pairing/keys.mjs` API
|
|
5
|
+
* (load / save / ensure / clear) so the rest of the remote-pairing
|
|
6
|
+
* client can consume the same shape regardless of platform. Pure crypto
|
|
7
|
+
* helpers (`generateIdentityKeypair`, `publicFromPrivate`,
|
|
8
|
+
* `fingerprintIdentity`, hex helpers) are re-exported from the shared
|
|
9
|
+
* bundle.
|
|
10
|
+
*
|
|
11
|
+
* Storage model:
|
|
12
|
+
* DB: viveworker-remote-pairing
|
|
13
|
+
* Store: keys
|
|
14
|
+
* Key: "identity"
|
|
15
|
+
* Value: { priv: Uint8Array(32), pub: Uint8Array(32), createdAtMs: number }
|
|
16
|
+
*
|
|
17
|
+
* Threat model (Phase 2 MVP):
|
|
18
|
+
* - IndexedDB is origin-scoped and survives PWA restarts. A user
|
|
19
|
+
* "clear site data" wipes it; we treat that as the device losing
|
|
20
|
+
* its pairing identity (re-pair from LAN required).
|
|
21
|
+
* - Private bytes are stored extractable. Migrating to Web Crypto
|
|
22
|
+
* non-extractable CryptoKey requires switching the handshake's DH
|
|
23
|
+
* calls to Web Crypto's X25519 (Chrome 100+, Safari 17+, Firefox 100+).
|
|
24
|
+
* That's tracked as a future hardening pass — for now filesystem-style
|
|
25
|
+
* origin scoping is what we lean on.
|
|
26
|
+
*
|
|
27
|
+
* The async API tolerates "no IndexedDB" (e.g., Safari private mode in
|
|
28
|
+
* some configs): callers get a thrown error with a clear message and can
|
|
29
|
+
* decide whether to surface it to the user or fall back to in-memory only.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// Relative path so the same source resolves correctly under both the bridge
|
|
33
|
+
// (which serves /remote-pairing/keys.js → /remote-pairing.bundle.js) and
|
|
34
|
+
// Node-driven unit tests (which load it from the filesystem).
|
|
35
|
+
import {
|
|
36
|
+
generateIdentityKeypair as bundleGenerateIdentityKeypair,
|
|
37
|
+
publicFromPrivate,
|
|
38
|
+
fingerprintIdentity,
|
|
39
|
+
bytesToHex,
|
|
40
|
+
hexToBytes,
|
|
41
|
+
IDENTITY_KEY_BYTES,
|
|
42
|
+
} from "../remote-pairing.bundle.js";
|
|
43
|
+
|
|
44
|
+
// Re-export the pure helpers so callers don't have to import the bundle
|
|
45
|
+
// directly for everyday operations.
|
|
46
|
+
export {
|
|
47
|
+
publicFromPrivate,
|
|
48
|
+
fingerprintIdentity,
|
|
49
|
+
bytesToHex,
|
|
50
|
+
hexToBytes,
|
|
51
|
+
IDENTITY_KEY_BYTES,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const DB_NAME = "viveworker-remote-pairing";
|
|
55
|
+
const DB_VERSION = 1;
|
|
56
|
+
const STORE_NAME = "keys";
|
|
57
|
+
const RECORD_KEY = "identity";
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// IndexedDB plumbing
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Open (or upgrade) the database. Cached after first open; safe to call
|
|
65
|
+
* concurrently — only one upgrade transaction will run.
|
|
66
|
+
*
|
|
67
|
+
* @param {{ indexedDB?: IDBFactory }} [options]
|
|
68
|
+
* @returns {Promise<IDBDatabase>}
|
|
69
|
+
*/
|
|
70
|
+
function openDB({ indexedDB = globalThis.indexedDB } = {}) {
|
|
71
|
+
if (!indexedDB) {
|
|
72
|
+
return Promise.reject(new Error("IndexedDB not available in this environment"));
|
|
73
|
+
}
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
76
|
+
req.onupgradeneeded = () => {
|
|
77
|
+
const db = req.result;
|
|
78
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
79
|
+
db.createObjectStore(STORE_NAME);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
req.onsuccess = () => resolve(req.result);
|
|
83
|
+
req.onerror = () => reject(req.error ?? new Error("indexedDB.open failed"));
|
|
84
|
+
req.onblocked = () => reject(new Error("indexedDB.open blocked by another tab"));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Run a single object-store transaction. The callback receives the store
|
|
90
|
+
* and returns the IDBRequest whose `result` we should resolve with.
|
|
91
|
+
*/
|
|
92
|
+
function runTx(db, mode, fn) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const tx = db.transaction(STORE_NAME, mode);
|
|
95
|
+
const store = tx.objectStore(STORE_NAME);
|
|
96
|
+
let req;
|
|
97
|
+
try {
|
|
98
|
+
req = fn(store);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
reject(err);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
tx.oncomplete = () => resolve(req?.result);
|
|
104
|
+
tx.onerror = () => reject(tx.error ?? new Error("transaction failed"));
|
|
105
|
+
tx.onabort = () => reject(tx.error ?? new Error("transaction aborted"));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Public API
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Generate a fresh keypair (does NOT persist; pair with `saveIdentityKeypair`
|
|
115
|
+
* or use `ensureIdentityKeypair` for the load-or-create path).
|
|
116
|
+
*
|
|
117
|
+
* @returns {{ priv: Uint8Array, pub: Uint8Array }}
|
|
118
|
+
*/
|
|
119
|
+
export function generateIdentityKeypair() {
|
|
120
|
+
return bundleGenerateIdentityKeypair();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Load the persisted keypair, or null if none has been saved yet.
|
|
125
|
+
*
|
|
126
|
+
* @param {{ indexedDB?: IDBFactory }} [options]
|
|
127
|
+
* @returns {Promise<{ priv: Uint8Array, pub: Uint8Array, createdAtMs: number } | null>}
|
|
128
|
+
*/
|
|
129
|
+
export async function loadIdentityKeypair(options) {
|
|
130
|
+
const db = await openDB(options);
|
|
131
|
+
try {
|
|
132
|
+
const record = await runTx(db, "readonly", (store) => store.get(RECORD_KEY));
|
|
133
|
+
if (!record) return null;
|
|
134
|
+
const priv = coerceBytes(record.priv);
|
|
135
|
+
const pub = coerceBytes(record.pub);
|
|
136
|
+
if (priv.length !== IDENTITY_KEY_BYTES || pub.length !== IDENTITY_KEY_BYTES) {
|
|
137
|
+
return null; // corrupt; let caller decide whether to reset
|
|
138
|
+
}
|
|
139
|
+
// Sanity check: derived pub should match stored pub. If it diverges
|
|
140
|
+
// (shouldn't happen barring storage corruption), trust the priv.
|
|
141
|
+
const derivedPub = publicFromPrivate(priv);
|
|
142
|
+
if (!equalBytes(derivedPub, pub)) {
|
|
143
|
+
// Best we can do client-side; surface via a console warning so devs
|
|
144
|
+
// notice during testing. Production callers will eventually want to
|
|
145
|
+
// wipe + regenerate.
|
|
146
|
+
console.warn("[remote-pairing/keys] stored pub disagrees with derived pub; trusting private key");
|
|
147
|
+
return { priv, pub: derivedPub, createdAtMs: numberOr(record.createdAtMs, Date.now()) };
|
|
148
|
+
}
|
|
149
|
+
return { priv, pub, createdAtMs: numberOr(record.createdAtMs, Date.now()) };
|
|
150
|
+
} finally {
|
|
151
|
+
db.close();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Persist a keypair. Overwrites any existing record.
|
|
157
|
+
*
|
|
158
|
+
* @param {{ priv: Uint8Array, pub: Uint8Array }} keypair
|
|
159
|
+
* @param {{ indexedDB?: IDBFactory }} [options]
|
|
160
|
+
*/
|
|
161
|
+
export async function saveIdentityKeypair(keypair, options) {
|
|
162
|
+
if (!keypair?.priv || !keypair?.pub) throw new TypeError("keypair { priv, pub } required");
|
|
163
|
+
if (keypair.priv.length !== IDENTITY_KEY_BYTES) throw new RangeError("priv must be 32 bytes");
|
|
164
|
+
if (keypair.pub.length !== IDENTITY_KEY_BYTES) throw new RangeError("pub must be 32 bytes");
|
|
165
|
+
|
|
166
|
+
const db = await openDB(options);
|
|
167
|
+
try {
|
|
168
|
+
const record = {
|
|
169
|
+
priv: copyBytes(keypair.priv),
|
|
170
|
+
pub: copyBytes(keypair.pub),
|
|
171
|
+
createdAtMs: Date.now(),
|
|
172
|
+
};
|
|
173
|
+
await runTx(db, "readwrite", (store) => store.put(record, RECORD_KEY));
|
|
174
|
+
} finally {
|
|
175
|
+
db.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Load the persisted keypair, generating + saving a fresh one if none exists.
|
|
181
|
+
*
|
|
182
|
+
* @param {{ indexedDB?: IDBFactory }} [options]
|
|
183
|
+
* @returns {Promise<{ priv: Uint8Array, pub: Uint8Array, createdAtMs: number }>}
|
|
184
|
+
*/
|
|
185
|
+
export async function ensureIdentityKeypair(options) {
|
|
186
|
+
const existing = await loadIdentityKeypair(options);
|
|
187
|
+
if (existing) return existing;
|
|
188
|
+
const fresh = generateIdentityKeypair();
|
|
189
|
+
await saveIdentityKeypair(fresh, options);
|
|
190
|
+
return { ...fresh, createdAtMs: Date.now() };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Wipe the persisted keypair. Used by the "unpair this device" flow.
|
|
195
|
+
*
|
|
196
|
+
* @param {{ indexedDB?: IDBFactory }} [options]
|
|
197
|
+
*/
|
|
198
|
+
export async function clearIdentityKeypair(options) {
|
|
199
|
+
const db = await openDB(options);
|
|
200
|
+
try {
|
|
201
|
+
await runTx(db, "readwrite", (store) => store.delete(RECORD_KEY));
|
|
202
|
+
} finally {
|
|
203
|
+
db.close();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Internals
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
function coerceBytes(value) {
|
|
212
|
+
if (value instanceof Uint8Array) return value;
|
|
213
|
+
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
214
|
+
if (ArrayBuffer.isView(value)) return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
215
|
+
if (Array.isArray(value)) return new Uint8Array(value);
|
|
216
|
+
throw new TypeError(`unsupported byte container: ${typeof value}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function copyBytes(bytes) {
|
|
220
|
+
// Defensive copy so a caller mutating its array after save doesn't
|
|
221
|
+
// poison the persisted record. (IndexedDB structured-clones, so this
|
|
222
|
+
// is partially redundant — but it costs ~64B and removes a footgun.)
|
|
223
|
+
const u8 = coerceBytes(bytes);
|
|
224
|
+
const out = new Uint8Array(u8.length);
|
|
225
|
+
out.set(u8);
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function equalBytes(a, b) {
|
|
230
|
+
if (a.length !== b.length) return false;
|
|
231
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function numberOr(value, fallback) {
|
|
236
|
+
return Number.isFinite(value) ? value : fallback;
|
|
237
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web/remote-pairing/pairing-state.js — localStorage-backed pairing record.
|
|
3
|
+
*
|
|
4
|
+
* After a successful LAN pairing the phone calls
|
|
5
|
+
* `POST /api/remote-pairing/lan-enroll` and gets back enough information
|
|
6
|
+
* to reach the same bridge over the relay when off-LAN:
|
|
7
|
+
*
|
|
8
|
+
* - pairingId (relay slot identifier; opaque to the phone)
|
|
9
|
+
* - relayToken (capability required by the public relay)
|
|
10
|
+
* - bridgePubHex (bridge's X25519 static pubkey — the phone uses
|
|
11
|
+
* it as the responder static for Noise IK)
|
|
12
|
+
* - bridgeFingerprint (canonical "AB:CD:EF…" form for display)
|
|
13
|
+
* - relayUrl (wss://… — destination of the WebSocket dial)
|
|
14
|
+
* - phonePub (echoed; useful for "is this still my keypair?"
|
|
15
|
+
* sanity checks against IndexedDB on bootstrap)
|
|
16
|
+
* - label (whatever the phone sent at enroll time)
|
|
17
|
+
* - addedAtMs (server-side timestamp; phones with skewed clocks
|
|
18
|
+
* still see a coherent "added on" date)
|
|
19
|
+
* - relayTokenUpdatedAtMs (server-side timestamp of the latest relayToken)
|
|
20
|
+
*
|
|
21
|
+
* Why localStorage and not IndexedDB:
|
|
22
|
+
* - The X25519 *private* key lives in IndexedDB (keys.js) because
|
|
23
|
+
* it's the secret half of the identity. This file stores routing
|
|
24
|
+
* capability metadata — including `relayToken`, which can reach the
|
|
25
|
+
* correct relay room but still cannot decrypt or authenticate the Noise
|
|
26
|
+
* channel without the IndexedDB private key. Treat XSS on this origin as
|
|
27
|
+
* device compromise, just like an attacker reading same-origin session
|
|
28
|
+
* state.
|
|
29
|
+
* - localStorage is synchronous, which is convenient on the bootstrap
|
|
30
|
+
* hot path where we want to know "is this phone enrolled?" before
|
|
31
|
+
* we even have a chance to await anything.
|
|
32
|
+
*
|
|
33
|
+
* Single-record assumption:
|
|
34
|
+
* The phone is paired with at most ONE bridge at a time (current PWA
|
|
35
|
+
* design — `bridge URL = origin`). If we later support multi-bridge
|
|
36
|
+
* bookmarking, this becomes an array keyed by origin and the helpers
|
|
37
|
+
* grow a `bridgeOrigin` parameter. Keep callers using the helpers
|
|
38
|
+
* here so that future migration is local.
|
|
39
|
+
*
|
|
40
|
+
* Schema versioning:
|
|
41
|
+
* The `version` field lets us reject + clear unknown shapes on bootstrap.
|
|
42
|
+
* When the format evolves, bump the constant and write a migrator.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const STORAGE_KEY = "viveworker.remote-pairing.state";
|
|
46
|
+
const SCHEMA_VERSION = 2;
|
|
47
|
+
const LEGACY_SCHEMA_VERSION = 1;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @typedef {Object} RemotePairingState
|
|
51
|
+
* @property {number} version schema version (== SCHEMA_VERSION)
|
|
52
|
+
* @property {string} pairingId relay slot identifier
|
|
53
|
+
* @property {string} relayToken relay capability token
|
|
54
|
+
* @property {string} phonePub lowercase 64-hex of the phone's X25519 pub
|
|
55
|
+
* @property {string} phoneFingerprint canonical "AB:CD:EF…" of phonePub
|
|
56
|
+
* @property {string} bridgePubHex lowercase 64-hex of the bridge's X25519 pub
|
|
57
|
+
* @property {string} bridgeFingerprint canonical fingerprint of bridgePubHex
|
|
58
|
+
* @property {string} relayUrl ws:// or wss:// URL the bridge is on
|
|
59
|
+
* @property {string} label user-visible device label
|
|
60
|
+
* @property {number} addedAtMs server-side enroll time
|
|
61
|
+
* @property {number} relayTokenUpdatedAtMs server-side token update time, 0 for legacy records
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the localStorage instance. Wrapped so tests can pass a stub
|
|
66
|
+
* (e.g. a fake { getItem, setItem, removeItem } object) without monkey-
|
|
67
|
+
* patching globalThis.
|
|
68
|
+
*
|
|
69
|
+
* @returns {Storage | null}
|
|
70
|
+
*/
|
|
71
|
+
function getStorage(opts) {
|
|
72
|
+
if (opts?.storage) return opts.storage;
|
|
73
|
+
if (typeof globalThis === "undefined") return null;
|
|
74
|
+
// localStorage may be missing entirely (older Node-based test contexts)
|
|
75
|
+
// or throw on access (some private-mode browsers). Treat both as "no
|
|
76
|
+
// persistent storage" — the caller can decide whether to surface the
|
|
77
|
+
// failure or fall back to in-memory only.
|
|
78
|
+
try {
|
|
79
|
+
return globalThis.localStorage ?? null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read the persisted pairing state, or null if there isn't one (or it's
|
|
87
|
+
* malformed / wrong version — both treated identically: caller should
|
|
88
|
+
* re-enroll).
|
|
89
|
+
*
|
|
90
|
+
* @param {{ storage?: Storage }} [opts]
|
|
91
|
+
* @returns {RemotePairingState | null}
|
|
92
|
+
*/
|
|
93
|
+
export function loadPairingState(opts) {
|
|
94
|
+
const store = getStorage(opts);
|
|
95
|
+
if (!store) return null;
|
|
96
|
+
let raw;
|
|
97
|
+
try {
|
|
98
|
+
raw = store.getItem(STORAGE_KEY);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (!raw) return null;
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
110
|
+
if (parsed.version !== SCHEMA_VERSION) return null;
|
|
111
|
+
// Light shape validation — every required string field must be present
|
|
112
|
+
// and non-empty. We don't re-validate hex format here; the bridge owns
|
|
113
|
+
// that and any garbage we'd find here came from a corrupted localStorage.
|
|
114
|
+
const required = [
|
|
115
|
+
"pairingId",
|
|
116
|
+
"relayToken",
|
|
117
|
+
"phonePub",
|
|
118
|
+
"phoneFingerprint",
|
|
119
|
+
"bridgePubHex",
|
|
120
|
+
"bridgeFingerprint",
|
|
121
|
+
"relayUrl",
|
|
122
|
+
];
|
|
123
|
+
for (const key of required) {
|
|
124
|
+
if (typeof parsed[key] !== "string" || parsed[key].length === 0) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
version: SCHEMA_VERSION,
|
|
130
|
+
pairingId: parsed.pairingId,
|
|
131
|
+
relayToken: parsed.relayToken,
|
|
132
|
+
phonePub: parsed.phonePub.toLowerCase(),
|
|
133
|
+
phoneFingerprint: parsed.phoneFingerprint,
|
|
134
|
+
bridgePubHex: parsed.bridgePubHex.toLowerCase(),
|
|
135
|
+
bridgeFingerprint: parsed.bridgeFingerprint,
|
|
136
|
+
relayUrl: parsed.relayUrl,
|
|
137
|
+
label: typeof parsed.label === "string" ? parsed.label : "",
|
|
138
|
+
addedAtMs: Number.isFinite(parsed.addedAtMs) ? parsed.addedAtMs : 0,
|
|
139
|
+
relayTokenUpdatedAtMs: Number.isFinite(parsed.relayTokenUpdatedAtMs)
|
|
140
|
+
? parsed.relayTokenUpdatedAtMs
|
|
141
|
+
: 0,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Inspect the raw stored pairing state without silently collapsing every
|
|
147
|
+
* non-v2 shape to null. This lets the app distinguish "not enrolled yet"
|
|
148
|
+
* from "legacy v1 record needs a LAN refresh after relayToken hardening".
|
|
149
|
+
*
|
|
150
|
+
* @param {{ storage?: Storage }} [opts]
|
|
151
|
+
* @returns {{
|
|
152
|
+
* status: "ready" | "missing" | "legacy-v1" | "missing-token" | "malformed" | "unsupported-version" | "storage-unavailable",
|
|
153
|
+
* needsEnrollment: boolean,
|
|
154
|
+
* record: RemotePairingState | null,
|
|
155
|
+
* legacyRecord?: object | null,
|
|
156
|
+
* }}
|
|
157
|
+
*/
|
|
158
|
+
export function inspectPairingState(opts) {
|
|
159
|
+
const store = getStorage(opts);
|
|
160
|
+
if (!store) {
|
|
161
|
+
return stateStatus("storage-unavailable", false);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let raw;
|
|
165
|
+
try {
|
|
166
|
+
raw = store.getItem(STORAGE_KEY);
|
|
167
|
+
} catch {
|
|
168
|
+
return stateStatus("storage-unavailable", false);
|
|
169
|
+
}
|
|
170
|
+
if (!raw) {
|
|
171
|
+
return stateStatus("missing", true);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let parsed;
|
|
175
|
+
try {
|
|
176
|
+
parsed = JSON.parse(raw);
|
|
177
|
+
} catch {
|
|
178
|
+
return stateStatus("malformed", true);
|
|
179
|
+
}
|
|
180
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
181
|
+
return stateStatus("malformed", true);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (parsed.version === LEGACY_SCHEMA_VERSION) {
|
|
185
|
+
return {
|
|
186
|
+
...stateStatus("legacy-v1", true),
|
|
187
|
+
legacyRecord: normalizeLegacyRecordForInspection(parsed),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (parsed.version !== SCHEMA_VERSION) {
|
|
192
|
+
return stateStatus("unsupported-version", true);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (typeof parsed.relayToken !== "string" || parsed.relayToken.length === 0) {
|
|
196
|
+
return stateStatus("missing-token", true);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const record = loadPairingState(opts);
|
|
200
|
+
if (!record) {
|
|
201
|
+
return stateStatus("malformed", true);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
...stateStatus("ready", false),
|
|
205
|
+
record,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Persist a pairing state record. Overwrites any existing record (the
|
|
211
|
+
* single-record assumption above — re-enrolling under the same origin
|
|
212
|
+
* replaces the previous bridge).
|
|
213
|
+
*
|
|
214
|
+
* @param {Omit<RemotePairingState, "version"> & { version?: number }} record
|
|
215
|
+
* @param {{ storage?: Storage }} [opts]
|
|
216
|
+
* @returns {boolean} true if written, false if storage is unavailable.
|
|
217
|
+
*/
|
|
218
|
+
export function savePairingState(record, opts) {
|
|
219
|
+
const store = getStorage(opts);
|
|
220
|
+
if (!store) return false;
|
|
221
|
+
if (!record || typeof record !== "object") {
|
|
222
|
+
throw new TypeError("savePairingState: record must be an object");
|
|
223
|
+
}
|
|
224
|
+
// Validate inputs synchronously so a buggy caller fails loudly rather
|
|
225
|
+
// than persisting "undefined" strings.
|
|
226
|
+
for (const key of [
|
|
227
|
+
"pairingId",
|
|
228
|
+
"relayToken",
|
|
229
|
+
"phonePub",
|
|
230
|
+
"phoneFingerprint",
|
|
231
|
+
"bridgePubHex",
|
|
232
|
+
"bridgeFingerprint",
|
|
233
|
+
"relayUrl",
|
|
234
|
+
]) {
|
|
235
|
+
if (typeof record[key] !== "string" || record[key].length === 0) {
|
|
236
|
+
throw new TypeError(`savePairingState: ${key} must be a non-empty string`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const out = {
|
|
240
|
+
version: SCHEMA_VERSION,
|
|
241
|
+
pairingId: record.pairingId,
|
|
242
|
+
relayToken: record.relayToken,
|
|
243
|
+
phonePub: record.phonePub.toLowerCase(),
|
|
244
|
+
phoneFingerprint: record.phoneFingerprint,
|
|
245
|
+
bridgePubHex: record.bridgePubHex.toLowerCase(),
|
|
246
|
+
bridgeFingerprint: record.bridgeFingerprint,
|
|
247
|
+
relayUrl: record.relayUrl,
|
|
248
|
+
label: typeof record.label === "string" ? record.label : "",
|
|
249
|
+
addedAtMs: Number.isFinite(record.addedAtMs) ? record.addedAtMs : Date.now(),
|
|
250
|
+
relayTokenUpdatedAtMs: Number.isFinite(record.relayTokenUpdatedAtMs)
|
|
251
|
+
? record.relayTokenUpdatedAtMs
|
|
252
|
+
: 0,
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
store.setItem(STORAGE_KEY, JSON.stringify(out));
|
|
256
|
+
return true;
|
|
257
|
+
} catch {
|
|
258
|
+
// Storage quota / disabled-by-user / private-mode-throws → swallow.
|
|
259
|
+
// The user can re-trigger enrollment from the settings page later.
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Clear the pairing state. Use cases:
|
|
266
|
+
* - User logged out / unpaired the phone
|
|
267
|
+
* - Bridge identity changed (we detect it via bridgePubHex mismatch
|
|
268
|
+
* and need to force a re-enroll)
|
|
269
|
+
* - localStorage held a malformed record (shouldn't happen but
|
|
270
|
+
* `loadPairingState` returning null while `getItem` returns
|
|
271
|
+
* non-empty is a signal we want gone)
|
|
272
|
+
*
|
|
273
|
+
* @param {{ storage?: Storage }} [opts]
|
|
274
|
+
*/
|
|
275
|
+
export function clearPairingState(opts) {
|
|
276
|
+
const store = getStorage(opts);
|
|
277
|
+
if (!store) return;
|
|
278
|
+
try {
|
|
279
|
+
store.removeItem(STORAGE_KEY);
|
|
280
|
+
} catch {
|
|
281
|
+
// ignore
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function stateStatus(status, needsEnrollment) {
|
|
286
|
+
return {
|
|
287
|
+
status,
|
|
288
|
+
needsEnrollment,
|
|
289
|
+
record: null,
|
|
290
|
+
legacyRecord: null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function normalizeLegacyRecordForInspection(parsed) {
|
|
295
|
+
return {
|
|
296
|
+
version: LEGACY_SCHEMA_VERSION,
|
|
297
|
+
pairingId: typeof parsed.pairingId === "string" ? parsed.pairingId : "",
|
|
298
|
+
phonePub: typeof parsed.phonePub === "string" ? parsed.phonePub.toLowerCase() : "",
|
|
299
|
+
phoneFingerprint: typeof parsed.phoneFingerprint === "string" ? parsed.phoneFingerprint : "",
|
|
300
|
+
bridgePubHex: typeof parsed.bridgePubHex === "string" ? parsed.bridgePubHex.toLowerCase() : "",
|
|
301
|
+
bridgeFingerprint: typeof parsed.bridgeFingerprint === "string" ? parsed.bridgeFingerprint : "",
|
|
302
|
+
relayUrl: typeof parsed.relayUrl === "string" ? parsed.relayUrl : "",
|
|
303
|
+
label: typeof parsed.label === "string" ? parsed.label : "",
|
|
304
|
+
addedAtMs: Number.isFinite(parsed.addedAtMs) ? parsed.addedAtMs : 0,
|
|
305
|
+
relayTokenUpdatedAtMs: Number.isFinite(parsed.relayTokenUpdatedAtMs)
|
|
306
|
+
? parsed.relayTokenUpdatedAtMs
|
|
307
|
+
: 0,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Test-visible constants for tests that want to assert raw key shape.
|
|
312
|
+
export const __STORAGE_KEY = STORAGE_KEY;
|
|
313
|
+
export const __SCHEMA_VERSION = SCHEMA_VERSION;
|