I was cleaning up my .zshrc and scrolled past a block of export lines I'd stopped
seeing years ago. A couple of API keys, a Postgres URL with the password sitting right
in it, and an AWS pair I'm fairly sure belongs to an account I closed.
That was fine back when the only thing reading that file was me. It's less fine now that I've got an agent in my editor that can read anything in the workspace, and a habit of pasting terminal output into a chat window when something breaks. So I encrypted it. Took an afternoon and it's been invisible ever since.
Pull the secrets out of .zshrc
Everything sensitive moves into its own file. Path tweaks, aliases, and prompt config
stay behind, so .zshrc goes back to being safe to screenshot.
# ~/.secrets.env
export ANTHROPIC_API_KEY="sk-ant-..."
export GITHUB_TOKEN="ghp_..."
export DATABASE_URL="postgres://user:pass@host/db"
Pick something to lock it with
You need a passphrase that's on this machine and nowhere else. On a Mac the hardware UUID works, and it has the nice property that a copy of your encrypted file is useless on any other computer:
system_profiler SPHardwareDataType | awk '/Hardware UUID/ {print $3}'
I went with a generated UUID in a locked-down file instead, mostly so I can rotate it without it being tied to hardware I might replace:
mkdir -p ~/.config/shell-secrets && chmod 700 ~/.config/shell-secrets
uuidgen > ~/.config/shell-secrets/binding.key
chmod 400 ~/.config/shell-secrets/binding.key
Encrypt it
brew install gnupg if you don't have it, then encrypt symmetrically:
BINDING=$(cat ~/.config/shell-secrets/binding.key)
gpg --batch --yes --passphrase "$BINDING" \
--symmetric --cipher-algo AES256 \
--output ~/.secrets.env.gpg ~/.secrets.env
Check that it decrypts before you delete anything. I say that as someone who does not always check things before deleting them.
gpg --batch --yes --passphrase "$BINDING" --decrypt ~/.secrets.env.gpg
If your exports print back, shred the plaintext. macOS doesn't ship shred, so it's
rm -P here and shred -u on Linux.
rm -P ~/.secrets.env
Decrypt it on shell startup
This is the whole trick. Save it as ~/.config/shell-secrets/load.sh:
#!/usr/bin/env bash
set -euo pipefail
BINDING="$(cat "$HOME/.config/shell-secrets/binding.key")"
PLAINTEXT="$(gpg --batch --quiet --passphrase "$BINDING" --decrypt "$HOME/.secrets.env.gpg")"
eval "$PLAINTEXT"
unset PLAINTEXT BINDING
My first version wrote the decrypted file to /dev/shm and sourced it from there, which
was dumb. That's a window where the plaintext exists for anything else to read. Capturing
stdout and evaling it keeps the whole thing in this shell's memory.
chmod 700 it, then one line at the bottom of .zshrc:
[[ -f "$HOME/.config/shell-secrets/load.sh" ]] && source "$HOME/.config/shell-secrets/load.sh"
Open a new terminal. Your env vars are there, and ls ~/.secrets.env comes back with
nothing.
Editing them later
Decrypt to a temp file, edit, re-encrypt, shred. Wrap it in a function called
edit-secrets and you'll never think about it again.
gpg --batch --quiet --passphrase "$BINDING" --decrypt ~/.secrets.env.gpg > /tmp/secrets.tmp
$EDITOR /tmp/secrets.tmp
gpg --batch --yes --passphrase "$BINDING" --symmetric --cipher-algo AES256 \
--output ~/.secrets.env.gpg /tmp/secrets.tmp
rm -P /tmp/secrets.tmp
What this doesn't do
Anything running as me can read the binding key and decrypt the file, so this is a
defense against passive exposure (an agent reading my dotfiles, a bad paste, a
screenshare), not against something already executing as my user. And if you've given an
agent shell access, it can just run env and see all of it anyway. That's a separate
problem with a separate fix.
None of this is clever. PGP is thirty years old and the script is fifteen lines. I'd just stopped noticing the pile of keys in my home directory until something else started reading it.