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 MESH_SECRET_INFO = new TextEncoder().encode("peach-mesh-rendezvous-v1");10const WINDOW_INFO = new TextEncoder().encode("peach-rendezvous-v1");11const EMPTY_SALT = new Uint8Array(0);121314export function deriveRendezvousSecret(handshakeHash: Uint8Array): Uint8Array {15 if (handshakeHash.length !== 32) {16 throw new Error("handshake hash must be exactly 32 bytes");17 }18 return new HKDF(SHA256, handshakeHash, EMPTY_SALT, SECRET_INFO).expand(32);19}2021222324252627282930export function deriveMeshRendezvousSecret(31 vaultRoot: Uint8Array,32 ourPublicKey: Uint8Array,33 peerPublicKey: Uint8Array,34): Uint8Array {35 if (vaultRoot.length !== 32) {36 throw new Error("vault root must be exactly 32 bytes");37 }38 if (ourPublicKey.length !== 32 || peerPublicKey.length !== 32) {39 throw new Error("mesh public keys must be exactly 32 bytes");40 }41 const order = compareBytes(ourPublicKey, peerPublicKey);42 if (order === 0) {43 throw new Error("mesh peers must have distinct public keys");44 }45 const [low, high] =46 order < 047 ? [ourPublicKey, peerPublicKey]48 : [peerPublicKey, ourPublicKey];49 const info = new Uint8Array(MESH_SECRET_INFO.length + 64);50 info.set(MESH_SECRET_INFO, 0);51 info.set(low, MESH_SECRET_INFO.length);52 info.set(high, MESH_SECRET_INFO.length + 32);53 return new HKDF(SHA256, vaultRoot, EMPTY_SALT, info).expand(32);54}555657585960616263export function deriveRendezvousId(64 rendezvousSecret: Uint8Array,65 windowIndex: number,66): string {67 if (rendezvousSecret.length !== 32) {68 throw new Error("rendezvous secret must be exactly 32 bytes");69 }70 if (!Number.isSafeInteger(windowIndex) || windowIndex < 0) {71 throw new Error("window index must be a non-negative safe integer");72 }73 const info = new Uint8Array(WINDOW_INFO.length + 8);74 info.set(WINDOW_INFO);75 new DataView(info.buffer).setBigUint64(WINDOW_INFO.length, BigInt(windowIndex), false);76 const derived = new HKDF(SHA256, rendezvousSecret, EMPTY_SALT, info).expand(32);77 return base64urlEncode(derived);78}7980export function rendezvousWindowIndex(81 unixSeconds: number,82 windowSeconds = RENDEZVOUS_WINDOW_SECONDS,83): number {84 if (!Number.isFinite(unixSeconds) || unixSeconds < 0) {85 throw new Error("unixSeconds must be non-negative");86 }87 if (!Number.isSafeInteger(windowSeconds) || windowSeconds <= 0) {88 throw new Error("windowSeconds must be a positive integer");89 }90 return Math.floor(unixSeconds / windowSeconds);91}929394export function waitingRendezvousIds(95 secret: Uint8Array,96 unixSeconds: number,97): string[] {98 const current = rendezvousWindowIndex(unixSeconds);99 return [current, Math.max(0, current - 1)].map((window) =>100 deriveRendezvousId(secret, window),101 );102}103104105export function dialingRendezvousIds(106 secret: Uint8Array,107 unixSeconds: number,108): string[] {109 const current = rendezvousWindowIndex(unixSeconds);110 return [current, Math.max(0, current - 1), current + 1].map((window) =>111 deriveRendezvousId(secret, window),112 );113}114115116export function generatePairingRendezvousToken(): string {117 return base64urlEncode(crypto.getRandomValues(118 new Uint8Array(PAIRING_RENDEZVOUS_TOKEN_BYTES),119 ));120}121122123export function normalizeRelayUrl(input: string): string {124 const url = new URL(input.trim());125 if (url.protocol !== "wss:") throw new Error("relay URL must use wss://");126 if (url.username || url.password || url.search || url.hash) {127 throw new Error("relay URL must not contain credentials, query, or fragment");128 }129 url.pathname = url.pathname.replace(/\/+$/, "");130 return url.toString().replace(/\/$/, "");131}132133export function buildRendezvousUrl(relayUrl: string, rendezvousId: string): string {134 if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {135 throw new Error("invalid rendezvous id");136 }137 return `${normalizeRelayUrl(relayUrl)}/r/${rendezvousId}`;138}139140export function bytesToHex(bytes: Uint8Array): string {141 return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");142}143144export function rendezvousSecretFromHex(hex: string): Uint8Array {145 if (!/^[0-9a-f]{64}$/.test(hex)) {146 throw new Error("rendezvous secret must be 32-byte lowercase hex");147 }148 const out = new Uint8Array(32);149 for (let i = 0; i < out.length; i++) {150 out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);151 }152 return out;153}154155function base64urlEncode(bytes: Uint8Array): string {156 let binary = "";157 for (const byte of bytes) binary += String.fromCharCode(byte);158 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");159}160161function compareBytes(a: Uint8Array, b: Uint8Array): number {162 for (let i = 0; i < a.length; i += 1) {163 if (a[i] !== b[i]) return a[i]! < b[i]! ? -1 : 1;164 }165 return 0;166}