summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-27 00:58:45 +0100
committertv <tv@krebsco.de>2026-10-27 00:58:45 +0100
commitb90cfd1fde4e1c00d03e44976753d0044b273bda (patch)
tree23c401e56b3caf6644c07091563e73827b43e177 /src
parentfde8a76ec5d8e697d9b13470cd15ed324bae3d00 (diff)
introduce node keys
Diffstat (limited to 'src')
-rw-r--r--src/config/mod.rs67
-rw-r--r--src/identity.rs155
-rw-r--r--src/main.rs3
-rw-r--r--src/swarm/auth.rs12
4 files changed, 219 insertions, 18 deletions
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)",