sync-types.ts

9.2 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. A valid v3 route is admitted34 *               automatically as same-vault membership. It is equivalent to35 *               direct for sync, but cannot issue manifests itself;36 *               surfaced with an "Added automatically" badge in Settings.37 */38export type TrustSource = "direct" | "vouched";3940/**41 * v3 paired-device record. Stored in `chrome.storage.local` — cryptographic42 * keys must not leave the device via a third-party cloud service.43 *44 * `longTermPublicKeyHex` is the counterpart's vault-derived X25519 sync45 * identity static key — see `deriveSyncIdentityKeypair`. Every WebSocket46 * reconnect runs Noise KK with both local static keys, followed by a47 * PAIR_IDENTITY exchange bound to the authenticated keys.48 *49 * `lastKnownIp` is intentionally NOT stored here — it's per-network and50 * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See51 * `getLastKnownIp` / `setLastKnownIp` in `paired-devices.ts`.52 */53export interface PairedDevice {54  deviceId: string;55  deviceName?: string;56  /** User-chosen label stored only on this device; never sent to peers. */57  localAlias?: string;58  deviceType?: DeviceType;59  longTermPublicKeyHex?: string;60  /**61   * Long-term Ed25519 verification key for the peer's device-manifest62   * signatures (v2 brief §4.3). Derived deterministically from the same63   * master key as `longTermPublicKeyHex`, via `deriveSyncSigningKeypair`.64   */65  signingPublicKeyHex?: string;66  /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */67  trustSource?: TrustSource;68  /**69   * The peer's WebSocket sync port. Current Android hosts use fixed port 4914270   * and announce it inside the encrypted pairing channel.71   */72  wsPort?: number;73  /** Pair-only 32-byte reconnect capability, derived from the first handshake. */74  rendezvousSecretHex?: string;75  /** Validated wss relay used by this pair. */76  relayUrl?: string;77  pairedAt: number;78  lastSyncedAt?: number;79}8081// ---------------------------------------------------------------------------82// v3 wire protocol — phase-2 WebSocket manifest sync83// ---------------------------------------------------------------------------8485/**86 * v3 wire protocol — after reconnect Noise KK is open and PAIR_IDENTITY has87 * been bound to both authenticated static keys.88 *89 * Every frame is JSON, then ChaCha20-Poly1305 encrypted by Noise KK. The90 * WebSocket transport sees only ciphertext.91 *92 * The manifest carries per-entry timestamps; the receiver requests entries93 * where the remote timestamp beats its own. Entries traverse as opaque94 * encrypted blobs — the sync layer never decrypts vault data.95 */96export type SyncMessage =97  | {98      type: "SYNC_HELLO";99      vaultId: string;100      deviceId: string;101      version: number;102      deviceTime: number;103      /**104       * Advertises equal-version device-graph exchange support. Older peers105       * omit this field and retain the legacy SYNC_DONE fast path.106       */107      deviceManifestVersion?: number;108    }109  | { type: "SYNC_MANIFEST"; entries: Record<string, number>; tombstones: Record<string, number> }110  | { type: "SYNC_REQUEST"; entryIds: string[] }111  | { type: "SYNC_ENTRIES"; entries: Record<string, string> }112  | { type: "SYNC_TOMBSTONES"; tombstones: Record<string, number> }113  | { type: "SYNC_DEVICE_MANIFEST"; manifest: SignedDeviceManifest | null }114  | { type: "SYNC_DONE"; entriesReceived?: number; entriesSent?: number }115  | { type: "SYNC_ERROR"; error: string };116117// ---------------------------------------------------------------------------118// Browser-to-browser relay frames (phone forwards; see peach-android's119// RelayFrames.kt / RelayHub.kt, the source of truth this mirrors — NOT the120// older 4-frame PEACH_SYNC_RELAY_DESIGN.md sketch, which is stale).121//122// Type-only for now: nothing in this repo sends or handles these yet — no123// browser can listen, so relaying laptop-to-laptop traffic through a shared124// phone is unbuilt browser-side work, tracked separately. This exists so125// that work starts from the real wire contract instead of the stale doc.126// ---------------------------------------------------------------------------127128/** RelayMessage.targetDeviceId/peerDeviceId are the destination's Noise129 *  static public key as lowercase hex — NOT `PairedDevice.deviceId`. */130export type RelayMessage =131  | { type: "RELAY_REQUEST"; targetDeviceId: string }132  | { type: "RELAY_ACCEPT" }133  | { type: "RELAY_UNAVAILABLE" }134  | { type: "RELAY_DISABLED" }135  | { type: "RELAY_OPEN"; peerDeviceId: string }136  | { type: "RELAY_FRAME"; payload: string }137  | { type: "RELAY_CLOSE" };138139// ---------------------------------------------------------------------------140// v3 device manifest — propagates trust between mobile devices (v2 brief §4.3)141// ---------------------------------------------------------------------------142143/** 0x03 adds each peer edge's relay URL so a newly introduced pair can agree144 *  on a route without depending on the introducing device. Coordinated wire145 *  change with peach-android. */146export const DEVICE_MANIFEST_VERSION = 0x03 as const;147/** Manifests older than this are silently rejected (v2 brief §4.3). */148export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;149150/**151 * One device entry inside a signed manifest. `longTermPublicKeyHex` is the152 * X25519 sync identity key the receiver will use for Noise KK reconnect.153 * `signingPublicKeyHex` is the154 * peer's Ed25519 manifest-verification key — needed so a vouched mobile155 * can in turn verify manifests issued by the same vault from a different156 * device.157 */158export interface ManifestDeviceEntry {159  deviceId: string;160  deviceName: string;161  deviceType: DeviceType;162  longTermPublicKeyHex: string;163  signingPublicKeyHex: string;164  /**165   * Optional sync WebSocket port for native/mobile hosts. Preserving this in166   * vouched entries lets mobile peers connect without another audio pairing.167   */168  wsPort?: number;169  /** Relay the issuer currently uses for this peer edge. */170  relayUrl?: string;171  pairedAt: number;172}173174/**175 * A device that must no longer be synced with. Revocation is TERMINAL: once a176 * deviceId OR its long-term key appears here, no manifest may re-vouch it and177 * no pending entry carrying it may be confirmed. That is deliberate —178 * tombstone-style "newest timestamp wins" ordering would let a peer resurrect a179 * revoked device just by issuing a manifest with a fresh `pairedAt`, and every180 * timestamp in a manifest is peer-supplied. Re-admitting a device requires a181 * direct, SAS-confirmed re-pair with a physically present user.182 *183 * HONEST LIMIT: every mesh member holds the vault root key, and signing keys184 * are derived from it (`deriveSyncSigningKeypair(rootKey, deviceId)`), so these185 * signatures prove *vault membership*, not device identity. A revoked device186 * still holds the root key and, running modified software, can ignore its own187 * revocation. This mechanism stops honest peers from talking to a revoked188 * device; it is not a cryptographic ejection. See HOLE 4 — a genuinely189 * compromised device needs the master password and recovery words rotated.190 */191export interface DeviceRevocation {192  deviceId: string;193  /** X25519 sync identity key at the moment of revocation. Load-bearing, not194   *  descriptive: `deviceId` is whatever an issuing manifest says it is, so the195   *  key is what re-vouching is actually matched against. `0`×64 means no key196   *  was ever stored for the device and matches nothing. */197  longTermPublicKeyHex: string;198  revokedAt: number;199  /** deviceId that issued the revocation, for display only. */200  revokedByDeviceId: string;201}202203/** Body that gets canonicalised + signed. Wire format JSON-stable. */204export interface DeviceManifestBody {205  manifestVersion: number;206  issuerDeviceId: string;207  issuedAt: number;208  devices: ManifestDeviceEntry[];209  revoked: DeviceRevocation[];210}211212export interface SignedDeviceManifest {213  body: DeviceManifestBody;214  /** Hex-encoded 64-byte Ed25519 signature over canonicalised body. */215  signatureHex: string;216}217218export type SyncStatusV3 = "up_to_date" | "syncing" | "unreachable" | "never";