# Peach Sync — Honest Status Snapshot as of 2026-04-30, HEAD `7d738aa11c96`, package v2.14.9, native v0.1.14. This document distinguishes what is **implemented and verified**, what is **implemented but not yet exercised on real hardware** (compiles, tests in isolation, walking-skeleton ready but not yet pair-tested between two real devices), and what is **deliberately deferred**. The previous version of this document (under v2.13.33) marked the system as "two devices cannot sync today." That is no longer accurate. The blockers it listed (B1–B5, M1–M8) have all been resolved in the v2.14.x rewrite per `peach_sync_and_history_spec.md`. The only remaining gate is M9 — the manual two-device E2E — which the user (June) is running personally on their daily driver. --- ## TL;DR — can two devices sync today? **Yes, by the walking skeleton:** 1. ✓ `runBlobSyncSession` is wired into both `TRIGGER_SYNC` (manual) and the discovered-peer auto-sync (60s debounced). 2. ✓ First-time pairing succeeds — native host gates an `pairing_mode` flag that admits otherwise-untrusted peers; `INITIATE_PAIR_WITH_PEER` actually sends `PAIR_REQUEST` over the encrypted Noise channel and awaits `PAIR_ACCEPT` / `PAIR_REJECT` (30s timeout). 3. ✓ Android runs Noise NK in JS via `@stablelib`. Cross-check test pins the auth-proof hex against the Rust side byte-for-byte. 4. ✓ Auto-advertise lifecycle (`sync-lifecycle.ts`) calls `advertise()` when Pro + unlocked + paired devices > 0, stops on lock. What's still unverified is **manual two-device E2E** (Phase 10 in the spec checklist) — that requires a release host build + manifest install + two devices on the same network. June will run this manually since this is their daily driver. --- ## What is solid (implemented + verified) ### Native Rust host (`native/peach-sync-host/`, v0.1.14) - **Per-install X25519 identity** (`identity.rs`) — generates on first run, persisted at `/peach-sync-identity.key` mode 0o600 on Unix, bails on corrupt keys. Tested. - **Noise NK transport** (`noise.rs`) — snow-based, X25519+ChaChaPoly+BLAKE2s. HKDF-SHA256 over the captured handshake hash for derived session keys. Tested. - **mDNS** (`mdns.rs`) — advertises and browses `_peach-sync._tcp.local.`, TXT records `pk` / `did` / `name` / `pair` / `wsport`. Tested. - **TCP transport** (`tcp.rs`) — listener at OS-assigned port, length-prefix framing, full Noise handshake, HMAC-SHA256 challenge/proof auth round, transport-mode encrypt/decrypt, per-IP rate limiting (1 conn/sec/IP, 60s pruning) **shared with WebSocket via `SharedConnectionState`** (M7 fix in v2.14.6), explicit `FrameWriter::shutdown()` on disconnect (M8 fix in v2.14.6 — TCP `write_half.shutdown()` / WS `Message::Close(None)`). 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 / **rate limiter** with TCP. Loopback test against a raw `tokio_tungstenite` client passes. - **Pairing-mode flag** (`HostCore::pairing_mode: Arc`, v2.14.2) — toggled by `EnterPairingMode` / `ExitPairingMode` host messages. When true, `authenticate_responder_side` returns `AuthOutcome { is_pairing_only: true }` for otherwise-untrusted peers whose auth proof verifies. The `IncomingConn` event carries `is_pairing_only` + `peer_public_key_hex` so the extension can route accordingly. - **Trusted devices store** (`trusted_devices.rs`) — JSON file in config dir. Tested. - **Native messaging framing** — u32-length-prefixed JSON over stdin/stdout, 1 MiB cap, parse errors do not kill the host. - **`cargo test`** — 30 passing, including the new `print_known_proof_for_js_cross_check` that pins the JS↔Rust auth-proof hex to `39efee52ccc9d7eaa24fcfec9a4c75eaad75a6bf3319d9794d9227f2b1466752`. ### Extension TypeScript - **`src/background/sync.ts`** — full client mirror of the host protocol. In v2.14.2 added `enterPairingMode` / `exitPairingMode` / `getIdentityPublicKey` and reshaped `onIncomingConnection` to deliver `IncomingConnectionInfo { connectionId, peerAddr, isPairingOnly, peerPublicKeyHex }`. - **`src/shared/sync-types.ts`** — `SyncMessage` union rewritten to the blob protocol: `SYNC_VAULT_SEND` / `SYNC_VAULT_ACK` / `SYNC_ERROR` / `PAIR_REQUEST` / `PAIR_ACCEPT` / `PAIR_REJECT`. The old per-entry variants are gone. `IncomingPairingRequest` carries the live `connectionId` so accept/reject can ride the still-open pairing connection. - **`src/shared/sync-wire.ts`** — base64url JSON-over-Noise codec with per-variant validators for the new union. Tested. - **`src/background/sync-coordinator.ts`** — - `applyIncomingVault(vault_a, vault_b, sent_at)` — last-write-wins against `peach_last_sync_at`; snapshots the about-to-be-overwritten blob into vault history with reason `sync_received` BEFORE writing. - `runBlobSyncSession(role, transport, deps)` — bidirectional exchange. Initiator sends first; responder applies-if-newer + ACK + sends back; initiator applies-if-newer + ACK + done. 30s timeout. Tested. - `waitForPairingResponse(connectionId, timeoutMs, bindings?)` — used by `INITIATE_PAIR_WITH_PEER` to await `PAIR_ACCEPT` / `PAIR_REJECT` after sending a `PAIR_REQUEST` over the encrypted channel. - `createNativeSyncTransport(connectionId, bindings?)` — adapter to `SyncSessionTransport`, scoped to a connection id. Tested. - **`src/background/sync-lifecycle.ts`** (new in v2.14.3) — owns: - `syncLifecycleTick()` — advertise iff Pro + unlocked + paired devices > 0; stop otherwise. - `setupIncomingPairingSubscriber()` — listens on `sync.onIncomingConnection`, reads the first frame on pairing-only connections, decodes `PAIR_REQUEST`, populates `IncomingPairingRequest` with the live `connectionId`, and pings the popup with `INCOMING_PAIRING_REQUEST`. - `getDiscoveredPeerCache()` — module-local mDNS peer cache fed by `onPeerDiscovered`/`onPeerLost` so `TRIGGER_SYNC` can quickly enumerate reachable paired devices. - `maybeAutoSync(peer)` — fires on `onPeerDiscovered` for known paired peers, with a 60s debounce against `peach_last_sync_at`. - `onNativeHostConnected` / `onNativeHostDisconnected` / `onVaultUnlocked` / `onVaultLocked` / `onProActivated` lifecycle hooks called from `entry.ts` and `alarms.ts`. - **`src/shared/noise-js.ts`** (new in v2.14.5) — JS Noise NK initiator using `@stablelib/x25519` / `chacha20poly1305` / `blake2s` / `hkdf` / `sha256` / `hmac`. `computeAuthProof` matches `auth.rs::proof_for` byte-for-byte. The `noise-js.test.ts` cross-check pins the JS hex to `39efee52…` — divergence from the Rust side fails the test immediately. - **`src/shared/capacitor-sync-transport.ts`** — opens `ws://host:wsport/`, runs Noise NK + auth, then exposes a `SyncSessionTransport`. WS Binary message ≡ one logical frame; no length prefix. - **`src/shared/android-sync-identity.ts`** — persistent X25519 keypair via the Capacitor `SyncKey` plugin. JS handles key generation; the Kotlin plugin is a typed encrypted-blob store (still TBD on the Kotlin side; sync-bridge.ts will throw cleanly if invoked before that ships). - **`src/shared/sync-bridge.ts`** — `getSyncBridge()` returns `CapacitorSyncBridge` (NsdManager via Sync plugin) on Capacitor, `NativeMessagingSyncBridge` on extension. Now exposes `syncWithPeer(peer)` on the Capacitor side that wires `getAndroidSyncIdentity` → `createCapacitorSyncTransport` → `runBlobSyncSession`. - **Settings UX** (Settings.tsx) — Sync section: real `Sync now` button with `Syncing… / ✓ Synced N / No devices found / Vault is locked / Sync failed / Sync now` feedback states; last-sync time; simultaneous-edit hint pointing at Vault History. - **Vault History UI** (Settings.tsx renderVault) — new VAULT HISTORY group at the bottom of Settings → Vault. 10 rows max, each showing time / entry count / reason label / Restore button. Restoring locks the vault; user re-unlocks with the password that encrypted that snapshot. - **`vitest`** — 281 passing, including 14 vault-history cases and 8 blob-sync session cases. ### Vault History (`peach_vault_history`, new in v2.14.0) Replaces the old `peach_vault_snapshots` system. Snapshots the about-to-be-overwritten encrypted blob BEFORE every real-mode vault write. Cap 10. Reasons: `local_save` / `sync_received` / `import` / `restore`. Dedup on byte-identical blobs. Restoring locks the vault. DESTROY_VAULT clears history. The Lock-screen self-recovery UI that used the old system (which let a user attempt a forgotten password against an old snapshot) was removed — that was a different feature from "undo a recent sync overwrite." Forgotten-password recovery may be brought back in a future spec. ### Android - **`SyncPlugin.kt`** — NsdManager-based mDNS browser of `_peach-sync._tcp.`, WiFi MulticastLock acquired during discovery, parses TXT records, emits Capacitor `peerDiscovered` / `peerLost` listeners. Tested via `./gradlew assembleDebug`. - **`SyncKey.kt` Capacitor plugin** — TBD. The TS side (`android-sync-identity.ts`) is ready and will throw cleanly if invoked before the Kotlin plugin lands. Plugin spec: persistent X25519 keypair via encrypted SharedPreferences, `loadKeypair`/`saveKeypair` returning hex. - `INTERNET`, `ACCESS_WIFI_STATE`, `CHANGE_WIFI_MULTICAST_STATE`, `CHANGE_NETWORK_STATE` permissions present in `AndroidManifest.xml`. ### Verification matrix (all green as of v2.14.9) - `cargo test` — 30/30 - `npx tsc --noEmit` — clean - `npm test` — 281/281 - `npm run build:chrome` / `:firefox` — clean (only the pre-existing dynamic-import warning on `Import.tsx`) --- ## Spec deviations worth knowing ### Auth proof scheme The spec text (Part 8.3) showed an auth proof computed as `SHA256(DH || "peach-sync-auth/v1" || iPub || rPub || nonce)`. The existing native `auth.rs::proof_for` already used a different scheme: `HMAC-SHA256(key=DH, "peach-sync-v1-initiator-auth" || iPub || rPub || nonce)`. The JS implementation in `noise-js.ts::computeAuthProof` matches the native side (source of truth) — both are pinned by the `print_known_proof_for_js_cross_check` test in `auth.rs` and the matching test in `noise-js.test.ts`. Change one, the other fails. ### Vault history replaces lock-screen recovery The spec replaced `peach_vault_snapshots` with `peach_vault_history`. A side effect was removing the lock-screen UI that let a user attempt a forgotten password against one of the last 6 snapshots. The new system only helps when you can already unlock — it's "undo a recent sync overwrite," not "recover from a forgotten password." This is by design per the spec, but if forgotten-password recovery is wanted back it needs a separate spec. ### `unlimitedStorage` not requested 10 × ~200KB blobs + slot A/B + settings fits comfortably under `chrome.storage.local`'s 5MB quota. If vaults grow past ~400 entries, history alone could approach 4MB; consider adding `unlimitedStorage` then. Not requested in v2.14.x. --- ## What still needs hands-on verification ### M9. Manual two-device E2E The only honest verification that the system works end-to-end. Steps: 1. `cargo build --release --manifest-path native/peach-sync-host/Cargo.toml` 2. `npm run native:install -- --browser chrome --extension-id ` on each device. 3. Pair via Settings → Sync → "Pair a new device" on Device A; accept on Device B's pairing banner. 4. Verify `peach_paired_devices` populated on both sides; verify both sides' trust stores carry each other's public key. 5. Make a vault change on Device A; tap Sync now → Device B should show the change. 6. Make a vault change on Device B; tap Sync now on Device A → should pull the change. 7. Auto-sync: change something on A while B is also unlocked and advertising → within 60s, B should pick it up via the `onPeerDiscovered` auto-sync path. 8. Vault History: open Settings → Vault → VAULT HISTORY; verify snapshots from the writes above appear with correct reasons. Tap Restore on a recent one; vault locks; unlock with current password succeeds. 9. Three devices: same flow with phone (once Kotlin `SyncKey` plugin lands) → desktop → laptop. `TRIGGER_SYNC` should walk the reachable paired devices sequentially. 10. After E2E passes, run `koh checkpoint "SYN-001 + vault history E2E verified"`. This is being run by June manually — daily-driver verification. ### Kotlin `SyncKey` Capacitor plugin Required for Android sync. Not included in the v2.14.x TS-side rewrite. Until it ships, the Capacitor sync bridge will throw cleanly when `getAndroidSyncIdentity()` is invoked. Desktop↔desktop sync is unaffected. --- ## What changed from the v2.13.33 status For posterity / reviewers comparing the old document to this one: - **B1 (responder hard-rejects untrusted)** — RESOLVED v2.14.2. Pairing-mode flag admits the connection; `AuthOutcome` distinguishes trusted from pairing-only. - **B2 (PAIR_* dispatcher stubs)** — RESOLVED v2.14.2. `INITIATE_PAIR_WITH_PEER` sends real `PAIR_REQUEST` over the authenticated Noise channel; `RESPOND_INCOMING_PAIRING` sends real `PAIR_ACCEPT` / `PAIR_REJECT` over the saved connection. - **B3 (banner never fires from real traffic)** — RESOLVED v2.14.3. `setupIncomingPairingSubscriber` in `sync-lifecycle.ts` reads the first frame off pairing-only connections and populates the IncomingPairingRequest from real traffic. - **B4 (extension never advertises)** — RESOLVED v2.14.3. Auto-advertise lifecycle ticks on vault unlock / lock / native-host connect / Pro change. - **B5 (TRIGGER_SYNC stub)** — RESOLVED v2.14.4. `TRIGGER_SYNC` now enumerates reachable paired devices, runs `runBlobSyncSession` sequentially against each, returns `{ syncedCount, totalReachable, results[] }`. - **M1 (JS Noise NK)** — RESOLVED v2.14.5. `noise-js.ts` + `capacitor-sync-transport.ts` + `android-sync-identity.ts`. JS↔Rust cross-check test pins the proof hex. - **M2 (pairing protocol)** — RESOLVED v2.14.2 (native + extension wiring). - **M3 (live trigger)** — RESOLVED v2.14.4 (TRIGGER_SYNC + on-discover auto-sync). - **M4 (PEER_CONNECTED → banner)** — RESOLVED v2.14.3. - **M5 (re-implement INITIATE_PAIR_WITH_PEER)** — RESOLVED v2.14.2. - **M6 (auto-advertise on Pro+unlocked)** — RESOLVED v2.14.3. - **M7 (shared rate limiter)** — RESOLVED v2.14.6. - **M8 (clean shutdown)** — RESOLVED v2.14.6 (`FrameWriter::shutdown`). - **M9 (manual E2E)** — DEFERRED to user, daily-driver verification pending. Plus, since the v2.13.33 doc was written, the entire per-entry sync protocol was retired and replaced with a blob-transfer protocol. At ~200KB for an 800-entry vault local-WiFi transfer takes under 200ms, which made the whole manifest/diff/conflict-resolver layer unnecessary. Vault history is the safety net for last-write-wins. See Projects.md §3.9 + §3.12 for the full architecture.