1234567891011121314import { generateKeyPair, sharedKey } from "@stablelib/x25519";15import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";16import { BLAKE2s } from "@stablelib/blake2s";17import { HKDF } from "@stablelib/hkdf";18import { SHA256 } from "@stablelib/sha256";19import { HMAC } from "@stablelib/hmac";2021const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2223function initHash(): Uint8Array {24 const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);25 if (nameBytes.length <= 32) {26 const h = new Uint8Array(32);27 h.set(nameBytes);28 return h;29 }30 const sha = new SHA256();31 sha.update(nameBytes);32 return sha.digest();33}3435function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {36 const blake = new BLAKE2s(32);37 blake.update(h);38 blake.update(data);39 return blake.digest();40}4142function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {43 const out = new HKDF(SHA256, ikm, ck).expand(64);44 return [out.slice(0, 32), out.slice(32, 64)];45}4647export interface NoiseNKSession {48 encrypt(plaintext: Uint8Array): Uint8Array;49 decrypt(ciphertext: Uint8Array): Uint8Array;50 handshakeHash: Uint8Array;51}52535455565758export async function noiseNKInitiator(59 responderStaticPublicKey: Uint8Array,60 send: (msg: Uint8Array) => Promise<void>,61 receive: () => Promise<Uint8Array>,62): Promise<NoiseNKSession> {63 let h: Uint8Array = initHash();64 let ck: Uint8Array = h.slice();6566 67 h = mixHash(h, responderStaticPublicKey);6869 const ephemeral = generateKeyPair();7071 72 h = mixHash(h, ephemeral.publicKey);73 const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey);74 const [ck1, k1] = hkdf2(ck, es);75 ck = ck1;7677 const cipher1 = new ChaCha20Poly1305(k1);78 const nonce1 = new Uint8Array(12);79 const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);80 h = mixHash(h, encrypted1);8182 const msg1 = new Uint8Array(32 + encrypted1.length);83 msg1.set(ephemeral.publicKey, 0);84 msg1.set(encrypted1, 32);85 await send(msg1);8687 88 const msg2 = await receive();89 if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");9091 const remoteEphemeralPublic = msg2.slice(0, 32);92 const encrypted2 = msg2.slice(32);9394 h = mixHash(h, remoteEphemeralPublic);95 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic);96 const [ck2, k2] = hkdf2(ck, ee);97 ck = ck2;9899 const cipher2 = new ChaCha20Poly1305(k2);100 const nonce2 = new Uint8Array(12);101 const decrypted2 = cipher2.open(nonce2, encrypted2, h);102 if (!decrypted2) {103 throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");104 }105 h = mixHash(h, encrypted2);106107 108 const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));109 const handshakeHash = h.slice();110111 let sendNonce = 0n;112 let recvNonce = 0n;113114 function nonceBytes(n: bigint): Uint8Array {115 const buf = new Uint8Array(12);116 new DataView(buf.buffer).setBigUint64(4, n, true);117 return buf;118 }119120 const sendCipher = new ChaCha20Poly1305(sendKey);121 const recvCipher = new ChaCha20Poly1305(recvKey);122123 return {124 encrypt(plaintext) {125 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));126 },127 decrypt(ciphertext) {128 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));129 if (!result) throw new Error("Noise NK: decryption failed");130 return result;131 },132 handshakeHash,133 };134}135136137138139140141142143144145146147148149150151152153154const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");155156export function computeAuthProof(157 initiatorPrivate: Uint8Array,158 initiatorPublic: Uint8Array,159 responderPublic: Uint8Array,160 nonce: Uint8Array,161): Uint8Array {162 if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");163164 const dh = sharedKey(initiatorPrivate, responderPublic);165 const mac = new HMAC(SHA256, dh);166 mac.update(AUTH_DOMAIN);167 mac.update(initiatorPublic);168 mac.update(responderPublic);169 mac.update(nonce);170 return mac.digest();171}172173interface AuthChallengeFrame {174 type: "CHALLENGE";175 nonce_hex: string;176 responder_public_key_hex: string;177}178179interface AuthProofFrame {180 type: "PROOF";181 initiator_public_key_hex: string;182 proof_hex: string;183}184185function bytesToHex(bytes: Uint8Array): string {186 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");187}188189function hexToBytes(hex: string): Uint8Array {190 if (hex.length % 2 !== 0) throw new Error("invalid hex");191 const bytes = new Uint8Array(hex.length / 2);192 for (let i = 0; i < hex.length; i += 2) {193 bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);194 }195 return bytes;196}197198199200201202203204export async function performInitiatorAuth(205 session: NoiseNKSession,206 ourPrivateKey: Uint8Array,207 ourPublicKey: Uint8Array,208 responderPublicKey: Uint8Array,209 sendEncryptedJson: (data: Uint8Array) => Promise<void>,210 receiveEncryptedJson: () => Promise<Uint8Array>,211): Promise<void> {212 void session; 213214 const challengeBytes = await receiveEncryptedJson();215 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;216 if (challenge.type !== "CHALLENGE") {217 throw new Error("expected CHALLENGE frame");218 }219 const nonce = hexToBytes(challenge.nonce_hex);220 const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);221 222 223 224 if (225 responderKeyFromChallenge.length !== responderPublicKey.length ||226 !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])227 ) {228 throw new Error("responder key in challenge does not match Noise handshake");229 }230231 const proof = computeAuthProof(ourPrivateKey, ourPublicKey, responderPublicKey, nonce);232 const proofFrame: AuthProofFrame = {233 type: "PROOF",234 initiator_public_key_hex: bytesToHex(ourPublicKey),235 proof_hex: bytesToHex(proof),236 };237 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));238}239240export { bytesToHex, hexToBytes };