import { HKDF } from "@stablelib/hkdf"; import { SHA256 } from "@stablelib/sha256"; export const DEFAULT_RELAY_URL = "wss://relay.peachpasswords.com"; export const RENDEZVOUS_WINDOW_SECONDS = 3600; export const PAIRING_RENDEZVOUS_TOKEN_BYTES = 16; const SECRET_INFO = new TextEncoder().encode("peach-rendezvous-secret-v1"); const MESH_SECRET_INFO = new TextEncoder().encode("peach-mesh-rendezvous-v1"); const WINDOW_INFO = new TextEncoder().encode("peach-rendezvous-v1"); const EMPTY_SALT = new Uint8Array(0); /** Derive pair-only reconnect state from the completed first-pair Noise hash. */ export function deriveRendezvousSecret(handshakeHash: Uint8Array): Uint8Array { if (handshakeHash.length !== 32) { throw new Error("handshake hash must be exactly 32 bytes"); } return new HKDF(SHA256, handshakeHash, EMPTY_SALT, SECRET_INFO).expand(32); } /** * Derive the reconnect capability for a manifest-introduced device pair. * * Cross-client encoding contract: * low = lexicographically smaller 32-byte X25519 public key * high = lexicographically larger 32-byte X25519 public key * info = UTF8("peach-mesh-rendezvous-v1") || low || high * secret = HKDF-SHA256(vaultRoot, salt="", info, 32) */ export function deriveMeshRendezvousSecret( vaultRoot: Uint8Array, ourPublicKey: Uint8Array, peerPublicKey: Uint8Array, ): Uint8Array { if (vaultRoot.length !== 32) { throw new Error("vault root must be exactly 32 bytes"); } if (ourPublicKey.length !== 32 || peerPublicKey.length !== 32) { throw new Error("mesh public keys must be exactly 32 bytes"); } const order = compareBytes(ourPublicKey, peerPublicKey); if (order === 0) { throw new Error("mesh peers must have distinct public keys"); } const [low, high] = order < 0 ? [ourPublicKey, peerPublicKey] : [peerPublicKey, ourPublicKey]; const info = new Uint8Array(MESH_SECRET_INFO.length + 64); info.set(MESH_SECRET_INFO, 0); info.set(low, MESH_SECRET_INFO.length); info.set(high, MESH_SECRET_INFO.length + 32); return new HKDF(SHA256, vaultRoot, EMPTY_SALT, info).expand(32); } /** * Derive one opaque hourly relay capability. * * Cross-client encoding contract: * info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex) * id = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32)) */ export function deriveRendezvousId( rendezvousSecret: Uint8Array, windowIndex: number, ): string { if (rendezvousSecret.length !== 32) { throw new Error("rendezvous secret must be exactly 32 bytes"); } if (!Number.isSafeInteger(windowIndex) || windowIndex < 0) { throw new Error("window index must be a non-negative safe integer"); } const info = new Uint8Array(WINDOW_INFO.length + 8); info.set(WINDOW_INFO); new DataView(info.buffer).setBigUint64(WINDOW_INFO.length, BigInt(windowIndex), false); const derived = new HKDF(SHA256, rendezvousSecret, EMPTY_SALT, info).expand(32); return base64urlEncode(derived); } export function rendezvousWindowIndex( unixSeconds: number, windowSeconds = RENDEZVOUS_WINDOW_SECONDS, ): number { if (!Number.isFinite(unixSeconds) || unixSeconds < 0) { throw new Error("unixSeconds must be non-negative"); } if (!Number.isSafeInteger(windowSeconds) || windowSeconds <= 0) { throw new Error("windowSeconds must be a positive integer"); } return Math.floor(unixSeconds / windowSeconds); } /** Waiting peers register current and previous windows for boundary tolerance. */ export function waitingRendezvousIds( secret: Uint8Array, unixSeconds: number, ): string[] { const current = rendezvousWindowIndex(unixSeconds); return [current, Math.max(0, current - 1)].map((window) => deriveRendezvousId(secret, window), ); } /** Dialers try current, previous, then next, matching the cross-client contract. */ export function dialingRendezvousIds( secret: Uint8Array, unixSeconds: number, ): string[] { const current = rendezvousWindowIndex(unixSeconds); return [current, Math.max(0, current - 1), current + 1].map((window) => deriveRendezvousId(secret, window), ); } /** Fresh, single-use first-pair bearer capability (128 bits). */ export function generatePairingRendezvousToken(): string { return base64urlEncode(crypto.getRandomValues( new Uint8Array(PAIRING_RENDEZVOUS_TOKEN_BYTES), )); } /** Accept only a credential-free `wss://` relay origin with an optional path. */ export function normalizeRelayUrl(input: string): string { const url = new URL(input.trim()); if (url.protocol !== "wss:") throw new Error("relay URL must use wss://"); if (url.username || url.password || url.search || url.hash) { throw new Error("relay URL must not contain credentials, query, or fragment"); } url.pathname = url.pathname.replace(/\/+$/, ""); return url.toString().replace(/\/$/, ""); } export function buildRendezvousUrl(relayUrl: string, rendezvousId: string): string { if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) { throw new Error("invalid rendezvous id"); } return `${normalizeRelayUrl(relayUrl)}/r/${rendezvousId}`; } export function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } export function rendezvousSecretFromHex(hex: string): Uint8Array { if (!/^[0-9a-f]{64}$/.test(hex)) { throw new Error("rendezvous secret must be 32-byte lowercase hex"); } const out = new Uint8Array(32); for (let i = 0; i < out.length; i++) { out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out; } function base64urlEncode(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function compareBytes(a: Uint8Array, b: Uint8Array): number { for (let i = 0; i < a.length; i += 1) { if (a[i] !== b[i]) return a[i]! < b[i]! ? -1 : 1; } return 0; }