Skip to main content

clawft_kernel/workload_runtime/
seed_creds.rs

1//! Persistent operator secret store for per-Seed tokens.
2//!
3//! One file per Seed under a private directory (`0700`), each file `0600`,
4//! written atomically (temp file, fsync, rename). Tokens survive daemon
5//! restarts and never expire on their own; a new pairing replaces them.
6//! Reads refuse a symlink, a non-regular file or a file other users can
7//! read, so a token planted or exposed by someone else is not used.
8//!
9//! The operator default is `<runtime dir>/secrets/workload.seed/`, where the
10//! runtime dir is `$WEFTOS_RUNTIME_DIR` or `~/.clawft` (the same root the
11//! chain uses), so probe and test daemons never read the operator's tokens.
12
13use std::fs;
14use std::io::Write;
15use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt, PermissionsExt};
16use std::path::{Path, PathBuf};
17
18use clawft_types::runtime_paths::{RootSource, RuntimePaths};
19use clawft_types::secret::SecretString;
20
21use super::seed_http::SeedCredentials;
22use super::types::RuntimeError;
23use crate::workload_pkg::manifest::valid_token;
24
25/// Sub-path of the runtime dir that holds Seed tokens.
26pub const SEED_SECRETS_SUBDIR: &str = "secrets/workload.seed";
27/// Largest token accepted.
28const MAX_TOKEN: u64 = 512;
29
30/// File-backed [`SeedCredentials`].
31#[derive(Debug, Clone)]
32pub struct FileCredentials {
33    dir: PathBuf,
34}
35
36fn io(what: &str, e: std::io::Error) -> RuntimeError {
37    RuntimeError::Backend(format!("seed credential {what}: {e}"))
38}
39
40/// Operator store directory for `paths`, and whether it is the legacy one.
41fn operator_dir(paths: &RuntimePaths, home: Option<&Path>) -> (PathBuf, bool) {
42    let new = paths.root().join(SEED_SECRETS_SUBDIR);
43    if !new.exists()
44        && matches!(paths.source(), RootSource::Project(_))
45        && let Some(legacy) = home.map(|h| h.join(".clawft").join(SEED_SECRETS_SUBDIR))
46        && legacy.is_dir()
47    {
48        return (legacy, true);
49    }
50    (new, false)
51}
52
53impl FileCredentials {
54    /// Store in `dir` (created `0700` on first write).
55    pub fn new(dir: impl Into<PathBuf>) -> Self {
56        Self { dir: dir.into() }
57    }
58
59    /// The operator store: the resolved runtime root (see
60    /// [`RuntimePaths`]), then [`SEED_SECRETS_SUBDIR`].
61    ///
62    /// Tokens used to live under `~/.clawft` regardless of project. When a
63    /// project root has no store yet but the legacy one exists, the legacy
64    /// store is used (WARN); secrets are never moved automatically.
65    pub fn operator_default() -> Result<Self, RuntimeError> {
66        let home = clawft_types::runtime_paths::home_dir();
67        let (dir, legacy) = operator_dir(&RuntimePaths::resolve(), home.as_deref());
68        if legacy {
69            tracing::warn!(
70                dir = %dir.display(),
71                "using the legacy ~/.clawft seed token store; it is not moved automatically"
72            );
73        }
74        Ok(Self::new(dir))
75    }
76
77    /// Directory holding the tokens.
78    pub fn dir(&self) -> &Path {
79        &self.dir
80    }
81
82    fn path_for(&self, node_id: &str) -> Result<PathBuf, RuntimeError> {
83        if !valid_token(node_id, 64) || node_id.starts_with('.') {
84            return Err(RuntimeError::InvalidConfig(
85                "seed node id must be a plain token".into(),
86            ));
87        }
88        Ok(self.dir.join(format!("{node_id}.token")))
89    }
90
91    fn ensure_dir(&self) -> Result<(), RuntimeError> {
92        match fs::symlink_metadata(&self.dir) {
93            Ok(m) if m.file_type().is_symlink() || !m.is_dir() => Err(RuntimeError::InvalidConfig(
94                "seed secret dir is not a plain directory".into(),
95            )),
96            Ok(_) => fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))
97                .map_err(|e| io("dir", e)),
98            Err(_) => fs::DirBuilder::new()
99                .recursive(true)
100                .mode(0o700)
101                .create(&self.dir)
102                .map_err(|e| io("dir", e)),
103        }
104    }
105}
106
107impl SeedCredentials for FileCredentials {
108    fn get(&self, node_id: &str) -> Result<SecretString, RuntimeError> {
109        let path = self.path_for(node_id)?;
110        let meta = fs::symlink_metadata(&path).map_err(|_| {
111            RuntimeError::InvalidConfig(format!("no credential for {node_id}; pair the Seed"))
112        })?;
113        if !meta.file_type().is_file() {
114            return Err(RuntimeError::InvalidConfig(
115                "seed credential is not a regular file".into(),
116            ));
117        }
118        if meta.mode() & 0o077 != 0 {
119            return Err(RuntimeError::InvalidConfig(
120                "seed credential is readable by other users; re-pair".into(),
121            ));
122        }
123        if meta.len() > MAX_TOKEN {
124            return Err(RuntimeError::InvalidConfig(
125                "seed credential too large".into(),
126            ));
127        }
128        let raw = fs::read_to_string(&path).map_err(|e| io("read", e))?;
129        let token = raw.trim();
130        if token.is_empty() || !token.bytes().all(|b| b.is_ascii_graphic()) {
131            return Err(RuntimeError::InvalidConfig(
132                "seed credential is malformed".into(),
133            ));
134        }
135        Ok(SecretString::new(token))
136    }
137
138    fn put(&self, node_id: &str, token: SecretString) -> Result<(), RuntimeError> {
139        let path = self.path_for(node_id)?;
140        let t = token.expose();
141        if t.is_empty() || t.len() as u64 > MAX_TOKEN || !t.bytes().all(|b| b.is_ascii_graphic()) {
142            return Err(RuntimeError::InvalidConfig(
143                "refusing a malformed token".into(),
144            ));
145        }
146        self.ensure_dir()?;
147        let tmp = self.dir.join(format!(".{node_id}.token.tmp"));
148        let _ = fs::remove_file(&tmp);
149        let mut f = fs::OpenOptions::new()
150            .create_new(true)
151            .write(true)
152            .mode(0o600)
153            .open(&tmp)
154            .map_err(|e| io("write", e))?;
155        f.write_all(t.as_bytes())
156            .and_then(|_| f.sync_all())
157            .map_err(|e| io("write", e))?;
158        fs::rename(&tmp, &path).map_err(|e| io("rename", e))
159    }
160}
161
162#[cfg(test)]
163mod operator_dir_tests {
164    use super::*;
165
166    #[test]
167    fn legacy_store_is_used_only_when_the_project_has_none() {
168        let t = tempfile::tempdir().unwrap();
169        let home = t.path().join("home");
170        fs::create_dir_all(home.join(".clawft").join(SEED_SECRETS_SUBDIR)).unwrap();
171        let proj = t.path().join("proj");
172        fs::create_dir_all(proj.join(".weftos")).unwrap();
173        fs::write(proj.join(".weftos/project.toml"), "").unwrap();
174        let paths = RuntimePaths::resolve_with(None, Some(&proj), Some(&home));
175
176        let (dir, legacy) = operator_dir(&paths, Some(&home));
177        assert!(legacy);
178        assert_eq!(dir, home.join(".clawft").join(SEED_SECRETS_SUBDIR));
179
180        fs::create_dir_all(proj.join(".weftos/runtime").join(SEED_SECRETS_SUBDIR)).unwrap();
181        let (dir, legacy) = operator_dir(&paths, Some(&home));
182        assert!(!legacy);
183        assert_eq!(dir, proj.join(".weftos/runtime").join(SEED_SECRETS_SUBDIR));
184
185        // Isolated runs never read the operator's tokens.
186        let iso = RuntimePaths::resolve_with(Some("/x"), Some(&proj), Some(&home));
187        assert!(!operator_dir(&iso, Some(&home)).1);
188    }
189}