clawft_kernel/
node_key.rs1use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10
11use ed25519_dalek::SigningKey;
12use rand::RngCore;
13use rand::rngs::OsRng;
14
15pub const NODE_KEY_FILE: &str = "node.key";
17
18#[derive(Debug, thiserror::Error)]
20pub enum NodeKeyError {
21 #[error("keyfile io: {0}")]
23 Io(#[from] io::Error),
24 #[error("keyfile {path:?} refused: {reason}")]
27 Insecure {
28 path: PathBuf,
30 reason: String,
32 },
33 #[error("keyfile {path:?} is malformed: expected 32 bytes, got {got}")]
35 Malformed {
36 path: PathBuf,
38 got: usize,
40 },
41}
42
43pub 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
57pub 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 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => load_existing(&path),
76 Err(e) => Err(e.into()),
77 }
78}
79
80const 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 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
142fn publish_private(dir: &Path, path: &Path, file: &str, seed: &[u8; 32]) -> io::Result<()> {
146 use std::io::Write;
147 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#[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 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 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 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}