use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use crate::settings::config_dir;
fn ssh_dir() -> PathBuf {
config_dir().join("ssh")
}
pub fn managed_key_path() -> PathBuf {
ssh_dir().join("id_ed25519")
}
pub fn known_hosts_path() -> PathBuf {
ssh_dir().join("known_hosts")
}
#[cfg(unix)]
fn chmod(p: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode));
}
#[cfg(not(unix))]
fn chmod(_p: &Path, _mode: u32) {}
pub fn ensure_managed_key() -> Result<String> {
let key = managed_key_path();
let pub_path = PathBuf::from(format!("{}.pub", key.display()));
if !key.exists() {
if let Some(dir) = key.parent() {
std::fs::create_dir_all(dir).context("create filament ssh dir")?;
chmod(dir, 0o700);
}
let st = std::process::Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-C", "filament-managed", "-f"])
.arg(&key)
.status()
.context("run ssh-keygen (is openssh-client installed?)")?;
if !st.success() {
return Err(anyhow!("ssh-keygen failed to create the managed key"));
}
crate::platform::SecretFile::restrict(&key);
chmod(&pub_path, 0o644);
}
let line = std::fs::read_to_string(&pub_path).context("read managed pubkey")?;
Ok(line.trim().to_string())
}
const BEGIN: &str = "# BEGIN filament-managed";
const END: &str = "# END filament-managed";
pub fn authorized_keys_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".ssh/authorized_keys")
}
pub fn validate_pubkey(pubkey: &str) -> Result<String> {
let key = pubkey.trim();
if key.is_empty() {
return Err(anyhow!("empty pubkey"));
}
if key.chars().any(|c| c.is_control()) {
return Err(anyhow!("pubkey contains a control character (multi-line injection?)"));
}
let mut fields = key.split_whitespace();
let key_type = fields.next().ok_or_else(|| anyhow!("pubkey missing key type"))?;
let blob = fields.next().ok_or_else(|| anyhow!("pubkey missing key material"))?;
let _comment: String = fields.collect::<Vec<_>>().join(" ");
if !(key_type.starts_with("ssh-") || key_type.starts_with("ecdsa-") || key_type.starts_with("sk-")) {
return Err(anyhow!("unrecognized pubkey type '{key_type}'"));
}
if blob.is_empty()
|| !blob.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=')
{
return Err(anyhow!("pubkey key material is not base64"));
}
Ok(key.to_string())
}
pub fn install_authorized_key(device: &str, pubkey: &str) -> Result<()> {
let pubkey = validate_pubkey(pubkey)?;
let pubkey = pubkey.as_str();
let path = authorized_keys_path();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).context("create ~/.ssh")?;
chmod(dir, 0o700);
}
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut kept = strip_block(&existing, device);
if !kept.is_empty() && !kept.ends_with('\n') {
kept.push('\n');
}
kept.push_str(&format!("{BEGIN} {device}\n{pubkey}\n{END} {device}\n"));
crate::platform::SecretFile::write_str(&path, &kept)
.context("write authorized_keys")?;
Ok(())
}
pub fn remove_authorized_key(device: &str) -> Result<()> {
let path = authorized_keys_path();
let Ok(existing) = std::fs::read_to_string(&path) else { return Ok(()) };
let kept = strip_block(&existing, device);
crate::platform::SecretFile::write_str(&path, &kept)
.context("write authorized_keys")?;
Ok(())
}
pub fn strip_block(content: &str, device: &str) -> String {
let begin = format!("{BEGIN} {device}");
let end = format!("{END} {device}");
let mut out = String::new();
let mut skipping = false;
for line in content.lines() {
if line.trim() == begin {
skipping = true;
continue;
}
if skipping {
if line.trim() == end {
skipping = false;
}
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
#[allow(dead_code)] pub fn has_block(content: &str, device: &str) -> bool {
content.lines().any(|l| l.trim() == format!("{BEGIN} {device}"))
}
pub fn host_pubkeys() -> Vec<String> {
if let Ok(p) = std::env::var("FILAMENT_SSH_HOSTKEY") {
if let Ok(s) = std::fs::read_to_string(&p) {
return s.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect();
}
}
let mut keys = Vec::new();
if let Ok(rd) = std::fs::read_dir("/etc/ssh") {
for e in rd.flatten() {
let name = e.file_name();
let name = name.to_string_lossy();
if name.starts_with("ssh_host_") && name.ends_with(".pub") {
if let Ok(s) = std::fs::read_to_string(e.path()) {
if let Some(l) = s.lines().next() {
let l = l.trim();
if !l.is_empty() {
keys.push(l.to_string());
}
}
}
}
}
}
keys
}
pub fn pin_host_keys(dest_token: &str, hostkeys: &[String]) -> Result<()> {
let path = known_hosts_path();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).context("create filament ssh dir")?;
chmod(dir, 0o700);
}
let existing = std::fs::read_to_string(&path).unwrap_or_default();
let mut out: String = existing
.lines()
.filter(|l| l.split_whitespace().next() != Some(dest_token))
.map(|l| format!("{l}\n"))
.collect();
for k in hostkeys {
let k = k.trim();
if k.is_empty() {
continue;
}
out.push_str(&format!("{dest_token} {k}\n"));
}
crate::platform::SecretFile::write_str(&path, &out)
.context("write known_hosts")?;
Ok(())
}
fn bootstrap_cache_path() -> PathBuf {
ssh_dir().join("bootstrap-cache.json")
}
const BOOTSTRAP_TTL_SECS: u64 = 24 * 3600;
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn host_pinned(dest_token: &str) -> bool {
std::fs::read_to_string(known_hosts_path())
.map(|s| s.lines().any(|l| l.split_whitespace().next() == Some(dest_token)))
.unwrap_or(false)
}
pub fn bootstrap_cache_get(device: &str) -> Option<Option<String>> {
let raw = std::fs::read_to_string(bootstrap_cache_path()).ok()?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
let e = v.get(device)?;
let ts = e.get("ts")?.as_u64()?;
if now_secs().saturating_sub(ts) > BOOTSTRAP_TTL_SECS {
return None;
}
Some(
e.get("user")
.and_then(|u| u.as_str())
.filter(|s| !s.is_empty())
.map(String::from),
)
}
pub fn bootstrap_cache_put(device: &str, user: Option<&str>) {
let path = bootstrap_cache_path();
let mut v: serde_json::Value = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
v[device] = serde_json::json!({ "user": user.unwrap_or(""), "ts": now_secs() });
let _ = crate::platform::SecretFile::write_str(&path, &v.to_string());
}
pub fn bootstrap_cache_clear(device: &str) {
let path = bootstrap_cache_path();
let Ok(s) = std::fs::read_to_string(&path) else { return };
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&s) else { return };
if let Some(obj) = v.as_object_mut() {
obj.remove(device);
}
let _ = crate::platform::SecretFile::write_str(&path, &v.to_string());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_block_removes_only_the_named_device() {
let c = "ssh-rsa AAAAother user@host\n\
# BEGIN filament-managed boxA\n\
ssh-ed25519 AAAAfilament filament-managed\n\
# END filament-managed boxA\n\
ssh-ed25519 AAAAkeep keep@host\n";
let out = strip_block(c, "boxA");
assert!(!out.contains("filament-managed"));
assert!(out.contains("AAAAother"));
assert!(out.contains("AAAAkeep"));
assert!(!has_block(&out, "boxA"));
}
#[test]
fn validate_pubkey_accepts_a_single_well_formed_key() {
let ok = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIabcDEF123+/= filament-managed";
let v = validate_pubkey(ok).expect("a clean single-line key is accepted");
assert_eq!(v, ok);
assert!(validate_pubkey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5").is_ok());
}
#[test]
fn validate_pubkey_rejects_multiline_injection() {
let inj = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 ok\nssh-ed25519 AAAAEVILKEY attacker";
assert!(validate_pubkey(inj).is_err(), "interior newline must be rejected");
assert!(validate_pubkey("ssh-ed25519 AAAA\rmore").is_err(), "CR must be rejected");
assert!(validate_pubkey("ssh-ed25519\tAAAA").is_err(), "tab control char rejected");
assert!(validate_pubkey("not-a-key blob").is_err(), "bad key type rejected");
assert!(validate_pubkey("ssh-ed25519 not_base64!!").is_err(), "non-base64 blob rejected");
assert!(validate_pubkey("").is_err(), "empty rejected");
let r = install_authorized_key("boxA", inj);
assert!(r.is_err(), "install must reject the multi-line key before writing");
}
#[test]
fn install_then_strip_is_idempotent_in_memory() {
let mut c = String::new();
c = strip_block(&c, "boxA");
c.push_str(&format!("{BEGIN} boxA\nKEY1\n{END} boxA\n"));
let mut c2 = strip_block(&c, "boxA");
c2.push_str(&format!("{BEGIN} boxA\nKEY2\n{END} boxA\n"));
assert_eq!(c2.matches(BEGIN).count(), 1);
assert!(c2.contains("KEY2"));
assert!(!c2.contains("KEY1"));
}
}