1import { HKDF } from "@stablelib/hkdf";2import { SHA256 } from "@stablelib/sha256";34export 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 WINDOW_INFO = new TextEncoder().encode("peach-rendezvous-v1");10const EMPTY_SALT = new Uint8Array(0);111213export function deriveRendezvousSecret(handshakeHash: Uint8Array): Uint8Array {14 if (handshakeHash.length !== 32) {15 throw new Error("handshake hash must be exactly 32 bytes");16 }17 return new HKDF(SHA256, handshakeHash, EMPTY_SALT, SECRET_INFO).expand(32);18}192021222324252627export function deriveRendezvousId(28 rendezvousSecret: Uint8Array,29 windowIndex: number,30): string {31 if (rendezvousSecret.length !== 32) {32 throw new Error("rendezvous secret must be exactly 32 bytes");33 }34 if (!Number.isSafeInteger(windowIndex) || windowIndex < 0) {35 throw new Error("window index must be a non-negative safe integer");36 }37 const info = new Uint8Array(WINDOW_INFO.length + 8);38 info.set(WINDOW_INFO);39 new DataView(info.buffer).setBigUint64(WINDOW_INFO.length, BigInt(windowIndex), false);40 const derived = new HKDF(SHA256, rendezvousSecret, EMPTY_SALT, info).expand(32);41 return base64urlEncode(derived);42}4344export function rendezvousWindowIndex(45 unixSeconds: number,46 windowSeconds = RENDEZVOUS_WINDOW_SECONDS,47): number {48 if (!Number.isFinite(unixSeconds) || unixSeconds < 0) {49 throw new Error("unixSeconds must be non-negative");50 }51 if (!Number.isSafeInteger(windowSeconds) || windowSeconds <= 0) {52 throw new Error("windowSeconds must be a positive integer");53 }54 return Math.floor(unixSeconds / windowSeconds);55}565758export function waitingRendezvousIds(59 secret: Uint8Array,60 unixSeconds: number,61): string[] {62 const current = rendezvousWindowIndex(unixSeconds);63 return [current, Math.max(0, current - 1)].map((window) =>64 deriveRendezvousId(secret, window),65 );66}676869export function dialingRendezvousIds(70 secret: Uint8Array,71 unixSeconds: number,72): string[] {73 const current = rendezvousWindowIndex(unixSeconds);74 return [current, Math.max(0, current - 1), current + 1].map((window) =>75 deriveRendezvousId(secret, window),76 );77}787980export function generatePairingRendezvousToken(): string {81 return base64urlEncode(crypto.getRandomValues(82 new Uint8Array(PAIRING_RENDEZVOUS_TOKEN_BYTES),83 ));84}858687export function normalizeRelayUrl(input: string): string {88 const url = new URL(input.trim());89 if (url.protocol !== "wss:") throw new Error("relay URL must use wss://");90 if (url.username || url.password || url.search || url.hash) {91 throw new Error("relay URL must not contain credentials, query, or fragment");92 }93 url.pathname = url.pathname.replace(/\/+$/, "");94 return url.toString().replace(/\/$/, "");95}9697export function buildRendezvousUrl(relayUrl: string, rendezvousId: string): string {98 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {99 throw new Error("invalid rendezvous id");100 }101 return `${normalizeRelayUrl(relayUrl)}/r/${rendezvousId}`;102}103104export function bytesToHex(bytes: Uint8Array): string {105 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");106}107108export function rendezvousSecretFromHex(hex: string): Uint8Array {109 if (!/^[0-9a-f]{64}$/.test(hex)) {110 throw new Error("rendezvous secret must be 32-byte lowercase hex");111 }112 const out = new Uint8Array(32);113 for (let i = 0; i < out.length; i++) {114 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);115 }116 return out;117}118119function base64urlEncode(bytes: Uint8Array): string {120 let binary = "";121 for (const byte of bytes) binary += String.fromCharCode(byte);122 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");123}