1234567891011121314151617import { generateKeyPair, sharedKey } from "@stablelib/x25519";18import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";19import { BLAKE2s } from "@stablelib/blake2s";20import { SHA256 } from "@stablelib/sha256";21import { HMAC } from "@stablelib/hmac";2223const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2425export function initHash(): Uint8Array {26 const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);27 if (nameBytes.length <= 32) {28 const h = new Uint8Array(32);29 h.set(nameBytes);30 return h;31 }32 33 34 return new BLAKE2s(32).update(nameBytes).digest();35}3637function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {38 const blake = new BLAKE2s(32);39 blake.update(h);40 blake.update(data);41 return blake.digest();42}4344export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {45 46 47 48 49 50 51 52 const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); 53 const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();54 const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();55 return [k1, k2];56}5758export interface NoiseNKSession {59 encrypt(plaintext: Uint8Array): Uint8Array;60 decrypt(ciphertext: Uint8Array): Uint8Array;61 handshakeHash: Uint8Array;62}63646566676869export async function noiseNKInitiator(70 responderStaticPublicKey: Uint8Array,71 send: (msg: Uint8Array) => Promise<void>,72 receive: () => Promise<Uint8Array>,73): Promise<NoiseNKSession> {74 let h: Uint8Array = initHash();75 let ck: Uint8Array = h.slice();7677 78 h = mixHash(h, responderStaticPublicKey);7980 const ephemeral = generateKeyPair();8182 83 h = mixHash(h, ephemeral.publicKey);84 const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey);85 const [ck1, k1] = hkdf2(ck, es);86 ck = ck1;8788 const cipher1 = new ChaCha20Poly1305(k1);89 const nonce1 = new Uint8Array(12);90 const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);91 h = mixHash(h, encrypted1);9293 const msg1 = new Uint8Array(32 + encrypted1.length);94 msg1.set(ephemeral.publicKey, 0);95 msg1.set(encrypted1, 32);96 await send(msg1);9798 99 const msg2 = await receive();100 if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");101102 const remoteEphemeralPublic = msg2.slice(0, 32);103 const encrypted2 = msg2.slice(32);104105 h = mixHash(h, remoteEphemeralPublic);106 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic);107 const [ck2, k2] = hkdf2(ck, ee);108 ck = ck2;109110 const cipher2 = new ChaCha20Poly1305(k2);111 const nonce2 = new Uint8Array(12);112 const decrypted2 = cipher2.open(nonce2, encrypted2, h);113 if (!decrypted2) {114 throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");115 }116 h = mixHash(h, encrypted2);117118 119 const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));120 const handshakeHash = h.slice();121122 let sendNonce = 0n;123 let recvNonce = 0n;124125 function nonceBytes(n: bigint): Uint8Array {126 const buf = new Uint8Array(12);127 new DataView(buf.buffer).setBigUint64(4, n, true);128 return buf;129 }130131 const sendCipher = new ChaCha20Poly1305(sendKey);132 const recvCipher = new ChaCha20Poly1305(recvKey);133134 return {135 encrypt(plaintext) {136 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));137 },138 decrypt(ciphertext) {139 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));140 if (!result) throw new Error("Noise NK: decryption failed");141 return result;142 },143 handshakeHash,144 };145}146147148149150151152153154155156157158159160161162163const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");164165export function computeAuthProof(166 initiatorPrivate: Uint8Array,167 initiatorPublic: Uint8Array,168 responderPublic: Uint8Array,169 nonce: Uint8Array,170 handshakeHash: Uint8Array,171): Uint8Array {172 if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");173 if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");174175 const dh = sharedKey(initiatorPrivate, responderPublic);176 const mac = new HMAC(SHA256, dh);177 178 179 180 mac.update(handshakeHash);181 mac.update(AUTH_DOMAIN);182 mac.update(initiatorPublic);183 mac.update(responderPublic);184 mac.update(nonce);185 return mac.digest();186}187188interface AuthChallengeFrame {189 type: "CHALLENGE";190 nonce_hex: string;191 responder_public_key_hex: string;192}193194interface AuthProofFrame {195 type: "PROOF";196 initiator_public_key_hex: string;197 proof_hex: string;198}199200function bytesToHex(bytes: Uint8Array): string {201 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");202}203204function hexToBytes(hex: string): Uint8Array {205 if (hex.length % 2 !== 0) throw new Error("invalid hex");206 const bytes = new Uint8Array(hex.length / 2);207 for (let i = 0; i < hex.length; i += 2) {208 bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);209 }210 return bytes;211}212213214215216217218219220221222223224225226227228export async function noiseNKResponder(229 ourStaticPrivateKey: Uint8Array,230 ourStaticPublicKey: Uint8Array,231 send: (msg: Uint8Array) => Promise<void>,232 receive: () => Promise<Uint8Array>,233): Promise<NoiseNKSession> {234 let h: Uint8Array = initHash();235 let ck: Uint8Array = h.slice();236237 238 239 h = mixHash(h, ourStaticPublicKey);240241 242 const msg1 = await receive();243 if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");244 const remoteEphemeralPublic = msg1.slice(0, 32);245 const encrypted1 = msg1.slice(32);246247 h = mixHash(h, remoteEphemeralPublic);248 const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic);249 const [ck1, k1] = hkdf2(ck, es);250 ck = ck1;251252 const cipher1 = new ChaCha20Poly1305(k1);253 const nonce1 = new Uint8Array(12);254 const decrypted1 = cipher1.open(nonce1, encrypted1, h);255 if (!decrypted1) {256 throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");257 }258 h = mixHash(h, encrypted1);259260 261 const ephemeral = generateKeyPair();262 h = mixHash(h, ephemeral.publicKey);263 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic);264 const [ck2, k2] = hkdf2(ck, ee);265 ck = ck2;266267 const cipher2 = new ChaCha20Poly1305(k2);268 const nonce2 = new Uint8Array(12);269 const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);270 h = mixHash(h, encrypted2);271272 const msg2 = new Uint8Array(32 + encrypted2.length);273 msg2.set(ephemeral.publicKey, 0);274 msg2.set(encrypted2, 32);275 await send(msg2);276277 278 279 280 const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));281 const handshakeHash = h.slice();282283 let sendNonce = 0n;284 let recvNonce = 0n;285286 function nonceBytes(n: bigint): Uint8Array {287 const buf = new Uint8Array(12);288 new DataView(buf.buffer).setBigUint64(4, n, true);289 return buf;290 }291292 const sendCipher = new ChaCha20Poly1305(responderSendKey);293 const recvCipher = new ChaCha20Poly1305(initiatorSendKey);294295 return {296 encrypt(plaintext) {297 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));298 },299 decrypt(ciphertext) {300 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));301 if (!result) throw new Error("Noise NK: decryption failed");302 return result;303 },304 handshakeHash,305 };306}307308309310311312function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {313 if (a.length !== b.length) return false;314 let diff = 0;315 for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;316 return diff === 0;317}318319320321322323324325326327export async function performResponderAuth(328 session: NoiseNKSession,329 ourPrivateKey: Uint8Array,330 ourPublicKey: Uint8Array,331 sendEncryptedJson: (data: Uint8Array) => Promise<void>,332 receiveEncryptedJson: () => Promise<Uint8Array>,333): Promise<{ initiatorPublicKey: Uint8Array }> {334 335336 const nonce = new Uint8Array(32);337 crypto.getRandomValues(nonce);338339 const challenge: AuthChallengeFrame = {340 type: "CHALLENGE",341 nonce_hex: bytesToHex(nonce),342 responder_public_key_hex: bytesToHex(ourPublicKey),343 };344 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));345346 const proofBytes = await receiveEncryptedJson();347 const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;348 if (proofFrame.type !== "PROOF") {349 throw new Error("expected PROOF frame");350 }351352 const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);353 const claimedProof = hexToBytes(proofFrame.proof_hex);354 if (initiatorPublicKey.length !== 32) {355 throw new Error("initiator public key must be 32 bytes");356 }357 if (claimedProof.length !== 32) {358 throw new Error("proof must be 32 bytes");359 }360361 362 const dh = sharedKey(ourPrivateKey, initiatorPublicKey);363 const mac = new HMAC(SHA256, dh);364 365 366 mac.update(session.handshakeHash);367 mac.update(AUTH_DOMAIN);368 mac.update(initiatorPublicKey);369 mac.update(ourPublicKey);370 mac.update(nonce);371 const expected = mac.digest();372373 if (!constantTimeEq(expected, claimedProof)) {374 throw new Error("auth proof did not verify");375 }376 return { initiatorPublicKey };377}378379export async function performInitiatorAuth(380 session: NoiseNKSession,381 ourPrivateKey: Uint8Array,382 ourPublicKey: Uint8Array,383 responderPublicKey: Uint8Array,384 sendEncryptedJson: (data: Uint8Array) => Promise<void>,385 receiveEncryptedJson: () => Promise<Uint8Array>,386): Promise<void> {387 388 389390 const challengeBytes = await receiveEncryptedJson();391 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;392 if (challenge.type !== "CHALLENGE") {393 throw new Error("expected CHALLENGE frame");394 }395 const nonce = hexToBytes(challenge.nonce_hex);396 const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);397 398 399 400 if (401 responderKeyFromChallenge.length !== responderPublicKey.length ||402 !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])403 ) {404 throw new Error("responder key in challenge does not match Noise handshake");405 }406407 const proof = computeAuthProof(408 ourPrivateKey,409 ourPublicKey,410 responderPublicKey,411 nonce,412 session.handshakeHash,413 );414 const proofFrame: AuthProofFrame = {415 type: "PROOF",416 initiator_public_key_hex: bytesToHex(ourPublicKey),417 proof_hex: bytesToHex(proof),418 };419 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));420}421422export { bytesToHex, hexToBytes };