Skip to main content

clawft_kernel/
node_key.rs

1//! File-backed node key (`<runtime-dir>/node.key`).
2//!
3//! The 32-byte Ed25519 seed the node id is derived from (ADR-025, ADR-103
4//! D11). Shared by the daemon and `weft kernel boot --foreground` so both
5//! get the same stable id.
6
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11use ed25519_dalek::SigningKey;
12use rand::RngCore;
13use rand::rngs::OsRng;
14
15/// Filename of the node keypair under the runtime directory.
16pub const NODE_KEY_FILE: &str = "node.key";
17
18/// Errors loading or creating the node key.
19#[derive(Debug, thiserror::Error)]
20pub enum NodeKeyError {
21    /// I/O failure reading or writing the key file.
22    #[error("keyfile io: {0}")]
23    Io(#[from] io::Error),
24    /// The key file is a symlink (or otherwise unsafe) and its target is not
25    /// private and owned by us; refused rather than used.
26    #[error("keyfile {path:?} refused: {reason}")]
27    Insecure {
28        /// Path that was inspected.
29        path: PathBuf,
30        /// Why it was refused.
31        reason: String,
32    },
33    /// Key file exists but is not 32 bytes.
34    #[error("keyfile {path:?} is malformed: expected 32 bytes, got {got}")]
35    Malformed {
36        /// Path that was read.
37        path: PathBuf,
38        /// Bytes actually present.
39        got: usize,
40    },
41}
42
43/// Load `<runtime_dir>/node.key`, creating it (mode 0600) if absent. The
44/// runtime dir is created too.
45///
46/// Creation is race-free where the filesystem allows: the seed is written
47/// to a private temp file in the same directory, then hard-linked to
48/// `node.key`. The link fails if the target already exists (create_new
49/// semantics), and a reader never sees a partially written key. On
50/// filesystems without hard links (exFAT, some network mounts) it falls back
51/// to `create_new` + write, and readers retry briefly on a zero-length file.
52/// Stale temp files (they hold the seed) are removed on entry.
53pub fn load_or_generate_node_key(runtime_dir: &Path) -> Result<SigningKey, NodeKeyError> {
54    load_or_generate_key_file(runtime_dir, NODE_KEY_FILE)
55}
56
57/// [`load_or_generate_node_key`] for another key file in `dir` (a plain file
58/// name, no path separators), with the same creation and permission rules.
59/// The daemon's placement control key in service mode uses it (ADR-106).
60pub fn load_or_generate_key_file(dir: &Path, file: &str) -> Result<SigningKey, NodeKeyError> {
61    if file.is_empty() || file.contains(['/', '\\']) || file.starts_with('.') {
62        return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("bad key file name {file:?}")).into());
63    }
64    fs::create_dir_all(dir)?;
65    remove_stale_temps(dir, file);
66    let path = dir.join(file);
67    if fs::symlink_metadata(&path).is_ok() {
68        return load_existing(&path);
69    }
70    let mut seed = [0u8; 32];
71    OsRng.fill_bytes(&mut seed);
72    match publish_private(dir, &path, file, &seed) {
73        Ok(()) => Ok(SigningKey::from_bytes(&seed)),
74        // Lost a race with another process creating the same key.
75        Err(e) if e.kind() == io::ErrorKind::AlreadyExists => load_existing(&path),
76        Err(e) => Err(e.into()),
77    }
78}
79
80/// How old a `.<file>.*.tmp` must be before it is treated as abandoned.
81const STALE_TMP_AGE: std::time::Duration = std::time::Duration::from_secs(30);
82
83fn remove_stale_temps(dir: &Path, file: &str) {
84    let prefix = format!(".{file}.");
85    let Ok(rd) = fs::read_dir(dir) else { return };
86    for e in rd.filter_map(Result::ok) {
87        let name = e.file_name();
88        let name = name.to_string_lossy();
89        if !(name.starts_with(&prefix) && name.ends_with(".tmp")) {
90            continue;
91        }
92        let old = e
93            .metadata()
94            .and_then(|m| m.modified())
95            .ok()
96            .and_then(|t| t.elapsed().ok())
97            .is_some_and(|age| age >= STALE_TMP_AGE);
98        if old {
99            let _ = fs::remove_file(e.path());
100        }
101    }
102}
103
104fn read_seed(path: &Path) -> Result<Vec<u8>, NodeKeyError> {
105    // A creator using the non-atomic fallback may not have written yet.
106    let mut bytes = fs::read(path)?;
107    for _ in 0..20 {
108        if !bytes.is_empty() {
109            break;
110        }
111        std::thread::sleep(std::time::Duration::from_millis(10));
112        bytes = fs::read(path)?;
113    }
114    Ok(bytes)
115}
116
117fn load_existing(path: &Path) -> Result<SigningKey, NodeKeyError> {
118    check_and_tighten_perms(path)?;
119    let bytes = read_seed(path)?;
120    if bytes.len() != 32 {
121        return Err(NodeKeyError::Malformed {
122            path: path.to_path_buf(),
123            got: bytes.len(),
124        });
125    }
126    let mut seed = [0u8; 32];
127    seed.copy_from_slice(&bytes);
128    Ok(SigningKey::from_bytes(&seed))
129}
130
131fn private_open_options() -> fs::OpenOptions {
132    let mut opts = fs::OpenOptions::new();
133    opts.write(true).create_new(true);
134    #[cfg(unix)]
135    {
136        use std::os::unix::fs::OpenOptionsExt;
137        opts.mode(0o600);
138    }
139    opts
140}
141
142/// Write `seed` to a 0600 temp file, sync it, hard-link it to `path`
143/// (failing with `AlreadyExists` if present), and remove the temp name.
144/// If linking is unsupported, fall back to `create_new` + write.
145fn publish_private(dir: &Path, path: &Path, file: &str, seed: &[u8; 32]) -> io::Result<()> {
146    use std::io::Write;
147    // Unique per call (pid + random), so concurrent creators never share a temp.
148    let tmp = dir.join(format!(
149        ".{file}.{}.{:016x}.tmp",
150        std::process::id(),
151        OsRng.next_u64()
152    ));
153    let linked = (|| {
154        let mut f = private_open_options().open(&tmp)?;
155        f.write_all(seed)?;
156        f.sync_all()?;
157        fs::hard_link(&tmp, path)
158    })();
159    let _ = fs::remove_file(&tmp);
160    match linked {
161        Ok(()) => Ok(()),
162        Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
163        Err(e) => {
164            tracing::warn!(error = %e, "hard-linking node.key unsupported here; using create_new + write");
165            let mut f = private_open_options().open(path)?;
166            f.write_all(seed)?;
167            f.sync_all()
168        }
169    }
170}
171
172/// Inspect the key file without following symlinks. A regular file that is
173/// group/other accessible is warned about and, when we own it, tightened to
174/// 0600. A symlink is never chmod'd: it is used only if its target is owned
175/// by us with private permissions, otherwise refused (fail closed).
176#[cfg(unix)]
177fn check_and_tighten_perms(path: &Path) -> Result<(), NodeKeyError> {
178    use std::os::unix::fs::{MetadataExt, PermissionsExt};
179    let Ok(link_meta) = fs::symlink_metadata(path) else {
180        return Ok(());
181    };
182    let dir_uid = path
183        .parent()
184        .and_then(|d| fs::metadata(d).ok())
185        .map(|d| d.uid());
186    if link_meta.file_type().is_symlink() {
187        tracing::warn!(path = %path.display(), "node key is a symlink; not changing its permissions");
188        let target = fs::metadata(path).map_err(|e| NodeKeyError::Insecure {
189            path: path.to_path_buf(),
190            reason: format!("symlink target unreadable: {e}"),
191        })?;
192        let mode = target.permissions().mode() & 0o777;
193        if Some(target.uid()) != dir_uid || mode & 0o077 != 0 {
194            return Err(NodeKeyError::Insecure {
195                path: path.to_path_buf(),
196                reason: format!(
197                    "symlink target must be owned by the runtime dir owner with mode 600 (found mode {mode:o})"
198                ),
199            });
200        }
201        return Ok(());
202    }
203    let mode = link_meta.permissions().mode() & 0o777;
204    if mode & 0o077 == 0 {
205        return Ok(());
206    }
207    tracing::warn!(
208        path = %path.display(),
209        mode = format!("{mode:o}"),
210        "node key file is readable by group/others"
211    );
212    if Some(link_meta.uid()) == dir_uid {
213        match fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
214            Ok(()) => tracing::warn!(path = %path.display(), "node key mode tightened to 600"),
215            Err(e) => tracing::warn!(path = %path.display(), error = %e, "could not tighten node key mode"),
216        }
217    }
218    Ok(())
219}
220
221#[cfg(not(unix))]
222fn check_and_tighten_perms(_path: &Path) -> Result<(), NodeKeyError> {
223    Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn creates_then_reloads_same_key_with_private_mode() {
232        let dir = tempfile::tempdir().unwrap();
233        let a = load_or_generate_node_key(dir.path()).unwrap();
234        let b = load_or_generate_node_key(dir.path()).unwrap();
235        assert_eq!(a.to_bytes(), b.to_bytes());
236        #[cfg(unix)]
237        {
238            use std::os::unix::fs::PermissionsExt;
239            let mode = fs::metadata(dir.path().join(NODE_KEY_FILE))
240                .unwrap()
241                .permissions()
242                .mode()
243                & 0o777;
244            assert_eq!(mode, 0o600);
245        }
246    }
247
248    #[test]
249    #[cfg(unix)]
250    fn loose_mode_is_tightened_on_load() {
251        use std::os::unix::fs::PermissionsExt;
252        let dir = tempfile::tempdir().unwrap();
253        let a = load_or_generate_node_key(dir.path()).unwrap();
254        let p = dir.path().join(NODE_KEY_FILE);
255        fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap();
256        let b = load_or_generate_node_key(dir.path()).unwrap();
257        assert_eq!(a.to_bytes(), b.to_bytes());
258        assert_eq!(fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
259    }
260
261    #[test]
262    fn concurrent_creators_agree_and_never_see_an_empty_key() {
263        let dir = tempfile::tempdir().unwrap();
264        let path = dir.path().to_path_buf();
265        let handles: Vec<_> = (0..8)
266            .map(|_| {
267                let p = path.clone();
268                std::thread::spawn(move || load_or_generate_node_key(&p).unwrap().to_bytes())
269            })
270            .collect();
271        let keys: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
272        assert!(keys.windows(2).all(|w| w[0] == w[1]), "all creators must agree");
273        // No temp files left behind.
274        let leftovers = fs::read_dir(&path)
275            .unwrap()
276            .filter_map(Result::ok)
277            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
278            .count();
279        assert_eq!(leftovers, 0);
280    }
281
282    #[test]
283    fn malformed_key_is_an_error() {
284        let dir = tempfile::tempdir().unwrap();
285        fs::write(dir.path().join(NODE_KEY_FILE), b"short").unwrap();
286        assert!(matches!(
287            load_or_generate_node_key(dir.path()),
288            Err(NodeKeyError::Malformed { got: 5, .. })
289        ));
290    }
291
292    #[test]
293    fn stale_temp_files_holding_the_seed_are_removed_fresh_ones_kept() {
294        let dir = tempfile::tempdir().unwrap();
295        let stale = dir.path().join(".node.key.1.00.tmp");
296        let fresh = dir.path().join(".node.key.2.11.tmp");
297        fs::write(&stale, [7u8; 32]).unwrap();
298        fs::write(&fresh, [8u8; 32]).unwrap();
299        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
300        fs::File::options().write(true).open(&stale).unwrap().set_modified(old).unwrap();
301        load_or_generate_node_key(dir.path()).unwrap();
302        assert!(!stale.exists(), "stale temp must be removed");
303        assert!(fresh.exists(), "a possibly live temp must be kept");
304    }
305
306    #[test]
307    fn empty_key_file_is_retried_then_reported_malformed() {
308        let dir = tempfile::tempdir().unwrap();
309        fs::write(dir.path().join(NODE_KEY_FILE), b"").unwrap();
310        assert!(matches!(
311            load_or_generate_node_key(dir.path()),
312            Err(NodeKeyError::Malformed { got: 0, .. })
313        ));
314    }
315
316    #[cfg(unix)]
317    #[test]
318    fn symlinked_key_is_not_chmodded_and_loose_target_is_refused() {
319        use std::os::unix::fs::PermissionsExt;
320        let dir = tempfile::tempdir().unwrap();
321        let store = tempfile::tempdir().unwrap();
322        let target = store.path().join("real.key");
323        fs::write(&target, [5u8; 32]).unwrap();
324        fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
325        std::os::unix::fs::symlink(&target, dir.path().join(NODE_KEY_FILE)).unwrap();
326
327        // Loose target: refused, and its mode is left alone.
328        assert!(matches!(
329            load_or_generate_node_key(dir.path()),
330            Err(NodeKeyError::Insecure { .. })
331        ));
332        assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o644);
333
334        // Private target owned by us: used, still not chmodded.
335        fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap();
336        let k = load_or_generate_node_key(dir.path()).unwrap();
337        assert_eq!(k.to_bytes(), [5u8; 32]);
338        assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
339    }
340}