1use anyhow::{anyhow, Context, Result};
16use std::path::{Path, PathBuf};
17
18use secret_write::SecretFile;
19
20fn ssh_dir(config_dir: &Path) -> PathBuf {
26 config_dir.join("ssh")
27}
28
29pub fn managed_key_path(config_dir: &Path) -> PathBuf {
31 ssh_dir(config_dir).join("id_ed25519")
32}
33
34pub fn known_hosts_path(config_dir: &Path) -> PathBuf {
36 ssh_dir(config_dir).join("known_hosts")
37}
38
39#[cfg(unix)]
40fn chmod(p: &Path, mode: u32) {
41 use std::os::unix::fs::PermissionsExt;
42 let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode));
43}
44#[cfg(not(unix))]
45fn chmod(_p: &Path, _mode: u32) {}
46
47pub fn ensure_managed_key(config_dir: &Path) -> Result<String> {
51 let key = managed_key_path(config_dir);
52 let pub_path = PathBuf::from(format!("{}.pub", key.display()));
54
55 let created_this_call = !key.exists();
61
62 if created_this_call {
63 if let Some(dir) = key.parent() {
64 std::fs::create_dir_all(dir).context("create filament ssh dir")?;
65 chmod(dir, 0o700);
66 }
67 let st = std::process::Command::new("ssh-keygen")
68 .args(["-q", "-t", "ed25519", "-N", "", "-C", "filament-managed", "-f"])
69 .arg(&key)
70 .status()
71 .context("run ssh-keygen (is openssh-client installed?)")?;
72 if !st.success() {
73 return Err(anyhow!("ssh-keygen failed to create the managed key"));
74 }
75 chmod(&pub_path, 0o644);
76 }
77 if let Err(e) = SecretFile::restrict(&key) {
84 if created_this_call {
85 let _ = std::fs::remove_file(&key);
86 let _ = std::fs::remove_file(&pub_path);
87 return Err(e).context("restrict newly-created managed key failed; removed unprotected key to force regeneration");
88 }
89 return Err(e).context("could not confirm managed key permissions; left the key in place. If this machine is shared, rotate it manually");
90 }
91 let line = std::fs::read_to_string(&pub_path).context("read managed pubkey")?;
92 Ok(line.trim().to_string())
93}
94
95const BEGIN: &str = "# BEGIN filament-managed";
98const END: &str = "# END filament-managed";
99
100pub fn authorized_keys_path() -> PathBuf {
104 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
105 PathBuf::from(home).join(".ssh/authorized_keys")
106}
107
108pub fn validate_pubkey(pubkey: &str) -> Result<String> {
118 let key = pubkey.trim();
119 if key.is_empty() {
120 return Err(anyhow!("empty pubkey"));
121 }
122 if key.chars().any(|c| c.is_control()) {
126 return Err(anyhow!("pubkey contains a control character (multi-line injection?)"));
127 }
128 let mut fields = key.split_whitespace();
131 let key_type = fields.next().ok_or_else(|| anyhow!("pubkey missing key type"))?;
132 let blob = fields.next().ok_or_else(|| anyhow!("pubkey missing key material"))?;
133 let _comment: String = fields.collect::<Vec<_>>().join(" ");
136 if !(key_type.starts_with("ssh-") || key_type.starts_with("ecdsa-") || key_type.starts_with("sk-")) {
137 return Err(anyhow!("unrecognized pubkey type '{key_type}'"));
138 }
139 if blob.is_empty()
141 || !blob.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=')
142 {
143 return Err(anyhow!("pubkey key material is not base64"));
144 }
145 Ok(key.to_string())
146}
147
148pub fn install_authorized_key(device: &str, pubkey: &str) -> Result<()> {
155 let pubkey = validate_pubkey(pubkey)?;
156 let pubkey = pubkey.as_str();
157 let path = authorized_keys_path();
158 if let Some(dir) = path.parent() {
159 std::fs::create_dir_all(dir).context("create ~/.ssh")?;
160 chmod(dir, 0o700);
161 }
162 let existing = std::fs::read_to_string(&path).unwrap_or_default();
163 let mut kept = strip_block(&existing, device);
164 if !kept.is_empty() && !kept.ends_with('\n') {
165 kept.push('\n');
166 }
167 kept.push_str(&format!("{BEGIN} {device}\n{pubkey}\n{END} {device}\n"));
168 SecretFile::write_str(&path, &kept)
169 .context("write authorized_keys")?;
170 Ok(())
171}
172
173pub fn remove_authorized_key(device: &str) -> Result<()> {
176 let path = authorized_keys_path();
177 let Ok(existing) = std::fs::read_to_string(&path) else { return Ok(()) };
178 let kept = strip_block(&existing, device);
179 SecretFile::write_str(&path, &kept)
180 .context("write authorized_keys")?;
181 Ok(())
182}
183
184pub fn strip_block(content: &str, device: &str) -> String {
188 let begin = format!("{BEGIN} {device}");
189 let end = format!("{END} {device}");
190 let mut out = String::new();
191 let mut skipping = false;
192 for line in content.lines() {
193 if line.trim() == begin {
194 skipping = true;
195 continue;
196 }
197 if skipping {
198 if line.trim() == end {
199 skipping = false;
200 }
201 continue;
202 }
203 out.push_str(line);
204 out.push('\n');
205 }
206 out
207}
208
209#[allow(dead_code)] pub fn has_block(content: &str, device: &str) -> bool {
213 content.lines().any(|l| l.trim() == format!("{BEGIN} {device}"))
214}
215
216pub fn host_pubkeys() -> Vec<String> {
223 if let Ok(p) = std::env::var("FILAMENT_SSH_HOSTKEY") {
224 if let Ok(s) = std::fs::read_to_string(&p) {
225 return s.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect();
226 }
227 }
228 let mut keys = Vec::new();
229 if let Ok(rd) = std::fs::read_dir("/etc/ssh") {
230 for e in rd.flatten() {
231 let name = e.file_name();
232 let name = name.to_string_lossy();
233 if name.starts_with("ssh_host_") && name.ends_with(".pub") {
234 if let Ok(s) = std::fs::read_to_string(e.path()) {
235 if let Some(l) = s.lines().next() {
236 let l = l.trim();
237 if !l.is_empty() {
238 keys.push(l.to_string());
239 }
240 }
241 }
242 }
243 }
244 }
245 keys
246}
247
248pub fn pin_host_keys(config_dir: &Path, dest_token: &str, hostkeys: &[String]) -> Result<()> {
255 let path = known_hosts_path(config_dir);
256 if let Some(dir) = path.parent() {
257 std::fs::create_dir_all(dir).context("create filament ssh dir")?;
258 chmod(dir, 0o700);
259 }
260 let existing = std::fs::read_to_string(&path).unwrap_or_default();
261 let mut out: String = existing
263 .lines()
264 .filter(|l| l.split_whitespace().next() != Some(dest_token))
265 .map(|l| format!("{l}\n"))
266 .collect();
267 for k in hostkeys {
268 let k = k.trim();
269 if k.is_empty() {
270 continue;
271 }
272 out.push_str(&format!("{dest_token} {k}\n"));
273 }
274 SecretFile::write_str(&path, &out)
275 .context("write known_hosts")?;
276 Ok(())
277}
278
279fn bootstrap_cache_path(config_dir: &Path) -> PathBuf {
288 ssh_dir(config_dir).join("bootstrap-cache.json")
289}
290
291const BOOTSTRAP_TTL_SECS: u64 = 24 * 3600;
296
297fn now_secs() -> u64 {
298 std::time::SystemTime::now()
299 .duration_since(std::time::UNIX_EPOCH)
300 .map(|d| d.as_secs())
301 .unwrap_or(0)
302}
303
304pub fn host_pinned(config_dir: &Path, dest_token: &str) -> bool {
307 std::fs::read_to_string(known_hosts_path(config_dir))
308 .map(|s| s.lines().any(|l| l.split_whitespace().next() == Some(dest_token)))
309 .unwrap_or(false)
310}
311
312pub fn bootstrap_cache_get(config_dir: &Path, device: &str) -> Option<Option<String>> {
316 let raw = std::fs::read_to_string(bootstrap_cache_path(config_dir)).ok()?;
317 let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
318 let e = v.get(device)?;
319 let ts = e.get("ts")?.as_u64()?;
320 if now_secs().saturating_sub(ts) > BOOTSTRAP_TTL_SECS {
321 return None;
322 }
323 Some(
324 e.get("user")
325 .and_then(|u| u.as_str())
326 .filter(|s| !s.is_empty())
327 .map(String::from),
328 )
329}
330
331pub fn bootstrap_cache_put(config_dir: &Path, device: &str, user: Option<&str>) {
333 let path = bootstrap_cache_path(config_dir);
334 let mut v: serde_json::Value = std::fs::read_to_string(&path)
335 .ok()
336 .and_then(|s| serde_json::from_str(&s).ok())
337 .unwrap_or_else(|| serde_json::json!({}));
338 v[device] = serde_json::json!({ "user": user.unwrap_or(""), "ts": now_secs() });
339 let _ = SecretFile::write_str(&path, &v.to_string());
340}
341
342pub fn bootstrap_cache_clear(config_dir: &Path, device: &str) {
344 let path = bootstrap_cache_path(config_dir);
345 let Ok(s) = std::fs::read_to_string(&path) else { return };
346 let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&s) else { return };
347 if let Some(obj) = v.as_object_mut() {
348 obj.remove(device);
349 }
350 let _ = SecretFile::write_str(&path, &v.to_string());
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 #[test]
358 fn strip_block_removes_only_the_named_device() {
359 let c = "ssh-rsa AAAAother user@host\n\
360 # BEGIN filament-managed boxA\n\
361 ssh-ed25519 AAAAfilament filament-managed\n\
362 # END filament-managed boxA\n\
363 ssh-ed25519 AAAAkeep keep@host\n";
364 let out = strip_block(c, "boxA");
365 assert!(!out.contains("filament-managed"));
366 assert!(out.contains("AAAAother"));
367 assert!(out.contains("AAAAkeep"));
368 assert!(!has_block(&out, "boxA"));
369 }
370
371 #[test]
372 fn validate_pubkey_accepts_a_single_well_formed_key() {
373 let ok = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabcDEF123+/= filament-managed";
374 let v = validate_pubkey(ok).expect("a clean single-line key is accepted");
375 assert_eq!(v, ok);
376 assert!(validate_pubkey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5").is_ok());
378 }
379
380 #[test]
381 fn validate_pubkey_rejects_multiline_injection() {
382 let inj = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 ok\nssh-ed25519 AAAAEVILKEY attacker";
384 assert!(validate_pubkey(inj).is_err(), "interior newline must be rejected");
385 assert!(validate_pubkey("ssh-ed25519 AAAA\rmore").is_err(), "CR must be rejected");
386 assert!(validate_pubkey("ssh-ed25519\tAAAA").is_err(), "tab control char rejected");
387 assert!(validate_pubkey("not-a-key blob").is_err(), "bad key type rejected");
388 assert!(validate_pubkey("ssh-ed25519 not_base64!!").is_err(), "non-base64 blob rejected");
389 assert!(validate_pubkey("").is_err(), "empty rejected");
390
391 let r = install_authorized_key("boxA", inj);
395 assert!(r.is_err(), "install must reject the multi-line key before writing");
396 }
397
398 #[test]
399 fn install_then_strip_is_idempotent_in_memory() {
400 let mut c = String::new();
402 c = strip_block(&c, "boxA");
403 c.push_str(&format!("{BEGIN} boxA\nKEY1\n{END} boxA\n"));
404 let mut c2 = strip_block(&c, "boxA");
406 c2.push_str(&format!("{BEGIN} boxA\nKEY2\n{END} boxA\n"));
407 assert_eq!(c2.matches(BEGIN).count(), 1);
408 assert!(c2.contains("KEY2"));
409 assert!(!c2.contains("KEY1"));
410 }
411}