CMA is designed to:
- keep account credentials encrypted at rest
- avoid secret leakage in normal output
- apply strict filesystem permissions
- make every mutation crash-safe with rollback
- AEAD:
XChaCha20-Poly1305 - envelope version:
cma-envelope-v2(readscma-envelope-v1) - key size: 32 bytes
From cma-envelope-v2, the fields that travel in plaintext beside the
ciphertext are bound into the AEAD as additional authenticated data: envelope
version, creation time, AEAD name and nonce, the KDF block, and the metadata
map. For a vault entry that map carries the account ID, source, and
fingerprint.
Editing any of those, for example relabelling an entry to another account or
rewriting its fingerprint, now fails to decrypt. Under cma-envelope-v1 the
ciphertext was authenticated but everything around it was freely rewritable.
cma-envelope-v1 files are still read, sealed as they were with no additional
data, so existing vaults and backups keep working. Any write re-seals the
entry as v2, so a vault migrates the first time it is modified.
Migration is one way. Once entries have been written as v2, a CMA older than
the version that introduced it cannot decrypt them and reports unsupported envelope version. Downgrading therefore needs a backup taken with the older
version, restored into a fresh vault by that older version.
Derived keys, vault keys, and passphrase buffers are zeroed once the operation that needed them completes, and the passphrase prompt reads into a byte slice rather than a string so the bytes can be wiped at all.
This is best effort, not a guarantee. Go's garbage collector may copy or move a
buffer before it is zeroed, so a core dump or a /proc/<pid>/mem read may still
catch key material.
- KDF:
Argon2id - AEAD:
XChaCha20-Poly1305 - backup format version:
cma-backup-v1
Vault key source order:
- OS keyring (when available and not disabled)
- local fallback key file (
vault.key.v1) with strict permissions
CMA enforces:
- directories:
0700 - files:
0600
This includes state, vault, key, lock, and backup targets.
Mutating operations use:
- lock acquisition (
gofrs/flock) - in-memory mutation planning
- atomic write (temp file,
fsync, rename) - post-write verification
- rollback on failure
State and vault are written in the order that makes a crash between them survivable: vault first when adding an entry, state first when removing one, so the residue is always an inert vault entry rather than a state pointer to a row that no longer exists.
Reads take a shared lock and mutations an exclusive one, so a reader can never
observe state.json and the vault from opposite sides of a commit.
Token refresh additionally takes a per-account lock held across the whole read-refresh-persist sequence. A refresh token is single-use, so this stops two processes from spending the same one, which would otherwise either fail a healthy account or persist a superseded token. The lock is per-account, so different accounts still refresh in parallel.
State and vault writes are validated after commit. Activation writes are also validated by auth fingerprint.
Normal command output avoids printing:
- access tokens
- refresh tokens
- ID tokens
- passphrases
- decrypted auth payloads
- derived keys
Tests include leak checks for command output and plaintext scans for vault/backup artifacts.
- primary auth store:
${CODEX_HOME:-~/.codex}/auth.json - optional keyring-backed auth path when configured and available
- activation verifies post-write fingerprint and restores prior auth on mismatch
Usage checks may refresh tokens through OAuth. Refreshed values are persisted through the same lock and atomic commit path used by other mutations. Refresh failures do not print token material and do not block best-effort usage fallback.