From 22720e718bc8199054cca4c11f23ece43ab15b5d Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Wed, 27 Aug 2025 02:32:03 +0200 Subject: [PATCH] node: Make location of secret key configurable With this change, the location of the secret SSH key can be configured through `${RAD_HOME}/config.json` so that the node key does not have to be placed under `${RAD_HOME}/keys` anymore. Further, there is now an option to override `config.json` directly when executing `radicle-node` via the command line argument `--secret`. The primary motivation is more flexible deployments, for example leveraging external secret management solutions, like . The secret key is fingerprinted by taking the fingerprint of the public key corresponding to the secret key. This allows to detect when the secret key changes. --- crates/radicle-node/src/fingerprint.rs | 134 +++++++++++++++++++++++++ crates/radicle-node/src/lib.rs | 1 + crates/radicle-node/src/main.rs | 58 ++++++++++- crates/radicle/src/node/config.rs | 9 ++ 4 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 crates/radicle-node/src/fingerprint.rs diff --git a/crates/radicle-node/src/fingerprint.rs b/crates/radicle-node/src/fingerprint.rs new file mode 100644 index 00000000..8f92885f --- /dev/null +++ b/crates/radicle-node/src/fingerprint.rs @@ -0,0 +1,134 @@ +//! Fingerprint the public key corresponding to the secret key used by +//! `radicle-node`. +//! +//! This allows users to configure the path to the secret key +//! freely, while ensuring that the key is not changed. +//! +//! In order to achieve this, the fingerprint of the public key +//! derived from the secret key is stored in the Radicle home +//! in a file (usually at `.radicle/node/fingerprint`). +//! When the node starts up and this file does not exist, it is assumed that +//! this is the first time the node is started, and the fingerprint is +//! initialized from the secret key in the keystore. +//! On subsequent startups, the fingerprint of the public key +//! derived from the secret key in the keystore is compared to the +//! fingerprint stored on disk, and if they do not match, the node +//! refuses to start (this last part is implemented in `main.rs`). +//! +//! If the user deletes the fingerprint file, the node will not be able +//! to detect a possible change of the secret key. The consequences of +//! doing this are unclear. + +use thiserror::Error; + +use radicle::crypto; +use radicle::profile::Home; + +/// Fingerprint of a public key. +#[derive(Debug, PartialEq)] +pub struct Fingerprint(String); + +impl std::fmt::Display for Fingerprint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum FingerprintVerification { + Match, + Mismatch, +} + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error("fingerprint file is not valid UTF-8: {0}")] + Utf8(#[from] std::str::Utf8Error), +} + +impl Fingerprint { + /// Return fingerprint of the node, if it exists. + pub fn read(home: &Home) -> Result, Error> { + match std::fs::read(path(home)) { + Ok(contents) => Ok(Some(Fingerprint( + String::from(std::str::from_utf8(contents.as_ref())?) + .trim_end() + .to_string(), + ))), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(Error::Io(err)), + } + } + + /// Initialize the fingerprint of the node with given public key. + pub fn init( + home: &Home, + secret_key: &impl std::ops::Deref, + ) -> Result<(), Error> { + let public_key = crypto::PublicKey(secret_key.deref().public_key()); + let mut file = std::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(path(home))?; + { + use std::io::Write as _; + file.write_all(crypto::ssh::fmt::fingerprint(&public_key).as_ref())?; + } + + Ok(()) + } + + /// Verify that the fingerprint of given public key matches self. + pub fn verify( + &self, + secret_key: &impl std::ops::Deref, + ) -> FingerprintVerification { + let public_key = crypto::PublicKey(secret_key.deref().public_key()); + if crypto::ssh::fmt::fingerprint(&public_key) == self.0 { + FingerprintVerification::Match + } else { + FingerprintVerification::Mismatch + } + } +} + +/// Return the location of the node fingerprint. +fn path(home: &Home) -> std::path::PathBuf { + home.node().join("fingerprint") +} + +#[cfg(test)] +mod tests { + use super::*; + + use crypto::ssh::Keystore; + + #[test] + fn matching() { + let tmp = tempfile::tempdir().unwrap(); + let home = Home::new(tmp.path()).unwrap(); + + let store = Keystore::new(&home.keys()); + store.init("test 1", None, crypto::Seed::default()).unwrap(); + let secret = store.secret_key(None).unwrap().unwrap(); + + assert_eq!(Fingerprint::read(&home).unwrap(), None); + Fingerprint::init(&home, &secret).unwrap(); + + let fp = Fingerprint::read(&home).unwrap().unwrap(); + assert_eq!(fp.verify(&secret), FingerprintVerification::Match); + + // Generate a new keypair, which does not match the fingerprint. + // This simulates the user modifying `~/.radicle/keys`. + std::fs::remove_dir_all(home.keys()).unwrap(); + store.init("test 1", None, crypto::Seed::default()).unwrap(); + let other_secret = store.secret_key(None).unwrap().unwrap(); + + assert_ne!(secret, other_secret); + // Note that `fp` has not changed since it was initialiazed from `secret`. + assert_eq!(fp.verify(&other_secret), FingerprintVerification::Mismatch); + } +} diff --git a/crates/radicle-node/src/lib.rs b/crates/radicle-node/src/lib.rs index 4e9fbfbe..5ba2c1e0 100644 --- a/crates/radicle-node/src/lib.rs +++ b/crates/radicle-node/src/lib.rs @@ -7,6 +7,7 @@ use std::str::FromStr; use std::sync::LazyLock; pub mod control; +pub mod fingerprint; pub mod runtime; pub(crate) use radicle_protocol::service; #[cfg(any(test, feature = "test"))] diff --git a/crates/radicle-node/src/main.rs b/crates/radicle-node/src/main.rs index 4fbcf360..799bd99b 100644 --- a/crates/radicle-node/src/main.rs +++ b/crates/radicle-node/src/main.rs @@ -11,6 +11,7 @@ use radicle::node::device::Device; use radicle::profile; use radicle_node::crypto::ssh::keystore::{Keystore, MemorySigner}; +use radicle_node::fingerprint::{Fingerprint, FingerprintVerification}; use radicle_node::{Runtime, VERSION}; #[cfg(unix)] use radicle_signals as signals; @@ -27,6 +28,8 @@ Options --config Config file to use (default: ~/.radicle/config.json) + --secret Secret key to use + (default ~/.radicle/keys/radicle) --force Force start even if an existing control socket is found --listen
Address to listen on @@ -100,6 +103,7 @@ struct LogOptions { struct Options { config: Option, + secret: Option, listen: Vec, log: LogOptions, force: bool, @@ -112,6 +116,7 @@ fn parse_options() -> Result { let mut parser = lexopt::Parser::from_env(); let mut listen = Vec::new(); let mut config = None; + let mut secret = None; let mut force = false; let mut log_level = None; let mut log_logger = Logger::default(); @@ -125,6 +130,9 @@ fn parse_options() -> Result { Long("config") => { config = Some(parser.value()?.parse_with(PathBuf::from_str)?); } + Long("secret") => { + secret = Some(parser.value()?.parse()?); + } Long("listen") => { let addr = parser.value()?.parse_with(SocketAddr::from_str)?; listen.push(addr); @@ -164,6 +172,7 @@ fn parse_options() -> Result { Ok(Options { force, + secret, listen, config, log: LogOptions { @@ -181,9 +190,21 @@ enum ExecutionError { #[error(transparent)] ConfigurationLoading(#[from] profile::config::LoadError), #[error(transparent)] - MemorySigner(#[from] radicle::crypto::ssh::keystore::MemorySignerError), - #[error(transparent)] Runtime(#[from] radicle_node::runtime::Error), + #[error(transparent)] + Fingerprint(#[from] radicle_node::fingerprint::Error), + #[error("failed to load secret key '{path}': not found")] + SecretNotFound { path: PathBuf }, + #[error("failed to load secret '{path}': {source}")] + SecretLoading { + path: PathBuf, + source: radicle::crypto::ssh::keystore::Error, + }, + #[error("failed to load secret key '{secret}': fingerprint of corresponding public key is different from '{fingerprint}'")] + FingerprintMismatch { + secret: PathBuf, + fingerprint: Fingerprint, + }, } fn execute(options: Options) -> Result<(), ExecutionError> { @@ -215,9 +236,38 @@ fn execute(options: Options) -> Result<(), ExecutionError> { log::info!(target: "node", "Unlocking node keystore.."); let passphrase = profile::env::passphrase(); - let keystore = Keystore::new(&home.keys()); - let signer = Device::from(MemorySigner::load(&keystore, passphrase)?); + let secret_path = options + .secret + .or_else(|| config.node.secret.clone()) + .unwrap_or_else(|| home.keys().join("radicle")); + + let keystore = Keystore::from_secret_path(&secret_path); + + let secret_key = keystore + .secret_key(passphrase.clone()) + .map_err(|err| ExecutionError::SecretLoading { + path: secret_path.clone(), + source: err, + })? + .ok_or_else(|| ExecutionError::SecretNotFound { + path: secret_path.clone(), + })?; + + if let Some(fp) = Fingerprint::read(&home)? { + log::debug!(target: "node", "Verifying fingerprint.."); + if fp.verify(&secret_key) != FingerprintVerification::Match { + return Err(ExecutionError::FingerprintMismatch { + secret: keystore.secret_key_path().to_path_buf(), + fingerprint: fp, + }); + } + } else { + log::info!(target: "node", "Initializing fingerprint.."); + Fingerprint::init(&home, &secret_key)?; + } + + let signer = Device::from(MemorySigner::from_secret(secret_key)); log::info!(target: "node", "Node ID is {}", signer.public_key()); // Add the preferred seeds as persistent peers so that we reconnect to them automatically. diff --git a/crates/radicle/src/node/config.rs b/crates/radicle/src/node/config.rs index 4e6aa637..faaf9f70 100644 --- a/crates/radicle/src/node/config.rs +++ b/crates/radicle/src/node/config.rs @@ -459,6 +459,14 @@ pub struct Config { /// Extra fields that aren't supported. #[serde(flatten, skip_serializing)] pub extra: json::Map, + /// Path to a file containing an Ed25519 secret key, in OpenSSH format, i.e. + /// with the `-----BEGIN OPENSSH PRIVATE KEY-----` header. The corresponding + /// public key will be used as the Node ID. + /// + /// A decryption password cannot be configured, but passed at runtime via + /// the environment variable `RAD_PASSPHRASE`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secret: Option, } impl Config { @@ -485,6 +493,7 @@ impl Config { log: LogLevel::default(), seeding_policy: DefaultSeedingPolicy::default(), extra: json::Map::default(), + secret: None, } }