pairing-invitation.ts

4.3 KB · raw

1import {2  buildRendezvousUrl,3  generatePairingRendezvousToken,4  normalizeRelayUrl,5} from "./rendezvous";67export const PAIRING_INVITATION_PREFIX = "peach-pair-v2:";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 *33 * PB-26-103: `static` pins the issuer's Noise static public key so the scanning34 * device can authenticate the cleartext PEACH_HELLO_ACK instead of trusting it.35 * Both adoption directions on Android now refuse an invitation without it,36 * which is a behaviour change rather than a format change — hence the v237 * prefix, so a mismatched pair fails at the scan instead of mid-handshake.38 * There is no interop with v1; both clients move together.39 */40export function createPairingInvitation(41  relayUrl: string,42  token = generatePairingRendezvousToken(),43  vaultStatus: VaultStatus = "populated",44  noiseStaticPublicKeyHex?: string,45): string {46  const normalized = normalizeRelayUrl(relayUrl);47  assertToken(token);48  if (noiseStaticPublicKeyHex) assertStaticKey(noiseStaticPublicKeyHex);49  const json = JSON.stringify({50    relay: normalized,51    token,52    vault: vaultStatus,53    ...(noiseStaticPublicKeyHex ? { static: noiseStaticPublicKeyHex } : {}),54  });55  return `${PAIRING_INVITATION_PREFIX}${base64urlEncode(new TextEncoder().encode(json))}`;56}5758export function parsePairingInvitation(input: string): PairingInvitation | null {59  const value = input.trim();60  if (!value.startsWith(PAIRING_INVITATION_PREFIX)) return null;61  try {62    const json = new TextDecoder("utf-8", { fatal: true }).decode(63      base64urlDecode(value.slice(PAIRING_INVITATION_PREFIX.length)),64    );65    const parsed = JSON.parse(json) as {66      relay?: unknown;67      token?: unknown;68      vault?: unknown;69      static?: unknown;70    };71    if (typeof parsed.relay !== "string" || typeof parsed.token !== "string") return null;72    assertToken(parsed.token);73    if (parsed.static !== undefined) {74      if (typeof parsed.static !== "string") return null;75      assertStaticKey(parsed.static);76    }77    return {78      relayUrl: normalizeRelayUrl(parsed.relay),79      token: parsed.token,80      vaultStatus: parseVaultStatus(parsed.vault),81      noiseStaticPublicKeyHex: parsed.static,82    };83  } catch {84    return null;85  }86}8788export function pairingInvitationUrl(invitation: PairingInvitation): string {89  return buildRendezvousUrl(invitation.relayUrl, invitation.token);90}9192function parseVaultStatus(value: unknown): VaultStatus {93  return value === "empty" ? "empty" : "populated";94}9596function assertToken(token: string): void {97  // A first-pair token is exactly 16 bytes encoded without padding.98  if (!/^[A-Za-z0-9_-]{22}$/.test(token)) {99    throw new Error("pairing invitation token must be 128-bit base64url");100  }101}102103function assertStaticKey(key: string): void {104  if (!/^[0-9a-f]{64}$/.test(key)) throw new Error("invalid Noise static public key");105}106107function base64urlEncode(bytes: Uint8Array): string {108  let binary = "";109  for (const byte of bytes) binary += String.fromCharCode(byte);110  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");111}112113function base64urlDecode(value: string): Uint8Array {114  if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("invalid base64url");115  const padded = value.replace(/-/g, "+").replace(/_/g, "/")116    .padEnd(Math.ceil(value.length / 4) * 4, "=");117  const binary = atob(padded);118  return Uint8Array.from(binary, (char) => char.charCodeAt(0));119}