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,181 @@
1
+ /**
2
+ * keys.mjs — Identity key persistence for the PC bridge (Node-side).
3
+ *
4
+ * Pure helpers (encoding, fingerprint, key derivation) live in keys-core.mjs
5
+ * so the browser bundle can use them without dragging in `node:fs`. This
6
+ * file adds the file-backed `loadIdentityKeypair` / `saveIdentityKeypair`
7
+ * / `ensureIdentityKeypair` trio the bridge uses.
8
+ *
9
+ * Layered key model (see review notes — three layers, intentionally separated):
10
+ *
11
+ * ┌────────────────────────┬──────────────────────┬─────────────────────────┐
12
+ * │ Wallet │ Identity (this file) │ Session │
13
+ * ├────────────────────────┼──────────────────────┼─────────────────────────┤
14
+ * │ secp256k1 (existing) │ X25519 long-term │ X25519 ephemeral → AEAD │
15
+ * │ EVM signing / USDC │ Device-pair auth │ Per-connection forward │
16
+ * │ │ (Noise IK static s) │ secrecy (CipherState) │
17
+ * │ hazBase / wallet UI │ Generated here │ Derived during Noise │
18
+ * │ │ │ handshake; never stored │
19
+ * └────────────────────────┴──────────────────────┴─────────────────────────┘
20
+ *
21
+ * Storage model (Phase 0 — minimum viable):
22
+ * - PC bridge: ~/.viveworker/remote-pairing.env
23
+ * - REMOTE_PAIRING_IDENTITY_PRIV: hex-encoded 32-byte X25519 private key
24
+ * - REMOTE_PAIRING_IDENTITY_PUB: hex-encoded 32-byte X25519 public key
25
+ * File mode 0o600. macOS Keychain / Secure Enclave integration is a
26
+ * follow-up (tracked separately) — for now we lean on filesystem perms.
27
+ * - Phone PWA: see web/remote-pairing/keys.js (IndexedDB-backed). The PWA
28
+ * side reuses keys-core.mjs for the pure parts (fingerprint, hex, etc).
29
+ *
30
+ * Encoding choice: hex (lowercase, no separators). Matches the existing
31
+ * a2a.env style of secrets so users see one consistent format.
32
+ */
33
+
34
+ import { promises as fs } from "node:fs";
35
+ import os from "node:os";
36
+ import path from "node:path";
37
+
38
+ import {
39
+ IDENTITY_KEY_BYTES,
40
+ bytesToHex,
41
+ hexToBytes,
42
+ generateIdentityKeypair,
43
+ publicFromPrivate,
44
+ fingerprintIdentity,
45
+ } from "./keys-core.mjs";
46
+
47
+ import { upsertEnvText } from "../pairing.mjs";
48
+
49
+ // Re-export the pure helpers so existing callers (and tests) that import
50
+ // from `keys.mjs` keep working without churn.
51
+ export {
52
+ IDENTITY_KEY_BYTES,
53
+ bytesToHex,
54
+ hexToBytes,
55
+ generateIdentityKeypair,
56
+ publicFromPrivate,
57
+ fingerprintIdentity,
58
+ };
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Persistence (Node-only)
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export const REMOTE_PAIRING_ENV_FILE = path.join(
65
+ os.homedir(),
66
+ ".viveworker",
67
+ "remote-pairing.env",
68
+ );
69
+
70
+ const ENV_KEY_PRIV = "REMOTE_PAIRING_IDENTITY_PRIV";
71
+ const ENV_KEY_PUB = "REMOTE_PAIRING_IDENTITY_PUB";
72
+
73
+ /**
74
+ * Load the bridge's identity keypair from disk. Returns null if the file
75
+ * doesn't exist or doesn't contain the expected keys (caller decides whether
76
+ * to generate a fresh one).
77
+ *
78
+ * @param {string} [envPath]
79
+ * @returns {Promise<{priv: Uint8Array, pub: Uint8Array} | null>}
80
+ */
81
+ export async function loadIdentityKeypair(envPath = REMOTE_PAIRING_ENV_FILE) {
82
+ let text;
83
+ try {
84
+ text = await fs.readFile(envPath, "utf8");
85
+ } catch (err) {
86
+ if (err.code === "ENOENT") return null;
87
+ throw err;
88
+ }
89
+
90
+ const map = parseEnv(text);
91
+ const privHex = map.get(ENV_KEY_PRIV);
92
+ if (!privHex) return null;
93
+
94
+ let priv;
95
+ try {
96
+ priv = hexToBytes(privHex);
97
+ } catch {
98
+ return null;
99
+ }
100
+ if (priv.length !== IDENTITY_KEY_BYTES) return null;
101
+
102
+ const pub = publicFromPrivate(priv);
103
+ // If a stored pub disagrees, prefer the derived one but warn.
104
+ const pubHex = map.get(ENV_KEY_PUB);
105
+ if (pubHex) {
106
+ try {
107
+ const stored = hexToBytes(pubHex);
108
+ if (stored.length === pub.length) {
109
+ let differs = false;
110
+ for (let i = 0; i < pub.length; i++) {
111
+ if (stored[i] !== pub[i]) { differs = true; break; }
112
+ }
113
+ if (differs) {
114
+ console.warn(`[remote-pairing/keys] ${ENV_KEY_PUB} disagrees with derived pub; trusting private key`);
115
+ }
116
+ }
117
+ } catch {
118
+ // ignore garbage pub line
119
+ }
120
+ }
121
+
122
+ return { priv, pub };
123
+ }
124
+
125
+ /**
126
+ * Persist an identity keypair to disk with mode 0o600 / dir mode 0o700.
127
+ * Existing file content is preserved (other keys in the env survive).
128
+ */
129
+ export async function saveIdentityKeypair(keypair, envPath = REMOTE_PAIRING_ENV_FILE) {
130
+ if (!keypair?.priv || !keypair?.pub) throw new TypeError("keypair {priv, pub} required");
131
+ if (keypair.priv.length !== IDENTITY_KEY_BYTES) throw new RangeError("priv must be 32 bytes");
132
+ if (keypair.pub.length !== IDENTITY_KEY_BYTES) throw new RangeError("pub must be 32 bytes");
133
+
134
+ const dir = path.dirname(envPath);
135
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
136
+
137
+ let current = "";
138
+ try {
139
+ current = await fs.readFile(envPath, "utf8");
140
+ } catch (err) {
141
+ if (err.code !== "ENOENT") throw err;
142
+ }
143
+
144
+ const updated = upsertEnvText(current, {
145
+ [ENV_KEY_PRIV]: bytesToHex(keypair.priv),
146
+ [ENV_KEY_PUB]: bytesToHex(keypair.pub),
147
+ });
148
+
149
+ await fs.writeFile(envPath, updated, { mode: 0o600 });
150
+ }
151
+
152
+ /**
153
+ * Load existing keypair, generating + persisting a new one if absent. The
154
+ * "ensure" pattern matches what we already do for A2A_RELAY_SECRET in
155
+ * a2a-relay-client.mjs.
156
+ */
157
+ export async function ensureIdentityKeypair(envPath = REMOTE_PAIRING_ENV_FILE) {
158
+ const existing = await loadIdentityKeypair(envPath);
159
+ if (existing) return existing;
160
+ const fresh = generateIdentityKeypair();
161
+ await saveIdentityKeypair(fresh, envPath);
162
+ return fresh;
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // Internal: env file parser (independent from upsertEnvText which is write-only)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ function parseEnv(text) {
170
+ const map = new Map();
171
+ for (const rawLine of String(text || "").split(/\r?\n/u)) {
172
+ const line = rawLine.trim();
173
+ if (!line || line.startsWith("#")) continue;
174
+ const eq = line.indexOf("=");
175
+ if (eq <= 0) continue;
176
+ const key = line.slice(0, eq).trim();
177
+ const value = line.slice(eq + 1).trim();
178
+ if (key) map.set(key, value);
179
+ }
180
+ return map;
181
+ }
@@ -0,0 +1,436 @@
1
+ /**
2
+ * noise.mjs — Noise_IK_25519_ChaChaPoly_SHA256 implementation for viveworker
3
+ * remote pairing.
4
+ *
5
+ * Why IK:
6
+ * - Initiator (phone) knows responder's (PC bridge's) static public key
7
+ * a priori, established during LAN-bootstrap pairing. This lets us do a
8
+ * 1-RTT handshake with mutual authentication (both sides' static keys
9
+ * are bound into the transcript).
10
+ * - The relay (CF Worker + Durable Object) carries Noise messages opaquely;
11
+ * it cannot decrypt anything, only route by an outer envelope's `pairingId`.
12
+ *
13
+ * Wire layout (this module's responsibility ends at "Noise transport message"):
14
+ *
15
+ * WSS frame body
16
+ * └─ outer envelope (relay sees this; defined elsewhere)
17
+ * ├─ seq, mid, type, ... ← routing-only metadata
18
+ * └─ payload: bytes ← Noise transport message (this module)
19
+ * └─ ciphertext + 16-byte Poly1305 tag
20
+ *
21
+ * Handshake:
22
+ * pre-message: <- s (responder's static public key, OOB at pairing)
23
+ * message 1 (i→r): -> e, es, s, ss (initiator sends ephemeral, encrypts its static)
24
+ * message 2 (r→i): <- e, ee, se (responder sends ephemeral, completes auth)
25
+ * then Split() yields cs1 (i→r) and cs2 (r→i) for transport.
26
+ *
27
+ * Reference: https://noiseprotocol.org/noise.html (revision 34)
28
+ */
29
+
30
+ import { x25519 } from "@noble/curves/ed25519";
31
+ import { chacha20poly1305 } from "@noble/ciphers/chacha";
32
+ import { sha256 } from "@noble/hashes/sha256";
33
+ import { hkdf } from "@noble/hashes/hkdf";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Constants
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export const PROTOCOL_NAME = "Noise_IK_25519_ChaChaPoly_SHA256";
40
+ export const HASHLEN = 32;
41
+ export const KEYLEN = 32;
42
+ export const DHLEN = 32;
43
+ export const TAGLEN = 16;
44
+ export const NONCE_MAX = 0xffff_ffff_ffff_ffffn; // 2^64 - 1; reserved as "rekey" in Noise
45
+
46
+ const PROTOCOL_NAME_BYTES = new TextEncoder().encode(PROTOCOL_NAME);
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Byte helpers (Uint8Array everywhere — no Buffer leakage so PWA can share)
50
+ // ---------------------------------------------------------------------------
51
+
52
+ function concat(...arrays) {
53
+ let total = 0;
54
+ for (const a of arrays) total += a.length;
55
+ const out = new Uint8Array(total);
56
+ let offset = 0;
57
+ for (const a of arrays) {
58
+ out.set(a, offset);
59
+ offset += a.length;
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function asBytes(input) {
65
+ if (input == null) return new Uint8Array(0);
66
+ if (input instanceof Uint8Array) return input;
67
+ if (typeof input === "string") return new TextEncoder().encode(input);
68
+ if (Array.isArray(input)) return new Uint8Array(input);
69
+ throw new TypeError(`unsupported input type: ${typeof input}`);
70
+ }
71
+
72
+ function timingSafeEqual(a, b) {
73
+ if (a.length !== b.length) return false;
74
+ let diff = 0;
75
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
76
+ return diff === 0;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Noise primitives
81
+ // ---------------------------------------------------------------------------
82
+
83
+ function noiseHash(data) {
84
+ return sha256(data);
85
+ }
86
+
87
+ /** HKDF with `n_outputs` 32-byte outputs, per Noise spec §4.3. */
88
+ function noiseHkdf(ck, ikm, nOutputs) {
89
+ const length = nOutputs * HASHLEN;
90
+ const out = hkdf(sha256, ikm, ck, new Uint8Array(0), length);
91
+ const slices = [];
92
+ for (let i = 0; i < nOutputs; i++) {
93
+ slices.push(out.slice(i * HASHLEN, (i + 1) * HASHLEN));
94
+ }
95
+ return slices;
96
+ }
97
+
98
+ function generateKeypair() {
99
+ const priv = x25519.utils.randomPrivateKey();
100
+ const pub = x25519.getPublicKey(priv);
101
+ return { priv, pub };
102
+ }
103
+
104
+ function dh(priv, pub) {
105
+ return x25519.getSharedSecret(priv, pub);
106
+ }
107
+
108
+ /**
109
+ * Format the AEAD nonce as 4 zero bytes (big-endian) followed by an 8-byte
110
+ * little-endian counter. This matches the Noise spec §5.1.
111
+ */
112
+ function formatNonce(counter) {
113
+ const out = new Uint8Array(12);
114
+ let v = BigInt(counter);
115
+ for (let i = 4; i < 12; i++) {
116
+ out[i] = Number(v & 0xffn);
117
+ v >>= 8n;
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function aeadEncrypt(key, counter, ad, plaintext) {
123
+ const cipher = chacha20poly1305(key, formatNonce(counter), asBytes(ad));
124
+ return cipher.encrypt(asBytes(plaintext));
125
+ }
126
+
127
+ function aeadDecrypt(key, counter, ad, ciphertext) {
128
+ const cipher = chacha20poly1305(key, formatNonce(counter), asBytes(ad));
129
+ return cipher.decrypt(asBytes(ciphertext));
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // CipherState
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * A single-direction symmetric cipher with a counter nonce. Used as building
138
+ * block for SymmetricState (during handshake) and standalone for transport
139
+ * messages after Split().
140
+ */
141
+ class CipherState {
142
+ constructor(key = null) {
143
+ /** @type {Uint8Array | null} */
144
+ this.k = key;
145
+ this.n = 0n;
146
+ }
147
+
148
+ hasKey() {
149
+ return this.k != null;
150
+ }
151
+
152
+ setKey(key) {
153
+ this.k = key;
154
+ this.n = 0n;
155
+ }
156
+
157
+ encryptWithAd(ad, plaintext) {
158
+ if (!this.hasKey()) return asBytes(plaintext);
159
+ if (this.n >= NONCE_MAX) throw new Error("nonce exhausted");
160
+ const ct = aeadEncrypt(this.k, this.n, ad, plaintext);
161
+ this.n += 1n;
162
+ return ct;
163
+ }
164
+
165
+ decryptWithAd(ad, ciphertext) {
166
+ if (!this.hasKey()) return asBytes(ciphertext);
167
+ if (this.n >= NONCE_MAX) throw new Error("nonce exhausted");
168
+ const pt = aeadDecrypt(this.k, this.n, ad, ciphertext);
169
+ this.n += 1n;
170
+ return pt;
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // SymmetricState
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Wraps a CipherState plus a chaining key (`ck`) and handshake hash (`h`).
180
+ * Drives MixKey / MixHash / EncryptAndHash / DecryptAndHash.
181
+ */
182
+ class SymmetricState {
183
+ constructor(protocolName) {
184
+ const nameBytes = asBytes(protocolName);
185
+ if (nameBytes.length <= HASHLEN) {
186
+ this.h = new Uint8Array(HASHLEN);
187
+ this.h.set(nameBytes, 0);
188
+ } else {
189
+ this.h = noiseHash(nameBytes);
190
+ }
191
+ this.ck = this.h.slice();
192
+ this.cipher = new CipherState();
193
+ }
194
+
195
+ mixKey(ikm) {
196
+ const [ck, tempK] = noiseHkdf(this.ck, ikm, 2);
197
+ this.ck = ck;
198
+ // Per Noise §5.2: truncate temp_k to KEYLEN (32). With SHA256 these are
199
+ // already equal so this is a no-op, but keep the slice for clarity.
200
+ this.cipher.setKey(tempK.slice(0, KEYLEN));
201
+ }
202
+
203
+ mixHash(data) {
204
+ this.h = noiseHash(concat(this.h, asBytes(data)));
205
+ }
206
+
207
+ encryptAndHash(plaintext) {
208
+ const ct = this.cipher.encryptWithAd(this.h, plaintext);
209
+ this.mixHash(ct);
210
+ return ct;
211
+ }
212
+
213
+ decryptAndHash(ciphertext) {
214
+ const pt = this.cipher.decryptWithAd(this.h, ciphertext);
215
+ this.mixHash(ciphertext);
216
+ return pt;
217
+ }
218
+
219
+ /** Final step: produce two CipherStates for transport (initiator→responder, responder→initiator). */
220
+ split() {
221
+ const [k1, k2] = noiseHkdf(this.ck, new Uint8Array(0), 2);
222
+ return [new CipherState(k1.slice(0, KEYLEN)), new CipherState(k2.slice(0, KEYLEN))];
223
+ }
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // HandshakeState — Noise IK
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Drive an IK handshake. Caller wires the `writeMessage` / `readMessage` calls
232
+ * to the underlying transport (WSS, in our production path; stdio pipes in
233
+ * the Phase 0 test harness).
234
+ *
235
+ * @param {object} opts
236
+ * @param {boolean} opts.initiator - True for phone, false for PC bridge.
237
+ * @param {{priv: Uint8Array, pub: Uint8Array}} opts.staticKeypair - Long-term identity keypair.
238
+ * @param {Uint8Array} [opts.remoteStatic] - Required if initiator; remote's identity public key.
239
+ * @param {Uint8Array} [opts.prologue] - Pre-handshake context bytes hashed into both transcripts. Empty by default.
240
+ */
241
+ export class HandshakeState {
242
+ constructor({ initiator, staticKeypair, remoteStatic, prologue = new Uint8Array(0) }) {
243
+ if (typeof initiator !== "boolean") throw new TypeError("initiator must be boolean");
244
+ if (!staticKeypair?.priv || !staticKeypair?.pub) throw new TypeError("staticKeypair required");
245
+ if (initiator && !remoteStatic) throw new Error("initiator requires remoteStatic (responder's public key)");
246
+
247
+ this.initiator = initiator;
248
+ this.s = staticKeypair;
249
+ this.rs = remoteStatic ? asBytes(remoteStatic) : null;
250
+ /** @type {{priv: Uint8Array, pub: Uint8Array} | null} */
251
+ this.e = null;
252
+ /** @type {Uint8Array | null} */
253
+ this.re = null;
254
+
255
+ this.symmetric = new SymmetricState(PROTOCOL_NAME);
256
+ this.symmetric.mixHash(prologue);
257
+
258
+ // Pre-message: <- s (responder's static public key, both sides hash it)
259
+ if (initiator) {
260
+ this.symmetric.mixHash(this.rs);
261
+ } else {
262
+ this.symmetric.mixHash(this.s.pub);
263
+ }
264
+
265
+ /** @type {0|1|2} - 0 = waiting to send/recv message 1, 1 = ready for message 2, 2 = done */
266
+ this.step = 0;
267
+ /** @type {[CipherState, CipherState] | null} */
268
+ this.transportCiphers = null;
269
+ /** @type {Uint8Array | null} - Final h, can be used as channel binding */
270
+ this.handshakeHash = null;
271
+ }
272
+
273
+ /** Write the next handshake message and return the bytes to put on the wire. */
274
+ writeMessage(payload = new Uint8Array(0)) {
275
+ const pt = asBytes(payload);
276
+ if (this.step === 0 && this.initiator) {
277
+ // -> e, es, s, ss
278
+ this.e = generateKeypair();
279
+ const buf = [];
280
+ buf.push(this.e.pub);
281
+ this.symmetric.mixHash(this.e.pub);
282
+ this.symmetric.mixKey(dh(this.e.priv, this.rs)); // es
283
+ buf.push(this.symmetric.encryptAndHash(this.s.pub)); // s (encrypted)
284
+ this.symmetric.mixKey(dh(this.s.priv, this.rs)); // ss
285
+ buf.push(this.symmetric.encryptAndHash(pt)); // payload
286
+ this.step = 1;
287
+ return concat(...buf);
288
+ }
289
+ if (this.step === 1 && !this.initiator) {
290
+ // <- e, ee, se
291
+ // Token names use Noise's "first letter = initiator's key, second = responder's key"
292
+ // convention. So `se` = DH(initiator.s, responder.e). On the responder
293
+ // side we compute that as DH(responder.e.priv, initiator.s.pub).
294
+ this.e = generateKeypair();
295
+ const buf = [];
296
+ buf.push(this.e.pub);
297
+ this.symmetric.mixHash(this.e.pub);
298
+ this.symmetric.mixKey(dh(this.e.priv, this.re)); // ee = DH(responder.e.priv, initiator.e.pub)
299
+ this.symmetric.mixKey(dh(this.e.priv, this.rs)); // se = DH(responder.e.priv, initiator.s.pub)
300
+ buf.push(this.symmetric.encryptAndHash(pt)); // payload
301
+ this._finalize();
302
+ return concat(...buf);
303
+ }
304
+ throw new Error(`writeMessage called in unexpected state (step=${this.step}, initiator=${this.initiator})`);
305
+ }
306
+
307
+ /** Consume a received handshake message and return the decrypted payload. */
308
+ readMessage(bytes) {
309
+ const data = asBytes(bytes);
310
+ let offset = 0;
311
+ if (this.step === 0 && !this.initiator) {
312
+ // -> e, es, s, ss (responder receiving)
313
+ const re = data.slice(offset, offset + DHLEN);
314
+ offset += DHLEN;
315
+ this.re = re;
316
+ this.symmetric.mixHash(re);
317
+ this.symmetric.mixKey(dh(this.s.priv, re)); // es (responder uses its static)
318
+ const sCiphertext = data.slice(offset, offset + DHLEN + TAGLEN);
319
+ offset += DHLEN + TAGLEN;
320
+ const remoteStatic = this.symmetric.decryptAndHash(sCiphertext);
321
+ this.rs = remoteStatic;
322
+ this.symmetric.mixKey(dh(this.s.priv, remoteStatic)); // ss
323
+ const payload = this.symmetric.decryptAndHash(data.slice(offset));
324
+ this.step = 1;
325
+ return payload;
326
+ }
327
+ if (this.step === 1 && this.initiator) {
328
+ // <- e, ee, se (initiator receiving)
329
+ // `se` = DH(initiator.s, responder.e). On the initiator side we have
330
+ // our own static priv plus responder's ephemeral pub from this message.
331
+ const re = data.slice(offset, offset + DHLEN);
332
+ offset += DHLEN;
333
+ this.re = re;
334
+ this.symmetric.mixHash(re);
335
+ this.symmetric.mixKey(dh(this.e.priv, re)); // ee = DH(initiator.e.priv, responder.e.pub)
336
+ this.symmetric.mixKey(dh(this.s.priv, re)); // se = DH(initiator.s.priv, responder.e.pub)
337
+ const payload = this.symmetric.decryptAndHash(data.slice(offset));
338
+ this._finalize();
339
+ return payload;
340
+ }
341
+ throw new Error(`readMessage called in unexpected state (step=${this.step}, initiator=${this.initiator})`);
342
+ }
343
+
344
+ _finalize() {
345
+ this.handshakeHash = this.symmetric.h.slice();
346
+ const [cs1, cs2] = this.symmetric.split();
347
+ // cs1 = initiator → responder, cs2 = responder → initiator
348
+ this.transportCiphers = [cs1, cs2];
349
+ this.step = 2;
350
+ }
351
+
352
+ isHandshakeFinished() {
353
+ return this.step === 2;
354
+ }
355
+
356
+ /** After handshake, return a NoiseSession for transport messages. */
357
+ intoSession() {
358
+ if (!this.isHandshakeFinished()) throw new Error("handshake not finished");
359
+ const [cs1, cs2] = this.transportCiphers;
360
+ return new NoiseSession({
361
+ initiator: this.initiator,
362
+ sendCipher: this.initiator ? cs1 : cs2,
363
+ recvCipher: this.initiator ? cs2 : cs1,
364
+ handshakeHash: this.handshakeHash,
365
+ remoteStatic: this.initiator ? this.rs : this.rs, // for both, populated after handshake
366
+ });
367
+ }
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // NoiseSession — post-handshake transport
372
+ // ---------------------------------------------------------------------------
373
+
374
+ /**
375
+ * Bidirectional encrypted session. Each direction has its own counter.
376
+ *
377
+ * Application-layer ad (associated data) is supported on every send/recv —
378
+ * upper layers (envelope) bind their routing metadata into the AEAD by
379
+ * passing it as `ad` so a tampered envelope can't pair with valid ciphertext.
380
+ */
381
+ export class NoiseSession {
382
+ constructor({ initiator, sendCipher, recvCipher, handshakeHash, remoteStatic }) {
383
+ this.initiator = initiator;
384
+ this.sendCipher = sendCipher;
385
+ this.recvCipher = recvCipher;
386
+ this.handshakeHash = handshakeHash;
387
+ this.remoteStatic = remoteStatic;
388
+ }
389
+
390
+ send(plaintext, ad = new Uint8Array(0)) {
391
+ return this.sendCipher.encryptWithAd(asBytes(ad), plaintext);
392
+ }
393
+
394
+ recv(ciphertext, ad = new Uint8Array(0)) {
395
+ return this.recvCipher.decryptWithAd(asBytes(ad), ciphertext);
396
+ }
397
+
398
+ /** Channel binding token suitable for pinning to higher-level auth (e.g. WebAuthn challenge). */
399
+ getChannelBinding() {
400
+ return this.handshakeHash.slice();
401
+ }
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // Convenience factories
406
+ // ---------------------------------------------------------------------------
407
+
408
+ export function createInitiator({ staticKeypair, remoteStatic, prologue }) {
409
+ return new HandshakeState({ initiator: true, staticKeypair, remoteStatic, prologue });
410
+ }
411
+
412
+ export function createResponder({ staticKeypair, prologue }) {
413
+ return new HandshakeState({ initiator: false, staticKeypair, prologue });
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Identity keypair helpers (low-level — see keys.mjs for the persistence layer)
418
+ // ---------------------------------------------------------------------------
419
+
420
+ export function generateIdentityKeypair() {
421
+ return generateKeypair();
422
+ }
423
+
424
+ // Re-exports for tests / debug tooling
425
+ export const _internals = {
426
+ concat,
427
+ asBytes,
428
+ timingSafeEqual,
429
+ noiseHkdf,
430
+ noiseHash,
431
+ formatNonce,
432
+ aeadEncrypt,
433
+ aeadDecrypt,
434
+ CipherState,
435
+ SymmetricState,
436
+ };