SYNC_STATUS.md
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:
- Some user gesture must call
runSyncSession. Nothing does. - First-time pairing must succeed without prior trust. It cannot — the responder hard-rejects un-trusted initiators.
- Android must run a Noise NK initiator over WebSocket. There is no JS Noise client.
- 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<config_dir>/peach-sync-identity.keymode 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 recordspk/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 overtokio_tungstenitewith one Binary message per logical frame, shares the connection map / channels with TCP viaSharedConnectionState. Loopback test against a rawtokio_tungsteniteclient 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 (chooseConflictWinnerlex-compares hashes; both peers reach the same physical-hash winner; loser archives viabuildConflictedCopy). Tested.src/shared/sync-entry-payload.ts— AES-256-GCM envelope keyed by the derived session key, stripspasskey.privateKeyJwkand legacyprivateKeyJwkbefore encryption, validates id / modifiedAt / deletedAt binding on decrypt. Tested.src/shared/sync-wire.ts— base64url JSON-over-Noise wire codec forSyncMessage. 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 fromsync.tstoSyncSessionTransport, scoped to a connection id. Tested.
src/shared/sync-bridge.ts—getSyncBridge()returns aCapacitorSyncBridge(NsdManager via Sync plugin) on Capacitor,NativeMessagingSyncBridge(sync.ts) on extension. Discovery only. HelperpeerWebSocketUrl(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) — writescom.peachpasswords.sync.jsonto the per-OS NativeMessagingHosts directory; Windows prints registry hint. Tested for all platform/browser combos via--show-dirJSON output. nativeMessagingpermission live in bothmanifest.jsonandmanifest.firefox.jsonsince 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 CapacitorpeerDiscovered/peerLostlisteners. MethodsstartDiscovery/stopDiscovery/getDiscoveredPeers.- Plugin registered in
MainActivity.kt. INTERNET,ACCESS_WIFI_STATE,CHANGE_WIFI_MULTICAST_STATE,CHANGE_NETWORK_STATEpermissions present inAndroidManifest.xml../gradlew assembleDebugsucceeds.npx cap sync androidclean.
Verification matrix all green
cargo test— 29/29npm run typecheck— cleannpm test— 301/301npm run build:chrome/:firefox/:mobile— clean (only the pre-existing dynamic-import warning onImport.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:
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:
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:
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 insiderunSyncSessionand 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-efforttry/catchsilently 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_DEVICElocally and replies withPAIR_ACCEPT { ed_public_key, name }. On Reject, replies withPAIR_REJECT { reason }and closes. - Initiator on PAIR_ACCEPT calls
TRUST_DEVICElocally 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 →
runSyncSessionper peer. - Periodic alarm → for each paired device whose last-seen broadcast was recent → run a session.
- mDNS PEER_DISCOVERED whose
didis inpeach_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:
- Subscribe via
sync.onIncomingConnection. - Read the first frame on that connection (PAIR_REQUEST).
- Call
setIncomingPairingRequest({ deviceName, publicKeyHex, ... }). - Broadcast
INCOMING_PAIRING_REQUESTto the popup. - On user Accept, send
PAIR_ACCEPTover the connection and callTRUST_DEVICE. On Reject, sendPAIR_REJECTanddisconnect.
M5. INITIATE_PAIR_WITH_PEER re-implementation
Currently it locally records the peer as "paired" before the handshake finishes. After M2 it should:
- Open a pairing-mode connection.
- Send
PAIR_REQUEST { our_public_key_hex, our_device_name, code }. - Wait for
PAIR_ACCEPT(orPAIR_REJECT/ timeout). - On accept, call native
TRUST_DEVICEfor the peer's pubkey. - Then add to
peach_paired_devices. - 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
nativeMessagingand 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:
- M6 + M3 + B5 first — make
TRIGGER_SYNC(or popup unlock) calladvertise()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 realrunSyncSessionruns against real native infrastructure. - 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.
- M1 — JS Noise NK. After this, an Android emulator can join.
- M7, M8 — small cleanups.
- 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
runSyncSessionetc. 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.