peach_sync_spec.md

19.3 KB · raw

rendered · source

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:

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):

  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

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:

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:


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

Integration tests (Playwright)

Manual test checklist


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.