age Encryption
Modern file encryption with age — simple, secure, and composable.
Key Generation
Generate a new age identity (private key) and extract the public key
age-keygen -o ~/.age/identities/personal.key
Output includes the public key as a comment: # public key: age1…. Extract it:
Extract public key from identity file
age-keygen -y ~/.age/identities/personal.key
Create a recipients file — one public key per line
age-keygen -y ~/.age/identities/personal.key > ~/.age/recipients/self.txt
Encrypt & Decrypt
Encrypt a file for a single recipient
age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p -o secret.age secret.txt
Encrypt using a recipients file — supports multiple recipients
age -e -R ~/.age/recipients/self.txt -o secret.age secret.txt
Decrypt with an identity file
age -d -i ~/.age/identities/personal.key secret.age > secret.txt
Decrypt with multiple identity files (age tries each)
age -d -i ~/.age/identities/personal.key -i ~/.age/identities/work.key secret.age
Multiple Recipients
Encrypt for multiple recipients — each can decrypt independently
age -r age1abc...recipient1 -r age1def...recipient2 -o shared.age document.txt
Combine recipients files — team encryption
cat ~/.age/recipients/self.txt ~/.age/recipients/team.txt > /tmp/all-recipients.txt
age -e -R /tmp/all-recipients.txt -o shared.age document.txt
Stdin Pipes — Streaming Encryption
Encrypt from stdin — pipe sensitive output directly
gopass show infra/vault-token | age -e -R ~/.age/recipients/self.txt -o token.age
Decrypt to stdout — pipe into consumer without touching disk
age -d -i ~/.age/identities/personal.key token.age | vault login -
Encrypt a tarball on the fly — never write unencrypted archive
tar czf - ~/sensitive-dir/ | age -e -R ~/.age/recipients/self.txt -o backup.tar.gz.age
Decrypt and extract in one pipeline
age -d -i ~/.age/identities/personal.key backup.tar.gz.age | tar xzf - -C /tmp/restore/
SSH Config Encryption Pattern
The dotfiles workflow: plaintext is gitignored, only .age is tracked.
Re-encrypt after editing SSH config
age -e -R ~/.age/recipients/self.txt -o ssh/.ssh/config.age ssh/.ssh/config
Decrypt on a new machine
age -d -i ~/.age/identities/personal.key ssh/.ssh/config.age > ssh/.ssh/config
chmod 600 ssh/.ssh/config
Script Integration
Conditional decrypt — only if identity exists
if [[ -f ~/.age/identities/personal.key ]]; then
age -d -i ~/.age/identities/personal.key secrets.age > /tmp/secrets.env
source /tmp/secrets.env
rm /tmp/secrets.env
fi
Batch encrypt all files in a directory
for f in ~/secrets/*.txt; do
age -e -R ~/.age/recipients/self.txt -o "${f%.txt}.age" "$f"
done
Batch encrypt specific files — brace expansion
# Encrypt multiple known files in one loop
# Brace expansion generates the file list, for loop encrypts each
for f in data/d001/investigations/2026-04-16-{murus-portae.adoc,murus-portae-ops.sh,fmc-rest-api-reference.adoc}; do
age -e -R ~/.age/recipients/self.txt -o "${f}.age" "$f" && echo "Encrypted: $f"
done
# Same pattern for any set of files sharing a prefix
for f in configs/{sshd,nginx,vault}.conf; do
age -e -R ~/.age/recipients/self.txt -o "${f}.age" "$f"
done
Batch decrypt multiple files — brace expansion
# Decrypt multiple related files in one loop
BASE="data/d001/projects/mschapv2-migration"
for f in "$BASE"/{auth-protocol,ers-endpoint,dataconnect-auth}-report-2026-04-17.adoc.age; do
decrypt-file "$f"
done
Re-encrypt updated files — rm old .age then encrypt (brace expansion)
# When you've edited plaintext and need to replace the .age
for f in data/d001/projects/<slug>/{file1,file2}-YYYY-MM-DD.adoc; do
rm -f "${f}.age" && echo y | encrypt-file "$f"
done
# Example: re-encrypt two mandiant files
for f in data/d001/projects/mandiant-remediation/{guest-acl-update,findings-status}-2026-04-16.adoc; do
rm -f "${f}.age" && echo y | encrypt-file "$f"
done
Batch encrypt with auto-remove — find + xargs + echo y
# Encrypt all .adoc files (excluding READMEs), auto-confirm plaintext removal
find data/d001/projects -name '*.adoc' ! -name 'README.adoc' -print0 \
| xargs -0 -I{} sh -c 'echo y | encrypt-file "{}"'
# Without auto-remove (interactive prompt defaults to N, plaintexts kept)
find data/d001/projects -name '*.adoc' ! -name 'README.adoc' -print0 \
| xargs -0 -I{} sh -c 'encrypt-file "{}" && echo "✓ {}"'
# Clean up plaintexts separately if needed
find data/d001/projects -name '*.adoc' ! -name 'README.adoc' -delete
Batch encrypt mixed file types — find with multiple predicates
# Encrypt all .csv and .txt files, excluding README
find data/d001/ise-analytics -name '*.csv' -o -name '*.txt' ! -name 'README.adoc' \
| xargs -I{} sh -c 'echo y | encrypt-file "{}"'
# General pattern: multiple extensions in one pass
find data/d001/projects/<slug> \( -name '*.csv' -o -name '*.json' -o -name '*.xlsx' \) \
| xargs -I{} sh -c 'echo y | encrypt-file "{}"'
Re-encrypt only modified files — git diff + xargs
# Only re-encrypt .adoc files you actually changed
git diff --name-only | grep '\.adoc$' | \
xargs -I{} sh -c 'age -e -R ~/.age/recipients/self.txt -o "{}.age" "{}" && echo "Encrypted: {}"'
Verify an encrypted file can be decrypted (test without writing output)
age -d -i ~/.age/identities/personal.key secret.age > /dev/null && echo "OK" || echo "FAIL"
Passphrase Encryption (No Keys)
Encrypt with a passphrase — interactive prompt
age -p -o secret.age secret.txt
Decrypt passphrase-encrypted file
age -d secret.age > secret.txt
| Passphrase mode uses scrypt. Slower than public key mode by design. Use key-based encryption for automation; passphrase mode for one-off human-accessible secrets. |
Scrub Plaintext from Git History
When plaintext was committed before encryption, remove it from all history.
Install git-filter-repo
uv tool install git-filter-repo
Remove specific files from all commits (one line — no line breaks)
git filter-repo --invert-paths --path path/to/secret1.csv --path path/to/secret2.txt --force
filter-repo removes the origin remote. Re-add and force push after.
|
Re-add remote and force push (all remotes)
git remote add origin git@github.com:<user>/<repo>.git
git push --force --set-upstream origin main
If you have multiple remotes, force push to all
git push --force gitea main && git push --force gitlab main
| Force push rewrites history for all collaborators. Only do this on repos you control. Coordinate with anyone else pulling from these remotes. |
Directory Layout Convention
~/.age/
├── identities/ # Private keys — NEVER commit, chmod 600
│ ├── personal.key
│ └── work.key
└── recipients/ # Public keys — safe to commit, share
├── self.txt
└── team.txt