Skip to content

Runbook: rotate a secret or the age key

Two distinct scenarios. Pick the right one.

Lifecycle

stateDiagram-v2
    [*] --> SecretValid
    SecretValid --> SingleRotation: AWS/PAT/etc. compromised
    SingleRotation --> SecretValid: edit ~/.zshrc.local<br/>chezmoi add --encrypt<br/>commit
    SecretValid --> KeyRotation: age key compromised
    KeyRotation --> NewKey: age-keygen -o key.txt.new
    NewKey --> DecryptedBlobs: age -d -i old/key.txt
    DecryptedBlobs --> RecipientUpdated: edit .chezmoi.toml.tmpl
    RecipientUpdated --> ReEncryptedBlobs: age -r new -o ...
    ReEncryptedBlobs --> KeysSwapped: mv key.txt.new key.txt
    KeysSwapped --> Verified: chezmoi diff (silent)
    Verified --> Distributed: copy to every machine
    Distributed --> SecretValid: commit + rm old key
    SecretValid --> [*]

Rotate a single secret (e.g. AWS key, GitHub PAT)

The plaintext secret lives in a file in $HOME (typically ~/.zshrc.local). The repo only ever holds the encrypted blob.

# 1. Edit the plaintext file in $HOME.
$EDITOR ~/.zshrc.local

# 2. Re-encrypt and update the source state.
chezmoi add --encrypt ~/.zshrc.local

# 3. Confirm only the encrypted blob changed.
chezmoi diff
git -C ~/.local/share/chezmoi status

# 4. Confirm no plaintext landed in git.
git -C ~/.local/share/chezmoi diff --stat

# 5. Commit.
chezmoi cd
git add encrypted_private_dot_zshrc.local.age
git commit -m "chore(secrets): rotate AWS key"

If git diff --stat shows any non-.age file containing a credential, stop. Run git restore --staged <file> and figure out where the plaintext came from before continuing.

Rotate BOT_PAT (the CI bot token)

