peach_sync_spec.md

19.3 KB · raw

rendered · source

1# Peach Sync — Protocol & Implementation Spec2**Generated:** 2026-04-20  3**Scope:** The complete peer-to-peer sync system for Peach Pro. This document covers protocol design, discovery, pairing, transport security, delta sync, conflict resolution, and extension integration. This is the largest engineering effort in the Peach roadmap.45---67## What Peach Sync is89Peach Sync is a **Pro-tier** feature that keeps vault data consistent across a user's devices without any cloud infrastructure. Devices communicate directly over the local network. No relay server. No Peach backend. No cloud vault.1011The website's description is authoritative and must remain accurate throughout implementation:1213> *"Noise NK protocol over your local network or Tailscale. Encrypted end-to-end. No relay. No cloud. Dormant on unknown networks — Peach has zero network presence on public WiFi."*1415> *"Pair once, and they sync automatically whenever they're on the same WiFi."*1617> *"If no peer is present, nothing leaves the device."*1819---2021## Design constraints22231. **No server.** There is no Peach-operated relay, STUN, or signalling server. If devices cannot see each other on the network, sync does not happen — and that is correct behavior, not a bug.24252. **Trusted networks only.** Peach Sync is completely dormant on networks the user has not explicitly marked as trusted. On an unknown network: no mDNS advertisement, no open socket, no listening port, no background activity.26273. **Offline-first.** Each device is authoritative for its own copy of the vault. Sync merges changes — it does not make any device dependent on another to function.28294. **Forward secrecy.** Session keys are ephemeral. Compromise of a past session key does not reveal future session traffic.30315. **Ed25519 device identity.** Each Peach installation has a permanent Ed25519 keypair. Device identity is the public key. Pairing binds two public keys together. The Noise NK handshake authenticates the responder's static key.3233---3435## Architecture overview3637```38Device A (initiator)                    Device B (responder)39────────────────────                    ─────────────────────40Peach Sync daemon                       Peach Sync daemon41  │                                       │42  ├─ mDNS: discover peers ──────────────> │43  │         _peach-sync._tcp.local        │44  │                                       │45  ├─ TCP connect ───────────────────────> │46  │                                       │47  ├─ Noise NK handshake ────────────────> │48  │ <────────────────────────────────────┤49  │  (session key established)            │50  │                                       │51  ├─ SYNC_HELLO (device ID, clock) ────> │52  │ <──────────────── SYNC_HELLO ────────┤53  │                                       │54  ├─ SYNC_MANIFEST (entry hashes) ─────> │55  │ <──────────────── SYNC_MANIFEST ─────┤56  │                                       │57  ├─ SYNC_REQUEST (missing IDs) ────────> │58  │ <──────────────── SYNC_ENTRIES ──────┤59  │ <──────────────── SYNC_REQUEST ──────┤60  ├─ SYNC_ENTRIES ──────────────────────> │61  │                                       │62  ├─ SYNC_DONE ─────────────────────────> │63  │ <──────────────── SYNC_DONE ─────────┤64  │                                       │65  (session ends, TCP connection closed)66```6768---6970## Transport security: Noise NK7172### Pattern choice7374Noise NK is used because:75- The responder's static key is known to the initiator before the handshake (stored at pairing time)76- The initiator needs no static key — this avoids complex initiator identity management77- NK provides mutual authentication (responder is authenticated via static key, initiator is authenticated implicitly by knowing the responder's key)78- It provides forward secrecy (ephemeral DH)7980### Handshake8182```83NK:84  <- s                    (pre-message: responder static public key)85  ...86  -> e, es                (initiator sends ephemeral key, performs DH with responder static)87  <- e, ee                (responder sends ephemeral key, performs DH)88```8990After the handshake completes, all transport messages are encrypted with ChaCha20-Poly1305 using the derived session key. Nonces are sequential counters, starting at 0, incremented per message.9192### Cryptographic primitives9394| Role | Algorithm |95|---|---|96| DH | X25519 |97| Cipher | ChaCha20-Poly1305 |98| Hash | BLAKE2s |99| Static key type | Ed25519 |100101Note: Noise NK requires a DH function over Curve25519 (X25519), not Ed25519. The device's Ed25519 static key is used for **identity** (mDNS announcement, pairing verification). The Noise handshake uses a separate X25519 keypair derived from the Ed25519 key via `ed25519_to_curve25519()` conversion, or simply a separate persistent X25519 keypair stored alongside the Ed25519 key.102103### Implementation104105**Use `@noble/ciphers` and `@noble/curves`** — already security-audited, no native dependencies, works in WebCrypto environments and Node.106107```ts108import { x25519 } from "@noble/curves/ed25519";109import { chacha20poly1305 } from "@noble/ciphers/chacha";110import { blake2s } from "@noble/hashes/blake2s";111```112113The Noise NK state machine is implemented in `src/sync/noise.ts` — a clean, typed implementation of the Noise protocol framework sufficient for the NK pattern.114115---116117## Device discovery: mDNS118119### Service type120`_peach-sync._tcp.local`121122### Advertisement payload123124Each Peach instance on a trusted network advertises:125```126Service name: peach-<base58(pubkey[0..4])>._peach-sync._tcp.local127Port:         TCP port (randomly assigned per session, 49152–65535)128TXT records:129  v=1                     (protocol version)130  pk=<base64(x25519_pubkey)>  (X25519 static public key for Noise NK)131  did=<base64(ed25519_pubkey)>  (device identity key — for pairing lookup)132  name=<device display name>133```134135### Discovery implementation136137**In Chrome extension context:** `chrome.sockets.udp` can send/receive UDP multicast packets (224.0.0.251:5353) with `multicastAddress` set. This is sufficient to implement basic mDNS querying and response.138139**Recommended alternative for v1 (simpler, lower risk):** Use a **Native Messaging host** (a small Rust binary bundled with the extension) that handles mDNS and TCP socket operations. The extension communicates with the host via `chrome.runtime.connectNative`. This avoids the complexity of raw UDP multicast inside a service worker and gives access to Rust's `mdns-sd` or `zeroconf` crates.140141Both approaches are specced; the Native Messaging approach is recommended for the first shipping version.142143### Trusted network check144145Before advertising or scanning, Peach checks whether the current network is trusted:146147```ts148interface TrustedNetwork {149  ssid?: string;       // WiFi SSID (from chrome.sockets or native host)150  bssid?: string;      // Optional: more specific151  isTailscale: boolean;152  addedAt: number;153}154```155156Trusted networks are stored as `peach_trusted_networks: TrustedNetwork[]` in `chrome.storage.local`.157158To detect Tailscale: check if a 100.x.x.x IP is assigned to a network interface (via native host or `chrome.sockets`).159160If the current network is not trusted and not Tailscale: **zero network activity.** No UDP, no TCP, no polling.161162---163164## Pairing flow165166Pairing is a one-time operation that exchanges device identity keys and creates a mutual trust record.167168### UX flow169170**Device A (initiating pairing):**1711. Settings → Sync → "Pair a new device"1722. A 6-digit numeric pairing code is displayed (generated CSPRNG, 30-second TTL)1733. Device A opens a temporary TCP listener on a random port and advertises via mDNS with `pair=true` TXT record174175**Device B (accepting pairing):**1761. Settings → Sync → "Pair with another device"1772. Enter the 6-digit code from Device A1783. Device B discovers Device A via mDNS (or user enters Device A's IP manually if mDNS fails)1794. Device B connects, completes a modified Noise NK handshake using the responder's advertised X25519 key1805. Device B sends `PAIR_REQUEST` message containing: Ed25519 pubkey, device name, the 6-digit code1816. Device A verifies the code matches, if correct: stores Device B's pubkey + X25519 key as a trusted peer1827. Device A sends `PAIR_ACCEPT` containing its own Ed25519 pubkey + device name1838. Both devices now have each other's keys stored as `peach_paired_devices`184185### Pairing record186187```ts188interface PairedDevice {189  deviceId: string;        // hex of Ed25519 pubkey190  displayName: string;191  x25519PublicKey: string; // hex, for Noise NK192  pairedAt: number;193  lastSeenAt?: number;194  lastSyncAt?: number;195}196```197198Stored as `peach_paired_devices: PairedDevice[]` in `chrome.storage.local`.199200---201202## Sync protocol messages203204All messages are serialized as MessagePack (or JSON if MessagePack adds complexity) and encrypted via ChaCha20-Poly1305 within the Noise NK session.205206### Message types207208```ts209type SyncMessage =210  | { type: "SYNC_HELLO";    deviceId: string; clockMs: number; vaultVersion: number }211  | { type: "SYNC_MANIFEST"; entries: Array<{ id: string; modifiedAt: number; deletedAt?: number; hash: string }> }212  | { type: "SYNC_REQUEST";  ids: string[] }213  | { type: "SYNC_ENTRIES";  entries: EncryptedSyncEntry[] }214  | { type: "SYNC_DONE" }215  | { type: "SYNC_ERROR";    code: string; message: string }216  | { type: "PAIR_REQUEST";  edPublicKey: string; name: string; code: string }217  | { type: "PAIR_ACCEPT";   edPublicKey: string; name: string }218  | { type: "PAIR_REJECT";   reason: string }219```220221### EncryptedSyncEntry222223Each entry in a `SYNC_ENTRIES` message is individually encrypted with the vault master key **before** being wrapped in the session encryption. This means even the sync peer sees only ciphertext — the sync transport layer has no access to plaintext vault data.224225```ts226interface EncryptedSyncEntry {227  id:          string;   // UUID, plaintext (for indexing)228  modifiedAt:  number;   // plaintext (for conflict resolution)229  deletedAt?:  number;   // plaintext (for tombstone propagation)230  ciphertext:  string;   // AES-256-GCM encrypted VaultEntry JSON, hex231  nonce:       string;   // 12-byte nonce for this entry, hex232  tag:         string;   // 16-byte GCM auth tag, hex233}234```235236The per-entry key is derived from the vault master key: `HKDF-SHA256(masterKey, salt="peach-sync-entry:" + id)`. This keeps each entry independently encrypted while using the vault key the user already holds.237238---239240## Delta sync algorithm241242### Manifest comparison243244Both devices exchange a manifest: the list of all entry IDs, their `modifiedAt` timestamps, and a content hash (SHA-256 of the decrypted entry JSON, truncated to 16 bytes — only used to detect content drift, not for security).245246```247For each entry in the union of both manifests:248  - Present on A only → A sends to B249  - Present on B only → B sends to A250  - Present on both, same modifiedAt → no transfer needed251  - Present on both, different modifiedAt → transfer the version with the higher modifiedAt (last-write-wins)252  - Present on both, same modifiedAt but different hash → conflict (see below)253```254255### Conflict resolution256257Conflicts (same timestamp, different content) are rare in practice but must be handled:258259**Rule:** When `modifiedAt` is equal but content differs, the entry with the lexicographically larger `id` wins. This is deterministic and produces the same result on both devices without communication.260261**Future improvement:** A vector-clock approach can detect more conflicts but is significantly more complex. Not in scope for v1.262263### Tombstones (deleted entries)264265Deleted entries have `deletedAt` set and `deletedAt > 0`. They persist in the vault as tombstones. The tombstone propagates via sync like any other entry — both devices retain the tombstone so that re-adding the entry from an older backup doesn't resurface it unexpectedly.266267Tombstones are garbage-collected after 90 days from `deletedAt` (on next sync or on vault unlock, whichever comes first).268269---270271## Chrome extension integration272273### Option A: Native Messaging host (recommended for v1)274275A small Rust binary (`peach-sync-host`) is distributed alongside the extension (or as a separate installer). It handles:276- mDNS advertisement and discovery (`mdns-sd` crate)277- TCP socket management (Tokio async runtime)278- Noise NK handshake implementation (`snow` crate — the reference Rust Noise implementation)279- Relaying encrypted sync messages to/from the extension280281The extension communicates with the host via `chrome.runtime.connectNative("com.peachpasswords.sync")`. Messages are JSON-framed. The host never sees plaintext vault data — the extension encrypts entries before passing them to the host.282283**`src/background/sync.ts`** — thin adapter:284```ts285let syncPort: chrome.runtime.Port | null = null;286287export function connectSyncHost(): void {288  syncPort = chrome.runtime.connectNative("com.peachpasswords.sync");289  syncPort.onMessage.addListener(handleHostMessage);290  syncPort.onDisconnect.addListener(() => { syncPort = null; });291}292293export function sendToHost(msg: object): void {294  syncPort?.postMessage(msg);295}296```297298The native host manifest (`com.peachpasswords.sync.json`) is installed to `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/` on macOS, the appropriate path on Windows/Linux.299300### Option B: Tauri companion (v2 — once the desktop app exists)301302When the Peach macOS/Windows desktop app ships (Tauri + SolidJS), it becomes the natural home for the sync daemon. The browser extension connects to the local Tauri app via a localhost WebSocket (`ws://127.0.0.1:4747/peach-sync`). The Tauri app handles all networking. The extension only handles vault encryption/decryption.303304This is the cleaner long-term architecture. Option A bridges the gap before the Tauri app exists.305306---307308## Settings UI309310**Settings → Sync** (Pro-gated — shows upgrade prompt for Free users)311312**Paired devices**  313List of paired devices with: device name, last seen, last synced, "Remove" button.314315**Networks**  316List of trusted networks with SSID display and "Remove" button.  317"Mark current network as trusted" button.  318"Tailscale is always trusted" — informational, read-only.319320**Sync status**  321Last sync timestamp.  322"Sync now" button (initiates a manual sync attempt on trusted networks).  323Status indicator: Synced / Syncing / No peers visible / Not on a trusted network.324325**Pairing**  326"Pair a new device" button (generates code, opens pairing overlay).  327"Pair with another device" button (enter code overlay).328329---330331## Security properties332333| Property | Mechanism |334|---|---|335| Confidentiality | Noise NK session encryption (ChaCha20-Poly1305) + per-entry AES-256-GCM |336| Authentication | Responder authenticated via X25519 static key; initiator authenticated by possession of responder's key |337| Forward secrecy | Ephemeral X25519 DH in Noise NK handshake |338| Integrity | ChaCha20-Poly1305 AEAD; per-entry GCM auth tag |339| Peer authenticity | Ed25519 device identity verified at pairing time; stored pubkey checked at every subsequent session |340| Network dormancy | Zero mDNS/TCP activity on untrusted networks |341| Data minimisation | Sync peer sees only entry IDs, modifiedAt timestamps, and ciphertext |342| Replay prevention | Noise NK nonce counter; sequential, never reused |343344**What Peach Sync does NOT protect against:**345- A compromised device (if a paired device is malicious, it can receive real ciphertext — but not decrypt it without the vault key)346- An attacker on the local network performing active MITM **before** pairing (after pairing, the static key pins the peer)347- Physical access to an unlocked device348349---350351## Tailscale support352353Tailscale assigns devices 100.x.x.x IPs on a virtual network. Peach treats any network with a 100.x.x.x local interface as implicitly trusted — no manual network marking required.354355mDNS on Tailscale: Tailscale's `MagicDNS` resolves `.ts.net` names, but multicast mDNS does not route over the Tailscale interface by default. The native sync host should also scan the `100.x.x.x` subnet for Peach peers via a small TCP probe (not mDNS) when on Tailscale.356357Alternatively: Tailscale's subnet routing allows the host to advertise on `100.64.0.0/10` via its standard TCP port. The other device can connect directly to the Tailscale IP without mDNS.358359---360361## Testing362363### Unit tests364- Noise NK handshake completes and derives matching session keys on both sides365- Manifest diff algorithm produces correct SYNC_REQUEST sets for insert/update/delete/conflict cases366- Conflict resolution is deterministic (same result regardless of which device initiates)367- Tombstone propagation: deleted entry on A propagates to B368369### Integration tests (Playwright)370- Two extension instances pairing and syncing an entry end-to-end (both instances in the same Playwright browser profile, simulated local network via loopback)371- Entry added on instance A appears on instance B after sync372- Entry deleted on instance A is tombstoned on instance B373- Conflict: same entry modified on both sides resolves to last-write-wins correctly374375### Manual test checklist376- [ ] Pairing code expires after 30 seconds377- [ ] No mDNS traffic visible (Wireshark) on an untrusted network378- [ ] mDNS advertisement visible on a trusted network379- [ ] Removing a paired device prevents future sync with that device380- [ ] Sync works over Tailscale between two different machines381382---383384## Implementation order3853861. `src/sync/noise.ts` — Noise NK state machine (can be developed and unit-tested independently)3872. Rust native host (`peach-sync-host`) — mDNS, TCP, Noise NK integration, installer3883. `src/background/sync.ts` — Native Messaging adapter in the extension3894. Pairing UI and flow3905. Manifest diff + sync protocol messages3916. Settings UI3927. Tailscale detection3938. Integration tests394395Estimated effort: **6–8 weeks** for a single engineer familiar with Rust async and Noise protocol. The cryptographic primitives and Noise implementation are the highest-risk components and should be reviewed before shipping.396397---398399## Open questions4004011. **Re-key on password change:** If the user changes their master password, all locally-stored entries are re-encrypted with the new key. Paired devices need their synced ciphertext re-encrypted too — currently undefined. Options: force re-sync of all entries after a password change, or derive the per-entry sync key from a separate sync key (not the master password) that doesn't change.4024032. **Many-device scale:** The current design assumes 2–5 devices. With many paired devices, the initiator needs to sync with each peer serially. Concurrent sessions are possible but add complexity. Not in scope for v1.4044053. **Version skew:** If Device A is running v1.2.x and Device B is running v1.4.x with new entry fields, the manifest and entry format must be forward-compatible. SYNC_HELLO includes the vault schema version; devices should gracefully ignore unknown fields.4064074. **Sync on mobile (Android):** The Android app will integrate Peach Sync via the same native Rust code (`peach-sync-host` compiled for Android via NDK). This is planned but scoped to the Android app spec.