sync-types.ts
1// Vault sync — type definitions (Master Brief v2).2//3// v3 — code 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// Device types9// ---------------------------------------------------------------------------1011export type DeviceType = "extension" | "android" | "ios";1213// Numeric tags for DeviceType on the wire — not the string form.14export const DEVICE_TYPE_EXTENSION = 0x01 as const;15export const DEVICE_TYPE_ANDROID = 0x02 as const;16export const DEVICE_TYPE_IOS = 0x03 as const;1718export type DeviceTypeByte =19 | typeof DEVICE_TYPE_EXTENSION20 | typeof DEVICE_TYPE_ANDROID21 | typeof DEVICE_TYPE_IOS;2223// ---------------------------------------------------------------------------24// Long-term paired device registry25// ---------------------------------------------------------------------------2627/**28 * Trust source of a paired-device entry — per v2 brief §4.2.29 *30 * "direct" — established via a pairing-code session with this device.31 * "vouched" — introduced by a directly-paired peer via a signed device32 * manifest during a normal sync, then confirmed by the user33 * (MV-7). Functionally equivalent to direct for sync purposes;34 * surfaced with an "Added automatically" badge in Settings.35 */36export type TrustSource = "direct" | "vouched";3738/**39 * v3 paired-device record. Stored in `chrome.storage.local` — cryptographic40 * keys must not leave the device via a third-party cloud service.41 *42 * `longTermPublicKeyHex` is the counterpart's vault-derived X25519 sync43 * identity static key — see `deriveSyncIdentityKeypair`. Every WebSocket44 * reconnect runs Noise KK with both local static keys, followed by a45 * PAIR_IDENTITY exchange bound to the authenticated keys.46 *47 * `lastKnownIp` is intentionally NOT stored here — it's per-network and48 * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See49 * `getLastKnownIp` / `setLastKnownIp` in `paired-devices.ts`.50 */51export interface PairedDevice {52 deviceId: string;53 deviceName?: string;54 deviceType?: DeviceType;55 longTermPublicKeyHex?: string;56 /**57 * Long-term Ed25519 verification key for the peer's device-manifest58 * signatures (v2 brief §4.3). Derived deterministically from the same59 * master key as `longTermPublicKeyHex`, via `deriveSyncSigningKeypair`.60 */61 signingPublicKeyHex?: string;62 /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */63 trustSource?: TrustSource;64 /**65 * The peer's WebSocket sync port. Current Android hosts use fixed port 4914266 * and announce it inside the encrypted pairing channel.67 */68 wsPort?: number;69 /** Pair-only 32-byte reconnect capability, derived from the first handshake. */70 rendezvousSecretHex?: string;71 /** Validated wss relay used by this pair. */72 relayUrl?: string;73 pairedAt: number;74 lastSyncedAt?: number;75}7677// ---------------------------------------------------------------------------78// v3 wire protocol — phase-2 WebSocket manifest sync79// ---------------------------------------------------------------------------8081/**82 * v3 wire protocol — after reconnect Noise KK is open and PAIR_IDENTITY has83 * been bound to both authenticated static keys.84 *85 * Every frame is JSON, then ChaCha20-Poly1305 encrypted by Noise KK. The86 * WebSocket transport sees only ciphertext.87 *88 * The manifest carries per-entry timestamps; the receiver requests entries89 * where the remote timestamp beats its own. Entries traverse as opaque90 * encrypted blobs — the sync layer never decrypts vault data.91 */92export type SyncMessage =93 | { type: "SYNC_HELLO"; vaultId: string; deviceId: string; version: number; deviceTime: number }94 | { type: "SYNC_MANIFEST"; entries: Record<string, number>; tombstones: Record<string, number> }95 | { type: "SYNC_REQUEST"; entryIds: string[] }96 | { type: "SYNC_ENTRIES"; entries: Record<string, string> }97 | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }98 | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest | null }99 | { type: "SYNC_DONE"; entriesReceived?: number; entriesSent?: number }100 | { type: "SYNC_ERROR"; error: string };101102// ---------------------------------------------------------------------------103// Browser-to-browser relay frames (phone forwards; see peach-android's104// RelayFrames.kt / RelayHub.kt, the source of truth this mirrors — NOT the105// older 4-frame PEACH_SYNC_RELAY_DESIGN.md sketch, which is stale).106//107// Type-only for now: nothing in this repo sends or handles these yet — no108// browser can listen, so relaying laptop-to-laptop traffic through a shared109// phone is unbuilt browser-side work, tracked separately. This exists so110// that work starts from the real wire contract instead of the stale doc.111// ---------------------------------------------------------------------------112113/** RelayMessage.targetDeviceId/peerDeviceId are the destination's Noise114 * static public key as lowercase hex — NOT `PairedDevice.deviceId`. */115export type RelayMessage =116 | { type: "RELAY_REQUEST"; targetDeviceId: string }117 | { type: "RELAY_ACCEPT" }118 | { type: "RELAY_UNAVAILABLE" }119 | { type: "RELAY_DISABLED" }120 | { type: "RELAY_OPEN"; peerDeviceId: string }121 | { type: "RELAY_FRAME"; payload: string }122 | { type: "RELAY_CLOSE" };123124// ---------------------------------------------------------------------------125// v3 device manifest — propagates trust between mobile devices (v2 brief §4.3)126// ---------------------------------------------------------------------------127128/** 0x02 added the `revoked` array to the signed body. Coordinated wire change129 * with peach-android — the canonical signing input differs, so a 0x01 peer and130 * a 0x02 peer cannot verify each other's manifests. */131export const DEVICE_MANIFEST_VERSION = 0x02 as const;132/** Manifests older than this are silently rejected (v2 brief §4.3). */133export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;134135/**136 * One device entry inside a signed manifest. `longTermPublicKeyHex` is the137 * X25519 sync identity key the receiver will use for Noise KK reconnect.138 * `signingPublicKeyHex` is the139 * peer's Ed25519 manifest-verification key — needed so a vouched mobile140 * can in turn verify manifests issued by the same vault from a different141 * device.142 */143export interface ManifestDeviceEntry {144 deviceId: string;145 deviceName: string;146 deviceType: DeviceType;147 longTermPublicKeyHex: string;148 signingPublicKeyHex: string;149 /**150 * Optional sync WebSocket port for native/mobile hosts. Preserving this in151 * vouched entries lets mobile peers connect without another audio pairing.152 */153 wsPort?: number;154 pairedAt: number;155}156157/**158 * A device that must no longer be synced with. Revocation is TERMINAL: once a159 * deviceId appears here, no manifest may re-vouch it. That is deliberate —160 * tombstone-style "newest timestamp wins" ordering would let a peer resurrect a161 * revoked device just by issuing a manifest with a fresh `pairedAt`, and every162 * timestamp in a manifest is peer-supplied. Re-admitting a device requires a163 * direct, SAS-confirmed re-pair with a physically present user.164 *165 * HONEST LIMIT: every mesh member holds the vault root key, and signing keys166 * are derived from it (`deriveSyncSigningKeypair(rootKey, deviceId)`), so these167 * signatures prove *vault membership*, not device identity. A revoked device168 * still holds the root key and, running modified software, can ignore its own169 * revocation. This mechanism stops honest peers from talking to a revoked170 * device; it is not a cryptographic ejection. See HOLE 4 — a genuinely171 * compromised device needs the master password and recovery words rotated.172 */173export interface DeviceRevocation {174 deviceId: string;175 /** X25519 sync identity key at the moment of revocation, so the record names176 * the exact key that was cut off rather than just an id. */177 longTermPublicKeyHex: string;178 revokedAt: number;179 /** deviceId that issued the revocation, for display only. */180 revokedByDeviceId: string;181}182183/** Body that gets canonicalised + signed. Wire format JSON-stable. */184export interface DeviceManifestBody {185 manifestVersion: number;186 issuerDeviceId: string;187 issuedAt: number;188 devices: ManifestDeviceEntry[];189 revoked: DeviceRevocation[];190}191192export interface SignedDeviceManifest {193 body: DeviceManifestBody;194 /** Hex-encoded 64-byte Ed25519 signature over canonicalised body. */195 signatureHex: string;196}197198export type SyncStatusV3 = "up_to_date" | "syncing" | "unreachable" | "never";