1234567891011121314import { generateKeyPair, sharedKey } from "@stablelib/x25519";15import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";16import { BLAKE2s } from "@stablelib/blake2s";17import { HKDF } from "@stablelib/hkdf";18import { SHA256 } from "@stablelib/sha256";19import { HMAC } from "@stablelib/hmac";2021const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2223function initHash(): Uint8Array {24 const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);25 if (nameBytes.length <= 32) {26 const h = new Uint8Array(32);27 h.set(nameBytes);28 return h;29 }30 const sha = new SHA256();31 sha.update(nameBytes);32 return sha.digest();33}3435function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {36 const blake = new BLAKE2s(32);37 blake.update(h);38 blake.update(data);39 return blake.digest();40}4142function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {43 const out = new HKDF(SHA256, ikm, ck).expand(64);44 return [out.slice(0, 32), out.slice(32, 64)];45}4647export interface NoiseNKSession {48 encrypt(plaintext: Uint8Array): Uint8Array;49 decrypt(ciphertext: Uint8Array): Uint8Array;50 handshakeHash: Uint8Array;51}52535455565758export async function noiseNKInitiator(59 responderStaticPublicKey: Uint8Array,60 send: (msg: Uint8Array) => Promise<void>,61 receive: () => Promise<Uint8Array>,62): Promise<NoiseNKSession> {63 let h: Uint8Array = initHash();64 let ck: Uint8Array = h.slice();6566 67 h = mixHash(h, responderStaticPublicKey);6869 const ephemeral = generateKeyPair();7071 72 h = mixHash(h, ephemeral.publicKey);73 const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey);74 const [ck1, k1] = hkdf2(ck, es);75 ck = ck1;7677 const cipher1 = new ChaCha20Poly1305(k1);78 const nonce1 = new Uint8Array(12);79 const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);80 h = mixHash(h, encrypted1);8182 const msg1 = new Uint8Array(32 + encrypted1.length);83 msg1.set(ephemeral.publicKey, 0);84 msg1.set(encrypted1, 32);85 await send(msg1);8687 88 const msg2 = await receive();89 if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");9091 const remoteEphemeralPublic = msg2.slice(0, 32);92 const encrypted2 = msg2.slice(32);9394 h = mixHash(h, remoteEphemeralPublic);95 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic);96 const [ck2, k2] = hkdf2(ck, ee);97 ck = ck2;9899 const cipher2 = new ChaCha20Poly1305(k2);100 const nonce2 = new Uint8Array(12);101 const decrypted2 = cipher2.open(nonce2, encrypted2, h);102 if (!decrypted2) {103 throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");104 }105 h = mixHash(h, encrypted2);106107 108 const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));109 const handshakeHash = h.slice();110111 let sendNonce = 0n;112 let recvNonce = 0n;113114 function nonceBytes(n: bigint): Uint8Array {115 const buf = new Uint8Array(12);116 new DataView(buf.buffer).setBigUint64(4, n, true);117 return buf;118 }119120 const sendCipher = new ChaCha20Poly1305(sendKey);121 const recvCipher = new ChaCha20Poly1305(recvKey);122123 return {124 encrypt(plaintext) {125 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));126 },127 decrypt(ciphertext) {128 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));129 if (!result) throw new Error("Noise NK: decryption failed");130 return result;131 },132 handshakeHash,133 };134}135136137138139140141142143144145146147148149150151152const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");153154export function computeAuthProof(155 initiatorPrivate: Uint8Array,156 initiatorPublic: Uint8Array,157 responderPublic: Uint8Array,158 nonce: Uint8Array,159): Uint8Array {160 if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");161162 const dh = sharedKey(initiatorPrivate, responderPublic);163 const mac = new HMAC(SHA256, dh);164 mac.update(AUTH_DOMAIN);165 mac.update(initiatorPublic);166 mac.update(responderPublic);167 mac.update(nonce);168 return mac.digest();169}170171interface AuthChallengeFrame {172 type: "CHALLENGE";173 nonce_hex: string;174 responder_public_key_hex: string;175}176177interface AuthProofFrame {178 type: "PROOF";179 initiator_public_key_hex: string;180 proof_hex: string;181}182183function bytesToHex(bytes: Uint8Array): string {184 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");185}186187function hexToBytes(hex: string): Uint8Array {188 if (hex.length % 2 !== 0) throw new Error("invalid hex");189 const bytes = new Uint8Array(hex.length / 2);190 for (let i = 0; i < hex.length; i += 2) {191 bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);192 }193 return bytes;194}195196197198199200201202203204205206207208209210export async function noiseNKResponder(211 ourStaticPrivateKey: Uint8Array,212 ourStaticPublicKey: Uint8Array,213 send: (msg: Uint8Array) => Promise<void>,214 receive: () => Promise<Uint8Array>,215): Promise<NoiseNKSession> {216 let h: Uint8Array = initHash();217 let ck: Uint8Array = h.slice();218219 220 221 h = mixHash(h, ourStaticPublicKey);222223 224 const msg1 = await receive();225 if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");226 const remoteEphemeralPublic = msg1.slice(0, 32);227 const encrypted1 = msg1.slice(32);228229 h = mixHash(h, remoteEphemeralPublic);230 const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic);231 const [ck1, k1] = hkdf2(ck, es);232 ck = ck1;233234 const cipher1 = new ChaCha20Poly1305(k1);235 const nonce1 = new Uint8Array(12);236 const decrypted1 = cipher1.open(nonce1, encrypted1, h);237 if (!decrypted1) {238 throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");239 }240 h = mixHash(h, encrypted1);241242 243 const ephemeral = generateKeyPair();244 h = mixHash(h, ephemeral.publicKey);245 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic);246 const [ck2, k2] = hkdf2(ck, ee);247 ck = ck2;248249 const cipher2 = new ChaCha20Poly1305(k2);250 const nonce2 = new Uint8Array(12);251 const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);252 h = mixHash(h, encrypted2);253254 const msg2 = new Uint8Array(32 + encrypted2.length);255 msg2.set(ephemeral.publicKey, 0);256 msg2.set(encrypted2, 32);257 await send(msg2);258259 260 261 262 const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));263 const handshakeHash = h.slice();264265 let sendNonce = 0n;266 let recvNonce = 0n;267268 function nonceBytes(n: bigint): Uint8Array {269 const buf = new Uint8Array(12);270 new DataView(buf.buffer).setBigUint64(4, n, true);271 return buf;272 }273274 const sendCipher = new ChaCha20Poly1305(responderSendKey);275 const recvCipher = new ChaCha20Poly1305(initiatorSendKey);276277 return {278 encrypt(plaintext) {279 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));280 },281 decrypt(ciphertext) {282 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));283 if (!result) throw new Error("Noise NK: decryption failed");284 return result;285 },286 handshakeHash,287 };288}289290291292293294function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {295 if (a.length !== b.length) return false;296 let diff = 0;297 for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;298 return diff === 0;299}300301302303304305306307308309export async function performResponderAuth(310 session: NoiseNKSession,311 ourPrivateKey: Uint8Array,312 ourPublicKey: Uint8Array,313 sendEncryptedJson: (data: Uint8Array) => Promise<void>,314 receiveEncryptedJson: () => Promise<Uint8Array>,315): Promise<{ initiatorPublicKey: Uint8Array }> {316 void session;317318 const nonce = new Uint8Array(32);319 crypto.getRandomValues(nonce);320321 const challenge: AuthChallengeFrame = {322 type: "CHALLENGE",323 nonce_hex: bytesToHex(nonce),324 responder_public_key_hex: bytesToHex(ourPublicKey),325 };326 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));327328 const proofBytes = await receiveEncryptedJson();329 const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;330 if (proofFrame.type !== "PROOF") {331 throw new Error("expected PROOF frame");332 }333334 const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);335 const claimedProof = hexToBytes(proofFrame.proof_hex);336 if (initiatorPublicKey.length !== 32) {337 throw new Error("initiator public key must be 32 bytes");338 }339 if (claimedProof.length !== 32) {340 throw new Error("proof must be 32 bytes");341 }342343 344 const dh = sharedKey(ourPrivateKey, initiatorPublicKey);345 const mac = new HMAC(SHA256, dh);346 mac.update(AUTH_DOMAIN);347 mac.update(initiatorPublicKey);348 mac.update(ourPublicKey);349 mac.update(nonce);350 const expected = mac.digest();351352 if (!constantTimeEq(expected, claimedProof)) {353 throw new Error("auth proof did not verify");354 }355 return { initiatorPublicKey };356}357358export async function performInitiatorAuth(359 session: NoiseNKSession,360 ourPrivateKey: Uint8Array,361 ourPublicKey: Uint8Array,362 responderPublicKey: Uint8Array,363 sendEncryptedJson: (data: Uint8Array) => Promise<void>,364 receiveEncryptedJson: () => Promise<Uint8Array>,365): Promise<void> {366 void session; 367368 const challengeBytes = await receiveEncryptedJson();369 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;370 if (challenge.type !== "CHALLENGE") {371 throw new Error("expected CHALLENGE frame");372 }373 const nonce = hexToBytes(challenge.nonce_hex);374 const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);375 376 377 378 if (379 responderKeyFromChallenge.length !== responderPublicKey.length ||380 !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])381 ) {382 throw new Error("responder key in challenge does not match Noise handshake");383 }384385 const proof = computeAuthProof(ourPrivateKey, ourPublicKey, responderPublicKey, nonce);386 const proofFrame: AuthProofFrame = {387 type: "PROOF",388 initiator_public_key_hex: bytesToHex(ourPublicKey),389 proof_hex: bytesToHex(proof),390 };391 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));392}393394export { bytesToHex, hexToBytes };