# Threat model + security architecture

The answer to "what happens if an attacker gets X?" for every asset this app holds, and the record of
every gating security decision this project has deliberately made rather than left implied. This doc is
the ASVS V1.2 (threat modeling) and V1.14 (security architecture) artifact; `docs/security/asvs-l2.md`
and `docs/security/masvs-l1.md` are the per-control checklists this doc's claims cite into — this doc
does not re-derive `file:line` evidence that already lives there. `docs/security/pii-inventory.md`
covers the orthogonal *minimization* question — not "is this asset protected?" but "should it
exist, and is it more or kept longer than needed?".

| | |
|---|---|
| **Last updated** | 2026-09-18 (issue [#377](https://github.com/DrewBrunning/mycorrhizal-crm/issues/377); gating decision 3 revised by issue [#507](https://github.com/DrewBrunning/mycorrhizal-crm/issues/507); self-hosted boundary given a concrete operator checklist by issue [#417](https://github.com/DrewBrunning/mycorrhizal-crm/issues/417); backup immutability / ransomware resistance resolved by issue [#505](https://github.com/DrewBrunning/mycorrhizal-crm/issues/505); backup-store authenticity resolved by signed manifests, issue [#943](https://github.com/DrewBrunning/mycorrhizal-crm/issues/943); operational/environmental-failure actor group added by issue [#930](https://github.com/DrewBrunning/mycorrhizal-crm/issues/930); sync-gap, SHA-pin, cosign-identity, governance-cadence and import-evidence claim drift corrected by issue [#931](https://github.com/DrewBrunning/mycorrhizal-crm/issues/931); the multi-user instance boundary and per-user resource quotas stated by issue [#950](https://github.com/DrewBrunning/mycorrhizal-crm/issues/950)) |
| **Scope** | Backend (Go/Gin + SQLite), frontend (React SPA), Android client, CardDAV/CalDAV sync, self-hosted deployment. |
| **Companion docs** | `docs/security/asvs-l2.md` (backend/frontend/deployment controls, OWASP ASVS 4.0.3 + API Top 10), `docs/security/masvs-l1.md` (Android client controls, OWASP MASVS 1.5.0) |

"Answered in the doc" means: pick any asset below, and this doc plus its two companions tells you what
protects it, who could threaten it, and what happens if that control fails.

## The self-hosted boundary, as a first-class assumption

**The operator owns the host, the disk, the network, and the logs.** State it once here and every other
section derives from it:

- There is no multi-tenant cloud boundary to defend — a compromised host is a compromised deployment,
  full stop. Controls in this doc therefore target *network* and *application* attackers (someone who
  doesn't already have host access), not an operator attacking their own instance.
- This is the same position `masvs-l1.md` P1 already argues for the Android client (no certificate
  pinning against a self-hosted origin the operator controls) and that `asvs-l2.md` uses to mark
  mTLS/KMS/centralized-log-shipping `not-applicable` (V1.2.2, V1.6.2, V1.7.2, V6.4.2) — this doc just
  names the assumption instead of leaving five separate rows to each imply it.
- It bounds what "at rest" protection means: field-level encryption (below) defends a *stolen disk or
  backup*, not a *live, logged-in root shell on the box* — no software control defends against the
  second, by definition, for a single-process self-hosted app.
- It does **not** excuse anything reachable over the network without host access: authentication,
  authorization, session handling, SSRF, and input validation are full-strength regardless of
  self-hosting, and are covered like any other app (`asvs-l2.md` V2–V5, V9, V12, V13).
- `docs/security/deployment-baseline.md` (issue #417) is this assumption's concrete, operator-facing
  form: the reference topology, a recommended baseline (TLS/HSTS, secure cookies, no Docker-socket
  mount, minimal capabilities, resource limits, encrypted backups, …), and the explicit list of what
  the application does not secure (host OS, Docker daemon, reverse proxy, TLS certs, DNS, firewall,
  host filesystem, external backup storage, host admins, host compromise) — this section states the
  assumption once; that doc is the checklist an operator actually runs against.

## Multi-user instances

**Multi-user-per-instance is a supported `1.0.0` configuration** (issue
[#558](https://github.com/DrewBrunning/mycorrhizal-crm/issues/558)): one deployment hosts several
independent accounts. The self-hosted boundary above says the operator owns the host; it does **not**
collapse those accounts into one trust domain. Within the application, user A is an adversary to user
B's data and budget, and the boundary between them is enforced and tested like any other:

- **Data isolation.** Every table carries `user_id` (graph entities are keyed by a contact UID and
  resolved with both clauses), every request is scoped to the authenticated user, and the every-route
  × six-persona matrix (`backend/routes/authorization_matrix_test.go`, issue #371) plus
  `backend/cmd/bolacheck` fail CI on an unscoped route. The admin role can administer accounts but
  cannot read another user's content (issue #371).
- **Resource isolation.** Opt-in per-user quotas (issue #950) bound the cumulative contacts, notes,
  relationship edges and attachment bytes one account can create, so a peer cannot fill the
  operator's disk one request at a time the way a single-operator deployment's owner could. Each
  check counts only the acting user's live rows (`backend/services/user_quota.go`, the
  `PER_USER_*` variables in `backend/config/config.go`), and the cross-user non-interference is
  pinned by `backend/services/user_quota_test.go` and
  `backend/controllers/user_quota_enforcement_test.go`. One budget genuinely cannot be split: the
  SQLite database's single writer and the scheduler's per-tick work, which iterates all users — which
  is why the intended scale is a small, operator-vetted group (`docs/supported-versions.md`
  "Intended scale"), not an open sign-up instance.
- **Per-user job isolation.** Background work (cadence, CardDAV/CalDAV sync, Immich, reach-out
  scanning, reminders) iterates every user and scopes each user's slice by `user_id`; a failure on
  one user's integration records its own sync-health/delivery state and emits its own event rather
  than stopping the tick for the others (`docs/int-01-integration-classification-matrix.md`).

**The 1.0 plan** (issue #950): the opt-in quota landed on the REST create paths. Extending the same
`services.UserQuota` checks to the bulk-ingestion paths (CSV/VCF/JSContact import confirm,
CardDAV/CalDAV reconcile) and adding a user-count dimension to the capacity profiles (#468/#498) are
tracked follow-ups, not silently assumed done. Until they land, a bulk path is bounded per request
(import row caps, export caps, disk preflight) rather than against the cumulative quota.

## Assets

| Asset | Where it lives | What "compromised" means |
|---|---|---|
| Contact/note/activity/life-event content (PII) | SQLite, field-level AES-256-GCM encrypted at rest (`backend/atrest`, issue #380) except FTS-indexed columns (plaintext by necessity — see Gating decision 1) | Disclosure of a user's personal relationship data; on a *live* DB this is scoped per-user by every query (`asvs-l2.md` V4/API1) |
| Password hashes | `users.password_hash`, bcrypt cost 10 | Offline cracking attempt only — bcrypt cost 10 is the throttle (`asvs-l2.md` P1) |
| JWT signing key (`JWT_SECRET_KEY`) | Operator env var/file | Forge any session for any user; boot-time strength validation is the only defense (`asvs-l2.md` P2, V1.6.1) |
| TOTP seeds + recovery codes | AES-256-GCM encrypted, key HKDF-derived from `JWT_SECRET_KEY` (`backend/services/credential_crypto.go`) | Bypass a user's second factor |
| API tokens (`full`/`carddav` scope) | Hashed at rest (`asvs-l2.md` V3.5.2) | Scoped account takeover — bounded by scope, see the persona gap in Gap 2 below |
| Integration credentials (WebDAV/Immich/Seafile) | AES-256-GCM encrypted (`credential_crypto.go`) | Pivot into a user's external services |
| Attachments/photos | Filesystem, UUID-named, 0700/0750 perms (`asvs-l2.md` V12.4.1) | Disclosure of uploaded files; traversal/SSRF guarded (V12.3) |
| At-rest master key / wrapped DEK | `DATA_ENCRYPTION_KEY`/`_FILE`, `data_encryption_keys` table | Losing it makes every encrypted column undecryptable, **by design** (`asvs-l2.md` P4) — the flip side, gaining it, decrypts everything it wraps |
| Android session token | `EncryptedSharedPreferences` behind a Keystore `MasterKey` (`masvs-l1.md` STORAGE-1) | Session takeover from a compromised/rooted device — accepted risk, see Gating decision 3 |
| Android local mirror (Room DB) | SQLCipher whole-DB encrypted (`masvs-l1.md` P4, issue #385) | Offline PII disclosure from a lost/stolen device |
| Backups | Operator-managed storage the operator chooses (`make backup` → `VACUUM INTO` snapshot + `rsync`'d photos/attachments, `docs/deployment.md`) | Disclosure of everything the live DB holds. Confidentiality (issue #420): the snapshot inherits the DB's field-level at-rest encryption — encrypted columns travel as ciphertext, the master key that unwraps the DEK is operator env and never in the backup, so a stolen backup alone yields ciphertext + hashes + the FTS-plaintext set. Retention is operator-owned by design (an app-side expirer would hand an attacker running as the app the same power). Immutability (issue #505): the app's backup-write surface is write-new-only — no overwrite, no rotate/expire/delete path anywhere, pinned by trying it (`backend/database/backup_immutability_test.go`); on-host that is a bug barrier only, so immutability against a *compromised host* is the operator putting backups off-host where the app holds no credential to reach them (pull-based copy = documented default, or object-locked storage), `docs/deployment.md` → "Backup immutability & ransomware resistance". Authenticity (issue #943): the store is a distinct trust boundary from the host, so `make backup` writes a detached HMAC-SHA256 manifest beside each snapshot, keyed by the at-rest master key with HKDF domain separation (`backend/database/backup_signature.go`, `backend/atrest/atrest.go`); `make backup-verify` fails closed on a missing or invalid manifest (legacy unsigned sets need the explicit `BACKUP_ALLOW_UNSIGNED=1` opt-out), and a store-only attacker without the key cannot forge one. It does not stop replay of an older *validly signed* snapshot — the `backup_stale` freshness alert (now driven by the operator's own `backup_completed` heartbeat, not the restore drill) plus off-host storage are the controls there, and it covers the database piece only, not the operator-copied directories |

## Trust boundaries

The chain data crosses, narrowest to widest:

```
browser → API → database → filesystem → integrations → Android local DB → backups
```

| Hop | Enforced by |
|---|---|
| browser → API | TLS at the operator's reverse proxy (`docs/deployment.md:35`); CORS strict origin allowlist, `"*"` refused in release (`backend/main.go:481-500`, `backend/config/config.go:823-849`); CSP/HSTS/`nosniff`/frame-ancestors (`backend/middleware/security_headers.go`); client IP for rate limiting comes only from a validated trusted-proxy hop (loopback trusted by default, a catch-all refused at boot) and buckets are keyed on the network prefix, not the literal address (`asvs-l2.md` V14.5.4, issue #954) |
| API → database | Every query AND-scoped by `user_id`/`VCardUID` (`asvs-l2.md` V4, API1); parameterized SQL only (V5.3.4) |
| API → filesystem | UUID filenames, traversal guards, 0700/0750 perms (`asvs-l2.md` V12.3–V12.4) |
| API → integrations | Public-IP-only SSRF dialer with DNS-rebinding pinning, per-service opt-in (`backend/httputil/safedial.go:27-47`, `asvs-l2.md` V5.2.6/API7) |
| API → Android local DB | Android is a thin client; the API is the only path to data, and everything it returns lands in the SQLCipher-encrypted Room mirror (`masvs-l1.md` V2, V4) |
| database/filesystem → backups | `make backup` snapshot is operator-owned storage; the snapshot inherits field-level at-rest encryption but the unwrap key is operator env, never in the backup — confidentiality/retention documented (issue #420, `docs/deployment.md` "Backup confidentiality & retention"); immutability is write-new-only in the app + off-host by architecture against a compromised host (issue #505, `docs/deployment.md` "Backup immutability & ransomware resistance"); authenticity is a detached HMAC-SHA256 manifest signed with the at-rest master key and verified by `make backup-verify` (issue #943, `docs/deployment.md` "Backup authenticity") — see Assets table |

## Actors × trust boundaries

Each actor sits on a boundary, is neutralized by a control, and is verified by a test or issue.

| Actor | Boundary | Neutralizing control | Verification |
|---|---|---|---|
| Unauthenticated network attacker | browser→API | authz middleware, TLS, rate limiting, security headers | `asvs-l2.md` V2, V9, V14.4; issues #371/#551, #373, #374, #368, #369 |
| Authenticated ordinary user (BOLA/IDOR) | API | `user_id`/`VCardUID` scoping | `asvs-l2.md` V4.2.1/API1; exhaustive route × six-persona matrix, issue #371/#551 |
| Peer user on a shared instance (cross-user resource exhaustion) | API→DB | Opt-in per-user resource quotas at create (`services/user_quota.go`, the `PER_USER_*` variables) count only the acting user's live rows; one account at its limit never refuses a peer | issue #950 — `services/user_quota_test.go` (`TestUserQuota_IsolationBetweenUsers`), `controllers/user_quota_enforcement_test.go` (`TestCreateContact_QuotaAndIsolation`) |
| Compromised authenticated session | browser→API | `TokenVersion` revocation, httpOnly cookie, CSRF mitigation | `asvs-l2.md` V3.3.3, V4.2.2; issues #372, #392, #419 |
| Rogue admin escalating against a peer admin | API | admin role changes and account deletion are self-service only — `UpdateUser` refuses to demote another admin, `DeleteUser` refuses to delete any admin; last-admin guards keep the instance non-empty | `asvs-l2.md` V4.1.3; issue #871 — `controllers/admin_user_controller_test.go`, `controllers/admin_user_delete_test.go`, `controllers/auth_audit_events_test.go` |
| Malicious/misconfigured CardDAV client | sync→API | CardDAV Basic auth, per-user collections | `asvs-l2.md` API5; issue #566 — `backend/routes/authorization_matrix_credentials_test.go` extends the persona matrix to a CardDAV/CalDAV Basic-auth credential (401 on every `/api/v1/*` route; DAV surface reaches only its own collections) and a `carddav`-scoped API token (403 on every REST route), with the same completeness guard |
| Malicious imported vCard/JSContact | import parser→DB | parser validation, fuzzing, hostile-input neutralization | issues #375, #376 — the parser fuzzers (`vcard3/fuzz_test.go`, `vcard4/fuzz_test.go`, `jscontact/fuzz_test.go`), the adversarial import suite (`services/adversarial_import_test.go`), and the TEST-04 corpus under `internal/adversarial/fixtures/`; `controllers/hostile_input_e2e_test.go` covers the adjacent photo-decompression and CSV-formula vectors, not vCard/JSContact parsing |
| Malicious attachment | →filesystem | magic-byte validation, randomized names, SSRF-safe proxy | issue #375, `asvs-l2.md` V12.2.1/V12.3.2 |
| Malicious API client | →API | token scopes, rate limiting | issues #371/#551, #413, #415; `carddav`-scope vs `full`-scope REST enforcement pinned by `backend/routes/authorization_matrix_credentials_test.go` (issue #566) |
| Compromised external integration | →integrations | SSRF dialer, fail-secure | `asvs-l2.md` V5.2.6/API7; issues #373, #465, #366 |
| Compromised host/container | →host | non-root, minimal caps, hardening; backups made unreachable to the app's own uid by off-host architecture (write-new-only in the app is a bug barrier only) | `asvs-l2.md` V1.2.1, V1.14.5, P5; issues #362, #417, #505 |
| Filesystem access to deployment (stolen disk) | →filesystem | field-level at-rest encryption | `asvs-l2.md` V6.1.1/P4, issue #380 |
| Obtains a backup | →backups | encrypted columns are field-level AES-256-GCM (the unwrap key is operator env, never in the backup), so a stolen backup alone yields only ciphertext + hashes + the FTS-plaintext set; restore security + retention documented (issue #420) | issue #420 (confidentiality/retention documented and verified by the restore drill); issue #505 (immutability: write-new-only in the app pinned by `backend/database/backup_immutability_test.go`, off-host architecture against a compromised host documented in `docs/deployment.md`) |
| Can write to (tamper with) the backup store | →backups | The store is a distinct trust boundary: `make backup` writes a detached HMAC-SHA256 manifest beside each snapshot, keyed by the at-rest master key with domain separation, and `make backup-verify` fails closed on a missing or invalid signature (legacy unsigned sets need the explicit `BACKUP_ALLOW_UNSIGNED=1` opt-out), so a store-only attacker without the key cannot substitute a snapshot or silently strip its manifest | issue #943 — `backend/database/backup_signature_test.go` (`TestSignBackupThenVerify`, `TestVerifyBackupSignatureRejectsTamperedSnapshot`, `TestVerifyBackupSignatureRejectsSwappedManifest`), `backend/cmd/backupverify/main_test.go` (`TestRunUnsignedSetFailsUnlessExplicitlyAllowed`, `TestRunTamperedManifestFailsClosed`) |
| Obtains JWT/API credentials | →session | secret strength validation, `TokenVersion`, token expiry/revocation | `asvs-l2.md` V1.6.1, V3.3.3; issues #393, #372, #413, #411 |
| Malicious data via CardDAV/CalDAV sync (reconcile path) | integrations→DB | parser validation on reconcile, same bar as import | issue [#512](https://github.com/DrewBrunning/mycorrhizal-crm/issues/512) (closed 2026-08-26) — `services/contact_sync_hostile_input_test.go` and `services/calendar_sync_hostile_input_test.go` pin that a hostile remote update is sanitized, that duplicate/oversized input is rejected without corrupting local state, and that a secret-sensitivity field value and its relationship edge survive a hostile remote overwrite |
| Lost/stolen Android device | device→local DB | SQLCipher-encrypted Room mirror, Keystore-backed session token, logout purge | `masvs-l1.md` STORAGE-1/P4, issue #385 |
| Compromised CI/CD pipeline (malicious Action, untrusted-PR injection, forged publish trust) | source→release | SHA-pinned Actions, no `pull_request_target`, least-privilege per-job tokens, OIDC-only signing, workflow-pinned cosign identity, reproducibility as divergence detection | [The CI/CD pipeline as a trust boundary](#the-cicd-pipeline-as-a-trust-boundary) below; issues #508, #513; `docs/development/release-gates.md`, `docs/development/repo-governance.md` |

## Operational / environmental failure (non-adversarial actors)

The Actors × trust boundaries table above models *attackers*. Issue **#500**'s catastrophic-scenario
list is dominated by failures that are not adversarial at all — killed processes, full disks, disk
rot, version skew, unreachable services — and those had no row to anchor a "did we prove this?" question
to (issue **#930**). They are modeled here in the same shape: what fails, which boundary it crosses, the
control that keeps the outcome *defined* (recovered, or failed closed with local data intact), and where
that is pinned.

| Failure | Boundary | Control | Verification |
|---|---|---|---|
| Process crash / interrupted startup / crash loop | →DB (schema change, before the listener binds) | Migrations run before the HTTP listener, so a kill leaves exactly one of four well-defined states (schema untouched / dirty / clean-intermediate / clean-latest); a dirty schema refuses every later start rather than force-clearing; a restart loop cannot make it worse (each refusal is not a write, and the pre-migration backup is reused, not rewritten) | `docs/operations/migration-recovery.md` "Interrupted startup"; `database/interrupted_startup_test.go`; `docs/development/fault-injection.md` catalog (`database.migration.statement`, `database.migration.before_batch`); chaos job `startup-interruption-kill-points` |
| Disk exhaustion (`ENOSPC`) | →DB, filesystem, backup | `diskspace.Require` preflight *degrades* to a clear refusal before any write, and every write path keeps its own fail-closed backstop — no partial file, never a corrupt database | `docs/development/capacity-under-constraint.md` §Disk exhaustion; `internal/diskspace/diskspace.go`; chaos jobs `disk-full-backup`, `disk-full-write`, `disk-full-fts-rebuild`, `large-migration-disk-full` |
| Storage corruption (disk rot, torn write, bad restore) | →DB | A startup integrity probe fails closed **before** the pre-migration backup and before any migration with a typed `ErrDatabaseCorrupt`; a populated database whose version row vanished refuses with `ErrPopulatedVersionlessDatabase` instead of replaying `000001`; `cmd/doctor -repair` refuses on a corrupt input rather than running its destructive repair | `database/startup_integrity.go`; `database/startup_integrity_test.go`; `database/versionless_populated_test.go`; `cmd/doctor/main.go`; `docs/operations/disaster-recovery.md` "Scenario: database corruption" |
| Failed import (partial or corrupt ingest) | →DB | The import confirm runs in one transaction and fails closed: every row rolls back, the session stays unconsumed, and a retry after the fault clears applies cleanly — no silent partial state; Meerkat/Monica source imports are one transaction too | `docs/development/fault-injection.md` catalog (`services.import.confirm`, `services.import.source`); `services/import_fault_injection_test.go`; `services/capacity_fault_injection_test.go` |
| Incomplete, tampered or lost backup | →backups | Every snapshot carries a detached HMAC-SHA256 manifest keyed by the at-rest master key; `make backup-verify` fails closed on a missing/invalid signature (legacy sets need an explicit opt-out); a weekly restore drill round-trips row counts, and cross-version restores are tested at scale | `docs/operations/disaster-recovery.md` "Scenario: a corrupted or incomplete backup"; `database/backup_signature_test.go`; `database/backup_preflight_test.go`; `internal/schemafixture` |
| Version skew (client/server, schema/binary, below-floor DB) | →browser/Android, DB | The client-version floor refuses a below-floor client `403 CLIENT_NOT_SUPPORTED` before any credential work; a database migrated by a newer binary refuses to start (schema ahead of binary); a below-floor database refuses with a two-step message naming `v0.6.0` rather than a best-effort hop | `middleware/client_version.go`; `routes/session_minting_route_gate_test.go`; `docs/operations/migration-recovery.md` "Schema ahead of the binary" + "Below the floor"; `docs/upgrade-compatibility.md` |
| Bad / broken release (honest failure, not compromise) | →deployment | Rollback is a first-class path: the pre-migration backup is the rollback point and the schema-ahead check keeps a rolled-back binary from touching the newer schema; the release gates, RC promotion, and post-publish smoke make a broken artifact hard to publish | `docs/operations/migration-recovery.md` "Rolling back a bad release (N+1 → N)"; `docs/security/release-verification.md`; `docs/development/release-gates.md` |
| Network partition / unreachable integration | →integrations | Failure is *defined and observable*: the run records failure, releases its lock, leaves local data intact, advances sync-health/delivery state, and emits a `sync_failed`/`integration_failed` event; transient vs permanent is classified in one shared table; a failed notification or webhook send is retried or recorded, never silently dropped or double-sent | `docs/development/fault-injection.md` catalog; `services/sync_failure_behavior_test.go`; `services/delivery_failure_behavior_test.go`; `docs/int-01-integration-classification-matrix.md` |
| Concurrent modification / write contention | →DB | `_txlock=immediate` takes the write lock up front so `busy_timeout` actually retries (a deferred upgrade fails instantly); conditional CAS writes guard read-modify-write races; integrity stays clean under concurrent writers | `database/concurrent_write_test.go`; `docs/development/capacity-under-constraint.md` "Slow storage / lock contention" |
| Corrupt derived state (FTS index, graph/suggestions) | →derived stores | Derived stores are rebuildable from source; the FTS rebuild runs in one transaction so a fault rolls back to the previously-good index, and a consistency checker reports drift | `services/search_service.go`; `cmd/backfill-search-index/main.go`; `services/capacity_fault_injection_test.go` |

### The #500 scenarios, each mapped to a row

| #500 scenario | Owning row above |
|---|---|
| Interrupted migration | Process crash / interrupted startup (and Disk exhaustion) |
| Failed import/restore | Failed import; Incomplete, tampered or lost backup |
| Concurrent modification | Concurrent modification / write contention |
| Corrupt derived state | Corrupt derived state |
| Lost integration | Network partition / unreachable integration |
| Bad release | Bad / broken release |
| Client/server version mismatch | Version skew |
| Disk exhaustion | Disk exhaustion |
| DB corruption | Storage corruption |
| Partial network failure | Network partition / unreachable integration |

## Controls → threat mapping

Rather than re-list individual `file:line`s (they're already cited row-by-row in the companion
checklists), this maps each actor class above to the *chapter* that answers it in detail:

- **Network/unauthenticated attackers** → `asvs-l2.md` V2 (Authentication), V9 (Communication), V14.4
  (security headers).
- **Authenticated-user BOLA/IDOR** → `asvs-l2.md` V4 (Access Control), API Top 10 → API1.
- **Session/credential compromise** → `asvs-l2.md` V3 (Session Management), V1.6 (key management).
- **Hostile input (import, upload, sync)** → `asvs-l2.md` V5 (Validation/Sanitization), V12 (Files and
  Resources).
- **SSRF / malicious integrations** → `asvs-l2.md` V5.2.6, API Top 10 → API7/API10.
- **Data-at-rest (stolen disk/backup, lost device)** → `asvs-l2.md` V6 (Stored Cryptography), V8 (Data
  Protection); `masvs-l1.md` V2 (Data Storage and Privacy), V3 (Cryptography).
- **Misconfiguration / deployment** → `asvs-l2.md` V14 (Configuration), V1.14 (Architecture).
- **Operational / environmental failure (not an attacker)** → the [Operational / environmental
  failure](#operational--environmental-failure-non-adversarial-actors) section above;
  `docs/operations/migration-recovery.md`, `docs/operations/disaster-recovery.md`,
  `docs/development/fault-injection.md`, `docs/development/capacity-under-constraint.md`.
- **CI/CD pipeline as the attacker** → the section immediately below (#513).

## The CI/CD pipeline as a trust boundary

Everything above treats the *application* as the thing under attack. The **release path is a
trust boundary in its own right** (#513): a compromised third-party Action, a malicious commit,
workflow injection from an untrusted PR, a forged OIDC publish identity, or a hijacked
dependency all reach users through the same `build → sign → publish → install` chain, without
touching the app's own attack surface. This is the cell #377's boundary matrix left empty.

### Attack surface

| Surface | What an attacker controls |
|---|---|
| **PR events** | Branch name, title, body, diff, and — on `pull_request` — a runner with the repo checked out. |
| **Dependency resolution** | The Go module graph, `frontend/yarn.lock`, Gradle deps, Docker base images, and the third-party Actions each workflow calls. |
| **The artifact path** | `docker-publish.yml` builds → cosign-signs (keyless) → mints SLSA provenance → pushes to GHCR + attaches assets to the GitHub Release → operators `docker pull` / Obtainium installs. |
| **CI secrets** | `GITHUB_TOKEN` (scoped per job), the four `SIGNING_*` Android keystore secrets, `RELEASE_APP_PRIVATE_KEY`. |

### Trust assumptions

| Component | Trusted for | If compromised | Containment + detection |
|---|---|---|---|
| GitHub platform (Actions, GHCR, rulesets) | Running workflows honestly, enforcing branch/tag rulesets | Total — nothing below matters | Out of scope; the whole model assumes GitHub is honest, same as any GitHub-hosted project |
| Third-party Actions | Doing only what their pinned ref does | Arbitrary code in a job with that job's token scope | **Every** `uses:` is pinned to a full commit SHA (Dependabot bumps SHA+comment together) **except one documented, permanent exception**: `docker-publish.yml` calls the SLSA generator at the `slsa-github-generator@v2.1.0` tag, because the trusted-builder model requires the generator to resolve its own ref to establish the builder identity in the provenance — a hash pin breaks the artifact that job produces. The exception is suppressed inline with a reason (`zizmor: ignore[unpinned-uses]`), accepted in `docs/dependency-upgrade-policy.md`, and version bumps are a deliberate tag change; `zizmor` still lints every other workflow, and a compromised low-priv job still has only `contents: read` |
| GHCR / the image registry | Serving the digest we pushed | A swapped image | cosign signature over the **digest** (not the tag); buildkit + GitHub SLSA provenance; `governance-drift.yml` re-verifies the latest release **weekly** (`cron: '20 6 * * 1'`, not nightly), and its sibling `ruleset-drift` job skips with an explicit `::warning::` when the optional `GOVERNANCE_READ_TOKEN` is unset |
| Sigstore (Fulcio CA, Rekor log) | Issuing a cert only to the real OIDC identity, logging every signature | Forged signatures that still name our identity | The identity is **workflow-pinned** (below); Rekor inclusion proof is part of `cosign verify` |
| Go proxy / npm registry / Gradle | Serving the exact versions the lockfiles name | A poisoned dependency | Lockfiles + `go.sum` hashes; Dependabot + Dependency Review + Grype/Trivy; toolchain + base-image pins; COMPAT-03 ([#474](https://github.com/DrewBrunning/mycorrhizal-crm/issues/474)) governs abandoned-dep / name-squat response |

### Threat → control

- **Workflow injection.** No workflow uses `pull_request_target`. No job triggered by
  `pull_request` has `id-token: write`, `contents: write`, `packages: write`, or reads a deploy
  secret — a fork PR runs with `contents: read` and nothing else. Untrusted PR text is never
  interpolated into a `run:` block (zizmor's template-injection audit is a required check).
  `persist-credentials: false` on every checkout (#358) keeps the token out of the workspace.
- **CI secret blast radius.** Least-privilege per job (`docker-publish.yml`: `create-release`
  `contents: write`, `build-and-push` `packages: write` + `id-token` + `attestations`,
  `build-android-apk` the same plus `contents: write`; everything else read-only —
  `docs/development/repo-governance.md`). Every signature is OIDC-federated keyless Sigstore; the
  only long-lived credential is `RELEASE_APP_PRIVATE_KEY`, minted to a `contents: write`-only
  token used by one `workflow_dispatch`-only workflow with no PR path.
- **Forged publish trust.** `docs/security/release-verification.md`'s `cosign verify` commands
  pin `--certificate-identity-regexp` to
  `.github/workflows/(docker-publish|promote-rc)\.yml@refs/tags/v*` (images/APK — a normal
  release or a promoted RC) or `syft-sbom.yml@refs/heads/main` (main-branch SBOM). A compromised
  low-privilege workflow that somehow obtained `id-token:
  write` would get a Fulcio cert naming *its own* path — which no longer matches. `#513`'s
  `governance-drift.yml` runs `cosign verify` against the latest real release with the exact
  documented regexp, so a drift between the doc and the pipeline's identity is caught.
- **Dependency hijack.** Covered by the trust-assumptions row above; the response *policy* (how
  fast, who decides) is COMPAT-03.
- **Compromised build (detection, not prevention).** The Go server binary and the `linux/amd64`
  image are reproducible (REL-04, `docs/security/reproducible-builds.md`); `reproducibility.yml`
  double-builds them on every relevant PR (path-filtered to backend/build-input changes, not
  every PR) as a same-runner path-independence check, and rebuilds the Go binary across two
  independent runner images weekly to prove true reproducibility end-to-end. A build that was
  tampered with in the pipeline diverges from an independent rebuild, and the SLSA provenance
  names the exact source commit and workflow run that produced a given digest.

### What is *not* covered

A compromise of GitHub itself, of Sigstore's roots, or of the maintainer's own credentials is
out of scope — the model assumes those are honest, the same assumption every GitHub-hosted
project makes. The mitigations above raise the bar for everything short of that.

## Gating decisions

The five decisions #377 asked to be explicitly revisited. All five were already resolved by prior work
(#380, #385, and the original security-hardening pass); this section is the written-down record the
issue asked for, each with a **keep** or **reverse** and why.

### 1. Data-at-rest encryption (ASVS V6.4/V8.3)

**Keep — encrypted, not just "operator owns the disk."** SQLite stores user-authored PII plaintext
by default; issue #380 added field-level AES-256-GCM encryption for everything sensitive that isn't
searched (contact free text, the neutral card, life events, reminders, gifts, preferences, conversation
notes, audit snapshots, sync-conflict copies), via a single wrapped-DEK envelope
(`backend/atrest`, `asvs-l2.md` V6.1.1 + P4). **Deliberate exception:** FTS5-indexed columns
(`notes.content`, `activities.*`, flat contact search fields) stay plaintext because SQL triggers can't
decrypt for indexing — this is the searchable-plaintext set referenced throughout this doc as the one
gap in an otherwise encrypted-at-rest dataset. The compensating control for that exception is still the
self-hosted boundary: those columns are protected the same way the *whole* DB was before #380 (operator
owns the disk), not left unprotected relative to some higher bar.

### 2. JWT key management (ASVS V6.4.1)

**Keep — env var + boot-time validation + revocation, not a vault.** `JWT_SECRET_KEY` is validated at
boot for length (≥ 32 bytes), placeholder rejection, and minimum entropy (`backend/config/config.go:476-543`).
There is no key-vault/KMS (`asvs-l2.md` V1.6.2 — not-applicable, self-hosted single process). Rotation
works via `TokenVersion`: bumping it invalidates every existing session immediately
(`backend/middleware/auth.go:141-154`); rotating the key itself is a restart with a new env var, at the
cost of invalidating the TOTP/integration-credential encryption that's HKDF-derived from it
(`credential_crypto.go:20-21`, `asvs-l2.md` P2). This coupling is accepted, not accidental: introducing
a dedicated key-derivation abstraction is deferred to when a second algorithm is actually adopted (P2).

### 3. MASVS-L2 resilience items (root detection, certificate pinning, screenshot prevention, tapjacking)

**Split decision, re-evaluated by issue [#507](https://github.com/DrewBrunning/mycorrhizal-crm/issues/507):
two kept declined, two reversed.** All four were re-examined individually against this doc's actors
(not as a block) — `masvs-l1.md` P1/P3/P6 record the full cost/benefit for each:

- **Certificate pinning — keep declined** (`masvs-l1.md` P1). Every user runs their own self-hosted
  server with a certificate the app cannot know in advance, frequently self-signed or from an internal
  CA; a naive pin would be wrong on day one for most installs. The MITM actor it would answer is real
  (Actors × trust boundaries, above) and is neutralized today by standard TLS + system CA trust — but
  that trust anchor is system CAs only: a release build has no KeyChain-import path for a self-signed
  cert (issue #961 corrected an earlier claim that one existed), so a self-hosted deployment behind
  such a cert cannot be reached by the Android app at all today, independent of the pinning decision.
  Trust-on-first-use/user-managed pinning remains a distinct, uncosted feature this issue does not
  adopt.
- **Root detection (+ SafetyNet) — keep declined** (`masvs-l1.md` P3). This is the audience-honesty
  weighing the issue specifically asked for: self-hosted users are disproportionately likely to root
  their devices deliberately, and the actor this control answers (attacker already has the device) is
  already covered by data-at-rest controls (SQLCipher Room mirror, Keystore session token) that hold
  regardless of root status. Blocking on root would punish this project's own audience for a threat
  it doesn't add protection against. SafetyNet is additionally deprecated.
- **Screenshot prevention — reversed** (`masvs-l1.md` P6, MSTG-STORAGE-9). Every screen renders
  relationship PII; the recent-apps thumbnail is a concrete, zero-sophistication disclosure vector
  (anyone with a moment's access to an unlocked-but-idle phone). `MainActivity` now sets
  `FLAG_SECURE` unconditionally.
- **Tapjacking protection — reversed** (`masvs-l1.md` P6, MSTG-PLATFORM-9). Near-zero cost —
  `filterTouchesWhenObscured` only changes behavior when an overlay is actually present — against a
  real actor class (malicious overlay apps tricking a tap on a destructive confirmation). `MainActivity`
  now sets `filterTouchesWhenObscured = true` on its decor view.

**Assurance-level consequence, stated explicitly (the issue's ask):** this does **not** move the
claim to MASVS-L2. Root detection and certificate pinning stay declined, and other L2-only rows
(`masvs-l1.md` STORAGE-10/11/13/14/15, and the entire V8 Resiliency chapter, which this doc's Android
scope doesn't track at all) remain out of scope or unaddressed. **The Android client's assurance
target is still MASVS-L1** — a deliberate scope decision, not an oversight — now with two L2 controls
satisfied as a documented bonus rather than a level claim. None of MASVS-L2's resilience rows are
counted toward "satisfied" for level-claiming purposes anywhere in `masvs-l1.md`.

### 4. mTLS / KMS / centralized log anomaly detection

**Keep not-applicable.** All three assume infrastructure this app doesn't have: mTLS assumes
service-to-service traffic (`asvs-l2.md` V1.2.2 — single process, nginx→app is loopback), KMS assumes a
cloud key-management API (V1.6.2/V6.4.2 — secrets are env vars by design, see Gating decision 2),
centralized log shipping assumes a remote log pipeline (V1.7.2 — self-hosted, logs go to stdout → the
operator's own docker log driver). Each row already states this as a decision, not a silent gap; this
section exists so the *reason* — the self-hosted boundary — is stated once instead of implied five
times.

### 5. Session cookie flags (ASVS V3.4: HttpOnly/Secure/SameSite)

**Keep, verified against the exact code.** `backend/controllers/user_controller.go:234-243` sets the
session cookie `HttpOnly` (always), `Secure = cfg.CookieSecure`, and `SameSite=Strict` — tightened
from `Lax` by issue #392, which closed the residual sibling-subdomain/CSRF gap `Lax` left open. The
only cookies still deliberately `Lax` are the transient OIDC handshake cookies — `oidc_state`/`oidc_nonce`/`oidc_pkce`
plus, for the native flow (issue #965), `oidc_client`/`oidc_app_state`/`oidc_app_challenge` — which must survive the cross-site redirect back from the provider
(`backend/controllers/oidc_controller.go:105-158`). Boot-time config
validation refuses the insecure combination `FRONTEND_URL=https` + `COOKIE_SECURE=false`
(`backend/config/config.go:798-810`), so a misconfigured deployment can't accidentally serve an
HTTPS-fronted cookie without `Secure`. No `__Host-` prefix (`asvs-l2.md` V3.4.4, partial) — the cookie
must remain host-only rather than prefix-locked while plain-HTTP LAN deployments are supported
(`.env.example:131-145`); revisit if/when HTTP-only self-hosting is dropped.

## How to keep this honest

- A reviewer can answer "what happens if an attacker gets X?" for every asset in the Assets table by
  reading this doc plus the two companion checklists it cites into.
- Each of the five gating decisions above has an explicit **keep** or **reverse** and a recorded reason
  — none is left as an implication. Decision 3 is the one split decision: two items kept declined
  (root detection, certificate pinning), two reversed (screenshot prevention, tapjacking protection).
- `asvs-l2.md`'s V1.1.2, V1.1.4, and V6.1.1 rows cite this doc instead of re-deriving trust-boundary or
  threat-modeling detail.
- Both gaps found while drafting this doc are now closed: sync-path hostile input (issue [#512](https://github.com/DrewBrunning/mycorrhizal-crm/issues/512), 2026-08-26) is pinned by `services/contact_sync_hostile_input_test.go` and `services/calendar_sync_hostile_input_test.go` and cited in the Actors × trust boundaries row above; the CardDAV/API-token persona gap in the authorization matrix (issue [#566](https://github.com/DrewBrunning/mycorrhizal-crm/issues/566)) is closed by `backend/routes/authorization_matrix_credentials_test.go`.
- A design change that adds a new trust boundary (a new integration, a new sync direction, a new client)
  updates this doc in the same PR — the same "living document" convention `asvs-l2.md` already holds
  itself to.
- A release that adds a security-relevant surface also records a per-release delta in
  [`adversarial-deltas.md`](adversarial-deltas.md), and the release workflow gates on it (issue #953):
  the milestone adversarial passes (#500, #502) are one-time events, so this is the per-release
  backstop for a new surface *class* none of the §9 mechanical checks anticipated.
