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
<https://systemd.io/CREDENTIALS/>.

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.
This commit is contained in:
Lorenz Leutgeb 2025-08-27 02:32:03 +02:00 committed by Fintan Halpenny
parent 5887edf93b
commit 22720e718b
4 changed files with 198 additions and 4 deletions

View File

@ -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<Option<Fingerprint>, 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<Target = crypto::SecretKey>,
) -> 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<Target = crypto::SecretKey>,
) -> 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);
}
}

View File

@ -7,6 +7,7 @@ use std::str::FromStr;
use std::sync::LazyLock; use std::sync::LazyLock;
pub mod control; pub mod control;
pub mod fingerprint;
pub mod runtime; pub mod runtime;
pub(crate) use radicle_protocol::service; pub(crate) use radicle_protocol::service;
#[cfg(any(test, feature = "test"))] #[cfg(any(test, feature = "test"))]

View File

@ -11,6 +11,7 @@ use radicle::node::device::Device;
use radicle::profile; use radicle::profile;
use radicle_node::crypto::ssh::keystore::{Keystore, MemorySigner}; use radicle_node::crypto::ssh::keystore::{Keystore, MemorySigner};
use radicle_node::fingerprint::{Fingerprint, FingerprintVerification};
use radicle_node::{Runtime, VERSION}; use radicle_node::{Runtime, VERSION};
#[cfg(unix)] #[cfg(unix)]
use radicle_signals as signals; use radicle_signals as signals;
@ -27,6 +28,8 @@ Options
--config <path> Config file to use --config <path> Config file to use
(default: ~/.radicle/config.json) (default: ~/.radicle/config.json)
--secret <path> Secret key to use
(default ~/.radicle/keys/radicle)
--force Force start even if an existing control socket --force Force start even if an existing control socket
is found is found
--listen <address> Address to listen on --listen <address> Address to listen on
@ -100,6 +103,7 @@ struct LogOptions {
struct Options { struct Options {
config: Option<PathBuf>, config: Option<PathBuf>,
secret: Option<PathBuf>,
listen: Vec<SocketAddr>, listen: Vec<SocketAddr>,
log: LogOptions, log: LogOptions,
force: bool, force: bool,
@ -112,6 +116,7 @@ fn parse_options() -> Result<Options, lexopt::Error> {
let mut parser = lexopt::Parser::from_env(); let mut parser = lexopt::Parser::from_env();
let mut listen = Vec::new(); let mut listen = Vec::new();
let mut config = None; let mut config = None;
let mut secret = None;
let mut force = false; let mut force = false;
let mut log_level = None; let mut log_level = None;
let mut log_logger = Logger::default(); let mut log_logger = Logger::default();
@ -125,6 +130,9 @@ fn parse_options() -> Result<Options, lexopt::Error> {
Long("config") => { Long("config") => {
config = Some(parser.value()?.parse_with(PathBuf::from_str)?); config = Some(parser.value()?.parse_with(PathBuf::from_str)?);
} }
Long("secret") => {
secret = Some(parser.value()?.parse()?);
}
Long("listen") => { Long("listen") => {
let addr = parser.value()?.parse_with(SocketAddr::from_str)?; let addr = parser.value()?.parse_with(SocketAddr::from_str)?;
listen.push(addr); listen.push(addr);
@ -164,6 +172,7 @@ fn parse_options() -> Result<Options, lexopt::Error> {
Ok(Options { Ok(Options {
force, force,
secret,
listen, listen,
config, config,
log: LogOptions { log: LogOptions {
@ -181,9 +190,21 @@ enum ExecutionError {
#[error(transparent)] #[error(transparent)]
ConfigurationLoading(#[from] profile::config::LoadError), ConfigurationLoading(#[from] profile::config::LoadError),
#[error(transparent)] #[error(transparent)]
MemorySigner(#[from] radicle::crypto::ssh::keystore::MemorySignerError),
#[error(transparent)]
Runtime(#[from] radicle_node::runtime::Error), 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> { fn execute(options: Options) -> Result<(), ExecutionError> {
@ -215,9 +236,38 @@ fn execute(options: Options) -> Result<(), ExecutionError> {
log::info!(target: "node", "Unlocking node keystore.."); log::info!(target: "node", "Unlocking node keystore..");
let passphrase = profile::env::passphrase(); 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()); log::info!(target: "node", "Node ID is {}", signer.public_key());
// Add the preferred seeds as persistent peers so that we reconnect to them automatically. // Add the preferred seeds as persistent peers so that we reconnect to them automatically.

View File

@ -459,6 +459,14 @@ pub struct Config {
/// Extra fields that aren't supported. /// Extra fields that aren't supported.
#[serde(flatten, skip_serializing)] #[serde(flatten, skip_serializing)]
pub extra: json::Map<String, json::Value>, pub extra: json::Map<String, json::Value>,
/// 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<std::path::PathBuf>,
} }
impl Config { impl Config {
@ -485,6 +493,7 @@ impl Config {
log: LogLevel::default(), log: LogLevel::default(),
seeding_policy: DefaultSeedingPolicy::default(), seeding_policy: DefaultSeedingPolicy::default(),
extra: json::Map::default(), extra: json::Map::default(),
secret: None,
} }
} }