rendezvous.ts

5.9 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 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);1213/** Derive pair-only reconnect state from the completed first-pair Noise hash. */14export 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}2021/**22 * Derive the reconnect capability for a manifest-introduced device pair.23 *24 * Cross-client encoding contract:25 *   low  = lexicographically smaller 32-byte X25519 public key26 *   high = lexicographically larger 32-byte X25519 public key27 *   info = UTF8("peach-mesh-rendezvous-v1") || low || high28 *   secret = HKDF-SHA256(vaultRoot, salt="", info, 32)29 */30export 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}5556/**57 * Derive one opaque hourly relay capability.58 *59 * Cross-client encoding contract:60 *   info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex)61 *   id   = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32))62 */63export 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}9293/** Waiting peers register current and previous windows for boundary tolerance. */94export 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}103104/** Dialers try current, previous, then next, matching the cross-client contract. */105export 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}114115/** Fresh, single-use first-pair bearer capability (128 bits). */116export function generatePairingRendezvousToken(): string {117  return base64urlEncode(crypto.getRandomValues(118    new Uint8Array(PAIRING_RENDEZVOUS_TOKEN_BYTES),119  ));120}121122/** Accept only a credential-free `wss://` relay origin with an optional path. */123export 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}