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,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
|
+
};
|