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,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* http-dispatch.mjs — Adapt RPC requests to a Node `(req, res) => void`
|
|
3
|
+
* request listener.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists:
|
|
6
|
+
* The bridge already has one big request handler that knows how to serve
|
|
7
|
+
* every PWA endpoint (auth, approvals, threads, share, …). Re-implementing
|
|
8
|
+
* that handler for relay-tunneled traffic would duplicate ~20k lines and
|
|
9
|
+
* guarantee drift. Instead, we synthesize Node IncomingMessage /
|
|
10
|
+
* ServerResponse objects from an RPC request, run the existing handler
|
|
11
|
+
* against them, and capture whatever the handler writes as the RPC
|
|
12
|
+
* response. The handler can't tell the difference between a relay-tunneled
|
|
13
|
+
* call and a LAN-HTTPS one — same headers, same cookies, same body.
|
|
14
|
+
*
|
|
15
|
+
* What the bridge handler actually touches (audited via grep):
|
|
16
|
+
* req.method, req.url, req.headers, req.on('data'|'end'),
|
|
17
|
+
* req.socket?.remoteAddress, req.destroy()
|
|
18
|
+
* res.statusCode, res.setHeader(), res.end()
|
|
19
|
+
*
|
|
20
|
+
* That's the surface this adapter implements. If the bridge starts using
|
|
21
|
+
* other Node http APIs (writeHead, write streaming, getHeader, etc.) we'll
|
|
22
|
+
* extend the synthetic objects rather than fall back to a real TCP loop —
|
|
23
|
+
* the loopback-fetch alternative would force this module to know the
|
|
24
|
+
* bridge's listening port + cert + auth scheme, all of which couples too
|
|
25
|
+
* tightly.
|
|
26
|
+
*
|
|
27
|
+
* Body handling:
|
|
28
|
+
* RPC bodies arrive as a single string (utf8 or base64). We feed the
|
|
29
|
+
* bytes synchronously via one `data` event followed by `end`, which is
|
|
30
|
+
* what `parseFormBody` and similar helpers expect. The body is fully
|
|
31
|
+
* buffered before dispatch — chunked streaming is out of scope until
|
|
32
|
+
* the relay supports it (PWA payloads are JSON / form-encoded, all small).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { Readable } from "node:stream";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Types
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {(req: import("node:http").IncomingMessage, res: import("node:http").ServerResponse) => (void | Promise<void>)} RequestListener
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {Object} HttpDispatchOptions
|
|
47
|
+
* @property {RequestListener} requestListener bridge's HTTP handler
|
|
48
|
+
* @property {string} [remoteAddressPrefix] prepended to the synthetic
|
|
49
|
+
* remoteAddress (e.g. "remote-pair:") so log lines / rate
|
|
50
|
+
* limiters can distinguish relay traffic from LAN traffic.
|
|
51
|
+
* @property {number} [responseTimeoutMs] if > 0, return 504 if the
|
|
52
|
+
* listener doesn't call res.end() within this many ms. Default 0
|
|
53
|
+
* (wait forever; rely on the phone-side AbortSignal).
|
|
54
|
+
* @property {{warn?: Function, error?: Function}} [logger]
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Path gate — defense in depth
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
//
|
|
61
|
+
// Noise IK + the phonePub allowlist already authenticate the peer, so in
|
|
62
|
+
// principle the bridge can trust anything that arrives with `fromRelay:
|
|
63
|
+
// true`. We still gate the path here as a belt-and-braces measure: if a
|
|
64
|
+
// phone identity key ever leaks (extractable IndexedDB record on a stolen
|
|
65
|
+
// device, supply-chain XSS in the PWA host, etc.), the blast radius is the
|
|
66
|
+
// reachable HTTP surface, and there's no reason that surface should
|
|
67
|
+
// include LAN-bootstrap or pairing-management endpoints that only make
|
|
68
|
+
// sense over a local-only origin.
|
|
69
|
+
//
|
|
70
|
+
// The rules are intentionally minimal:
|
|
71
|
+
// 1. Only `/api/...` paths can ride the relay. The bridge serves the PWA
|
|
72
|
+
// shell, the SW, static assets, and a few diagnostic pages on other
|
|
73
|
+
// paths — none of those need to be reachable through the relay (the
|
|
74
|
+
// PWA is hosted by the phone OS, not by the bridge over the relay).
|
|
75
|
+
// 2. A short deny list inside `/api/` blocks endpoints whose threat
|
|
76
|
+
// model only makes sense over LAN: pairing enrollment / revoke /
|
|
77
|
+
// LAN session bootstrap. These would already 403 in practice (no
|
|
78
|
+
// session cookie, etc.), but locking them out at the dispatch layer
|
|
79
|
+
// keeps a future relaxation of cookie auth from accidentally opening
|
|
80
|
+
// them.
|
|
81
|
+
//
|
|
82
|
+
// Both checks return a synthetic 403 RPC response — they never invoke the
|
|
83
|
+
// bridge's request listener.
|
|
84
|
+
|
|
85
|
+
const RELAY_DENIED_PATHS = new Set([
|
|
86
|
+
"/api/remote-pairing/lan-enroll",
|
|
87
|
+
"/api/remote-pairing/revoke",
|
|
88
|
+
"/api/remote-pairing/rotate-token",
|
|
89
|
+
"/api/session/pair",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const RELAY_DENIED_PREFIXES = [
|
|
93
|
+
"/admin/",
|
|
94
|
+
"/internal/",
|
|
95
|
+
"/__",
|
|
96
|
+
"/api/internal/",
|
|
97
|
+
"/api/admin/",
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @param {string} rawPath
|
|
102
|
+
* @returns {{ allowed: boolean, reason?: string }}
|
|
103
|
+
*/
|
|
104
|
+
export function classifyRelayPath(rawPath) {
|
|
105
|
+
const path = String(rawPath || "");
|
|
106
|
+
let pathname;
|
|
107
|
+
try {
|
|
108
|
+
pathname = new URL(path, "http://relay.local/").pathname;
|
|
109
|
+
} catch {
|
|
110
|
+
return { allowed: false, reason: "invalid-path" };
|
|
111
|
+
}
|
|
112
|
+
if (!pathname.startsWith("/api/")) {
|
|
113
|
+
return { allowed: false, reason: "non-api-path" };
|
|
114
|
+
}
|
|
115
|
+
for (const prefix of RELAY_DENIED_PREFIXES) {
|
|
116
|
+
if (pathname.startsWith(prefix)) {
|
|
117
|
+
return { allowed: false, reason: "denied-prefix" };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (RELAY_DENIED_PATHS.has(pathname)) {
|
|
121
|
+
return { allowed: false, reason: "denied-path" };
|
|
122
|
+
}
|
|
123
|
+
return { allowed: true };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Public API
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build a `dispatch` function suitable for `BridgeRelayClient`. The returned
|
|
132
|
+
* function takes the unwrapped RPC request fields (method, path, headers,
|
|
133
|
+
* body, signal, pairing) and returns `{ status, headers, body, bodyEncoding }`.
|
|
134
|
+
*
|
|
135
|
+
* @param {HttpDispatchOptions} opts
|
|
136
|
+
*/
|
|
137
|
+
export function createHttpDispatch(opts) {
|
|
138
|
+
if (typeof opts?.requestListener !== "function") {
|
|
139
|
+
throw new TypeError("createHttpDispatch: requestListener function required");
|
|
140
|
+
}
|
|
141
|
+
const listener = opts.requestListener;
|
|
142
|
+
const addrPrefix = opts.remoteAddressPrefix ?? "remote-pair:";
|
|
143
|
+
const responseTimeoutMs = Math.max(0, Number(opts.responseTimeoutMs ?? 0) || 0);
|
|
144
|
+
const log = opts.logger ?? {};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {{
|
|
148
|
+
* method: string,
|
|
149
|
+
* path: string,
|
|
150
|
+
* headers: Record<string,string>,
|
|
151
|
+
* body?: string,
|
|
152
|
+
* bodyEncoding?: "utf8" | "base64",
|
|
153
|
+
* signal: AbortSignal,
|
|
154
|
+
* pairing: import("./pairings.mjs").Pairing,
|
|
155
|
+
* channelBinding: Uint8Array,
|
|
156
|
+
* }} rpcReq
|
|
157
|
+
*/
|
|
158
|
+
return async function httpDispatch(rpcReq) {
|
|
159
|
+
// Defense-in-depth path gate — see RELAY_DENIED_* above.
|
|
160
|
+
const gate = classifyRelayPath(rpcReq.path);
|
|
161
|
+
if (!gate.allowed) {
|
|
162
|
+
log.warn?.(
|
|
163
|
+
`[http-dispatch] denied relay request ` +
|
|
164
|
+
`(${gate.reason}) ${String(rpcReq.method || "").toUpperCase()} ${redactPathForLog(rpcReq.path)}`,
|
|
165
|
+
);
|
|
166
|
+
return {
|
|
167
|
+
status: 403,
|
|
168
|
+
headers: { "content-type": "application/json" },
|
|
169
|
+
body: JSON.stringify({ error: "path-not-allowed-via-relay" }),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const bodyBuf = decodeBody(rpcReq.body, rpcReq.bodyEncoding);
|
|
174
|
+
const remoteAddress = `${addrPrefix}${rpcReq.pairing.phoneFingerprint}`;
|
|
175
|
+
|
|
176
|
+
const req = buildSyntheticRequest({
|
|
177
|
+
method: rpcReq.method,
|
|
178
|
+
url: rpcReq.path,
|
|
179
|
+
headers: rpcReq.headers ?? {},
|
|
180
|
+
body: bodyBuf,
|
|
181
|
+
remoteAddress,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Attach relay context so the bridge's auth gates can recognize this
|
|
185
|
+
// as a trusted off-LAN call. The Noise channel binding + pairing
|
|
186
|
+
// identity is the auth here:
|
|
187
|
+
// - HttpOnly session cookies don't ride the relay (the PWA's JS
|
|
188
|
+
// can't read them, the RPC layer doesn't carry them), so cookie
|
|
189
|
+
// auth alone would 401 every relay request.
|
|
190
|
+
// - There's no browser-side CSRF surface — an attacker can't
|
|
191
|
+
// establish a Noise channel without the pairing's static key,
|
|
192
|
+
// so Origin / Referer checks have nothing to add.
|
|
193
|
+
// Bridge code reads `req.viveworker.fromRelay` to take the relay
|
|
194
|
+
// path through readSession() / requireTrustedMutationOrigin().
|
|
195
|
+
req.viveworker = {
|
|
196
|
+
fromRelay: true,
|
|
197
|
+
pairing: rpcReq.pairing,
|
|
198
|
+
channelBinding: rpcReq.channelBinding,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const res = new SyntheticResponse(req);
|
|
202
|
+
|
|
203
|
+
// If the caller cancels, surface it to the handler as a destroyed
|
|
204
|
+
// request — most Node-style handlers stop processing on `req.destroy()`.
|
|
205
|
+
const onAbort = () => {
|
|
206
|
+
// Pass no error to destroy() — listeners that care can check
|
|
207
|
+
// req.destroyed; we don't want a stray "error" event for what's
|
|
208
|
+
// really a cooperative cancel.
|
|
209
|
+
try { req.destroy(); } catch {}
|
|
210
|
+
try { res._abort(); } catch {}
|
|
211
|
+
};
|
|
212
|
+
if (rpcReq.signal?.aborted) onAbort();
|
|
213
|
+
else rpcReq.signal?.addEventListener("abort", onAbort, { once: true });
|
|
214
|
+
|
|
215
|
+
// Invoke the listener. Let it throw — the BridgeRelayClient's catch
|
|
216
|
+
// block already converts dispatcher errors into 500s. We do NOT swallow
|
|
217
|
+
// the rejection here.
|
|
218
|
+
let listenerResult;
|
|
219
|
+
try {
|
|
220
|
+
listenerResult = listener(req, res);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
// Synchronous throw before the listener could write anything.
|
|
223
|
+
throw err;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Wait for the listener's response. Two completion paths:
|
|
227
|
+
// - res.end() runs (normal case) → donePromise resolves
|
|
228
|
+
// - listener's own promise rejects → propagate the rejection
|
|
229
|
+
// A listener that returns synchronously hasn't necessarily called
|
|
230
|
+
// res.end() yet (the body may arrive via async req.on('data') events),
|
|
231
|
+
// so we MUST wait on donePromise rather than the listener's return.
|
|
232
|
+
//
|
|
233
|
+
// To detect a misbehaving listener that returns without ever calling
|
|
234
|
+
// res.end(), the caller can pass `responseTimeoutMs`. Without it, we
|
|
235
|
+
// wait indefinitely; the phone-side AbortSignal is the real backstop.
|
|
236
|
+
const listenerPromise = Promise.resolve(listenerResult).catch((err) => {
|
|
237
|
+
// Capture and rethrow once we know the response state. We can't
|
|
238
|
+
// throw from here because that would race with the await below.
|
|
239
|
+
res._listenerError = err;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const racers = [res.donePromise, listenerPromise.then(() => {
|
|
244
|
+
// After the listener settles, give it one microtask to flush any
|
|
245
|
+
// synchronous res.end() inside callbacks, then yield. If donePromise
|
|
246
|
+
// wins by then, great. If not, we fall through to the next check.
|
|
247
|
+
})];
|
|
248
|
+
if (responseTimeoutMs > 0) {
|
|
249
|
+
racers.push(new Promise((resolve) => setTimeout(resolve, responseTimeoutMs)).then(() => {
|
|
250
|
+
res._timedOut = true;
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
// First settle.
|
|
254
|
+
await Promise.race(racers);
|
|
255
|
+
|
|
256
|
+
// If the listener errored and nothing was written, propagate.
|
|
257
|
+
if (res._listenerError && !res._ended) {
|
|
258
|
+
throw res._listenerError;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// If the listener returned a value (sync or async) and we didn't
|
|
262
|
+
// get a res.end yet, wait for donePromise — the listener may still
|
|
263
|
+
// be running async work.
|
|
264
|
+
if (!res._ended && !res._timedOut) {
|
|
265
|
+
await res.donePromise;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!res._ended && res._timedOut) {
|
|
269
|
+
log.warn?.(`[http-dispatch] listener did not respond within ${responseTimeoutMs}ms (path=${rpcReq.path})`);
|
|
270
|
+
return {
|
|
271
|
+
status: 504,
|
|
272
|
+
headers: { "content-type": "application/json" },
|
|
273
|
+
body: JSON.stringify({ error: "bridge-listener-timeout" }),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// If the listener errored AFTER writing a partial response, prefer
|
|
278
|
+
// the response (the listener wrote something deliberately); if the
|
|
279
|
+
// error happened before any write, propagate.
|
|
280
|
+
if (res._listenerError && !res._headersSent && !res._chunks.length && !res._ended) {
|
|
281
|
+
throw res._listenerError;
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
rpcReq.signal?.removeEventListener?.("abort", onAbort);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return res._toRpcResponse();
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function redactPathForLog(rawPath) {
|
|
292
|
+
const value = String(rawPath || "");
|
|
293
|
+
try {
|
|
294
|
+
const parsed = new URL(value, "http://relay.local/");
|
|
295
|
+
const segments = parsed.pathname.split("/").filter(Boolean).slice(0, 3);
|
|
296
|
+
return `/${segments.join("/")}`;
|
|
297
|
+
} catch {
|
|
298
|
+
return value.split("?")[0].slice(0, 64);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Internals — synthetic IncomingMessage / ServerResponse
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Build a Readable that quacks like http.IncomingMessage. We use Readable
|
|
308
|
+
* directly (instead of subclassing IncomingMessage) because IncomingMessage
|
|
309
|
+
* insists on a real Socket. The handler only reads:
|
|
310
|
+
* .method .url .headers .socket?.remoteAddress
|
|
311
|
+
* .on('data', ...), .on('end', ...), .on('error', ...)
|
|
312
|
+
* .destroy()
|
|
313
|
+
* which are all available on a Readable + a few attached properties.
|
|
314
|
+
*/
|
|
315
|
+
function buildSyntheticRequest({ method, url, headers, body, remoteAddress }) {
|
|
316
|
+
// One-shot Readable that yields the buffered body then ends.
|
|
317
|
+
const stream = Readable.from(body.length > 0 ? [body] : [], { objectMode: false });
|
|
318
|
+
stream.method = method;
|
|
319
|
+
stream.url = url;
|
|
320
|
+
// Lowercase by convention — matches what real http.IncomingMessage does.
|
|
321
|
+
// Our RPC encoder already lowercases, but we don't want to depend on that
|
|
322
|
+
// from inside the dispatch path.
|
|
323
|
+
const lower = {};
|
|
324
|
+
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
|
|
325
|
+
stream.headers = lower;
|
|
326
|
+
// rawHeaders pairs [k1, v1, k2, v2, …] — some libraries inspect it. We
|
|
327
|
+
// produce it from the lowercased map for consistency.
|
|
328
|
+
const rawHeaders = [];
|
|
329
|
+
for (const [k, v] of Object.entries(lower)) rawHeaders.push(k, v);
|
|
330
|
+
stream.rawHeaders = rawHeaders;
|
|
331
|
+
stream.httpVersion = "1.1";
|
|
332
|
+
stream.httpVersionMajor = 1;
|
|
333
|
+
stream.httpVersionMinor = 1;
|
|
334
|
+
stream.complete = false;
|
|
335
|
+
stream.socket = {
|
|
336
|
+
remoteAddress,
|
|
337
|
+
remoteFamily: "IPv4",
|
|
338
|
+
remotePort: 0,
|
|
339
|
+
localAddress: "127.0.0.1",
|
|
340
|
+
localPort: 0,
|
|
341
|
+
encrypted: true, // Noise channel is encrypted; some auth code checks this
|
|
342
|
+
destroy: () => {},
|
|
343
|
+
};
|
|
344
|
+
// Some handlers use this for keep-alive heuristics; null is harmless.
|
|
345
|
+
stream.connection = stream.socket;
|
|
346
|
+
|
|
347
|
+
// When the stream ends naturally, mark complete so any code that polls
|
|
348
|
+
// for it sees the right state.
|
|
349
|
+
stream.once("end", () => { stream.complete = true; });
|
|
350
|
+
|
|
351
|
+
// Default no-op `error` handler. Real http.IncomingMessage is paired with
|
|
352
|
+
// an HTTP server that catches its errors; ours stands alone, and a
|
|
353
|
+
// listener-less stream that destroy(err)'s would surface as an uncaught
|
|
354
|
+
// exception. We don't want abort-on-cancel to crash the process.
|
|
355
|
+
stream.on("error", () => {});
|
|
356
|
+
|
|
357
|
+
return stream;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Synthetic ServerResponse. Captures statusCode + headers + body, exposes
|
|
362
|
+
* a `donePromise` that resolves when the listener calls `res.end()`.
|
|
363
|
+
*/
|
|
364
|
+
class SyntheticResponse {
|
|
365
|
+
constructor(req) {
|
|
366
|
+
this._req = req;
|
|
367
|
+
this.statusCode = 200;
|
|
368
|
+
this.statusMessage = "";
|
|
369
|
+
/** @type {Record<string,string>} */
|
|
370
|
+
this._headers = {};
|
|
371
|
+
/** @type {Buffer[]} */
|
|
372
|
+
this._chunks = [];
|
|
373
|
+
this._ended = false;
|
|
374
|
+
this._aborted = false;
|
|
375
|
+
this._headersSent = false;
|
|
376
|
+
|
|
377
|
+
this.donePromise = new Promise((resolve) => {
|
|
378
|
+
this._resolveDone = resolve;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ----- Header API -----
|
|
383
|
+
|
|
384
|
+
setHeader(name, value) {
|
|
385
|
+
if (this._ended) return;
|
|
386
|
+
if (Array.isArray(value)) value = value.join(", ");
|
|
387
|
+
this._headers[String(name).toLowerCase()] = String(value);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
getHeader(name) {
|
|
391
|
+
return this._headers[String(name).toLowerCase()];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
hasHeader(name) {
|
|
395
|
+
return Object.prototype.hasOwnProperty.call(this._headers, String(name).toLowerCase());
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
removeHeader(name) {
|
|
399
|
+
delete this._headers[String(name).toLowerCase()];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
getHeaders() {
|
|
403
|
+
return { ...this._headers };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
writeHead(statusCode, statusMessageOrHeaders, headersMaybe) {
|
|
407
|
+
if (this._ended) return this;
|
|
408
|
+
this.statusCode = statusCode;
|
|
409
|
+
let headers = null;
|
|
410
|
+
if (typeof statusMessageOrHeaders === "string") {
|
|
411
|
+
this.statusMessage = statusMessageOrHeaders;
|
|
412
|
+
headers = headersMaybe ?? null;
|
|
413
|
+
} else {
|
|
414
|
+
headers = statusMessageOrHeaders ?? null;
|
|
415
|
+
}
|
|
416
|
+
if (headers && typeof headers === "object") {
|
|
417
|
+
for (const [k, v] of Object.entries(headers)) this.setHeader(k, v);
|
|
418
|
+
}
|
|
419
|
+
this._headersSent = true;
|
|
420
|
+
return this;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
flushHeaders() {
|
|
424
|
+
this._headersSent = true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ----- Body API -----
|
|
428
|
+
|
|
429
|
+
write(chunk, encoding) {
|
|
430
|
+
if (this._ended) return false;
|
|
431
|
+
this._chunks.push(toBuffer(chunk, encoding));
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
end(chunk, encoding) {
|
|
436
|
+
if (this._ended) return this;
|
|
437
|
+
if (chunk != null) this._chunks.push(toBuffer(chunk, encoding));
|
|
438
|
+
this._ended = true;
|
|
439
|
+
this._headersSent = true;
|
|
440
|
+
this._resolveDone();
|
|
441
|
+
return this;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ----- Adapter glue -----
|
|
445
|
+
|
|
446
|
+
_abort() {
|
|
447
|
+
// The peer cancelled before the listener wrote res.end(). Mark ended so
|
|
448
|
+
// the wrapper stops waiting; the BridgeRelayClient won't send a
|
|
449
|
+
// response anyway because the AbortSignal already fired.
|
|
450
|
+
if (this._ended) return;
|
|
451
|
+
this._aborted = true;
|
|
452
|
+
this._ended = true;
|
|
453
|
+
this._resolveDone();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
_toRpcResponse() {
|
|
457
|
+
const buf = Buffer.concat(this._chunks);
|
|
458
|
+
const status = this.statusCode;
|
|
459
|
+
// We always lowercase header keys (matches RPC convention; the
|
|
460
|
+
// RemotePairingTransport doesn't care, the phone-side adapter prefers
|
|
461
|
+
// it).
|
|
462
|
+
const headers = { ...this._headers };
|
|
463
|
+
|
|
464
|
+
// Pick body encoding: utf8 if the bytes are valid UTF-8 AND the
|
|
465
|
+
// content-type isn't obviously binary; otherwise base64. This keeps
|
|
466
|
+
// the wire compact for JSON/HTML responses and round-trips binaries
|
|
467
|
+
// (PDFs, images) safely.
|
|
468
|
+
if (buf.length === 0) {
|
|
469
|
+
return { status, headers };
|
|
470
|
+
}
|
|
471
|
+
const ct = (headers["content-type"] ?? "").toLowerCase();
|
|
472
|
+
const isBinary = isBinaryContentType(ct) || !isValidUtf8(buf);
|
|
473
|
+
if (isBinary) {
|
|
474
|
+
return {
|
|
475
|
+
status,
|
|
476
|
+
headers,
|
|
477
|
+
body: buf.toString("base64"),
|
|
478
|
+
bodyEncoding: "base64",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
return { status, headers, body: buf.toString("utf8"), bodyEncoding: "utf8" };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
// Helpers
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
function decodeBody(body, encoding) {
|
|
490
|
+
if (body == null || body === "") return Buffer.alloc(0);
|
|
491
|
+
if (typeof body !== "string") {
|
|
492
|
+
throw new TypeError("rpc body must be a string");
|
|
493
|
+
}
|
|
494
|
+
if (encoding === "base64") return Buffer.from(body, "base64");
|
|
495
|
+
// Default utf8 — the RPC layer already validates this.
|
|
496
|
+
return Buffer.from(body, "utf8");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function toBuffer(chunk, encoding) {
|
|
500
|
+
if (chunk == null) return Buffer.alloc(0);
|
|
501
|
+
if (Buffer.isBuffer(chunk)) return chunk;
|
|
502
|
+
if (chunk instanceof Uint8Array) return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
503
|
+
if (typeof chunk === "string") return Buffer.from(chunk, encoding ?? "utf8");
|
|
504
|
+
throw new TypeError(`unsupported response chunk type: ${typeof chunk}`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function isBinaryContentType(ct) {
|
|
508
|
+
if (!ct) return false;
|
|
509
|
+
// Anything explicitly text/* or json or xml is text. application/octet-stream,
|
|
510
|
+
// image/*, application/pdf, etc. are binary.
|
|
511
|
+
if (ct.startsWith("text/")) return false;
|
|
512
|
+
if (ct.includes("json") || ct.includes("xml") || ct.includes("javascript") ||
|
|
513
|
+
ct.includes("urlencoded") || ct.includes("html")) {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Lightweight UTF-8 validation. Buffer doesn't expose a "did this round-trip"
|
|
521
|
+
* helper, so we use TextDecoder in fatal mode and catch the error.
|
|
522
|
+
*/
|
|
523
|
+
function isValidUtf8(buf) {
|
|
524
|
+
try {
|
|
525
|
+
new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
526
|
+
return true;
|
|
527
|
+
} catch {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* keys-core.mjs — Identity key helpers that run in both Node and the browser.
|
|
3
|
+
*
|
|
4
|
+
* Sibling of `keys.mjs` (Node-only persistence). Anything that touches
|
|
5
|
+
* `node:fs`, `node:os`, or `node:path` belongs in keys.mjs; anything pure
|
|
6
|
+
* (encoding, fingerprinting, key derivation) belongs here so the same
|
|
7
|
+
* code path serves the PWA bundle and the bridge.
|
|
8
|
+
*
|
|
9
|
+
* Layered key model (recap — see keys.mjs comment for the full table):
|
|
10
|
+
*
|
|
11
|
+
* Wallet (secp256k1) — wallet UI [other module]
|
|
12
|
+
* Identity (X25519 long-term) — Noise IK static `s` [this file + keys.mjs]
|
|
13
|
+
* Session (X25519 ephemeral) — derived in Noise [noise.mjs]
|
|
14
|
+
*
|
|
15
|
+
* The browser implementation (`web/remote-pairing/keys.js`) layers
|
|
16
|
+
* IndexedDB / non-extractable CryptoKey storage *over* this module.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { x25519 } from "@noble/curves/ed25519";
|
|
20
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export const IDENTITY_KEY_BYTES = 32;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Encoding helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const HEX_RE = /^[0-9a-fA-F]+$/;
|
|
33
|
+
|
|
34
|
+
export function bytesToHex(bytes) {
|
|
35
|
+
if (!(bytes instanceof Uint8Array)) {
|
|
36
|
+
throw new TypeError("bytesToHex expects Uint8Array");
|
|
37
|
+
}
|
|
38
|
+
let hex = "";
|
|
39
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
40
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
41
|
+
}
|
|
42
|
+
return hex;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function hexToBytes(hex) {
|
|
46
|
+
if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0 || !HEX_RE.test(hex)) {
|
|
47
|
+
throw new TypeError(`invalid hex: ${typeof hex === "string" ? hex.slice(0, 16) : typeof hex}…`);
|
|
48
|
+
}
|
|
49
|
+
const out = new Uint8Array(hex.length / 2);
|
|
50
|
+
for (let i = 0; i < out.length; i++) {
|
|
51
|
+
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Generation
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Generate a fresh X25519 identity keypair. Caller owns persistence.
|
|
62
|
+
*/
|
|
63
|
+
export function generateIdentityKeypair() {
|
|
64
|
+
const priv = x25519.utils.randomPrivateKey();
|
|
65
|
+
const pub = x25519.getPublicKey(priv);
|
|
66
|
+
return { priv, pub };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Derive the public key from a stored private key. Microseconds — fine to
|
|
71
|
+
* call on every load instead of persisting `pub` separately.
|
|
72
|
+
*/
|
|
73
|
+
export function publicFromPrivate(priv) {
|
|
74
|
+
return x25519.getPublicKey(priv);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Fingerprint — short user-visible identifier for the pub key
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 12-character base32-ish fingerprint of the public key, formatted as
|
|
83
|
+
* `XXXX-XXXX-XXXX`. Designed for the pairing screen so the human can
|
|
84
|
+
* eyeball-confirm both sides see the same key (defends against MitM
|
|
85
|
+
* during pairing-code flows).
|
|
86
|
+
*
|
|
87
|
+
* Encoding: SHA-256(pub) → first 60 bits → 12 chars from a Crockford-like
|
|
88
|
+
* alphabet (no easily-confused glyphs).
|
|
89
|
+
*/
|
|
90
|
+
export function fingerprintIdentity(pub) {
|
|
91
|
+
if (!(pub instanceof Uint8Array)) throw new TypeError("pub must be Uint8Array");
|
|
92
|
+
if (pub.length !== IDENTITY_KEY_BYTES) throw new RangeError("pub must be 32 bytes");
|
|
93
|
+
|
|
94
|
+
const digest = sha256(pub);
|
|
95
|
+
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford-ish (no I/L/O/U)
|
|
96
|
+
|
|
97
|
+
let acc = 0n;
|
|
98
|
+
for (let i = 0; i < 8; i++) acc = (acc << 8n) | BigInt(digest[i]);
|
|
99
|
+
// Take the high 60 bits → 12 base32 chars
|
|
100
|
+
acc >>= 4n;
|
|
101
|
+
|
|
102
|
+
let out = "";
|
|
103
|
+
for (let i = 0; i < 12; i++) {
|
|
104
|
+
const idx = Number(acc & 31n);
|
|
105
|
+
out = alphabet[idx] + out;
|
|
106
|
+
acc >>= 5n;
|
|
107
|
+
}
|
|
108
|
+
// Insert a dash every 4 chars for legibility: XXXX-XXXX-XXXX
|
|
109
|
+
return `${out.slice(0, 4)}-${out.slice(4, 8)}-${out.slice(8, 12)}`;
|
|
110
|
+
}
|