Project · Lifebot V2

lifebot-v2 — Security Review

type referencestatus activelifebot · security · audit · review

Verdict

The zero-knowledge invariant does not hold as written — not because of a bug, but because of where the crypto runs.

lifebot-v2/adr/0001-zero-knowledge-e2ee claims that "the operator must never be able to read or decrypt any user's health data." For the hosted web client that is live today, that claim is false, and the UI repeats it to users verbatim. The operator serves the JavaScript that reads the master password and derives the data key, over an origin with no Content-Security-Policy, no Subresource-Integrity and no bundle pinning. Whoever controls the served bytes controls the crypto, so one added line captures the password at the moment of use — upstream of the KDF, where password strength is irrelevant. This is not exploitable by an outsider; it is the operator's ordinary capability, and no key-rotation path exists for a user who suspects it.

This is the well-known limit of browser-delivered E2EE and it constrains Proton and the Bitwarden web vault identically — it is not an implementation failure. But it means the honest claim today is "as strong as your password and our honesty at serve time", and the native iOS client planned for Stage 4 is what would actually make the original claim true.

Everything below that headline is more encouraging. The cryptographic core is correctly built — Argon2id into a wrapped random data key, XChaCha20-Poly1305 with authenticated headers, no home-rolled primitives, verified byte-identical across TypeScript and Python. Row-level security, tenant scoping and the opaque-metadata boundary all exist and mostly work. The problems are concentrated in credential placement, client-side trust of server responses, and operational posture — all fixable without redesign.

