# Peach v2.29.3 — Audit Report --- ## Section 1 — Sync: Dead Code Inventory ### What exists **Native host binary (`native/peach-sync-host/`):** EXISTS — full Rust crate with Cargo.lock, Cargo.toml, and `src/` containing `lib.rs`, `main.rs`, `mdns.rs`, `message.rs`, `noise.rs`, `tcp.rs`. **`nativeMessaging` permission:** Present in **both** `manifest.json` (line 55) and `manifest.firefox.json` (line 80). The comment in `sync.ts` explicitly says to keep it out of manifests until SYN-001 passes. **The permission is in the manifests prematurely.** **`src/background/sync.ts` (553 lines):** Marked `GATED` at the top — explicitly states it is not imported by any live background path. Contains `connectNative`, the full native host protocol, all message types. **Other sync files:** | File | Lines | Purpose | |---|---|---| | `src/background/sync-coordinator.ts` | 356 | Blob sync session logic — keep | | `src/background/sync-lifecycle.ts` | 385 | Drives native host advertisement + mDNS lifecycle — imports sync.ts | | `src/shared/sync-bridge.ts` | 238 | Android NSD bridge — keep | | `src/shared/noise-js.ts` | 397 | Noise NK JS implementation — keep | | `src/shared/capacitor-sync-transport.ts` | 226 | WebSocket transport for Android — keep | | `src/shared/android-sync-identity.ts` | 90 | X25519 keypair management — keep | | `src/popup/views/SyncPairing.tsx` | 323 | Pairing UI (show + enter modes) — keep | **Installer scripts:** `scripts/generate-native-sync-manifest.mjs`, `scripts/install-native-sync-host.mjs`, `scripts/sync-chrome-dist.mjs` (last one is unrelated to native host; keep it). **Sync message types in `types.ts`:** 14+ sync/pairing message types — all relevant to the WebSocket path; keep them. ### Verdict **Delete cleanly:** - `native/peach-sync-host/` — entire Rust directory - `src/background/sync.ts` — native host client only, nothing here belongs to the WebSocket path - `scripts/generate-native-sync-manifest.mjs` - `scripts/install-native-sync-host.mjs` - Remove `nativeMessaging` from both manifests **Delete with surgery:** - `src/background/sync-lifecycle.ts` — currently imports and drives `sync.ts`; the mDNS advertisement it manages belongs to the native host. Either delete entirely and replace with a lightweight desktop-side sync coordinator, or strip the native host parts and keep only the blob-sync scheduling logic. **Keep intact:** - `noise-js.ts`, `sync-bridge.ts`, `capacitor-sync-transport.ts`, `android-sync-identity.ts`, `sync-coordinator.ts`, `SyncPairing.tsx` **Build from scratch:** - Mayan code (QR) pairing UI — not present at all - `SCAN_PAIRING_QR` message handler - Subnet scan for peer discovery - Desktop-side sync-lifecycle replacement (decoupled from native host) --- ## Section 2 — Kotlin Plugins: What Exists and What's Wired All six plugins are registered in `MainActivity`: `AutofillPlugin`, `BiometricPlugin`, `InMemoryVaultPlugin`, `SyncPlugin`, `SyncKeyPlugin`, `FoldPlugin`. ### AssistStructureParser.kt Helper class (no plugin methods). Traverses autofill `AssistStructure` to extract `usernameId`, `passwordId`, `webDomain`, and `packageName`. Used only by `PeachAutofillService`. ### AutofillPlugin.kt — 5 plugin methods | Method | What it does | |---|---| | `checkPendingSave()` | Reads `peach_pending_save` from SharedPreferences; consumed on read | | `openAutofillSettings()` | Opens system autofill settings screen | | `isEnabledAsAutofillService()` | Returns whether Peach is the active autofill service | | `consumePendingAutofillRequest()` | Reads `autofill_domain`/`autofill_package` from intent; consumed on read | | `completeAutofillAuthentication()` | After unlock, constructs `FillResponse` and returns via `EXTRA_AUTHENTICATION_RESULT` | Fully wired to JS. The `completeAutofillAuthentication()` path is complete — it reconstructs field IDs from intent extras and builds datasets from `VaultBridge.findEntries()`. ### BiometricPlugin.kt — 2 plugin methods | Method | What it does | |---|---| | `authenticate()` | Shows `BiometricPrompt.PromptInfo`, resolves `{ verified: true }` or rejects | | `isAvailable()` | Checks `BIOMETRIC_STRONG` via `BiometricManager` | Correctly implemented on the Kotlin side. Wired to `src/shared/biometric.ts`. Gap is in JS layer (see Section 5). ### FoldPlugin.kt — 2 plugin methods | Method | What it does | |---|---| | `startMonitoring()` | Starts `WindowInfoTracker` collector; emits `postureChanged` events | | `stopMonitoring()` | Cancels coroutine | Used in `App.tsx` via `startFoldMonitoring()`. Fully functional. Sets `data-posture` and `--peach-hinge-height` CSS variable. ### InMemoryVaultPlugin.kt — 2 plugin methods | Method | What it does | |---|---| | `setEntries()` | Deserializes JSON entries into `InMemoryVault` singleton | | `clearEntries()` | Wipes `InMemoryVault` | Wired correctly. `InMemoryVault.kt` is the in-memory singleton — not a plugin itself. ### SyncPlugin.kt — 8 plugin methods | Method | What it does | |---|---| | `startDiscovery()` | NSD `_peach-sync._tcp` discovery with MulticastLock | | `stopDiscovery()` | Stops NSD discovery | | `getDiscoveredPeers()` | Returns resolved peer map | | `resolvePeerByCode()` | UDP broadcast on port 44101 to locate peer by 6-digit token | | `startPairingTcpServer()` | Opens `ServerSocket(0)`, pumps bytes as base64 `pairingChunk` events | | `sendPairingChunk()` | Writes raw bytes to connected pairing socket | | `startPairingBeacon()` | UDP listener on 44101; responds to `PEACH_PAIR_QUERY` with `PEACH_PAIR_RESPONSE` | | `stopPairingServer()` | Closes all sockets, clears state | Both sides of pairing implemented: enter-mode (UDP broadcast → `resolvePeerByCode`) and show-mode (TCP server + UDP beacon). Wired to `sync-bridge.ts`. ### SyncKeyPlugin.kt — 3 plugin methods Stores X25519 keypair in `EncryptedSharedPreferences` (Keystore-backed AES256-GCM). Validates key length (64 hex = 32 bytes) on save. Wired to `android-sync-identity.ts`. ### PeachAutofillService.kt (118 lines) System service, not a Capacitor plugin. Uses `VaultBridge` directly. See Section 6. ### VaultBridge.kt Kotlin singleton. `isUnlocked()` delegates to `InMemoryVault.isAvailable()`. `findEntries()` has tiered domain matching: exact host (100) > base-domain (80) > subdomain (70/60) > package hint (50) > substring (40/25). Used by both `PeachAutofillService` and `AutofillPlugin`. --- ## Section 3 — Mobile CSS: Leakage and Dead Code **42 occurrences** of `peach-mobile` in `popup.css`. All major layout overrides are properly scoped to `body.peach-mobile`. **Potential leakage (not `body.peach-mobile`-scoped):** - `.peach-mobile-root` (line 12) — applied to ``, harmless; only set on native platform - `.peach-mobile-row` (line 7093) — standalone utility class; would render in extension if applied in non-mobile context, but only used in mobile-specific components - `.peach-mobile-status` (line 7100) — same; standalone class - `.peach-onboarding-mobile-card-buttons` (line 6529) — standalone; only used in onboarding flow gated behind native platform checks in JS None produce broken extension behavior because the classes are only applied from JS when `Capacitor.isNativePlatform()` is true. **Panel/swipe navigation remnants:** NONE. No `.peach-panel`, `.peach-panels`, `300vw`, `VaultPanels`, or `activePanel` anywhere. The old 3-panel swipe layout has been completely removed. **Bottom sheet CSS:** Does NOT exist. Line 7284 has a comment about "long-press context menu + swipe-copy flash" — unrelated. No `.bottom-sheet`, `.peach-sheet`, drag, or swipe CSS for sheets. **The bottom sheet must be built from scratch.** **Entry row gesture CSS:** `.peach-entry-row` is standard list styling. The `transform: translateX/Y` values in the file are all fade-in/slide-in animations, not gesture responses. --- ## Section 4 — App.tsx: Mobile Wiring **`body.peach-mobile` on mount:** YES — lines 378–380, guarded by `Capacitor.isNativePlatform()`, applied in `onMount`. Both `document.body` and `document.documentElement` are classed correctly. **Autolock implementation:** The `appStateChange` listener (line 418) does NOT implement autolock. It only handles: (a) checking for a pending autofill save when the app foregrounds, and (b) re-checking autofill request extras. The `autoLockMinutes` signal (line 150) is read from settings (line 898) and passed as a prop to the Lock component (line 1502). There is no `backgroundedAt` timestamp and no setTimeout-based lock timer in `App.tsx`. Autolock either lives inside the Lock component (which receives the prop) or is not implemented. **Vault list on mobile:** `VaultView` at line 1481 handles the main entry view. A separate `view() === "list"` path at line 1480 renders a standalone list. No bottom sheet. On single-pane mobile, the vault opens directly to the entry view (no visible list). Two-pane layout (tablet) shows list + detail side by side via CSS overrides on `body.peach-mobile.layout-medium/layout-expanded`. --- ## Section 5 — Biometrics: Current Wiring **JS module (`src/shared/biometric.ts`):** - `biometricUnlock()` → `Biometric.authenticate()` → returns `result.verified` - `biometricAvailable()` → `Biometric.isAvailable()` **Kotlin side:** Correct. `authenticate()` shows a strong biometric prompt, resolves `{ verified: true }` on success. **Lock.tsx flow:** 1. `BiometricUnlockButton` calls `handleBiometricUnlock(props)` on click 2. `handleBiometricUnlock` calls `biometricUnlock()` — gets `true` on success 3. On success, calls `props.onUnlock()` — which is App.tsx's `handleUnlock` **The gap:** `props.onUnlock` is the *post*-unlock callback — the same one called after a successful password `UNLOCK` message. Biometric success calls this directly, bypassing the UNLOCK message entirely. **The vault is never decrypted.** No master password is retrieved; no derived key is stored or loaded from a biometric-protected keystore. Biometric auth returns `verified: true` and the app proceeds as if unlocked, but `vault.isUnlocked()` in the background is still false. **Result:** Biometric button appears, prompt shows, authentication succeeds, but the vault remains encrypted. The app's subsequent behavior (trying to read entries) will fail silently or show an empty vault. **Fix required:** Store the master password (or a vault-derived key) in an AndroidKeystore-backed `EncryptedSharedPreferences` entry, keyed to biometric auth. On successful `authenticate()`, retrieve that secret and send a real `UNLOCK` message with it. --- ## Section 6 — Autofill: Current State **`PeachAutofillService.kt` (118 lines):** *When locked:* Builds a single auth dataset — "Unlock Peach to autofill" — with a `PendingIntent` that launches `MainActivity`. The `autofill_domain`, `autofill_package`, and field IDs are passed as extras. *When unlocked:* Calls `VaultBridge.findEntries(domain ?? packageName)`. Returns up to 3 match datasets with title + username in the presentation view. Password is filled but not shown in presentation. *`onSaveRequest`:* Stub — calls `callback.onSuccess(null)` immediately. Save is handled via `VaultBridge.offerSave()` called from the autofill service's save path, which writes to SharedPreferences. App.tsx reads this on foreground via `checkPendingSave()`. **Inline suggestions (IME bar):** NOT implemented. Zero references to `InlineSuggestionsRequest`, `InlinePresentationSpec`, or `InlineSuggestion` anywhere in Kotlin. **"Create a new login" dataset:** NOT present. Only existing-match datasets are returned. **TOTP in autofill:** NOT handled. No OTP field detection. **`InMemoryVault` population:** Correctly wired. On vault open, `publishInMemoryVault(loadedEntries)` is called (App.tsx line 940) before `openVault()` completes. On lock, `clearInMemoryVault()` is called (lines 1101, 1608). `VaultBridge.isUnlocked()` checks `InMemoryVault.isAvailable()` — so the autofill service correctly knows vault state. **Multiple datasets:** YES, up to 3 per fill request. --- ## Section 7 — Extension Health Check | Target | Result | |---|---| | Tests | **311 passed**, 31 files, 0 failures | | TypeScript (`--noEmit`) | **Clean** — 0 errors | | `build:chrome` | **Success** (chunk size warning only, not an error) | | `build:firefox` | **Success** (same warning) | | `build:mobile` | **Success** (same warning) | | Android `assembleDebug` | **BUILD SUCCESSFUL** in 4s, 34 tasks executed | The chunk size warning (`>500 kB after minification`) is consistent across all three web builds and is a bundle optimization note, not a correctness issue. --- ## Section 8 — Dead Code and Imports **Files importing `sync.ts` (native client):** - `src/background/sync-lifecycle.ts` — the only live importer (`import * as sync from "./sync"`) - `src/background/sync.test.ts` — test file mocking `connectNative` **Native host references in TypeScript:** - `connectNative` — `sync.ts` lines 269, 273; test mock in `sync.test.ts` - `peach-sync-host` — `sync.ts` header comment, `capacitor-sync-transport.ts` (protocol reference), `noise-js.ts` (auth proof reference), `native-sync-manifest-script.test.ts` - `mDNS`/`mdns` — `sync-lifecycle.ts` (lines 4, 147, 361), `sync-bridge.ts` (comment) - `NsdManager` — `SyncPlugin.kt` (actively used, not dead) **Kotlin dead imports:** None — `NsdManager` and `WifiManager` in `SyncPlugin.kt` are in active use. **`SYN-001`:** Referenced only in `sync.ts` line 6 (the original gate comment). Not used elsewhere. **Safe to delete:** | File/item | Reason | |---|---| | `native/peach-sync-host/` | Entire Rust binary; replaced by WebSocket path | | `src/background/sync.ts` | Native host client; nothing reusable | | `src/background/sync-lifecycle.ts` | Tightly coupled to sync.ts; needs replacement | | `scripts/generate-native-sync-manifest.mjs` | Native host installer | | `scripts/install-native-sync-host.mjs` | Native host installer | | `nativeMessaging` in both manifests | Premature; no installer in place | **References to clean up after deletions:** - `messages.ts` line 1227 — `connectNative` comment reference - `sync-bridge.ts` — `NsdManager` comment reference (the Kotlin plugin it describes stays) - `noise-js.ts` — `native/peach-sync-host/src/auth.rs` comment reference (keep the code, update the comment) --- ## Section 9 — Master Spec vs. What Exists ### Vault List Bottom Sheet - No bottom sheet CSS exists — not even a skeleton - No bottom sheet JS component exists - Mobile vault uses extension-style layout with `body.peach-mobile` overrides - Single-pane mobile goes straight to the entry view; no swipeable list - **Must be built entirely from scratch:** component, CSS, gesture handling, open/close animation ### Biometrics - `authenticate()` completes successfully — Kotlin is correct - After authentication: calls the post-unlock callback without sending `UNLOCK` → vault not decrypted - No derived key stored/retrieved; no biometric-protected keystore entry - **Broken:** biometric succeeds but vault decryption never happens. Fix requires storing the master password (or a vault key) in an AndroidKeystore-backed secret, retrievable only after biometric auth, and using that key to perform the actual UNLOCK. ### Autofill Inline (IME bar) - `InlineSuggestionsRequest` — NOT present in any Kotlin file - Multiple datasets: YES (up to 3 existing matches) - "Create new login" dataset: NO - TOTP: NOT handled ### Sync — Delete / Keep / Build | Category | Items | |---|---| | **Delete** | `native/peach-sync-host/`, `sync.ts`, `sync-lifecycle.ts`, installer scripts, `nativeMessaging` from manifests | | **Keep** | `noise-js.ts`, `sync-bridge.ts`, `capacitor-sync-transport.ts`, `android-sync-identity.ts`, `sync-coordinator.ts`, `SyncPairing.tsx`, all Kotlin sync plugins | | **Build from scratch** | Mayan code scanning UI, `SCAN_PAIRING_QR` message handler, subnet scan for peer discovery (no JS or Kotlin implementation exists), new desktop sync-lifecycle (decoupled from native host) |