BOT_PAT is a GitHub Actions secret, not an age blob — nothing in this repo holds it and chezmoi plays no part. Five workflows read it, each falling back to GITHUB_TOKEN when it is unset: auto-rebase, update-externals, update-mas, update-precommit, update-vscode. It exists so bot-authored PRs trigger CI; a GITHUB_TOKEN-authored PR would not (GitHub's anti-recursion guard).

# 1. Create a fine-grained PAT scoped to this repository, with
#    Contents: read and write, and Pull requests: read and write.
# 2. Store it (paste at the prompt):
gh secret set BOT_PAT --repo edjchapman/dotfiles

# 3. Confirm a previously failing workflow now passes.
gh workflow run auto-rebase.yml

Record the expiry date somewhere you will see it. A dead BOT_PAT is not obvious: auto-rebase fails immediately because the token sits on its checkout step, but the four update-* workflows only touch it when they actually have something to raise a PR for — so they report green while their Create draft PR step is skipped. That combination hid a revoked PAT from 2026-06-23 to 2026-08-14. Every workflow's first step now validates the token and fails with an explicit message naming the secret and the remedy.

To retire the PAT instead, delete the secret — each workflow falls back to GITHUB_TOKEN, at the cost of bot PRs no longer auto-triggering CI.

Rotate the age key itself

Destructive — back up first

This rewrites every *.age blob in the repo. Confirm the new key is backed up to your password manager before proceeding. If anything fails after step 5 (key swap), recovery requires the new key.

This is a much bigger operation. The age key decrypts every .age file in the repo, so rotating it requires re-encrypting all of them.

1. Generate a new key on a trusted machine

age-keygen -o ~/.config/chezmoi/key.txt.new

Note the new public recipient (it's printed to stdout, also commented at the top of the new key file).

2. Decrypt every existing blob with the old key

chezmoi cd
for f in $(find . -name '*.age'); do
    age -d -i ~/.config/chezmoi/key.txt "$f" > "${f%.age}.plain"
done

3. Update .chezmoi.toml.tmpl with the new recipient

Replace the recipient = "age1..." line with the new public key.

4. Re-encrypt every blob with the new key

new_recipient="age1...your-new-recipient..."
for f in $(find . -name '*.age'); do
    plain="${f%.age}.plain"
    age -r "$new_recipient" -o "$f" "$plain"
    rm -f "$plain"
done

5. Swap keys and verify

mv ~/.config/chezmoi/key.txt ~/.config/chezmoi/key.txt.old
mv ~/.config/chezmoi/key.txt.new ~/.config/chezmoi/key.txt
chmod 600 ~/.config/chezmoi/key.txt
chezmoi diff   # should be silent — re-encrypted blobs decrypt to the same plaintext
chezmoi verify

6. Distribute the new key

Transfer ~/.config/chezmoi/key.txt to every machine that needs it (AirDrop, USB, password manager). See Back up the age key below for the canonical backup procedure.

After every machine has the new key, delete the old one: rm ~/.config/chezmoi/key.txt.old. On macOS APFS, plain rm is genuinely unrecoverable — TRIM on the SSD does what shred used to do on spinning disks. Also delete any copies of the old key from your password manager / backups.

7. Commit

git add .chezmoi.toml.tmpl encrypted_private_dot_zshrc.local.age dot_aws/encrypted_private_config.age
git commit -m "chore(secrets): rotate age recipient key"

The old recipient is now public history — that's fine. Only the new private key matters for decryption.

Back up the age key

The age private key at ~/.config/chezmoi/key.txt is a one-of-one failure mode: if it's only on this Mac and the disk dies, you lose decryption access to every *.age blob in the repo. The underlying secrets (AWS keys, PATs) are mostly re-issuable upstream, but bootstrap day is annoying. Back the key up after first-time generation, and again after every rotation.

What to back up

The complete contents of ~/.config/chezmoi/key.txt — all three lines (# created: …, # public key: …, AGE-SECRET-KEY-1…). Save the public recipient string alongside (it's the # public key: line and matches what's in .chezmoi.toml.tmpl). The recipient is public-by-design but having it next to the private key makes recovery verification cheap.

Strategies (pick one — multiple is better)

Strategy Trust boundary Online? Friction (recovery) Single point of failure
Password manager (recommended) Master credential + 2FA Yes Low — open vault, copy-paste Vault provider
Passphrase-encrypted blob in cloud Cloud account + age passphrase Yes Medium — download + age -d -p Forgotten passphrase
Hardware USB (encrypted) Physical access No Medium — locate USB, mount, copy USB lost/damaged
Paper print (fireproof safe) Physical access No High — type ~200 bytes by hand Fire/water; misfiled

Details:

  1. Password manager (recommended) — 1Password / Dashlane / Bitwarden / iCloud Keychain all support free-form secure notes. Create one titled chezmoi age private key (Mac primary), paste in the file contents, tag it infrastructure.
  2. Passphrase-encrypted blob in any cloudage -p ~/.config/chezmoi/key.txt > ~/iCloud\ Drive/chezmoi-key.txt.age prompts for a passphrase; the resulting blob is decryptable only with that passphrase, so the cloud provider sees only ciphertext. Higher friction than a password manager; smaller single-point-of-failure surface.
  3. Hardware USB stored physically — copy key.txt to an encrypted USB stick (e.g. APFS-encrypted), put it somewhere durable. No online dependency. Friction: needs physical access on recovery.
  4. Paper print — the key file is under 200 bytes; one printed page in a fireproof safe is a real, audit-friendly fallback. Combines well with a hardware token guarding the password manager.

Recovery on a new (or wiped) Mac

mkdir -p ~/.config/chezmoi
chmod 700 ~/.config/chezmoi
$EDITOR ~/.config/chezmoi/key.txt          # paste contents from your backup
chmod 600 ~/.config/chezmoi/key.txt

# Sanity check: derive the public key from the file and compare to the
# recipient in .chezmoi.toml.tmpl. They must match.
age-keygen -y ~/.config/chezmoi/key.txt

# Bootstrap chezmoi against the public repo and apply.
chezmoi init --apply edjchapman/dotfiles

If age-keygen -y outputs a recipient that doesn't match the one in .chezmoi.toml.tmpl, you've restored an old key from before the last rotation. Pull the current key from backup instead — chezmoi will report age: no identity matched any of the recipients if you proceed with a mismatched key.

What NOT to back up the key to

  • The dotfiles git repo itself (in any form, encrypted or not) — circular dependency: you'd need the key to decrypt the key.
  • Any sync target that also mirrors ~/.config/chezmoi/ to a shared cloud account someone else can access (e.g. a family iCloud, a work Google Drive that admins can subpoena).
  • A non-encrypted USB stick.
  • Email or chat with cleartext attachments.

See also