This dotfiles system uses a two-layer secrets management approach : Bitwarden for runtime credential retrieval and age for at-rest file encryption.
Security Architecture
Bitwarden CLI Runtime Secrets Retrieves credentials during chezmoi apply
age Encryption At-Rest Protection Encrypts files committed to Git
This dual approach ensures secrets are never stored in plaintext while keeping the workflow simple and automated.
Scenario Tool Reason SSH private keys Bitwarden Generated externally, stored centrally AWS credentials Bitwarden Rotate frequently, per-environment SSH config file age Static configuration, machine-specific Git signing key Bitwarden Shared across machines
Rule of thumb : Use Bitwarden for credentials that change or need central management. Use age for static config files that contain sensitive data.
Bitwarden Integration
Setup
The bootstrap script handles Bitwarden authentication:
# 5. Bitwarden Login & Unlock
if bw status | grep -q '"status":"unauthenticated"' ; then
echo "Logging into Bitwarden..."
bw login
fi
if bw status | grep -q '"status":"locked"' ; then
echo "Unlocking Bitwarden..."
BW_SESSION = $( bw unlock --raw )
export BW_SESSION
bw sync
fi
Login
Authenticates with your Bitwarden account (email/password)
Unlock
Decrypts the vault using your master password
Export Session
Sets BW_SESSION environment variable for subsequent commands
Sync
Downloads latest vault contents from the server
Vault Organization
Organize items with consistent naming:
Secure Notes
Custom Fields
Age Key
Store multi-line secrets like keys: Item Name : ssh-private-key
Type : Secure Note
Notes :-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
...
-----END OPENSSH PRIVATE KEY-----
Store structured key-value data: Item Name : aws-credentials-work
Type : Login
Custom Fields :
access_key_id = AKIAIOSFODNN7EXAMPLE
secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
region = us-east-1
Store the age encryption key: Item Name : chezmoi-age-key
Type : Secure Note
Notes :# created: 2024-01-15T10:30:00Z
# public key: age1abc123...
AGE-SECRET-KEY-1ABC123...
Use exact item names as expected by your templates. Bitwarden queries are case-sensitive.
Template Functions
Chezmoi provides two Bitwarden template functions:
1. bitwarden
Retrieves the entire item as JSON:
{{- $item := bitwarden "item" "ssh-private-key" -}}
{{ $item.notes }}
Use for :
Secure Note contents (.notes)
Login passwords (.login.password)
Login usernames (.login.username)
Example : SSH private key
private_dot_ssh/id_ed25519.tmpl
{{- $sshKey := bitwarden "item" "ssh-private-key" -}}
{{ $sshKey.notes }}
Rendered output:
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmU...
-----END OPENSSH PRIVATE KEY-----
2. bitwardenFields
Retrieves custom fields as a map:
{{- $aws := bitwardenFields "item" "aws-credentials-work" -}}
aws_access_key_id = {{ $aws.access_key_id.value }}
aws_secret_access_key = {{ $aws.secret_access_key.value }}
Use for :
Structured credentials (AWS, database configs)
Multi-value secrets
Environment-specific settings
Example : AWS credentials
{{- $aws := bitwardenFields "item" "aws-credentials-work" -}}
[work]
aws_access_key_id = {{ $aws.access_key_id.value }}
aws_secret_access_key = {{ $aws.secret_access_key.value }}
region = {{ $aws.region.value }}
{{- if eq .machine_type "hybrid" -}}
{{- $awsPersonal := bitwardenFields "item" "aws-credentials-personal" -}}
[personal]
aws_access_key_id = {{ $awsPersonal.access_key_id.value }}
aws_secret_access_key = {{ $awsPersonal.secret_access_key.value }}
region = {{ $awsPersonal.region.value }}
{{- end }}
Bitwarden CLI Configuration
Chezmoi’s Bitwarden integration is configured in .chezmoi.toml.tmpl:
[ bitwarden ]
command = "bw"
unlock = "auto"
command : Path to Bitwarden CLI binary
unlock = "auto" : Automatically prompt for unlock if vault is locked
Common Patterns
Conditional Secrets by Machine Type
{{- if eq .machine_type "work" -}}
{{- $creds := bitwardenFields "item" "work-vpn" -}}
VPN_USER={{ $creds.username.value }}
VPN_PASS={{ $creds.password.value }}
{{- end -}}
private_dot_ssh/config.tmpl
Host github.com-personal
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_personal
Host github.com-work
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_work
private_dot_ssh/id_ed25519_personal.tmpl
{{- (bitwarden "item" "ssh-key-personal").notes -}}
private_dot_ssh/id_ed25519_work.tmpl
{{- (bitwarden "item" "ssh-key-work").notes -}}
{{- $token := "" -}}
{{- if bitwarden "item" "api-token" -}}
{{- $token = (bitwarden "item" "api-token").login.password -}}
{{- end -}}
API_TOKEN={{ $token | default "PLACEHOLDER" }}
Age Encryption
What is age?
age is a simple, modern file encryption tool:
Simple : Single identity key, no key servers
Secure : Based on X25519, ChaCha20-Poly1305, and HKDF
Fast : Written in Go, minimal dependencies
Key Management
The age private key is stored in two places:
Runtime : ~/.config/chezmoi/key.txt (NOT committed to Git)
Backup : Bitwarden Secure Note named chezmoi-age-key
Key Initialization
The bootstrap script retrieves or generates the key:
mkdir -p " $HOME /.config/chezmoi"
if [ ! -f " $HOME /.config/chezmoi/key.txt" ]; then
echo "Checking for age key in Bitwarden..."
if bw get notes "chezmoi-age-key" > " $HOME /.config/chezmoi/key.txt" 2> /dev/null ; then
echo "Successfully retrieved age key from Bitwarden."
else
echo "Could not find 'chezmoi-age-key' in Bitwarden."
echo "Generating a new one instead..."
age-keygen -o " $HOME /.config/chezmoi/key.txt"
echo "IMPORTANT: Save the following content as a Secure Note named 'chezmoi-age-key' in Bitwarden:"
cat " $HOME /.config/chezmoi/key.txt"
fi
fi
chmod 600 " $HOME /.config/chezmoi/key.txt"
Check for Existing Key
Looks for ~/.config/chezmoi/key.txt
Try Bitwarden Retrieval
Attempts to pull chezmoi-age-key from vault
Generate if Missing
Creates new key with age-keygen if not found
Prompt for Backup
Displays key and reminds you to save it in Bitwarden
Set Permissions
Ensures key file is readable only by you (600)
Critical : If you generate a new key, immediately save it as a Secure Note in Bitwarden. If you lose this key, you cannot decrypt your files.
Chezmoi Configuration
encryption = "age"
[ age ]
identity = "~/.config/chezmoi/key.txt"
recipient = {{ output "age-keygen" "-y" (joinPath .chezmoi.homeDir ".config/chezmoi/key.txt") | trim | quote } }
identity : Your private key (for decryption)
recipient : Your public key (for encryption, derived from private key)
Encrypting Files
Add with Encryption
Edit Encrypted File
Manual Encryption
chezmoi add --encrypt ~/.ssh/config
Creates private_dot_ssh/config.age in the source directory. chezmoi edit ~/.ssh/config
Decrypts to temporary file
Opens in configured editor
Re-encrypts on save
# Encrypt
age -r $( age-keygen -y ~/.config/chezmoi/key.txt ) \
-o ~/.local/share/chezmoi/private_dot_ssh/config.age \
~/.ssh/config
# Decrypt
age -d -i ~/.config/chezmoi/key.txt \
~/.local/share/chezmoi/private_dot_ssh/config.age
File Naming
Encrypted files use the .age suffix:
private_dot_ssh/config.age → ~/.ssh/config (decrypted)
private_dot_ssh/known_hosts.age → ~/.ssh/known_hosts (decrypted)
private_dot_aws/credentials.age → ~/.aws/credentials (decrypted)
The .age extension is automatically removed when applying. The private_ prefix sets permissions to 0600.
What to Encrypt
Encrypt with age
SSH config files
Known hosts files
GPG keyrings
Application configs with tokens
Use Bitwarden Instead
SSH private keys
API credentials
Database passwords
OAuth tokens
Security Best Practices
Never commit ~/.config/chezmoi/key.txt to Git
Always backup age key to Bitwarden
Rotate Bitwarden master password regularly
Use strong, unique passwords for Bitwarden
Enable two-factor authentication
Lock vault when not in use (bw lock)
Sync before making changes (bw sync)
Organize with folders (Personal, Work, Shared)
Mark sensitive files with private_ prefix (sets 0600)
Verify after apply: ls -la ~/.ssh/
Never commit unencrypted secrets
Unset BW_SESSION when done: unset BW_SESSION
Lock vault: bw lock
Logout on shared machines: bw logout
Troubleshooting
Bitwarden Issues
Symptom : chezmoi apply fails with “Vault is locked”Solution :BW_SESSION = $( bw unlock --raw )
export BW_SESSION
chezmoi apply
Symptom : Template error about missing itemSolution :# List all items
bw list items | jq '.[].name'
# Search for specific item
bw get item "ssh-private-key"
Verify the item name matches exactly.
Symptom : bw commands fail after some timeSolution :bw unlock --check || BW_SESSION = $( bw unlock --raw )
export BW_SESSION
Age Encryption Issues
Symptom : chezmoi apply fails with decryption errorSolution :# Verify key exists
test -f ~/.config/chezmoi/key.txt && echo "Key found" || echo "Key missing"
# Check permissions
ls -la ~/.config/chezmoi/key.txt # Should be 0600
# Retrieve from Bitwarden
bw get notes "chezmoi-age-key" > ~/.config/chezmoi/key.txt
chmod 600 ~/.config/chezmoi/key.txt
Symptom : Error about recipient mismatchSolution :
Files were encrypted with a different key. Re-encrypt:# Decrypt with old key
age -d -i ~/.config/chezmoi/old-key.txt file.age > file
# Re-add with new key
chezmoi add --encrypt ~/file
Real-World Examples
Complete SSH Setup
private_dot_ssh/id_ed25519.tmpl
private_dot_ssh/id_ed25519.pub.tmpl
private_dot_ssh/config.age
{{- $sshKey := bitwarden "item" "ssh-private-key" -}}
{{ $sshKey.notes }}
Apply with:
chezmoi apply
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub
Multi-Environment AWS Config
{{- if or (eq .machine_type "work" ) (eq .machine_type "hybrid" ) -}}
{{- $awsWork := bitwardenFields "item" "aws-credentials-work" -}}
[work]
aws_access_key_id = {{ $awsWork.access_key_id.value }}
aws_secret_access_key = {{ $awsWork.secret_access_key.value }}
{{- end }}
{{- if or (eq .machine_type "personal" ) (eq .machine_type "hybrid" ) -}}
{{- $awsPersonal := bitwardenFields "item" "aws-credentials-personal" -}}
[personal]
aws_access_key_id = {{ $awsPersonal.access_key_id.value }}
aws_secret_access_key = {{ $awsPersonal.secret_access_key.value }}
{{- end }}
Git Signing Key
run_once_before_import-gpg-key.sh.tmpl
#!/bin/bash
{{- $gpgKey := bitwarden "item" "gpg-signing-key" }}
echo "{{ $gpgKey .notes }}" | gpg --import
gpg --list-secret-keys --keyid-format=long
Security Model Summary
Defense in Depth :
Bitwarden vault protected by master password + 2FA
Age key stored in Bitwarden (not in Git)
Encrypted files use age encryption
Applied files have restrictive permissions
Git repository contains no plaintext secrets
Next Steps
System Architecture Understand how all components interact
Add Secrets Learn to add your own credentials
Bitwarden CLI Docs Official Bitwarden CLI reference
age Documentation age encryption tool documentation