Skip to main content

authkeys_managed/
lib.rs

1// sshkeys, filament-managed ssh auth material for the seamless `filament ssh`
2// path (docs/design-seamless-ssh.md). NEVER touches the user's ~/.ssh.
3//
4// Two roles:
5//   * INITIATOR keeps an ephemeral managed keypair + a private known_hosts under
6//     the filament config dir. `filament ssh` points ssh at exactly these via
7//     -o IdentityFile / -o UserKnownHostsFile / -o IdentitiesOnly=yes, so a user
8//     with ZERO ssh setup connects with no prompts and no key copying.
9//   * ACCEPTOR installs an initiator's managed pubkey into its OWN
10//     $HOME/.ssh/authorized_keys inside a CLEARLY-MARKED, removable
11//     `# BEGIN/END filament-managed <device>` block, ONLY over the authenticated
12//     channel AND ONLY when the `shell` cap is granted (enforced by the caller).
13//     It also reports its real host public keys so the initiator can pin them.
14
15use anyhow::{anyhow, Context, Result};
16use std::path::{Path, PathBuf};
17
18use secret_write::SecretFile;
19
20/// Directory holding the managed keypair + private known_hosts.
21///
22/// `config_dir` is the filament config root, injected by the caller (the CLI
23/// passes `crate::settings::config_dir()`) so this crate has no coupling to the
24/// CLI's path resolution.
25fn ssh_dir(config_dir: &Path) -> PathBuf {
26    config_dir.join("ssh")
27}
28
29/// Managed private key path (`id_ed25519`). The pubkey is `<this>.pub`.
30pub fn managed_key_path(config_dir: &Path) -> PathBuf {
31    ssh_dir(config_dir).join("id_ed25519")
32}
33
34/// Filament-private known_hosts (pin store), never the user's.
35pub 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
47/// Ensure the managed ed25519 keypair exists; generate it on demand via
48/// `ssh-keygen` if absent. Returns the PUBLIC key line (one line, no trailing
49/// newline). The private key NEVER leaves disk and is never printed.
50pub fn ensure_managed_key(config_dir: &Path) -> Result<String> {
51    let key = managed_key_path(config_dir);
52    // ssh-keygen writes the pubkey to "<key>.pub".
53    let pub_path = PathBuf::from(format!("{}.pub", key.display()));
54
55    // Capture whether THIS invocation created the key: the delete-on-failure
56    // below must only destroy keys we just minted (gap-captured material
57    // must be invalidated before it can be blessed on replay). A pre-existing,
58    // possibly-distributed key must never be deleted on a restrict failure
59    // (that breaks working access without un-exposing anything).
60    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    // Run unconditionally: repairs existing keys whose ACLs were never
78    // applied by the old code. On failure, scope the delete to keys
79    // CREATED in this invocation: a fresh key we could not protect must be
80    // destroyed so gap-captured material is never blessed on a later run.
81    // A pre-existing, possibly-distributed key must NOT be deleted (that
82    // breaks working access and un-exposes nothing); fail loud instead.
83    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
95// ----------------------------------------------------- ACCEPTOR: authorized_keys
96
97const BEGIN: &str = "# BEGIN filament-managed";
98const END: &str = "# END filament-managed";
99
100/// Path to the ACCEPTOR daemon user's authorized_keys ($HOME/.ssh/authorized_keys).
101/// Deliberately rooted at $HOME (NOT the config dir), that is where sshd reads
102/// it. Tests sandbox the write by running the acceptor with HOME set to a temp.
103pub 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
108/// M-3 (authorized_keys injection): validate that `pubkey` is a SINGLE, well-
109/// formed ssh public-key line before it is ever written. A trusted+shell peer
110/// could otherwise send a pubkey containing an interior `\n` (which `.trim()`
111/// does NOT strip) to inject EXTRA authorized_keys lines, extra keys, a
112/// `command=`/`from=` forced-command, etc. We reject anything with a control
113/// character (newline, CR, tab, …) or more than one whitespace-separated key
114/// line, and require the shape `<key-type> <base64-blob> [single-line comment]`.
115///
116/// Returns the trimmed, validated single-line key on success.
117pub 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    // Reject ANY control character (covers \n, \r, \t, NUL, vertical tab, …).
123    // After trimming surrounding whitespace, a control char anywhere means the
124    // value is not a single clean line, refuse outright.
125    if key.chars().any(|c| c.is_control()) {
126        return Err(anyhow!("pubkey contains a control character (multi-line injection?)"));
127    }
128    // Shape: 2 or 3 whitespace-separated fields. Field 0 is the key type, field 1
129    // is the base64 blob, optional field 2 is a single-line comment.
130    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    // The comment may itself contain spaces, so collapse the rest into one field;
134    // what matters is there is no embedded newline (already rejected above).
135    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    // base64 blob: non-empty and only the base64 alphabet (+ '=' padding).
140    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
148/// Install (or replace) `pubkey` in a marked block for `device` in
149/// authorized_keys. Idempotent: a re-grant replaces that device's block rather
150/// than appending a duplicate. Creates ~/.ssh (0700) and the file (0600) if
151/// absent. SECURITY: the caller MUST have verified the trusted channel + `shell`
152/// cap before calling this. The pubkey is re-validated here (M-3), defense in
153/// depth, so a bad key is NEVER written even if a caller forgot to check.
154pub 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
173/// Remove `device`'s marked block from authorized_keys (the "removable" half of
174/// the audit story; used by `filament revoke`). No-op if absent.
175pub 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
184/// Return `content` with the `# BEGIN/END filament-managed <device>` block (and
185/// the lines between) removed. Lines outside any such block are preserved
186/// verbatim. Path-pure (testable), the file I/O wrappers call this.
187pub 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/// True if a marked block for `device` is present (used by the gate to prove the
210/// block is installed / removed).
211#[allow(dead_code)] // exercised by the unit test + grep in the gate
212pub fn has_block(content: &str, device: &str) -> bool {
213    content.lines().any(|l| l.trim() == format!("{BEGIN} {device}"))
214}
215
216// --------------------------------------------------------- ACCEPTOR: host keys
217
218/// Read the acceptor's real public host keys. Prod reads /etc/ssh/ssh_host_*.pub;
219/// the gate points FILAMENT_SSH_HOSTKEY at a throwaway sshd's hostkey pubfile so
220/// it never needs to touch the system's. Returns the raw pubkey lines (e.g.
221/// "ssh-ed25519 AAAA...").
222pub 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
248// ------------------------------------------------------- INITIATOR: known_hosts
249
250/// Pin the acceptor's host keys into our private known_hosts, keyed by the EXACT
251/// destination token ssh will use (so the pin is not silently inert). Replaces
252/// any prior pins for that token (host keys can rotate). Each `hostkeys` entry is
253/// a pubkey line like "ssh-ed25519 AAAA...".
254pub 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    // Drop prior lines for this token (first field == dest_token).
262    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
279// ---- shell-bootstrap fast-path cache ---------------------------------------
280// A repeat `filament ssh <dev>` re-ran the full key/host-key/cap bootstrap every
281// time (a second establish before the data link). Once a device has been
282// bootstrapped its host keys are pinned, our key is installed, and the shell cap
283// was granted, so the next ssh can skip straight to the data link. The skip
284// self-heals: if the device since rotated keys or revoked the cap the ssh
285// attempt fails and the caller falls back to a full bootstrap + one retry.
286
287fn bootstrap_cache_path(config_dir: &Path) -> PathBuf {
288    ssh_dir(config_dir).join("bootstrap-cache.json")
289}
290
291/// How long a recorded bootstrap lets `filament ssh` skip the pre-flight (s).
292/// 24h: repeat ssh stays instant across a working day. A stale skip self-heals,
293/// the ssh-layer 255 retry re-runs a fresh bootstrap, and a cache miss now rides
294/// the daemon's warm link anyway, so a longer window costs nothing.
295const 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
304/// True if `dest_token` already has a pinned host key in our known_hosts (the
305/// bootstrap already ran for it at least once).
306pub 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
312/// The cached login for `device` if its bootstrap is still fresh, else None
313/// (None = do a full bootstrap). Outer Some = fresh cache hit; the inner Option
314/// is the login account the acceptor last reported (None when it reported none).
315pub 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
331/// Record a successful bootstrap so the next `filament ssh` to `device` skips it.
332pub 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
342/// Drop the cached bootstrap for `device` (forces a full bootstrap next time).
343pub 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        // No-comment form is fine too.
377        assert!(validate_pubkey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5").is_ok());
378    }
379
380    #[test]
381    fn validate_pubkey_rejects_multiline_injection() {
382        // M-3: a newline-bearing pubkey must be refused and NEVER reach the file.
383        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        // And the install path must refuse it too (defense in depth): the
392        // validation runs BEFORE any filesystem write, so install_authorized_key
393        // errors out on a multi-line key and never touches authorized_keys.
394        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        // Re-install must not duplicate: strip then add yields exactly one block.
401        let mut c = String::new();
402        c = strip_block(&c, "boxA");
403        c.push_str(&format!("{BEGIN} boxA\nKEY1\n{END} boxA\n"));
404        // simulate re-grant with a new key
405        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}