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}197198199200201202203204205206207208209210211212export async function noiseNKResponder(213 ourStaticPrivateKey: Uint8Array,214 ourStaticPublicKey: Uint8Array,215 send: (msg: Uint8Array) => Promise<void>,216 receive: () => Promise<Uint8Array>,217): Promise<NoiseNKSession> {218 let h: Uint8Array = initHash();219 let ck: Uint8Array = h.slice();220221 222 223 h = mixHash(h, ourStaticPublicKey);224225 226 const msg1 = await receive();227 if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");228 const remoteEphemeralPublic = msg1.slice(0, 32);229 const encrypted1 = msg1.slice(32);230231 h = mixHash(h, remoteEphemeralPublic);232 const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic);233 const [ck1, k1] = hkdf2(ck, es);234 ck = ck1;235236 const cipher1 = new ChaCha20Poly1305(k1);237 const nonce1 = new Uint8Array(12);238 const decrypted1 = cipher1.open(nonce1, encrypted1, h);239 if (!decrypted1) {240 throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");241 }242 h = mixHash(h, encrypted1);243244 245 const ephemeral = generateKeyPair();246 h = mixHash(h, ephemeral.publicKey);247 const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic);248 const [ck2, k2] = hkdf2(ck, ee);249 ck = ck2;250251 const cipher2 = new ChaCha20Poly1305(k2);252 const nonce2 = new Uint8Array(12);253 const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);254 h = mixHash(h, encrypted2);255256 const msg2 = new Uint8Array(32 + encrypted2.length);257 msg2.set(ephemeral.publicKey, 0);258 msg2.set(encrypted2, 32);259 await send(msg2);260261 262 263 264 const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));265 const handshakeHash = h.slice();266267 let sendNonce = 0n;268 let recvNonce = 0n;269270 function nonceBytes(n: bigint): Uint8Array {271 const buf = new Uint8Array(12);272 new DataView(buf.buffer).setBigUint64(4, n, true);273 return buf;274 }275276 const sendCipher = new ChaCha20Poly1305(responderSendKey);277 const recvCipher = new ChaCha20Poly1305(initiatorSendKey);278279 return {280 encrypt(plaintext) {281 return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));282 },283 decrypt(ciphertext) {284 const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));285 if (!result) throw new Error("Noise NK: decryption failed");286 return result;287 },288 handshakeHash,289 };290}291292293294295296function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {297 if (a.length !== b.length) return false;298 let diff = 0;299 for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;300 return diff === 0;301}302303304305306307308309310311312export async function performResponderAuth(313 session: NoiseNKSession,314 ourPrivateKey: Uint8Array,315 ourPublicKey: Uint8Array,316 sendEncryptedJson: (data: Uint8Array) => Promise<void>,317 receiveEncryptedJson: () => Promise<Uint8Array>,318): Promise<{ initiatorPublicKey: Uint8Array }> {319 void session;320321 const nonce = new Uint8Array(32);322 crypto.getRandomValues(nonce);323324 const challenge: AuthChallengeFrame = {325 type: "CHALLENGE",326 nonce_hex: bytesToHex(nonce),327 responder_public_key_hex: bytesToHex(ourPublicKey),328 };329 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));330331 const proofBytes = await receiveEncryptedJson();332 const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;333 if (proofFrame.type !== "PROOF") {334 throw new Error("expected PROOF frame");335 }336337 const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);338 const claimedProof = hexToBytes(proofFrame.proof_hex);339 if (initiatorPublicKey.length !== 32) {340 throw new Error("initiator public key must be 32 bytes");341 }342 if (claimedProof.length !== 32) {343 throw new Error("proof must be 32 bytes");344 }345346 347 const dh = sharedKey(ourPrivateKey, initiatorPublicKey);348 const mac = new HMAC(SHA256, dh);349 mac.update(AUTH_DOMAIN);350 mac.update(initiatorPublicKey);351 mac.update(ourPublicKey);352 mac.update(nonce);353 const expected = mac.digest();354355 if (!constantTimeEq(expected, claimedProof)) {356 throw new Error("auth proof did not verify");357 }358 return { initiatorPublicKey };359}360361export async function performInitiatorAuth(362 session: NoiseNKSession,363 ourPrivateKey: Uint8Array,364 ourPublicKey: Uint8Array,365 responderPublicKey: Uint8Array,366 sendEncryptedJson: (data: Uint8Array) => Promise<void>,367 receiveEncryptedJson: () => Promise<Uint8Array>,368): Promise<void> {369 void session; 370371 const challengeBytes = await receiveEncryptedJson();372 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;373 if (challenge.type !== "CHALLENGE") {374 throw new Error("expected CHALLENGE frame");375 }376 const nonce = hexToBytes(challenge.nonce_hex);377 const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);378 379 380 381 if (382 responderKeyFromChallenge.length !== responderPublicKey.length ||383 !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])384 ) {385 throw new Error("responder key in challenge does not match Noise handshake");386 }387388 const proof = computeAuthProof(ourPrivateKey, ourPublicKey, responderPublicKey, nonce);389 const proofFrame: AuthProofFrame = {390 type: "PROOF",391 initiator_public_key_hex: bytesToHex(ourPublicKey),392 proof_hex: bytesToHex(proof),393 };394 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));395}396397export { bytesToHex, hexToBytes };