Skip to content

Commit 0d362a9

Browse files
committed
Merge #421: feat: [#411] detect passphrase-protected SSH key and warn during create environment
e688a7c feat: [#411] detect passphrase-protected SSH key and warn during create environment (Jose Celano) 3a3c078 docs: [#411] add alternative detection approach and ADR task to spec (Jose Celano) Pull request description: ## Summary Closes #411 When a passphrase-protected SSH private key is configured in `ssh_credentials`, the deployer previously failed silently during `provision` with a misleading `Permission denied (publickey,password)` error. This PR adds an early non-blocking warning during `create environment` so users can make an informed decision before reaching `provision`. ## Changes ### Detection (`src/adapters/ssh/key_inspector.rs` — new module) - Moved passphrase detection out of `credentials.rs` into a dedicated `key_inspector` module (cleaner separation of concerns) - `is_passphrase_protected(path: &Path) -> bool` — best-effort heuristic: - Legacy PEM: checks for `BEGIN ENCRYPTED PRIVATE KEY` / `Proc-Type: 4,ENCRYPTED` header - OpenSSH format: base64-decodes the body and scans first 100 bytes for `bcrypt` KDF marker - Uses the `base64` crate (now a direct dependency; was already transitive at v0.22.1) instead of an inline decoder - 4 unit tests covering: unencrypted key, passphrase-protected key, missing file, legacy PEM header ### Warning (`src/presentation/cli/views/progress/mod.rs`, `handler.rs`) - Added `ProgressReporter::warn()` for advisory user-facing messages - `CreateEnvironmentCommandController::execute()` calls `warn_if_ssh_key_passphrase_protected()` after config load — emits a detailed warning with 3 resolution options but does **not** block environment creation ### Test fixture - `fixtures/testing_ed25519_encrypted` — real ed25519 key protected with passphrase `"password"`, used in unit tests ### ADR - `docs/decisions/ssh-key-passphrase-detection.md` — documents the byte inspection approach and why `ssh-keygen -y` probe was rejected ### Documentation - `docs/user-guide/ssh-keys.md` — new page covering SSH key requirements, 3 supported workflows, how to remove a passphrase, and security notes - `docs/user-guide/providers/hetzner.md` — added SSH Key Requirements section with warning callout - `docs/user-guide/commands/create.md` — added passphrase warning subsection in Troubleshooting - `docs/user-guide/README.md` — added SSH Keys subsection under Security with link ## Testing All pre-commit checks pass: - `cargo machete` ✅ - `cargo run --bin linter all` (clippy stable + nightly, rustfmt, cspell, shellcheck, markdownlint, yamllint, taplo) ✅ - `cargo test` (417 tests) ✅ - `cargo doc` ✅ - E2E infrastructure lifecycle tests ✅ - E2E deployment workflow tests ✅ ACKs for top commit: josecelano: ACK e688a7c Tree-SHA512: 9cd13240680cf1cd248c0b34f0e2df88b302a52823d92e9f8e5b7ad0976d57a47c475d3ed765b43dda66866f0d248f2d4d7b972a5ad76d36095087b4c65fb857
2 parents 541765b + e688a7c commit 0d362a9

15 files changed

Lines changed: 580 additions & 9 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/issues/411-bug-ssh-key-passphrase-breaks-automated-deployment.md

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,47 @@ The reliable detection approach is to read the first line of the PEM file:
6868
- OpenSSH format: the body begins with `bcrypt` if passphrase-protected; the header
6969
alone is not sufficient — the first few bytes of the decoded body must be checked.
7070

71-
The simplest robust approach in Rust is to read the raw bytes of the key file and check:
71+
### Detection Approaches Considered
72+
73+
Two approaches were evaluated for detecting whether a key is passphrase-protected:
74+
75+
#### Option A — Byte inspection (chosen)
76+
77+
Read the raw bytes of the key file and check:
7278

7379
- For legacy PEM: `ENCRYPTED` appears in the header
7480
- For OpenSSH format: after the base64-decoded body, the string `bcrypt` appears near
7581
the start (OpenSSH uses bcrypt KDF for passphrase derivation)
7682

83+
| | |
84+
| --- | ------------------------------------------------------------------------------- |
85+
|| Pure Rust, no external tools required |
86+
|| Fast — reads only the first ~64 bytes of the file |
87+
|| Works in any environment, including minimal Docker images |
88+
|| No process spawning overhead |
89+
|| Must handle two PEM variants (legacy `ENCRYPTED` header, OpenSSH `bcrypt` body) |
90+
|| False-negative risk for exotic or future key formats (acceptable per spec) |
91+
92+
#### Option B — `ssh-keygen -y` probe
93+
94+
Spawn `ssh-keygen -y -f <key> < /dev/null`: this command attempts to derive the public
95+
key from the private key and exits non-zero with a passphrase prompt when the key is
96+
encrypted.
97+
98+
| | |
99+
| --- | --------------------------------------------------------------------------------------------------- |
100+
|| Handles all key formats transparently — no format-specific parsing |
101+
|| Implementation is a single `std::process::Command` call |
102+
|| Requires `ssh-keygen` to be present in the runtime environment |
103+
|| Spawns an external process just for detection |
104+
|| Must distinguish "encrypted" from "file not found" / "unsupported format" via exit codes and stderr |
105+
|| Slower than reading a few bytes from a file |
106+
107+
**Chosen approach**: Option A (byte inspection). The deployer already targets Docker
108+
containers where `ssh-keygen` may not be installed, and the detection is best-effort
109+
(a missed warning is acceptable — a false positive is not). An ADR documents this
110+
choice in full (see Implementation Plan).
111+
77112
The check only needs to be a best-effort heuristic — it is used to emit a warning, not
78113
to block the user. A false negative (missing the warning) is acceptable; a false
79114
positive (warning for an unencrypted key) would be confusing and should be avoided.
@@ -200,14 +235,22 @@ configured private key appears to be passphrase-protected.
200235
with and without passphrase if available, or by constructing the minimal PEM
201236
structure in the test)
202237

203-
### Phase 2: Documentation
238+
### Phase 2: ADR
239+
240+
- [ ] Create `docs/decisions/XXX-ssh-key-passphrase-detection.md` documenting:
241+
- Why byte inspection was chosen over the `ssh-keygen -y` probe
242+
- Pros and cons of each approach
243+
- Consequences and limitations (best-effort, false-negative acceptable)
244+
- [ ] Register the new ADR in `docs/decisions/README.md`
245+
246+
### Phase 3: Documentation
204247

205248
- [ ] Create `docs/user-guide/ssh-keys.md` covering all workflows and security notes
206249
- [ ] Update `docs/user-guide/providers/hetzner.md` with an SSH key requirements note
207250
- [ ] Update `docs/user-guide/commands/create.md` to mention the passphrase warning
208251
- [ ] Update `docs/user-guide/README.md` to link to the new `ssh-keys.md` page
209252

210-
### Phase 3: Linting and pre-commit
253+
### Phase 4: Linting and pre-commit
211254

212255
- [ ] Run linters: `cargo run --bin linter all`
213256
- [ ] Run pre-commit: `./scripts/pre-commit.sh`

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:

0 commit comments

Comments
 (0)