diff options
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | src/config/mod.rs | 67 | ||||
| -rw-r--r-- | src/identity.rs | 155 | ||||
| -rw-r--r-- | src/main.rs | 3 | ||||
| -rw-r--r-- | src/swarm/auth.rs | 12 |
5 files changed, 220 insertions, 18 deletions
@@ -19,6 +19,7 @@ blake3 = "1.8" bytes = "1.12" clap = "4.6" dashmap = "6.2" +ed25519-dalek = "3" getrandom = "0.4" libc = "0.2" password-hash = "0.6" diff --git a/src/config/mod.rs b/src/config/mod.rs index 928730e..65d481d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -180,6 +180,20 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "File where this node remembers its swarm across restarts: the swarm ID and some members to rejoin through (not the document). Written once ready, then rarely. Without it, a restarted node knows only its configured seeds.", ), option( + "node-key-file", + Kind::Text, + "PATH", + None, + "This node's key (Ed25519, 32 raw bytes): who the node is, whatever transport reaches it. Keep it with the node: a new key is a new node. Required.", + ), + option( + "create-node-key", + Kind::Flag, + "BOOL", + Some("false"), + "Create a new node key at node-key-file if there is no file there (a new node). Without it, a missing key file is an error.", + ), + option( "swarm-key-file", Kind::Text, "PATH", @@ -311,6 +325,9 @@ pub(crate) struct Settings { pub(crate) remake: Option<String>, #[serde(default)] pub(crate) force: Vec<String>, + pub(crate) node_key_file: PathBuf, + #[serde(default)] + pub(crate) create_node_key: bool, pub(crate) swarm_key_file: Option<PathBuf>, pub(crate) alias: Option<String>, #[serde(default = "default_keepalive", with = "crate::units::duration_option")] @@ -788,6 +805,7 @@ fn culprit(merged: &Layer) -> Option<(&String, &Entry)> { } let mut alone = serde_json::Map::new(); alone.insert("htpasswd".to_owned(), JsonValue::String(String::new())); + alone.insert("node-key-file".to_owned(), JsonValue::String(String::new())); alone.insert((*name).clone(), entry.value.clone()); serde_json::from_value::<Settings>(JsonValue::Object(alone)).is_err() }) @@ -1122,7 +1140,7 @@ mod tests { written: None, }, }; - let mut merged = layer(json!({ "htpasswd": "u" })); + let mut merged = layer(json!({ "htpasswd": "u", "node-key-file": "node.key" })); merged.insert( "force".to_owned(), from(Provenance::File(PathBuf::from("continuum.toml"))), @@ -1135,7 +1153,11 @@ mod tests { #[test] fn options_and_settings_agree() { // Every option is a Settings field, and every field an option. - let loaded = resolve(layer(json!({ "htpasswd": "users" })), Vec::new()).unwrap(); + let loaded = resolve( + layer(json!({ "htpasswd": "users", "node-key-file": "node.key" })), + Vec::new(), + ) + .unwrap(); let JsonValue::Object(fields) = serde_json::to_value(&loaded.settings).unwrap() else { panic!() }; @@ -1184,6 +1206,12 @@ mod tests { ); overlay( &mut merged, + "node-key-file".into(), + file(json!("node.key")), + Overlay::FileOverFile, + ); + overlay( + &mut merged, "mycelium-key-file".into(), file(json!("k")), Overlay::FileOverFile, @@ -1230,7 +1258,9 @@ mod tests { #[test] fn report_hides_imported_values() { - let mut values = layer(json!({ "htpasswd": "users", "alias": "secret-ish" })); + let mut values = layer( + json!({ "htpasswd": "users", "node-key-file": "node.key", "alias": "secret-ish" }), + ); values.get_mut("alias").unwrap().origin.imports = vec!["env:HOSTNAME".into()]; let report = resolve(values, Vec::new()) .unwrap() @@ -1265,9 +1295,12 @@ mod tests { /// `Settings::check` and the override rule name the same supervised-only options. #[test] fn supervised_only_options_are_the_conflicts() { - let settings = resolve(layer(json!({ "htpasswd": "u" })), Vec::new()) - .unwrap() - .settings; + let settings = resolve( + layer(json!({ "htpasswd": "u", "node-key-file": "node.key" })), + Vec::new(), + ) + .unwrap() + .settings; let names: Vec<&str> = settings .supervised_only() .iter() @@ -1283,14 +1316,14 @@ mod tests { assert!(resolve(layer(json!({})), Vec::new()).is_err()); // htpasswd is required assert!( resolve( - layer(json!({ "htpasswd": "u", "neighbours": 1 })), + layer(json!({ "htpasswd": "u", "node-key-file": "node.key", "neighbours": 1 })), Vec::new() ) .is_err() ); assert!( resolve( - layer(json!({ "htpasswd": "u", "debug-epoch": 3 })), + layer(json!({ "htpasswd": "u", "node-key-file": "node.key", "debug-epoch": 3 })), Vec::new() ) .is_err() @@ -1299,6 +1332,7 @@ mod tests { resolve( layer(json!({ "htpasswd": "u", + "node-key-file": "node.key", "mycelium-rest-url": "http://[::1]:8989", "mycelium-key-file": "k", })), @@ -1306,10 +1340,16 @@ mod tests { ) .is_err() ); - assert!(resolve(layer(json!({ "htpasswd": "u", "alias": " " })), Vec::new()).is_err()); assert!( resolve( - layer(json!({ "htpasswd": "u", "keepalive": "x" })), + layer(json!({ "htpasswd": "u", "node-key-file": "node.key", "alias": " " })), + Vec::new() + ) + .is_err() + ); + assert!( + resolve( + layer(json!({ "htpasswd": "u", "node-key-file": "node.key", "keepalive": "x" })), Vec::new() ) .is_err() @@ -1352,6 +1392,8 @@ mod tests { "continuum", "--htpasswd", "users", + "--node-key-file", + "node.key", "--alias", "node-${env:CONTINUUM_TEST_SITE}", "--seeds", @@ -1397,8 +1439,9 @@ mod tests { #[test] fn secrets_are_never_shown() { - let values = - layer(json!({ "htpasswd": "users", "mycelium-rest-url": "http://u:pw@h:8989" })); + let values = layer( + json!({ "htpasswd": "users", "node-key-file": "node.key", "mycelium-rest-url": "http://u:pw@h:8989" }), + ); assert_eq!( values["mycelium-rest-url"].shown("mycelium-rest-url"), "<secret>" diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..8f7fdda --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,155 @@ +//! Who a node is: its node key (`--node-key-file`), an Ed25519 key of its own, and the +//! node ID derived from it, `continuum:<hex of the public key>`. +//! +//! The node ID names the node whatever transport reaches it: a transport address only +//! says where it is (`docs/peers.md`, node IDs and addresses). A new key is a new +//! node, so the key is created only when asked to (`--create-node-key`), never because +//! a path was mistyped. + +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use ed25519_dalek::SigningKey; +use tracing::info; + +use crate::transport::Hex; + +/// What every node ID starts with. +pub(crate) const NODE_ID_PREFIX: &str = "continuum:"; + +/// Bytes in a key file: the Ed25519 secret key (its seed), raw. +const KEY_BYTES: usize = 32; + +/// This node's key. +pub(crate) struct NodeKey { + signing: SigningKey, +} + +impl NodeKey { + /// Reads the key from `path`; with `create`, makes a new one there if the file + /// doesn't exist. + pub(crate) fn load(path: &Path, create: bool) -> Result<Self> { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if !create { + bail!( + "The node key file {} doesn't exist; add --create-node-key to create a new node key there (a new key is a new node)", + path.display() + ); + } + let key = Self::create(path)?; + info!(path = %path.display(), node_id = %key.id(), "Created a new node key: this is a new node"); + return Ok(key); + } + Err(error) => { + return Err(error) + .with_context(|| format!("Reading the node key from {}", path.display())); + } + }; + crate::swarm::auth::warn_if_readable_by_others(path, "node key"); + let Ok(seed) = <[u8; KEY_BYTES]>::try_from(bytes.as_slice()) else { + bail!( + "The node key in {} has {} bytes; a node key has {KEY_BYTES}", + path.display(), + bytes.len() + ); + }; + Ok(Self::from_seed(seed)) + } + + fn from_seed(seed: [u8; KEY_BYTES]) -> Self { + Self { + signing: SigningKey::from_bytes(&seed), + } + } + + /// A new key in a new file at `path` (only its owner may read it). + fn create(path: &Path) -> Result<Self> { + let mut seed = [0u8; KEY_BYTES]; + getrandom::fill(&mut seed).map_err(|error| anyhow::anyhow!("No randomness: {error}"))?; + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let written = (|| { + let mut file = options.open(path)?; + file.write_all(&seed)?; + file.sync_all() + })(); + written.with_context(|| format!("Creating the node key file {}", path.display()))?; + Ok(Self::from_seed(seed)) + } + + /// This node's ID. + pub(crate) fn id(&self) -> String { + format!( + "{NODE_ID_PREFIX}{}", + Hex(self.signing.verifying_key().as_bytes()) + ) + } +} + +/// Whether `id` has the form of a node ID. +pub(crate) fn is_node_id(id: &str) -> bool { + id.strip_prefix(NODE_ID_PREFIX).is_some_and(|hex| { + hex.len() == 2 * KEY_BYTES + && hex + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(name: &str) -> std::path::PathBuf { + let dir = + std::env::temp_dir().join(format!("continuum-identity-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn created_only_when_asked_and_then_kept() { + let path = scratch("create").join("node.key"); + assert!(NodeKey::load(&path, false).is_err()); + assert!(!path.exists()); + + let created = NodeKey::load(&path, true).unwrap(); + assert!(is_node_id(&created.id())); + assert_eq!(std::fs::read(&path).unwrap().len(), KEY_BYTES); + // The same node from now on, with or without the flag. + assert_eq!(NodeKey::load(&path, false).unwrap().id(), created.id()); + assert_eq!(NodeKey::load(&path, true).unwrap().id(), created.id()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + } + + #[test] + fn a_key_of_the_wrong_size_is_refused() { + let path = scratch("size").join("node.key"); + std::fs::write(&path, [7u8; 16]).unwrap(); + assert!(NodeKey::load(&path, true).is_err()); + } + + #[test] + fn node_ids() { + let id = NodeKey::from_seed([1; KEY_BYTES]).id(); + assert!(is_node_id(&id)); + assert_eq!(id.len(), NODE_ID_PREFIX.len() + 64); + assert!(!is_node_id(&id[NODE_ID_PREFIX.len()..])); // a Mycelium key is no node ID + assert!(!is_node_id(&id.to_uppercase())); + assert!(!is_node_id(&id[..id.len() - 1])); + } +} diff --git a/src/main.rs b/src/main.rs index dade8bc..a891110 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod config; mod document; mod files; mod force; +mod identity; mod locks; mod logging; mod node; @@ -52,6 +53,8 @@ async fn main() -> Result<()> { let args = &config.settings; // Read before anything starts, so a bad key file fails right away. + let node_key = identity::NodeKey::load(&args.node_key_file, args.create_node_key)?; + info!(node_id = %node_key.id(), "Node identity"); let swarm_key = match &args.swarm_key_file { Some(path) => Some(Arc::new(swarm::auth::SwarmKey::from_file(path)?)), None => { diff --git a/src/swarm/auth.rs b/src/swarm/auth.rs index 3bfbd4d..088f615 100644 --- a/src/swarm/auth.rs +++ b/src/swarm/auth.rs @@ -40,9 +40,9 @@ struct Rejections { suppressed: u64, } -/// The key is a secret: a file others may read is worth a warning (not an error: a -/// group may be deliberate, e.g. shared with a deployment tool). -fn warn_if_readable_by_others(path: &Path) { +/// A key is a secret: a file others may read is worth a warning (not an error: a +/// group may be deliberate, e.g. shared with a deployment tool). `what`: which key. +pub(crate) fn warn_if_readable_by_others(path: &Path, what: &str) { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -52,13 +52,13 @@ fn warn_if_readable_by_others(path: &Path) { tracing::warn!( path = %path.display(), mode = format!("{:o}", mode & 0o777), - "The swarm key file is readable by others than its owner; chmod 600 it" + "The {what} file is readable by others than its owner; chmod 600 it" ); } } } #[cfg(not(unix))] - let _ = path; + let _ = (path, what); } /// At most one rejection log line per this interval. @@ -70,7 +70,7 @@ impl SwarmKey { pub(crate) fn from_file(path: &Path) -> Result<Self> { let key = std::fs::read(path) .with_context(|| format!("Reading the swarm key from {}", path.display()))?; - warn_if_readable_by_others(path); + warn_if_readable_by_others(path, "swarm key"); if key.len() < 16 { bail!( "The swarm key in {} has {} bytes; use at least 16 (32 random bytes recommended)", |
