credentials
Committed, encrypted credentials for the BigConfig package. Secrets
live in git as age-encrypted files and are decrypted into
your shell automatically on direnv load — using a private
identity file you keep locally and never commit. No plaintext ever
touches disk.
What it is
This leaf flips the usual "never commit secrets" rule: it commits
them, but encrypted. The whole mechanism is
.envrc (a ~20-line bash loader at the project root)
plus everything else under .credentials/ — no
secret-manager service, no per-developer key sprawl, no "ping me for
the .env" on day one.
🔑 Asymmetric, by recipient
Each file is encrypted to one or more age public
keys. Holding the matching private identity is the only way to
read it.
🚫 Zero plaintext on disk
Decryption streams through process substitution straight into
source — no mktemp, no TTY, nothing
written.
⚡ Auto-loaded on cd
direnv runs .envrc the moment you
enter the directory and unsets the vars when you leave.
👥 Two shared team keypairs
team-ro and team-rw are shared across
the team, not minted per developer.
Two-tier access
Two shared keypairs gate access. Both encrypted files export the same variable names; only the values differ by permission level.
| File | Encrypted to | Holds |
|---|---|---|
.credentials/read-only.age |
team-ro team-rw | limited-scope credentials |
.credentials/read-write.age |
team-rw only | full-scope credentials |
Because .envrc sources the read-only file first and the
read-write file second:
-
A read-only developer (holds the
team-roidentity) decrypts onlyread-only.age; the read-writesourceis a silent no-op. -
A read-write developer (holds the
team-rwidentity) decrypts both, and the read-write values win.
The secret set spans three providers:
# Cloudflare
export CLOUDFLARE_API_TOKEN=… # RO: Zone.Read | RW: edit scope
export CLOUDFLARE_ACCOUNT_ID=…
# R2 (S3-compatible)
export R2_ACCESS_KEY_ID=… # RO: read-only | RW: read-write
export R2_SECRET_ACCESS_KEY=…
export R2_ACCOUNT_ID=…
# Resend
export RESEND_API_KEY=… # RO: sending-only | RW: full-access
Load sequence
What .envrc does, in order, every time
direnv enters the directory:
[ -f .envrc.private ] && source .envrc.private
ident="${AGE_IDENTITY:-.credentials/team-rw-identity.txt}"
if command -v age >/dev/null 2>&1 && [ -f "$ident" ]; then
# read-only first, read-write last: when both decrypt, RW values win.
for f in .credentials/read-only.age .credentials/read-write.age; do
[ -f "$f" ] || continue
if plain="$(age -d -i "$ident" "$f" 2>/dev/null)" && [ -n "$plain" ]; then
source <(printf '%s' "$plain")
fi
done
unset plain f
fi
source is a harmless
no-op — the loader silently skips whatever your identity can't open.
Prerequisites
-
age—brew install age -
direnv—brew install direnv, then hook it into your shell
Setup
-
Obtain your team identity file from whoever provisions keys (RO
devs get
team-ro, RW devs getteam-rw). It is a private key — never commit or share it. -
Place it at
.credentials/team-rw-identity.txt(the default the loader looks for), or put it anywhere and pointAGE_IDENTITYat it. - Allow direnv in this directory:
direnv allow
Your secrets are now exported whenever you cd into this
directory.
Using an identity outside the project
Create a gitignored .envrc.private that exports the
override:
# .envrc.private (never committed)
export AGE_IDENTITY="$HOME/.config/bigconfig/age-identity.txt"
Editing a secret
Never edit .age files directly. Decrypt to a gitignored
scratch file, edit the plaintext, re-seal, then delete the scratch:
# 1. decrypt to a gitignored *.plain scratch file
age -d -i .credentials/team-rw-identity.txt .credentials/read-write.age > .credentials/read-write.plain
# 2. edit .credentials/read-write.plain …
# 3. re-seal (rw -> team-rw only; ro -> team-ro + team-rw)
.credentials/seal .credentials/read-write.plain rw
# 4. remove the plaintext
rm .credentials/read-write.plain
seal reads the public recipient keys from
.credentials/recipients.txt and picks both the output
file and the recipient set from the ro/rw
argument. After re-sealing, run direnv reload (or
re-enter the directory) to pick up the new values.
.age file needs no re-seal — age
payloads are path-independent.
File layout
credentials/
├── .envrc # loader — stays at root, references .credentials/
├── .gitignore # stays at root
└── .credentials/
├── read-only.age # committed, encrypted (team-ro + team-rw)
├── read-write.age # committed, encrypted (team-rw only)
├── recipients.txt # committed, public keys
├── seal # the re-encryption script
├── team-ro-identity.txt # gitignored private key
├── team-rw-identity.txt # gitignored private key (default identity)
└── *.plain # gitignored scratch plaintext (transient)
Committed vs local
| Committed | Local only (gitignored) |
|---|---|
.envrc,
.credentials/{read-only,read-write}.age,
.credentials/recipients.txt,
.credentials/seal
|
.envrc.private, *identity* (private
keys), *.plain (scratch)
|
recipients.txt holds public keys only
and is safe to commit. The .gitignore globs
*.plain and *identity* are unanchored, so
they keep private keys and scratch plaintext out wherever they sit.
Provisioning keys
For maintainers — generate the shared keypairs and record their public halves:
age-keygen -o .credentials/team-ro-identity.txt # record public key as `team-ro` in recipients.txt
age-keygen -o .credentials/team-rw-identity.txt # record public key as `team-rw`
Distribute identity files out of band: RO devs get
team-ro; RW devs get team-rw (which
already opens the RO file too).
Caveats
.age payloads currently in the repo are
dev/test placeholders — regenerate them and re-seal
with real secrets before any real use.
-
source <(…)requires bash;direnvuses bash regardless of your login shell. -
After editing
.envrc, re-rundirenv allowfor the change to take effect. - See
plan.mdfor the full design rationale.