Credential Safety
How madtea stores your Forgejo/Gitea token locally and what the “credential-safe git wrappers” claim actually guarantees. For what scopes to enable on the token itself, see PERMISSIONS.md. For how to report a credential-related vulnerability, see SECURITY.md.
Storage backends
By default madtea auth login stores your token in a secure backend — the OS keychain when one is available, otherwise pass, otherwise systemd-creds on a headless server with systemd. The URL and username always go to git config (they are not secret). When no secure backend is available, madtea refuses to store the token with an error naming the fix — plaintext git config is never an automatic fallback; it happens only with the explicit --insecure-storage flag, which also forces the plaintext path when a keychain is present. The backend is not user-selectable beyond that: auto-detection always tries the OS keychain first, then pass, then systemd-creds.
Default: OS keychain
| Aspect | Detail |
|---|---|
| Backend | KeychainStore (internal/config/keychain.go) wrapping github.com/zalando/go-keyring |
| Linux | org.freedesktop.secrets D-Bus service (GNOME Keyring, KDE Wallet, KeePassXC, etc.) |
| macOS | Keychain Access |
| Windows | Credential Manager |
| Plaintext at rest | No. The OS encrypts the secret. |
Probing the keychain may produce OS audit-log entries (e.g. PolKit / system keyring access logs). Normal.
Fallback backend: pass (when no OS keychain) (password-store.org)
| Aspect | Detail |
|---|---|
| Backend | PassStore (internal/config/pass.go) shelling out to pass insert -e and pass show |
| Entry name | madtea/<host>/<user> |
| Storage | GPG-encrypted file under ~/.password-store/ |
| Plaintext at rest | No. Encrypted with the user’s GPG key. |
| Token on argv | No — pass insert -e reads stdin, pass show prints to madtea’s stdout only. |
Fallback backend: systemd-creds (headless server, no keyring / no pass)
For a headless Linux server that ships with neither an OS keychain nor pass (a fresh Debian 13 / trixie box, for example), madtea uses systemd-creds (systemd ≥ 256) to encrypt the token at rest. This is the OS-native secure-at-rest mechanism on such hosts.
| Aspect | Detail |
|---|---|
| Backend | SystemdCredsStore (internal/config/systemd_creds.go) shelling out to systemd-creds encrypt / systemd-creds decrypt |
| Key policy | --with-key=auto — TPM2-sealed when a TPM is present, host key (/var/lib/systemd/) otherwise. “TPM-sealable”, not TPM-required. |
| Scoping | --user: the credential is scoped to the invoking user, so no root / /var/lib/systemd/credential.secret access is needed. Requires systemd ≥ 256. |
| Credential name | --name=madtea-token, identical on encrypt and decrypt (systemd refuses to decrypt under a different name). |
| Storage | Base64 ciphertext in git config under madtea.token-enc (per-repo or --global). The base64 framing is done in madtea so the stored value is clean single-line base64. |
| Plaintext at rest | No. The blob is encrypted host-bound (TPM-sealed when available) and is useless if copied off the machine. |
| Token on argv | No — the plaintext token is fed to systemd-creds encrypt on stdin, never as an argument. Only the encrypted ciphertext is written to git config. |
Availability is gated by a real encrypt→decrypt round-trip probe (IsSystemdCredsAvailable): on a host with too-old systemd (no --user), a missing binary, or a missing/locked host key the probe returns false and madtea treats the host as having no secure backend rather than recording a backend it cannot decrypt — which means storage is refused unless --insecure-storage explicitly consents to plaintext (see below).
Fallback when no secure backend: plaintext git config
madtea lands here only when you pass --insecure-storage — the flag is the sole consent for a plaintext token (ADR 0013). On a host with no secure backend (no keychain, no pass, no systemd-creds), the no-flag default is a refusal with an actionable error naming the flag, never a warn-and-proceed fallback; the guard sits on every save path (internal/cmd/setup/save.go) and on the interactive prompt before a token is ever created. With the flag, madtea stores the plaintext token and prints a loud warning.
A plaintext token is host-agnostic - unlike a secure-backend entry it is not keyed by forge host. At resolution time it therefore attaches only when the resolved forge host matches a host you have recorded; in a clone of an untrusted repository whose origin names an unrecorded host, resolution refuses rather than send the token there. See Host binding for host-agnostic tokens under the threat model below.
| Aspect | Detail |
|---|---|
| Location (per-repo) | <repo>/.git/config, [madtea] token = … |
Location (--global) | ~/.gitconfig, [madtea] token = … |
| File mode | 0600 (madtea writes the token line directly to enforce this) |
| Visibility on argv | The token never goes through git config <key> <value> argv, so it does not appear in /proc/<pid>/cmdline |
| Plaintext at rest | Yes. The token sits in the gitconfig file as readable text. File mode 0600 limits access to the owning user. |
Migrating between backends
Re-running madtea auth login re-detects the backend, stores the token there, and removes any plaintext token line from the same scope’s git config. The URL and user are kept in git config (not secret).
Pass --insecure-storage to move the token the other way — into plaintext git config. madtea writes the plaintext token and clears the secure-backend marker for that scope; if a global secure setup still applies, the per-repo plaintext token masks it locally. The keychain / pass entry itself is left in place — it is keyed by <host>/<user> and can be shared by any number of repos, so madtea never deletes it on a downgrade. Remove it manually (e.g. with your keychain tool or pass rm) if you no longer want it stored.
The systemd-creds ciphertext lives in git config (madtea.token-enc) rather than an external store. Switching that scope to a different backend leaves the old ciphertext key in place but unread (resolution is gated by the credential-backend marker, which now names the new backend). It is host-bound encrypted data, useless off the machine; remove it with git config --unset madtea.token-enc if you want it gone.
Clone under a secure backend
madtea clone does not re-introduce a plaintext token. When the machine-wide setup uses a secure backend (a madtea.credential-backend marker in your --global git config), the clone records that same marker on the new repo’s local .git/config (alongside the non-secret madtea.url / madtea.user) and writes no madtea.token line. The secure backend keys its entry by <host>/<user>, so a same-host clone resolves the token through the backend with nothing to store locally. Only when the source scope holds a plaintext token — which exists only by prior --insecure-storage consent — does the clone write the plaintext token — exactly as setup would under the same conditions. Either way the token never reaches git’s argv (it is written directly to the gitconfig file).
The git credential store (credential.helper)
madtea auth login also offers to populate git’s own credential store so that raw git (not via madtea) can authenticate against the same host. madtea never silently writes a plaintext copy of the token there under a secure backend:
| Situation | What madtea does |
|---|---|
A credential.helper is already configured | Uses it as-is (git credential approve). |
None configured, but a keychain-aware helper is on PATH (git-credential-libsecret / -osxkeychain / -manager / -manager-core) | Configures that helper, so raw git stores the token in the OS keychain — not plaintext. |
| None configured, none available, but a madtea secure backend is active | Skips the credential-store write and warns. madtea’s own git ops already authenticate via GIT_ASKPASS, so nothing is lost for madtea; raw git will prompt unless you install/configure a keychain-aware helper. |
| None configured, none available, and no secure backend (the plaintext-only case) | Sets credential.helper = store and writes ~/.git-credentials (plaintext), with a loud warning — unchanged from before. |
This means a correctly secure-backend setup leaves the token in the backend only: neither clone nor the git credential store deposits a plaintext copy.
Threat model
madtea’s “credential-safe git wrappers” claim covers four concrete guarantees:
- No token on argv. When a token is written to plaintext git config,
madtea auth login,clone,init, andremote addwritemadtea.tokendirectly to the gitconfig file rather than viagit config <key> <value>, which would briefly expose the token viaps//proc/<pid>/cmdline. The default keychain /passbackends keep the token off argv too (the keychain via the go-keyring API;pass insert -evia stdin). Verified byinternal/config/gitconfig_writer_test.go. - No token in HTTP error messages. API calls route through
formatAPIError/formatAPIRedirectError, which only echo server-provided response bodies andLocationheaders — never the client’sAuthorizationheader value. Verified byinternal/gitea/leak_test.go. - Raw
git push/fetch/ls-remote/remote -v/remote show/remote get-url/config --get remote.*.urlblocked by the bundled Claude Code hook (hooks/scripts/check-git-remote.sh) when the repo’s remote URL has user-info embedded. The hook fires from the agent harness — it’s not a binary-level check. - Authenticated git operations route through
GIT_ASKPASS.madtea push,pull,fetch,clone,finishwrite a temporary askpass script (mode0700) under$TMPDIR, setGIT_ASKPASSto its path, and remove the file before returning (signal handler covers SIGINT/SIGTERM; SIGKILL or kernel panic can leave a file behind). The token never appears on git’s argv or in its environment.
Host binding for host-agnostic tokens
The four guarantees above keep a token you legitimately hold from leaking to a bystander. Host binding keeps it from going to the wrong forge in the first place.
madtea resolves a repository’s forge from its origin remote (origin-first resolution, ADR 0025). In a clone of an untrusted repository the origin can name any forge host. Most token sources are already safe against this: a secure-backend entry is keyed by forge host, so a lookup for a host you never authenticated to returns nothing and the request stays anonymous. Two sources are host-agnostic and carry no forge host of their own:
- the
MADTEA_TOKENenvironment variable, whenMADTEA_URLis not also set; - a plaintext
madtea.tokenin git config (the--insecure-storagepath above).
A host-agnostic token attaches only when the resolved forge host matches a host you have recorded:
- the host of
MADTEA_URL, when it is set; - the host of a
madtea.urlin git config, at repo (--local) or global (--global) scope.
Git config is operator-set and is never copied in by a clone, so a madtea.url there is a trustworthy record of which forge you expect; either recorded source matching is enough. When the resolved host is derived from the origin alone and matches no recorded host, resolution refuses with a teaching error that names the host and its three remedies:
- set
MADTEA_URLto that host alongsideMADTEA_TOKEN; - record
madtea.urlfor that host in git config; - run
madtea auth login(which stores a host-keyed token in a secure backend).
Host-keyed secure backends (keychain, pass, systemd-creds; ADR 0013) are unaffected - they already fail safe for a host you never authenticated to. If you export MADTEA_TOKEN and it is not corroborated, resolution does not hard-fail on it; it falls through to the host-keyed backend, whose entry for the resolved host is itself a recording of that forge. So a machine that authenticated with madtea auth login keeps working even with a stray MADTEA_TOKEN in the environment.
Your own --insecure-storage login keeps working. Because a plaintext token is host-agnostic, madtea auth login --insecure-storage records a madtea.url for the forge it authenticated to, so resolution in your own repo is corroborated. (The default secure login records no madtea.url - its host-keyed backend entry is the record.) A dir= call against a sibling checkout is corroborated by that sibling’s own --local madtea.url, never by its clone-transferred origin.
Headless setups keep working by exporting MADTEA_URL alongside MADTEA_TOKEN. A bare MADTEA_TOKEN with no MADTEA_URL and no recorded madtea.url no longer authenticates against an origin-derived host. The guard lives in internal/config/hostcorroboration.go, locked by the TestHostCorroboration_* tests.
Out of scope
- Shell history. If you
git clone https://user:token@host/repo.gityourself, the URL lands in~/.bash_history. madtea cannot prevent that. Usemadtea clone owner/repo— it constructs the URL internally and authenticates viaGIT_ASKPASS. - Server logs. If your Forgejo/Gitea instance logs full request URLs at
infolevel, your token reaches those logs viaAuthorization: token …on every request. Server-side configuration concern. - Multi-user host attacks beyond what mode
0600and process isolation protect against. Anyone withrootor with the same UID as your shell can read your token regardless of which backend is in use.
Canonical source: docs/safety/credential-safety.md in the madtea repo.