# Security checklist: OWASP MASVS-L1 (Android client)

The Android companion to `asvs-l2.md` (which covers backend + frontend + deployment). This file is
the living answer to "is the mobile app secure?" — a per-control map of the OWASP MASVS Level 1
requirements to the Kotlin source and CI that satisfy them. If a control's status changes, the PR
that changes it updates this file. A security-sensitive Android PR should touch the relevant row
here; if it cannot point at a row, it does not know what it is changing.

| | |
|---|---|
| **Standards pinned** | OWASP MASVS 1.5.0, chapters V2–V7 (the `MSTG-*` control IDs). MASVS 2.0 renamed these one-to-one to `MASVS-*` (V2–V7); the L1 rows below are the same either way. |
| **Level** | **MASVS-L1** (rows marked `x` in the L1 column). L2- and R-only rows are listed per chapter as out of scope. |
| **Last full pass** | 2026-09-18 — the MASVS-L1 verification pass (#2), superseding the 2026-08-26 pass (#1, issue #378), recorded alongside the ASVS pass in `docs/security/asvs-l2-verification-report.md`. Prior pass 2026-08-25 (issue #507: MASVS-L2 resilience re-evaluation — see Documented positions). |
| **Level claimed** | **MASVS-L1 with 1 documented exception**, self-assessed — see the [verification report](asvs-l2-verification-report.md) §6; this is not an independent third-party audit. The exception is STORAGE-5, the login screen's keyboard options. STORAGE-9 (`partial`, issue #1741) and PLATFORM-9 (`satisfied`) are L2 controls carried as a bonus, not a level claim — see P6. |
| **Scope** | The Android client (`android/`): Kotlin + Jetpack Compose, Hilt, OkHttp, Room, DataStore, `androidx.security:crypto`. The server it talks to is covered by `asvs-l2.md`; MSTG-AUTH rows therefore cite the *client-side* contract and point at the backend for enforcement. |

## Status legend

| Status | Meaning |
|---|---|
| **satisfied** | Control is met; the row cites `path#anchor` or a test name. |
| **partial** | Control is met in part, or met with a documented deviation (each `partial` row names the gap). |
| **not-applicable** | Control does not apply (no WebView, no native code, no third-party SDK, …) — one-line reason given. |
| **out-of-scope** | L2/R-only control (not in the L1 target); listed for grep-ability. |

"Answered in the doc" means: take any `MSTG-xxxx-y` from MASVS 1.5.0, grep for it below, and get
a status + citation. No row is left `satisfied` without a citation.

## Documented positions

The `android/.mobsf` config already records a set of deliberate scanner findings as ignore-list
entries with one-line rationales. These are positions, not code changes — they are promoted here
with their full rationale so the audit is self-contained.

### P1 — No certificate pinning (MSTG-NETWORK-4 is L2 anyway; re-evaluated, kept declined, issue #507)

Certificate pinning / transparency (`android_certificate_pinning`, `android_certificate_transparency`,
`android_ssl_pinning`) is deliberately not done. MSTG-NETWORK-4 is an L2 control, so it is out of
scope for L1 — but the ignore-list is explicit rather than silent.

**Defends against:** an on-path attacker (rogue Wi-Fi AP, compromised router, malicious CA) presenting
a valid-but-wrong certificate for the operator's domain. **Costs:** every user runs their own
**self-hosted** server (`docs/deployment.md#"You only need an external reverse proxy for TLS termination"`) with a certificate the app cannot know in advance —
frequently self-signed or issued by an internal/private CA the user set up themselves, rotated on
whatever schedule that user chooses. A pin the app ships with would be wrong for essentially every
install on day one, and a pin computed from the first-seen cert (trust-on-first-use) is a materially
different feature — a pin store, a UI to review/clear it, and a break-glass path for legitimate
rotation — not a checkbox toggle on the naive pinning MSTG-NETWORK-4 describes. **In scope per #377:**
yes, MITM is a real actor (`threat-model.md` "Unauthenticated network attacker" row) — but it's
neutralized today by standard TLS + system CA trust (`asvs-l2.md` V9, `masvs-l1.md` NETWORK-1/NETWORK-3).
That trust anchor is `system` only in a release build — there is no KeyChain-import
(`installCustomCertificate`) or other path to trust a self-signed or private-CA cert there (issue
#961 found and corrected an earlier, false claim that one existed; see the production
`network_security_config.xml` comment). A self-hosted deployment behind such a cert needs a
certificate chaining to a CA the OS already trusts (e.g. a public ACME issuer such as Let's Encrypt
in front of the reverse proxy, `docs/deployment.md#"You only need an external reverse proxy for TLS termination"`) for the Android client to reach it at all —
that gap is tracked by #961, not something this pinning decision can lean on as a mitigation.
**Decision: keep declined.** Naive pinning is actively wrong
for this app's threat model, and TOFU/user-managed pinning is a distinct, uncosted feature this issue
does not adopt — see `threat-model.md` gating decision 3.

### P2 — Debug-only cleartext to the emulator loopback

The debug source set (`app/src/debug/res/xml/network_security_config.xml#<domain-config cleartextTrafficPermitted="true">`) permits cleartext
HTTP to `10.0.2.2` / `127.0.0.1` / `localhost` so `adb`-driven local-backend testing works. This
file **never ships in a release build** — the production config forbids cleartext unconditionally
(`app/src/main/res/xml/network_security_config.xml#<base-config cleartextTrafficPermitted="false">`). Ignored at INFO (`android_manifest_domain_config_cleartext`)
so it stays visible in the source comment rather than adding CI noise.

### P3 — No root/SafetyNet detection, anti-task-hijacking by design (re-evaluated, kept declined, issue #507)

`android_root_detection` and `android_safetynet`/`android_safetynet_api` are ignored.

**Defends against:** the app running on a rooted/compromised device where the OS sandbox itself can no
longer be trusted — a malicious app with root could read another app's memory or storage regardless of
what this app does. **Costs:** self-hosted users are disproportionately likely to root their devices
*deliberately* (custom ROMs, ad blocking, backup tooling, the same self-reliance that led them to
self-host a CRM in the first place); blocking or degrading the app on root would punish exactly the
audience this project serves, for a threat model where the attacker already has the device. SafetyNet
Attestation is additionally a **deprecated** platform service (superseded by Play Integrity, which
itself assumes a Play-Store-distributed app — this project also ships a direct-download APK, see
`android-apk-build.yml`). **In scope per #377:** the "lost/stolen Android device" actor
(`threat-model.md`) is real, but it's answered by data-at-rest controls (SQLCipher Room mirror,
Keystore-backed session token — P4/STORAGE-1) that hold regardless of root status, not by refusing to
run. A rooted-but-not-stolen device run by its own owner is not a threat this app has an asset to
protect against. **Decision: keep declined** — this is the control the issue named as the one to weigh
"honestly" against the audience, and the audience-cost argument holds without qualification. See
`threat-model.md` gating decision 3.

