use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use ed25519_dalek::SigningKey;
use rand::RngCore;
use rand::rngs::OsRng;
pub const NODE_KEY_FILE: &str = "node.key";
#[derive(Debug, thiserror::Error)]
pub enum NodeKeyError {
#[error("keyfile io: {0}")]
Io(#[from] io::Error),
#[error("keyfile {path:?} refused: {reason}")]
Insecure {
path: PathBuf,
reason: String,
},
#[error("keyfile {path:?} is malformed: expected 32 bytes, got {got}")]
Malformed {
path: PathBuf,
got: usize,
},
}
pub fn load_or_generate_node_key(runtime_dir: &Path) -> Result<SigningKey, NodeKeyError> {
load_or_generate_key_file(runtime_dir, NODE_KEY_FILE)
}
pub fn load_or_generate_key_file(dir: &Path, file: &str) -> Result<SigningKey, NodeKeyError> {
if file.is_empty() || file.contains(['/', '\\']) || file.starts_with('.') {
return Err(io::Error::new(io::ErrorKind::InvalidInput, format!("bad key file name {file:?}")).into());
}
fs::create_dir_all(dir)?;
remove_stale_temps(dir, file);
let path = dir.join(file);
if fs::symlink_metadata(&path).is_ok() {
return load_existing(&path);
}
let mut seed = [0u8; 32];
OsRng.fill_bytes(&mut seed);
match publish_private(dir, &path, file, &seed) {
Ok(()) => Ok(SigningKey::from_bytes(&seed)),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => load_existing(&path),
Err(e) => Err(e.into()),
}
}
const STALE_TMP_AGE: std::time::Duration = std::time::Duration::from_secs(30);
fn remove_stale_temps(dir: &Path, file: &str) {
let prefix = format!(".{file}.");
let Ok(rd) = fs::read_dir(dir) else { return };
for e in rd.filter_map(Result::ok) {
let name = e.file_name();
let name = name.to_string_lossy();
if !(name.starts_with(&prefix) && name.ends_with(".tmp")) {
continue;
}
let old = e
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age >= STALE_TMP_AGE);
if old {
let _ = fs::remove_file(e.path());
}
}
}
fn read_seed(path: &Path) -> Result<Vec<u8>, NodeKeyError> {
let mut bytes = fs::read(path)?;
for _ in 0..20 {
if !bytes.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
bytes = fs::read(path)?;
}
Ok(bytes)
}
fn load_existing(path: &Path) -> Result<SigningKey, NodeKeyError> {
check_and_tighten_perms(path)?;
let bytes = read_seed(path)?;
if bytes.len() != 32 {
return Err(NodeKeyError::Malformed {
path: path.to_path_buf(),
got: bytes.len(),
});
}
let mut seed = [0u8; 32];
seed.copy_from_slice(&bytes);
Ok(SigningKey::from_bytes(&seed))
}
fn private_open_options() -> fs::OpenOptions {
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
opts
}
fn publish_private(dir: &Path, path: &Path, file: &str, seed: &[u8; 32]) -> io::Result<()> {
use std::io::Write;
let tmp = dir.join(format!(
".{file}.{}.{:016x}.tmp",
std::process::id(),
OsRng.next_u64()
));
let linked = (|| {
let mut f = private_open_options().open(&tmp)?;
f.write_all(seed)?;
f.sync_all()?;
fs::hard_link(&tmp, path)
})();
let _ = fs::remove_file(&tmp);
match linked {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(e),
Err(e) => {
tracing::warn!(error = %e, "hard-linking node.key unsupported here; using create_new + write");
let mut f = private_open_options().open(path)?;
f.write_all(seed)?;
f.sync_all()
}
}
}
#[cfg(unix)]
fn check_and_tighten_perms(path: &Path) -> Result<(), NodeKeyError> {
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let Ok(link_meta) = fs::symlink_metadata(path) else {
return Ok(());
};
let dir_uid = path
.parent()
.and_then(|d| fs::metadata(d).ok())
.map(|d| d.uid());
if link_meta.file_type().is_symlink() {
tracing::warn!(path = %path.display(), "node key is a symlink; not changing its permissions");
let target = fs::metadata(path).map_err(|e| NodeKeyError::Insecure {
path: path.to_path_buf(),
reason: format!("symlink target unreadable: {e}"),
})?;
let mode = target.permissions().mode() & 0o777;
if Some(target.uid()) != dir_uid || mode & 0o077 != 0 {
return Err(NodeKeyError::Insecure {
path: path.to_path_buf(),
reason: format!(
"symlink target must be owned by the runtime dir owner with mode 600 (found mode {mode:o})"
),
});
}
return Ok(());
}
let mode = link_meta.permissions().mode() & 0o777;
if mode & 0o077 == 0 {
return Ok(());
}
tracing::warn!(
path = %path.display(),
mode = format!("{mode:o}"),
"node key file is readable by group/others"
);
if Some(link_meta.uid()) == dir_uid {
match fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
Ok(()) => tracing::warn!(path = %path.display(), "node key mode tightened to 600"),
Err(e) => tracing::warn!(path = %path.display(), error = %e, "could not tighten node key mode"),
}
}
Ok(())
}
#[cfg(not(unix))]
fn check_and_tighten_perms(_path: &Path) -> Result<(), NodeKeyError> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn creates_then_reloads_same_key_with_private_mode() {
let dir = tempfile::tempdir().unwrap();
let a = load_or_generate_node_key(dir.path()).unwrap();
let b = load_or_generate_node_key(dir.path()).unwrap();
assert_eq!(a.to_bytes(), b.to_bytes());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(dir.path().join(NODE_KEY_FILE))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
}
#[test]
#[cfg(unix)]
fn loose_mode_is_tightened_on_load() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let a = load_or_generate_node_key(dir.path()).unwrap();
let p = dir.path().join(NODE_KEY_FILE);
fs::set_permissions(&p, fs::Permissions::from_mode(0o644)).unwrap();
let b = load_or_generate_node_key(dir.path()).unwrap();
assert_eq!(a.to_bytes(), b.to_bytes());
assert_eq!(fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o600);
}
#[test]
fn concurrent_creators_agree_and_never_see_an_empty_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().to_path_buf();
let handles: Vec<_> = (0..8)
.map(|_| {
let p = path.clone();
std::thread::spawn(move || load_or_generate_node_key(&p).unwrap().to_bytes())
})
.collect();
let keys: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
assert!(keys.windows(2).all(|w| w[0] == w[1]), "all creators must agree");
let leftovers = fs::read_dir(&path)
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(leftovers, 0);
}
#[test]
fn malformed_key_is_an_error() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(NODE_KEY_FILE), b"short").unwrap();
assert!(matches!(
load_or_generate_node_key(dir.path()),
Err(NodeKeyError::Malformed { got: 5, .. })
));
}
#[test]
fn stale_temp_files_holding_the_seed_are_removed_fresh_ones_kept() {
let dir = tempfile::tempdir().unwrap();
let stale = dir.path().join(".node.key.1.00.tmp");
let fresh = dir.path().join(".node.key.2.11.tmp");
fs::write(&stale, [7u8; 32]).unwrap();
fs::write(&fresh, [8u8; 32]).unwrap();
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
fs::File::options().write(true).open(&stale).unwrap().set_modified(old).unwrap();
load_or_generate_node_key(dir.path()).unwrap();
assert!(!stale.exists(), "stale temp must be removed");
assert!(fresh.exists(), "a possibly live temp must be kept");
}
#[test]
fn empty_key_file_is_retried_then_reported_malformed() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(NODE_KEY_FILE), b"").unwrap();
assert!(matches!(
load_or_generate_node_key(dir.path()),
Err(NodeKeyError::Malformed { got: 0, .. })
));
}
#[cfg(unix)]
#[test]
fn symlinked_key_is_not_chmodded_and_loose_target_is_refused() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store = tempfile::tempdir().unwrap();
let target = store.path().join("real.key");
fs::write(&target, [5u8; 32]).unwrap();
fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap();
std::os::unix::fs::symlink(&target, dir.path().join(NODE_KEY_FILE)).unwrap();
assert!(matches!(
load_or_generate_node_key(dir.path()),
Err(NodeKeyError::Insecure { .. })
));
assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o644);
fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap();
let k = load_or_generate_node_key(dir.path()).unwrap();
assert_eq!(k.to_bytes(), [5u8; 32]);
assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
}
}