12345678910import { sharedKey } from "@stablelib/x25519";11import { BLAKE2s } from "@stablelib/blake2s";12import { SHA256 } from "@stablelib/sha256";13import { HMAC } from "@stablelib/hmac";14import {15 rustNoiseKkInitiator,16 rustNoiseKkResponder,17 rustNoiseNkInitiator,18 rustNoiseNkResponder,19} from "./noise-core";2021const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2223function initProtocolHash(protocolName: string): Uint8Array {24 const nameBytes = new TextEncoder().encode(protocolName);25 if (nameBytes.length <= 32) {26 const h = new Uint8Array(32);27 h.set(nameBytes);28 return h;29 }30 31 32 return new BLAKE2s(32).update(nameBytes).digest();33}3435export function initHash(): Uint8Array {36 return initProtocolHash(PROTOCOL_NAME);37}3839export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {40 41 42 43 44 45 46 47 const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); 48 const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();49 const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();50 return [k1, k2];51}5253export interface NoiseNKSession {54 encrypt(plaintext: Uint8Array): Uint8Array;55 decrypt(ciphertext: Uint8Array): Uint8Array;56 readonly handshakeHash: Uint8Array;57 rekeyOutgoing(): void;58 rekeyIncoming(): void;59 close(): void;60}6162interface NoiseKeyPair {63 publicKey: Uint8Array;64 secretKey: Uint8Array;65}666768697071727374757677export async function noiseKKInitiator(78 ourStaticPrivateKey: Uint8Array,79 ourStaticPublicKey: Uint8Array,80 responderStaticPublicKey: Uint8Array,81 send: (msg: Uint8Array) => Promise<void>,82 receive: () => Promise<Uint8Array>,83 ephemeral?: NoiseKeyPair,84): Promise<NoiseNKSession> {85 return rustNoiseKkInitiator(86 ourStaticPrivateKey,87 ourStaticPublicKey,88 responderStaticPublicKey,89 send,90 receive,91 ephemeral,92 );93}949596979899export async function noiseKKResponder(100 ourStaticPrivateKey: Uint8Array,101 ourStaticPublicKey: Uint8Array,102 expectedInitiatorStaticPublicKey: Uint8Array,103 send: (msg: Uint8Array) => Promise<void>,104 receive: () => Promise<Uint8Array>,105 ephemeral?: NoiseKeyPair,106): Promise<NoiseNKSession> {107 return rustNoiseKkResponder(108 ourStaticPrivateKey,109 ourStaticPublicKey,110 expectedInitiatorStaticPublicKey,111 send,112 receive,113 ephemeral,114 );115}116117118119120121122export async function noiseNKInitiator(123 responderStaticPublicKey: Uint8Array,124 send: (msg: Uint8Array) => Promise<void>,125 receive: () => Promise<Uint8Array>,126 ephemeral?: NoiseKeyPair,127): Promise<NoiseNKSession> {128 return rustNoiseNkInitiator(129 responderStaticPublicKey,130 send,131 receive,132 ephemeral,133 );134}135136137138139140141142143144145146147148149150151152const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");153154export function computeAuthProof(155 initiatorPrivate: Uint8Array,156 initiatorPublic: Uint8Array,157 responderPublic: Uint8Array,158 nonce: Uint8Array,159 handshakeHash: Uint8Array,160): Uint8Array {161 if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");162 if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");163164 const dh = sharedKey(initiatorPrivate, responderPublic, true);165 const mac = new HMAC(SHA256, dh);166 167 168 169 mac.update(handshakeHash);170 mac.update(AUTH_DOMAIN);171 mac.update(initiatorPublic);172 mac.update(responderPublic);173 mac.update(nonce);174 return mac.digest();175}176177interface AuthChallengeFrame {178 type: "CHALLENGE";179 nonce_hex: string;180 responder_public_key_hex: string;181}182183interface AuthProofFrame {184 type: "PROOF";185 initiator_public_key_hex: string;186 proof_hex: string;187}188189function bytesToHex(bytes: Uint8Array): string {190 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");191}192193function hexToBytes(hex: string): Uint8Array {194 if (hex.length % 2 !== 0) throw new Error("invalid hex");195 const bytes = new Uint8Array(hex.length / 2);196 for (let i = 0; i < hex.length; i += 2) {197 bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);198 }199 return bytes;200}201202203204205206207208209210211212213214215216export async function noiseNKResponder(217 ourStaticPrivateKey: Uint8Array,218 ourStaticPublicKey: Uint8Array,219 send: (msg: Uint8Array) => Promise<void>,220 receive: () => Promise<Uint8Array>,221 ephemeral?: NoiseKeyPair,222): Promise<NoiseNKSession> {223 return rustNoiseNkResponder(224 ourStaticPrivateKey,225 ourStaticPublicKey,226 send,227 receive,228 ephemeral,229 );230}231232233234235236function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {237 if (a.length !== b.length) return false;238 let diff = 0;239 for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;240 return diff === 0;241}242243244245246247248249250251export async function performResponderAuth(252 session: NoiseNKSession,253 ourPrivateKey: Uint8Array,254 ourPublicKey: Uint8Array,255 sendEncryptedJson: (data: Uint8Array) => Promise<void>,256 receiveEncryptedJson: () => Promise<Uint8Array>,257): Promise<{ initiatorPublicKey: Uint8Array }> {258 259260 const nonce = new Uint8Array(32);261 crypto.getRandomValues(nonce);262263 const challenge: AuthChallengeFrame = {264 type: "CHALLENGE",265 nonce_hex: bytesToHex(nonce),266 responder_public_key_hex: bytesToHex(ourPublicKey),267 };268 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));269270 const proofBytes = await receiveEncryptedJson();271 const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;272 if (proofFrame.type !== "PROOF") {273 throw new Error("expected PROOF frame");274 }275276 const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);277 const claimedProof = hexToBytes(proofFrame.proof_hex);278 if (initiatorPublicKey.length !== 32) {279 throw new Error("initiator public key must be 32 bytes");280 }281 if (claimedProof.length !== 32) {282 throw new Error("proof must be 32 bytes");283 }284285 286 const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);287 const mac = new HMAC(SHA256, dh);288 289 290 mac.update(session.handshakeHash);291 mac.update(AUTH_DOMAIN);292 mac.update(initiatorPublicKey);293 mac.update(ourPublicKey);294 mac.update(nonce);295 const expected = mac.digest();296297 if (!constantTimeEq(expected, claimedProof)) {298 throw new Error("auth proof did not verify");299 }300 return { initiatorPublicKey };301}302303export async function performInitiatorAuth(304 session: NoiseNKSession,305 ourPrivateKey: Uint8Array,306 ourPublicKey: Uint8Array,307 responderPublicKey: Uint8Array,308 sendEncryptedJson: (data: Uint8Array) => Promise<void>,309 receiveEncryptedJson: () => Promise<Uint8Array>,310): Promise<void> {311 312 313314 const challengeBytes = await receiveEncryptedJson();315 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;316 if (challenge.type !== "CHALLENGE") {317 throw new Error("expected CHALLENGE frame");318 }319 const nonce = hexToBytes(challenge.nonce_hex);320 const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);321 322 323 324 if (325 responderKeyFromChallenge.length !== responderPublicKey.length ||326 !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])327 ) {328 throw new Error("responder key in challenge does not match Noise handshake");329 }330331 const proof = computeAuthProof(332 ourPrivateKey,333 ourPublicKey,334 responderPublicKey,335 nonce,336 session.handshakeHash,337 );338 const proofFrame: AuthProofFrame = {339 type: "PROOF",340 initiator_public_key_hex: bytesToHex(ourPublicKey),341 proof_hex: bytesToHex(proof),342 };343 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));344}345346export { bytesToHex, hexToBytes };