sync-types.ts

9.5 KB · raw

1// Chirp Sync — type definitions.2//3// This file is in the middle of a v2.14 → v3 migration. The v3 chirp-sync4// architecture (audio-bootstrapped pairing + manifest-diff WebSocket sync)5// is being added alongside the v2.14 full-blob-over-native-host types.6// The v2.14 types are scheduled for removal once every consumer in7// messages.ts has been migrated; do not write new code against them.8//9// v3 — audio pairing once per device pair (Phase 1). All ongoing vault sync10// after that runs over a direct WebSocket on the local network (Phase 2).11// The two phases never overlap.1213// ---------------------------------------------------------------------------14// v3 chirp pairing — on-air audio frame15// ---------------------------------------------------------------------------1617export type DeviceType = "extension" | "android" | "ios" | "desktop";1819// Numeric tags used in the on-air chirp payload. The audio frame is a20// fixed 64-byte struct (see chirp-payload.ts); these are the byte values21// that go on the wire — not the string DeviceType.22export const DEVICE_TYPE_EXTENSION = 0x01 as const;23export const DEVICE_TYPE_ANDROID = 0x02 as const;24export const DEVICE_TYPE_IOS = 0x03 as const;25export const DEVICE_TYPE_DESKTOP = 0x04 as const;2627export type DeviceTypeByte =28  | typeof DEVICE_TYPE_EXTENSION29  | typeof DEVICE_TYPE_ANDROID30  | typeof DEVICE_TYPE_IOS31  | typeof DEVICE_TYPE_DESKTOP;3233export const CHIRP_MAGIC_BYTES = Uint8Array.of(0x50, 0x45, 0x41, 0x43); // "PEAC"34export const CHIRP_PROTOCOL_VERSION = 0x01 as const;35export const CHIRP_PAYLOAD_LENGTH = 64 as const;3637export function deviceTypeFromByte(b: DeviceTypeByte): DeviceType {38  switch (b) {39    case DEVICE_TYPE_EXTENSION: return "extension";40    case DEVICE_TYPE_ANDROID: return "android";41    case DEVICE_TYPE_IOS: return "ios";42    case DEVICE_TYPE_DESKTOP: return "desktop";43  }44}4546export function deviceTypeToByte(t: DeviceType): DeviceTypeByte {47  switch (t) {48    case "extension": return DEVICE_TYPE_EXTENSION;49    case "android": return DEVICE_TYPE_ANDROID;50    case "ios": return DEVICE_TYPE_IOS;51    case "desktop": return DEVICE_TYPE_DESKTOP;52  }53}5455export interface ChirpPayload {56  version: number;57  deviceType: DeviceTypeByte;58  ephemeralPublicKey: Uint8Array; // 32 bytes59  sessionNonce: Uint8Array; // 16 bytes60}6162// ---------------------------------------------------------------------------63// v3 chirp pairing — long-term paired device registry64// ---------------------------------------------------------------------------6566/**67 * v3 paired-device record. Stored in `chrome.storage.sync` so the registry68 * roams across browser profiles signed into Chrome/Firefox sync.69 *70 * `longTermPublicKeyHex` is the counterpart's vault-derived sync identity71 * static X25519 public key — see `deriveSyncIdentityKeypair`. Mutual Noise72 * XX authentication on every WebSocket connection rejects any peer not73 * present in this registry.74 *75 * `lastKnownIp` is intentionally NOT stored here — it's per-network and76 * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See77 * `getLastKnownIp` / `setLastKnownIp` in sync-storage.ts.78 *79 * Legacy v2.14 fields `displayName`, `x25519PublicKey`, `lastSeenAt`,80 * `lastSyncAt`, `lastKnownIp` are kept for migration only; new code reads81 * `deviceName`, `longTermPublicKeyHex`, `lastSyncedAt`.82 */83/**84 * Trust source of a paired-device entry — per v2 brief §4.2.85 *86 *   "direct"  — established via an audio pairing session with this device.87 *               The extension only ever has direct entries; mobiles can88 *               also direct-pair with other mobiles (allowed but unusual).89 *   "vouched" — received via a signed device-manifest from the extension90 *               during a normal sync. Functionally equivalent to direct91 *               for sync purposes; surfaced with an "Added automatically"92 *               badge in Settings.93 */94export type TrustSource = "direct" | "vouched";9596export interface PairedDevice {97  deviceId: string;98  deviceName?: string;99  deviceType?: DeviceType;100  longTermPublicKeyHex?: string;101  /**102   * Long-term Ed25519 verification key for the peer's device-manifest103   * signatures (v2 brief §4.3). Derived deterministically from the same104   * master key as `longTermPublicKeyHex`, via `deriveSyncSigningKeypair`.105   * Optional during the migration — older paired records lack it, so106   * manifests issued by such peers can't be verified and will be rejected.107   */108  signingPublicKeyHex?: string;109  /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */110  trustSource?: TrustSource;111  /**112   * The peer's WebSocket sync port. Android picks a random high port113   * (49152–65535) at first launch and announces it inside the encrypted114   * pairing channel; the extension caches it here. iOS / Tauri sync hosts115   * follow the same convention.116   */117  wsPort?: number;118  pairedAt: number;119  lastSyncedAt?: number;120121  // v2.14 legacy — kept readable so the migration sweep doesn't crash122  // every test that still constructs the old shape. New writers must use123  // the v3 fields above.124  displayName?: string;125  x25519PublicKey?: string;126  lastSeenAt?: number;127  lastSyncAt?: number;128  lastKnownIp?: string;129}130131// ---------------------------------------------------------------------------132// v3 chirp pairing — phase-1 UI state133// ---------------------------------------------------------------------------134135/**136 * Phase 1 pairing state, surfaced to the SyncPairing view. The view binds137 * its bird-animation acts directly to these states: `listening` and138 * `chirping` map to Act 1, `handshake` maps to Act 2, `paired` to Act 3.139 */140export type PairingPhase =141  | "idle"142  | "listening"143  | "chirping"144  | "handshake"145  | "paired"146  | "timeout"147  | "mic_denied"148  | "version_mismatch"149  | "handshake_failed";150151export interface PairingProgress {152  phase: PairingPhase;153  peerDeviceName?: string;154  errorMessage?: string;155}156157// ---------------------------------------------------------------------------158// v3 wire protocol — phase-2 WebSocket manifest sync159// ---------------------------------------------------------------------------160161/**162 * v3 wire protocol — after Noise XX is open.163 *164 * Every frame is JSON, then ChaCha20-Poly1305 encrypted by Noise XX. The165 * WebSocket transport sees only ciphertext.166 *167 * The manifest carries per-entry timestamps; the receiver requests entries168 * where the remote timestamp beats its own. Entries traverse as opaque169 * encrypted blobs — the sync layer never decrypts vault data.170 */171export type ChirpSyncMessage =172  | { type: "SYNC_HELLO"; vaultId: string; deviceId: string }173  | { type: "SYNC_MANIFEST"; entries: Record<string, number>; tombstones: Record<string, number> }174  | { type: "SYNC_REQUEST"; entryIds: string[] }175  | { type: "SYNC_ENTRIES"; entries: Record<string, string> }176  | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }177  | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest }178  | { type: "SYNC_DONE" }179  | { type: "SYNC_ERROR"; error: string };180181// ---------------------------------------------------------------------------182// v3 device manifest — propagates trust between mobile devices (v2 brief §4.3)183// ---------------------------------------------------------------------------184185export const DEVICE_MANIFEST_VERSION = 0x01 as const;186/** Manifests older than this are silently rejected (v2 brief §4.3). */187export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;188189/**190 * One device entry inside a signed manifest. `longTermPublicKeyHex` is the191 * X25519 sync identity key the receiver will use for Noise XX, exactly as192 * if the entry had been audio-paired locally. `signingPublicKeyHex` is the193 * peer's Ed25519 manifest-verification key — needed so a vouched mobile194 * can in turn verify manifests issued by the same vault from a different195 * device.196 */197export interface ManifestDeviceEntry {198  deviceId: string;199  deviceName: string;200  deviceType: DeviceType;201  longTermPublicKeyHex: string;202  signingPublicKeyHex: string;203  pairedAt: number;204}205206/** Body that gets canonicalised + signed. Wire format JSON-stable. */207export interface DeviceManifestBody {208  manifestVersion: number;209  issuerDeviceId: string;210  issuedAt: number;211  devices: ManifestDeviceEntry[];212}213214export interface SignedDeviceManifest {215  body: DeviceManifestBody;216  /** Hex-encoded 64-byte Ed25519 signature over canonicalised body. */217  signatureHex: string;218}219220export type SyncStatusV3 = "up_to_date" | "syncing" | "unreachable" | "never";221222// ---------------------------------------------------------------------------223// v2.14 legacy types — DO NOT USE IN NEW CODE224//225// These remain only because messages.ts and the v2.14 coordinator/network226// modules import them. Once the migration sweep lands, this whole block227// goes away.228// ---------------------------------------------------------------------------229230export interface TrustedNetwork {231  ssid?: string;232  bssid?: string;233  isTailscale: boolean;234  addedAt: number;235}236237export interface SyncableDevice {238  publicKeyHex: string;239  deviceName: string;240  pairedAt: number;241}242243export type SyncMessage =244  | { type: 'SYNC_HELLO' }245  | { type: 'SYNC_VAULT_SEND'; blob: string; timestamp: number; license?: string; devices: SyncableDevice[] }246  | { type: 'SYNC_VAULT_ACK'; applied: boolean }247  | { type: 'SYNC_DONE' }248  | { type: 'SYNC_ERROR'; error: string };249250export type SyncStatus = "synced" | "syncing" | "no_peers" | "not_trusted" | "offline";251252export interface IncomingPairingRequest {253  deviceName: string;254  publicKeyHex: string;255  receivedAt: number;256  expiresAt: number;257  connectionId?: string;258}