Skip to main content

cloud/
identities.rs

1//! Stub local identity registry: `.yah/cloud/identities/<machine>.json`.
2//!
3//! Phase 1 (R092-F8) bootstrap layer. The "broker" is a local JSON file; once
4//! R034 ships a real global identity broker, the CLI will POST there as well.
5//! Until then, the local file IS the source of truth for machine hostkeys.
6//!
7//! Self-attested mode: fingerprints written here carry `self_attested: true`.
8//! The re-sign step (posting to the real R034 broker and clearing the flag)
9//! is gated on broker availability and ships as a follow-on.
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::path::Path;
14
15/// A machine's hostkey fingerprint entry in the local stub registry.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct LocalIdentity {
18    /// Machine name, matches `machines/<machine>.toml`.
19    pub machine: String,
20    /// OpenSSH-style fingerprint, e.g. `SHA256:abc123…` (no padding).
21    pub fingerprint: String,
22    /// Algorithm, e.g. `ssh-ed25519`.
23    pub algorithm: String,
24    /// UNIX epoch seconds when this entry was last written.
25    pub attested_at_secs: u64,
26    /// `true` until the entry has been confirmed by a real identity broker
27    /// (R034). Self-attested entries are valid for Phase 1; re-sign via
28    /// `yah cloud identity re-sign` once the broker is deployed.
29    pub self_attested: bool,
30}
31
32/// Write (or overwrite) a machine's fingerprint to the stub local registry.
33///
34/// Path: `<cloud_dir>/identities/<machine>.json`.
35/// Uses a write-tmp + rename so the file is never left in a partial state.
36pub fn register(cloud_dir: &Path, machine: &str, fingerprint: &str, algorithm: &str) -> Result<()> {
37    let dir = cloud_dir.join("identities");
38    std::fs::create_dir_all(&dir)
39        .with_context(|| format!("creating identity dir {}", dir.display()))?;
40
41    let id = LocalIdentity {
42        machine: machine.to_string(),
43        fingerprint: fingerprint.to_string(),
44        algorithm: algorithm.to_string(),
45        attested_at_secs: unix_now_secs(),
46        self_attested: true,
47    };
48
49    let path = dir.join(format!("{machine}.json"));
50    let tmp = path.with_extension("json.tmp");
51    let content = serde_json::to_string_pretty(&id).context("serializing local identity")?;
52    std::fs::write(&tmp, &content).with_context(|| format!("writing {}", tmp.display()))?;
53    std::fs::rename(&tmp, &path)
54        .with_context(|| format!("renaming {} → {}", tmp.display(), path.display()))?;
55    Ok(())
56}
57
58/// Look up a machine's entry in the stub local registry. Returns `None` if
59/// no entry has been written yet (machine not yet provisioned or attached).
60pub fn lookup(cloud_dir: &Path, machine: &str) -> Result<Option<LocalIdentity>> {
61    let path = cloud_dir.join("identities").join(format!("{machine}.json"));
62    if !path.exists() {
63        return Ok(None);
64    }
65    let content =
66        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
67    serde_json::from_str(&content)
68        .map(Some)
69        .with_context(|| format!("parsing {}", path.display()))
70}
71
72fn unix_now_secs() -> u64 {
73    std::time::SystemTime::now()
74        .duration_since(std::time::UNIX_EPOCH)
75        .unwrap_or_default()
76        .as_secs()
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use tempfile::TempDir;
83
84    const MACHINE: &str = "noisetable-pdx-1";
85    const FP: &str = "SHA256:HAo2DsB7cN+GmrEbJ8SR305rJagwQhgP2dNyUemUBbU";
86    const ALGO: &str = "ssh-ed25519";
87
88    #[test]
89    fn lookup_returns_none_when_not_registered() {
90        let tmp = TempDir::new().unwrap();
91        let cloud_dir = tmp.path();
92        assert!(lookup(cloud_dir, MACHINE).unwrap().is_none());
93    }
94
95    #[test]
96    fn register_then_lookup_round_trips() {
97        let tmp = TempDir::new().unwrap();
98        let cloud_dir = tmp.path();
99        register(cloud_dir, MACHINE, FP, ALGO).unwrap();
100        let entry = lookup(cloud_dir, MACHINE).unwrap().unwrap();
101        assert_eq!(entry.machine, MACHINE);
102        assert_eq!(entry.fingerprint, FP);
103        assert_eq!(entry.algorithm, ALGO);
104        assert!(entry.self_attested);
105        assert!(entry.attested_at_secs > 0);
106    }
107
108    #[test]
109    fn register_is_idempotent_and_overwrites() {
110        let tmp = TempDir::new().unwrap();
111        let cloud_dir = tmp.path();
112        register(cloud_dir, MACHINE, FP, ALGO).unwrap();
113        let new_fp = "SHA256:ZZZnewfingerprint";
114        register(cloud_dir, MACHINE, new_fp, ALGO).unwrap();
115        let entry = lookup(cloud_dir, MACHINE).unwrap().unwrap();
116        assert_eq!(entry.fingerprint, new_fp);
117    }
118
119    #[test]
120    fn register_creates_identities_subdir() {
121        let tmp = TempDir::new().unwrap();
122        let cloud_dir = tmp.path();
123        // identities/ does not exist yet
124        assert!(!cloud_dir.join("identities").exists());
125        register(cloud_dir, MACHINE, FP, ALGO).unwrap();
126        assert!(cloud_dir.join("identities").is_dir());
127        assert!(cloud_dir
128            .join("identities")
129            .join(format!("{MACHINE}.json"))
130            .exists());
131    }
132
133    #[test]
134    fn separate_machines_have_separate_files() {
135        let tmp = TempDir::new().unwrap();
136        let cloud_dir = tmp.path();
137        register(cloud_dir, "machine-a", FP, ALGO).unwrap();
138        register(cloud_dir, "machine-b", "SHA256:other", ALGO).unwrap();
139        let a = lookup(cloud_dir, "machine-a").unwrap().unwrap();
140        let b = lookup(cloud_dir, "machine-b").unwrap().unwrap();
141        assert_eq!(a.fingerprint, FP);
142        assert_eq!(b.fingerprint, "SHA256:other");
143    }
144}