The one task-hijacking-adjacent risk that *is* real is addressed by design, not by a detection
control: the exported `MainActivity` uses `launchMode="singleTask"` specifically so the OIDC deep
link is delivered via `onNewIntent` rather than stacking a second instance
(`app/src/main/AndroidManifest.xml#android:launchMode="singleTask"`, comment at `:66-68`); `android_task_hijacking1/2` are ignored
for that reason, unrelated to the two resilience controls above.

### P4 — Room cache encryption carve-outs (resolved 2026-08-25, issue #385)

The Room mirror (`mycorrhizal-cache.db`) is now SQLCipher-encrypted whole-DB — AES-256-CBC pages
with per-page IVs + HMAC-SHA512 integrity, keyed by a random 32-byte passphrase stored in
`EncryptedSharedPreferences` behind a Keystore `MasterKey` (`RoomPassphraseStore.kt`). A one-time
bootstrap re-encrypts a pre-existing plaintext DB in place via `sqlcipher_export`
(`RoomCacheEncryption.kt`), preserving every object including the FTS4 mirror and the
non-rebuildable `pending_interactions` outbox; the plaintext file is overwritten before deletion.
The DB, its WAL/shm sidecars, and the Coil/photo cache are purged on logout/account-removal
(`LocalDataCleaner.kt`).