Two findings need attention regardless of the security argument, because they are destroying user data right now: H3 (a server 404 makes the client silently mint a new vault) and H8 (the client's hardcoded version vector makes the server discard every update after the first).

Remediation status (2026-07-26, same day)

The critical finding, all nine highs and most mediums were fixed, deployed and verified the same day — PR #5 on BobMck/lifebot-v2. Closed: C1 (CSP + header set, Clerk SDK pinned, the false UI claim corrected), H1/H5 (superuser DSN moved to a one-shot migrate service; control plane on its own least-privilege role), H2 (12-character floor, on creation only), H3 + M1 (unlock no longer creates on a 404; PUT /v1/vault is create-only, 409), H7 (Clerk pinned), H8 (real version vectors), H9 (capability restrictions), M4/M10/M11/M12 and several lows.

Still open, deliberately: H4 (AAD binding to row identity — needs an envelope version bump), H6's root cause and M7 (no recovery key, no password change, no key rotation), M6 (Clerk is still a development instance), M8/M9 (the metadata-inference spec violations), and rate limiting. These are the "before real health data" gate — see lifebot-v2/README for stage status.

C1 is closed only in the sense that the trust surface is now much smaller and the product no longer claims something untrue. The structural point stands: a hosted web SPA cannot deliver "the operator can never decrypt". The native iOS client is the real remedy.

Two problems the deployment surfaced that this audit had not: REVOKE CONNECT must target PUBLIC rather than the role (Postgres grants it to PUBLIC by default, so revoking from the role alone is a no-op), and cap_drop: ALL crash-loops postgres/redis/nginx, which start as root to drop privileges. Both are now covered by lifebot-v2/runbooks/linux-deploy.

Scope

Post-deployment audit of the live platform, 2026-07-26, one day after Stage 2 went live on life-bot.uk with a friends-only beta of three real users. Covers commit 71a9bef on main.

Measured against the pre-build controls in lifebot-v2/security-design and the field-level cleartext boundary in the repo's spec/data-classification.md.

Method

Nine specialist roles run as parallel agents over the code and the live system, each with a disjoint mandate so overlapping findings could be merged rather than double-counted:

Role Owns
Surface cartographer authoritative entrypoint map; true auth per route, verified by request
Cryptography & key management envelope, Argon2id parameters, AAD binding, key lifecycle
Identity & session Clerk JWT verification, JWKS, Access interaction, dev-instance posture
Tenant isolation FORCE RLS, SET LOCAL under pooling, role privileges
API logic & abuse endpoint limits, sync/conflict semantics, resource exhaustion
Client & supply chain browser trust tier, CSP/SRI, dependencies, credential exposure
Infrastructure & operations host co-tenancy, containers, backups, secrets, admin path
Zero-knowledge red team cross-domain attack chains; metadata inference against the spec
Adversarial verifier refutes every finding; anything unproven is downgraded or dropped

Evidence standard. Every finding required an exact file:line with the code quoted, or a command actually run with its output. An independent verifier then attempted to refute each one, with "refuted" as the default verdict under uncertainty.

Result: 54 raw findings → 44 confirmed, 9 plausible, 1 refuted, merged here into 44 distinct issues. The single refutation is recorded in the "Refuted" section below.

Rules of engagement. The system is live and shares a host with unrelated homelab services, so all testing was read-only: no writes to production data, no restarts, no config changes, no load or brute-force testing, no contact with the homelab stack. A fresh database backup was taken first.

System under audit

Hostname Serves Edge protection
www.life-bot.uk Next.js control plane (pages only — no API routes) Cloudflare Access whitelist, all paths
app.life-bot.uk React dashboard SPA; all crypto happens here Cloudflare Access whitelist, all paths
api.life-bot.uk FastAPI zero-knowledge sync API none — Clerk JWT only

api. is deliberately ungated (Access would break the SPA's XHR), making it the real internet-facing surface: /health, /v1/vault, /v1/sync, /v1/coach/credential, plus FastAPI's /docs, /openapi.json and /redoc — all three of which are publicly readable.

Identity runs on a Clerk development instance. One Postgres instance holds both the zero-knowledge lifebot database and the control plane's platform database. Lifebot's containers have their own Docker network but share a host, kernel and Docker daemon with ~14 homelab services publishing on 0.0.0.0.

Findings

44 issues: 1 critical, 9 high, 12 medium, 20 low, 2 informational.

Critical

C1 — The operator can decrypt any user's data by serving one tampered line of JavaScript. apps/web/nginx.conf:1-9 · apps/web/src/main.tsx:15 · packages/ui/src/UnlockForm.tsx:32-40

The nginx config is nine lines with zero add_header — no CSP, no nosniff, no Referrer-Policy, confirmed at runtime against both the origin and the edge. An operator, anyone who compromises the web container or the tunnel that terminates TLS, or the Clerk supply chain (see H7) can add an input listener on #pw or patch Lifebot.prototype.unlock to POST the master password or the derived key to an external endpoint. No connect-src would stop the exfiltration and no SRI would detect the swap. UnlockForm.tsx:19-21 tells the user "nobody, including us, can read your data" — the claim this falsifies.

Fix: correct the UI claim first (it is currently false to real users); add a strict CSP, nosniff and Referrer-Policy; pin clerkJSVersion and move Clerk sign-in off the vault origin so no third-party script shares a realm with the password; treat the Stage-4 native client as the real remedy.

High

ID Finding Location
H1 The internet-facing API container holds the Postgres superuser DSN (ADMIN_DATABASE_URL) for the life of the process, though it is needed only for a one-shot schema apply at boot. Superuser bypasses RLS even under FORCE, so the sole tenant-containment mechanism is void from inside the public process — and COPY … TO PROGRAM gives command execution in the DB container. docker-compose.prod.yml:19
H2 Master password minimum is 4 characters against Argon2id ops=3, mem=64MiB. Any DB or backup read yields a complete offline cracking oracle (salt, params, wrapped key, verifier) for every user at once. packages/ui/src/UnlockForm.tsx:12,42
H3 Live data-loss. If GET /v1/vault 404s for any reason, unlock() silently treats it as first use, mints a brand-new data key and PUTs it over the real vault — permanently orphaning all existing ciphertext. The client never distinguishes "no vault yet" from "vault not returned". packages/client/src/engine.ts:35-44
H4 The production envelope (encryptJson) uses a constant AAD bound to nothing — not the row, owner or version — and the client verifies no identity after decrypt. A DB-write attacker can roll records back or swap ciphertexts between rows undetected. (encryptRecord, which does bind metadata, is not the path in use.) packages/crypto/src/vault.ts:102-117
H5 Chain. Control-plane superuser + unconditional vault upsert (M1) + the silent 404→createVault path (H3) lets an operator or platform-compromise force key substitution and harvest master passwords. docker-compose.prod.yml:54
H6 Chain. One world-readable unencrypted backup + the 4-character floor (H2) + no key rotation (M7) = full offline plaintext recovery, unrevocable. ops/backup.sh:16
H7 Clerk's ~1 MB browser SDK is fetched at runtime under a floating major tag with no SRI directly into the vault origin — a third-party script in the same realm as the master password. apps/web/src/main.tsx:15
H8 Live data-loss. The client hardcodes vv = {device: 1} on every push, so after the first write every subsequent update to a record compares as duplicate/stale and is silently discarded server-side. packages/client/src/engine.ts:57-64
H9 pg_hba.conf inside the db container trusts local/loopback, so any holder of the Docker socket gets passwordless superuser. lifebot-v2-db-1:/var/lib/postgresql/data/pg_hba.conf

Medium

ID Finding Location
M1 PUT /v1/vault unconditionally overwrites key material — one request permanently destroys a user's data. No version guard, no confirmation. apps/api/app/routers/vault.py:23-36
M2 Sync accepts client-forged version vectors; a single push with inflated counters permanently write-locks a record against all future updates. apps/api/app/sync.py:46-65
M3 The Lamport clock is client-controlled and never advanced on receive, so conflict resolution is not sound under real concurrency. packages/client/src/engine.ts:46-50
M4 Unbounded sync batch: one authenticated request can hold a DB connection and row locks for minutes. No array cap, no payload cap, no rate limiting anywhere in the service. apps/api/app/routers/sync.py:22-47
M5 GET /v1/coach/credential returns the operator's shared, billed Gemini API key verbatim to every authenticated browser — unscoped, non-expiring, usable for arbitrary non-Lifebot calls. apps/api/app/routers/coach.py:16-27
M6 Production trusts a Clerk development instance: ~100-user hard cap, relaxed security posture, and Clerk may reset it. live instance nearby-lizard-17
M7 The recovery key is specified but never implemented — no recovery, no password change, no key rotation. A suspected compromise cannot be remediated by the user. packages/crypto/src/vault.ts:44-58
M8 Spec violation. Ciphertext length is unpadded, so record type is a deterministic function of cleartext length — spec/data-classification.md:31-33 promises type/category is never server-visible. packages/crypto/src/vault.ts:106-110
M9 Spec violation. Combining lengths, write order and updated_at, the operator can reconstruct a dated, coarse-value health timeline without decrypting anything. The spec promises "timing only". spec/data-classification.md:26,31-33
M10 Nightly backups are unencrypted, world-readable (0664), single-copy, and stored on the same host as the database. ops/backup.sh:12-26
M11 Every production secret is a plain container environment variable, readable via docker inspect by any Docker-socket holder. docker-compose.prod.yml:19-20,28,55,73
M12 All production hardening lives only in the override file — a bare docker compose up -d republishes ports and re-enables AUTH_DEV_BYPASS. docker-compose.yml vs docker-compose.prod.yml

Low

L1 azp claim unverified (auth.py:44-54) · L2 Clerk telemetry beacons from the vault origin · L3 client accepts server-supplied Argon2id parameters with no bounds check (vault.ts:63) · L4 Access is edge-only; origins answer unauthenticated on the LAN · L5 the data key is never zeroized and there is no auto-lock · L6 device IDs embed a millisecond timestamp, so sync metadata is not opaque (engine.ts:23) · L7 no cache directives on the SPA, so clients pin stale bundles with no way to force an update · L8 PyJWT 2.10.1 carries advisories, one reachable pre-auth · L9 Sentry session replay ships with masking disabled and PII enabled on the platform app · L10 the global records_seq is a real-time cross-tenant write-volume oracle · L11 cloudflared — the sole ingress, holding the tunnel token — is pinned to :latest · L12 no container hardening and no resource limits on a shared host · L13 SET LOCAL app.user_id resets to empty string, not NULL (fails closed, but by exception) · L14 users has no RLS and the app role holds full DML · L15 email_verified enforcement is dead code — Clerk's default session token omits the claim · L16 AUTH_DEV_BYPASS=1 is the base-compose default · L17 no Docker log rotation on a shared disk · L18 bogus-kid tokens force a synchronous outbound JWKS refetch per request · L19 unvalidated bounds cause a 500 that silently rolls back already-accepted records · L20 apps/platform ships 37 known-vulnerable packages (5 critical, 9 high).

Informational

I1 /health is the only unauthenticated endpoint doing database work, on the shared 10-connection pool. · I2 third parties (the Clerk dev instance, Cloudflare Access) each accumulate a picture of user activity that the threat model does not acknowledge.

Refuted

One finding was thrown out: a claim that operator-terminated TLS at the tunnel enables the ciphertext-length inference chain. The verifier established the inference works from the database alone, so the tunnel adds nothing — the underlying leak survives as M8/M9.

Remediation

Before inviting more users. H3 and H8 first — they are corrupting data today. Then correct the false UI claim (C1); add the CSP/nosniff/Referrer-Policy header set; raise the master-password floor with a strength meter (H2); drop ADMIN_DATABASE_URL from the running API container, applying the schema as a separate one-shot step (H1); chmod 600 and encrypt the backups (M10); rotate and scope the Gemini key (M5).

Before public launch. Move to a Clerk production instance (M6). Implement the recovery key, password change and key rotation (M7) — without them no compromise is ever remediable. Bind the AAD to row identity and version (H4). Give the control plane its own least-privilege database role (H5). Add rate limiting — Redis is already a dependency and configured but entirely unused. Validate version vectors and Lamport clocks server-side (M2, M3) and cap batch sizes (M4). Decide on ciphertext padding versus amending the spec (M8, M9). Pin images, harden containers, set resource limits (L11, L12). Stand up CI with dependency and secret scanning — there is none (L8, L20).

Hardening backlog. L1L19 broadly: azp verification, email_verified, RLS on users, per-tenant sequences, opaque device IDs, key zeroization and auto-lock, the Sentry replay configuration, log rotation, JWKS cache tuning, and origin-level auth behind Access.

Cleared

Audited and found sound: the choice and use of cryptographic primitives (Argon2id → wrapped random data key, XChaCha20-Poly1305, 192-bit random nonces, no home-rolled crypto); cross-language ciphertext identity between TypeScript and Python; the AEAD verifier construction, which leaks nothing beyond what the wrapped key already exposes offline; SET LOCAL tenant scoping, which does not leak across pooled connections; RLS policy logic itself, which fails closed when unset; the dedupe-key derivation, which is genuinely opaque and non-enumerable; Cloudflare Access coverage, verified with no path exclusions on either gated hostname; container non-root users; the absence of any real credential in git; and .env permissions on the box.

Coverage and limits

Not covered: no dynamic exploitation was attempted against production (rules of engagement), so findings marked plausible are reasoned rather than demonstrated. No fuzzing, no authenticated load testing, no physical or social-engineering vectors, no review of Cloudflare account security or the Tailscale admin path beyond configuration reading, and no formal cryptographic proof of the envelope. The iOS scaffold (apps/mobile) was out of scope as it does not run. The internet reachability of the host's homelab ports was not determined — that depends on router NAT and should be confirmed separately.

Compiled from wiki/projects/lifebot-v2/security-review.md · git is the source of truth