sync-types.ts
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// ---------------------------------------------------------------------------15// Long-term paired device registry16// ---------------------------------------------------------------------------1718/**19 * Trust source of a paired-device entry — per v2 brief §4.2.20 *21 * "direct" — established via a pairing-code session with this device.22 * "vouched" — introduced by a directly-paired peer via a signed device23 * manifest during a normal sync. A valid v3 route is admitted24 * automatically as same-vault membership. It is equivalent to25 * direct for sync, but cannot issue manifests itself;26 * surfaced with an "Added automatically" badge in Settings.27 */28export type TrustSource = "direct" | "vouched";2930/**31 * v3 paired-device record. Stored in `chrome.storage.local` — cryptographic32 * keys must not leave the device via a third-party cloud service.33 *34 * `longTermPublicKeyHex` is the counterpart's vault-derived X25519 sync35 * identity static key — see `deriveSyncIdentityKeypair`. Every WebSocket36 * reconnect runs Noise KK with both local static keys, followed by a37 * PAIR_IDENTITY exchange bound to the authenticated keys.38 *39 * `lastKnownIp` is intentionally NOT stored here — it's per-network and40 * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See41 * `getLastKnownIp` / `setLastKnownIp` in `paired-devices.ts`.42 */43export interface PairedDevice {44 deviceId: string;45 deviceName?: string;46 /** User-chosen label stored only on this device; never sent to peers. */47 localAlias?: string;48 deviceType?: DeviceType;49 longTermPublicKeyHex?: string;50 /**51 * Long-term Ed25519 verification key for the peer's device-manifest52 * signatures (v2 brief §4.3). Derived deterministically from the same53 * master key as `longTermPublicKeyHex`, via `deriveSyncSigningKeypair`.54 */55 signingPublicKeyHex?: string;56 /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */57 trustSource?: TrustSource;58 /**59 * The peer's WebSocket sync port. Current Android hosts use fixed port 4914260 * and announce it inside the encrypted pairing channel.61 */62 wsPort?: number;63 /** Pair-only 32-byte reconnect capability, derived from the first handshake. */64 rendezvousSecretHex?: string;65 /** Validated wss relay used by this pair. */66 relayUrl?: string;67 pairedAt: number;68 lastSyncedAt?: number;69}7071// ---------------------------------------------------------------------------72// v3 wire protocol — phase-2 WebSocket manifest sync73// ---------------------------------------------------------------------------7475/**76 * v3 wire protocol — after reconnect Noise KK is open and PAIR_IDENTITY has77 * been bound to both authenticated static keys.78 *79 * Every frame is JSON, then ChaCha20-Poly1305 encrypted by Noise KK. The80 * WebSocket transport sees only ciphertext.81 *82 * The manifest carries per-entry timestamps; the receiver requests entries83 * where the remote timestamp beats its own. Entries traverse as opaque84 * encrypted blobs — the sync layer never decrypts vault data.85 */86export type SyncMessage =87 | {88 type: "SYNC_HELLO";89 vaultId: string;90 deviceId: string;91 version: number;92 deviceTime: number;93 /**94 * Advertises equal-version device-graph exchange support. Older peers95 * omit this field and retain the legacy SYNC_DONE fast path.96 */97 deviceManifestVersion?: number;98 }99 | { type: "SYNC_MANIFEST"; entries: Record<string, number>; tombstones: Record<string, number> }100 | { type: "SYNC_REQUEST"; entryIds: string[] }101 | { type: "SYNC_ENTRIES"; entries: Record<string, string> }102 | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }103 | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest | null }104 | { type: "SYNC_DONE"; entriesReceived?: number; entriesSent?: number }105 | { type: "SYNC_ERROR"; error: string };106107// ---------------------------------------------------------------------------108// Browser-to-browser relay frames (phone forwards; see peach-android's109// RelayFrames.kt / RelayHub.kt, the source of truth this mirrors — NOT the110// older 4-frame PEACH_SYNC_RELAY_DESIGN.md sketch, which is stale).111//112// Type-only for now: nothing in this repo sends or handles these yet — no113// browser can listen, so relaying laptop-to-laptop traffic through a shared114// phone is unbuilt browser-side work, tracked separately. This exists so115// that work starts from the real wire contract instead of the stale doc.116// ---------------------------------------------------------------------------117118/** RelayMessage.targetDeviceId/peerDeviceId are the destination's Noise119 * static public key as lowercase hex — NOT `PairedDevice.deviceId`. */120export type RelayMessage =121 | { type: "RELAY_REQUEST"; targetDeviceId: string }122 | { type: "RELAY_ACCEPT" }123 | { type: "RELAY_UNAVAILABLE" }124 | { type: "RELAY_DISABLED" }125 | { type: "RELAY_OPEN"; peerDeviceId: string }126 | { type: "RELAY_FRAME"; payload: string }127 | { type: "RELAY_CLOSE" };128129// ---------------------------------------------------------------------------130// v3 device manifest — propagates trust between mobile devices (v2 brief §4.3)131// ---------------------------------------------------------------------------132133/** 0x03 adds each peer edge's relay URL so a newly introduced pair can agree134 * on a route without depending on the introducing device. Coordinated wire135 * change with peach-android. */136export const DEVICE_MANIFEST_VERSION = 0x03 as const;137/** Manifests older than this are silently rejected (v2 brief §4.3). */138export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;139140/**141 * One device entry inside a signed manifest. `longTermPublicKeyHex` is the142 * X25519 sync identity key the receiver will use for Noise KK reconnect.143 * `signingPublicKeyHex` is the144 * peer's Ed25519 manifest-verification key — needed so a vouched mobile145 * can in turn verify manifests issued by the same vault from a different146 * device.147 */148export interface ManifestDeviceEntry {149 deviceId: string;150 deviceName: string;151 deviceType: DeviceType;152 longTermPublicKeyHex: string;153 signingPublicKeyHex: string;154 /**155 * Optional sync WebSocket port for native/mobile hosts. Preserving this in156 * vouched entries lets mobile peers connect without another audio pairing.157 */158 wsPort?: number;159 /** Relay the issuer currently uses for this peer edge. */160 relayUrl?: string;161 pairedAt: number;162}163164/**165 * A device that must no longer be synced with. Revocation is TERMINAL: once a166 * deviceId OR its long-term key appears here, no manifest may re-vouch it and167 * no pending entry carrying it may be confirmed. That is deliberate —168 * tombstone-style "newest timestamp wins" ordering would let a peer resurrect a169 * revoked device just by issuing a manifest with a fresh `pairedAt`, and every170 * timestamp in a manifest is peer-supplied. Re-admitting a device requires a171 * direct, SAS-confirmed re-pair with a physically present user.172 *173 * HONEST LIMIT: every mesh member holds the vault root key, and signing keys174 * are derived from it (`deriveSyncSigningKeypair(rootKey, deviceId)`), so these175 * signatures prove *vault membership*, not device identity. A revoked device176 * still holds the root key and, running modified software, can ignore its own177 * revocation. This mechanism stops honest peers from talking to a revoked178 * device; it is not a cryptographic ejection. See HOLE 4 — a genuinely179 * compromised device needs the master password and recovery words rotated.180 */181export interface DeviceRevocation {182 deviceId: string;183 /** X25519 sync identity key at the moment of revocation. Load-bearing, not184 * descriptive: `deviceId` is whatever an issuing manifest says it is, so the185 * key is what re-vouching is actually matched against. `0`×64 means no key186 * was ever stored for the device and matches nothing. */187 longTermPublicKeyHex: string;188 revokedAt: number;189 /** deviceId that issued the revocation, for display only. */190 revokedByDeviceId: string;191}192193/** Body that gets canonicalised + signed. Wire format JSON-stable. */194export interface DeviceManifestBody {195 manifestVersion: number;196 issuerDeviceId: string;197 issuedAt: number;198 devices: ManifestDeviceEntry[];199 revoked: DeviceRevocation[];200}201202export interface SignedDeviceManifest {203 body: DeviceManifestBody;204 /** Hex-encoded 64-byte Ed25519 signature over canonicalised body. */205 signatureHex: string;206}