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";25const KK_PROTOCOL_NAME = "Noise_KK_25519_ChaChaPoly_BLAKE2s";2627function initProtocolHash(protocolName: string): Uint8Array {28 const nameBytes = new TextEncoder().encode(protocolName);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}3839export function initHash(): Uint8Array {40 return initProtocolHash(PROTOCOL_NAME);41}4243function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {44 const blake = new BLAKE2s(32);45 blake.update(h);46 blake.update(data);47 return blake.digest();48}4950export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {51 52 53 54 55 56 57 58 const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); 59 const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();60 const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();61 return [k1, k2];62}6364export interface NoiseNKSession {65 encrypt(plaintext: Uint8Array): Uint8Array;66 decrypt(ciphertext: Uint8Array): Uint8Array;67 handshakeHash: Uint8Array;68}6970interface NoiseKeyPair {71 publicKey: Uint8Array;72 secretKey: Uint8Array;73}7475function requireX25519Key(key: Uint8Array, label: string): void {76 if (key.length !== 32) throw new Error(`${label} must be 32 bytes`);77}787980818283848586878889export async function noiseKKInitiator(90 ourStaticPrivateKey: Uint8Array,91 ourStaticPublicKey: Uint8Array,92 responderStaticPublicKey: Uint8Array,93 send: (msg: Uint8Array) => Promise<void>,94 receive: () => Promise<Uint8Array>,95 ephemeral: NoiseKeyPair = generateKeyPair(),96): Promise<NoiseNKSession> {97 requireX25519Key(ourStaticPrivateKey, "initiator private key");98 requireX25519Key(ourStaticPublicKey, "initiator public key");99 requireX25519Key(responderStaticPublicKey, "responder public key");100 requireX25519Key(ephemeral.secretKey, "initiator ephemeral private key");101 requireX25519Key(ephemeral.publicKey, "initiator ephemeral public key");102103 let h: Uint8Array = initProtocolHash(KK_PROTOCOL_NAME);104 let ck: Uint8Array = h.slice();105 h = mixHash(h, ourStaticPublicKey);106 h = mixHash(h, responderStaticPublicKey);107108 109 h = mixHash(h, ephemeral.publicKey);110 let keys = hkdf2(111 ck,112 sharedKey(ephemeral.secretKey, responderStaticPublicKey, true),113 );114 ck = keys[0];115 keys = hkdf2(116 ck,117 sharedKey(ourStaticPrivateKey, responderStaticPublicKey, true),118 );119 ck = keys[0];120 const encrypted1 = new ChaCha20Poly1305(keys[1]).seal(121 new Uint8Array(12),122 new Uint8Array(0),123 h,124 );125 h = mixHash(h, encrypted1);126 const msg1 = new Uint8Array(32 + encrypted1.length);127 msg1.set(ephemeral.publicKey, 0);128 msg1.set(encrypted1, 32);129 await send(msg1);130131 132 const msg2 = await receive();133 if (msg2.length < 48) throw new Error("Noise KK: msg2 too short");134 const responderEphemeralPublic = msg2.slice(0, 32);135 const encrypted2 = msg2.slice(32);136 h = mixHash(h, responderEphemeralPublic);137 keys = hkdf2(138 ck,139 sharedKey(ephemeral.secretKey, responderEphemeralPublic, true),140 );141 ck = keys[0];142 keys = hkdf2(143 ck,144 sharedKey(ourStaticPrivateKey, responderEphemeralPublic, true),145 );146 ck = keys[0];147 const opened2 = new ChaCha20Poly1305(keys[1]).open(148 new Uint8Array(12),149 encrypted2,150 h,151 );152 if (!opened2) {153 throw new Error("Noise KK: msg2 auth failed — wrong peer key or MITM");154 }155 h = mixHash(h, encrypted2);156157 const [sendKey, receiveKey] = hkdf2(ck, new Uint8Array(0));158 return transportSession(sendKey, receiveKey, h, "KK");159}160161162163164165export async function noiseKKResponder(166 ourStaticPrivateKey: Uint8Array,167 ourStaticPublicKey: Uint8Array,168 expectedInitiatorStaticPublicKey: Uint8Array,169 send: (msg: Uint8Array) => Promise<void>,170 receive: () => Promise<Uint8Array>,171 ephemeral: NoiseKeyPair = generateKeyPair(),172): Promise<NoiseNKSession> {173 requireX25519Key(ourStaticPrivateKey, "responder private key");174 requireX25519Key(ourStaticPublicKey, "responder public key");175 requireX25519Key(expectedInitiatorStaticPublicKey, "initiator public key");176 requireX25519Key(ephemeral.secretKey, "responder ephemeral private key");177 requireX25519Key(ephemeral.publicKey, "responder ephemeral public key");178179 let h: Uint8Array = initProtocolHash(KK_PROTOCOL_NAME);180 let ck: Uint8Array = h.slice();181 h = mixHash(h, expectedInitiatorStaticPublicKey);182 h = mixHash(h, ourStaticPublicKey);183184 185 const msg1 = await receive();186 if (msg1.length < 48) throw new Error("Noise KK: msg1 too short");187 const initiatorEphemeralPublic = msg1.slice(0, 32);188 const encrypted1 = msg1.slice(32);189 h = mixHash(h, initiatorEphemeralPublic);190 let keys = hkdf2(191 ck,192 sharedKey(ourStaticPrivateKey, initiatorEphemeralPublic, true),193 );194 ck = keys[0];195 keys = hkdf2(196 ck,197 sharedKey(ourStaticPrivateKey, expectedInitiatorStaticPublicKey, true),198 );199 ck = keys[0];200 const opened1 = new ChaCha20Poly1305(keys[1]).open(201 new Uint8Array(12),202 encrypted1,203 h,204 );205 if (!opened1) {206 throw new Error("Noise KK: msg1 auth failed — wrong peer key or MITM");207 }208 h = mixHash(h, encrypted1);209210 211 h = mixHash(h, ephemeral.publicKey);212 keys = hkdf2(213 ck,214 sharedKey(ephemeral.secretKey, initiatorEphemeralPublic, true),215 );216 ck = keys[0];217 keys = hkdf2(218 ck,219 sharedKey(ephemeral.secretKey, expectedInitiatorStaticPublicKey, true),220 );221 ck = keys[0];222 const encrypted2 = new ChaCha20Poly1305(keys[1]).seal(223 new Uint8Array(12),224 new Uint8Array(0),225 h,226 );227 h = mixHash(h, encrypted2);228 const msg2 = new Uint8Array(32 + encrypted2.length);229 msg2.set(ephemeral.publicKey, 0);230 msg2.set(encrypted2, 32);231 await send(msg2);232233 const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));234 return transportSession(responderSendKey, initiatorSendKey, h, "KK");235}236237function transportSession(238 sendKey: Uint8Array,239 receiveKey: Uint8Array,240 handshakeHash: Uint8Array,241 pattern: "KK",242): NoiseNKSession {243 let sendNonce = 0n;244 let receiveNonce = 0n;245 const sendCipher = new ChaCha20Poly1305(sendKey);246 const receiveCipher = new ChaCha20Poly1305(receiveKey);247 const nonceBytes = (n: bigint) => {248 const nonce = new Uint8Array(12);249 new DataView(nonce.buffer).setBigUint64(4, n, true);250 return nonce;251 };252 return {253 encrypt(plaintext) {254 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));255 },256 decrypt(ciphertext) {257 const plaintext = receiveCipher.open(258 nonceBytes(receiveNonce++),259 ciphertext,260 new Uint8Array(0),261 );262 if (!plaintext) throw new Error(`Noise ${pattern}: decryption failed`);263 return plaintext;264 },265 handshakeHash: handshakeHash.slice(),266 };267}268269270271272273274export async function noiseNKInitiator(275 responderStaticPublicKey: Uint8Array,276 send: (msg: Uint8Array) => Promise<void>,277 receive: () => Promise<Uint8Array>,278): Promise<NoiseNKSession> {279 let h: Uint8Array = initHash();280 let ck: Uint8Array = h.slice();281282 283 h = mixHash(h, responderStaticPublicKey);284285 const ephemeral = generateKeyPair();286287 288 h = mixHash(h, ephemeral.publicKey);289 290 291 const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey, true);292 const [ck1, k1] = hkdf2(ck, es);293 ck = ck1;294295 const cipher1 = new ChaCha20Poly1305(k1);296 const nonce1 = new Uint8Array(12);297 const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);298 h = mixHash(h, encrypted1);299300 const msg1 = new Uint8Array(32 + encrypted1.length);301 msg1.set(ephemeral.publicKey, 0);302 msg1.set(encrypted1, 32);303 await send(msg1);304305 306 const msg2 = await receive();307 if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");308309 const remoteEphemeralPublic = msg2.slice(0, 32);310 const encrypted2 = msg2.slice(32);311312 h = mixHash(h, remoteEphemeralPublic);313 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);314 const [ck2, k2] = hkdf2(ck, ee);315 ck = ck2;316317 const cipher2 = new ChaCha20Poly1305(k2);318 const nonce2 = new Uint8Array(12);319 const decrypted2 = cipher2.open(nonce2, encrypted2, h);320 if (!decrypted2) {321 throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");322 }323 h = mixHash(h, encrypted2);324325 326 const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));327 const handshakeHash = h.slice();328329 let sendNonce = 0n;330 let recvNonce = 0n;331332 function nonceBytes(n: bigint): Uint8Array {333 const buf = new Uint8Array(12);334 new DataView(buf.buffer).setBigUint64(4, n, true);335 return buf;336 }337338 const sendCipher = new ChaCha20Poly1305(sendKey);339 const recvCipher = new ChaCha20Poly1305(recvKey);340341 return {342 encrypt(plaintext) {343 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));344 },345 decrypt(ciphertext) {346 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));347 if (!result) throw new Error("Noise NK: decryption failed");348 return result;349 },350 handshakeHash,351 };352}353354355356357358359360361362363364365366367368369370const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");371372export function computeAuthProof(373 initiatorPrivate: Uint8Array,374 initiatorPublic: Uint8Array,375 responderPublic: Uint8Array,376 nonce: Uint8Array,377 handshakeHash: Uint8Array,378): Uint8Array {379 if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");380 if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");381382 const dh = sharedKey(initiatorPrivate, responderPublic, true);383 const mac = new HMAC(SHA256, dh);384 385 386 387 mac.update(handshakeHash);388 mac.update(AUTH_DOMAIN);389 mac.update(initiatorPublic);390 mac.update(responderPublic);391 mac.update(nonce);392 return mac.digest();393}394395interface AuthChallengeFrame {396 type: "CHALLENGE";397 nonce_hex: string;398 responder_public_key_hex: string;399}400401interface AuthProofFrame {402 type: "PROOF";403 initiator_public_key_hex: string;404 proof_hex: string;405}406407function bytesToHex(bytes: Uint8Array): string {408 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");409}410411function hexToBytes(hex: string): Uint8Array {412 if (hex.length % 2 !== 0) throw new Error("invalid hex");413 const bytes = new Uint8Array(hex.length / 2);414 for (let i = 0; i < hex.length; i += 2) {415 bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);416 }417 return bytes;418}419420421422423424425426427428429430431432433434435export async function noiseNKResponder(436 ourStaticPrivateKey: Uint8Array,437 ourStaticPublicKey: Uint8Array,438 send: (msg: Uint8Array) => Promise<void>,439 receive: () => Promise<Uint8Array>,440): Promise<NoiseNKSession> {441 let h: Uint8Array = initHash();442 let ck: Uint8Array = h.slice();443444 445 446 h = mixHash(h, ourStaticPublicKey);447448 449 const msg1 = await receive();450 if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");451 const remoteEphemeralPublic = msg1.slice(0, 32);452 const encrypted1 = msg1.slice(32);453454 h = mixHash(h, remoteEphemeralPublic);455 const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true);456 const [ck1, k1] = hkdf2(ck, es);457 ck = ck1;458459 const cipher1 = new ChaCha20Poly1305(k1);460 const nonce1 = new Uint8Array(12);461 const decrypted1 = cipher1.open(nonce1, encrypted1, h);462 if (!decrypted1) {463 throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");464 }465 h = mixHash(h, encrypted1);466467 468 const ephemeral = generateKeyPair();469 h = mixHash(h, ephemeral.publicKey);470 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);471 const [ck2, k2] = hkdf2(ck, ee);472 ck = ck2;473474 const cipher2 = new ChaCha20Poly1305(k2);475 const nonce2 = new Uint8Array(12);476 const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);477 h = mixHash(h, encrypted2);478479 const msg2 = new Uint8Array(32 + encrypted2.length);480 msg2.set(ephemeral.publicKey, 0);481 msg2.set(encrypted2, 32);482 await send(msg2);483484 485 486 487 const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));488 const handshakeHash = h.slice();489490 let sendNonce = 0n;491 let recvNonce = 0n;492493 function nonceBytes(n: bigint): Uint8Array {494 const buf = new Uint8Array(12);495 new DataView(buf.buffer).setBigUint64(4, n, true);496 return buf;497 }498499 const sendCipher = new ChaCha20Poly1305(responderSendKey);500 const recvCipher = new ChaCha20Poly1305(initiatorSendKey);501502 return {503 encrypt(plaintext) {504 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));505 },506 decrypt(ciphertext) {507 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));508 if (!result) throw new Error("Noise NK: decryption failed");509 return result;510 },511 handshakeHash,512 };513}514515516517518519function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {520 if (a.length !== b.length) return false;521 let diff = 0;522 for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;523 return diff === 0;524}525526527528529530531532533534export async function performResponderAuth(535 session: NoiseNKSession,536 ourPrivateKey: Uint8Array,537 ourPublicKey: Uint8Array,538 sendEncryptedJson: (data: Uint8Array) => Promise<void>,539 receiveEncryptedJson: () => Promise<Uint8Array>,540): Promise<{ initiatorPublicKey: Uint8Array }> {541 542543 const nonce = new Uint8Array(32);544 crypto.getRandomValues(nonce);545546 const challenge: AuthChallengeFrame = {547 type: "CHALLENGE",548 nonce_hex: bytesToHex(nonce),549 responder_public_key_hex: bytesToHex(ourPublicKey),550 };551 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));552553 const proofBytes = await receiveEncryptedJson();554 const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;555 if (proofFrame.type !== "PROOF") {556 throw new Error("expected PROOF frame");557 }558559 const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);560 const claimedProof = hexToBytes(proofFrame.proof_hex);561 if (initiatorPublicKey.length !== 32) {562 throw new Error("initiator public key must be 32 bytes");563 }564 if (claimedProof.length !== 32) {565 throw new Error("proof must be 32 bytes");566 }567568 569 const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);570 const mac = new HMAC(SHA256, dh);571 572 573 mac.update(session.handshakeHash);574 mac.update(AUTH_DOMAIN);575 mac.update(initiatorPublicKey);576 mac.update(ourPublicKey);577 mac.update(nonce);578 const expected = mac.digest();579580 if (!constantTimeEq(expected, claimedProof)) {581 throw new Error("auth proof did not verify");582 }583 return { initiatorPublicKey };584}585586export async function performInitiatorAuth(587 session: NoiseNKSession,588 ourPrivateKey: Uint8Array,589 ourPublicKey: Uint8Array,590 responderPublicKey: Uint8Array,591 sendEncryptedJson: (data: Uint8Array) => Promise<void>,592 receiveEncryptedJson: () => Promise<Uint8Array>,593): Promise<void> {594 595 596597 const challengeBytes = await receiveEncryptedJson();598 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;599 if (challenge.type !== "CHALLENGE") {600 throw new Error("expected CHALLENGE frame");601 }602 const nonce = hexToBytes(challenge.nonce_hex);603 const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);604 605 606 607 if (608 responderKeyFromChallenge.length !== responderPublicKey.length ||609 !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])610 ) {611 throw new Error("responder key in challenge does not match Noise handshake");612 }613614 const proof = computeAuthProof(615 ourPrivateKey,616 ourPublicKey,617 responderPublicKey,618 nonce,619 session.handshakeHash,620 );621 const proofFrame: AuthProofFrame = {622 type: "PROOF",623 initiator_public_key_hex: bytesToHex(ourPublicKey),624 proof_hex: bytesToHex(proof),625 };626 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));627}628629export { bytesToHex, hexToBytes };