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 85 86 const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey, true);87 const [ck1, k1] = hkdf2(ck, es);88 ck = ck1;8990 const cipher1 = new ChaCha20Poly1305(k1);91 const nonce1 = new Uint8Array(12);92 const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);93 h = mixHash(h, encrypted1);9495 const msg1 = new Uint8Array(32 + encrypted1.length);96 msg1.set(ephemeral.publicKey, 0);97 msg1.set(encrypted1, 32);98 await send(msg1);99100 101 const msg2 = await receive();102 if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");103104 const remoteEphemeralPublic = msg2.slice(0, 32);105 const encrypted2 = msg2.slice(32);106107 h = mixHash(h, remoteEphemeralPublic);108 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);109 const [ck2, k2] = hkdf2(ck, ee);110 ck = ck2;111112 const cipher2 = new ChaCha20Poly1305(k2);113 const nonce2 = new Uint8Array(12);114 const decrypted2 = cipher2.open(nonce2, encrypted2, h);115 if (!decrypted2) {116 throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");117 }118 h = mixHash(h, encrypted2);119120 121 const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));122 const handshakeHash = h.slice();123124 let sendNonce = 0n;125 let recvNonce = 0n;126127 function nonceBytes(n: bigint): Uint8Array {128 const buf = new Uint8Array(12);129 new DataView(buf.buffer).setBigUint64(4, n, true);130 return buf;131 }132133 const sendCipher = new ChaCha20Poly1305(sendKey);134 const recvCipher = new ChaCha20Poly1305(recvKey);135136 return {137 encrypt(plaintext) {138 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));139 },140 decrypt(ciphertext) {141 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));142 if (!result) throw new Error("Noise NK: decryption failed");143 return result;144 },145 handshakeHash,146 };147}148149150151152153154155156157158159160161162163164165const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");166167export function computeAuthProof(168 initiatorPrivate: Uint8Array,169 initiatorPublic: Uint8Array,170 responderPublic: Uint8Array,171 nonce: Uint8Array,172 handshakeHash: Uint8Array,173): Uint8Array {174 if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");175 if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");176177 const dh = sharedKey(initiatorPrivate, responderPublic, true);178 const mac = new HMAC(SHA256, dh);179 180 181 182 mac.update(handshakeHash);183 mac.update(AUTH_DOMAIN);184 mac.update(initiatorPublic);185 mac.update(responderPublic);186 mac.update(nonce);187 return mac.digest();188}189190interface AuthChallengeFrame {191 type: "CHALLENGE";192 nonce_hex: string;193 responder_public_key_hex: string;194}195196interface AuthProofFrame {197 type: "PROOF";198 initiator_public_key_hex: string;199 proof_hex: string;200}201202function bytesToHex(bytes: Uint8Array): string {203 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");204}205206function hexToBytes(hex: string): Uint8Array {207 if (hex.length % 2 !== 0) throw new Error("invalid hex");208 const bytes = new Uint8Array(hex.length / 2);209 for (let i = 0; i < hex.length; i += 2) {210 bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);211 }212 return bytes;213}214215216217218219220221222223224225226227228229230export async function noiseNKResponder(231 ourStaticPrivateKey: Uint8Array,232 ourStaticPublicKey: Uint8Array,233 send: (msg: Uint8Array) => Promise<void>,234 receive: () => Promise<Uint8Array>,235): Promise<NoiseNKSession> {236 let h: Uint8Array = initHash();237 let ck: Uint8Array = h.slice();238239 240 241 h = mixHash(h, ourStaticPublicKey);242243 244 const msg1 = await receive();245 if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");246 const remoteEphemeralPublic = msg1.slice(0, 32);247 const encrypted1 = msg1.slice(32);248249 h = mixHash(h, remoteEphemeralPublic);250 const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true);251 const [ck1, k1] = hkdf2(ck, es);252 ck = ck1;253254 const cipher1 = new ChaCha20Poly1305(k1);255 const nonce1 = new Uint8Array(12);256 const decrypted1 = cipher1.open(nonce1, encrypted1, h);257 if (!decrypted1) {258 throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");259 }260 h = mixHash(h, encrypted1);261262 263 const ephemeral = generateKeyPair();264 h = mixHash(h, ephemeral.publicKey);265 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);266 const [ck2, k2] = hkdf2(ck, ee);267 ck = ck2;268269 const cipher2 = new ChaCha20Poly1305(k2);270 const nonce2 = new Uint8Array(12);271 const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);272 h = mixHash(h, encrypted2);273274 const msg2 = new Uint8Array(32 + encrypted2.length);275 msg2.set(ephemeral.publicKey, 0);276 msg2.set(encrypted2, 32);277 await send(msg2);278279 280 281 282 const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));283 const handshakeHash = h.slice();284285 let sendNonce = 0n;286 let recvNonce = 0n;287288 function nonceBytes(n: bigint): Uint8Array {289 const buf = new Uint8Array(12);290 new DataView(buf.buffer).setBigUint64(4, n, true);291 return buf;292 }293294 const sendCipher = new ChaCha20Poly1305(responderSendKey);295 const recvCipher = new ChaCha20Poly1305(initiatorSendKey);296297 return {298 encrypt(plaintext) {299 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));300 },301 decrypt(ciphertext) {302 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));303 if (!result) throw new Error("Noise NK: decryption failed");304 return result;305 },306 handshakeHash,307 };308}309310311312313314function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {315 if (a.length !== b.length) return false;316 let diff = 0;317 for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;318 return diff === 0;319}320321322323324325326327328329export async function performResponderAuth(330 session: NoiseNKSession,331 ourPrivateKey: Uint8Array,332 ourPublicKey: Uint8Array,333 sendEncryptedJson: (data: Uint8Array) => Promise<void>,334 receiveEncryptedJson: () => Promise<Uint8Array>,335): Promise<{ initiatorPublicKey: Uint8Array }> {336 337338 const nonce = new Uint8Array(32);339 crypto.getRandomValues(nonce);340341 const challenge: AuthChallengeFrame = {342 type: "CHALLENGE",343 nonce_hex: bytesToHex(nonce),344 responder_public_key_hex: bytesToHex(ourPublicKey),345 };346 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));347348 const proofBytes = await receiveEncryptedJson();349 const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;350 if (proofFrame.type !== "PROOF") {351 throw new Error("expected PROOF frame");352 }353354 const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);355 const claimedProof = hexToBytes(proofFrame.proof_hex);356 if (initiatorPublicKey.length !== 32) {357 throw new Error("initiator public key must be 32 bytes");358 }359 if (claimedProof.length !== 32) {360 throw new Error("proof must be 32 bytes");361 }362363 364 const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);365 const mac = new HMAC(SHA256, dh);366 367 368 mac.update(session.handshakeHash);369 mac.update(AUTH_DOMAIN);370 mac.update(initiatorPublicKey);371 mac.update(ourPublicKey);372 mac.update(nonce);373 const expected = mac.digest();374375 if (!constantTimeEq(expected, claimedProof)) {376 throw new Error("auth proof did not verify");377 }378 return { initiatorPublicKey };379}380381export async function performInitiatorAuth(382 session: NoiseNKSession,383 ourPrivateKey: Uint8Array,384 ourPublicKey: Uint8Array,385 responderPublicKey: Uint8Array,386 sendEncryptedJson: (data: Uint8Array) => Promise<void>,387 receiveEncryptedJson: () => Promise<Uint8Array>,388): Promise<void> {389 390 391392 const challengeBytes = await receiveEncryptedJson();393 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;394 if (challenge.type !== "CHALLENGE") {395 throw new Error("expected CHALLENGE frame");396 }397 const nonce = hexToBytes(challenge.nonce_hex);398 const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);399 400 401 402 if (403 responderKeyFromChallenge.length !== responderPublicKey.length ||404 !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])405 ) {406 throw new Error("responder key in challenge does not match Noise handshake");407 }408409 const proof = computeAuthProof(410 ourPrivateKey,411 ourPublicKey,412 responderPublicKey,413 nonce,414 session.handshakeHash,415 );416 const proofFrame: AuthProofFrame = {417 type: "PROOF",418 initiator_public_key_hex: bytesToHex(ourPublicKey),419 proof_hex: bytesToHex(proof),420 };421 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));422}423424export { bytesToHex, hexToBytes };