encrypting shell secrets

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.