peach_sync_spec.md
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
-
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.
-
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.
-
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.
-
Forward secrecy. Session keys are ephemeral. Compromise of a past session key does not reveal future session traffic.
-
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.
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-<base58(pubkey[0..4])>._peach-sync._tcp.local
Port: TCP port (randomly assigned per session, 49152–65535)
TXT records:
v=1 (protocol version)
pk=<base64(x25519_pubkey)> (X25519 static public key for Noise NK)
did=<base64(ed25519_pubkey)> (device identity key — for pairing lookup)
name=<device display 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:
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):
- Settings → Sync → "Pair a new device"
- A 6-digit numeric pairing code is displayed (generated CSPRNG, 30-second TTL)
- Device A opens a temporary TCP listener on a random port and advertises via mDNS with
pair=trueTXT record
Device B (accepting pairing):
- Settings → Sync → "Pair with another device"
- Enter the 6-digit code from Device A
- Device B discovers Device A via mDNS (or user enters Device A's IP manually if mDNS fails)
- Device B connects, completes a modified Noise NK handshake using the responder's advertised X25519 key
- Device B sends
PAIR_REQUESTmessage containing: Ed25519 pubkey, device name, the 6-digit code - Device A verifies the code matches, if correct: stores Device B's pubkey + X25519 key as a trusted peer
- Device A sends
PAIR_ACCEPTcontaining its own Ed25519 pubkey + device name - Both devices now have each other's keys stored as
peach_paired_devices
Pairing record
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
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.
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-sdcrate) - TCP socket management (Tokio async runtime)
- Noise NK handshake implementation (
snowcrate — 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:
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
src/sync/noise.ts— Noise NK state machine (can be developed and unit-tested independently)- Rust native host (
peach-sync-host) — mDNS, TCP, Noise NK integration, installer src/background/sync.ts— Native Messaging adapter in the extension- Pairing UI and flow
- Manifest diff + sync protocol messages
- Settings UI
- Tailscale detection
- 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
-
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.
-
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.
-
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.
-
Sync on mobile (Android): The Android app will integrate Peach Sync via the same native Rust code (
peach-sync-hostcompiled for Android via NDK). This is planned but scoped to the Android app spec.