# Peach Sync — Honest Status Snapshot as of 2026-04-30, HEAD `a85d969e6a00`, package v2.13.33, native v0.1.12. This document distinguishes what is **implemented and verified**, what is **implemented but dormant** (compiles, tests in isolation, not reachable from any live runtime path), what is **stubbed**, and what is **missing entirely**. It exists because earlier session summaries called Phases 6–9 "done." That is true at the spec-checklist level. It is **not** true at the "two real devices can pair and sync" level. The system has multiple dead-ends between the parts. --- ## TL;DR — can two devices sync today? **No.** The minimum walking skeleton requires four things, none of which currently work end-to-end: 1. Some user gesture must call `runSyncSession`. Nothing does. 2. First-time pairing must succeed without prior trust. It cannot — the responder hard-rejects un-trusted initiators. 3. Android must run a Noise NK initiator over WebSocket. There is no JS Noise client. 4. The native host must actually be advertising. Nothing in the live runtime path calls `advertise()`. Every individual piece below is real and tested. They just are not connected to one another yet. --- ## What is solid (implemented + verified) ### Native Rust host (`native/peach-sync-host/`, v0.1.12) - **Per-install X25519 identity** (`identity.rs`) — generates on first run, persisted at `/peach-sync-identity.key` mode 0o600 on Unix, bails on corrupt keys. SYN-001's original `[0u8; 32]` private-key bug is gone. Tested. - **Noise NK transport** (`noise.rs`) — snow-based, X25519+ChaChaPoly+BLAKE2s. HKDF-SHA256 over the captured handshake hash gives both peers an identical 32-byte session key per labeled purpose. Tested (deterministic, label separation, session separation). - **mDNS** (`mdns.rs`) — advertises and browses `_peach-sync._tcp.local.`, TXT records `pk` / `did` / `name` / `pair` / `wsport`. Stop/restart lifecycle clears state cleanly. Tested. - **TCP transport** (`tcp.rs`) — listener at OS-assigned port, length-prefix framing, full Noise handshake, challenge/proof auth round, transport-mode encrypt/decrypt, per-IP rate limiting (1 conn/sec/IP, 60s pruning), authenticated read loop emitting via broadcast. Loopback initiator↔ responder integration test passes. - **WebSocket transport** (`websocket.rs`) — same Noise/auth/transport stack over `tokio_tungstenite` with one Binary message per logical frame, shares the connection map / channels with TCP via `SharedConnectionState`. Loopback test against a raw `tokio_tungstenite` client passes. - **Trusted devices store** (`trusted_devices.rs`) — JSON file in config dir. Tested. - **Native messaging framing** (`lib.rs`) — u32-length-prefixed JSON over stdin/stdout, 1 MiB cap, parse errors do not kill the host. - **`cargo test`** — 29 passing, including the WS↔TCP shared-state path. ### Extension TypeScript - **`src/background/sync.ts`** — full client mirror of the host protocol: GET_IDENTITY, GET_TRUSTED_DEVICES, TRUST_DEVICE, REMOVE_TRUSTED_DEVICE, ROTATE_IDENTITY, ADVERTISE / STOP_ADVERTISING, DISCOVER / STOP_DISCOVERY, CONNECT, DISCONNECT, SYNC_SEND, GET_SESSION_KEY, STATUS. Length-checked base64url session-key decode. Tested. - **`src/shared/sync-protocol.ts`** — canonical-JSON SHA-256 manifests, diff into request/offer/conflict buckets, deterministic conflict resolver (`chooseConflictWinner` lex-compares hashes; both peers reach the same physical-hash winner; loser archives via `buildConflictedCopy`). Tested. - **`src/shared/sync-entry-payload.ts`** — AES-256-GCM envelope keyed by the derived session key, strips `passkey.privateKeyJwk` and legacy `privateKeyJwk` before encryption, validates id / modifiedAt / deletedAt binding on decrypt. Tested. - **`src/shared/sync-wire.ts`** — base64url JSON-over-Noise wire codec for `SyncMessage`. Tested. - **`src/background/sync-coordinator.ts`** — - `dispatchSyncFrame(frame, key, deps)` — pure stateless dispatcher, handles SYNC_HELLO / SYNC_MANIFEST / SYNC_REQUEST / SYNC_ENTRIES / SYNC_DONE / SYNC_ERROR, wires the conflict resolver into the MANIFEST path (resolver chooses winner, loser archives). Tested. - `runSyncSession({ role, deviceId, vaultVersion, transport, deps })` — preflight (Pro + unlocked), per-connection session key, half-close protocol (only peer's DONE/ERROR/close ends our side), merge totals aggregated. Tested. - `createNativeSyncTransport(connectionId, bindings?)` — adapter from `sync.ts` to `SyncSessionTransport`, scoped to a connection id. Tested. - **`src/shared/sync-bridge.ts`** — `getSyncBridge()` returns a `CapacitorSyncBridge` (NsdManager via Sync plugin) on Capacitor, `NativeMessagingSyncBridge` (sync.ts) on extension. Discovery only. Helper `peerWebSocketUrl(peer)` builds the URL Capacitor must connect to. Tested. - **Settings UX** — Sync section with status, last-sync, paired devices + Remove, Pair-a-new-device, Pair-with-another-device, trusted networks, Reset sync identity in Advanced (clears local devices, best-effort native rotate). Discover-and-tap pairing flow listens for SYNC_PEER_DISCOVERED / SYNC_PEER_LOST broadcasts. - **Incoming pairing banner** — App.tsx renders 60-second countdown banner over any view; Accept/Reject calls RESPOND_INCOMING_PAIRING. - **Manifest installer** (`scripts/install-native-sync-host.mjs`, `npm run native:install`) — writes `com.peachpasswords.sync.json` to the per-OS NativeMessagingHosts directory; Windows prints registry hint. Tested for all platform/browser combos via `--show-dir` JSON output. - **`nativeMessaging`** permission live in both `manifest.json` and `manifest.firefox.json` since v2.13.27. - **`vitest`** — 301 passing. ### Android - **`SyncPlugin.kt`** — NsdManager-based mDNS browser of `_peach-sync._tcp.`, WiFi MulticastLock acquired during discovery, parses TXT records, emits Capacitor `peerDiscovered` / `peerLost` listeners. Methods `startDiscovery` / `stopDiscovery` / `getDiscoveredPeers`. - Plugin registered in `MainActivity.kt`. - `INTERNET`, `ACCESS_WIFI_STATE`, `CHANGE_WIFI_MULTICAST_STATE`, `CHANGE_NETWORK_STATE` permissions present in `AndroidManifest.xml`. - `./gradlew assembleDebug` succeeds. - `npx cap sync android` clean. ### Verification matrix all green - `cargo test` — 29/29 - `npm run typecheck` — clean - `npm test` — 301/301 - `npm run build:chrome` / `:firefox` / `:mobile` — clean (only the pre-existing dynamic-import warning on `Import.tsx`) - `npx cap sync android` — clean - `./gradlew assembleDebug` — BUILD SUCCESSFUL --- ## What is wired but wrong (live but broken) ### B1. `INITIATE_PAIR_WITH_PEER` cannot work for first-time pairing **Where:** `src/background/messages.ts` `INITIATE_PAIR_WITH_PEER` → `sync.connectToPeer(host, port, x25519PublicKey)` → `tcp.rs::authenticate_responder_side` line ~503. **What happens:** the responder rejects any initiator whose public key is not already in `trusted_devices`: ```rust if !trusted_devices.read().await.is_trusted(&normalized_public_key_hex) { anyhow::bail!("initiator is not trusted"); } ``` The discover-and-tap flow then locally adds the peer to `peach_paired_devices` on success — but the connect attempt has already failed at the responder. The user sees "pair failed" and the local store ends up out of sync with the peer's reality. **First-time pairing cannot succeed via this path.** **Why it shipped this way:** I built the pairing UI shell against the authenticated transport without first wiring an unauthenticated pairing channel. The spec defines `PAIR_REQUEST` / `PAIR_ACCEPT` / `PAIR_REJECT` messages exactly for this; they are present in the type union but stubbed in the dispatcher. ### B2. The pairing protocol is a no-op stub **Where:** `src/background/sync-coordinator.ts:343-347`: ```ts case "PAIR_REQUEST": case "PAIR_ACCEPT": case "PAIR_REJECT": // Pairing flow is handled outside the data-sync state machine. return { outbound: [] }; ``` There is no out-of-band pairing channel either. The comment is a lie — nothing handles these "outside the data-sync state machine" because no such machine exists. ### B3. The incoming pairing banner never fires from real traffic **Where:** `src/background/messages.ts` — the only writer to `setIncomingPairingRequest` is the `DEBUG_INJECT_INCOMING_PAIRING` test hook. `sync.ts::onIncomingConnection` exists but no one subscribes to it. The native host emits `PEER_CONNECTED` over native messaging when an incoming TCP/WS connection completes the handshake; this event is unhandled in the runtime path. **What's missing:** a subscriber on `onIncomingConnection` that, when the connection's pairing flag is set, calls `setIncomingPairingRequest` and broadcasts `INCOMING_PAIRING_REQUEST` to the popup. ### B4. The extension never advertises **Where:** `src/background/sync.ts:303` exports `advertise()`. It is called from **nowhere** in the runtime path. Search: ``` grep -rn "advertise\|Advertise" src/popup/ → no matches grep -rn "sync.advertise\|advertise(" src/background/ → only the export ``` Consequence: even if Phase 10 ran with two extensions on the same network, neither would be discoverable by the other. mDNS works only because **both sides advertise**. The native host has the code; the extension never asks it to start. ### B5. `TRIGGER_SYNC` is a Pro-gate stub **Where:** `src/background/messages.ts:634-637`: ```ts case "TRIGGER_SYNC": { const gate = await requirePro("sync"); if (!gate.allowed) return { ok: false, error: "pro_required" }; return { ok: true }; } ``` The "Sync now" button in Settings calls this. It does nothing. The coordinator I built in Phase 5 (`runSyncSession`) is fully formed and tested but has no live caller. **No user action currently triggers a sync.** --- ## What is implemented but dormant (compiles + tests, not reachable) These are real and correct; they just have no live caller upstream of them. Once the gaps above are fixed they should light up without further changes. - `runSyncSession` (`sync-coordinator.ts:368`) — preflight, lifecycle, half-close. No caller. - `createNativeSyncTransport` (`sync-coordinator.ts:95`) — adapter. No caller. - `dispatchSyncFrame` (`sync-coordinator.ts:193`) — full SYNC_* state machine. Only called from inside `runSyncSession` and tests. - `getSyncBridge` / `CapacitorSyncBridge` / `NativeMessagingSyncBridge` (`sync-bridge.ts`) — discovery. No caller in popup or background. - `advertise` / `stopAdvertising` (`sync.ts`) — native host control. No caller. - `onIncomingConnection` (`sync.ts:415`) — incoming-pair native event. No subscriber. - `RESET_SYNC_IDENTITY`'s native-side rotate — best-effort `try/catch` silently swallowed because the host is not connected at the moment of call. - `START_PEER_DISCOVERY` / `STOP_PEER_DISCOVERY` / `INITIATE_PAIR_WITH_PEER` — handlers exist; they connect the popup to native discovery. They run, but produce nothing because no one advertises (B4) and the trust path is broken (B1, B2). --- ## What is missing entirely (not even started) ### M1. JavaScript Noise NK initiator for Capacitor **This is the biggest gap.** Android has discovery (`SyncPlugin.kt`) and the bridge stub (`sync-bridge.ts`), but no code that: - opens the WebSocket - runs the X25519 + ChaChaPoly + BLAKE2s Noise NK handshake - runs the challenge/proof auth round - runs the transport-mode encrypt/decrypt loop - derives the HKDF session key from the handshake hash Until this exists, Android can list peers but cannot connect to one. The desktop-to-desktop case can sidestep this because the native host (Rust) runs Noise on both ends; mobile cannot, because Capacitor's WebView cannot load the native messaging host and cannot open raw TCP. **Rough scope:** a `src/shared/noise-js.ts` using `@stablelib/x25519`, `@stablelib/chacha20poly1305`, `blakejs` (or a maintained noise-js package), plus a `CapacitorSyncTransport` that satisfies the same `SyncSessionTransport` interface as `createNativeSyncTransport`. Probably 500–800 lines plus tests. ### M2. Pairing protocol implementation A pairing-mode connection that: - Both sides put their host into "pairing" advertising state (TXT `pair=1`). - Initiator opens an **un-authenticated** Noise NK connection (no trust check on responder side; only allowed when responder is in pairing mode). - Initiator sends `PAIR_REQUEST { ed_public_key, name, code }`. - Responder pops the inline approval banner; on Accept, calls `TRUST_DEVICE` locally and replies with `PAIR_ACCEPT { ed_public_key, name }`. On Reject, replies with `PAIR_REJECT { reason }` and closes. - Initiator on PAIR_ACCEPT calls `TRUST_DEVICE` locally and persists the PairedDevice record. - Both sides exit pairing mode. The native responder needs a `pairing_mode: bool` flag that, when true, allows one un-trusted handshake to succeed for the purpose of the PAIR exchange (and rejects all data-sync messages on that connection until trust is mutually recorded). This requires coordination between `tcp.rs`/`websocket.rs` (skip the trust check), `auth.rs` (still require proof — pairing should still bind the keypair, just not require prior trust), and the dispatcher in TS. ### M3. Live trigger for sync After pairing, something needs to actually call `runSyncSession`. Sensible triggers: - "Sync now" button → connect to each paired device → `runSyncSession` per peer. - Periodic alarm → for each paired device whose last-seen broadcast was recent → run a session. - mDNS PEER_DISCOVERED whose `did` is in `peach_paired_devices` → run a session if vault is unlocked. None of these exist. ### M4. PEER_CONNECTED → incoming pairing banner subscriber When the native host accepts an incoming pairing-mode connection, it emits `PEER_CONNECTED { connection_id, peer_addr }`. The extension needs to: 1. Subscribe via `sync.onIncomingConnection`. 2. Read the first frame on that connection (PAIR_REQUEST). 3. Call `setIncomingPairingRequest({ deviceName, publicKeyHex, ... })`. 4. Broadcast `INCOMING_PAIRING_REQUEST` to the popup. 5. On user Accept, send `PAIR_ACCEPT` over the connection and call `TRUST_DEVICE`. On Reject, send `PAIR_REJECT` and `disconnect`. ### M5. `INITIATE_PAIR_WITH_PEER` re-implementation Currently it locally records the peer as "paired" before the handshake finishes. After M2 it should: 1. Open a **pairing-mode** connection. 2. Send `PAIR_REQUEST { our_public_key_hex, our_device_name, code }`. 3. Wait for `PAIR_ACCEPT` (or `PAIR_REJECT` / timeout). 4. On accept, call native `TRUST_DEVICE` for the peer's pubkey. 5. **Then** add to `peach_paired_devices`. 6. Disconnect the pairing-mode connection. Subsequent sync uses a fresh trusted connection. ### M6. Auto-advertise on Pro+unlocked Some lifecycle should call `advertise()` when: - vault is unlocked and - user is Pro and - (current network is trusted or Tailscale) And `stopAdvertising` on lock or Pro loss. This is what makes the device discoverable to its peers in the first place. ### M7. WebSocket-side rate limiting `tcp.rs::AcceptRateLimiter` is shared with `websocket.rs` (good), but each manager has its own instance (bad). A single attacker scanning both ports gets 2 attempts per second per IP instead of 1. Low priority but worth a note. ### M8. Connection close from `sync.ts` `disconnectFromPeer(connectionId)` sends `DISCONNECT` to the native host. The native side `handle_disconnect` removes the connection from the map but **does not actively close the TCP/WS stream** — it just drops the write half. The read loop will exit on next failed read, which only happens when the peer also closes. For clean shutdown the native side should explicitly shutdown the TcpStream / send a Close frame on WS. ### M9. Phase 10 (manual two-device E2E) Has not been run. This is the only honest verification that the above is all wrong-or-right. Until M1–M8 land, this can't be run for mobile, and can only be run for desktop↔desktop after M1 (kidding — without M2/M3/M4 desktop↔desktop also doesn't work past pairing). --- ## What changed under SYN-001 that genuinely improved the codebase Even though the system isn't end-to-end working, these are real wins already on `dev`: - **The original SYN-001 critical bug is fixed.** The Noise responder no longer uses an all-zero static private key; per-install identity is generated and persisted. The full audit checklist in the spec was walked through in v2.13.26, including the rate limiter (gap discovered during that walk). - **Native host compiles and runs cleanly**, which it didn't at the start. - **The protocol layer is fully realized in pure functions** (sync-protocol, sync-entry-payload, sync-wire) with thorough tests. When the live wiring is fixed, these don't have to change. - **The coordinator's dispatcher and lifecycle are correct** (modulo the PAIR_* stub) and tested with synthetic transports — the half-close protocol is real. - **Conflict resolution is deterministic and symmetric** — both peers agree on a physical-hash winner without a round-trip; the loser archives. This is one of the harder bits and it's done. - **Manifests carry `nativeMessaging`** and the per-OS installer ships, so the desktop runtime stack is at least *plumbed* even if not yet *triggered*. - **Android shell is in place** with discovery and the right permissions — M1 lands cleanly when written. --- ## Suggested next-bite order If picking this up, the cheapest path to a working E2E is roughly: 1. **M6 + M3 + B5 first** — make `TRIGGER_SYNC` (or popup unlock) call `advertise()` and a paired-peer sync session. This requires no new protocol; it just connects existing parts. Verifies the desktop↔desktop data-sync path against an *already-trusted* pair. Lets you see real `runSyncSession` runs against real native infrastructure. 2. **M2 + B1 + B2 + B3 + M4 + M5** — pairing protocol, in one go since they share the same surface. After this, two unrelated desktops can pair and sync. 3. **M1** — JS Noise NK. After this, an Android emulator can join. 4. **M7, M8** — small cleanups. 5. **M9 / Phase 10** — actually pair and sync two devices end-to-end. Roughly: 1 is half a day if disciplined; 2 is one to two days; 3 is the biggest single chunk, probably two to four days depending on whether a maintained noise-js package is acceptable or a hand-rolled implementation is required. --- ## How this report differs from earlier session notes CONTINUITY.md has a sequence of `[CODE]` outcome entries that mark Phases 3, 5, 6, 7, 8, 9 as DONE. Those are accurate **at the spec checklist level** — the items the spec asked for in those phases were shipped. They are not accurate at the "system works" level. Specifically: - Phase 5 ("extension background sync") shipped `runSyncSession` etc. but not a caller for it — see B5, M3. - Phase 6 ("Android sync") shipped discovery and the sync-bridge shell, but not the JS Noise initiator — see M1. - Phase 7 ("Settings sync UX") shipped Reset / banner / discover-tap UI, but the banner is unreachable from real traffic and the tap path cannot succeed for first-time pairing — see B1, B2, B3. That's an overstatement on my part. This document is the correction.