1# Peach Sync — Honest Status23Snapshot as of 2026-04-30, HEAD `7d738aa11c96`, package v2.14.9, native v0.1.14.45This document distinguishes what is **implemented and verified**, what is6**implemented but not yet exercised on real hardware** (compiles, tests in7isolation, walking-skeleton ready but not yet pair-tested between two real8devices), and what is **deliberately deferred**.910The previous version of this document (under v2.13.33) marked the system as11"two devices cannot sync today." That is no longer accurate. The blockers it12listed (B1–B5, M1–M8) have all been resolved in the v2.14.x rewrite per13`peach_sync_and_history_spec.md`. The only remaining gate is M9 — the manual14two-device E2E — which the user (June) is running personally on their daily15driver.1617---1819## TL;DR — can two devices sync today?2021**Yes, by the walking skeleton:**22231. ✓ `runBlobSyncSession` is wired into both `TRIGGER_SYNC` (manual) and24 the discovered-peer auto-sync (60s debounced).252. ✓ First-time pairing succeeds — native host gates an `pairing_mode`26 flag that admits otherwise-untrusted peers; `INITIATE_PAIR_WITH_PEER`27 actually sends `PAIR_REQUEST` over the encrypted Noise channel and28 awaits `PAIR_ACCEPT` / `PAIR_REJECT` (30s timeout).293. ✓ Android runs Noise NK in JS via `@stablelib`. Cross-check test pins30 the auth-proof hex against the Rust side byte-for-byte.314. ✓ Auto-advertise lifecycle (`sync-lifecycle.ts`) calls `advertise()`32 when Pro + unlocked + paired devices > 0, stops on lock.3334What's still unverified is **manual two-device E2E** (Phase 10 in the35spec checklist) — that requires a release host build + manifest install36+ two devices on the same network. June will run this manually since37this is their daily driver.3839---4041## What is solid (implemented + verified)4243### Native Rust host (`native/peach-sync-host/`, v0.1.14)4445- **Per-install X25519 identity** (`identity.rs`) — generates on first run,46 persisted at `<config_dir>/peach-sync-identity.key` mode 0o600 on Unix,47 bails on corrupt keys. Tested.48- **Noise NK transport** (`noise.rs`) — snow-based, X25519+ChaChaPoly+BLAKE2s.49 HKDF-SHA256 over the captured handshake hash for derived session keys.50 Tested.51- **mDNS** (`mdns.rs`) — advertises and browses `_peach-sync._tcp.local.`,52 TXT records `pk` / `did` / `name` / `pair` / `wsport`. Tested.53- **TCP transport** (`tcp.rs`) — listener at OS-assigned port, length-prefix54 framing, full Noise handshake, HMAC-SHA256 challenge/proof auth round,55 transport-mode encrypt/decrypt, per-IP rate limiting (1 conn/sec/IP, 60s56 pruning) **shared with WebSocket via `SharedConnectionState`** (M7 fix in57 v2.14.6), explicit `FrameWriter::shutdown()` on disconnect (M8 fix in58 v2.14.6 — TCP `write_half.shutdown()` / WS `Message::Close(None)`).59 Loopback initiator↔responder integration test passes.60- **WebSocket transport** (`websocket.rs`) — same Noise/auth/transport stack61 over `tokio_tungstenite` with one Binary message per logical frame, shares62 the connection map / channels / **rate limiter** with TCP. Loopback test63 against a raw `tokio_tungstenite` client passes.64- **Pairing-mode flag** (`HostCore::pairing_mode: Arc<AtomicBool>`,65 v2.14.2) — toggled by `EnterPairingMode` / `ExitPairingMode` host66 messages. When true, `authenticate_responder_side` returns67 `AuthOutcome { is_pairing_only: true }` for otherwise-untrusted peers68 whose auth proof verifies. The `IncomingConn` event carries69 `is_pairing_only` + `peer_public_key_hex` so the extension can route70 accordingly.71- **Trusted devices store** (`trusted_devices.rs`) — JSON file in config72 dir. Tested.73- **Native messaging framing** — u32-length-prefixed JSON over stdin/stdout,74 1 MiB cap, parse errors do not kill the host.75- **`cargo test`** — 30 passing, including the new76 `print_known_proof_for_js_cross_check` that pins the JS↔Rust auth-proof77 hex to `39efee52ccc9d7eaa24fcfec9a4c75eaad75a6bf3319d9794d9227f2b1466752`.7879### Extension TypeScript8081- **`src/background/sync.ts`** — full client mirror of the host protocol.82 In v2.14.2 added `enterPairingMode` / `exitPairingMode` /83 `getIdentityPublicKey` and reshaped `onIncomingConnection` to deliver84 `IncomingConnectionInfo { connectionId, peerAddr, isPairingOnly,85 peerPublicKeyHex }`.86- **`src/shared/sync-types.ts`** — `SyncMessage` union rewritten to the87 blob protocol: `SYNC_VAULT_SEND` / `SYNC_VAULT_ACK` / `SYNC_ERROR` /88 `PAIR_REQUEST` / `PAIR_ACCEPT` / `PAIR_REJECT`. The old per-entry89 variants are gone. `IncomingPairingRequest` carries the live90 `connectionId` so accept/reject can ride the still-open pairing91 connection.92- **`src/shared/sync-wire.ts`** — base64url JSON-over-Noise codec with93 per-variant validators for the new union. Tested.94- **`src/background/sync-coordinator.ts`** —95 - `applyIncomingVault(vault_a, vault_b, sent_at)` — last-write-wins96 against `peach_last_sync_at`; snapshots the about-to-be-overwritten97 blob into vault history with reason `sync_received` BEFORE writing.98 - `runBlobSyncSession(role, transport, deps)` — bidirectional99 exchange. Initiator sends first; responder applies-if-newer +100 ACK + sends back; initiator applies-if-newer + ACK + done. 30s101 timeout. Tested.102 - `waitForPairingResponse(connectionId, timeoutMs, bindings?)` — used103 by `INITIATE_PAIR_WITH_PEER` to await `PAIR_ACCEPT` / `PAIR_REJECT`104 after sending a `PAIR_REQUEST` over the encrypted channel.105 - `createNativeSyncTransport(connectionId, bindings?)` — adapter to106 `SyncSessionTransport`, scoped to a connection id. Tested.107- **`src/background/sync-lifecycle.ts`** (new in v2.14.3) — owns:108 - `syncLifecycleTick()` — advertise iff Pro + unlocked + paired109 devices > 0; stop otherwise.110 - `setupIncomingPairingSubscriber()` — listens on111 `sync.onIncomingConnection`, reads the first frame on112 pairing-only connections, decodes `PAIR_REQUEST`, populates113 `IncomingPairingRequest` with the live `connectionId`, and pings114 the popup with `INCOMING_PAIRING_REQUEST`.115 - `getDiscoveredPeerCache()` — module-local mDNS peer cache fed by116 `onPeerDiscovered`/`onPeerLost` so `TRIGGER_SYNC` can quickly117 enumerate reachable paired devices.118 - `maybeAutoSync(peer)` — fires on `onPeerDiscovered` for known119 paired peers, with a 60s debounce against `peach_last_sync_at`.120 - `onNativeHostConnected` / `onNativeHostDisconnected` /121 `onVaultUnlocked` / `onVaultLocked` / `onProActivated` lifecycle122 hooks called from `entry.ts` and `alarms.ts`.123- **`src/shared/noise-js.ts`** (new in v2.14.5) — JS Noise NK initiator124 using `@stablelib/x25519` / `chacha20poly1305` / `blake2s` / `hkdf` /125 `sha256` / `hmac`. `computeAuthProof` matches `auth.rs::proof_for`126 byte-for-byte. The `noise-js.test.ts` cross-check pins the JS hex to127 `39efee52…` — divergence from the Rust side fails the test128 immediately.129- **`src/shared/capacitor-sync-transport.ts`** — opens130 `ws://host:wsport/`, runs Noise NK + auth, then exposes a131 `SyncSessionTransport`. WS Binary message ≡ one logical frame; no132 length prefix.133- **`src/shared/android-sync-identity.ts`** — persistent X25519 keypair134 via the Capacitor `SyncKey` plugin. JS handles key generation; the135 Kotlin plugin is a typed encrypted-blob store (still TBD on the136 Kotlin side; sync-bridge.ts will throw cleanly if invoked before that137 ships).138- **`src/shared/sync-bridge.ts`** — `getSyncBridge()` returns139 `CapacitorSyncBridge` (NsdManager via Sync plugin) on Capacitor,140 `NativeMessagingSyncBridge` on extension. Now exposes141 `syncWithPeer(peer)` on the Capacitor side that wires142 `getAndroidSyncIdentity` → `createCapacitorSyncTransport` →143 `runBlobSyncSession`.144- **Settings UX** (Settings.tsx) — Sync section: real `Sync now` button145 with `Syncing… / ✓ Synced N / No devices found / Vault is locked /146 Sync failed / Sync now` feedback states; last-sync time;147 simultaneous-edit hint pointing at Vault History.148- **Vault History UI** (Settings.tsx renderVault) — new VAULT HISTORY149 group at the bottom of Settings → Vault. 10 rows max, each showing150 time / entry count / reason label / Restore button. Restoring locks151 the vault; user re-unlocks with the password that encrypted that152 snapshot.153- **`vitest`** — 281 passing, including 14 vault-history cases and 8154 blob-sync session cases.155156### Vault History (`peach_vault_history`, new in v2.14.0)157158Replaces the old `peach_vault_snapshots` system. Snapshots the159about-to-be-overwritten encrypted blob BEFORE every real-mode vault160write. Cap 10. Reasons: `local_save` / `sync_received` / `import` /161`restore`. Dedup on byte-identical blobs. Restoring locks the vault.162DESTROY_VAULT clears history.163164The Lock-screen self-recovery UI that used the old system (which let a165user attempt a forgotten password against an old snapshot) was removed166— that was a different feature from "undo a recent sync overwrite."167Forgotten-password recovery may be brought back in a future spec.168169### Android170171- **`SyncPlugin.kt`** — NsdManager-based mDNS browser of172 `_peach-sync._tcp.`, WiFi MulticastLock acquired during discovery,173 parses TXT records, emits Capacitor `peerDiscovered` / `peerLost`174 listeners. Tested via `./gradlew assembleDebug`.175- **`SyncKey.kt` Capacitor plugin** — TBD. The TS side176 (`android-sync-identity.ts`) is ready and will throw cleanly if177 invoked before the Kotlin plugin lands. Plugin spec: persistent178 X25519 keypair via encrypted SharedPreferences,179 `loadKeypair`/`saveKeypair` returning hex.180- `INTERNET`, `ACCESS_WIFI_STATE`, `CHANGE_WIFI_MULTICAST_STATE`,181 `CHANGE_NETWORK_STATE` permissions present in `AndroidManifest.xml`.182183### Verification matrix (all green as of v2.14.9)184185- `cargo test` — 30/30186- `npx tsc --noEmit` — clean187- `npm test` — 281/281188- `npm run build:chrome` / `:firefox` — clean (only the pre-existing189 dynamic-import warning on `Import.tsx`)190191---192193## Spec deviations worth knowing194195### Auth proof scheme196197The spec text (Part 8.3) showed an auth proof computed as198`SHA256(DH || "peach-sync-auth/v1" || iPub || rPub || nonce)`. The199existing native `auth.rs::proof_for` already used a different scheme:200`HMAC-SHA256(key=DH, "peach-sync-v1-initiator-auth" || iPub || rPub ||201nonce)`. The JS implementation in `noise-js.ts::computeAuthProof`202matches the native side (source of truth) — both are pinned by the203`print_known_proof_for_js_cross_check` test in `auth.rs` and the204matching test in `noise-js.test.ts`. Change one, the other fails.205206### Vault history replaces lock-screen recovery207208The spec replaced `peach_vault_snapshots` with `peach_vault_history`. A209side effect was removing the lock-screen UI that let a user attempt a210forgotten password against one of the last 6 snapshots. The new system211only helps when you can already unlock — it's "undo a recent sync212overwrite," not "recover from a forgotten password." This is by design213per the spec, but if forgotten-password recovery is wanted back it214needs a separate spec.215216### `unlimitedStorage` not requested21721810 × ~200KB blobs + slot A/B + settings fits comfortably under219`chrome.storage.local`'s 5MB quota. If vaults grow past ~400 entries,220history alone could approach 4MB; consider adding `unlimitedStorage`221then. Not requested in v2.14.x.222223---224225## What still needs hands-on verification226227### M9. Manual two-device E2E228229The only honest verification that the system works end-to-end. Steps:2302311. `cargo build --release --manifest-path232 native/peach-sync-host/Cargo.toml`2332. `npm run native:install -- --browser chrome --extension-id234 <extension-id>` on each device.2353. Pair via Settings → Sync → "Pair a new device" on Device A; accept on236 Device B's pairing banner.2374. Verify `peach_paired_devices` populated on both sides; verify both238 sides' trust stores carry each other's public key.2395. Make a vault change on Device A; tap Sync now → Device B should240 show the change.2416. Make a vault change on Device B; tap Sync now on Device A → should242 pull the change.2437. Auto-sync: change something on A while B is also unlocked and244 advertising → within 60s, B should pick it up via the245 `onPeerDiscovered` auto-sync path.2468. Vault History: open Settings → Vault → VAULT HISTORY; verify247 snapshots from the writes above appear with correct reasons. Tap248 Restore on a recent one; vault locks; unlock with current password249 succeeds.2509. Three devices: same flow with phone (once Kotlin `SyncKey` plugin251 lands) → desktop → laptop. `TRIGGER_SYNC` should walk the reachable252 paired devices sequentially.25310. After E2E passes, run `koh checkpoint "SYN-001 + vault history E2E254 verified"`.255256This is being run by June manually — daily-driver verification.257258### Kotlin `SyncKey` Capacitor plugin259260Required for Android sync. Not included in the v2.14.x TS-side rewrite.261Until it ships, the Capacitor sync bridge will throw cleanly when262`getAndroidSyncIdentity()` is invoked. Desktop↔desktop sync is263unaffected.264265---266267## What changed from the v2.13.33 status268269For posterity / reviewers comparing the old document to this one:270271- **B1 (responder hard-rejects untrusted)** — RESOLVED v2.14.2.272 Pairing-mode flag admits the connection; `AuthOutcome` distinguishes273 trusted from pairing-only.274- **B2 (PAIR_* dispatcher stubs)** — RESOLVED v2.14.2.275 `INITIATE_PAIR_WITH_PEER` sends real `PAIR_REQUEST` over the276 authenticated Noise channel; `RESPOND_INCOMING_PAIRING` sends real277 `PAIR_ACCEPT` / `PAIR_REJECT` over the saved connection.278- **B3 (banner never fires from real traffic)** — RESOLVED v2.14.3.279 `setupIncomingPairingSubscriber` in `sync-lifecycle.ts` reads the280 first frame off pairing-only connections and populates the281 IncomingPairingRequest from real traffic.282- **B4 (extension never advertises)** — RESOLVED v2.14.3. Auto-advertise283 lifecycle ticks on vault unlock / lock / native-host connect /284 Pro change.285- **B5 (TRIGGER_SYNC stub)** — RESOLVED v2.14.4. `TRIGGER_SYNC` now286 enumerates reachable paired devices, runs `runBlobSyncSession`287 sequentially against each, returns `{ syncedCount, totalReachable,288 results[] }`.289- **M1 (JS Noise NK)** — RESOLVED v2.14.5. `noise-js.ts` +290 `capacitor-sync-transport.ts` + `android-sync-identity.ts`. JS↔Rust291 cross-check test pins the proof hex.292- **M2 (pairing protocol)** — RESOLVED v2.14.2 (native +293 extension wiring).294- **M3 (live trigger)** — RESOLVED v2.14.4 (TRIGGER_SYNC + on-discover295 auto-sync).296- **M4 (PEER_CONNECTED → banner)** — RESOLVED v2.14.3.297- **M5 (re-implement INITIATE_PAIR_WITH_PEER)** — RESOLVED v2.14.2.298- **M6 (auto-advertise on Pro+unlocked)** — RESOLVED v2.14.3.299- **M7 (shared rate limiter)** — RESOLVED v2.14.6.300- **M8 (clean shutdown)** — RESOLVED v2.14.6 (`FrameWriter::shutdown`).301- **M9 (manual E2E)** — DEFERRED to user, daily-driver verification302 pending.303304Plus, since the v2.13.33 doc was written, the entire per-entry sync305protocol was retired and replaced with a blob-transfer protocol. At306~200KB for an 800-entry vault local-WiFi transfer takes under 200ms,307which made the whole manifest/diff/conflict-resolver layer308unnecessary. Vault history is the safety net for last-write-wins. See309Projects.md §3.9 + §3.12 for the full architecture.