Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
node: Make location of secret key configurable
Lorenz Leutgeb committed 8 months ago
commit dec836e7ad4697f0c8eea848a18b2da75913ba03
parent 790a906df7859add1630df2d63386bdd2a2eca9d
5 files changed +136 -3
added crates/radicle-node/src/fingerprint.rs
@@ -0,0 +1,92 @@
+
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)),
+
        }
+
    }
+

+
    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(())
+
    }
+

+
    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()));
+
        };
+
        if radicle::crypto::ssh::fmt::fingerprint(&public_key) == self.0 {
+
            Ok(FingerprintVerification::Match)
+
        } else {
+
            Ok(FingerprintVerification::Mismatch)
+
        }
+
    }
+
}
+

+
const NODE_FINGERPRINT_FILE: &str = "fingerprint";
+

+
/// Return the location of the node fingerprint.
+
fn path(home: &Home) -> std::path::PathBuf {
+
    home.node().join(NODE_FINGERPRINT_FILE)
+
}
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
@@ -7,6 +7,7 @@ use crossbeam_channel as chan;
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;
@@ -19,9 +20,13 @@ Usage
   If you're running a public seed node, make sure to use `--listen` to bind a listening socket to
   eg. `0.0.0.0:8776`, and add your external addresses in your configuration.

+
   The option `--secret` overrides the config file and allows for more
+
   flexibility in deployment.
+

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
    --log                <level>        Set log level (default: info)
@@ -32,6 +37,7 @@ Options
#[derive(Debug)]
struct Options {
    config: Option<PathBuf>,
+
    secret: Option<PathBuf>,
    listen: Vec<net::SocketAddr>,
    log: Option<log::Level>,
    force: bool,
@@ -44,6 +50,7 @@ impl Options {
        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 = None;

@@ -57,6 +64,11 @@ impl Options {
                    let path = PathBuf::from(value);
                    config = Some(path);
                }
+
                Long("secret") => {
+
                    let value = parser.value()?;
+
                    let path = PathBuf::from(value);
+
                    secret = Some(path);
+
                }
                Long("listen") => {
                    let addr = parser.value()?.parse()?;
                    listen.push(addr);
@@ -81,6 +93,7 @@ impl Options {
            listen,
            log,
            config,
+
            secret,
        })
    }
}
@@ -120,9 +133,28 @@ fn execute() -> anyhow::Result<()> {
    log::info!(target: "node", "Unlocking node keystore..");

    let passphrase = profile::env::passphrase();
-
    let keystore = Keystore::new(&home.keys());
+

+
    let keystore = match options.secret.or_else(|| config.node.secret.clone()) {
+
        Some(secret) => Keystore::new_secret(&secret),
+
        None => Keystore::new(home.keys()),
+
    };
+

+
    match Fingerprint::read(&home)? {
+
        Some(fp) => {
+
            if fp.verify(&keystore, passphrase.as_ref())? != FingerprintVerification::Match {
+
                anyhow::bail!(
+
                    "Fingerprint mismatch. Expected '{}' to have fingerprint '{}', which is not the case. Refusing operation.",
+
                    keystore.secret_key_path().display(), fp,
+
                )
+
            }
+
        }
+
        None => {
+
            Fingerprint::init(&home, &keystore, passphrase.as_ref())?;
+
        }
+
    }
+

    let signer = Device::from(
-
        MemorySigner::load(&keystore, passphrase).context("couldn't load secret key")?,
+
        MemorySigner::load(&keystore, passphrase.as_ref()).context("couldn't load secret key")?,
    );

    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,13 @@ 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 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 +492,7 @@ impl Config {
            log: LogLevel::default(),
            seeding_policy: DefaultSeedingPolicy::default(),
            extra: json::Map::default(),
+
            secret: None,
        }
    }