SYNC_STATUS.md
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:
- ✓
runBlobSyncSessionis wired into bothTRIGGER_SYNC(manual) and the discovered-peer auto-sync (60s debounced). - ✓ First-time pairing succeeds — native host gates an
pairing_modeflag that admits otherwise-untrusted peers;INITIATE_PAIR_WITH_PEERactually sendsPAIR_REQUESTover the encrypted Noise channel and awaitsPAIR_ACCEPT/PAIR_REJECT(30s timeout). - ✓ Android runs Noise NK in JS via
@stablelib. Cross-check test pins the auth-proof hex against the Rust side byte-for-byte. - ✓ Auto-advertise lifecycle (
sync-lifecycle.ts) callsadvertise()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<config_dir>/peach-sync-identity.keymode 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 recordspk/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 viaSharedConnectionState(M7 fix in v2.14.6), explicitFrameWriter::shutdown()on disconnect (M8 fix in v2.14.6 — TCPwrite_half.shutdown()/ WSMessage::Close(None)). Loopback initiator↔responder integration test passes. - WebSocket transport (
websocket.rs) — same Noise/auth/transport stack overtokio_tungstenitewith one Binary message per logical frame, shares the connection map / channels / rate limiter with TCP. Loopback test against a rawtokio_tungsteniteclient passes. - Pairing-mode flag (
HostCore::pairing_mode: Arc<AtomicBool>, v2.14.2) — toggled byEnterPairingMode/ExitPairingModehost messages. When true,authenticate_responder_sidereturnsAuthOutcome { is_pairing_only: true }for otherwise-untrusted peers whose auth proof verifies. TheIncomingConnevent carriesis_pairing_only+peer_public_key_hexso 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 newprint_known_proof_for_js_cross_checkthat pins the JS↔Rust auth-proof hex to39efee52ccc9d7eaa24fcfec9a4c75eaad75a6bf3319d9794d9227f2b1466752.
Extension TypeScript
src/background/sync.ts— full client mirror of the host protocol. In v2.14.2 addedenterPairingMode/exitPairingMode/getIdentityPublicKeyand reshapedonIncomingConnectionto deliverIncomingConnectionInfo { connectionId, peerAddr, isPairingOnly, peerPublicKeyHex }.src/shared/sync-types.ts—SyncMessageunion 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.IncomingPairingRequestcarries the liveconnectionIdso 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 againstpeach_last_sync_at; snapshots the about-to-be-overwritten blob into vault history with reasonsync_receivedBEFORE 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 byINITIATE_PAIR_WITH_PEERto awaitPAIR_ACCEPT/PAIR_REJECTafter sending aPAIR_REQUESTover the encrypted channel.createNativeSyncTransport(connectionId, bindings?)— adapter toSyncSessionTransport, 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 onsync.onIncomingConnection, reads the first frame on pairing-only connections, decodesPAIR_REQUEST, populatesIncomingPairingRequestwith the liveconnectionId, and pings the popup withINCOMING_PAIRING_REQUEST.getDiscoveredPeerCache()— module-local mDNS peer cache fed byonPeerDiscovered/onPeerLostsoTRIGGER_SYNCcan quickly enumerate reachable paired devices.maybeAutoSync(peer)— fires ononPeerDiscoveredfor known paired peers, with a 60s debounce againstpeach_last_sync_at.onNativeHostConnected/onNativeHostDisconnected/onVaultUnlocked/onVaultLocked/onProActivatedlifecycle hooks called fromentry.tsandalarms.ts.
src/shared/noise-js.ts(new in v2.14.5) — JS Noise NK initiator using@stablelib/x25519/chacha20poly1305/blake2s/hkdf/sha256/hmac.computeAuthProofmatchesauth.rs::proof_forbyte-for-byte. Thenoise-js.test.tscross-check pins the JS hex to39efee52…— divergence from the Rust side fails the test immediately.src/shared/capacitor-sync-transport.ts— opensws://host:wsport/, runs Noise NK + auth, then exposes aSyncSessionTransport. WS Binary message ≡ one logical frame; no length prefix.src/shared/android-sync-identity.ts— persistent X25519 keypair via the CapacitorSyncKeyplugin. 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()returnsCapacitorSyncBridge(NsdManager via Sync plugin) on Capacitor,NativeMessagingSyncBridgeon extension. Now exposessyncWithPeer(peer)on the Capacitor side that wiresgetAndroidSyncIdentity→createCapacitorSyncTransport→runBlobSyncSession.- Settings UX (Settings.tsx) — Sync section: real
Sync nowbutton withSyncing… / ✓ Synced N / No devices found / Vault is locked / Sync failed / Sync nowfeedback 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 CapacitorpeerDiscovered/peerLostlisteners. Tested via./gradlew assembleDebug.SyncKey.ktCapacitor 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/saveKeypairreturning hex.INTERNET,ACCESS_WIFI_STATE,CHANGE_WIFI_MULTICAST_STATE,CHANGE_NETWORK_STATEpermissions present inAndroidManifest.xml.
Verification matrix (all green as of v2.14.9)
cargo test— 30/30npx tsc --noEmit— cleannpm test— 281/281npm run build:chrome/:firefox— clean (only the pre-existing dynamic-import warning onImport.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:
cargo build --release --manifest-path native/peach-sync-host/Cargo.tomlnpm run native:install -- --browser chrome --extension-id <extension-id>on each device.- Pair via Settings → Sync → "Pair a new device" on Device A; accept on Device B's pairing banner.
- Verify
peach_paired_devicespopulated on both sides; verify both sides' trust stores carry each other's public key. - Make a vault change on Device A; tap Sync now → Device B should show the change.
- Make a vault change on Device B; tap Sync now on Device A → should pull the change.
- Auto-sync: change something on A while B is also unlocked and
advertising → within 60s, B should pick it up via the
onPeerDiscoveredauto-sync path. - 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.
- Three devices: same flow with phone (once Kotlin
SyncKeyplugin lands) → desktop → laptop.TRIGGER_SYNCshould walk the reachable paired devices sequentially. - 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;
AuthOutcomedistinguishes trusted from pairing-only. - B2 (PAIR_ dispatcher stubs)* — RESOLVED v2.14.2.
INITIATE_PAIR_WITH_PEERsends realPAIR_REQUESTover the authenticated Noise channel;RESPOND_INCOMING_PAIRINGsends realPAIR_ACCEPT/PAIR_REJECTover the saved connection. - B3 (banner never fires from real traffic) — RESOLVED v2.14.3.
setupIncomingPairingSubscriberinsync-lifecycle.tsreads 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_SYNCnow enumerates reachable paired devices, runsrunBlobSyncSessionsequentially 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.