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";2223242526const X25519_SMALL_ORDER_PUBKEYS = new Set([27 "00".repeat(32),28 "01" + "00".repeat(31),29 "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",30 "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",31 "ec" + "ff".repeat(30) + "7f",32 "ed" + "ff".repeat(30) + "7f",33 "ee" + "ff".repeat(30) + "7f",34 "cdeb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",35 "4c9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",36]);3738export function isSmallOrderPubkey(publicKey: Uint8Array): boolean {39 return publicKey.length === 32 && X25519_SMALL_ORDER_PUBKEYS.has(bytesToHex(publicKey));40}4142function initProtocolHash(protocolName: string): Uint8Array {43 const nameBytes = new TextEncoder().encode(protocolName);44 if (nameBytes.length <= 32) {45 const h = new Uint8Array(32);46 h.set(nameBytes);47 return h;48 }49 50 51 return new BLAKE2s(32).update(nameBytes).digest();52}5354export function initHash(): Uint8Array {55 return initProtocolHash(PROTOCOL_NAME);56}5758export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {59 60 61 62 63 64 65 66 const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); 67 const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();68 const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();69 return [k1, k2];70}7172export interface NoiseNKSession {73 encrypt(plaintext: Uint8Array): Uint8Array;74 decrypt(ciphertext: Uint8Array): Uint8Array;75 readonly handshakeHash: Uint8Array;76 rekeyOutgoing(): void;77 rekeyIncoming(): void;78 close(): void;79}8081interface NoiseKeyPair {82 publicKey: Uint8Array;83 secretKey: Uint8Array;84}858687888990919293949596export async function noiseKKInitiator(97 ourStaticPrivateKey: Uint8Array,98 ourStaticPublicKey: Uint8Array,99 responderStaticPublicKey: Uint8Array,100 send: (msg: Uint8Array) => Promise<void>,101 receive: () => Promise<Uint8Array>,102 ephemeral?: NoiseKeyPair,103): Promise<NoiseNKSession> {104 if (isSmallOrderPubkey(responderStaticPublicKey)) {105 throw new Error("noise: small-order X25519 responder static key");106 }107 return rustNoiseKkInitiator(108 ourStaticPrivateKey,109 ourStaticPublicKey,110 responderStaticPublicKey,111 send,112 receive,113 ephemeral,114 );115}116117118119120121export async function noiseKKResponder(122 ourStaticPrivateKey: Uint8Array,123 ourStaticPublicKey: Uint8Array,124 expectedInitiatorStaticPublicKey: Uint8Array,125 send: (msg: Uint8Array) => Promise<void>,126 receive: () => Promise<Uint8Array>,127 ephemeral?: NoiseKeyPair,128): Promise<NoiseNKSession> {129 return rustNoiseKkResponder(130 ourStaticPrivateKey,131 ourStaticPublicKey,132 expectedInitiatorStaticPublicKey,133 send,134 receive,135 ephemeral,136 );137}138139140141142143144export async function noiseNKInitiator(145 responderStaticPublicKey: Uint8Array,146 send: (msg: Uint8Array) => Promise<void>,147 receive: () => Promise<Uint8Array>,148 ephemeral?: NoiseKeyPair,149): Promise<NoiseNKSession> {150 if (isSmallOrderPubkey(responderStaticPublicKey)) {151 throw new Error("noise: small-order X25519 responder static key");152 }153 return rustNoiseNkInitiator(154 responderStaticPublicKey,155 send,156 receive,157 ephemeral,158 );159}160161162163164165166167168169170171172173174175176177const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");178179export function computeAuthProof(180 initiatorPrivate: Uint8Array,181 initiatorPublic: Uint8Array,182 responderPublic: Uint8Array,183 nonce: Uint8Array,184 handshakeHash: Uint8Array,185): Uint8Array {186 if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");187 if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");188 if (isSmallOrderPubkey(responderPublic)) {189 throw new Error("small-order X25519 responder public key");190 }191192 const dh = sharedKey(initiatorPrivate, responderPublic, true);193 const mac = new HMAC(SHA256, dh);194 195 196 197 mac.update(handshakeHash);198 mac.update(AUTH_DOMAIN);199 mac.update(initiatorPublic);200 mac.update(responderPublic);201 mac.update(nonce);202 return mac.digest();203}204205interface AuthChallengeFrame {206 type: "CHALLENGE";207 nonce_hex: string;208 responder_public_key_hex: string;209}210211interface AuthProofFrame {212 type: "PROOF";213 initiator_public_key_hex: string;214 proof_hex: string;215}216217function bytesToHex(bytes: Uint8Array): string {218 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");219}220221function hexToBytes(hex: string): Uint8Array {222 if (hex.length % 2 !== 0) throw new Error("invalid hex");223 const bytes = new Uint8Array(hex.length / 2);224 for (let i = 0; i < hex.length; i += 2) {225 bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);226 }227 return bytes;228}229230231232233234235236237238239240241242243244export async function noiseNKResponder(245 ourStaticPrivateKey: Uint8Array,246 ourStaticPublicKey: Uint8Array,247 send: (msg: Uint8Array) => Promise<void>,248 receive: () => Promise<Uint8Array>,249 ephemeral?: NoiseKeyPair,250): Promise<NoiseNKSession> {251 return rustNoiseNkResponder(252 ourStaticPrivateKey,253 ourStaticPublicKey,254 send,255 receive,256 ephemeral,257 );258}259260261262263264function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {265 if (a.length !== b.length) return false;266 let diff = 0;267 for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;268 return diff === 0;269}270271272273274275276277278279export async function performResponderAuth(280 session: NoiseNKSession,281 ourPrivateKey: Uint8Array,282 ourPublicKey: Uint8Array,283 sendEncryptedJson: (data: Uint8Array) => Promise<void>,284 receiveEncryptedJson: () => Promise<Uint8Array>,285): Promise<{ initiatorPublicKey: Uint8Array }> {286 287288 const nonce = new Uint8Array(32);289 crypto.getRandomValues(nonce);290291 const challenge: AuthChallengeFrame = {292 type: "CHALLENGE",293 nonce_hex: bytesToHex(nonce),294 responder_public_key_hex: bytesToHex(ourPublicKey),295 };296 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));297298 const proofBytes = await receiveEncryptedJson();299 const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;300 if (proofFrame.type !== "PROOF") {301 throw new Error("expected PROOF frame");302 }303304 const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);305 const claimedProof = hexToBytes(proofFrame.proof_hex);306 if (initiatorPublicKey.length !== 32) {307 throw new Error("initiator public key must be 32 bytes");308 }309 if (claimedProof.length !== 32) {310 throw new Error("proof must be 32 bytes");311 }312 if (isSmallOrderPubkey(initiatorPublicKey)) {313 throw new Error("small-order X25519 initiator public key");314 }315316 317 const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);318 const mac = new HMAC(SHA256, dh);319 320 321 mac.update(session.handshakeHash);322 mac.update(AUTH_DOMAIN);323 mac.update(initiatorPublicKey);324 mac.update(ourPublicKey);325 mac.update(nonce);326 const expected = mac.digest();327328 if (!constantTimeEq(expected, claimedProof)) {329 throw new Error("auth proof did not verify");330 }331 return { initiatorPublicKey };332}333334export async function performInitiatorAuth(335 session: NoiseNKSession,336 ourPrivateKey: Uint8Array,337 ourPublicKey: Uint8Array,338 responderPublicKey: Uint8Array,339 sendEncryptedJson: (data: Uint8Array) => Promise<void>,340 receiveEncryptedJson: () => Promise<Uint8Array>,341): Promise<void> {342 343 344345 const challengeBytes = await receiveEncryptedJson();346 const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;347 if (challenge.type !== "CHALLENGE") {348 throw new Error("expected CHALLENGE frame");349 }350 const nonce = hexToBytes(challenge.nonce_hex);351 const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);352 353 354 355 if (356 responderKeyFromChallenge.length !== responderPublicKey.length ||357 !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])358 ) {359 throw new Error("responder key in challenge does not match Noise handshake");360 }361362 const proof = computeAuthProof(363 ourPrivateKey,364 ourPublicKey,365 responderPublicKey,366 nonce,367 session.handshakeHash,368 );369 const proofFrame: AuthProofFrame = {370 type: "PROOF",371 initiator_public_key_hex: bytesToHex(ourPublicKey),372 proof_hex: bytesToHex(proof),373 };374 await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));375}376377export { bytesToHex, hexToBytes };