aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-01 05:32:36 +0100
committertv <tv@krebsco.de>2026-11-01 05:32:36 +0100
commitdf74bf7026569514210982a2c7c3a4c36f469a32 (patch)
tree79b6b0d1fde6acfca37187de0dd459cea7c23335 /src
parent6bab0c4af15988e3c0d4e38de3ee9c1f8c4a8be2 (diff)
allow creating all key files
Diffstat (limited to 'src')
-rw-r--r--src/config/mod.rs45
-rw-r--r--src/files.rs51
-rw-r--r--src/identity.rs58
-rw-r--r--src/main.rs7
-rw-r--r--src/swarm/auth.rs30
-rw-r--r--src/transport/libp2p/config.rs31
-rw-r--r--src/transport/mycelium/mod.rs28
7 files changed, 187 insertions, 63 deletions
diff --git a/src/config/mod.rs b/src/config/mod.rs
index ea323e1..25b39ea 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -201,7 +201,7 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
"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, but with relay-only (then not allowed).",
),
option(
- "create-node-key",
+ "node-key-create",
Kind::Flag,
"BOOL",
Some("false"),
@@ -215,6 +215,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
"Pre-shared swarm key: only nodes that know it may join (raw bytes, at least 16; e.g. `head -c 32 /dev/urandom`). Without it, any node that can reach a member may join.",
),
option(
+ "swarm-key-create",
+ Kind::Flag,
+ "BOOL",
+ Some("false"),
+ "Create a new swarm key at swarm-key-file if there is no file there: on the swarm's first node, as the others need a copy of it (a key of their own keeps them out). Without it, a missing key file is an error.",
+ ),
+ option(
"alias",
Kind::Text,
"NAME",
@@ -373,6 +380,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
"With the libp2p transport: its key (Ed25519, 32 raw bytes), which makes its peer ID; a new one each start if absent (the node ID stays the same either way).",
),
option(
+ "libp2p-key-create",
+ Kind::Flag,
+ "BOOL",
+ Some("false"),
+ "With the libp2p transport: create a new libp2p key at libp2p-key-file if there is no file there (a new peer ID). Without it, a missing key file is an error.",
+ ),
+ option(
"mycelium-rest-url",
Kind::Text,
"SCHEME://HOSTNAME:PORT",
@@ -389,6 +403,13 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[
"Private key of the supervised Mycelium daemon (its identity); a new one each start if absent.",
),
option(
+ "mycelium-key-create",
+ Kind::Flag,
+ "BOOL",
+ Some("false"),
+ "Create a new key for the supervised Mycelium daemon at mycelium-key-file if there is no file there (a new Mycelium address). Without it, a missing key file is an error.",
+ ),
+ option(
"mycelium-network-key-file",
Kind::Text,
"PATH",
@@ -470,8 +491,10 @@ pub(crate) struct Settings {
/// Required (`check`), but not allowed with `relay_only`.
pub(crate) node_key_file: Option<PathBuf>,
#[serde(default)]
- pub(crate) create_node_key: bool,
+ pub(crate) node_key_create: bool,
pub(crate) swarm_key_file: Option<PathBuf>,
+ #[serde(default)]
+ pub(crate) swarm_key_create: bool,
pub(crate) alias: Option<String>,
#[serde(default = "default_keepalive", with = "crate::units::duration_option")]
pub(crate) keepalive: Duration,
@@ -530,6 +553,9 @@ pub(crate) struct Settings {
pub(crate) libp2p_public: bool,
#[cfg_attr(not(feature = "libp2p"), allow(dead_code))]
pub(crate) libp2p_key_file: Option<PathBuf>,
+ #[serde(default)]
+ #[cfg_attr(not(feature = "libp2p"), allow(dead_code))]
+ pub(crate) libp2p_key_create: bool,
#[cfg_attr(not(feature = "tor"), allow(dead_code))]
pub(crate) tor_state_dir: Option<PathBuf>,
#[serde(default = "default_tor_port")]
@@ -541,6 +567,9 @@ pub(crate) struct Settings {
pub(crate) mycelium_rest_url: Option<String>,
#[cfg_attr(not(feature = "mycelium"), allow(dead_code))]
pub(crate) mycelium_key_file: Option<PathBuf>,
+ #[serde(default)]
+ #[cfg_attr(not(feature = "mycelium"), allow(dead_code))]
+ pub(crate) mycelium_key_create: bool,
#[cfg_attr(not(feature = "mycelium"), allow(dead_code))]
pub(crate) mycelium_network_key_file: Option<PathBuf>,
#[cfg_attr(not(feature = "mycelium"), allow(dead_code))]
@@ -624,6 +653,12 @@ impl Settings {
if self.genesis && self.recover_from.is_some() {
bail!("recover-from starts the swarm from a backup; it can't be combined with genesis");
}
+ if self.node_key_create && self.node_key_file.is_none() {
+ bail!("node-key-create creates the key at node-key-file; set that too");
+ }
+ if self.swarm_key_create && self.swarm_key_file.is_none() {
+ bail!("swarm-key-create creates the key at swarm-key-file; set that too");
+ }
if self.relay_only {
self.check_relay_only()?;
} else {
@@ -632,7 +667,7 @@ impl Settings {
}
if self.node_key_file.is_none() {
bail!(
- "node-key-file is required: who this node is (create one with create-node-key)"
+ "node-key-file is required: who this node is (create one with node-key-create)"
);
}
}
@@ -706,7 +741,7 @@ impl Settings {
// Its listen address and key: libp2p's checks (`transport::libp2p::check`).
let swarm_options = [
("node-key-file", self.node_key_file.is_some()),
- ("create-node-key", self.create_node_key),
+ ("node-key-create", self.node_key_create),
("seeds", !self.seeds.is_empty()),
("genesis", self.genesis),
("state-file", self.state_file.is_some()),
@@ -714,6 +749,7 @@ impl Settings {
("recover-from", self.recover_from.is_some()),
("remake", self.remake.is_some()),
("swarm-key-file", self.swarm_key_file.is_some()),
+ ("swarm-key-create", self.swarm_key_create),
("protocol-writers", !self.protocol_writers.is_empty()),
("alias", self.alias.is_some()),
("debug-api", self.debug_api),
@@ -840,6 +876,7 @@ const CONFLICTS: &[(&[&str], &[&str])] = &[(
&["mycelium-rest-url"],
&[
"mycelium-key-file",
+ "mycelium-key-create",
"mycelium-network-key-file",
"mycelium-network-name",
"mycelium-tcp-listen-port",
diff --git a/src/files.rs b/src/files.rs
index cd2f38c..bc8fb8e 100644
--- a/src/files.rs
+++ b/src/files.rs
@@ -167,6 +167,57 @@ fn sync_parent(path: &Path) {
}
}
+/// A key file's bytes: read from `path`; or with `create`, if there is no file there,
+/// `len` random bytes in a new one only its owner may read (then also `true`). Without
+/// `create`, a missing file is an error saying to give `create_option` (`what`: which
+/// key; `hint`: what a new one means). Never created where a file is: a mistyped path
+/// stays an error unless asked.
+pub(crate) fn key_file(
+ path: &Path,
+ what: &str,
+ create: bool,
+ create_option: &str,
+ hint: &str,
+ len: usize,
+) -> anyhow::Result<(Vec<u8>, bool)> {
+ use anyhow::Context;
+ use std::io::Write;
+ match std::fs::read(path) {
+ Ok(bytes) => {
+ warn_if_readable_by_others(path, what);
+ Ok((bytes, false))
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ if !create {
+ anyhow::bail!(
+ "The {what} file {} doesn't exist; add --{create_option} to create a new {what} there ({hint})",
+ path.display()
+ );
+ }
+ let mut bytes = vec![0u8; len];
+ getrandom::fill(&mut bytes)
+ .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(&bytes)?;
+ file.sync_all()
+ })();
+ written.with_context(|| format!("Creating the {what} file {}", path.display()))?;
+ Ok((bytes, true))
+ }
+ Err(error) => {
+ Err(error).with_context(|| format!("Reading the {what} from {}", path.display()))
+ }
+ }
+}
+
/// 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) {
diff --git a/src/identity.rs b/src/identity.rs
index 8efe30f..04b7e6f 100644
--- a/src/identity.rs
+++ b/src/identity.rs
@@ -3,13 +3,12 @@
//!
//! 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
+//! node, so the key is created only when asked to (`--node-key-create`), never because
//! a path was mistyped.
-use std::io::Write;
use std::path::Path;
-use anyhow::{Context, Result, bail};
+use anyhow::{Result, bail};
use ed25519_dalek::SigningKey;
use tracing::info;
@@ -30,25 +29,14 @@ 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::files::warn_if_readable_by_others(path, "node key");
+ let (bytes, created) = crate::files::key_file(
+ path,
+ "node key",
+ create,
+ "node-key-create",
+ "a new key is a new node",
+ KEY_BYTES,
+ )?;
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}",
@@ -56,7 +44,11 @@ impl NodeKey {
bytes.len()
);
};
- Ok(Self::from_seed(seed))
+ let key = Self::from_seed(seed);
+ if created {
+ info!(path = %path.display(), node_id = %key.id(), "Created a new node key: this is a new node");
+ }
+ Ok(key)
}
/// A key from its 32 secret bytes: for simulated nodes, which have no key files.
@@ -66,26 +58,6 @@ impl NodeKey {
}
}
- /// 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 {
node_id_of(&self.public())
diff --git a/src/main.rs b/src/main.rs
index a725174..a6f2937 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -76,11 +76,14 @@ async fn main() -> Result<()> {
.context("node-key-file is required")?;
let node_key = Arc::new(identity::NodeKey::load(
node_key_file,
- args.create_node_key,
+ args.node_key_create,
)?);
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)?)),
+ Some(path) => Some(Arc::new(swarm::auth::SwarmKey::from_file(
+ path,
+ args.swarm_key_create,
+ )?)),
None => {
warn!("No swarm key: any node that can reach a member may join");
None
diff --git a/src/swarm/auth.rs b/src/swarm/auth.rs
index 534a64d..98a004c 100644
--- a/src/swarm/auth.rs
+++ b/src/swarm/auth.rs
@@ -21,7 +21,7 @@ use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::locks::MutexExt;
-use anyhow::{Context, Result, bail};
+use anyhow::{Result, bail};
use sha2::{Digest, Sha256};
const HELLO_LABEL: &[u8] = b"continuum/hello/1";
@@ -46,10 +46,19 @@ const REJECTION_LOG_INTERVAL: Duration = Duration::from_secs(10);
impl SwarmKey {
/// Reads the key from a file; its bytes are the key, as they are. Generate one with
/// e.g. `head -c 32 /dev/urandom > swarm.key`.
- 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()))?;
- crate::files::warn_if_readable_by_others(path, "swarm key");
+ /// With `create`, a new one (32 random bytes) if there is no file at `path`.
+ pub(crate) fn from_file(path: &Path, create: bool) -> Result<Self> {
+ let (key, created) = crate::files::key_file(
+ path,
+ "swarm key",
+ create,
+ "swarm-key-create",
+ "on the swarm's first node; the others need a copy of it",
+ 32,
+ )?;
+ if created {
+ tracing::info!(path = %path.display(), "Created a new swarm key: copy it to the swarm's other nodes");
+ }
if key.len() < 16 {
bail!(
"The swarm key in {} has {} bytes; use at least 16 (32 random bytes recommended)",
@@ -154,6 +163,17 @@ mod tests {
crate::transport::Hex(bytes).to_string()
}
+ /// A swarm key file is made only when asked, then read as any other.
+ #[test]
+ fn swarm_key_created_only_when_asked() {
+ let path = crate::files::scratch_dir("swarm-key-create").join("swarm.key");
+ assert!(SwarmKey::from_file(&path, false).is_err());
+ assert!(!path.exists());
+ let created = SwarmKey::from_file(&path, true).unwrap();
+ assert_eq!(created.key.len(), 32);
+ assert_eq!(SwarmKey::from_file(&path, false).unwrap().key, created.key);
+ }
+
#[test]
fn hmac_matches_rfc_4231() {
// Test case 2.
diff --git a/src/transport/libp2p/config.rs b/src/transport/libp2p/config.rs
index 6d26224..348a170 100644
--- a/src/transport/libp2p/config.rs
+++ b/src/transport/libp2p/config.rs
@@ -70,6 +70,9 @@ pub(crate) fn check(settings: &crate::config::Settings) -> Result<()> {
&settings.libp2p_ignore,
&settings.libp2p_prefer,
)?;
+ if settings.libp2p_key_create && settings.libp2p_key_file.is_none() {
+ bail!("libp2p-key-create creates the key at libp2p-key-file; set that too");
+ }
if settings.relay_only {
if settings.libp2p_listen.is_empty() {
bail!("relay-only needs libp2p-listen: where nodes reach the relay");
@@ -119,7 +122,10 @@ impl Libp2pConfig {
) -> Result<Self> {
let parse = parse_addresses;
Ok(Self {
- key: Self::load_key(settings.libp2p_key_file.as_deref())?,
+ key: Self::load_key(
+ settings.libp2p_key_file.as_deref(),
+ settings.libp2p_key_create,
+ )?,
listen: parse("libp2p-listen", &settings.libp2p_listen)?,
announce: parse("libp2p-announce", &settings.libp2p_announce)?,
relays: parse("libp2p-relays", &settings.libp2p_relays)?,
@@ -135,16 +141,25 @@ impl Libp2pConfig {
})
}
- /// The key in `path` (32 raw bytes, an Ed25519 secret key), or a new one each start
- /// without a file: the peer ID only says where a node is, so a new one costs nothing
- /// but the WHO that finds it again (`transport::identity`).
- pub(crate) fn load_key(path: Option<&Path>) -> Result<Keypair> {
+ /// The key in `path` (32 raw bytes, an Ed25519 secret key), created there with
+ /// `create` if there is no file; or a new one each start without a path: the peer
+ /// ID only says where a node is, so a new one costs nothing but the WHO that finds
+ /// it again (`transport::identity`).
+ pub(crate) fn load_key(path: Option<&Path>, create: bool) -> Result<Keypair> {
let Some(path) = path else {
return Ok(Keypair::generate_ed25519());
};
- let mut bytes = std::fs::read(path)
- .with_context(|| format!("Reading the libp2p key from {}", path.display()))?;
- crate::files::warn_if_readable_by_others(path, "libp2p key");
+ let (mut bytes, created) = crate::files::key_file(
+ path,
+ "libp2p key",
+ create,
+ "libp2p-key-create",
+ "a new peer ID",
+ 32,
+ )?;
+ if created {
+ info!(path = %path.display(), "Created a new libp2p key");
+ }
if bytes.len() != 32 {
bail!(
"The libp2p key in {} has {} bytes; it has 32",
diff --git a/src/transport/mycelium/mod.rs b/src/transport/mycelium/mod.rs
index 21c939d..9078134 100644
--- a/src/transport/mycelium/mod.rs
+++ b/src/transport/mycelium/mod.rs
@@ -104,6 +104,9 @@ pub(crate) fn check(settings: &crate::config::Settings) -> Result<()> {
);
}
}
+ if settings.mycelium_key_create && settings.mycelium_key_file.is_none() {
+ anyhow::bail!("mycelium-key-create creates the key at mycelium-key-file; set that too");
+ }
// A private network has both; one alone would be ignored or invented.
if settings.mycelium_network_key_file.is_some() != settings.mycelium_network_name.is_some() {
anyhow::bail!("mycelium-network-key-file and mycelium-network-name go together");
@@ -113,9 +116,10 @@ pub(crate) fn check(settings: &crate::config::Settings) -> Result<()> {
/// The options only a supervised Mycelium daemon takes, and whether each is set: the
/// other side of `config::CONFLICTS`' `mycelium-rest-url` (a test keeps them the same).
-pub(crate) fn supervised_only(settings: &crate::config::Settings) -> [(&'static str, bool); 4] {
+pub(crate) fn supervised_only(settings: &crate::config::Settings) -> [(&'static str, bool); 5] {
[
("mycelium-key-file", settings.mycelium_key_file.is_some()),
+ ("mycelium-key-create", settings.mycelium_key_create),
(
"mycelium-network-key-file",
settings.mycelium_network_key_file.is_some(),
@@ -138,6 +142,28 @@ pub(crate) async fn start(
settings: &crate::config::Settings,
mut stop: tokio::sync::watch::Receiver<bool>,
) -> Result<Option<MyceliumTransport>> {
+ // The daemon's key, 32 raw bytes (as `daemon` hands it an ephemeral one): read,
+ // or made, here, so a missing file fails before the daemon starts.
+ if let Some(path) = &settings.mycelium_key_file {
+ let (bytes, created) = crate::files::key_file(
+ path,
+ "Mycelium key",
+ settings.mycelium_key_create,
+ "mycelium-key-create",
+ "a new Mycelium address",
+ 32,
+ )?;
+ if bytes.len() != 32 {
+ anyhow::bail!(
+ "The Mycelium key in {} has {} bytes; it has 32",
+ path.display(),
+ bytes.len()
+ );
+ }
+ if created {
+ tracing::info!(path = %path.display(), "Created a new Mycelium key");
+ }
+ }
let spawn_config = MyceliumSpawnConfig {
rest_url_opt: settings
.mycelium_rest_url