sync-types.ts

8.5 KB · raw

1// Vault sync — type definitions.2//3// Pairing happens once per device pair over a relay rendezvous; all ongoing4// sync afterwards runs over that same relay with Noise KK. There is one5// transport — LAN peer-to-peer sync is retired6// (docs/sync-lan-retirement.md).78// ---------------------------------------------------------------------------9// Device types10// ---------------------------------------------------------------------------1112export type DeviceType = "extension" | "android" | "ios";1314// Numeric tags for DeviceType on the wire — not the string form.15export const DEVICE_TYPE_EXTENSION = 0x01 as const;16export const DEVICE_TYPE_ANDROID = 0x02 as const;17export const DEVICE_TYPE_IOS = 0x03 as const;1819export type DeviceTypeByte =20  | typeof DEVICE_TYPE_EXTENSION21  | typeof DEVICE_TYPE_ANDROID22  | typeof DEVICE_TYPE_IOS;2324// ---------------------------------------------------------------------------25// Long-term paired device registry26// ---------------------------------------------------------------------------2728/**29 * Trust source of a paired-device entry — per v2 brief §4.2.30 *31 *   "direct"  — established via a pairing-code session with this device.32 *   "vouched" — introduced by a directly-paired peer via a signed device33 *               manifest during a normal sync, then confirmed by the user34 *               (MV-7). Functionally equivalent to direct for sync purposes;35 *               surfaced with an "Added automatically" badge in Settings.36 */37export type TrustSource = "direct" | "vouched";3839/**40 * v3 paired-device record. Stored in `chrome.storage.local` — cryptographic41 * keys must not leave the device via a third-party cloud service.42 *43 * `longTermPublicKeyHex` is the counterpart's vault-derived X25519 sync44 * identity static key — see `deriveSyncIdentityKeypair`. Every WebSocket45 * reconnect runs Noise KK with both local static keys, followed by a46 * PAIR_IDENTITY exchange bound to the authenticated keys.47 *48 * `lastKnownIp` is intentionally NOT stored here — it's per-network and49 * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See50 * `getLastKnownIp` / `setLastKnownIp` in `paired-devices.ts`.51 */52export interface PairedDevice {53  deviceId: string;54  deviceName?: string;55  deviceType?: DeviceType;56  longTermPublicKeyHex?: string;57  /**58   * Long-term Ed25519 verification key for the peer's device-manifest59   * signatures (v2 brief §4.3). Derived deterministically from the same60   * master key as `longTermPublicKeyHex`, via `deriveSyncSigningKeypair`.61   */62  signingPublicKeyHex?: string;63  /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */64  trustSource?: TrustSource;65  /**66   * The peer's WebSocket sync port. Current Android hosts use fixed port 4914267   * and announce it inside the encrypted pairing channel.68   */69  wsPort?: number;70  /** Pair-only 32-byte reconnect capability, derived from the first handshake. */71  rendezvousSecretHex?: string;72  /** Validated wss relay used by this pair. */73  relayUrl?: string;74  pairedAt: number;75  lastSyncedAt?: number;76}7778// ---------------------------------------------------------------------------79// v3 wire protocol — phase-2 WebSocket manifest sync80// ---------------------------------------------------------------------------8182/**83 * v3 wire protocol — after reconnect Noise KK is open and PAIR_IDENTITY has84 * been bound to both authenticated static keys.85 *86 * Every frame is JSON, then ChaCha20-Poly1305 encrypted by Noise KK. The87 * WebSocket transport sees only ciphertext.88 *89 * The manifest carries per-entry timestamps; the receiver requests entries90 * where the remote timestamp beats its own. Entries traverse as opaque91 * encrypted blobs — the sync layer never decrypts vault data.92 */93export type SyncMessage =94  | { type: "SYNC_HELLO"; vaultId: string; deviceId: string; version: number; deviceTime: number }95  | { type: "SYNC_MANIFEST"; entries: Record<string, number>; tombstones: Record<string, number> }96  | { type: "SYNC_REQUEST"; entryIds: string[] }97  | { type: "SYNC_ENTRIES"; entries: Record<string, string> }98  | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }99  | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest | null }100  | { type: "SYNC_DONE"; entriesReceived?: number; entriesSent?: number }101  | { type: "SYNC_ERROR"; error: string };102103// ---------------------------------------------------------------------------104// Browser-to-browser relay frames (phone forwards; see peach-android's105// RelayFrames.kt / RelayHub.kt, the source of truth this mirrors — NOT the106// older 4-frame PEACH_SYNC_RELAY_DESIGN.md sketch, which is stale).107//108// Type-only for now: nothing in this repo sends or handles these yet — no109// browser can listen, so relaying laptop-to-laptop traffic through a shared110// phone is unbuilt browser-side work, tracked separately. This exists so111// that work starts from the real wire contract instead of the stale doc.112// ---------------------------------------------------------------------------113114/** RelayMessage.targetDeviceId/peerDeviceId are the destination's Noise115 *  static public key as lowercase hex — NOT `PairedDevice.deviceId`. */116export type RelayMessage =117  | { type: "RELAY_REQUEST"; targetDeviceId: string }118  | { type: "RELAY_ACCEPT" }119  | { type: "RELAY_UNAVAILABLE" }120  | { type: "RELAY_DISABLED" }121  | { type: "RELAY_OPEN"; peerDeviceId: string }122  | { type: "RELAY_FRAME"; payload: string }123  | { type: "RELAY_CLOSE" };124125// ---------------------------------------------------------------------------126// v3 device manifest — propagates trust between mobile devices (v2 brief §4.3)127// ---------------------------------------------------------------------------128129/** 0x02 added the `revoked` array to the signed body. Coordinated wire change130 *  with peach-android — the canonical signing input differs, so a 0x01 peer and131 *  a 0x02 peer cannot verify each other's manifests. */132export const DEVICE_MANIFEST_VERSION = 0x02 as const;133/** Manifests older than this are silently rejected (v2 brief §4.3). */134export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;135136/**137 * One device entry inside a signed manifest. `longTermPublicKeyHex` is the138 * X25519 sync identity key the receiver will use for Noise KK reconnect.139 * `signingPublicKeyHex` is the140 * peer's Ed25519 manifest-verification key — needed so a vouched mobile141 * can in turn verify manifests issued by the same vault from a different142 * device.143 */144export interface ManifestDeviceEntry {145  deviceId: string;146  deviceName: string;147  deviceType: DeviceType;148  longTermPublicKeyHex: string;149  signingPublicKeyHex: string;150  /**151   * Optional sync WebSocket port for native/mobile hosts. Preserving this in152   * vouched entries lets mobile peers connect without another audio pairing.153   */154  wsPort?: number;155  pairedAt: number;156}157158/**159 * A device that must no longer be synced with. Revocation is TERMINAL: once a160 * deviceId appears here, no manifest may re-vouch it. That is deliberate —161 * tombstone-style "newest timestamp wins" ordering would let a peer resurrect a162 * revoked device just by issuing a manifest with a fresh `pairedAt`, and every163 * timestamp in a manifest is peer-supplied. Re-admitting a device requires a164 * direct, SAS-confirmed re-pair with a physically present user.165 *166 * HONEST LIMIT: every mesh member holds the vault root key, and signing keys167 * are derived from it (`deriveSyncSigningKeypair(rootKey, deviceId)`), so these168 * signatures prove *vault membership*, not device identity. A revoked device169 * still holds the root key and, running modified software, can ignore its own170 * revocation. This mechanism stops honest peers from talking to a revoked171 * device; it is not a cryptographic ejection. See HOLE 4 — a genuinely172 * compromised device needs the master password and recovery words rotated.173 */174export interface DeviceRevocation {175  deviceId: string;176  /** X25519 sync identity key at the moment of revocation, so the record names177   *  the exact key that was cut off rather than just an id. */178  longTermPublicKeyHex: string;179  revokedAt: number;180  /** deviceId that issued the revocation, for display only. */181  revokedByDeviceId: string;182}183184/** Body that gets canonicalised + signed. Wire format JSON-stable. */185export interface DeviceManifestBody {186  manifestVersion: number;187  issuerDeviceId: string;188  issuedAt: number;189  devices: ManifestDeviceEntry[];190  revoked: DeviceRevocation[];191}192193export interface SignedDeviceManifest {194  body: DeviceManifestBody;195  /** Hex-encoded 64-byte Ed25519 signature over canonicalised body. */196  signatureHex: string;197}198199export type SyncStatusV3 = "up_to_date" | "syncing" | "unreachable" | "never";