pairing-invitation.ts

3.3 KB · raw

1import {2  buildRendezvousUrl,3  generatePairingRendezvousToken,4  normalizeRelayUrl,5} from "./rendezvous";67export const PAIRING_INVITATION_PREFIX = "peach-pair-v1:";89export type VaultStatus = "empty" | "populated";1011export interface PairingInvitation {12  relayUrl: string;13  token: string;14  /**15   * Whether the device that issued this invitation has a vault. The phone reads16   * this to decide its role on connect:17   *   "empty"     → issuer has no vault; the phone is the source and shares its vault18   *   "populated" → issuer has the vault; the phone is the receiver and receives it19   *20   * Older invitations from before this field existed parse as "populated" (the21   * historical default), so a phone that doesn't know about `vaultStatus` still22   * does the right thing: it receives the vault from the source.23   */24  vaultStatus: VaultStatus;25}2627/**28 * QR/manual first-pair payload. Reachability plus a credential-free relay URL,29 * a fresh 128-bit bearer token, and the issuer's vault state — no identity or30 * vault material is present before Noise and the two-person SAS gate.31 */32export function createPairingInvitation(33  relayUrl: string,34  token = generatePairingRendezvousToken(),35  vaultStatus: VaultStatus = "populated",36): string {37  const normalized = normalizeRelayUrl(relayUrl);38  assertToken(token);39  const json = JSON.stringify({ relay: normalized, token, vault: vaultStatus });40  return `${PAIRING_INVITATION_PREFIX}${base64urlEncode(new TextEncoder().encode(json))}`;41}4243export function parsePairingInvitation(input: string): PairingInvitation | null {44  const value = input.trim();45  if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null;46  try {47    const json = new TextDecoder("utf-8", { fatal: true }).decode(48      base64urlDecode(value.slice(PAIRING_INVITATION_PREFIX.length)),49    );50    const parsed = JSON.parse(json) as {51      relay?: unknown;52      token?: unknown;53      vault?: unknown;54    };55    if (typeof parsed.relay !== "string" || typeof parsed.token !== "string") return null;56    assertToken(parsed.token);57    return {58      relayUrl: normalizeRelayUrl(parsed.relay),59      token: parsed.token,60      vaultStatus: parseVaultStatus(parsed.vault),61    };62  } catch {63    return null;64  }65}6667export function pairingInvitationUrl(invitation: PairingInvitation): string {68  return buildRendezvousUrl(invitation.relayUrl, invitation.token);69}7071function parseVaultStatus(value: unknown): VaultStatus {72  return value === "empty" ? "empty" : "populated";73}7475function assertToken(token: string): void {76  // A first-pair token is exactly 16 bytes encoded without padding.77  if (!/^[A-Za-z0-9_-]{22}$/.test(token)) {78    throw new Error("pairing invitation token must be 128-bit base64url");79  }80}8182function base64urlEncode(bytes: Uint8Array): string {83  let binary = "";84  for (const byte of bytes) binary += String.fromCharCode(byte);85  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");86}8788function base64urlDecode(value: string): Uint8Array {89  if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url");90  const padded = value.replace(/-/g, "+").replace(/_/g, "/")91    .padEnd(Math.ceil(value.length / 4) * 4, "=");92  const binary = atob(padded);93  return Uint8Array.from(binary, (char) => char.charCodeAt(0));94}