ADR 0013 — Credential storage: secure-backend-preferred, scope-consistent, never on argv

  • Status: Accepted
  • Date: 2026-07-01
  • Tracking: legacy tracker 1726, legacy tracker 1753, legacy tracker 1765; legacy tracker 1718 / ADR 0009

Context

madtea authenticates to a forge with a token; where and how it is stored sets the blast radius of a leak. Several incidents shaped the model: plaintext tokens written even under a secure backend (legacy tracker 1726), a token exposed by git config --get-regexp (legacy tracker 1753 / legacy tracker 1718), and a token read from the wrong git-config scope so the wrong ciphertext was decrypted (legacy tracker 1765).

Decision

Prefer an OS secure backend, store and read at a consistent scope, and never put a credential on a command line.

  • Backend precedence, chosen at setup: keychain (macOS / libsecret) → passsystemd-creds → plaintext. The first three keep the token out of git config (keychain/pass in the OS store; systemd-creds as host/TPM-bound ciphertext in madtea.token-enc). Plaintext madtea.token is written only when no secure backend is available or --insecure-storage is passed — a consented fallback with a loud warning, never silently written alongside a secure backend (legacy tracker 1726).
  • Scope consistency: a credential is read from the same git-config scope it was written to. SystemdCredsStore reads token-enc starting at the scope of the resolved madtea.credential-backend marker (local beats global) rather than git’s merged view, so a stray per-repo token-enc cannot shadow a global one and cause the wrong token to be decrypted (legacy tracker 1765). Because madtea clone records the marker per-repo without copying the ciphertext, a local read that finds no ciphertext falls back to the global scope — the scope the credential was actually written to (legacy tracker 1810). A local ciphertext, when present, still wins. Store and Delete remain strictly single-scope.
  • Never credential-shaped on argv. Tokens are written through an on-disk writer and handed to git via GIT_ASKPASS, never as a git config <key> <value> argument or any command-line flag — so a token never appears in ps / /proc/<pid>/cmdline or shell history. The agent’s read surface is additionally guarded by the non-overridable credential-read hook (ADR 0009): git config --get-regexp / --list / credential --get are blocked so a token cannot land in a transcript.

Consequences

  • Exposure of a secure-backend value is bounded. token-enc is host/TPM-bound ciphertext — useless off-host and to anyone below the same UID/root, which SECURITY.md places out of scope (legacy tracker 1753). The genuinely sensitive value is the plaintext fallback, and that is a documented, consented choice.
  • Scope drift is a correctness and security concern (wrong token = auth failure, or decrypting a stale/foreign credential); scope-consistency closes that class (legacy tracker 1765).
  • Complements ADR 0009. That ADR guards the agent’s read surface; this one records the storage model. Together: don’t store it in the clear, don’t put it on argv, don’t let the agent dump it.

Alternatives considered

  • Plaintext madtea.token always (simplest). Rejected: a readable token in ~/.gitconfig is the largest blast radius; secure backends exist to avoid it.
  • Merged-view credential reads (find the token wherever it lives). Rejected (legacy tracker 1765): convenient, but a local value shadows the intended global one; scope-consistency is required for correctness.
  • Encrypt the plaintext fallback too. Out of scope: the fallback exists precisely for hosts with no secure backend / no TPM, so there is no host-bound key to encrypt with — it is a consented, warned plaintext store.

Decided 2026-07-10 (“NO PLAINTEXT FALLBACK WITHOUT –insecure-storage”, declared a HARD RULE): the automatic plaintext fallback when no secure backend is available is removed. Consent is the --insecure-storage flag, never the absence of alternatives:

  • With no OS keychain, no pass, and no systemd-creds, auth login/setup refuses with an actionable error (install a backend, or pass --insecure-storage) — interactively it refuses before walking the user through token creation; saveConfig carries the same guard for every non-interactive path.
  • --insecure-storage remains the sole plaintext path, with the same loud warning as before.
  • The body text above describing the no-backend case as “a consented fallback with a loud warning” is superseded by this amendment; the “Encrypt the plaintext fallback too” alternative note now applies only to the --insecure-storage path. Locked by TestSaveConfig_NoBackendRefusesPlaintext and TestPromptToken_NoBackendRefuses.

Canonical source: docs/adr/0013-credential-storage-model.md in the madtea repo.