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.

age direnv bash Cloudflare R2 Resend

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-ro identity) decrypts only read-only.age; the read-write source is a silent no-op.
  • A read-write developer (holds the team-rw identity) 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:

1
.envrc.private
optional override, sourced first
2
pick identity
AGE_IDENTITY or default
3
decrypt + source
RO then RW
4
unset scratch
no leftovers
[ -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
Note A failed decrypt (wrong or missing key) yields empty output, so the source is a harmless no-op — the loader silently skips whatever your identity can't open.

Prerequisites

Setup

  1. Obtain your team identity file from whoever provisions keys (RO devs get team-ro, RW devs get team-rw). It is a private key — never commit or share it.
  2. Place it at .credentials/team-rw-identity.txt (the default the loader looks for), or put it anywhere and point AGE_IDENTITY at it.
  3. 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.

Tip Renaming or relocating an .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

Placeholders The keypairs and .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; direnv uses bash regardless of your login shell.
  • After editing .envrc, re-run direnv allow for the change to take effect.
  • See plan.md for the full design rationale.