**Correction (2026-09-16, found via Codecov coverage triage, no filed issue — see PR #1080):** "the
plaintext file is overwritten before deletion" was not true from this control's resolution
(2026-08-25) until this fix. `RoomCacheEncryption.overwriteFile` read `file.length()` *after*
`FileOutputStream(file)` had already opened (and truncated) it, so `remaining` was always `0` and
the write loop never ran a single iteration — the plaintext file went straight from its real content
to empty with the underlying disk blocks never actually overwritten, exactly the "stolen device,
data recoverable from freed blocks" scenario this control exists to prevent. Fixed by capturing the
length before the truncating stream opens
(`android/core/data/src/main/kotlin/com/mycorrhizal/crm/data/local/RoomCacheEncryption.kt#overwriteFile`);
pinned by three new JVM tests in `RoomCacheEncryptionTest.kt` (`overwriteFile replaces the content
but keeps the file length`, `overwriteFile on an empty file does not throw and leaves it empty`,
`overwriteFile handles content larger than its internal buffer`), hand-verified to fail against the
pre-fix code. STORAGE-1 stays `satisfied` — on a now-true basis, not a status flip, since the row
was never marked `partial` for this — see `asvs-l2-verification-report.md` pass 1.26.

Deliberate test carve-out: the Robolectric JVM unit tests run the migration/DAO logic against the
plain framework SQLite factory because SQLCipher's `libsqlcipher.so` is an Android-native binary
that cannot load on the JVM. The encrypted factory — same migration chain plus the transition and
FTS search under encryption — is verified by instrumented tests
(`app/src/androidTest/.../storage/RoomCacheEncryptionTest.kt`) and implicitly by every E2E boot of
the real app, which opens the encrypted DB through `DataModule`.

### P5 — `.mobsf` false positives (not positions, recorded for the record)

`android_kotlin_hardcoded` fires only on the `const val SECRET = "secret"` sensitivity enum value
(the `normal|private|secret` taxonomy shared with the backend) — a domain value, not a credential.
`android_kotlin_sql_raw_query` fires only on the static Room-migration DDL strings
(`core/data/.../Migrations.kt`, `Migration13To14Test.kt`) — compile-time SQL with no injection
surface.

### P6 — Screenshot prevention (opt-in, reshaped 2026-10-16 by issue #1741) + tapjacking protection (resolved 2026-08-25, issue #507)

The other two of the four MASVS-L2 resilience items issue #507 asked to re-evaluate. Tapjacking
protection stays **reversed** (unconditional). Screenshot prevention was first adopted unconditionally
(#507), then reshaped by issue #1741 into an **opt-in, device-local setting** (default off) once its
cost proved larger than this position first assumed.

**MSTG-STORAGE-9** ("the app removes sensitive data from views when moved to the background"):
every screen in this single-Activity app renders relationship PII (contact records, personal notes,
`secret`-sensitivity fields), so the concrete threat is the recent-apps thumbnail — anyone with brief
physical access to an unlocked-but-idle phone can open the app switcher and read a contact's data
without ever unlocking the app itself, no attacker sophistication required. **Cost of the original
unconditional `FLAG_SECURE`** (#507): it blanked the thumbnail but also blocked screenshots and screen
recording as a side effect, so users could not screenshot their own data, screen sharing for support
failed, Circle to Search and screenshot-based accessibility tools were blocked, and UX-audit evidence
could not be collected (issue #1718; interim debug-build exemption #1740). **Current position (#1741):**

- *Recents thumbnail, API 33+, always:* `MainActivity.onCreate` calls
  `Activity.setRecentsScreenshotEnabled(false)` on API 33+ in every build type regardless of the setting.
  That blanks the thumbnail **without** blocking screenshots.
- *Opt-in `FLAG_SECURE`:* Settings -> App lock carries a switch "Block screenshots and screen
  recording", **default off**, stored device-locally in `LocalAuthSettingsRepository` (never synced).
  `MainActivity` reads the stored value synchronously before the first frame on cold start (so a user who
  turned it on never gets one unprotected frame) and then sets/clears `FLAG_SECURE` live as the setting
  changes (`shouldSetFlagSecure`, `MainActivity.kt`). When on, it blocks screenshots, screen recording,
  and the recents preview on every Android version.
- *Accepted residual risk:* on API 26-32 with the setting off (the default) the recents thumbnail shows
  app content — there is no targeted API on those versions. Mitigations: the opt-in app lock (P7) and the
  opt-in setting itself, whose supporting text tells API < 33 users the preview is only hidden while it
  is on. Sharing a contact card remains a first-class feature via the vCard share sheet
  (`AndroidManifest.xml#androidx.core.content.FileProvider`).

**MSTG-PLATFORM-9** ("the app protects itself against screen overlay attacks"): a
malicious overlay app could tapjack a user into confirming a destructive action (e.g. contact/account
deletion) they can't see underneath the overlay. **Cost:** effectively none — `filterTouchesWhenObscured`
only changes behavior when another window is actually drawn on top, which never happens during normal
single-window use. `MainActivity.onCreate` sets `window.decorView.filterTouchesWhenObscured = true`
(`MainActivity.kt`), unconditionally in every build type, which gates touch dispatch to the whole view
tree from the decor view down — no per-screen wiring needed. The tapjacking ignore-rules
(`android_detect_tapjacking`/`android_tapjacking`) were removed from `.mobsf` in the #507 change, so
mobsfscan asserts their presence. The `android_prevent_screenshot` rule is **ignored again** in `.mobsf`
(issue #1741) with a reason pointing here: the flag is no longer unconditional, so the scan would flag
it by design.

Verified by the instrumented `ScreenResilienceTest`
(`app/src/androidTest/.../security/ScreenResilienceTest.kt`, issue #238's suite): flag absent with the
setting off, present on cold start with it on, flips live when toggled, recents screenshots disabled on
API 33+ in both cases, tapjacking flag set — plus the JVM `FlagSecurePolicyTest` and
`LocalAuthSettingsRepositoryImplTest` (default off, persistence). A Robolectric JVM test's shadow
`Window` doesn't model `FLAG_SECURE` or touch filtering, so the window behavior can only be verified on a
real device/emulator. See `threat-model.md` gating decision 3 for the assurance-level consequence:
this does **not** make MASVS-L2 claimable (root detection and certificate pinning above stay declined,
and several other L2 rows — STORAGE-10/11/13/14/15, the entire V8 Resiliency chapter — remain
out of scope) — the client's claimed target is still MASVS-L1, with L2 controls satisfied or partial as
a documented bonus rather than a level claim.

### P7 — Opt-in local app lock: biometric / device-credential gate before a resumed session (issue #722)

MASVS-L1 has no direct control for an *application-level* unlock (biometrics/PIN before the app shows
data) — the relevant behavior is split between MSTG-STORAGE-1/9 (data at rest is encrypted, and views
are blanked when backgrounded) and the platform's own device-lock, which this project deliberately
does not duplicate with a stored PIN. The Android testing pass that asked "how does Android login work
offline?" exposed the actual gap: a persisted session was resumed into the authenticated tree with no
local check at all, so anyone holding an unlocked device got the whole contact database. This position
records the decision that closes it. Full design + trade-offs: `docs/adrs/0014-local-app-lock-and-biometric-resume.md`.

**What shipped.** An opt-in, **default-off** "require biometric or device PIN to open the app" setting
(`feature/settings/.../AppearancePrivacySettingsScreens.kt#PrivacySettingsContent`, persisted in
`core/data/.../LocalAuthSettingsRepositoryImpl.kt`). When on, a resumed session must pass the device's
own gate: on **cold start** with a persisted JWT, and again after the app has been backgrounded longer
than a configurable grace period (immediately / 1 / 5 / 15 / 60 minutes) — never on a quick app-switch
within the grace period (`core/data/.../DefaultAppLockController.kt#DefaultAppLockController`). The controller awaits `SessionManager.awaitHydrated()` before treating the first observed `SessionState` as authoritative, so the placeholder logged-out emission before hydration cannot resolve the gate (issue #2049; pinned by the cold-start hydration tests in `DefaultAppLockControllerTest`). The gate lives in the app
root's branch decision, so the authenticated tree is never composed before it clears
(`MycorrhizalApp.kt#MycorrhizalApp`, `MycorrhizalApp.kt#rootSurface`; pinned by `RootSurfaceTest`). Cancelling the OS prompt leaves the app
locked; the only ways off are authenticating or "Log out" (which ends the session normally and keeps
the opt-in — an explicit logout does **not** clear the preference). A fresh interactive login (password
/ API token / 2FA / OIDC) is never gated — the user just authenticated. Session expiry (401 →
`clearSession`) unlocks the gate so the next login lands on the main tree, not a stale lock.

**Authentication surface.** The prompt accepts a Class 3 (`BIOMETRIC_STRONG`) biometric **or** the
device credential (PIN/pattern/password) as the fallback — never a weak (Class 2) biometric, and no
in-app PIN is stored. The prompt is bound to a Keystore **user-authentication-required** AES key via a
`CryptoObject`, so the authentication result is consumed by a real cryptographic operation
(`app/.../applock/BiometricUnlockVault.kt`; authenticator set in `app/.../applock/AppUnlockPrompter.kt`;
capability gate in `core/data/.../AndroidLocalAuthCapabilities.kt`). On a device with neither a strong
biometric nor a secure lock screen the toggle is disabled with the reason, so the gate can never be
enabled without a way to open it. The biometric comparison happens in the OS (Keystore-authenticated);
the app stores no biometric material and no PIN hash, and the auth-bound key is invalidated when
biometric enrollment changes.

**Server half — revocable device grants.** To make biometric a genuine alternative to the password
(not just a 96 h resume window), an enrolled install holds a **device grant**: a long-lived, hashed,
revocable per-device credential minted after an interactive login (`backend/database/migrations/
000051_device_grants.*`), exchanged for a fresh session JWT via `POST /auth/device/session`
(`device_grant_controller.go`). On a 401 the app tries one grant exchange before falling back to
`clearSession` (`SessionExpiryWiring`), so an expired-but-valid session resumes seamlessly after the
device's own biometric gate; a *revoked* grant still ends exactly as a 401 always has. The server
revokes every grant on password change/reset and 2FA enable/disable/reset, so a stolen password or a
changed 2FA posture cannot keep a passwordless door open. Since issue #1759 enrolment is part of
the single app-lock switch: switching the lock on enrols where the server offers device grants, and
switching it off revokes the grant on the server **first** and clears the lock only once that
succeeded, so an offline or failed revoke leaves the lock on in front of the still-live grant
(`feature/settings/.../SettingsViewModel.kt#performAppLockOff`, pinned by `SettingsViewModelTest`
`a failed revoke leaves the app lock on and says so`). The one-shot post-login "Sign in with
biometrics?" prompt (`app/.../enroll/BiometricEnrollmentViewModel.kt#BiometricEnrollmentViewModel`)
still enrols without switching the lock on; a grant held with the lock off stays revocable from
Settings > Privacy & security (`feature/settings/.../AppearancePrivacySettingsScreens.kt#PrivacySettingsContent`).
Phone (have) + biometric (are) is verified
*locally* by the OS; the server sees a single possession factor — the standard refresh-token model,
which is how the docs describe it rather than claiming server-verified 2FA.

### P8 — Embedded local store: field-level at-rest encryption + FBE, with accepted plaintext residue (issue #1262)

The local-only Android mode (ADR 0028 Decision 2) runs the real Go backend on the device, so a `Local`
profile holds a store that is the **only** copy of the user's data — not a rebuildable cache like the
Room mirror (P4). The mirror's control, whole-database SQLCipher, does not carry over: the embedded
server's SQLite driver is pure Go (`modernc.org/sqlite`, built `CGO_ENABLED=0`), and an encrypted-SQLite
Go driver would reintroduce cgo and an NDK build. ADR 0028 Decision 4 therefore adopts the backend's
existing **field-level `atrest` encryption** (`backend/atrest/atrest.go`) under a master key generated
on-device, plus Android **file-based encryption** of app-private storage (credential-encrypted, locked
until first unlock). The embedded binary, its database, photos, attachments and the Unix socket live
under `filesDir/local-server/`, which the Room-mirror cleaner deliberately does not touch
(`android/core/data/src/main/kotlin/com/mycorrhizal/crm/data/local/LocalDataCleaner.kt`).

**The residue is accepted, not a silent gap.** Field-level `atrest` encryption leaves the full-text
search index and structural columns — names in `sort_name`, ids, timestamps — in the clear inside the
database file. On a single-user, single-device store already protected by Android FBE, encrypting those
would be a marginal gain for a large build cost, so this position records them as accepted, exactly as
ADR 0028 Decision 4 requires. The store is deletable in one action ("Delete local data", gated behind a
typed confirmation), which stops the embedded server and removes the directory and its Keystore-wrapped
keys (`android/core/data/src/main/kotlin/com/mycorrhizal/crm/data/local/LocalServerHost.kt`).

**Secrets.** The embedded server's JWT signing secret and at-rest master key are generated on first run
and wrapped by **a dedicated Android Keystore AES-256-GCM key per purpose** (MASVS CRYPTO), stored in
`filesDir/local-server/keys.bin`, and handed to the child process over its private stdin — never an
environment variable, a log, or a plaintext file
(`android/core/data/src/main/kotlin/com/mycorrhizal/crm/data/local/LocalServerSecrets.kt`). The host's
Keystore calls cannot be exercised on the JVM (no Keystore under Robolectric), so they are pinned by a
source guard test in the same file's module, like the token/device-grant stores.

---

## V2 — Data Storage and Privacy (MSTG-STORAGE)

L2-only, out of scope: STORAGE-8, STORAGE-10, STORAGE-11, STORAGE-13, STORAGE-14, STORAGE-15.
(STORAGE-8 is met anyway — backups are disabled: `allowBackup="false"` and `data_extraction_rules` exclude
every domain, `app/src/main/AndroidManifest.xml#android:allowBackup="false"`, `res/xml/data_extraction_rules.xml`. STORAGE-9 is
L2 and `partial` (satisfied on API 33+) — see the row below and P6.)

| ID | Requirement (abbrev.) | Status | Evidence |
|---|---|---|---|
| STORAGE-1 | Sensitive data in system credential storage | satisfied | The JWT is stored in `EncryptedSharedPreferences` with a Keystore `MasterKey` (AES256_GCM) — `core/data/.../EncryptedTokenStorage.kt#EncryptedTokenStorage`, DI binding `SessionStorageModule.kt#provideTokenStorage`. The Room mirror is SQLCipher whole-DB encrypted — random 32-byte passphrase in `EncryptedSharedPreferences` + Keystore `MasterKey` (`RoomPassphraseStore.kt`), `SupportOpenHelperFactory` wired in `DataModule.kt#buildDatabase`, plaintext→encrypted transition at `RoomCacheEncryption.kt` (P4); a database that fails to open under its passphrase is wiped and rebuilt rather than boot-looping (`RoomDatabaseRecovery.kt`, issue #998). The embedded local store (ADR 0028 Decision 2, P8) is app-private and FBE-protected, with its own field-level `atrest` encryption under a Keystore-wrapped master key; it lives outside the mirror under `filesDir/local-server/` and is deleted only by the explicit "Delete local data" action. |
| STORAGE-2 | No sensitive data outside app container / credential storage | satisfied | All persistence is inside the sandbox (EncryptedSharedPreferences, DataStore, SQLCipher-encrypted Room). No external/shared storage for app data; the only `content://` surface is the FileProvider cache for user-initiated vCard sharing (`app/src/main/AndroidManifest.xml#androidx.core.content.FileProvider`). Offline PII is purged on logout/account-removal: the Room tables are cleared and the photo/attachment cache directory deleted (`LocalDataCleaner.kt`, invoked from `DefaultSessionManager.clearSession()`). |
| STORAGE-3 | No sensitive data written to logs | satisfied | The JWT is never logged (`EncryptedTokenStorage.kt#EncryptedTokenStorage` comment); OkHttp debug logging is `Level.BASIC` (request line only, no headers/body) and debug-only (`core/network/.../NetworkFactory.kt#builder`). |
| STORAGE-4 | No sharing with third parties unless necessary | satisfied | No telemetry/analytics/third-party SDKs; Firebase is a distribution-variant concern (issue #1133): the Google Services plugin is applied only when a real `google-services.json` exists (`app/build.gradle.kts#"com.google.gms.google-services"`), the FCM SDK is on the `obtainium`/`play` flavors only, and the `foss` (F-Droid) variant binds a no-op push seam (`app/src/foss/kotlin/com/mycorrhizal/crm/push/FossPushModule.kt`) with no Firebase/GMS. The FCM service is declared in the main manifest and removed for the FOSS flavor (`app/src/main/AndroidManifest.xml#com.mycorrhizal.crm.push.MyFirebaseMessagingService`). vCard export is a user-initiated system share sheet (`AndroidManifest.xml#androidx.core.content.FileProvider`). |
| STORAGE-5 | Keyboard cache disabled on sensitive inputs | partial | Register / forgot-password / settings / users secret fields use `KeyboardType.Password` (the Android signal that disables IME personalized learning) alongside `PasswordVisualTransformation` — `feature/auth/.../RegisterScreen.kt#password`, `ForgotPasswordScreen.kt#confirmPassword`, `feature/settings/.../AccountSettingsScreens.kt#ChangePasswordContent`, and the GeoPulse API-token field (`feature/settings/.../GeoPulseSettingsScreen.kt#GeoPulseSettingsScreen`, issue #160 — the token is write-only: sent once, cleared from UI state on save, never read back or persisted on the device, `feature/settings/.../GeoPulseSettingsViewModel.kt#GeoPulseSettingsUiState`). **Gap:** the login screen's password field (`feature/auth/.../LoginScreen.kt#twoFactorCode`) masks via `PasswordVisualTransformation` + autofill `ContentType.Password` but keeps default keyboard options — no `KeyboardType.Password` on the one field where a password is typed most often. |
| STORAGE-6 | No sensitive data exposed via IPC | satisfied | The only exported components are system-driven: `MainActivity` (launcher + OIDC deep link + the public navigation-only `mycorrhizal://` deep-link filter, issue #1269 / ADR 0029 §2 — six explicit hosts, no scheme-only filter, `AndroidManifest.xml#android:scheme="mycorrhizal"`; pinned by `NotificationDeepLinkRouteTest` `the manifest resolves allowed deep-link hosts to MainActivity and nothing else`) and the `PHONE_STATE`/`SMS_RECEIVED` receivers (the latter guarded by `android.permission.BROADCAST_SMS`); and, for share-to-CRM (issue #1271 / ADR 0029 §6), a second `MainActivity` filter for `ACTION_SEND` `text/plain` only — no `text/vcard`/image/stream, no permission implied; it only ever *prefills* a note draft (pinned by `NotificationDeepLinkRouteTest` `the manifest offers ACTION_SEND text plain to MainActivity only`). Everything else is `exported="false"` (`AndroidManifest.xml#android:scheme="mycorrhizal"`, `AndroidManifest.xml#com.google.firebase.MESSAGING_EVENT`). The FileProvider is `exported="false"` with per-URI grants (`:80-81`). |
| STORAGE-7 | No sensitive data exposed via UI | satisfied | Passwords are masked via `PasswordVisualTransformation` on all secret fields (see STORAGE-5 citations); no secret is rendered readably. |
| STORAGE-9 (L2) | App removes sensitive data from views when backgrounded | partial | **Satisfied on API 33+, partial overall** (issue #1741, see P6). `MainActivity.onCreate` calls `setRecentsScreenshotEnabled(false)` on API 33+ in every build type, blanking the recent-apps thumbnail without blocking screenshots; an opt-in, default-off, device-local "Block screenshots and screen recording" setting additionally sets `WindowManager.LayoutParams.FLAG_SECURE` live (`shouldSetFlagSecure`, `MainActivity.kt`), which hides the preview on every API level. **Gap:** on API 26-32 with the setting off (the default) the thumbnail shows app content — no targeted API exists there; accepted residual risk, mitigated by the opt-in app lock (P7) and the opt-in setting. Verified by `ScreenResilienceTest.screenshotsAreAllowedByDefault`, `ScreenResilienceTest.settingOnSetsFlagSecureBeforeTheFirstFrameOnColdStart`, `ScreenResilienceTest.togglingTheSettingFlipsTheFlagLiveWithoutARestart`, `ScreenResilienceTest.recentsScreenshotsAreDisabledOnApi33PlusRegardlessOfTheSetting` (`app/src/androidTest/.../security/ScreenResilienceTest.kt`) and `FlagSecurePolicyTest`. |
| STORAGE-12 | App educates user on PII handling | satisfied | Self-hosted personal CRM: the privacy stance (data stays on the operator's server) is documented in `docs/deployment.md` and `AGENTS.md`; no third-party collection exists to disclose. |

## V3 — Cryptography (MSTG-CRYPTO)

No L2-only rows in this chapter (CRYPTO-1…6 are all L1).

| ID | Requirement (abbrev.) | Status | Evidence |
|---|---|---|---|
| CRYPTO-1 | No hardcoded keys as sole encryption | satisfied | No symmetric keys in source; the Keystore `MasterKey` (AES256_GCM) is hardware-backed (`EncryptedTokenStorage.kt#masterKey`). mobsfscan's `android_kotlin_hardcoded` fires only on the `SECRET` enum value (P5). Passkey private keys (issue #1293, ADR 0034) are created and held by the platform authenticator / credential provider (Credential Manager) and never touch app storage, app memory or the Room mirror: the app only carries the public attestation and assertion JSON through `PasskeyCredentialClient`, and no app-held key material is added. |
| CRYPTO-2 | Proven cryptographic implementations | satisfied | `androidx.security:crypto` (`EncryptedSharedPreferences`, `MasterKey`) for the session token and Room passphrase, and SQLCipher (`net.zetetic:sqlcipher-android`) for the Room mirror — both platform-standard, no hand-rolled crypto (`EncryptedTokenStorage.kt#"androidx.security.crypto.MasterKey"`, `RoomPassphraseStore.kt`, `DataModule.kt`). |
| CRYPTO-3 | Appropriate primitives, best-practice parameters | satisfied | Master key `AES256_GCM`; pref keys `AES256_SIV`; pref values `AES256_GCM` (`EncryptedTokenStorage.kt#masterKey`). SQLCipher uses AES-256-CBC pages with per-page random IVs + HMAC-SHA512 page integrity, keyed via PBKDF2-HMAC-SHA512 (256k iterations) from a random 32-byte passphrase — the library's standard parameters. |
| CRYPTO-4 | No deprecated algorithms | satisfied | No MD5/SHA1/DES/ECB/Blowfish anywhere in `android/`; only AES-256 (GCM/SIV/CBC) + HMAC-SHA512 via the platform library and SQLCipher. |
| CRYPTO-5 | No key reuse across purposes | satisfied | The single Keystore master key wraps *distinct* keys per purpose: the session-token key and the Room-mirror passphrase are separately generated (`EncryptedTokenStorage.kt`, `RoomPassphraseStore.kt`); the DB passphrase never encrypts anything but the SQLCipher database. The embedded local store adds one dedicated Keystore AES-256-GCM key **per purpose** — `mycorrhizal.local_server.jwt` and `mycorrhizal.local_server.atrest` (`LocalServerSecrets.kt`) — so the JWT signing secret and the at-rest master key are never wrapped by the same key. No key is reused across different secrets. |
| CRYPTO-6 | Secure random number generator | satisfied | No custom RNG; the Keystore master key, `androidx.security.crypto`, and `SecureRandom` (Room passphrase, `RoomPassphraseStore.kt`) all draw from the platform CSPRNG; SQLCipher generates its per-page IVs and DB salt internally. |

## V4 — Authentication and Session Management (MSTG-AUTH)

The app is a thin client: authentication and authorization are enforced at the remote endpoint
(covered in `asvs-l2.md` V2/V3/V4). Client-side rows cite the client contract.

L2-only, out of scope: AUTH-8, AUTH-9, AUTH-10, AUTH-11.

| ID | Requirement (abbrev.) | Status | Evidence |
|---|---|---|---|
| AUTH-1 | Authentication performed at the remote endpoint | satisfied | Username/password login (`feature/auth/.../LoginScreen.kt`, `LoginViewModel`) and OIDC (`feature/auth`) both submit to the backend; nothing is authenticated client-side only. Passkeys (issue #1293, ADR 0034): the WebAuthn assertion is produced by the platform Credential Manager and verified by the backend (`feature/auth` "Use a passkey" only relays the server-issued options and the resulting assertion; the app never verifies or trusts it locally), and the session is minted server-side exactly as for a TOTP code. |
| AUTH-2 | Stateful sessions use random server ids | not-applicable | Sessions are stateless JWTs, not stateful server ids — the `not-applicable` is the clean read; see AUTH-3. |
| AUTH-3 | Stateless tokens signed with a secure algorithm | satisfied | The backend issues HS256 JWTs (`asvs-l2.md` 3.2.4); the client treats the JWT as an opaque bearer credential and validates it against `/users/me` before flipping to logged-in (`MainActivity.kt#MainActivity.handleOidcReturn`, `MainActivity.kt#MainActivity.startOidcLogin`). |
| AUTH-4 | Remote endpoint terminates session on logout | satisfied | Backend logout + `token_version` revocation (`asvs-l2.md` 3.3.1); client `clearSession()` drops the token (`DefaultSessionManager`) and advances a session epoch, so a device-grant refresh or Local re-mint that resolves after logout is rejected instead of persisting a credential (`SessionExpiryWiring`, `setTokenIfCurrent`, `activateLocalProfileIfCurrent`; pinned by the deferred-logout tests in `SessionExpiryWiringTest` and `LocalSessionReminterTest`, issue #2051). Profile switches use the same epoch and clear the bearer before changing the origin, so a request can never carry one profile's token to another profile's server (`ProfileSwitchTokenIsolationTest`, issue #2050). |
| AUTH-5 | Password policy enforced at remote endpoint | satisfied | Entropy-based policy + strength meter (`asvs-l2.md` 2.1.1/2.1.8); client mirrors it in the register form. |
| AUTH-6 | Anti-automation against credential submission | satisfied | Per-account lockout + per-IP limiter at the endpoint (`asvs-l2.md` 2.2.1); client surfaces the 429 as a generic failure. |
| AUTH-7 | Session invalidation after inactivity, token expiry | satisfied | JWT 96 h absolute expiry + `token_version` revocation (`asvs-l2.md` 3.3.1/3.3.2); client handles 401 by clearing the session. |
| AUTH-12 | Authorization model enforced at remote endpoint | satisfied | Ownership scoping is entirely server-side (`asvs-l2.md` V4); the client only renders what the API returns — no client-side authz to bypass. |

## V5 — Network Communication (MSTG-NETWORK)

L2-only, out of scope: NETWORK-4, NETWORK-5, NETWORK-6.

| ID | Requirement (abbrev.) | Status | Evidence |
|---|---|---|---|
| NETWORK-1 | TLS consistently, no cleartext | satisfied | Production network security config forbids cleartext (`cleartextTrafficPermitted="false"`) and trusts only system CAs — `app/src/main/res/xml/network_security_config.xml#<base-config cleartextTrafficPermitted="false">`, wired via `AndroidManifest.xml#android:networkSecurityConfig="@xml/network_security_config"`. Pinned by test (see `NetworkSecurityConfigTest`). Debug-only loopback cleartext never ships (P2). |
| NETWORK-2 | TLS settings align with best practice | satisfied | The app lets the platform's TLS stack negotiate (no custom socket factory, no `sslSocketFactory` overrides anywhere); current OkHttp enforces TLS 1.2+. |
| NETWORK-3 | Verify X.509, only trusted CAs | satisfied | Trust anchors are `system` only (`network_security_config.xml#<domain-config cleartextTrafficPermitted="true">`); user-installed CAs are trusted **only** in the debug variant (`debug/res/xml/network_security_config.xml#embedded.invalid`), which never ships in a release build. No custom trust manager or `X509TrustManager` override, and no KeyChain-import path either (P1, issue #961) — a self-signed or private-CA cert is not trusted by a release build at all. |

## V6 — Platform Interaction (MSTG-PLATFORM)

L2-only, out of scope: PLATFORM-10 (WebView cleanup — not-applicable, no WebView anywhere, see
PLATFORM-5/6/7), PLATFORM-11 (iOS-only third-party keyboard restriction — not-applicable, this is the
Android client). PLATFORM-9 is L2 but satisfied — see the row below and P6.

| ID | Requirement (abbrev.) | Status | Evidence |
|---|---|---|---|
| PLATFORM-1 | Minimum necessary permissions | satisfied | Permissions are runtime, opt-in, requested at the moment the user turns the feature on, and documented inline with rationale: `READ_CONTACTS` (link-action resolver, requested inline — `AndroidManifest.xml#android.permission.READ_CONTACTS`). The call-log/SMS/phone tracking permissions (`AndroidManifest.xml#android.permission.READ_CALL_LOG`, `AndroidManifest.xml#android.permission.RECEIVE_SMS`) are requested from the Settings toggles (issue #721): the DataStore opt-in flag is only ever set once the matching grant is present, a denial reverts the toggle and offers a rationale or the system app-settings deep link, and every entry into Settings reconciles each toggle against the real grant state so a revoked permission renders off rather than lying (`feature/settings/src/main/kotlin/com/mycorrhizal/crm/feature/settings/SettingsViewModel.kt#SettingsViewModel`). Notifications, overlay, foreground service, boot (`:18-26`). `uses-feature ... required="false"` so tablets/ChromeOS install without telephony hardware (`:30-31`). The `play` distribution flavor omits the call/SMS capture feature entirely (issue #1200), so its merged manifest drops those permissions rather than requesting capabilities it cannot hold (`app/src/play/AndroidManifest.xml`). |
| PLATFORM-2 | Validate + sanitize all external input | satisfied | The OIDC deep link is strictly validated — scheme, host, **and** path — so an exported `MainActivity` cannot be driven by a foreign explicit-component VIEW intent, and only a callback carrying both the short-lived exchange `code` and the app's `state` nonce is accepted (`MainActivity.kt#shouldHandleLaunchIntent`, `MainActivity.kt#parseOidcReturn`); pinned by `OidcReturnParsingTest`, including the regression that a legacy raw-`token` link is ignored (`app/src/test/.../OidcReturnParsingTest.kt#uri`). Notification deep links are parsed into known routes only, strictly (issue #1268, ADR 0029 §2–4): ids are `[1-9][0-9]{0,9}` ≤ `Int.MAX_VALUE` or `[A-Za-z0-9._:-]{1,128}`, and an empty, `.`/`..` or `%2F`-bearing path segment is rejected (`MainActivity.kt#deepLinkRoute`, `MainActivity.kt#searchRoute`); pinned by the shared `testdata/deep-links/vectors.json` vectors (`NotificationDeepLinkRouteTest`, `every shared vector resolves to its android route`). A launch intent is handled at most once — `shouldHandleLaunchIntent` refuses a recreate or a reopen-from-Recents replay (`MainActivity.kt#shouldHandleLaunchIntent`), the consumed data is stripped from the retained intent, and a pending link expires after 10 minutes and is cleared on logout (`MainActivity.kt#PendingDeepLink`, `MainActivity.kt#clearWhenLoggedOut`). Public `VIEW` links (issue #1269) and the notification extra feed the same `deepLinkRoute` (`deepLinkUri`, `MainActivity.kt#deepLinkUri`); the manifest filter is advisory (an explicit intent bypasses it), `deepLinkRoute` is the boundary, and `mycorrhizal://search?q=` is sanitised (trim, strip Unicode Cc/Cf, truncate to 200 chars) before it reaches the contact list as a search *prefill* — it never opens a result. No deep-link URI is ever logged. Shared text (`ACTION_SEND` `text/plain`, issue #1271 / ADR 0029 §6) is untrusted input under the same rules: `shareDraftFromIntent` re-checks action + type (the filter is advisory), `composeSharedDraft` strips Unicode Cc/Cf except `\n`/`\t` and truncates to 10,000 chars (`ShareDraft.kt`; pinned by `ShareDraftTest` and `NotificationDeepLinkRouteTest` `the draft is truncated to 10000 characters with an ellipsis`), it is consumed once, expires after 10 minutes and is cleared on logout like a deep link, is held only in the in-memory `ShareDraftHolder` (never in a route, saved state or log) and only prefills a note the user must pick a contact for and save by hand (`NoteFormViewModelTest` `prefill does not save anything by itself`). |
| PLATFORM-3 | No sensitive functionality via custom URL schemes unless protected | satisfied | The only custom scheme is `mycorrhizal://`, and its one token-bearing route (`/oidc/callback`) no longer carries the OIDC JWT (issue #965). The callback delivers a short-lived exchange code bound to a PKCE S256 challenge the app generated; the code is redeemable only with the on-device verifier (never sent through the scheme). `AuthMiddleware` refuses any token carrying the `oidc_native_exchange` purpose claim (`backend/middleware/auth.go#AuthMiddleware`). The narrow exchange handler verifies the PKCE verifier before it issues the session (`backend/controllers/oidc_controller.go#OIDCNativeExchangeHandler`), and before redemption the app checks its own state nonce and pending-request TTL (`OidcLoginCoordinator.kt#OidcLoginCoordinator.onCallback`, `OidcLoginCoordinator.kt#OidcLoginCoordinator.redeem`; `OidcPkce.kt`). A stolen redirect yields an unusable code. The other public `mycorrhizal://` routes (issue #1269, ADR 0029 §1) are **navigation-only**: `home`, `contacts/{id}`, `contacts/{id}/activities`, `circles\|tags\|households/{id}` and `search?q=` open a read screen inside the already-authenticated session and carry no token, resolve only through a closed route allowlist to known screens — no settings, edit or destructive route exists in it (`MainActivity.kt#deepLinkRoute`); with the app lock armed the link is held until unlock (`DeepLinkE2eTest`). |
| PLATFORM-4 | No sensitive functionality via IPC unless protected | satisfied | No `ContentProvider` exposes app data (the FileProvider is `exported="false"` + per-URI grants); the exported receivers are system broadcasts only, and the SMS receiver requires `BROADCAST_SMS` (`AndroidManifest.xml#com.mycorrhizal.crm.push.MyFirebaseMessagingService`, `AndroidManifest.xml#com.mycorrhizal.crm.feature.tracking.SmsReceiver`). |
| PLATFORM-5 | JavaScript disabled in WebViews | not-applicable | No WebView anywhere in the app (grep for `WebView`/`CustomTabs` returns nothing); OIDC login opens the system browser. |
| PLATFORM-6 | WebView protocol handlers minimized | not-applicable | No WebView (see PLATFORM-5). |
| PLATFORM-7 | Native bridges only render in-package JS | not-applicable | No WebView / `addJavascriptInterface` anywhere. |
| PLATFORM-8 | Safe deserialization | satisfied | Moshi (`NetworkFactory.kt#moshi`, `NetworkFactory.kt#okHttpClient`) with codegen (`@JsonClass`) — reflection-free, no `ObjectInputStream`/`Parcelable`-of-untrusted-data, no pickle/Java serialization. |
| PLATFORM-9 (L2) | App protects itself against screen overlay attacks | satisfied | `MainActivity.onCreate` sets `window.decorView.filterTouchesWhenObscured = true` unconditionally (`MainActivity.kt`), gating touch dispatch to the whole view tree so a tapjacking overlay can't tap through it — issue #507, see P6. Verified by `ScreenResilienceTest.mainActivityDecorViewFiltersTouchesWhenObscured` (`app/src/androidTest/.../security/ScreenResilienceTest.kt`). |

## V7 — Code Quality and Build Settings (MSTG-CODE)

No L2-only rows in this chapter.

| ID | Requirement (abbrev.) | Status | Evidence |
|---|---|---|---|
| CODE-1 | Signed with valid cert, private key protected | satisfied | Release signing via env/properties only, never committed; a partial set fails fast rather than producing a null-password APK (`app/build.gradle.kts#"com.google.gms.google-services"`). |
| CODE-2 | Release-mode build, non-debuggable | satisfied | `isMinifyEnabled = true`, `isShrinkResources = true`, ProGuard rules for R8 (`app/build.gradle.kts#embeddedServerJniLibs`, `app/proguard-rules.pro`); release builds are non-debuggable by AGP default; `./gradlew :app:assembleObtainiumRelease` is gated in `.github/workflows/android-apk-build.yml` (signing secrets required, fail-fast) and `docker-publish.yml#Materialize release keystore`. |
| CODE-3 | Debug symbols removed from native binaries | not-applicable | No native code (pure Kotlin/JVM); nothing to strip. |
| CODE-4 | No debug/backdoor code, no verbose logging | satisfied | HTTP logging is debug-build-only and `Level.BASIC` (`NetworkFactory.kt#builder`); no hidden settings/backdoors; `detekt` in CI gates dead code and leftover debug paths (`android-tests.yml` → `./gradlew detekt`). |
| CODE-5 | Third-party components identified + vulnerability-checked | satisfied | CodeQL `java-kotlin` (`codeql.yml#analyze-java-kotlin`), mobsfscan (`sast.yml`), Android Lint, `detekt`, and Dependabot's `gradle` ecosystem (`/android`, `.github/dependabot.yml`) + GitHub Dependency Review (`dependency-review.yml`). `filters.yaml` maps `android/**` to the Android + SAST + CodeQL jobs. |
| CODE-6 | Exceptions caught and handled | satisfied | Result/`fold` error handling across repositories and the OIDC flow (`MainActivity.kt#MainActivity.onNewIntent`, `MainActivity.kt#MainActivity.handleOidcReturn`); `detekt`'s over-broad-exception rule in CI. |
| CODE-7 | Error handling denies access by default | satisfied | 401/404s clear the session rather than leaking data; failed profile fetch flips to logged-out (`OidcLoginCoordinator.kt#OidcLoginCoordinator.onCallback`, `OidcLoginCoordinator.kt#OidcLoginCoordinator.redeem`); no client-side fallback that reveals data on error. |
| CODE-8 | Unmanaged-code memory handled securely | not-applicable | No unmanaged/native code. |
| CODE-9 | Free toolchain security features enabled | satisfied | R8 byte-code minification + resource shrink (`app/build.gradle.kts#embeddedServerJniLibs`), ProGuard rules for reflection-using libs (`app/proguard-rules.pro`), and the platform's default hardening (NX, ASLR) apply to the managed app. |
