import { buildRendezvousUrl, generatePairingRendezvousToken, normalizeRelayUrl, } from "./rendezvous"; export const PAIRING_INVITATION_PREFIX = "peach-pair-v2:"; export type VaultStatus = "empty" | "populated"; export interface PairingInvitation { relayUrl: string; token: string; /** * Whether the device that issued this invitation has a vault. The phone reads * this to decide its role on connect: * "empty" → issuer has no vault; the phone is the source and shares its vault * "populated" → issuer has the vault; the phone is the receiver and receives it * * Older invitations from before this field existed parse as "populated" (the * historical default), so a phone that doesn't know about `vaultStatus` still * does the right thing: it receives the vault from the source. */ vaultStatus: VaultStatus; noiseStaticPublicKeyHex?: string; } /** * QR/manual first-pair payload. Reachability plus a credential-free relay URL, * a fresh 128-bit bearer token, and the issuer's vault state — no identity or * vault material is present before Noise and the two-person SAS gate. * * PB-26-103: `static` pins the issuer's Noise static public key so the scanning * device can authenticate the cleartext PEACH_HELLO_ACK instead of trusting it. * Both adoption directions on Android now refuse an invitation without it, * which is a behaviour change rather than a format change — hence the v2 * prefix, so a mismatched pair fails at the scan instead of mid-handshake. * There is no interop with v1; both clients move together. */ export function createPairingInvitation( relayUrl: string, token = generatePairingRendezvousToken(), vaultStatus: VaultStatus = "populated", noiseStaticPublicKeyHex?: string, ): string { const normalized = normalizeRelayUrl(relayUrl); assertToken(token); if (noiseStaticPublicKeyHex) assertStaticKey(noiseStaticPublicKeyHex); const json = JSON.stringify({ relay: normalized, token, vault: vaultStatus, ...(noiseStaticPublicKeyHex ? { static: noiseStaticPublicKeyHex } : {}), }); return `${PAIRING_INVITATION_PREFIX}${base64urlEncode(new TextEncoder().encode(json))}`; } export function parsePairingInvitation(input: string): PairingInvitation | null { const value = input.trim(); if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null; try { const json = new TextDecoder("utf-8", { fatal: true }).decode( base64urlDecode(value.slice(PAIRING_INVITATION_PREFIX.length)), ); const parsed = JSON.parse(json) as { relay?: unknown; token?: unknown; vault?: unknown; static?: unknown; }; if (typeof parsed.relay !== "string" || typeof parsed.token !== "string") return null; assertToken(parsed.token); if (parsed.static !== undefined) { if (typeof parsed.static !== "string") return null; assertStaticKey(parsed.static); } return { relayUrl: normalizeRelayUrl(parsed.relay), token: parsed.token, vaultStatus: parseVaultStatus(parsed.vault), noiseStaticPublicKeyHex: parsed.static, }; } catch { return null; } } export function pairingInvitationUrl(invitation: PairingInvitation): string { return buildRendezvousUrl(invitation.relayUrl, invitation.token); } function parseVaultStatus(value: unknown): VaultStatus { return value === "empty" ? "empty" : "populated"; } function assertToken(token: string): void { // A first-pair token is exactly 16 bytes encoded without padding. if (!/^[A-Za-z0-9_-]{22}$/.test(token)) { throw new Error("pairing invitation token must be 128-bit base64url"); } } function assertStaticKey(key: string): void { if (!/^[0-9a-f]{64}$/.test(key)) throw new Error("invalid Noise static public key"); } 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 base64urlDecode(value: string): Uint8Array { if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url"); const padded = value.replace(/-/g, "+").replace(/_/g, "/") .padEnd(Math.ceil(value.length / 4) * 4, "="); const binary = atob(padded); return Uint8Array.from(binary, (char) => char.charCodeAt(0)); }