12345678910111213141516171819import { generateKeyPair, sharedKey } from "@stablelib/x25519";20import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";21import { BLAKE2s } from "@stablelib/blake2s";22import { SHA256 } from "@stablelib/sha256";23import { HMAC } from "@stablelib/hmac";2425const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2627export function initHash(): Uint8Array {28 const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);29 if (nameBytes.length <= 32) {30 const h = new Uint8Array(32);31 h.set(nameBytes);32 return h;33 }34 35 36 return new BLAKE2s(32).update(nameBytes).digest();37}3839function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {40 const blake = new BLAKE2s(32);41 blake.update(h);42 blake.update(data);43 return blake.digest();44}4546export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {47 48 49 50 51 52 53 54 const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); 55 const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();56 const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();57 return [k1, k2];58}5960export interface NoiseNKSession {61 encrypt(plaintext: Uint8Array): Uint8Array;62 decrypt(ciphertext: Uint8Array): Uint8Array;63 handshakeHash: Uint8Array;64}65666768697071export async function noiseNKInitiator(72 responderStaticPublicKey: Uint8Array,73 send: (msg: Uint8Array) => Promise<void>,74 receive: () => Promise<Uint8Array>,75): Promise<NoiseNKSession> {76 let h: Uint8Array = initHash();77 let ck: Uint8Array = h.slice();7879 80 h = mixHash(h, responderStaticPublicKey);8182 const ephemeral = generateKeyPair();8384 85 h = mixHash(h, ephemeral.publicKey);86 87 88 const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey, true);89 const [ck1, k1] = hkdf2(ck, es);90 ck = ck1;9192 const cipher1 = new ChaCha20Poly1305(k1);93 const nonce1 = new Uint8Array(12);94 const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);95 h = mixHash(h, encrypted1);9697 const msg1 = new Uint8Array(32 + encrypted1.length);98 msg1.set(ephemeral.publicKey, 0);99 msg1.set(encrypted1, 32);100 await send(msg1);101102 103 const msg2 = await receive();104 if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");105106 const remoteEphemeralPublic = msg2.slice(0, 32);107 const encrypted2 = msg2.slice(32);108109 h = mixHash(h, remoteEphemeralPublic);110 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);111 const [ck2, k2] = hkdf2(ck, ee);112 ck = ck2;113114 const cipher2 = new ChaCha20Poly1305(k2);115 const nonce2 = new Uint8Array(12);116 const decrypted2 = cipher2.open(nonce2, encrypted2, h);117 if (!decrypted2) {118 throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");119 }120 h = mixHash(h, encrypted2);121122 123 const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));124 const handshakeHash = h.slice();125126 let sendNonce = 0n;127 let recvNonce = 0n;128129 function nonceBytes(n: bigint): Uint8Array {130 const buf = new Uint8Array(12);131 new DataView(buf.buffer).setBigUint64(4, n, true);132 return buf;133 }134135 const sendCipher = new ChaCha20Poly1305(sendKey);136 const recvCipher = new ChaCha20Poly1305(recvKey);137138 return {139 encrypt(plaintext) {140 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));141 },142 decrypt(ciphertext) {143 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));144 if (!result) throw new Error("Noise NK: decryption failed");145 return result;146 },147 handshakeHash,148 };149}150151152153154155156157158159160161162163164165166167const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");168169export function computeAuthProof(170 initiatorPrivate: Uint8Array,171 initiatorPublic: Uint8Array,172 responderPublic: Uint8Array,173 nonce: Uint8Array,174 handshakeHash: Uint8Array,175): Uint8Array {176 if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");177 if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");178179 const dh = sharedKey(initiatorPrivate, responderPublic, true);180 const mac = new HMAC(SHA256, dh);181 182 183 184 mac.update(handshakeHash);185 mac.update(AUTH_DOMAIN);186 mac.update(initiatorPublic);187 mac.update(responderPublic);188 mac.update(nonce);189 return mac.digest();190}191192interface AuthChallengeFrame {193 type: "CHALLENGE";194 nonce_hex: string;195 responder_public_key_hex: string;196}197198interface AuthProofFrame {199 type: "PROOF";200 initiator_public_key_hex: string;201 proof_hex: string;202}203204function bytesToHex(bytes: Uint8Array): string {205 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");206}207208function hexToBytes(hex: string): Uint8Array {209 if (hex.length % 2 !== 0) throw new Error("invalid hex");210 const bytes = new Uint8Array(hex.length / 2);211 for (let i = 0; i < hex.length; i += 2) {212 bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);213 }214 return bytes;215}216217218219220221222223224225226227228229230231232export async function noiseNKResponder(233 ourStaticPrivateKey: Uint8Array,234 ourStaticPublicKey: Uint8Array,235 send: (msg: Uint8Array) => Promise<void>,236 receive: () => Promise<Uint8Array>,237): Promise<NoiseNKSession> {238 let h: Uint8Array = initHash();239 let ck: Uint8Array = h.slice();240241 242 243 h = mixHash(h, ourStaticPublicKey);244245 246 const msg1 = await receive();247 if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");248 const remoteEphemeralPublic = msg1.slice(0, 32);249 const encrypted1 = msg1.slice(32);250251 h = mixHash(h, remoteEphemeralPublic);252 const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true);253 const [ck1, k1] = hkdf2(ck, es);254 ck = ck1;255256 const cipher1 = new ChaCha20Poly1305(k1);257 const nonce1 = new Uint8Array(12);258 const decrypted1 = cipher1.open(nonce1, encrypted1, h);259 if (!decrypted1) {260 throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");261 }262 h = mixHash(h, encrypted1);263264 265 const ephemeral = generateKeyPair();266 h = mixHash(h, ephemeral.publicKey);267 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);268 const [ck2, k2] = hkdf2(ck, ee);269 ck = ck2;270271 const cipher2 = new ChaCha20Poly1305(k2);272 const nonce2 = new Uint8Array(12);273 const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);274 h = mixHash(h, encrypted2);275276 const msg2 = new Uint8Array(32 + encrypted2.length);277 msg2.set(ephemeral.publicKey, 0);278 msg2.set(encrypted2, 32);279 await send(msg2);280281 282 283 284 const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));285 const handshakeHash = h.slice();286287 let sendNonce = 0n;288 let recvNonce = 0n;289290 function nonceBytes(n: bigint): Uint8Array {291 const buf = new Uint8Array(12);292 new DataView(buf.buffer).setBigUint64(4, n, true);293 return buf;294 }295296 const sendCipher = new ChaCha20Poly1305(responderSendKey);297 const recvCipher = new ChaCha20Poly1305(initiatorSendKey);298299 return {300 encrypt(plaintext) {301 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));302 },303 decrypt(ciphertext) {304 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));305 if (!result) throw new Error("Noise NK: decryption failed");306 return result;307 },308 handshakeHash,309 };310}311312313314315316function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {317 if (a.length !== b.length) return false;318 let diff = 0;319 for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;320 return diff === 0;321}322323324325326327328329330331export async function performResponderAuth(332 session: NoiseNKSession,333 ourPrivateKey: Uint8Array,334 ourPublicKey: Uint8Array,335 sendEncryptedJson: (data: Uint8Array) => Promise<void>,336 receiveEncryptedJson: () => Promise<Uint8Array>,337): Promise<{ initiatorPublicKey: Uint8Array }> {338 339340 const nonce = new Uint8Array(32);341 crypto.getRandomValues(nonce);342343 const challenge: AuthChallengeFrame = {344 type: "CHALLENGE",345 nonce_hex: bytesToHex(nonce),346 responder_public_key_hex: bytesToHex(ourPublicKey),347 };348 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));349350 const proofBytes = await receiveEncryptedJson();351 const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;352 if (proofFrame.type !== "PROOF") {353 throw new Error("expected PROOF frame");354 }355356 const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);357 const claimedProof = hexToBytes(proofFrame.proof_hex);358 if (initiatorPublicKey.length !== 32) {359 throw new Error("initiator public key must be 32 bytes");360 }361 if (claimedProof.length !== 32) {362 throw new Error("proof must be 32 bytes");363 }364365 366 const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);367 const mac = new HMAC(SHA256, dh);368 369 370 mac.update(session.handshakeHash);371 mac.update(AUTH_DOMAIN);372 mac.update(initiatorPublicKey);373 mac.update(ourPublicKey);374 mac.update(nonce);375 const expected = mac.digest();376377 if (!constantTimeEq(expected, claimedProof)) {378 throw new Error("auth proof did not verify");379 }380 return { initiatorPublicKey };381}382383export async function performInitiatorAuth(384 session: NoiseNKSession,385 ourPrivateKey: Uint8Array,386 ourPublicKey: Uint8Array,387 responderPublicKey: Uint8Array,388 sendEncryptedJson: (data: Uint8Array) => Promise<void>,389 receiveEncryptedJson: () => Promise<Uint8Array>,390): Promise<void> {391 392 393394 const challengeBytes = await receiveEncryptedJson();395 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;396 if (challenge.type !== "CHALLENGE") {397 throw new Error("expected CHALLENGE frame");398 }399 const nonce = hexToBytes(challenge.nonce_hex);400 const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);401 402 403 404 if (405 responderKeyFromChallenge.length !== responderPublicKey.length ||406 !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])407 ) {408 throw new Error("responder key in challenge does not match Noise handshake");409 }410411 const proof = computeAuthProof(412 ourPrivateKey,413 ourPublicKey,414 responderPublicKey,415 nonce,416 session.handshakeHash,417 );418 const proofFrame: AuthProofFrame = {419 type: "PROOF",420 initiator_public_key_hex: bytesToHex(ourPublicKey),421 proof_hex: bytesToHex(proof),422 };423 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));424}425426export { bytesToHex, hexToBytes };