Resolves #14 Each client now gets a dedicated SSH key pair, ensuring that compromise of one client server does not grant access to other client servers. ## Changes ### Infrastructure (OpenTofu) - Replace shared `hcloud_ssh_key.default` with per-client `hcloud_ssh_key.client` - Each client key read from `keys/ssh/<client_name>.pub` - Server recreated with new key (dev server only, acceptable downtime) ### Key Management - Created `keys/ssh/` directory for SSH keys - Added `.gitignore` to protect private keys from git - Generated ED25519 key pair for dev client - Private key gitignored, public key committed ### Scripts - **`scripts/generate-client-keys.sh`** - Generate SSH key pairs for clients - Updated `scripts/deploy-client.sh` to check for client SSH key ### Documentation - **`docs/ssh-key-management.md`** - Complete SSH key management guide - **`keys/ssh/README.md`** - Quick reference for SSH keys directory ### Configuration - Removed `ssh_public_key` variable from `variables.tf` - Updated `terraform.tfvars` to remove shared SSH key reference - Updated `terraform.tfvars.example` with new key generation instructions ## Security Improvements ✅ Client isolation: Each client has dedicated SSH key ✅ Granular rotation: Rotate keys per-client without affecting others ✅ Defense in depth: Minimize blast radius of key compromise ✅ Proper key storage: Private keys gitignored, backups documented ## Testing - ✅ Generated new SSH key for dev client - ✅ Applied OpenTofu changes (server recreated) - ✅ Tested SSH access: `ssh -i keys/ssh/dev root@78.47.191.38` - ✅ Verified key isolation: Old shared key removed from Hetzner ## Migration Notes For existing clients: 1. Generate key: `./scripts/generate-client-keys.sh <client>` 2. Apply OpenTofu: `cd tofu && tofu apply` (will recreate server) 3. Deploy: `./scripts/deploy-client.sh <client>` For new clients: 1. Generate key first 2. Deploy as normal 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
7.2 KiB
SSH Key Management
Per-client SSH key isolation ensures that compromise of one client server does not grant access to other client servers.
Architecture
Each client gets a dedicated SSH key pair:
- Private key:
keys/ssh/<client_name>(gitignored, never committed) - Public key:
keys/ssh/<client_name>.pub(committed to repository)
Security Benefits
| Benefit | Description |
|---|---|
| Isolation | Compromising one client ≠ compromising others |
| Granular Rotation | Rotate keys per-client without affecting others |
| Access Control | Different teams can have access to different client keys |
| Auditability | Track which key accessed which server |
Generating Keys for New Clients
Automated (Recommended)
# Generate key pair for new client
./scripts/generate-client-keys.sh newclient
# Output:
# ✓ SSH key pair generated successfully
# Private key: keys/ssh/newclient
# Public key: keys/ssh/newclient.pub
Manual
# Create keys directory
mkdir -p keys/ssh
# Generate ED25519 key pair
ssh-keygen -t ed25519 \
-f keys/ssh/newclient \
-C "client-newclient-deploy-key" \
-N ""
# Verify generation
ls -la keys/ssh/newclient*
Using Client SSH Keys
With SSH Command
# Connect to client server
ssh -i keys/ssh/dev root@78.47.191.38
# Run command on client server
ssh -i keys/ssh/dev root@78.47.191.38 "docker ps"
With Ansible
Ansible automatically uses the correct key per client:
# Deploy to specific client (uses client-specific key)
ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit dev
The dynamic inventory provides the correct host, and OpenTofu ensures the server has the matching public key.
Adding to SSH Config
# ~/.ssh/config
Host dev.vrije.cloud
User root
IdentityFile ~/path/to/infrastructure/keys/ssh/dev
StrictHostKeyChecking no
Host newclient.vrije.cloud
User root
IdentityFile ~/path/to/infrastructure/keys/ssh/newclient
StrictHostKeyChecking no
Then simply: ssh dev.vrije.cloud
Key Rotation
When to Rotate
- Annually: Routine rotation (best practice)
- On Compromise: Immediately if key suspected compromised
- On Departure: When team member with key access leaves
- On Audit: During security audits
Rotation Procedure
-
Generate new key:
# Backup old key cp keys/ssh/dev keys/ssh/dev.old cp keys/ssh/dev.pub keys/ssh/dev.pub.old # Generate new key (overwrites old) ./scripts/generate-client-keys.sh dev -
Update OpenTofu (will recreate server):
cd tofu tofu apply # Server will be recreated with new key -
Test new key:
ssh -i keys/ssh/dev root@<new_ip> hostname -
Remove old key backup:
rm keys/ssh/dev.old keys/ssh/dev.pub.old
Zero-Downtime Rotation (Advanced)
For production clients where downtime is unacceptable:
- Generate new key with temporary name
- Add both keys to server via OpenTofu
- Test new key works
- Remove old key from OpenTofu
- Update local key file
Key Storage & Backup
Local Storage
keys/ssh/
├── .gitignore # Protects private keys from git
├── dev # Private key (gitignored)
├── dev.pub # Public key (committed)
├── client1 # Private key (gitignored)
├── client1.pub # Public key (committed)
└── README.md # Documentation (to be created)
Backup Strategy
Private keys must be backed up securely:
-
Password Manager (Recommended):
- Store in 1Password, Bitwarden, or similar
- Tag with "ssh-key" and client name
- Include server IP and hostname
-
Encrypted Backup:
# Create encrypted archive tar czf - keys/ssh/ | gpg -c > ssh-keys-backup.tar.gz.gpg # Store backup in secure location (NOT in git) -
Team Shared Vault:
- Use team password manager
- Ensure key escrow for bus factor
- Document who has access
⚠️ NEVER commit private keys to git!
The .gitignore file protects you, but double-check:
# Verify no private keys in git
git ls-files keys/ssh/
# Should only show:
# keys/ssh/.gitignore
# keys/ssh/README.md
# keys/ssh/*.pub (public keys only)
Troubleshooting
"Permission denied (publickey)"
Cause: Server doesn't have the public key or wrong private key used.
Solution:
# 1. Verify public key is in OpenTofu state
cd tofu
tofu state show 'hcloud_ssh_key.client["dev"]'
# 2. Verify server has the key
ssh-keygen -lf keys/ssh/dev.pub # Get fingerprint
# Compare with Hetzner Cloud Console → Server → SSH Keys
# 3. Use correct private key
ssh -i keys/ssh/dev root@<server_ip>
"No such file or directory: keys/ssh/dev"
Cause: SSH key not generated yet.
Solution:
./scripts/generate-client-keys.sh dev
"Connection refused"
Cause: Server not yet booted or firewall blocking SSH.
Solution:
# Wait for server to boot (check Hetzner Console)
# Check firewall rules allow your IP
cd tofu
tofu state show 'hcloud_firewall.client_firewall'
Key Permissions Wrong
Cause: Private key has incorrect permissions.
Solution:
# Private keys must be 600
chmod 600 keys/ssh/dev
# Public keys should be 644
chmod 644 keys/ssh/dev.pub
Migration from Shared Key
If migrating from a shared SSH key setup:
-
Generate per-client keys:
for client in dev client1 client2; do ./scripts/generate-client-keys.sh $client done -
Update OpenTofu:
- Remove
hcloud_ssh_key.defaultresource - Update
hcloud_server.clientto usehcloud_ssh_key.client[each.key].id
- Remove
-
Apply changes (will recreate servers):
cd tofu tofu apply -
Update Ansible/scripts to use new keys
-
Remove old shared key from Hetzner Cloud Console
Best Practices
✅ DO:
- Generate unique keys per client
- Use ED25519 algorithm (modern, secure, fast)
- Backup private keys securely
- Rotate keys annually
- Document key ownership
- Use descriptive comments in keys
❌ DON'T:
- Reuse keys between clients
- Share private keys via email/Slack
- Commit private keys to git
- Use weak SSH algorithms (RSA < 4096, DSA)
- Store keys in unencrypted cloud storage
- Forget to backup keys
Key Specifications
| Property | Value | Rationale |
|---|---|---|
| Algorithm | ED25519 | Modern, secure, fast, small keys |
| Key Size | 256 bits | Standard for ED25519 |
| Comment | client-<name>-deploy-key |
Identifies key purpose |
| Passphrase | None (empty) | Automation requires no passphrase |
| Permissions | 600 (private), 644 (public) | Standard SSH security |
Note on Passphrases: Automation keys typically have no passphrase. If adding a passphrase, use ssh-agent to avoid prompts during deployment.
See Also
- OpenTofu Configuration - SSH key resources
- Deployment Scripts - Uses client keys
- Issue #14 - Original requirement
- Architecture Decisions - Security baseline