import { buildRendezvousUrl, generatePairingRendezvousToken, normalizeRelayUrl, } from "./rendezvous"; export const PAIRING_INVITATION_PREFIX = "peach-pair-v1:"; 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; } /** * 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. */ export function createPairingInvitation( relayUrl: string, token = generatePairingRendezvousToken(), vaultStatus: VaultStatus = "populated", ): string { const normalized = normalizeRelayUrl(relayUrl); assertToken(token); const json = JSON.stringify({ relay: normalized, token, vault: vaultStatus }); 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; }; if (typeof parsed.relay !== "string" || typeof parsed.token !== "string") return null; assertToken(parsed.token); return { relayUrl: normalizeRelayUrl(parsed.relay), token: parsed.token, vaultStatus: parseVaultStatus(parsed.vault), }; } 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 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)); }