pairing-invitation.ts

3.8 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  noiseStaticPublicKeyHex?: string;26}2728/**29 * QR/manual first-pair payload. Reachability plus a credential-free relay URL,30 * a fresh 128-bit bearer token, and the issuer's vault state — no identity or31 * vault material is present before Noise and the two-person SAS gate.32 */33export function createPairingInvitation(34  relayUrl: string,35  token = generatePairingRendezvousToken(),36  vaultStatus: VaultStatus = "populated",37  noiseStaticPublicKeyHex?: string,38): string {39  const normalized = normalizeRelayUrl(relayUrl);40  assertToken(token);41  if (noiseStaticPublicKeyHex) assertStaticKey(noiseStaticPublicKeyHex);42  const json = JSON.stringify({43    relay: normalized,44    token,45    vault: vaultStatus,46    ...(noiseStaticPublicKeyHex ? { static: noiseStaticPublicKeyHex } : {}),47  });48  return `${PAIRING_INVITATION_PREFIX}${base64urlEncode(new TextEncoder().encode(json))}`;49}5051export function parsePairingInvitation(input: string): PairingInvitation | null {52  const value = input.trim();53  if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null;54  try {55    const json = new TextDecoder("utf-8", { fatal: true }).decode(56      base64urlDecode(value.slice(PAIRING_INVITATION_PREFIX.length)),57    );58    const parsed = JSON.parse(json) as {59      relay?: unknown;60      token?: unknown;61      vault?: unknown;62      static?: unknown;63    };64    if (typeof parsed.relay !== "string" || typeof parsed.token !== "string") return null;65    assertToken(parsed.token);66    if (parsed.static !== undefined) {67      if (typeof parsed.static !== "string") return null;68      assertStaticKey(parsed.static);69    }70    return {71      relayUrl: normalizeRelayUrl(parsed.relay),72      token: parsed.token,73      vaultStatus: parseVaultStatus(parsed.vault),74      noiseStaticPublicKeyHex: parsed.static,75    };76  } catch {77    return null;78  }79}8081export function pairingInvitationUrl(invitation: PairingInvitation): string {82  return buildRendezvousUrl(invitation.relayUrl, invitation.token);83}8485function parseVaultStatus(value: unknown): VaultStatus {86  return value === "empty" ? "empty" : "populated";87}8889function assertToken(token: string): void {90  // A first-pair token is exactly 16 bytes encoded without padding.91  if (!/^[A-Za-z0-9_-]{22}$/.test(token)) {92    throw new Error("pairing invitation token must be 128-bit base64url");93  }94}9596function assertStaticKey(key: string): void {97  if (!/^[0-9a-f]{64}$/.test(key)) throw new Error("invalid Noise static public key");98}99100function base64urlEncode(bytes: Uint8Array): string {101  let binary = "";102  for (const byte of bytes) binary += String.fromCharCode(byte);103  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");104}105106function base64urlDecode(value: string): Uint8Array {107  if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url");108  const padded = value.replace(/-/g, "+").replace(/_/g, "/")109    .padEnd(Math.ceil(value.length / 4) * 4, "=");110  const binary = atob(padded);111  return Uint8Array.from(binary, (char) => char.charCodeAt(0));112}