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
39    let id = LocalIdentity {
40        machine: machine.to_string(),
41        fingerprint: fingerprint.to_string(),
42        algorithm: algorithm.to_string(),
43        attested_at_secs: unix_now_secs(),
44        self_attested: true,
45    };
46
47    let path = dir.join(format!("{machine}.json"));
48    let content = serde_json::to_string_pretty(&id).context("serializing local identity")?;
49    // R925: the staging path used to be `<machine>.json.tmp`, which every
50    // concurrent writer of this entry shares. `yah cloud bootstrap` and `yah
51    // cloud attach` both land here as SEPARATE PROCESSES against one
52    // `<cloud_dir>`, and a re-attach during provisioning routinely overlaps
53    // them on the same machine name — so two writes interleaved into one
54    // staging file and the rename published whichever fragment won. The
55    // registry is the source of truth for machine hostkeys and a malformed
56    // entry reads as "not provisioned" rather than as an error.
57    //
58    // Default permissions are deliberate: the payload is a public hostkey
59    // FINGERPRINT, not key material, so this does not need the hand-rolled 0600
60    // staging that `yubaba::tenant_passway::write_if_changed` keeps.
61    crate::atomic_write::write_atomic(&path, content.as_bytes())
62        .with_context(|| format!("registering local identity {}", path.display()))
63}
64
65/// Look up a machine's entry in the stub local registry. Returns `None` if
66/// no entry has been written yet (machine not yet provisioned or attached).
67pub fn lookup(cloud_dir: &Path, machine: &str) -> Result<Option<LocalIdentity>> {
68    let path = cloud_dir.join("identities").join(format!("{machine}.json"));
69    if !path.exists() {
70        return Ok(None);
71    }
72    let content =
73        std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
74    serde_json::from_str(&content)
75        .map(Some)
76        .with_context(|| format!("parsing {}", path.display()))
77}
78
79fn unix_now_secs() -> u64 {
80    std::time::SystemTime::now()
81        .duration_since(std::time::UNIX_EPOCH)
82        .unwrap_or_default()
83        .as_secs()
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89    use tempfile::TempDir;
90
91    const MACHINE: &str = "noisetable-pdx-1";
92    const FP: &str = "SHA256:HAo2DsB7cN+GmrEbJ8SR305rJagwQhgP2dNyUemUBbU";
93    const ALGO: &str = "ssh-ed25519";
94
95    #[test]
96    fn lookup_returns_none_when_not_registered() {
97        let tmp = TempDir::new().unwrap();
98        let cloud_dir = tmp.path();
99        assert!(lookup(cloud_dir, MACHINE).unwrap().is_none());
100    }
101
102    #[test]
103    fn register_then_lookup_round_trips() {
104        let tmp = TempDir::new().unwrap();
105        let cloud_dir = tmp.path();
106        register(cloud_dir, MACHINE, FP, ALGO).unwrap();
107        let entry = lookup(cloud_dir, MACHINE).unwrap().unwrap();
108        assert_eq!(entry.machine, MACHINE);
109        assert_eq!(entry.fingerprint, FP);
110        assert_eq!(entry.algorithm, ALGO);
111        assert!(entry.self_attested);
112        assert!(entry.attested_at_secs > 0);
113    }
114
115    #[test]
116    fn register_is_idempotent_and_overwrites() {
117        let tmp = TempDir::new().unwrap();
118        let cloud_dir = tmp.path();
119        register(cloud_dir, MACHINE, FP, ALGO).unwrap();
120        let new_fp = "SHA256:ZZZnewfingerprint";
121        register(cloud_dir, MACHINE, new_fp, ALGO).unwrap();
122        let entry = lookup(cloud_dir, MACHINE).unwrap().unwrap();
123        assert_eq!(entry.fingerprint, new_fp);
124
125        // R925: repeat writes must not accumulate staging files. Since the
126        // staging name carries a pid and a sequence number, no later write ever
127        // reuses one, so a missed cleanup grows this directory without bound.
128        // Scanned as "any `.tmp` entry" on purpose — the obvious spelling,
129        // `assert!(!path.with_extension("json.tmp").exists())`, passes VACUOUSLY
130        // now that nothing writes that fixed name, and would leave this test
131        // green while covering nothing.
132        let strays: Vec<_> = std::fs::read_dir(cloud_dir.join("identities"))
133            .unwrap()
134            .flatten()
135            .map(|e| e.file_name().to_string_lossy().into_owned())
136            .filter(|n| n.ends_with(".tmp"))
137            .collect();
138        assert!(strays.is_empty(), "staging files leaked: {strays:?}");
139    }
140
141    #[test]
142    fn register_creates_identities_subdir() {
143        let tmp = TempDir::new().unwrap();
144        let cloud_dir = tmp.path();
145        // identities/ does not exist yet
146        assert!(!cloud_dir.join("identities").exists());
147        register(cloud_dir, MACHINE, FP, ALGO).unwrap();
148        assert!(cloud_dir.join("identities").is_dir());
149        assert!(cloud_dir
150            .join("identities")
151            .join(format!("{MACHINE}.json"))
152            .exists());
153    }
154
155    #[test]
156    fn separate_machines_have_separate_files() {
157        let tmp = TempDir::new().unwrap();
158        let cloud_dir = tmp.path();
159        register(cloud_dir, "machine-a", FP, ALGO).unwrap();
160        register(cloud_dir, "machine-b", "SHA256:other", ALGO).unwrap();
161        let a = lookup(cloud_dir, "machine-a").unwrap().unwrap();
162        let b = lookup(cloud_dir, "machine-b").unwrap().unwrap();
163        assert_eq!(a.fingerprint, FP);
164        assert_eq!(b.fingerprint, "SHA256:other");
165    }
166}