123456789101112131415161718import { generateKeyPair, sharedKey } from "@stablelib/x25519";19import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";20import { BLAKE2s } from "@stablelib/blake2s";21import { SHA256 } from "@stablelib/sha256";22import { HMAC } from "@stablelib/hmac";2324const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2526export function initHash(): Uint8Array {27 const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);28 if (nameBytes.length <= 32) {29 const h = new Uint8Array(32);30 h.set(nameBytes);31 return h;32 }33 34 35 return new BLAKE2s(32).update(nameBytes).digest();36}3738function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {39 const blake = new BLAKE2s(32);40 blake.update(h);41 blake.update(data);42 return blake.digest();43}4445export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {46 47 48 49 50 51 52 53 const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); 54 const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();55 const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();56 return [k1, k2];57}5859export interface NoiseNKSession {60 encrypt(plaintext: Uint8Array): Uint8Array;61 decrypt(ciphertext: Uint8Array): Uint8Array;62 handshakeHash: Uint8Array;63}64656667686970export async function noiseNKInitiator(71 responderStaticPublicKey: Uint8Array,72 send: (msg: Uint8Array) => Promise<void>,73 receive: () => Promise<Uint8Array>,74): Promise<NoiseNKSession> {75 let h: Uint8Array = initHash();76 let ck: Uint8Array = h.slice();7778 79 h = mixHash(h, responderStaticPublicKey);8081 const ephemeral = generateKeyPair();8283 84 h = mixHash(h, ephemeral.publicKey);85 86 87 const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey, true);88 const [ck1, k1] = hkdf2(ck, es);89 ck = ck1;9091 const cipher1 = new ChaCha20Poly1305(k1);92 const nonce1 = new Uint8Array(12);93 const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);94 h = mixHash(h, encrypted1);9596 const msg1 = new Uint8Array(32 + encrypted1.length);97 msg1.set(ephemeral.publicKey, 0);98 msg1.set(encrypted1, 32);99 await send(msg1);100101 102 const msg2 = await receive();103 if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");104105 const remoteEphemeralPublic = msg2.slice(0, 32);106 const encrypted2 = msg2.slice(32);107108 h = mixHash(h, remoteEphemeralPublic);109 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);110 const [ck2, k2] = hkdf2(ck, ee);111 ck = ck2;112113 const cipher2 = new ChaCha20Poly1305(k2);114 const nonce2 = new Uint8Array(12);115 const decrypted2 = cipher2.open(nonce2, encrypted2, h);116 if (!decrypted2) {117 throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");118 }119 h = mixHash(h, encrypted2);120121 122 const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));123 const handshakeHash = h.slice();124125 let sendNonce = 0n;126 let recvNonce = 0n;127128 function nonceBytes(n: bigint): Uint8Array {129 const buf = new Uint8Array(12);130 new DataView(buf.buffer).setBigUint64(4, n, true);131 return buf;132 }133134 const sendCipher = new ChaCha20Poly1305(sendKey);135 const recvCipher = new ChaCha20Poly1305(recvKey);136137 return {138 encrypt(plaintext) {139 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));140 },141 decrypt(ciphertext) {142 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));143 if (!result) throw new Error("Noise NK: decryption failed");144 return result;145 },146 handshakeHash,147 };148}149150151152153154155156157158159160161162163164165166const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");167168export function computeAuthProof(169 initiatorPrivate: Uint8Array,170 initiatorPublic: Uint8Array,171 responderPublic: Uint8Array,172 nonce: Uint8Array,173 handshakeHash: Uint8Array,174): Uint8Array {175 if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");176 if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");177178 const dh = sharedKey(initiatorPrivate, responderPublic, true);179 const mac = new HMAC(SHA256, dh);180 181 182 183 mac.update(handshakeHash);184 mac.update(AUTH_DOMAIN);185 mac.update(initiatorPublic);186 mac.update(responderPublic);187 mac.update(nonce);188 return mac.digest();189}190191interface AuthChallengeFrame {192 type: "CHALLENGE";193 nonce_hex: string;194 responder_public_key_hex: string;195}196197interface AuthProofFrame {198 type: "PROOF";199 initiator_public_key_hex: string;200 proof_hex: string;201}202203function bytesToHex(bytes: Uint8Array): string {204 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");205}206207function hexToBytes(hex: string): Uint8Array {208 if (hex.length % 2 !== 0) throw new Error("invalid hex");209 const bytes = new Uint8Array(hex.length / 2);210 for (let i = 0; i < hex.length; i += 2) {211 bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);212 }213 return bytes;214}215216217218219220221222223224225226227228229230231export async function noiseNKResponder(232 ourStaticPrivateKey: Uint8Array,233 ourStaticPublicKey: Uint8Array,234 send: (msg: Uint8Array) => Promise<void>,235 receive: () => Promise<Uint8Array>,236): Promise<NoiseNKSession> {237 let h: Uint8Array = initHash();238 let ck: Uint8Array = h.slice();239240 241 242 h = mixHash(h, ourStaticPublicKey);243244 245 const msg1 = await receive();246 if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");247 const remoteEphemeralPublic = msg1.slice(0, 32);248 const encrypted1 = msg1.slice(32);249250 h = mixHash(h, remoteEphemeralPublic);251 const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true);252 const [ck1, k1] = hkdf2(ck, es);253 ck = ck1;254255 const cipher1 = new ChaCha20Poly1305(k1);256 const nonce1 = new Uint8Array(12);257 const decrypted1 = cipher1.open(nonce1, encrypted1, h);258 if (!decrypted1) {259 throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");260 }261 h = mixHash(h, encrypted1);262263 264 const ephemeral = generateKeyPair();265 h = mixHash(h, ephemeral.publicKey);266 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);267 const [ck2, k2] = hkdf2(ck, ee);268 ck = ck2;269270 const cipher2 = new ChaCha20Poly1305(k2);271 const nonce2 = new Uint8Array(12);272 const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);273 h = mixHash(h, encrypted2);274275 const msg2 = new Uint8Array(32 + encrypted2.length);276 msg2.set(ephemeral.publicKey, 0);277 msg2.set(encrypted2, 32);278 await send(msg2);279280 281 282 283 const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));284 const handshakeHash = h.slice();285286 let sendNonce = 0n;287 let recvNonce = 0n;288289 function nonceBytes(n: bigint): Uint8Array {290 const buf = new Uint8Array(12);291 new DataView(buf.buffer).setBigUint64(4, n, true);292 return buf;293 }294295 const sendCipher = new ChaCha20Poly1305(responderSendKey);296 const recvCipher = new ChaCha20Poly1305(initiatorSendKey);297298 return {299 encrypt(plaintext) {300 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));301 },302 decrypt(ciphertext) {303 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));304 if (!result) throw new Error("Noise NK: decryption failed");305 return result;306 },307 handshakeHash,308 };309}310311312313314315function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {316 if (a.length !== b.length) return false;317 let diff = 0;318 for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;319 return diff === 0;320}321322323324325326327328329330export async function performResponderAuth(331 session: NoiseNKSession,332 ourPrivateKey: Uint8Array,333 ourPublicKey: Uint8Array,334 sendEncryptedJson: (data: Uint8Array) => Promise<void>,335 receiveEncryptedJson: () => Promise<Uint8Array>,336): Promise<{ initiatorPublicKey: Uint8Array }> {337 338339 const nonce = new Uint8Array(32);340 crypto.getRandomValues(nonce);341342 const challenge: AuthChallengeFrame = {343 type: "CHALLENGE",344 nonce_hex: bytesToHex(nonce),345 responder_public_key_hex: bytesToHex(ourPublicKey),346 };347 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));348349 const proofBytes = await receiveEncryptedJson();350 const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;351 if (proofFrame.type !== "PROOF") {352 throw new Error("expected PROOF frame");353 }354355 const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);356 const claimedProof = hexToBytes(proofFrame.proof_hex);357 if (initiatorPublicKey.length !== 32) {358 throw new Error("initiator public key must be 32 bytes");359 }360 if (claimedProof.length !== 32) {361 throw new Error("proof must be 32 bytes");362 }363364 365 const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);366 const mac = new HMAC(SHA256, dh);367 368 369 mac.update(session.handshakeHash);370 mac.update(AUTH_DOMAIN);371 mac.update(initiatorPublicKey);372 mac.update(ourPublicKey);373 mac.update(nonce);374 const expected = mac.digest();375376 if (!constantTimeEq(expected, claimedProof)) {377 throw new Error("auth proof did not verify");378 }379 return { initiatorPublicKey };380}381382export async function performInitiatorAuth(383 session: NoiseNKSession,384 ourPrivateKey: Uint8Array,385 ourPublicKey: Uint8Array,386 responderPublicKey: Uint8Array,387 sendEncryptedJson: (data: Uint8Array) => Promise<void>,388 receiveEncryptedJson: () => Promise<Uint8Array>,389): Promise<void> {390 391 392393 const challengeBytes = await receiveEncryptedJson();394 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;395 if (challenge.type !== "CHALLENGE") {396 throw new Error("expected CHALLENGE frame");397 }398 const nonce = hexToBytes(challenge.nonce_hex);399 const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);400 401 402 403 if (404 responderKeyFromChallenge.length !== responderPublicKey.length ||405 !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])406 ) {407 throw new Error("responder key in challenge does not match Noise handshake");408 }409410 const proof = computeAuthProof(411 ourPrivateKey,412 ourPublicKey,413 responderPublicKey,414 nonce,415 session.handshakeHash,416 );417 const proofFrame: AuthProofFrame = {418 type: "PROOF",419 initiator_public_key_hex: bytesToHex(ourPublicKey),420 proof_hex: bytesToHex(proof),421 };422 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));423}424425export { bytesToHex, hexToBytes };