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