1import { HKDF } from "@stablelib/hkdf";2import { SHA256 } from "@stablelib/sha256";3import { sharedKey } from "@stablelib/x25519";4export const DEFAULT_RELAY_URL = "wss://relay.peachpasswords.com";5export const RENDEZVOUS_WINDOW_SECONDS = 3600;6export const PAIRING_RENDEZVOUS_TOKEN_BYTES = 16;78const SECRET_INFO = new TextEncoder().encode("peach-rendezvous-secret-v1");9const MESH_SECRET_INFO = new TextEncoder().encode("peach-mesh-rendezvous-v2");10const WINDOW_INFO = new TextEncoder().encode("peach-rendezvous-v1");11const EMPTY_SALT = new Uint8Array(0);12131415export function deriveRendezvousSecret(handshakeHash: Uint8Array): Uint8Array {16 if (handshakeHash.length !== 32) throw new Error("handshake hash must be exactly 32 bytes");17 return new HKDF(SHA256, handshakeHash, EMPTY_SALT, SECRET_INFO).expand(32);18}192021export function deriveMeshRendezvousSecret(22 ourPrivateKey: Uint8Array,23 ourPublicKey: Uint8Array,24 peerPublicKey: Uint8Array,25): Uint8Array {26 if (ourPrivateKey.length !== 32 || ourPublicKey.length !== 32 || peerPublicKey.length !== 32) {27 throw new Error("mesh keys must be exactly 32 bytes");28 }29 if (compareBytes(ourPublicKey, peerPublicKey) === 0) throw new Error("mesh peers must have distinct public keys");30 const shared = sharedKey(ourPrivateKey, peerPublicKey, true);31 const low = compareBytes(ourPublicKey, peerPublicKey) < 0 ? ourPublicKey : peerPublicKey;32 const high = low === ourPublicKey ? peerPublicKey : ourPublicKey;33 const info = new Uint8Array(MESH_SECRET_INFO.length + 64);34 info.set(MESH_SECRET_INFO); info.set(low, MESH_SECRET_INFO.length); info.set(high, MESH_SECRET_INFO.length + 32);35 const secret = new HKDF(SHA256, shared, EMPTY_SALT, info).expand(32);36 shared.fill(0); info.fill(0);37 return secret;38}394041424344454647export function deriveRendezvousId(48 rendezvousSecret: Uint8Array,49 windowIndex: number,50): string {51 if (rendezvousSecret.length !== 32) {52 throw new Error("rendezvous secret must be exactly 32 bytes");53 }54 if (!Number.isSafeInteger(windowIndex) || windowIndex < 0) {55 throw new Error("window index must be a non-negative safe integer");56 }57 const info = new Uint8Array(WINDOW_INFO.length + 8);58 info.set(WINDOW_INFO);59 new DataView(info.buffer).setBigUint64(WINDOW_INFO.length, BigInt(windowIndex), false);60 const derived = new HKDF(SHA256, rendezvousSecret, EMPTY_SALT, info).expand(32);61 return base64urlEncode(derived);62}6364export function rendezvousWindowIndex(65 unixSeconds: number,66 windowSeconds = RENDEZVOUS_WINDOW_SECONDS,67): number {68 if (!Number.isFinite(unixSeconds) || unixSeconds < 0) {69 throw new Error("unixSeconds must be non-negative");70 }71 if (!Number.isSafeInteger(windowSeconds) || windowSeconds <= 0) {72 throw new Error("windowSeconds must be a positive integer");73 }74 return Math.floor(unixSeconds / windowSeconds);75}767778export function waitingRendezvousIds(79 secret: Uint8Array,80 unixSeconds: number,81): string[] {82 const current = rendezvousWindowIndex(unixSeconds);83 return [current, Math.max(0, current - 1)].map((window) =>84 deriveRendezvousId(secret, window),85 );86}878889export function dialingRendezvousIds(90 secret: Uint8Array,91 unixSeconds: number,92): string[] {93 const current = rendezvousWindowIndex(unixSeconds);94 return [current, Math.max(0, current - 1), current + 1].map((window) =>95 deriveRendezvousId(secret, window),96 );97}9899100export function generatePairingRendezvousToken(): string {101 return base64urlEncode(crypto.getRandomValues(102 new Uint8Array(PAIRING_RENDEZVOUS_TOKEN_BYTES),103 ));104}105106107export function normalizeRelayUrl(input: string): string {108 const url = new URL(input.trim());109 if (url.protocol !== "wss:") throw new Error("relay URL must use wss://");110 if (url.username || url.password || url.search || url.hash) {111 throw new Error("relay URL must not contain credentials, query, or fragment");112 }113 url.pathname = url.pathname.replace(/\/+$/, "");114 return url.toString().replace(/\/$/, "");115}116117export function buildRendezvousUrl(relayUrl: string, rendezvousId: string): string {118 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {119 throw new Error("invalid rendezvous id");120 }121 return `${normalizeRelayUrl(relayUrl)}/r/${rendezvousId}`;122}123124export function bytesToHex(bytes: Uint8Array): string {125 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");126}127128export function rendezvousSecretFromHex(hex: string): Uint8Array {129 if (!/^[0-9a-f]{64}$/.test(hex)) {130 throw new Error("rendezvous secret must be 32-byte lowercase hex");131 }132 const out = new Uint8Array(32);133 for (let i = 0; i < out.length; i++) {134 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);135 }136 return out;137}138139function base64urlEncode(bytes: Uint8Array): string {140 let binary = "";141 for (const byte of bytes) binary += String.fromCharCode(byte);142 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");143}144145function compareBytes(a: Uint8Array, b: Uint8Array): number {146 for (let i = 0; i < a.length; i += 1) {147 if (a[i] !== b[i]) return a[i]! < b[i]! ? -1 : 1;148 }149 return 0;150}