Skip to content

Commit e688a7c

Browse files
committed
feat: [#411] detect passphrase-protected SSH key and warn during create environment
- Add is_passphrase_protected() in new src/adapters/ssh/key_inspector.rs - Detection uses byte inspection: bcrypt KDF scan for OpenSSH format, header check for legacy PEM (ENCRYPTED/Proc-Type: 4,ENCRYPTED) - Use base64 crate (already transitive, now direct) instead of inline decoder - Add ProgressReporter::warn() for advisory user-facing warnings - Emit non-blocking warning in CreateEnvironmentCommandController::execute() when the configured SSH private key appears passphrase-protected - Add encrypted ed25519 test fixture (fixtures/testing_ed25519_encrypted) - Add ADR: docs/decisions/ssh-key-passphrase-detection.md - Add user guide: docs/user-guide/ssh-keys.md - Update docs/user-guide/providers/hetzner.md with SSH key requirements - Update docs/user-guide/commands/create.md with passphrase warning note - Update docs/user-guide/README.md with ssh-keys link Closes #411
1 parent 3a3c078 commit e688a7c

14 files changed

Lines changed: 534 additions & 6 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ path = "src/bin/test_logging.rs"
4444
[dependencies]
4545
tokio = { version = "1.0", features = [ "full" ] }
4646
anyhow = "1.0"
47+
base64 = "0.22"
4748
chrono = { version = "0.4", features = [ "serde" ] }
4849
clap = { version = "4.0", features = [ "derive" ] }
4950
derive_more = { version = "2.1", features = [ "display", "from" ] }

docs/decisions/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This directory contains architectural decision records for the Torrust Tracker D
66

77
| Status | Date | Decision | Summary |
88
| ------------- | ---------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
9+
| ✅ Accepted | 2026-04-07 | [SSH Key Passphrase Detection](./ssh-key-passphrase-detection.md) | Detect passphrase-protected keys via byte inspection; reject ssh-keygen probe approach |
910
| ✅ Accepted | 2026-02-26 | [SDK Package Naming](./sdk-package-naming.md) | Keep "SDK" name for packages/sdk — the modern API-wrapper meaning is industry-standard |
1011
| ✅ Accepted | 2026-02-24 | [SDK Presentation Layer Interface Design](./sdk-presentation-layer-interface-design.md) | Return () from SDK operations; no domain types or typestate pattern in the SDK public API |
1112
| ✅ Accepted | 2026-02-24 | [Docker Compose Local Validation Placement](./docker-compose-local-validation-placement.md) | Validate rendered docker-compose.yml in the infrastructure generator, not the app layer |
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Decision: SSH Key Passphrase Detection via Byte Inspection
2+
3+
## Status
4+
5+
✅ Accepted
6+
7+
## Date
8+
9+
2026-04-07
10+
11+
## Context
12+
13+
When a user configures a passphrase-protected SSH private key in `ssh_credentials`,
14+
the deployer fails silently during the `provision` step with a misleading
15+
`Permission denied (publickey,password)` error. The root cause — that the key is
16+
encrypted and cannot be decrypted without a passphrase in an unattended environment —
17+
is never surfaced.
18+
19+
To give users an early, actionable warning, the `create environment` command needs to
20+
detect whether the configured private key is passphrase-protected before it runs the
21+
deployment workflow. Two approaches were considered.
22+
23+
See also: [Issue #411](../../docs/issues/411-bug-ssh-key-passphrase-breaks-automated-deployment.md)
24+
25+
## Decision
26+
27+
Detect passphrase protection by reading and inspecting the raw bytes of the key file
28+
(**byte inspection**), implemented as a pure-Rust free function
29+
`is_passphrase_protected(path: &Path) -> bool` in
30+
`src/adapters/ssh/credentials.rs`.
31+
32+
Detection rules:
33+
34+
- **Legacy PEM format** (`PKCS#8` / traditional): the header line contains
35+
`BEGIN ENCRYPTED PRIVATE KEY` or the `Proc-Type: 4,ENCRYPTED` header is present.
36+
- **OpenSSH format** (`-----BEGIN OPENSSH PRIVATE KEY-----`): the binary body
37+
(base64-decoded) contains the byte sequence `bcrypt` within the first 100 bytes.
38+
OpenSSH uses `bcrypt` as the KDF name when a passphrase is set; the KDF name is
39+
`none` for unencrypted keys.
40+
41+
The check is **best-effort**:
42+
43+
- A false negative (encrypted key not detected) is acceptable — the warning is advisory.
44+
- A false positive (unencrypted key flagged) must be avoided — it would confuse users.
45+
- Any I/O or parse error returns `false` (no spurious warning).
46+
47+
## Consequences
48+
49+
**Positive**:
50+
51+
- Pure Rust, zero new dependencies. No external process is spawned just for detection.
52+
- Fast: reads only the first ~150 bytes of the file (header + base64 start).
53+
- Works in any environment, including minimal Docker images where `ssh-keygen` may not
54+
be present.
55+
- Handles both common key formats (legacy PEM, OpenSSH).
56+
57+
**Negative / Risks**:
58+
59+
- False-negative risk for exotic or future key formats (e.g., PKCS#1 passphrase-
60+
protected RSA keys with `Proc-Type` elsewhere in the file). Acceptable per spec.
61+
- Requires maintaining a small inline base64 decoder (~25 lines) because no base64
62+
crate is in the dependency list. The decoder is minimal but covers the standard
63+
alphabet only; malformed base64 returns `false` rather than an error.
64+
65+
## Alternatives Considered
66+
67+
### `ssh-keygen -y` probe
68+
69+
Spawn `ssh-keygen -y -f <key> < /dev/null`: this exits non-zero when the key is
70+
passphrase-protected, allowing detection via the exit code.
71+
72+
**Rejected because**:
73+
74+
- Requires `ssh-keygen` to be present at runtime. Docker images used in CI/CD may not
75+
include it, making the check environment-dependent.
76+
- Spawns an external process with I/O redirection just for an advisory check — this
77+
adds latency and error-handling complexity (exit codes must distinguish "encrypted"
78+
from "file not found" or "unsupported format").
79+
- The byte-inspection approach is faster, self-contained, and sufficient for the
80+
best-effort goal.
81+
82+
## Related Decisions
83+
84+
- [Validated Deserialization for Domain Types](./validated-deserialization-for-domain-types.md)
85+
86+
## References
87+
88+
- [Issue #411 spec](../issues/411-bug-ssh-key-passphrase-breaks-automated-deployment.md)
89+
- [OpenSSH key format](https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key)
90+
- Implementation: `src/adapters/ssh/credentials.rs`

docs/user-guide/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,13 @@ See individual service guides for detailed configuration options and verificatio
388388
- Production security checklist
389389
- Security incident response
390390

391+
### SSH Keys
392+
393+
The deployer uses SSH for all remote operations (`provision`, `configure`, `release`, `run`).
394+
Automated deployments (Docker, CI/CD) require a passphrase-free key or SSH agent forwarding.
395+
396+
**[📖 SSH Keys Guide →](ssh-keys.md)**
397+
391398
### Logging Configuration
392399

393400
Control logging output with command-line options:

docs/user-guide/commands/create.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,32 @@ ssh-keygen -t rsa -b 4096 -f ~/.ssh/deployer_key
833833
"public_key_path": "~/.ssh/deployer_key.pub"
834834
```
835835

836+
### Passphrase-Protected SSH Key Warning
837+
838+
**During `create environment` you may see**:
839+
840+
```text
841+
⚠️ SSH private key appears to be passphrase-protected.
842+
Key: /home/you/.ssh/torrust_key
843+
844+
Automated deployment (e.g. Docker, CI/CD) requires an SSH key that can be
845+
used without interactive input. A passphrase-protected key will cause the
846+
`provision` step to fail with "Permission denied" unless one of the
847+
following is arranged: …
848+
```
849+
850+
This is a **warning, not an error** — the environment is created normally. The warning
851+
means the configured key is encrypted and may not work in automated contexts (Docker,
852+
CI/CD) without an SSH agent.
853+
854+
**Resolution options**:
855+
856+
1. Remove the passphrase: `ssh-keygen -p -f /path/to/your/key`
857+
2. Forward your SSH agent socket into the Docker container.
858+
3. Use a separate passphrase-free deployment key.
859+
860+
See the [SSH Keys Guide](../ssh-keys.md) for full details on all three workflows.
861+
836862
### Environment Already Exists
837863

838864
**Problem**: `Environment 'my-env' already exists`

docs/user-guide/providers/hetzner.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,32 @@ cat /var/log/cloud-init-output.log
145145
4. **Regular updates** - Keep server packages updated
146146
5. **Disable root SSH access** - For production, see [SSH Root Access Guide](../../security/ssh-root-access-hetzner.md)
147147

148+
## SSH Key Requirements
149+
150+
> ⚠️ **Docker deployments require a passphrase-free SSH key (or SSH agent forwarding).**
151+
152+
When you run the deployer inside a Docker container (the recommended approach for
153+
Hetzner), there is no SSH agent and no interactive terminal. A passphrase-protected
154+
private key will cause the `provision` step to fail with
155+
`Permission denied (publickey,password)`.
156+
157+
**Options**:
158+
159+
1. **Remove the passphrase** (recommended for dedicated deployment keys):
160+
161+
```bash
162+
ssh-keygen -p -f ~/.ssh/your_deployment_key
163+
# Press Enter twice for an empty new passphrase
164+
```
165+
166+
2. **Forward your SSH agent** into the container (see [SSH Keys Guide](../ssh-keys.md#workflow-2--passphrase-protected-key-with-ssh-agent-forwarding-into-docker)).
167+
168+
The `create environment` command will warn you if it detects a passphrase-protected key
169+
so you can resolve this before reaching `provision`.
170+
171+
For more detail on generating keys, removing passphrases, and security considerations,
172+
see the [SSH Keys Guide](../ssh-keys.md).
173+
148174
## SSH Key Behavior
149175

150176
Hetzner deployments configure SSH access through two mechanisms:

docs/user-guide/ssh-keys.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# SSH Key Handling
2+
3+
This page covers everything you need to know about SSH keys for the Torrust Tracker Deployer:
4+
why they are required, what format they must be in for automated deployment, and how to
5+
generate or adjust them.
6+
7+
## Why the Deployer Needs SSH Keys
8+
9+
The deployer uses SSH for every remote operation after infrastructure is provisioned:
10+
11+
- **`provision`** — Waits for the VM to accept SSH connections; verifies cloud-init ran
12+
successfully.
13+
- **`configure`** — Uploads configuration files and runs Ansible playbooks over SSH.
14+
- **`release`** — Pushes Docker images and starts Docker Compose over SSH.
15+
- **`run`** — Triggers service restarts and smoke tests over SSH.
16+
17+
All steps use the key pair configured in `ssh_credentials.private_key_path` /
18+
`ssh_credentials.public_key_path`.
19+
20+
## Key Requirements for Unattended Automation
21+
22+
When running in an automated environment (Docker container, CI/CD pipeline, GitHub
23+
Actions), there is **no interactive terminal and no SSH agent**. This means:
24+
25+
| Scenario | Works? |
26+
| ---------------------------------- | ------ |
27+
| Unencrypted (passphrase-free) key | ✅ Yes |
28+
| Passphrase-protected + SSH agent | ✅ Yes |
29+
| Passphrase-protected, no SSH agent | ❌ No |
30+
31+
A passphrase-protected key without an accessible SSH agent will cause every `provision`
32+
(and later) step to fail with:
33+
34+
```text
35+
Permission denied (publickey,password)
36+
```
37+
38+
This error is indistinguishable from a wrong key or an unconfigured `authorized_keys`
39+
file. The deployer will emit a warning during `create environment` if it detects a
40+
passphrase-protected key so you can resolve this before reaching the `provision` step.
41+
42+
## Supported Workflows
43+
44+
### Workflow 1 — Passphrase-free dedicated deployment key (recommended)
45+
46+
Generate a dedicated key with no passphrase and use it only for deploying this
47+
environment. This is the simplest and most portable approach.
48+
49+
```bash
50+
ssh-keygen -t ed25519 -C "torrust-tracker-deployer" \
51+
-f ~/.ssh/torrust_tracker_deployer_ed25519
52+
# Leave the passphrase empty (press Enter twice)
53+
```
54+
55+
Configure it in your environment JSON:
56+
57+
```json
58+
"ssh_credentials": {
59+
"private_key_path": "/home/you/.ssh/torrust_tracker_deployer_ed25519",
60+
"public_key_path": "/home/you/.ssh/torrust_tracker_deployer_ed25519.pub"
61+
}
62+
```
63+
64+
### Workflow 2 — Passphrase-protected key with SSH agent forwarding into Docker
65+
66+
If you need to keep the passphrase on the key, you can forward your local SSH agent
67+
socket into the deployer Docker container:
68+
69+
```bash
70+
# Make sure your key is loaded into the agent
71+
ssh-add ~/.ssh/torrust_tracker_deployer_ed25519
72+
73+
# Pass the agent socket when running the container
74+
docker run --rm \
75+
-v "$SSH_AUTH_SOCK:/tmp/ssh-agent.sock" \
76+
-e SSH_AUTH_SOCK=/tmp/ssh-agent.sock \
77+
-v $(pwd)/envs:/var/lib/torrust/deployer/envs \
78+
-v $(pwd)/build:/var/lib/torrust/deployer/build \
79+
torrust/tracker-deployer:latest \
80+
provision my-environment
81+
```
82+
83+
The deployer will use the agent socket to authenticate without needing the passphrase
84+
in plaintext.
85+
86+
### Workflow 3 — Native (non-Docker) execution with an SSH agent on the host
87+
88+
When running the deployer binary directly (not in Docker), your desktop SSH agent is
89+
typically already running and holds the unlocked key. The deployer inherits the
90+
`SSH_AUTH_SOCK` environment variable automatically.
91+
92+
```bash
93+
# Load your key once per session
94+
ssh-add ~/.ssh/torrust_tracker_deployer_ed25519
95+
96+
# Run the deployer natively — the agent socket is inherited
97+
torrust-tracker-deployer provision my-environment
98+
```
99+
100+
## Removing an Existing Passphrase
101+
102+
If you already created a key with a passphrase and want to remove it:
103+
104+
```bash
105+
ssh-keygen -p -f ~/.ssh/torrust_tracker_deployer_ed25519
106+
# Enter old passphrase, then press Enter twice for the new (empty) passphrase
107+
```
108+
109+
## Security Notes
110+
111+
- **Dedicated deployment keys** — Use a separate key pair for each deployer environment;
112+
never reuse your personal SSH key for automated deployments.
113+
- **Key rotation** — Replace deployment keys after the deployment is complete or the
114+
environment is destroyed.
115+
- **Filesystem permissions** — Private key files must be readable only by the owner:
116+
117+
```bash
118+
chmod 600 ~/.ssh/torrust_tracker_deployer_ed25519
119+
```
120+
121+
- **Never commit private keys** — Add key paths to `.gitignore`; store them outside the
122+
repository.
123+
124+
## Configuration Reference
125+
126+
The SSH key paths are specified in the `ssh_credentials` section of the environment
127+
configuration file:
128+
129+
```json
130+
"ssh_credentials": {
131+
"private_key_path": "/absolute/path/to/private_key",
132+
"public_key_path": "/absolute/path/to/private_key.pub",
133+
"username": "torrust",
134+
"port": 22
135+
}
136+
```
137+
138+
See the [environment config JSON schema](../../schemas/environment-config.json) for the
139+
full `ssh_credentials` field documentation.
140+
141+
## Related Documentation
142+
143+
- [Create Environment Command](commands/create.md) — passphrase warning details
144+
- [Hetzner Provider Guide](providers/hetzner.md) — SSH key requirements for cloud deployments
145+
- [Security Guide](security.md) — broader security considerations
146+
- [ADR: SSH Key Passphrase Detection](../../docs/decisions/ssh-key-passphrase-detection.md)

fixtures/testing_ed25519_encrypted

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-----BEGIN OPENSSH PRIVATE KEY-----
2+
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCtM/OB+H
3+
A9QtdudEIKnvoDAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIIkjTjzVEWbr51lw
4+
dxmTUtxE2yb1tm90w0Pof0cxtdosAAAAoJHTSbtCOaQBtfxlDt5ucdRRPAHeOztym/RMYw
5+
AINeYEA2IgJsBB0oGwmqhg8xBQiXdJPcdCMWvaNgp95YgXkJN0IdNObCsbbimCf67fZJca
6+
GQ6uDsVJKwZigrEtZl34dbIHrlQoer0lfIDAn7zOvhqi9QqlMj+SNt46d5xyv0aTjhy5BS
7+
8VzGOS3INun2IjIixrN+VaIL/fYeCLoWADdaA=
8+
-----END OPENSSH PRIVATE KEY-----
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIkjTjzVEWbr51lwdxmTUtxE2yb1tm90w0Pof0cxtdos test-only-do-not-use

src/adapters/ssh/credentials.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! SSH credentials management for remote authentication
1+
//! SSH credentials for remote instance authentication
22
//!
33
//! This module provides the `SshCredentials` struct which manages SSH authentication
44
//! information including private/public key paths and username configuration.

0 commit comments

Comments
 (0)