# Peach Sync — Protocol & Implementation Spec **Generated:** 2026-04-20 **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. --- ## What Peach Sync is Peach 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. The website's description is authoritative and must remain accurate throughout implementation: > *"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."* > *"Pair once, and they sync automatically whenever they're on the same WiFi."* > *"If no peer is present, nothing leaves the device."* --- ## Design constraints 1. **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. 2. **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. 3. **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. 4. **Forward secrecy.** Session keys are ephemeral. Compromise of a past session key does not reveal future session traffic. 5. **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. --- ## Architecture overview ``` Device A (initiator) Device B (responder) ──────────────────── ───────────────────── Peach Sync daemon Peach Sync daemon │ │ ├─ mDNS: discover peers ──────────────> │ │ _peach-sync._tcp.local │ │ │ ├─ TCP connect ───────────────────────> │ │ │ ├─ Noise NK handshake ────────────────> │ │ <────────────────────────────────────┤ │ (session key established) │ │ │ ├─ SYNC_HELLO (device ID, clock) ────> │ │ <──────────────── SYNC_HELLO ────────┤ │ │ ├─ SYNC_MANIFEST (entry hashes) ─────> │ │ <──────────────── SYNC_MANIFEST ─────┤ │ │ ├─ SYNC_REQUEST (missing IDs) ────────> │ │ <──────────────── SYNC_ENTRIES ──────┤ │ <──────────────── SYNC_REQUEST ──────┤ ├─ SYNC_ENTRIES ──────────────────────> │ │ │ ├─ SYNC_DONE ─────────────────────────> │ │ <──────────────── SYNC_DONE ─────────┤ │ │ (session ends, TCP connection closed) ``` --- ## Transport security: Noise NK ### Pattern choice Noise NK is used because: - The responder's static key is known to the initiator before the handshake (stored at pairing time) - The initiator needs no static key — this avoids complex initiator identity management - NK provides mutual authentication (responder is authenticated via static key, initiator is authenticated implicitly by knowing the responder's key) - It provides forward secrecy (ephemeral DH) ### Handshake ``` NK: <- s (pre-message: responder static public key) ... -> e, es (initiator sends ephemeral key, performs DH with responder static) <- e, ee (responder sends ephemeral key, performs DH) ``` After 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. ### Cryptographic primitives | Role | Algorithm | |---|---| | DH | X25519 | | Cipher | ChaCha20-Poly1305 | | Hash | BLAKE2s | | Static key type | Ed25519 | Note: 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. ### Implementation **Use `@noble/ciphers` and `@noble/curves`** — already security-audited, no native dependencies, works in WebCrypto environments and Node. ```ts import { x25519 } from "@noble/curves/ed25519"; import { chacha20poly1305 } from "@noble/ciphers/chacha"; import { blake2s } from "@noble/hashes/blake2s"; ``` The 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. --- ## Device discovery: mDNS ### Service type `_peach-sync._tcp.local` ### Advertisement payload Each Peach instance on a trusted network advertises: ``` Service name: peach-._peach-sync._tcp.local Port: TCP port (randomly assigned per session, 49152–65535) TXT records: v=1 (protocol version) pk= (X25519 static public key for Noise NK) did= (device identity key — for pairing lookup) name= ``` ### Discovery implementation **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. **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. Both approaches are specced; the Native Messaging approach is recommended for the first shipping version. ### Trusted network check Before advertising or scanning, Peach checks whether the current network is trusted: ```ts interface TrustedNetwork { ssid?: string; // WiFi SSID (from chrome.sockets or native host) bssid?: string; // Optional: more specific isTailscale: boolean; addedAt: number; } ``` Trusted networks are stored as `peach_trusted_networks: TrustedNetwork[]` in `chrome.storage.local`. To detect Tailscale: check if a 100.x.x.x IP is assigned to a network interface (via native host or `chrome.sockets`). If the current network is not trusted and not Tailscale: **zero network activity.** No UDP, no TCP, no polling. --- ## Pairing flow Pairing is a one-time operation that exchanges device identity keys and creates a mutual trust record. ### UX flow **Device A (initiating pairing):** 1. Settings → Sync → "Pair a new device" 2. A 6-digit numeric pairing code is displayed (generated CSPRNG, 30-second TTL) 3. Device A opens a temporary TCP listener on a random port and advertises via mDNS with `pair=true` TXT record **Device B (accepting pairing):** 1. Settings → Sync → "Pair with another device" 2. Enter the 6-digit code from Device A 3. Device B discovers Device A via mDNS (or user enters Device A's IP manually if mDNS fails) 4. Device B connects, completes a modified Noise NK handshake using the responder's advertised X25519 key 5. Device B sends `PAIR_REQUEST` message containing: Ed25519 pubkey, device name, the 6-digit code 6. Device A verifies the code matches, if correct: stores Device B's pubkey + X25519 key as a trusted peer 7. Device A sends `PAIR_ACCEPT` containing its own Ed25519 pubkey + device name 8. Both devices now have each other's keys stored as `peach_paired_devices` ### Pairing record ```ts interface PairedDevice { deviceId: string; // hex of Ed25519 pubkey displayName: string; x25519PublicKey: string; // hex, for Noise NK pairedAt: number; lastSeenAt?: number; lastSyncAt?: number; } ``` Stored as `peach_paired_devices: PairedDevice[]` in `chrome.storage.local`. --- ## Sync protocol messages All messages are serialized as MessagePack (or JSON if MessagePack adds complexity) and encrypted via ChaCha20-Poly1305 within the Noise NK session. ### Message types ```ts type SyncMessage = | { type: "SYNC_HELLO"; deviceId: string; clockMs: number; vaultVersion: number } | { type: "SYNC_MANIFEST"; entries: Array<{ id: string; modifiedAt: number; deletedAt?: number; hash: string }> } | { type: "SYNC_REQUEST"; ids: string[] } | { type: "SYNC_ENTRIES"; entries: EncryptedSyncEntry[] } | { type: "SYNC_DONE" } | { type: "SYNC_ERROR"; code: string; message: string } | { type: "PAIR_REQUEST"; edPublicKey: string; name: string; code: string } | { type: "PAIR_ACCEPT"; edPublicKey: string; name: string } | { type: "PAIR_REJECT"; reason: string } ``` ### EncryptedSyncEntry Each 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. ```ts interface EncryptedSyncEntry { id: string; // UUID, plaintext (for indexing) modifiedAt: number; // plaintext (for conflict resolution) deletedAt?: number; // plaintext (for tombstone propagation) ciphertext: string; // AES-256-GCM encrypted VaultEntry JSON, hex nonce: string; // 12-byte nonce for this entry, hex tag: string; // 16-byte GCM auth tag, hex } ``` The 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. --- ## Delta sync algorithm ### Manifest comparison Both 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). ``` For each entry in the union of both manifests: - Present on A only → A sends to B - Present on B only → B sends to A - Present on both, same modifiedAt → no transfer needed - Present on both, different modifiedAt → transfer the version with the higher modifiedAt (last-write-wins) - Present on both, same modifiedAt but different hash → conflict (see below) ``` ### Conflict resolution Conflicts (same timestamp, different content) are rare in practice but must be handled: **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. **Future improvement:** A vector-clock approach can detect more conflicts but is significantly more complex. Not in scope for v1. ### Tombstones (deleted entries) Deleted 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. Tombstones are garbage-collected after 90 days from `deletedAt` (on next sync or on vault unlock, whichever comes first). --- ## Chrome extension integration ### Option A: Native Messaging host (recommended for v1) A small Rust binary (`peach-sync-host`) is distributed alongside the extension (or as a separate installer). It handles: - mDNS advertisement and discovery (`mdns-sd` crate) - TCP socket management (Tokio async runtime) - Noise NK handshake implementation (`snow` crate — the reference Rust Noise implementation) - Relaying encrypted sync messages to/from the extension The 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. **`src/background/sync.ts`** — thin adapter: ```ts let syncPort: chrome.runtime.Port | null = null; export function connectSyncHost(): void { syncPort = chrome.runtime.connectNative("com.peachpasswords.sync"); syncPort.onMessage.addListener(handleHostMessage); syncPort.onDisconnect.addListener(() => { syncPort = null; }); } export function sendToHost(msg: object): void { syncPort?.postMessage(msg); } ``` The native host manifest (`com.peachpasswords.sync.json`) is installed to `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/` on macOS, the appropriate path on Windows/Linux. ### Option B: Tauri companion (v2 — once the desktop app exists) When 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. This is the cleaner long-term architecture. Option A bridges the gap before the Tauri app exists. --- ## Settings UI **Settings → Sync** (Pro-gated — shows upgrade prompt for Free users) **Paired devices** List of paired devices with: device name, last seen, last synced, "Remove" button. **Networks** List of trusted networks with SSID display and "Remove" button. "Mark current network as trusted" button. "Tailscale is always trusted" — informational, read-only. **Sync status** Last sync timestamp. "Sync now" button (initiates a manual sync attempt on trusted networks). Status indicator: Synced / Syncing / No peers visible / Not on a trusted network. **Pairing** "Pair a new device" button (generates code, opens pairing overlay). "Pair with another device" button (enter code overlay). --- ## Security properties | Property | Mechanism | |---|---| | Confidentiality | Noise NK session encryption (ChaCha20-Poly1305) + per-entry AES-256-GCM | | Authentication | Responder authenticated via X25519 static key; initiator authenticated by possession of responder's key | | Forward secrecy | Ephemeral X25519 DH in Noise NK handshake | | Integrity | ChaCha20-Poly1305 AEAD; per-entry GCM auth tag | | Peer authenticity | Ed25519 device identity verified at pairing time; stored pubkey checked at every subsequent session | | Network dormancy | Zero mDNS/TCP activity on untrusted networks | | Data minimisation | Sync peer sees only entry IDs, modifiedAt timestamps, and ciphertext | | Replay prevention | Noise NK nonce counter; sequential, never reused | **What Peach Sync does NOT protect against:** - A compromised device (if a paired device is malicious, it can receive real ciphertext — but not decrypt it without the vault key) - An attacker on the local network performing active MITM **before** pairing (after pairing, the static key pins the peer) - Physical access to an unlocked device --- ## Tailscale support Tailscale 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. mDNS 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. Alternatively: 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. --- ## Testing ### Unit tests - Noise NK handshake completes and derives matching session keys on both sides - Manifest diff algorithm produces correct SYNC_REQUEST sets for insert/update/delete/conflict cases - Conflict resolution is deterministic (same result regardless of which device initiates) - Tombstone propagation: deleted entry on A propagates to B ### Integration tests (Playwright) - Two extension instances pairing and syncing an entry end-to-end (both instances in the same Playwright browser profile, simulated local network via loopback) - Entry added on instance A appears on instance B after sync - Entry deleted on instance A is tombstoned on instance B - Conflict: same entry modified on both sides resolves to last-write-wins correctly ### Manual test checklist - [ ] Pairing code expires after 30 seconds - [ ] No mDNS traffic visible (Wireshark) on an untrusted network - [ ] mDNS advertisement visible on a trusted network - [ ] Removing a paired device prevents future sync with that device - [ ] Sync works over Tailscale between two different machines --- ## Implementation order 1. `src/sync/noise.ts` — Noise NK state machine (can be developed and unit-tested independently) 2. Rust native host (`peach-sync-host`) — mDNS, TCP, Noise NK integration, installer 3. `src/background/sync.ts` — Native Messaging adapter in the extension 4. Pairing UI and flow 5. Manifest diff + sync protocol messages 6. Settings UI 7. Tailscale detection 8. Integration tests Estimated 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. --- ## Open questions 1. **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. 2. **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. 3. **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. 4. **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.