Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
node: Make location of secret key configurable
✗ CI failure Lorenz Leutgeb committed 7 months ago
commit 5ce5010cb45523867b3cc420b9167bb7e2d3383b
parent 36c870c844edcfea68b6eebda94299acda9a8611
2 failed 1 pending (3 total) View logs
5 files changed +164 -3
added crates/radicle-node/src/fingerprint.rs
@@ -0,0 +1,119 @@
+
//! Fingerprint 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::ssh::{Keystore, Passphrase};
+
use radicle::profile::Home;
+

+
/// Fingerprint of a public key.
+
#[derive(Debug)]
+
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("file not found: {0}")]
+
    NotFound(std::path::PathBuf),
+

+
    #[error("keystore error: {0}")]
+
    Keystore(#[from] radicle::crypto::ssh::keystore::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 the secret key in the
+
    /// keystore, optionally using the passphrase to decrypt.
+
    pub fn init(
+
        home: &Home,
+
        keystore: &Keystore,
+
        passphrase: Option<&Passphrase>,
+
    ) -> Result<(), Error> {
+
        let Some(public_key) = keystore.public_key_derived(passphrase)? else {
+
            return Err(Error::NotFound(keystore.secret_key_path().to_path_buf()));
+
        };
+

+
        let mut file = std::fs::OpenOptions::new()
+
            .create_new(true)
+
            .write(true)
+
            .open(path(home))?;
+

+
        {
+
            use std::io::Write as _;
+
            file.write_all(radicle::crypto::ssh::fmt::fingerprint(&public_key).as_ref())?;
+
        }
+

+
        Ok(())
+
    }
+

+
    /// Verify that the fingerprint of the secret key in the keystore matches
+
    /// the fingerprint of the node as stored on disk.
+
    pub fn verify(
+
        &self,
+
        keystore: &Keystore,
+
        passphrase: Option<&Passphrase>,
+
    ) -> Result<FingerprintVerification, Error> {
+
        let Some(public_key) = keystore.public_key_derived(passphrase)? else {
+
            return Err(Error::NotFound(keystore.secret_key_path().to_path_buf()));
+
        };
+

+
        Ok(
+
            if radicle::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")
+
}
modified 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"))]
modified 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      <path>                            Config file to use
                  (default: ~/.radicle/config.json)
+
    --secret      <path>                            Secret key to use
+
                  (default ~/.radicle/keys/radicle)
    --force                                         Force start even if an existing control socket
                                                      is found
    --listen      <address>                         Address to listen on
@@ -100,6 +103,7 @@ struct LogOptions {

struct Options {
    config: Option<PathBuf>,
+
    secret: Option<PathBuf>,
    listen: Vec<SocketAddr>,
    log: LogOptions,
    force: bool,
@@ -112,6 +116,7 @@ fn parse_options() -> Result<Options, lexopt::Error> {
    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<Options, lexopt::Error> {
            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<Options, lexopt::Error> {

    Ok(Options {
        force,
+
        secret,
        listen,
        config,
        log: LogOptions {
@@ -184,6 +193,13 @@ enum ExecutionError {
    MemorySigner(#[from] radicle::crypto::ssh::keystore::MemorySignerError),
    #[error(transparent)]
    Runtime(#[from] radicle_node::runtime::Error),
+
    #[error(transparent)]
+
    Fingerprint(#[from] radicle_node::fingerprint::Error),
+
    #[error("Fingerprint mismatch. Expected the public key corresponding to the secret key at '{secret}' to have fingerprint '{fingerprint}', which is not the case. Refusing operation.")]
+
    FingerprintMismatch {
+
        secret: PathBuf,
+
        fingerprint: Fingerprint,
+
    }
}

fn execute(options: Options) -> Result<(), ExecutionError> {
@@ -215,8 +231,24 @@ 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 keystore = match options.secret.or_else(|| config.node.secret.clone()) {
+
        Some(secret) => Keystore::from_secret_path(&secret),
+
        None => Keystore::new(home.keys()),
+
    };
+

+
    match Fingerprint::read(&home)? {
+
        Some(fp) => {
+
            if fp.verify(&keystore, passphrase.as_ref())? != FingerprintVerification::Match {
+
                return Err(ExecutionError::FingerprintMismatch { secret: keystore.secret_key_path().to_path_buf(), fingerprint: fp });
+
            }
+
        }
+
        None => {
+
            Fingerprint::init(&home, &keystore, passphrase.as_ref())?;
+
        }
+
    }
+

+
    let signer = Device::from(MemorySigner::load(&keystore, passphrase.as_ref())?);

    log::info!(target: "node", "Node ID is {}", signer.public_key());

modified crates/radicle-schemars/src/main.rs
@@ -81,7 +81,7 @@ fn print_schema() -> io::Result<()> {

            #[derive(JsonSchema)]
            #[schemars(untagged)]
-
            #[allow(dead_code)]
+
            #[allow(dead_code, clippy::large_enum_variant)]
            enum CommandResult {
                Nid(
                    #[schemars(with = "radicle::schemars_ext::crypto::PublicKey")]
modified 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<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 {
@@ -485,6 +493,7 @@ impl Config {
            log: LogLevel::default(),
            seeding_policy: DefaultSeedingPolicy::default(),
            extra: json::Map::default(),
+
            secret: None,
        }
    }