sync-types.ts
1// Chirp Sync — type definitions (Master Brief v2).2//3// v3 — audio pairing once per device pair (Phase 1). All ongoing vault sync4// after that runs over a direct WebSocket on the local network (Phase 2).5// The two phases never overlap.67// ---------------------------------------------------------------------------8// v3 chirp pairing — on-air audio frame9// ---------------------------------------------------------------------------1011// "desktop" — peach-sync daemon (kepr.uk/peach-sync). Already a full member:12// chirp byte tag 0x04 below, deviceTypeFrom/ToByte cases, and the paired-device13// registry all accept it; the helper announces device_type "desktop" verbatim.14export type DeviceType = "extension" | "android" | "ios" | "desktop";1516// Numeric tags used in the on-air chirp payload. The audio frame is a17// fixed 64-byte struct (see chirp-payload.ts); these are the byte values18// that go on the wire — not the string DeviceType.19export const DEVICE_TYPE_EXTENSION = 0x01 as const;20export const DEVICE_TYPE_ANDROID = 0x02 as const;21export const DEVICE_TYPE_IOS = 0x03 as const;22export const DEVICE_TYPE_DESKTOP = 0x04 as const;2324export type DeviceTypeByte =25 | typeof DEVICE_TYPE_EXTENSION26 | typeof DEVICE_TYPE_ANDROID27 | typeof DEVICE_TYPE_IOS28 | typeof DEVICE_TYPE_DESKTOP;2930export const CHIRP_MAGIC_BYTES = Uint8Array.of(0x50, 0x45, 0x41, 0x43); // "PEAC"31export const CHIRP_PROTOCOL_VERSION = 0x01 as const;32export const CHIRP_PAYLOAD_LENGTH = 64 as const;3334export function deviceTypeFromByte(b: DeviceTypeByte): DeviceType {35 switch (b) {36 case DEVICE_TYPE_EXTENSION: return "extension";37 case DEVICE_TYPE_ANDROID: return "android";38 case DEVICE_TYPE_IOS: return "ios";39 case DEVICE_TYPE_DESKTOP: return "desktop";40 }41}4243export function deviceTypeToByte(t: DeviceType): DeviceTypeByte {44 switch (t) {45 case "extension": return DEVICE_TYPE_EXTENSION;46 case "android": return DEVICE_TYPE_ANDROID;47 case "ios": return DEVICE_TYPE_IOS;48 case "desktop": return DEVICE_TYPE_DESKTOP;49 }50}5152export interface ChirpPayload {53 version: number;54 deviceType: DeviceTypeByte;55 ephemeralPublicKey: Uint8Array; // 32 bytes56 sessionNonce: Uint8Array; // 16 bytes57}5859// ---------------------------------------------------------------------------60// v3 chirp pairing — long-term paired device registry61// ---------------------------------------------------------------------------6263/**64 * Trust source of a paired-device entry — per v2 brief §4.2.65 *66 * "direct" — established via an audio pairing session with this device.67 * The extension only ever has direct entries; mobiles can68 * also direct-pair with other mobiles (allowed but unusual).69 * "vouched" — received via a signed device-manifest from the extension70 * during a normal sync. Functionally equivalent to direct71 * for sync purposes; surfaced with an "Added automatically"72 * badge in Settings.73 */74export type TrustSource = "direct" | "vouched";7576/**77 * v3 paired-device record. Stored in `chrome.storage.local` — cryptographic78 * keys must not leave the device via a third-party cloud service.79 *80 * `longTermPublicKeyHex` is the counterpart's vault-derived X25519 sync81 * identity static key — see `deriveSyncIdentityKeypair`. Mutual Noise XX82 * authentication on every WebSocket connection rejects any peer not83 * present in this registry.84 *85 * `lastKnownIp` is intentionally NOT stored here — it's per-network and86 * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See87 * `getLastKnownIp` / `setLastKnownIp` in `paired-devices.ts`.88 */89export interface PairedDevice {90 deviceId: string;91 deviceName?: string;92 deviceType?: DeviceType;93 longTermPublicKeyHex?: string;94 /**95 * Long-term Ed25519 verification key for the peer's device-manifest96 * signatures (v2 brief §4.3). Derived deterministically from the same97 * master key as `longTermPublicKeyHex`, via `deriveSyncSigningKeypair`.98 */99 signingPublicKeyHex?: string;100 /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */101 trustSource?: TrustSource;102 /**103 * The peer's WebSocket sync port. Mobile hosts pick a random high port104 * (49152–65535) at first launch and announce it inside the encrypted105 * pairing channel; the extension caches it here.106 */107 wsPort?: number;108 pairedAt: number;109 lastSyncedAt?: number;110}111112// ---------------------------------------------------------------------------113// v3 chirp pairing — phase-1 UI state114// ---------------------------------------------------------------------------115116/**117 * Phase 1 pairing state, surfaced to the chirp pairing view. The view118 * binds its bird-animation acts directly to these states: `listening` and119 * `chirping` map to Act 1, `handshake` maps to Act 2, `paired` to Act 3.120 */121export type PairingPhase =122 | "idle"123 | "listening"124 | "chirping"125 | "handshake"126 | "paired"127 | "timeout"128 | "mic_denied"129 | "version_mismatch"130 | "handshake_failed";131132export interface PairingProgress {133 phase: PairingPhase;134 peerDeviceName?: string;135 errorMessage?: string;136}137138// ---------------------------------------------------------------------------139// v3 wire protocol — phase-2 WebSocket manifest sync140// ---------------------------------------------------------------------------141142/**143 * v3 wire protocol — after Noise XX is open.144 *145 * Every frame is JSON, then ChaCha20-Poly1305 encrypted by Noise XX. The146 * WebSocket transport sees only ciphertext.147 *148 * The manifest carries per-entry timestamps; the receiver requests entries149 * where the remote timestamp beats its own. Entries traverse as opaque150 * encrypted blobs — the sync layer never decrypts vault data.151 */152export type ChirpSyncMessage =153 | { type: "SYNC_HELLO"; vaultId: string; deviceId: string; version: number; deviceTime: number }154 | { type: "SYNC_MANIFEST"; entries: Record<string, number>; tombstones: Record<string, number> }155 | { type: "SYNC_REQUEST"; entryIds: string[] }156 | { type: "SYNC_ENTRIES"; entries: Record<string, string> }157 | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }158 | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest }159 | { type: "SYNC_DONE"; entriesReceived?: number; entriesSent?: number }160 | { type: "SYNC_ERROR"; error: string };161162// ---------------------------------------------------------------------------163// v3 device manifest — propagates trust between mobile devices (v2 brief §4.3)164// ---------------------------------------------------------------------------165166export const DEVICE_MANIFEST_VERSION = 0x01 as const;167/** Manifests older than this are silently rejected (v2 brief §4.3). */168export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;169170/**171 * One device entry inside a signed manifest. `longTermPublicKeyHex` is the172 * X25519 sync identity key the receiver will use for Noise XX, exactly as173 * if the entry had been audio-paired locally. `signingPublicKeyHex` is the174 * peer's Ed25519 manifest-verification key — needed so a vouched mobile175 * can in turn verify manifests issued by the same vault from a different176 * device.177 */178export interface ManifestDeviceEntry {179 deviceId: string;180 deviceName: string;181 deviceType: DeviceType;182 longTermPublicKeyHex: string;183 signingPublicKeyHex: string;184 /**185 * Optional sync WebSocket port for native/mobile hosts. Preserving this in186 * vouched entries lets mobile peers connect without another audio pairing.187 */188 wsPort?: number;189 pairedAt: number;190}191192/** Body that gets canonicalised + signed. Wire format JSON-stable. */193export interface DeviceManifestBody {194 manifestVersion: number;195 issuerDeviceId: string;196 issuedAt: number;197 devices: ManifestDeviceEntry[];198}199200export interface SignedDeviceManifest {201 body: DeviceManifestBody;202 /** Hex-encoded 64-byte Ed25519 signature over canonicalised body. */203 signatureHex: string;204}205206export type SyncStatusV3 = "up_to_date" | "syncing" | "unreachable" | "never";