@@ -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+
77112The check only needs to be a best-effort heuristic — it is used to emit a warning, not
78113to block the user. A false negative (missing the warning) is acceptable; a false
79114positive (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`
0 commit comments