Skip to main content

Client Credential Store: Threat Model & Build-vs-Integrate

Status: decision record for M19 (issue #240). This document decides the architecture the rest of the milestone implements; the M19-5 security review gate (#244) audits the built system against the mitigations promised here.

Context

Taking a Bloom project live needs client deployment credentials (a Cloudflare token, an npm publish token, Supabase keys - the scheme's required_client_credentials, M18). The M18 interim handoff was repository Actions secrets set by hand from a terminal. M19 replaces that with a first-class flow: the client provides credentials once through the dashboard (#241), they are stored encrypted with least-privilege access (#242), and project CI obtains them at deploy time through a short-lived, scoped exchange (#243) - hands-free from then on.

Bloom's relevant topology: the FastAPI API on an exe.dev VM; Supabase Postgres for state; the dashboard SPA on Cloudflare Pages; project repos deploying via GitHub Actions; and the engine runner on a separate VM, which must never be in the secrets path.

Assets and trust boundaries

Protected assets: client deployment credentials. (Bloom's own operational secrets - bot tokens, API keys - are out of scope; they already live in GitHub Actions secrets per deployment.md.)

Principals: the project owner (browser session), the Bloom API server, Supabase, the credential backend, a project repo's CI jobs, the engine runner, and GitHub itself.

The honest constraint (and what "ZK" buys here)

CI/CD must eventually hold plaintext to deploy. No architecture removes that; designs differ only in where the decryption capability lives and how long and how scoped the plaintext- granting credential is.

  • Zero-knowledge proofs are the wrong primitive: proving statements about hidden values does not help store-and-later-use secrets.
  • Zero-knowledge (E2E) storage, Bitwarden-style - the server holds only client-side-encrypted blobs - is the relevant idea, but taken fully it moves the decryption key into "a deploy agent the client controls", which reintroduces a terminal and defeats the hands-free goal.
  • The practical boundary we choose: no plaintext at rest anywhere Bloom stores data; plaintext exists only (a) in the owner's browser at entry time, (b) inside the credential backend's process during an exchange, and (c) in the CI job at deploy time - obtained via a short-lived identity-federated token, never a static secret in the repo.

Threat scenarios × candidate designs

Candidates:

  • A. Status quo: humans set GitHub Actions repo secrets by hand (the M18 interim).
  • B. Build on Supabase: app-level envelope encryption (key in the API server's env), Bloom syncs values into Actions secrets on approval.
  • C. Build, E2E: browser-side encryption; a client-held key decrypts in a deploy agent.
  • D. Integrate: self-hosted Infisical Community Edition - MIT-licensed core, self-hosted on Postgres + Redis, with machine identities federating GitHub Actions OIDC so a workflow exchanges its GitHub-issued OIDC token (scoped to repo/workflow/ref) for a short-lived secrets-read token at deploy time.
  • E. Integrate: OpenBao (the Linux Foundation MPL-2.0 fork of Vault 1.14; Vault itself is BUSL-1.1 under IBM and is excluded on license grounds).
  • F. SaaS: Doppler - polished, but SaaS-only (no self-hosting) and per-seat priced; client credentials would live in a third party's cloud.

What a compromise reveals, per design:

ScenarioA (manual)B (build)D (Infisical)E (OpenBao)
Supabase dump / backup leaknothingencrypted blobs (safe)nothing (values not in Supabase)nothing
Bloom API server compromisenothingall plaintext (key + blobs reachable)control plane only (see mitigation)control plane only
Credential-backend host compromisen/an/aplaintext (bounded to that host)plaintext (bounded)
CI log leakexposed unless maskedexposed unless maskedmasked by the action; token expires in minutessame
Engine-runner compromisenothingnothing (if never synced there)nothing - runner is not in the pathnothing
Malicious workflow edit in a project repothat repo's secretsthat repo's secretsthat project's secrets at deploy time¹same¹
GitHub account compromiseall repo secretsall repo secretstokens mintable only within OIDC claim scopessame

¹ Inherent to "CI needs plaintext". Mitigated by pinning the OIDC subject claims (repository, workflow, default branch) and per-project scoping, so one compromised repo never reaches another project's credentials.

Build-vs-integrate

Building (B) fails the standards test twice: the encryption key sits in the same process that serves the API (one server compromise = every client's credentials), and we would hand-roll scoping, rotation, audit, and masking - the exact wheel Infisical/OpenBao already are. C (full E2E) fails the product goal (hands-free). F (Doppler) fails self-hosting/data locality.

Between D and E: OpenBao is Vault-shaped - powerful, but operationally heavy for this team (unseal ceremonies, policy language) and has no first-class dashboard we would want to expose. Infisical CE matches every M19 issue natively: a web UI, machine identities with GitHub OIDC (exactly #243's "short-lived scoped exchange"), encryption at rest, audit log, and an API Bloom can drive - on a Postgres+Redis stack we already know how to operate.

Decision

Integrate self-hosted Infisical CE as the credential backend; Bloom orchestrates it.

  • Deployment: Infisical runs as its own service (Docker) beside - but isolated from - the Bloom API (own VM preferred; at minimum its own container + database). Config seam: BLOOM_INFISICAL_URL + a least-privilege service token.
  • Operational topology (owner decision, 2026-07-31): Infisical's Postgres is a dedicated Supabase project - standard DB_CONNECTION_URI, on the direct/session connection (port 5432; Infisical runs schema migrations, which the transaction-mode pooler does not tolerate) and separate from Bloom's own state database so neither leak implicates the other. Its root encryption key stays in the Infisical service environment, never in Supabase, so a database dump reveals ciphertext only (the matrix row above reads "encrypted blobs" for this topology). Redis runs co-located as a container in the same compose file: Infisical uses it only for caching and its job queue - ephemeral data, no credentials at rest - and the queue's constant polling makes remote serverless Redis slower and metered for no benefit. Neither Supabase nor Cloudflare offers a Redis-protocol service (Cloudflare KV/Durable Objects are different APIs); if managed Redis is ever wanted, Upstash (Redis-protocol TCP endpoint) is the designated fallback.
  • Provisioning: when a project's deployment scheme lands, Bloom's API creates the project's Infisical scope and a machine identity whose OIDC claims pin the project repo's deploy workflow on the default branch.
  • Dashboard (#241): the credentials page is Bloom UX backed by a thin Bloom-API proxy to Infisical - values pass through over TLS write-only, are never logged, never persisted in Supabase, and never readable back through Bloom.
  • Least privilege (#242): Bloom's service token gets provisioning permissions but no secrets-read - a Bloom API compromise cannot bulk-read plaintext. #242 becomes configure-and- verify (plus documenting the model), not building crypto.
  • Delivery (#243): scaffolded deploy workflows use Infisical's GitHub Action with OIDC - the repo holds no static secret at all; the short-lived token is minted per run and scoped per project.
  • Failure mode: backend down → deploys fail closed with a clear owner-facing message; nothing falls back to plaintext or long-lived tokens.
  • Parking flow (#269): the awaiting-client message links the dashboard page; "required set complete" is read from Infisical metadata (names only).

As configured and verified (M19-3, 2026-07-31)

The running instance at https://bloom-secrets.exe.xyz matches the design:

  • Encryption at rest: Infisical's root encryption key and auth secret live only in the service's env file on the bloom-secrets VM (mode 600); the database is the dedicated bloom-secrets Supabase project reached over the IPv4 session pooler - a database dump yields ciphertext only.
  • Least-privilege identity: Bloom authenticates as the bloom-orchestrator machine identity (universal auth) whose org role only creates projects; access tokens are minted per exchange with a 3600 s TTL and refreshed transparently by InfisicalClient (_TOKEN_REFRESH_MARGIN_SECONDS before expiry). The long-lived client secret exists only in pass and the deploy-rendered .env.
  • Write-only surface, verified live: the real client ran the full lifecycle against the instance - workspace auto-provisioning, upserts, metadata-only listing (viewSecretValue=false; asserted no value appears in any response), rotate, revoke - and the client exposes no value-returning method at all. Failed logins raise without echoing request material.
  • Invite-only instance: no SMTP is configured on purpose; only the admin can add users.

Security review (M19-5, 2026-07-31)

Reviewed against the checklist this document promised, on the system as built (M19-2/3/4/6):

  • No value-returning surface: verified in code and tests - the client's only reads set viewSecretValue=false, the API returns metadata models, the dashboard renders password inputs that are never repopulated. Exactly two transient plaintext touchpoints exist (the route body and the client's upsert payload), both en route to the store over TLS.
  • Log and error hygiene: every log call on the store-adjacent paths carries status/name only; login failures raise without echoing request material; bandit -ll over integrations/infisical and routes/credentials reports no findings.
  • AuthN/AuthZ: dashboard endpoints sit behind the session cookie + verified-Telegram-link ownership gate; credential names are validated (^[A-Z][A-Z0-9_]{0,63}$) before reaching URLs or CI; Bloom's identity uses universal auth with 3600 s tokens (60 s refresh margin); CI identities are OIDC-bound (boundSubject = repo + default branch, audience = repo owner) with 900 s tokens and viewer-only membership in their own workspace.
  • Audit trail: every API-path secret pull happens as an authenticated identity and lands in Infisical's audit log; there is no unaudited API read path. Out-of-band reads require host or database compromise, which the encryption-at-rest posture covers (ciphertext).
  • Failure-closed: unconfigured store -> writes 503 and parking falls back to explicit manual wording; store outage -> the workflow's fetch step fails the deploy; nothing falls back to plaintext or long-lived tokens.
  • Residual finding (closed by #282): Infisical grants workspace-creator admin membership, so a fully compromised Bloom API could once read plaintext through the RAW store API. Closed: credential METADATA (name -> provided-at) now lives in Bloom's run state (Bloom brokers every save), the store port has no read method at all, and delivery provisioning ends by self-demoting Bloom's membership to a write-only role (secrets create/edit/delete + identity management, no read) - ordered last because a write-only actor cannot grant the viewer role (privilege-escalation guard). The no-read property now holds at the token, not just the client surface.

Incident response & rotation runbook

  • Client credential compromised (a value, e.g. a Cloudflare token): the owner rotates it at the provider and pastes the new value on the dashboard Credentials page; the next deploy picks it up. No repo or pipeline change.
  • Bloom's client secret compromised: revoke it in Infisical (Identities -> bloom-orchestrator -> client secrets), mint a new one, update pass infisical/client-secret and the BLOOM_INFISICAL_CLIENT_SECRET Actions secret, redeploy dev.
  • A project repo compromised: delete that project's machine identity (or its OIDC binding) in Infisical - its pipeline can no longer exchange tokens; other projects are unaffected by construction.
  • Infisical host compromised: treat every stored credential as exposed - rotate all provider credentials via the dashboard after rebuilding the VM (compose + env from pass; database intact in Supabase). The root ENCRYPTION_KEY/AUTH_SECRET live in pass.
  • Database restore: the bloom-secrets Supabase project carries provider backups; restoring it plus the env from pass reconstitutes the store (ciphertext + key).

Consequences for the milestone

  • #241 and #243 build on Infisical's API/Action rather than bespoke storage and delivery.
  • #242 is reframed as configuration, verification, and documentation of the encryption and access model (including the no-secrets-read Bloom token).
  • #244 (security review) audits: claim-pinned OIDC subjects, the service-token permission set, log hygiene (no values in Bloom or CI logs), backup/restore of the Infisical database, and the failure-closed behavior.
  • The M18 interim (hand-set Actions secrets) remains documented as the degraded/manual path.

Sources