rendezvous.ts

5.7 KB · raw

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);121314/** Derive pair-only reconnect state from the completed first-pair Noise hash. */15export 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}1920/** Derive the manifest mesh capability from random local X25519 credentials. */21export 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}3940/**41 * Derive one opaque hourly relay capability.42 *43 * Cross-client encoding contract:44 *   info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex)45 *   id   = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32))46 */47export 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}7677/** Waiting peers register current and previous windows for boundary tolerance. */78export 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}8788/** Dialers try current, previous, then next, matching the cross-client contract. */89export 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}9899/** Fresh, single-use first-pair bearer capability (128 bits). */100export function generatePairingRendezvousToken(): string {101  return base64urlEncode(crypto.getRandomValues(102    new Uint8Array(PAIRING_RENDEZVOUS_TOKEN_BYTES),103  ));104}105106/** Accept only a credential-free `wss://` relay origin with an optional path. */107export 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}