eb87e56fd631
SYNC_STATUS.md — rewrite for v2.14.x: blockers all resolved, only manual E2E remains for daily-driver verification (v2.14.10)
- time
- 2026-04-30 21:24 UTC · 6 months ago
- machine
- koh2
- parent
- 4e0219b3fb32
↓ peach-browser-eb87e56f.tar.gzatlas at this save
files changed4 files
SYNC_STATUS.md+248 −384manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1SYNC_STATUS.md+248 −384
@@ -1,445 +1,309 @@11 # Peach Sync — Honest Status22 3-Snapshot as of 2026-04-30, HEAD `a85d969e6a00`, package v2.13.33, native v0.1.12.3+Snapshot as of 2026-04-30, HEAD `7d738aa11c96`, package v2.14.9, native v0.1.14.44 55 This document distinguishes what is **implemented and verified**, what is6-**implemented but dormant** (compiles, tests in isolation, not reachable from7-any live runtime path), what is **stubbed**, and what is **missing entirely**.6+**implemented but not yet exercised on real hardware** (compiles, tests in7+isolation, walking-skeleton ready but not yet pair-tested between two real8+devices), and what is **deliberately deferred**.89 9-It exists because earlier session summaries called Phases 6–9 "done." That is10-true at the spec-checklist level. It is **not** true at the "two real devices11-can pair and sync" level. The system has multiple dead-ends between the12-parts.10+The 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 it12+listed (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 manual14+two-device E2E — which the user (June) is running personally on their daily15+driver.1316 1417 ---1518 1619 ## TL;DR — can two devices sync today?1720 18-**No.**21+**Yes, by the walking skeleton:**1922 20-The minimum walking skeleton requires four things, none of which currently21-work end-to-end:23+1. ✓ `runBlobSyncSession` is wired into both `TRIGGER_SYNC` (manual) and24+ the discovered-peer auto-sync (60s debounced).25+2. ✓ 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).29+3. ✓ Android runs Noise NK in JS via `@stablelib`. Cross-check test pins30+ the auth-proof hex against the Rust side byte-for-byte.31+4. ✓ Auto-advertise lifecycle (`sync-lifecycle.ts`) calls `advertise()`32+ when Pro + unlocked + paired devices > 0, stops on lock.2233 23-1. Some user gesture must call `runSyncSession`. Nothing does.24-2. First-time pairing must succeed without prior trust. It cannot — the25- responder hard-rejects un-trusted initiators.26-3. Android must run a Noise NK initiator over WebSocket. There is no JS27- Noise client.28-4. The native host must actually be advertising. Nothing in the live29- runtime path calls `advertise()`.34+What's still unverified is **manual two-device E2E** (Phase 10 in the35+spec checklist) — that requires a release host build + manifest install36++ two devices on the same network. June will run this manually since37+this is their daily driver.3038 31-Every individual piece below is real and tested. They just are not connected32-to one another yet.33-3439 ---3540 3641 ## What is solid (implemented + verified)3742 38-### Native Rust host (`native/peach-sync-host/`, v0.1.12)43+### Native Rust host (`native/peach-sync-host/`, v0.1.14)3944 4045 - **Per-install X25519 identity** (`identity.rs`) — generates on first run,4146 persisted at `<config_dir>/peach-sync-identity.key` mode 0o600 on Unix,42- bails on corrupt keys. SYN-001's original `[0u8; 32]` private-key bug is43- gone. Tested.47+ bails on corrupt keys. Tested.4448 - **Noise NK transport** (`noise.rs`) — snow-based, X25519+ChaChaPoly+BLAKE2s.45- HKDF-SHA256 over the captured handshake hash gives both peers an identical46- 32-byte session key per labeled purpose. Tested (deterministic, label47- separation, session separation).49+ HKDF-SHA256 over the captured handshake hash for derived session keys.50+ Tested.4851 - **mDNS** (`mdns.rs`) — advertises and browses `_peach-sync._tcp.local.`,49- TXT records `pk` / `did` / `name` / `pair` / `wsport`. Stop/restart50- lifecycle clears state cleanly. Tested.52+ TXT records `pk` / `did` / `name` / `pair` / `wsport`. Tested.5153 - **TCP transport** (`tcp.rs`) — listener at OS-assigned port, length-prefix52- framing, full Noise handshake, challenge/proof auth round, transport-mode53- encrypt/decrypt, per-IP rate limiting (1 conn/sec/IP, 60s pruning),54- authenticated read loop emitting via broadcast. Loopback initiator↔55- responder integration test passes.54+ 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.5660 - **WebSocket transport** (`websocket.rs`) — same Noise/auth/transport stack5761 over `tokio_tungstenite` with one Binary message per logical frame, shares58- the connection map / channels with TCP via `SharedConnectionState`.59- Loopback test against a raw `tokio_tungstenite` client passes.60-- **Trusted devices store** (`trusted_devices.rs`) — JSON file in config dir.61- Tested.62-- **Native messaging framing** (`lib.rs`) — u32-length-prefixed JSON over63- stdin/stdout, 1 MiB cap, parse errors do not kill the host.64-- **`cargo test`** — 29 passing, including the WS↔TCP shared-state path.62+ 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`.6578 6679 ### Extension TypeScript6780 68-- **`src/background/sync.ts`** — full client mirror of the host protocol:69- GET_IDENTITY, GET_TRUSTED_DEVICES, TRUST_DEVICE, REMOVE_TRUSTED_DEVICE,70- ROTATE_IDENTITY, ADVERTISE / STOP_ADVERTISING, DISCOVER / STOP_DISCOVERY,71- CONNECT, DISCONNECT, SYNC_SEND, GET_SESSION_KEY, STATUS. Length-checked72- base64url session-key decode. Tested.73-- **`src/shared/sync-protocol.ts`** — canonical-JSON SHA-256 manifests,74- diff into request/offer/conflict buckets, deterministic conflict resolver75- (`chooseConflictWinner` lex-compares hashes; both peers reach the same76- physical-hash winner; loser archives via `buildConflictedCopy`). Tested.77-- **`src/shared/sync-entry-payload.ts`** — AES-256-GCM envelope keyed by the78- derived session key, strips `passkey.privateKeyJwk` and legacy79- `privateKeyJwk` before encryption, validates id / modifiedAt / deletedAt80- binding on decrypt. Tested.81-- **`src/shared/sync-wire.ts`** — base64url JSON-over-Noise wire codec for82- `SyncMessage`. Tested.81+- **`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.8394 - **`src/background/sync-coordinator.ts`** —84- - `dispatchSyncFrame(frame, key, deps)` — pure stateless dispatcher,85- handles SYNC_HELLO / SYNC_MANIFEST / SYNC_REQUEST / SYNC_ENTRIES /86- SYNC_DONE / SYNC_ERROR, wires the conflict resolver into the87- MANIFEST path (resolver chooses winner, loser archives). Tested.88- - `runSyncSession({ role, deviceId, vaultVersion, transport, deps })` —89- preflight (Pro + unlocked), per-connection session key, half-close90- protocol (only peer's DONE/ERROR/close ends our side), merge totals91- aggregated. Tested.92- - `createNativeSyncTransport(connectionId, bindings?)` — adapter from93- `sync.ts` to `SyncSessionTransport`, scoped to a connection id.94- Tested.95-- **`src/shared/sync-bridge.ts`** — `getSyncBridge()` returns a95+ - `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()` returns96139 `CapacitorSyncBridge` (NsdManager via Sync plugin) on Capacitor,97- `NativeMessagingSyncBridge` (sync.ts) on extension. Discovery only.98- Helper `peerWebSocketUrl(peer)` builds the URL Capacitor must connect to.99- Tested.100-- **Settings UX** — Sync section with status, last-sync, paired devices101- + Remove, Pair-a-new-device, Pair-with-another-device, trusted networks,102- Reset sync identity in Advanced (clears local devices, best-effort native103- rotate). Discover-and-tap pairing flow listens for SYNC_PEER_DISCOVERED /104- SYNC_PEER_LOST broadcasts.105-- **Incoming pairing banner** — App.tsx renders 60-second countdown banner106- over any view; Accept/Reject calls RESPOND_INCOMING_PAIRING.107-- **Manifest installer** (`scripts/install-native-sync-host.mjs`,108- `npm run native:install`) — writes `com.peachpasswords.sync.json` to the109- per-OS NativeMessagingHosts directory; Windows prints registry hint.110- Tested for all platform/browser combos via `--show-dir` JSON output.111-- **`nativeMessaging`** permission live in both `manifest.json` and112- `manifest.firefox.json` since v2.13.27.113-- **`vitest`** — 301 passing.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.114155 156+### Vault History (`peach_vault_history`, new in v2.14.0)157+158+Replaces the old `peach_vault_snapshots` system. Snapshots the159+about-to-be-overwritten encrypted blob BEFORE every real-mode vault160+write. Cap 10. Reasons: `local_save` / `sync_received` / `import` /161+`restore`. Dedup on byte-identical blobs. Restoring locks the vault.162+DESTROY_VAULT clears history.163+164+The Lock-screen self-recovery UI that used the old system (which let a165+user attempt a forgotten password against an old snapshot) was removed166+— that was a different feature from "undo a recent sync overwrite."167+Forgotten-password recovery may be brought back in a future spec.168+115169 ### Android116170 117-- **`SyncPlugin.kt`** — NsdManager-based mDNS browser of `_peach-sync._tcp.`,118- WiFi MulticastLock acquired during discovery, parses TXT records, emits119- Capacitor `peerDiscovered` / `peerLost` listeners. Methods120- `startDiscovery` / `stopDiscovery` / `getDiscoveredPeers`.121-- Plugin registered in `MainActivity.kt`.171+- **`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.122180 - `INTERNET`, `ACCESS_WIFI_STATE`, `CHANGE_WIFI_MULTICAST_STATE`,123181 `CHANGE_NETWORK_STATE` permissions present in `AndroidManifest.xml`.124-- `./gradlew assembleDebug` succeeds.125-- `npx cap sync android` clean.126182 127-### Verification matrix all green183+### Verification matrix (all green as of v2.14.9)128184 129-- `cargo test` — 29/29130-- `npm run typecheck` — clean131-- `npm test` — 301/301132-- `npm run build:chrome` / `:firefox` / `:mobile` — clean (only the133- pre-existing dynamic-import warning on `Import.tsx`)134-- `npx cap sync android` — clean135-- `./gradlew assembleDebug` — BUILD SUCCESSFUL185+- `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`)136190 137191 ---138192 139-## What is wired but wrong (live but broken)193+## Spec deviations worth knowing140194 141-### B1. `INITIATE_PAIR_WITH_PEER` cannot work for first-time pairing195+### Auth proof scheme142196 143-**Where:** `src/background/messages.ts` `INITIATE_PAIR_WITH_PEER` →144-`sync.connectToPeer(host, port, x25519PublicKey)` →145-`tcp.rs::authenticate_responder_side` line ~503.197+The spec text (Part 8.3) showed an auth proof computed as198+`SHA256(DH || "peach-sync-auth/v1" || iPub || rPub || nonce)`. The199+existing native `auth.rs::proof_for` already used a different scheme:200+`HMAC-SHA256(key=DH, "peach-sync-v1-initiator-auth" || iPub || rPub ||201+nonce)`. The JS implementation in `noise-js.ts::computeAuthProof`202+matches the native side (source of truth) — both are pinned by the203+`print_known_proof_for_js_cross_check` test in `auth.rs` and the204+matching test in `noise-js.test.ts`. Change one, the other fails.146205 147-**What happens:** the responder rejects any initiator whose public key is148-not already in `trusted_devices`:206+### Vault history replaces lock-screen recovery149207 150-```rust151-if !trusted_devices.read().await.is_trusted(&normalized_public_key_hex) {152- anyhow::bail!("initiator is not trusted");153-}154-```208+The spec replaced `peach_vault_snapshots` with `peach_vault_history`. A209+side effect was removing the lock-screen UI that let a user attempt a210+forgotten password against one of the last 6 snapshots. The new system211+only helps when you can already unlock — it's "undo a recent sync212+overwrite," not "recover from a forgotten password." This is by design213+per the spec, but if forgotten-password recovery is wanted back it214+needs a separate spec.155215 156-The discover-and-tap flow then locally adds the peer to `peach_paired_devices`157-on success — but the connect attempt has already failed at the responder.158-The user sees "pair failed" and the local store ends up out of sync with the159-peer's reality. **First-time pairing cannot succeed via this path.**216+### `unlimitedStorage` not requested160217 161-**Why it shipped this way:** I built the pairing UI shell against the162-authenticated transport without first wiring an unauthenticated pairing163-channel. The spec defines `PAIR_REQUEST` / `PAIR_ACCEPT` / `PAIR_REJECT`164-messages exactly for this; they are present in the type union but stubbed165-in the dispatcher.218+10 × ~200KB blobs + slot A/B + settings fits comfortably under219+`chrome.storage.local`'s 5MB quota. If vaults grow past ~400 entries,220+history alone could approach 4MB; consider adding `unlimitedStorage`221+then. Not requested in v2.14.x.166222 167-### B2. The pairing protocol is a no-op stub168-169-**Where:** `src/background/sync-coordinator.ts:343-347`:170-171-```ts172-case "PAIR_REQUEST":173-case "PAIR_ACCEPT":174-case "PAIR_REJECT":175- // Pairing flow is handled outside the data-sync state machine.176- return { outbound: [] };177-```178-179-There is no out-of-band pairing channel either. The comment is a lie —180-nothing handles these "outside the data-sync state machine" because no such181-machine exists.182-183-### B3. The incoming pairing banner never fires from real traffic184-185-**Where:** `src/background/messages.ts` — the only writer to186-`setIncomingPairingRequest` is the `DEBUG_INJECT_INCOMING_PAIRING` test187-hook. `sync.ts::onIncomingConnection` exists but no one subscribes to it.188-The native host emits `PEER_CONNECTED` over native messaging when an189-incoming TCP/WS connection completes the handshake; this event is190-unhandled in the runtime path.191-192-**What's missing:** a subscriber on `onIncomingConnection` that, when the193-connection's pairing flag is set, calls `setIncomingPairingRequest` and194-broadcasts `INCOMING_PAIRING_REQUEST` to the popup.195-196-### B4. The extension never advertises197-198-**Where:** `src/background/sync.ts:303` exports `advertise()`. It is called199-from **nowhere** in the runtime path. Search:200-201-```202-grep -rn "advertise\|Advertise" src/popup/ → no matches203-grep -rn "sync.advertise\|advertise(" src/background/ → only the export204-```205-206-Consequence: even if Phase 10 ran with two extensions on the same network,207-neither would be discoverable by the other. mDNS works only because **both208-sides advertise**. The native host has the code; the extension never asks209-it to start.210-211-### B5. `TRIGGER_SYNC` is a Pro-gate stub212-213-**Where:** `src/background/messages.ts:634-637`:214-215-```ts216-case "TRIGGER_SYNC": {217- const gate = await requirePro("sync");218- if (!gate.allowed) return { ok: false, error: "pro_required" };219- return { ok: true };220-}221-```222-223-The "Sync now" button in Settings calls this. It does nothing. The224-coordinator I built in Phase 5 (`runSyncSession`) is fully formed and tested225-but has no live caller. **No user action currently triggers a sync.**226-227223 ---228224 229-## What is implemented but dormant (compiles + tests, not reachable)225+## What still needs hands-on verification230226 231-These are real and correct; they just have no live caller upstream of them.232-Once the gaps above are fixed they should light up without further changes.227+### M9. Manual two-device E2E233228 234-- `runSyncSession` (`sync-coordinator.ts:368`) — preflight, lifecycle,235- half-close. No caller.236-- `createNativeSyncTransport` (`sync-coordinator.ts:95`) — adapter. No237- caller.238-- `dispatchSyncFrame` (`sync-coordinator.ts:193`) — full SYNC_*239- state machine. Only called from inside `runSyncSession` and tests.240-- `getSyncBridge` / `CapacitorSyncBridge` / `NativeMessagingSyncBridge`241- (`sync-bridge.ts`) — discovery. No caller in popup or background.242-- `advertise` / `stopAdvertising` (`sync.ts`) — native host control. No243- caller.244-- `onIncomingConnection` (`sync.ts:415`) — incoming-pair native event. No245- subscriber.246-- `RESET_SYNC_IDENTITY`'s native-side rotate — best-effort `try/catch`247- silently swallowed because the host is not connected at the moment of248- call.249-- `START_PEER_DISCOVERY` / `STOP_PEER_DISCOVERY` / `INITIATE_PAIR_WITH_PEER`250- — handlers exist; they connect the popup to native discovery. They run,251- but produce nothing because no one advertises (B4) and the trust path is252- broken (B1, B2).229+The only honest verification that the system works end-to-end. Steps:253230 254----231+1. `cargo build --release --manifest-path232+ native/peach-sync-host/Cargo.toml`233+2. `npm run native:install -- --browser chrome --extension-id234+ <extension-id>` on each device.235+3. Pair via Settings → Sync → "Pair a new device" on Device A; accept on236+ Device B's pairing banner.237+4. Verify `peach_paired_devices` populated on both sides; verify both238+ sides' trust stores carry each other's public key.239+5. Make a vault change on Device A; tap Sync now → Device B should240+ show the change.241+6. Make a vault change on Device B; tap Sync now on Device A → should242+ pull the change.243+7. 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.246+8. 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.250+9. Three devices: same flow with phone (once Kotlin `SyncKey` plugin251+ lands) → desktop → laptop. `TRIGGER_SYNC` should walk the reachable252+ paired devices sequentially.253+10. After E2E passes, run `koh checkpoint "SYN-001 + vault history E2E254+ verified"`.255255 256-## What is missing entirely (not even started)256+This is being run by June manually — daily-driver verification.257257 258-### M1. JavaScript Noise NK initiator for Capacitor258+### Kotlin `SyncKey` Capacitor plugin259259 260-**This is the biggest gap.** Android has discovery (`SyncPlugin.kt`) and261-the bridge stub (`sync-bridge.ts`), but no code that:260+Required for Android sync. Not included in the v2.14.x TS-side rewrite.261+Until it ships, the Capacitor sync bridge will throw cleanly when262+`getAndroidSyncIdentity()` is invoked. Desktop↔desktop sync is263+unaffected.262264 263-- opens the WebSocket264-- runs the X25519 + ChaChaPoly + BLAKE2s Noise NK handshake265-- runs the challenge/proof auth round266-- runs the transport-mode encrypt/decrypt loop267-- derives the HKDF session key from the handshake hash268-269-Until this exists, Android can list peers but cannot connect to one. The270-desktop-to-desktop case can sidestep this because the native host (Rust)271-runs Noise on both ends; mobile cannot, because Capacitor's WebView cannot272-load the native messaging host and cannot open raw TCP.273-274-**Rough scope:** a `src/shared/noise-js.ts` using `@stablelib/x25519`,275-`@stablelib/chacha20poly1305`, `blakejs` (or a maintained noise-js276-package), plus a `CapacitorSyncTransport` that satisfies the same277-`SyncSessionTransport` interface as `createNativeSyncTransport`. Probably278-500–800 lines plus tests.279-280-### M2. Pairing protocol implementation281-282-A pairing-mode connection that:283-284-- Both sides put their host into "pairing" advertising state (TXT `pair=1`).285-- Initiator opens an **un-authenticated** Noise NK connection (no286- trust check on responder side; only allowed when responder is in pairing287- mode).288-- Initiator sends `PAIR_REQUEST { ed_public_key, name, code }`.289-- Responder pops the inline approval banner; on Accept, calls290- `TRUST_DEVICE` locally and replies with `PAIR_ACCEPT { ed_public_key,291- name }`. On Reject, replies with `PAIR_REJECT { reason }` and closes.292-- Initiator on PAIR_ACCEPT calls `TRUST_DEVICE` locally and persists the293- PairedDevice record.294-- Both sides exit pairing mode.295-296-The native responder needs a `pairing_mode: bool` flag that, when true,297-allows one un-trusted handshake to succeed for the purpose of the PAIR298-exchange (and rejects all data-sync messages on that connection until299-trust is mutually recorded). This requires coordination between300-`tcp.rs`/`websocket.rs` (skip the trust check), `auth.rs` (still require301-proof — pairing should still bind the keypair, just not require prior302-trust), and the dispatcher in TS.303-304-### M3. Live trigger for sync305-306-After pairing, something needs to actually call `runSyncSession`. Sensible307-triggers:308-309-- "Sync now" button → connect to each paired device → `runSyncSession`310- per peer.311-- Periodic alarm → for each paired device whose last-seen broadcast was312- recent → run a session.313-- mDNS PEER_DISCOVERED whose `did` is in `peach_paired_devices` → run a314- session if vault is unlocked.315-316-None of these exist.317-318-### M4. PEER_CONNECTED → incoming pairing banner subscriber319-320-When the native host accepts an incoming pairing-mode connection, it emits321-`PEER_CONNECTED { connection_id, peer_addr }`. The extension needs to:322-323-1. Subscribe via `sync.onIncomingConnection`.324-2. Read the first frame on that connection (PAIR_REQUEST).325-3. Call `setIncomingPairingRequest({ deviceName, publicKeyHex, ... })`.326-4. Broadcast `INCOMING_PAIRING_REQUEST` to the popup.327-5. On user Accept, send `PAIR_ACCEPT` over the connection and call328- `TRUST_DEVICE`. On Reject, send `PAIR_REJECT` and `disconnect`.329-330-### M5. `INITIATE_PAIR_WITH_PEER` re-implementation331-332-Currently it locally records the peer as "paired" before the handshake333-finishes. After M2 it should:334-335-1. Open a **pairing-mode** connection.336-2. Send `PAIR_REQUEST { our_public_key_hex, our_device_name, code }`.337-3. Wait for `PAIR_ACCEPT` (or `PAIR_REJECT` / timeout).338-4. On accept, call native `TRUST_DEVICE` for the peer's pubkey.339-5. **Then** add to `peach_paired_devices`.340-6. Disconnect the pairing-mode connection. Subsequent sync uses a fresh341- trusted connection.342-343-### M6. Auto-advertise on Pro+unlocked344-345-Some lifecycle should call `advertise()` when:346-347-- vault is unlocked and348-- user is Pro and349-- (current network is trusted or Tailscale)350-351-And `stopAdvertising` on lock or Pro loss. This is what makes the device352-discoverable to its peers in the first place.353-354-### M7. WebSocket-side rate limiting355-356-`tcp.rs::AcceptRateLimiter` is shared with `websocket.rs` (good), but each357-manager has its own instance (bad). A single attacker scanning both ports358-gets 2 attempts per second per IP instead of 1. Low priority but worth a359-note.360-361-### M8. Connection close from `sync.ts`362-363-`disconnectFromPeer(connectionId)` sends `DISCONNECT` to the native host.364-The native side `handle_disconnect` removes the connection from the map365-but **does not actively close the TCP/WS stream** — it just drops the366-write half. The read loop will exit on next failed read, which only367-happens when the peer also closes. For clean shutdown the native side368-should explicitly shutdown the TcpStream / send a Close frame on WS.369-370-### M9. Phase 10 (manual two-device E2E)371-372-Has not been run. This is the only honest verification that the above is373-all wrong-or-right. Until M1–M8 land, this can't be run for mobile, and374-can only be run for desktop↔desktop after M1 (kidding — without M2/M3/M4375-desktop↔desktop also doesn't work past pairing).376-377265 ---378266 379-## What changed under SYN-001 that genuinely improved the codebase267+## What changed from the v2.13.33 status380268 381-Even though the system isn't end-to-end working, these are real wins382-already on `dev`:269+For posterity / reviewers comparing the old document to this one:383270 384-- **The original SYN-001 critical bug is fixed.** The Noise responder385- no longer uses an all-zero static private key; per-install identity is386- generated and persisted. The full audit checklist in the spec was walked387- through in v2.13.26, including the rate limiter (gap discovered during388- that walk).389-- **Native host compiles and runs cleanly**, which it didn't at the start.390-- **The protocol layer is fully realized in pure functions** (sync-protocol,391- sync-entry-payload, sync-wire) with thorough tests. When the live wiring392- is fixed, these don't have to change.393-- **The coordinator's dispatcher and lifecycle are correct** (modulo the394- PAIR_* stub) and tested with synthetic transports — the half-close395- protocol is real.396-- **Conflict resolution is deterministic and symmetric** — both peers agree397- on a physical-hash winner without a round-trip; the loser archives. This398- is one of the harder bits and it's done.399-- **Manifests carry `nativeMessaging`** and the per-OS installer ships, so400- the desktop runtime stack is at least *plumbed* even if not yet401- *triggered*.402-- **Android shell is in place** with discovery and the right permissions —403- M1 lands cleanly when written.271+- **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.404303 405----406-407-## Suggested next-bite order408-409-If picking this up, the cheapest path to a working E2E is roughly:410-411-1. **M6 + M3 + B5 first** — make `TRIGGER_SYNC` (or popup unlock) call412- `advertise()` and a paired-peer sync session. This requires no new413- protocol; it just connects existing parts. Verifies the desktop↔desktop414- data-sync path against an *already-trusted* pair. Lets you see real415- `runSyncSession` runs against real native infrastructure.416-2. **M2 + B1 + B2 + B3 + M4 + M5** — pairing protocol, in one go since417- they share the same surface. After this, two unrelated desktops can418- pair and sync.419-3. **M1** — JS Noise NK. After this, an Android emulator can join.420-4. **M7, M8** — small cleanups.421-5. **M9 / Phase 10** — actually pair and sync two devices end-to-end.422-423-Roughly: 1 is half a day if disciplined; 2 is one to two days; 3 is the424-biggest single chunk, probably two to four days depending on whether a425-maintained noise-js package is acceptable or a hand-rolled implementation426-is required.427-428----429-430-## How this report differs from earlier session notes431-432-CONTINUITY.md has a sequence of `[CODE]` outcome entries that mark433-Phases 3, 5, 6, 7, 8, 9 as DONE. Those are accurate **at the spec434-checklist level** — the items the spec asked for in those phases were435-shipped. They are not accurate at the "system works" level. Specifically:436-437-- Phase 5 ("extension background sync") shipped `runSyncSession` etc.438- but not a caller for it — see B5, M3.439-- Phase 6 ("Android sync") shipped discovery and the sync-bridge shell,440- but not the JS Noise initiator — see M1.441-- Phase 7 ("Settings sync UX") shipped Reset / banner / discover-tap UI,442- but the banner is unreachable from real traffic and the tap path443- cannot succeed for first-time pairing — see B1, B2, B3.444-445-That's an overstatement on my part. This document is the correction.304+Plus, since the v2.13.33 doc was written, the entire per-entry sync305+protocol was retired and replaced with a blob-transfer protocol. At306+~200KB for an 800-entry vault local-WiFi transfer takes under 200ms,307+which made the whole manifest/diff/conflict-resolver layer308+unnecessary. Vault history is the safety net for last-write-wins. See309+Projects.md §3.9 + §3.12 for the full architecture.manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.14.9",4+ "version": "2.14.10",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.14.9",4+ "version": "2.14.10",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "2.14.9",3+ "version": "2.14.10",44 "description": "Zero-knowledge password manager",55 "type": "module",66 "scripts": {