rendezvous.ts

4.4 KB · raw

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);1112/** Derive pair-only reconnect state from the completed first-pair Noise hash. */13export 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}1920/**21 * Derive one opaque hourly relay capability.22 *23 * Cross-client encoding contract:24 *   info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex)25 *   id   = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32))26 */27export 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}5657/** Waiting peers register current and previous windows for boundary tolerance. */58export 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}6768/** Dialers try current, previous, then next, matching the cross-client contract. */69export 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}7879/** Fresh, single-use first-pair bearer capability (128 bits). */80export function generatePairingRendezvousToken(): string {81  return base64urlEncode(crypto.getRandomValues(82    new Uint8Array(PAIRING_RENDEZVOUS_TOKEN_BYTES),83  ));84}8586/** Accept only a credential-free `wss://` relay origin with an optional path. */87export 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}