Complete SOPS secrets management setup (#5)

Completed Issue #5: SOPS Secrets Management

All objectives met:
-  Age encryption key generated (keys/age-key.txt)
-  SOPS configured with Age backend (.sops.yaml)
-  Secrets directory structure created
-  Example encrypted secrets (shared + test client)
-  Comprehensive documentation for key backup
-  Ready for Ansible integration

Security measures:
- Age private key gitignored (keys/age-key.txt)
- Only encrypted .sops.yaml files committed
- Plaintext secrets explicitly excluded
- Key backup procedures documented

Files added:
- .sops.yaml - SOPS configuration with Age public key
- secrets/shared.sops.yaml - Shared secrets (encrypted)
- secrets/clients/test.sops.yaml - Test client secrets (encrypted)
- secrets/README.md - Complete SOPS usage guide
- keys/README.md - Key backup procedures
- keys/.gitignore - Protects private keys

Updated:
- .gitignore - Allow .sops.yaml, block plaintext

Tested:
- Encryption:  Files encrypted successfully
- Decryption:  Secrets decrypt correctly
- Git safety:  Private key excluded from commits

Next: Ready for Zitadel/Nextcloud deployment with secure credentials

Closes #5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pieter 2025-12-27 14:23:36 +01:00
parent 4e72ddf4ef
commit 6bc8e508c6
7 changed files with 358 additions and 1 deletions

3
.gitignore vendored
View file

@ -1,6 +1,7 @@
# Secrets - NEVER commit these # Secrets - NEVER commit plaintext, only encrypted .sops.yaml files
secrets/**/*.yaml secrets/**/*.yaml
secrets/**/*.yml secrets/**/*.yml
!secrets/**/*.sops.yaml
!secrets/.sops.yaml !secrets/.sops.yaml
keys/age-key.txt keys/age-key.txt
*.key *.key

7
.sops.yaml Normal file
View file

@ -0,0 +1,7 @@
# SOPS Configuration
# Defines encryption keys and rules for secret files
creation_rules:
# All files in secrets/ directory
- path_regex: secrets/.*\.sops\.yaml$
age: age170jqy5pg6z62kevadqyxxekw8ryf3e394zaquw0nhs9ae3v9wd6qq2hxnk

6
keys/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# NEVER commit Age private keys
*.txt
*.key
# Only allow README
!README.md

52
keys/README.md Normal file
View file

@ -0,0 +1,52 @@
# Age Encryption Keys
⚠️ **CRITICAL**: This directory contains encryption keys that are **NOT committed to Git**.
## Key Files
- `age-key.txt` - Age private key for SOPS encryption (GITIGNORED)
## Backup Checklist
Before proceeding with any infrastructure work, ensure you have:
- [ ] Copied `age-key.txt` to password manager
- [ ] Created offline backup (printed or encrypted USB)
- [ ] Verified backup can decrypt secrets successfully
## Key Recovery
If you lose access to `age-key.txt`:
1. **Check password manager** for backup
2. **Check offline backups** (printed copy, USB drive)
3. **If no backup exists**: Secrets are PERMANENTLY LOST
- You will need to regenerate all secrets
- Re-encrypt all `.sops.yaml` files
- Update all services with new credentials
## Generating a New Key
Only do this if you've lost the original key or need to rotate for security:
```bash
# Generate new Age key
age-keygen -o age-key.txt
# Extract public key
grep "public key:" age-key.txt
# Update .sops.yaml in repository root with new public key
# Re-encrypt all secrets
cd ..
for file in secrets/**/*.sops.yaml; do
SOPS_AGE_KEY_FILE=keys/age-key.txt sops updatekeys -y "$file"
done
```
## Security Notes
- This directory is in `.gitignore`
- Keys should never be shared via email, Slack, or unencrypted channels
- Always use secure methods for key distribution (password manager, encrypted channels)

234
secrets/README.md Normal file
View file

@ -0,0 +1,234 @@
# Secrets Management with SOPS + Age
This directory contains encrypted secrets for the infrastructure using [SOPS](https://github.com/getsops/sops) with [Age](https://github.com/FiloSottile/age) encryption.
## 🔐 Security Model
- **Encryption**: All secret files encrypted with Age before committing to Git
- **Key Storage**: Age private key stored OUTSIDE this repository
- **Git-Safe**: Only encrypted files (.sops.yaml) are committed
- **Decryption**: Happens at runtime by Ansible or manually with `sops`
## 📁 Directory Structure
```
secrets/
├── README.md # This file
├── shared.sops.yaml # Shared secrets (encrypted)
└── clients/
└── *.sops.yaml # Per-client secrets (encrypted)
```
## 🔑 Age Key Location
**IMPORTANT**: The Age private key is stored at:
```
keys/age-key.txt
```
This file is **gitignored** and must **NEVER** be committed.
### Key Backup Checklist
✅ **You MUST backup the Age key securely:**
1. **Password Manager**: Store in Bitwarden/1Password/etc
```bash
# Copy key content
cat keys/age-key.txt
# Store as secure note in password manager
```
2. **Print Backup** (optional but recommended):
```bash
# Print and store in secure physical location
cat keys/age-key.txt | lpr
```
3. **Encrypted USB Drive** (optional):
```bash
# Copy to encrypted USB for offline backup
cp keys/age-key.txt /Volumes/SecureUSB/infrastructure-age-key.txt
```
⚠️ **WARNING**: If you lose this key, encrypted secrets are PERMANENTLY UNRECOVERABLE!
## 🚀 Quick Start
### Prerequisites
```bash
# Install SOPS and Age
brew install sops age
# Ensure you have the Age key
ls -la keys/age-key.txt
```
### View Encrypted Secrets
```bash
# View shared secrets
SOPS_AGE_KEY_FILE=keys/age-key.txt sops secrets/shared.sops.yaml
# View client secrets
SOPS_AGE_KEY_FILE=keys/age-key.txt sops secrets/clients/test.sops.yaml
```
### Edit Encrypted Secrets
```bash
# Edit shared secrets (decrypts, opens $EDITOR, re-encrypts on save)
SOPS_AGE_KEY_FILE=keys/age-key.txt sops secrets/shared.sops.yaml
# Edit client secrets
SOPS_AGE_KEY_FILE=keys/age-key.txt sops secrets/clients/test.sops.yaml
```
### Create New Client Secrets
```bash
# Copy template
cp secrets/clients/test.sops.yaml secrets/clients/newclient.sops.yaml
# Edit with generated passwords
SOPS_AGE_KEY_FILE=keys/age-key.txt sops secrets/clients/newclient.sops.yaml
```
### Generate Secure Passwords
```bash
# Random 32-character password
openssl rand -base64 32
# Random 24-character password
openssl rand -base64 24
# Zitadel masterkey (32-byte hex)
openssl rand -hex 32
```
## 🔧 Usage with Ansible
Ansible automatically decrypts SOPS files using the `community.sops` collection.
**In playbooks:**
```yaml
- name: Load client secrets
community.sops.load_vars:
file: "{{ playbook_dir }}/../secrets/clients/{{ client_name }}.sops.yaml"
name: client_secrets
- name: Use decrypted secret
debug:
msg: "DB Password: {{ client_secrets.zitadel_db_password }}"
```
**Environment variable required:**
```bash
export SOPS_AGE_KEY_FILE=/path/to/infrastructure/keys/age-key.txt
```
## 📝 Secret File Structure
### shared.sops.yaml
Contains secrets shared across all infrastructure:
- Hetzner Cloud API token
- Hetzner Storage Box credentials
- ACME email for SSL certificates
### clients/*.sops.yaml
Per-client secrets:
- Database passwords (Zitadel, Nextcloud)
- Admin passwords
- Zitadel masterkey
- Restic repository password
- OIDC credentials (after generation)
## 🛠️ Common Tasks
### Decrypt to Temporary File
```bash
# Decrypt for one-time use
SOPS_AGE_KEY_FILE=keys/age-key.txt sops --decrypt secrets/shared.sops.yaml > /tmp/secrets.yaml
# Use the file
cat /tmp/secrets.yaml
# IMPORTANT: Delete when done
rm /tmp/secrets.yaml
```
### Encrypt New File
```bash
# Create plaintext file
cat > secrets/newfile.sops.yaml <<EOF
my_secret: "super-secret-value"
EOF
# Encrypt in place
SOPS_AGE_KEY_FILE=keys/age-key.txt sops --encrypt --in-place secrets/newfile.sops.yaml
```
### Re-encrypt with New Key
If you need to rotate the Age key:
```bash
# Generate new key
age-keygen -o keys/age-key-new.txt
# Get public key
grep "public key:" keys/age-key-new.txt
# Update .sops.yaml with new public key
# Re-encrypt all files
for file in secrets/**/*.sops.yaml; do
SOPS_AGE_KEY_FILE=keys/age-key.txt sops updatekeys -y "$file"
done
# Replace old key
mv keys/age-key.txt keys/age-key-old.txt
mv keys/age-key-new.txt keys/age-key.txt
```
## 🔍 Troubleshooting
### "Failed to get the data key required to decrypt the SOPS file"
- **Cause**: Age private key not found or incorrect
- **Fix**: Ensure `SOPS_AGE_KEY_FILE` points to correct key
```bash
export SOPS_AGE_KEY_FILE=/full/path/to/keys/age-key.txt
```
### "no matching creation rules found"
- **Cause**: File path doesn't match `.sops.yaml` regex
- **Fix**: Ensure filename ends with `.sops.yaml`
### "config file not found"
- **Cause**: `.sops.yaml` not in repository root
- **Fix**: Check `.sops.yaml` exists at repo root
## 🔒 Security Best Practices
1. ✅ **Never commit** `keys/age-key.txt`
2. ✅ **Always encrypt** before committing secrets
3. ✅ **Backup the key** in multiple secure locations
4. ✅ **Use strong passwords**: minimum 24 characters
5. ✅ **Rotate secrets** periodically
6. ✅ **Limit key access** to essential personnel only
7. ✅ **Delete temp files** after decryption
## 📚 References
- [SOPS Documentation](https://github.com/getsops/sops)
- [Age Documentation](https://github.com/FiloSottile/age)
- [Ansible SOPS Collection](https://docs.ansible.com/ansible/latest/collections/community/sops/)

View file

@ -0,0 +1,32 @@
#ENC[AES256_GCM,data:nK9yR3JOQB56nTI8H8g2Mp7vnb8wArivoQ==,iv:Ke/n7VHkQs1X5b8/kj7Put6BNuZvK5A1WDLdVNJvPAg=,tag:kLvhJ3KCBnO6qd9KSD0OLw==,type:comment]
#ENC[AES256_GCM,data:L8VurAyFOT0RdJXab18xpAgW0ZULY9nxw/DdJ9kisEBfT+m0FZU=,iv:xv1i5wLoOR7x2N2ukuasGCrK2N5xHlfDdnwhaL+XBm8=,tag:LIzSGObX5NMloLU01T0iwg==,type:comment]
#ENC[AES256_GCM,data:vMG1ExYmlXI1RWbQWyUdmKNCqg==,iv:l9TGHsz2KOqF1i6j39ftXxUYvlfAzXY5Bi5nAJMWSQA=,tag:cJ3lrld4vL7uJOnqQRmJjQ==,type:comment]
client_name: ENC[AES256_GCM,data:MZWftQ==,iv:f5MS6vLBC+tHJlB+VWTpOWTej7+sJZKbioMfA37ZjiA=,tag:/h+aYKOh4BPh96CJlNzpJw==,type:str]
client_domain: ENC[AES256_GCM,data:CtSIb4/bQU8etWJpTqudxZwhuUk+hqU=,iv:FZhFwV88FglVQzjgPNJW5ZizJtHQbfdFaUbeLWaU1io=,tag:3VNoF543JENvPbiLU/todg==,type:str]
#ENC[AES256_GCM,data:BdAVkrQXKwMUuNr+P8iqGA==,iv:8Acn7K+tR2b8mkPe5EugAKpV9A540FVJC0kIuDQIPD4=,tag:jdfmiZ5o4dy8Q/YbZqq/ng==,type:comment]
zitadel_db_password: ENC[AES256_GCM,data:Jt6C7U88Ale90QxSm7E4ZwluIbuLq3tWl0+tOFDpzP2og7eDyQ==,iv:fqvUJcK8h0xRSAzxsVOwSUyyL2CKlyvszCihL4syot8=,tag:rDXQIQ/0MDRftkDuz50Tkw==,type:str]
zitadel_admin_password: ENC[AES256_GCM,data:apcy1CuWpICWULo8VULqH47loeFB3eUKLvUBIuVXIuu7BPwtbw==,iv:U7JB0wDhGKPwpRs1RE0X8dfcuE7sa5b9ikc+0XDWKos=,tag:YcT3y+1wRLfHsYoM4c/2yg==,type:str]
zitadel_masterkey: ENC[AES256_GCM,data:PZHiQK3Z2IGE0DUp/DRsQ7omfNM0xKmiaPAQHn1D4vU1XuJ2t54=,iv:K11E24TK6886crExpEWF/eDF53w5lQzIt5BG5jS557Q=,tag:NFNY7rTdxKLUOxglCfdYRw==,type:str]
#ENC[AES256_GCM,data:UCxVKl+EGvZvHFHZa91rjlYI,iv:RmN4jI05bkM1uEE1TglzE5a54RYFYMzCMQvlpq2ydbg=,tag:POsM86j4Jj+8wjwF7ffWgA==,type:comment]
nextcloud_db_password: ENC[AES256_GCM,data:oN+PC7pD07VyV9bKqZOGWLkdH6VhKOz+BBRmPYmm/8q4OQ47iQ==,iv:8ZAipySlXTgZm50R+AOKWQGszc+fcgKPMoa+TOFq+ig=,tag:6pEqOidt8Dz4P0QQ+7u+BQ==,type:str]
nextcloud_admin_password: ENC[AES256_GCM,data:ZMoK4M6xAFK3DQIBMn0a1mtkKCjhW6P/dLfUUILccnjmqO8a3A==,iv:ctXQhhO5NK5/i2Hg73lnCy1bHlgXsgBjMxQPhJy2yrw=,tag:LzbWUwZ2AEQyx2lbshu73Q==,type:str]
#ENC[AES256_GCM,data:J9fmtOMRn7VCA4qn3KN5L4QXuaNLBmk35q8NlqxMYg5TJg==,iv:f7DM3G1VN3rvIkFzAJrouG4d1A2jRtNWuJu5/+YezMQ=,tag:WA4qDSQH5+NSs/8tiHNM6Q==,type:comment]
restic_repo_password: ENC[AES256_GCM,data:V3Pw2hZIFWD/uK+pXPETHNAula4SfmPQGEOEqw/v7KdcwMlhgQ==,iv:DukqTm/LtliLioALDwZI0BDW3sJwNfq/6vcHVIit0Gc=,tag:mNIHwcBtranAmJNBTl4thw==,type:str]
#ENC[AES256_GCM,data:Jnxs5WoVDE31NgQmocYH80W569qK8yHDwY8ZDYeDyOY+Fn1mbK7xdilCB4aOn4vP5qtMzqCKa7paXm78BzZ9FpRgAkY=,iv:kgLwRvT5XhgDN7O4yEYkxMVFCuNtAdB+mmhYjar1pqY=,tag:fplXZLOILDOzh7n8WIWm/A==,type:comment]
#ENC[AES256_GCM,data:xpilPXQdvCRTIBjWEfRZMfILlWi/gDGL7onkT9o=,iv:1XsMusNaqv80/TLLfdrBk56RqNCDTB0EavhQXBJVS3I=,tag:OXlrYZuldsJeyaJQBWeDPQ==,type:comment]
#ENC[AES256_GCM,data:hwTLhDd5S4EWFFBcrkxGRazBVU50txHIKjKyOb3VJOqF,iv:7DGf0PvBKYN9NxhAiAi2bGThWf7jHmAhJDuqgGb+7+4=,tag:SO35c4iT8hBDlorx/6I8ww==,type:comment]
sops:
age:
- recipient: age170jqy5pg6z62kevadqyxxekw8ryf3e394zaquw0nhs9ae3v9wd6qq2hxnk
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWZko3UVJkSzRjK3J0bXBm
Yk0rUTRRbmxYL001QXNEMHpIaGsvMUc2ZjFnCnZnTU8ySHc0QVB6amgrTjBPdG5w
TFhucW9VZHNDWmdaVDdZWDRQbjhOQzAKLS0tIHJWWm00VWVIZlNXd04veGRoTkIw
R0kyRC9VcTFoWkFCUnl6ZmlyRjh3bXcKCkAed8Gx9jxFmoFg7vyM4a3xO9N+FxtI
CdpnZ9Wk1O498wPIV2meM3RFBclkWFgqGvAqzUNbzGuMnoSlRfJq+w==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2025-12-27T13:19:53Z"
mac: ENC[AES256_GCM,data:XkD2lptFXJrRmKg/Rxd25D1Y2bGxKM+GqBcgXQTDXvr+BIrE5jC9AWzycmkB+GX0Ta5LYlcLk1mrXGp/SbNxE8rubCvqS7qZbpxEBQi8fsy+LX0kiCOgM5SSxMM6ON/gSJ2eivLzpEbeBGwXau77fNm/2MAAWZIdlfzeIN/9o4I=,iv:MU1pp6+rr7Gvs6mCPMUqz6VnPGLttUB0dgZsb43WyH4=,tag:ts54uUviBdmHif9KiQfrKQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.11.0

25
secrets/shared.sops.yaml Normal file
View file

@ -0,0 +1,25 @@
#ENC[AES256_GCM,data:MNLUiYAc9T7qkQQJbVRdw/bMIjRLGAFZySZUGF0T8k1xBtP3hRhp5cI=,iv:IDaJgax/Slpn+AeTCfl3wX5i1oRjCLfzVAfO7FTvOiw=,tag:OjKdnUsBo1ZXJWvM191pog==,type:comment]
#ENC[AES256_GCM,data:I0PUaOrJWwHUpAXWVVdpzgubLhQqtvbXaxpnuFWwdDI5fdO98rg=,iv:Ow8NPenrdXDB4qC+IO+2wX5hylCaaTDkac7E6DDHffE=,tag:WpGJGI+OzgCInOc2eKN9UQ==,type:comment]
#ENC[AES256_GCM,data:wMIcXre4zoR4MPGMGlNM6wZZ81sL7QSjk++ZyUmCxJDF0F3BtlCT2XCwKZBX6/ULEvJGm4H2aVc=,iv:1RnvNQAB72SPRNsaLbLWCKloWNrdiL75vTcJ6E0EjLY=,tag:H86RhqoruUnvjBmuvzYPKg==,type:comment]
hcloud_token: ENC[AES256_GCM,data:4rJp1/Z/4PrHRUNq5oD4EhmyZrWnzKcQJfJeg0dif59nL6kw4Q==,iv:KTbPG5ngZAneKUShbUTQ6NwHjLQO7CunkSiIZDgF79Q=,tag:rUBqJYcOvR/F3SVnPjmp2A==,type:str]
#ENC[AES256_GCM,data:yksU9TbbH85os7wjqUspVaDLCIbAj4vFhGlTzlt7niRFCRZmHfTsdQobC6o0Xw==,iv:XOrfoiBmQ1elOqTlvLh3u2FHrMq1cp5Zf67emKDQpGo=,tag:S59pVXvJW6KSJ2EqLu4IHg==,type:comment]
storage_box_host: ENC[AES256_GCM,data:D5Hzi16Z46GFNE8VHbKV8i8iEH7hIdlDQS0=,iv:jhCitS25tZMsE6UZ+tJkanXJNX6DQfOn30+7cQDNs60=,tag:EpzUyXhZCL6EEbnicY1BPg==,type:str]
storage_box_user: ENC[AES256_GCM,data:P+rWUs+QwA==,iv:aVdWOfVkf7P5y8WlUP5ga8H7woEdGaFSUUbycDOPGYI=,tag:+lvTwQN4vOKRj5lNGZTiXw==,type:str]
storage_box_password: ENC[AES256_GCM,data:corwnHJVanBt1/ELlVJwf1klVnR3WB6oUFtxoCQz+XYE,iv:egM5VqRx4N3zSrR37cCfBfml00AFRk1/UecWhaVrgS0=,tag:9Z1vXlPgyM57X9CAuRYV8w==,type:str]
#ENC[AES256_GCM,data:WQ4w3gItBICoC9tgNoecH+2nppf0aqX+6h5a,iv:pIfewJ7Ee6P6FLClOSJUFs7Jl/nXyIkHN8rGsM6InHE=,tag:zWb46M0jzB7N4Z1Of7cT3w==,type:comment]
acme_email: ENC[AES256_GCM,data:UbNndaUjrUj7+S6z3o7JlelE0EUZsP6T,iv:cMYKvkxHEIgW0G2vSmARry01K22WALRi8QVs/8BkdCA=,tag:nYgZiDfvLazBzo+2HU8XUA==,type:str]
sops:
age:
- recipient: age170jqy5pg6z62kevadqyxxekw8ryf3e394zaquw0nhs9ae3v9wd6qq2hxnk
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBHQnBBTGZSVHg1YVNGb0Qw
UllrYVlON0l5U2Qvek5zTXlBekNTNnd3ZkZ3Ck1xcVF1UGhLMGVrREw0cEkwbWpB
Vjl3cW1Kby9GTWZpWUZqM3g2YjllcG8KLS0tIDY0eEpOMmtJTmxua28wQytmMkJy
c0NBVERZZmV6L2lYaHVlb3lEK3Q4ejQKz/oho9gId2Fm9eM2Vl/JRKC5VL2Wj9as
bz4wd7rRjvt1nwJsH10EqB7hf7lq9ukxdkzDySH+0haaZT64G8TcNw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2025-12-27T13:19:44Z"
mac: ENC[AES256_GCM,data:tWwoGOH2s2LJYCYZII7urlWCs2CZ9M0PyhONlCwcApc3ykMvD8OVQsWiGqJpDfbZHaz5Vy7XWnINuFF99u4cOJpqQPFe8q8cRluURDUJ8aMOI05/jbzFqhPyzgtJ4OvZVyrm2LxlrYMtuBfo1NSVmGeHLhus0jD3P6cCY09Yew4=,iv:7s/lzOtNTa3wj5JwIBuMkGzCFRbtzd9+y3sG0Hbd458=,tag:c7G+S/usZvu9/QOA2lOsKA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.11.0