audit2.29.3.md
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 directorysrc/background/sync.ts— native host client only, nothing here belongs to the WebSocket pathscripts/generate-native-sync-manifest.mjsscripts/install-native-sync-host.mjs- Remove
nativeMessagingfrom both manifests
Delete with surgery:
src/background/sync-lifecycle.ts— currently imports and drivessync.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_QRmessage 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<html>, 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()→ returnsresult.verifiedbiometricAvailable()→Biometric.isAvailable()
Kotlin side: Correct. authenticate() shows a strong biometric prompt, resolves { verified: true } on success.
Lock.tsx flow:
BiometricUnlockButtoncallshandleBiometricUnlock(props)on clickhandleBiometricUnlockcallsbiometricUnlock()— getstrueon success- On success, calls
props.onUnlock()— which is App.tsx'shandleUnlock
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 mockingconnectNative
Native host references in TypeScript:
connectNative—sync.tslines 269, 273; test mock insync.test.tspeach-sync-host—sync.tsheader comment,capacitor-sync-transport.ts(protocol reference),noise-js.ts(auth proof reference),native-sync-manifest-script.test.tsmDNS/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.tsline 1227 —connectNativecomment referencesync-bridge.ts—NsdManagercomment reference (the Kotlin plugin it describes stays)noise-js.ts—native/peach-sync-host/src/auth.rscomment 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-mobileoverrides - 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) |