/** * Noise NK initiator for Android / Capacitor. * Pattern: Noise_NK_25519_ChaChaPoly_BLAKE2s * * The Android client is a hand-port of this code and must derive byte-for-byte * the same handshake — interop holds by porting discipline, not by a shared * library. The earlier Rust `snow` host has been removed from this repo, so * there is no cross-implementation pin; `noise-js.test.ts` pins SELF-derived * vectors only (a regression guard against accidental changes on the JS side). * * PCH-01-002: this implementation derives `h` and HKDF over BLAKE2s to match the * named pattern. A stock `snow` stack would interoperate; the Android port must * mirror the same hash choices. Any change to the hash, HKDF, or transcript here * is a BREAKING wire change and requires a coordinated Android release. */ import { generateKeyPair, sharedKey } from "@stablelib/x25519"; import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305"; import { BLAKE2s } from "@stablelib/blake2s"; import { SHA256 } from "@stablelib/sha256"; import { HMAC } from "@stablelib/hmac"; const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s"; export function initHash(): Uint8Array { const nameBytes = new TextEncoder().encode(PROTOCOL_NAME); if (nameBytes.length <= 32) { const h = new Uint8Array(32); h.set(nameBytes); return h; } // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash // MUST use it too (Noise spec §5.2), not SHA-256. return new BLAKE2s(32).update(nameBytes).digest(); } function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array { const blake = new BLAKE2s(32); blake.update(h); blake.update(data); return blake.digest(); } export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] { // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256. // @stablelib's HKDF reuses one HMAC across blocks and BLAKE2s's restoreState() // never clears its `finished` flag, so `new HKDF(BLAKE2s, …)` throws. Run // standard HKDF-Expand (RFC 5869, empty info) with a FRESH HMAC-BLAKE2s per // block — byte-identical to BouncyCastle `HKDFBytesGenerator(Blake2sDigest)` // on peach-android. NOTE: the HMAC-SHA256 post-handshake auth proof is a // SEPARATE construction, intentionally unchanged (so the SHA256 import stays). const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); // Extract (salt=ck) const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest(); const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest(); return [k1, k2]; } export interface NoiseNKSession { encrypt(plaintext: Uint8Array): Uint8Array; decrypt(ciphertext: Uint8Array): Uint8Array; handshakeHash: Uint8Array; } /** * Run the initiator side of the Noise NK handshake. The caller provides * `send` / `receive` for the two transport-side handshake messages and gets * back a session object that encrypts/decrypts subsequent transport frames. */ export async function noiseNKInitiator( responderStaticPublicKey: Uint8Array, send: (msg: Uint8Array) => Promise, receive: () => Promise, ): Promise { let h: Uint8Array = initHash(); let ck: Uint8Array = h.slice(); // NK pre-message: mix responder static public key. h = mixHash(h, responderStaticPublicKey); const ephemeral = generateKeyPair(); // --- Message 1: e, es --- h = mixHash(h, ephemeral.publicKey); // PCH-02-009: reject all-zero DH outputs from low-order peer points before // they enter the Noise key schedule. Honest peers are unaffected. const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey, true); const [ck1, k1] = hkdf2(ck, es); ck = ck1; const cipher1 = new ChaCha20Poly1305(k1); const nonce1 = new Uint8Array(12); const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h); h = mixHash(h, encrypted1); const msg1 = new Uint8Array(32 + encrypted1.length); msg1.set(ephemeral.publicKey, 0); msg1.set(encrypted1, 32); await send(msg1); // --- Message 2: e, ee --- const msg2 = await receive(); if (msg2.length < 48) throw new Error("Noise NK: msg2 too short"); const remoteEphemeralPublic = msg2.slice(0, 32); const encrypted2 = msg2.slice(32); h = mixHash(h, remoteEphemeralPublic); const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true); const [ck2, k2] = hkdf2(ck, ee); ck = ck2; const cipher2 = new ChaCha20Poly1305(k2); const nonce2 = new Uint8Array(12); const decrypted2 = cipher2.open(nonce2, encrypted2, h); if (!decrypted2) { throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM"); } h = mixHash(h, encrypted2); // --- Transport phase --- const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0)); const handshakeHash = h.slice(); let sendNonce = 0n; let recvNonce = 0n; function nonceBytes(n: bigint): Uint8Array { const buf = new Uint8Array(12); new DataView(buf.buffer).setBigUint64(4, n, true); return buf; } const sendCipher = new ChaCha20Poly1305(sendKey); const recvCipher = new ChaCha20Poly1305(recvKey); return { encrypt(plaintext) { return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0)); }, decrypt(ciphertext) { const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0)); if (!result) throw new Error("Noise NK: decryption failed"); return result; }, handshakeHash, }; } /** * Compute the post-handshake auth proof. * * proof = HMAC-SHA256( * key = X25519(local_priv, remote_pub), // 32-byte DH shared * data = "peach-sync-v1-initiator-auth" * || initiator_public * || responder_public * || nonce_32 * ) * * Both sides pass the same DH peer key (their PEER's public key) and the same * initiator_public/responder_public ordering so the MAC matches. This helper * targets the initiator-side: `local_priv` is the initiator's private key, * `remote_pub` is the responder's public key. */ const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth"); export function computeAuthProof( initiatorPrivate: Uint8Array, initiatorPublic: Uint8Array, responderPublic: Uint8Array, nonce: Uint8Array, handshakeHash: Uint8Array, ): Uint8Array { if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes"); if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes"); const dh = sharedKey(initiatorPrivate, responderPublic, true); const mac = new HMAC(SHA256, dh); // PCH-01-003b: bind the proof to THIS Noise session so it cannot be replayed // onto a different handshake. handshakeHash is prepended; both sides hold the // identical value and feed it in the same position. mac.update(handshakeHash); mac.update(AUTH_DOMAIN); mac.update(initiatorPublic); mac.update(responderPublic); mac.update(nonce); return mac.digest(); } interface AuthChallengeFrame { type: "CHALLENGE"; nonce_hex: string; responder_public_key_hex: string; } interface AuthProofFrame { type: "PROOF"; initiator_public_key_hex: string; proof_hex: string; } function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } function hexToBytes(hex: string): Uint8Array { if (hex.length % 2 !== 0) throw new Error("invalid hex"); const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); } return bytes; } /** * Drive the initiator side of the post-handshake challenge/proof round. The * responder sends a `Challenge` JSON frame; we compute the HMAC and reply * with a `Proof` frame. Both frames are encrypted via the post-handshake * Noise transport (caller owns the `sendEncrypted` / `receiveEncrypted` pair). */ /** * Run the responder side of the Noise NK handshake. Symmetric with * `noiseNKInitiator` — the caller provides `send` / `receive` for the two * handshake messages and gets back a session for transport-phase frames. * * Symmetric with the Android hand-port; no shared Rust implementation remains. * An Android-as-host pairing session can be driven by the desktop initiator and * vice versa as long as both derive over BLAKE2s (PCH-01-002). */ export async function noiseNKResponder( ourStaticPrivateKey: Uint8Array, ourStaticPublicKey: Uint8Array, send: (msg: Uint8Array) => Promise, receive: () => Promise, ): Promise { let h: Uint8Array = initHash(); let ck: Uint8Array = h.slice(); // NK pre-message: mix our (responder) static public key. The initiator // pre-mixed the same key on its side; both transcripts must match. h = mixHash(h, ourStaticPublicKey); // --- Message 1: e, es --- const msg1 = await receive(); if (msg1.length < 48) throw new Error("Noise NK: msg1 too short"); const remoteEphemeralPublic = msg1.slice(0, 32); const encrypted1 = msg1.slice(32); h = mixHash(h, remoteEphemeralPublic); const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true); const [ck1, k1] = hkdf2(ck, es); ck = ck1; const cipher1 = new ChaCha20Poly1305(k1); const nonce1 = new Uint8Array(12); const decrypted1 = cipher1.open(nonce1, encrypted1, h); if (!decrypted1) { throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM"); } h = mixHash(h, encrypted1); // --- Message 2: e, ee --- const ephemeral = generateKeyPair(); h = mixHash(h, ephemeral.publicKey); const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true); const [ck2, k2] = hkdf2(ck, ee); ck = ck2; const cipher2 = new ChaCha20Poly1305(k2); const nonce2 = new Uint8Array(12); const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h); h = mixHash(h, encrypted2); const msg2 = new Uint8Array(32 + encrypted2.length); msg2.set(ephemeral.publicKey, 0); msg2.set(encrypted2, 32); await send(msg2); // --- Transport phase --- // hkdf2 returns (k1, k2); the initiator uses k1 to send and k2 to receive, // so the responder swaps: k1 → recv, k2 → send. const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0)); const handshakeHash = h.slice(); let sendNonce = 0n; let recvNonce = 0n; function nonceBytes(n: bigint): Uint8Array { const buf = new Uint8Array(12); new DataView(buf.buffer).setBigUint64(4, n, true); return buf; } const sendCipher = new ChaCha20Poly1305(responderSendKey); const recvCipher = new ChaCha20Poly1305(initiatorSendKey); return { encrypt(plaintext) { return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0)); }, decrypt(ciphertext) { const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0)); if (!result) throw new Error("Noise NK: decryption failed"); return result; }, handshakeHash, }; } /** * Constant-time 32-byte equality. The MAC comparison in `verifyAuthProof` * goes through this so a same-LAN attacker can't time the HMAC compare. */ function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!; return diff === 0; } /** * Drive the responder side of the post-handshake challenge/proof round. We * generate a random 32-byte nonce, send `Challenge`, receive `Proof`, and * verify. Returns the initiator's authenticated public key on success. * * The MAC key is X25519(our_priv, initiator_pub) and the data is the same * domain-separated transcript both sides feed into HMAC-SHA256. */ export async function performResponderAuth( session: NoiseNKSession, ourPrivateKey: Uint8Array, ourPublicKey: Uint8Array, sendEncryptedJson: (data: Uint8Array) => Promise, receiveEncryptedJson: () => Promise, ): Promise<{ initiatorPublicKey: Uint8Array }> { // PCH-01-003b: bind the verifier's recomputed MAC to this Noise session. const nonce = new Uint8Array(32); crypto.getRandomValues(nonce); const challenge: AuthChallengeFrame = { type: "CHALLENGE", nonce_hex: bytesToHex(nonce), responder_public_key_hex: bytesToHex(ourPublicKey), }; await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge))); const proofBytes = await receiveEncryptedJson(); const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame; if (proofFrame.type !== "PROOF") { throw new Error("expected PROOF frame"); } const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex); const claimedProof = hexToBytes(proofFrame.proof_hex); if (initiatorPublicKey.length !== 32) { throw new Error("initiator public key must be 32 bytes"); } if (claimedProof.length !== 32) { throw new Error("proof must be 32 bytes"); } // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce) const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true); const mac = new HMAC(SHA256, dh); // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the // existing domain/keys/nonce transcript. Same value, same position on both ends. mac.update(session.handshakeHash); mac.update(AUTH_DOMAIN); mac.update(initiatorPublicKey); mac.update(ourPublicKey); mac.update(nonce); const expected = mac.digest(); if (!constantTimeEq(expected, claimedProof)) { throw new Error("auth proof did not verify"); } return { initiatorPublicKey }; } export async function performInitiatorAuth( session: NoiseNKSession, ourPrivateKey: Uint8Array, ourPublicKey: Uint8Array, responderPublicKey: Uint8Array, sendEncryptedJson: (data: Uint8Array) => Promise, receiveEncryptedJson: () => Promise, ): Promise { // PCH-01-003b: the Noise handshake hash is now mixed into the proof, binding // it to this exact session (was previously bound via the DH key only). const challengeBytes = await receiveEncryptedJson(); const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame; if (challenge.type !== "CHALLENGE") { throw new Error("expected CHALLENGE frame"); } const nonce = hexToBytes(challenge.nonce_hex); const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex); // Defence against an off-key challenge: the responder's claimed key MUST be // the one we Noise-NK-handshook against, otherwise an active MITM that // managed to substitute a different key is rerouting the auth round. if ( responderKeyFromChallenge.length !== responderPublicKey.length || !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i]) ) { throw new Error("responder key in challenge does not match Noise handshake"); } const proof = computeAuthProof( ourPrivateKey, ourPublicKey, responderPublicKey, nonce, session.handshakeHash, ); const proofFrame: AuthProofFrame = { type: "PROOF", initiator_public_key_hex: bytesToHex(ourPublicKey), proof_hex: bytesToHex(proof), }; await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame))); } export { bytesToHex, hexToBytes };