Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
node: Make location of secret key configurable
Merged lorenz opened 1 year ago

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/.

In order to get this implemented, I had to make modifications to the keystore in radicle-ssh.

7 files changed +314 -52 9e1d6b1f 22720e71
modified crates/radicle-cli/examples/rad-key-mismatch.md
@@ -2,5 +2,5 @@ This test assumes that one of the two keys in `$RAD_HOME/keys` was swapped so th

``` (fail)
$ rad issue open --title "flux capacitor underpowered" --description "Flux capacitor power requirements exceed current supply" --no-announce
-
✗ Error: secret and public keys in '[..]/.radicle/keys' do not match
+
✗ Error: secret key '[..]/.radicle/keys/radicle' and public key '[..]/.radicle/keys/radicle.pub' do not match
```

\ No newline at end of file
modified crates/radicle-cli/src/commands/auth.rs
@@ -240,7 +240,7 @@ pub fn register(
                e.into()
            }
        })?
-
        .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.path()))?;
+
        .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.secret_key_path()))?;

    agent.register(&secret)?;

modified crates/radicle-crypto/src/ssh/keystore.rs
@@ -22,8 +22,8 @@ pub enum Error {
    Ssh(#[from] ssh_key::Error),
    #[error("invalid key type, expected ed25519 key")]
    InvalidKeyType,
-
    #[error("keystore already initialized")]
-
    AlreadyInitialized,
+
    #[error("keystore already initialized, file '{exists}' exists")]
+
    AlreadyInitialized { exists: PathBuf },
    #[error("keystore is encrypted; a passphrase is required")]
    PassphraseMissing,
}
@@ -38,28 +38,52 @@ impl Error {
/// Stores keys on disk, in OpenSSH format.
#[derive(Debug, Clone)]
pub struct Keystore {
-
    path: PathBuf,
+
    path_secret: PathBuf,
+
    path_public: Option<PathBuf>,
}

impl Keystore {
-
    /// Create a new keystore pointing to the given path. Use [`Keystore::init`] to initialize.
+
    /// Create a new keystore pointing to the given path.
+
    ///
+
    /// Use [`Keystore::init`] to initialize.
    pub fn new<P: AsRef<Path>>(path: &P) -> Self {
+
        const DEFAULT_SECRET_KEY_FILE_NAME: &str = "radicle";
+
        const DEFAULT_PUBLIC_KEY_FILE_NAME: &str = "radicle.pub";
+

+
        let keys = path.as_ref().to_path_buf();
+

+
        Self {
+
            path_secret: keys.join(DEFAULT_SECRET_KEY_FILE_NAME),
+
            path_public: Some(keys.join(DEFAULT_PUBLIC_KEY_FILE_NAME)),
+
        }
+
    }
+

+
    /// Create a new keystore pointing to the given paths.
+
    ///
+
    /// Use [`Keystore::init`] to initialize.
+
    pub fn from_secret_path<P: AsRef<Path>>(secret: &P) -> Self {
        Self {
-
            path: path.as_ref().to_path_buf(),
+
            path_secret: secret.as_ref().to_path_buf(),
+
            path_public: None,
        }
    }

-
    /// Get the path to the keystore.
-
    pub fn path(&self) -> &Path {
-
        self.path.as_path()
+
    /// Get the path to the secret key backing the keystore.
+
    pub fn secret_key_path(&self) -> &Path {
+
        self.path_secret.as_path()
+
    }
+

+
    /// Get the path to the public key backing the keystore, if present.
+
    pub fn public_key_path(&self) -> Option<&Path> {
+
        self.path_public.as_deref()
    }

-
    /// Initialize a keystore by generating a key pair and storing the secret and public key
-
    /// at the given path.
+
    /// Initialize a keystore by generating a key pair and storing the secret
+
    /// and public key at the given path.
    ///
-
    /// The `comment` is associated with the private key.
-
    /// The `passphrase` is used to encrypt the private key.
-
    /// The `seed` is used to derive the private key and should almost always be generated.
+
    /// The `comment` is associated with the private key. The `passphrase` is
+
    /// used to encrypt the private key. The `seed` is used to derive the
+
    /// private key and should almost always be generated.
    ///
    /// If `passphrase` is `None`, the key is not encrypted.
    pub fn init(
@@ -71,7 +95,7 @@ impl Keystore {
        self.store(KeyPair::from_seed(seed), comment, passphrase)
    }

-
    /// Store a keypair on disk. Returns an error if the key already exists.
+
    /// Store a keypair on disk. Returns an error if any of the two key files already exist.
    pub fn store(
        &self,
        keypair: KeyPair,
@@ -87,13 +111,25 @@ impl Keystore {
            secret
        };
        let public = secret.public_key();
-
        let path = self.path.join("radicle");

-
        if path.exists() {
-
            return Err(Error::AlreadyInitialized);
+
        if self.path_secret.exists() {
+
            return Err(Error::AlreadyInitialized {
+
                exists: self.path_secret.to_path_buf(),
+
            });
        }

-
        {
+
        if let Some(path_public) = &self.path_public {
+
            if path_public.exists() {
+
                return Err(Error::AlreadyInitialized {
+
                    exists: path_public.to_path_buf(),
+
                });
+
            }
+
        }
+

+
        // NOTE: If [`PathBuf::parent`] returns `None`,
+
        // then the path is at root or empty, so don't
+
        // attempt to create any parents.
+
        self.path_secret.parent().map_or(Ok(()), |parent| {
            let mut builder = fs::DirBuilder::new();
            builder.recursive(true);

@@ -103,27 +139,43 @@ impl Keystore {
                builder.mode(0o700);
            }

-
            builder.create(&self.path)?;
-
        }
+
            builder.create(parent)
+
        })?;
+
        secret.write_openssh_file(&self.path_secret, ssh_key::LineEnding::default())?;
+

+
        if let Some(path_public) = &self.path_public {
+
            path_public.parent().map_or(Ok(()), |parent| {
+
                let mut builder = fs::DirBuilder::new();
+
                builder.recursive(true);

-
        secret.write_openssh_file(&path, ssh_key::LineEnding::default())?;
-
        public.write_openssh_file(&path.with_extension("pub"))?;
+
                #[cfg(unix)]
+
                {
+
                    use std::os::unix::fs::DirBuilderExt as _;
+
                    builder.mode(0o700);
+
                }
+

+
                builder.create(parent)
+
            })?;
+
            public.write_openssh_file(path_public)?;
+
        }

        Ok(keypair.pk.into())
    }

    /// Load the public key from the store. Returns `None` if it wasn't found.
    pub fn public_key(&self) -> Result<Option<PublicKey>, Error> {
-
        let path = self.path.join("radicle.pub");
-
        if !path.exists() {
+
        let Some(path_public) = &self.path_public else {
            return Ok(None);
-
        }
+
        };

-
        let public = ssh_key::PublicKey::read_openssh_file(&path)?;
-
        match public.try_into() {
-
            Ok(public) => Ok(Some(public)),
-
            _ => Err(Error::InvalidKeyType),
+
        if !path_public.exists() {
+
            return Ok(None);
        }
+

+
        let public = ssh_key::PublicKey::read_openssh_file(path_public)?;
+
        PublicKey::try_from(public)
+
            .map(Some)
+
            .map_err(|_| Error::InvalidKeyType)
    }

    /// Load the secret key from the store, decrypting it with the given passphrase.
@@ -132,12 +184,13 @@ impl Keystore {
        &self,
        passphrase: Option<Passphrase>,
    ) -> Result<Option<Zeroizing<SecretKey>>, Error> {
-
        let path = self.path.join("radicle");
+
        let path = &self.path_secret;
        if !path.exists() {
            return Ok(None);
        }

-
        let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
+
        let secret = ssh_key::PrivateKey::read_openssh_file(path)?;
+

        let secret = if let Some(p) = passphrase {
            secret.decrypt(p)?
        } else if secret.is_encrypted() {
@@ -155,12 +208,11 @@ impl Keystore {

    /// Check that the passphrase is valid.
    pub fn is_valid_passphrase(&self, passphrase: &Passphrase) -> Result<bool, Error> {
-
        let path = self.path.join("radicle");
-
        if !path.exists() {
+
        if !self.path_secret.exists() {
            return Err(Error::Io(io::ErrorKind::NotFound.into()));
        }

-
        let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
+
        let secret = ssh_key::PrivateKey::read_openssh_file(&self.path_secret)?;
        let valid = secret.decrypt(passphrase).is_ok();

        Ok(valid)
@@ -168,8 +220,7 @@ impl Keystore {

    /// Check whether the secret key is encrypted.
    pub fn is_encrypted(&self) -> Result<bool, Error> {
-
        let path = self.path.join("radicle");
-
        let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
+
        let secret = ssh_key::PrivateKey::read_openssh_file(&self.path_secret)?;

        Ok(secret.is_encrypted())
    }
@@ -183,8 +234,8 @@ pub enum MemorySignerError {
    NotFound(PathBuf),
    #[error("invalid passphrase")]
    InvalidPassphrase,
-
    #[error("secret and public keys in '{path}' do not match")]
-
    KeyMismatch { path: PathBuf },
+
    #[error("secret key '{secret}' and public key '{public}' do not match")]
+
    KeyMismatch { secret: PathBuf, public: PathBuf },
}

/// An in-memory signer that keeps its secret key internally
@@ -260,9 +311,6 @@ impl MemorySigner {
        keystore: &Keystore,
        passphrase: Option<Passphrase>,
    ) -> Result<Self, MemorySignerError> {
-
        let public = keystore
-
            .public_key()?
-
            .ok_or_else(|| MemorySignerError::NotFound(keystore.path().to_path_buf()))?;
        let secret = keystore
            .secret_key(passphrase)
            .map_err(|e| {
@@ -272,17 +320,37 @@ impl MemorySigner {
                    e.into()
                }
            })?
-
            .ok_or_else(|| MemorySignerError::NotFound(keystore.path().to_path_buf()))?;
+
            .ok_or_else(|| MemorySignerError::NotFound(keystore.secret_key_path().to_path_buf()))?;
+

+
        let Some(public_path) = keystore.public_key_path() else {
+
            // There is no public key in the key store, so there's nothing
+
            // to validate. Derive it from the secret key.
+
            return Ok(Self::from_secret(secret));
+
        };
+

+
        let public = keystore
+
            .public_key()?
+
            .ok_or_else(|| MemorySignerError::NotFound(public_path.to_path_buf()))?;

        secret
            .validate_public_key(&public)
            .map_err(|_| MemorySignerError::KeyMismatch {
-
                path: keystore.path().to_path_buf(),
+
                secret: keystore.secret_key_path().to_path_buf(),
+
                public: public_path.to_path_buf(),
            })?;

        Ok(Self { public, secret })
    }

+
    /// Create a new memory signer from the given secret key, deriving
+
    /// the public key from the secret key.
+
    pub fn from_secret(secret: Zeroizing<SecretKey>) -> Self {
+
        Self {
+
            public: PublicKey(secret.public_key()),
+
            secret,
+
        }
+
    }
+

    /// Box this signer into a trait object.
    pub fn boxed(self) -> Box<dyn Signer> {
        Box::new(self)
@@ -320,7 +388,7 @@ mod tests {
    #[test]
    fn test_init_passphrase() {
        let tmp = tempfile::tempdir().unwrap();
-
        let store = Keystore::new(&tmp.path());
+
        let store = Keystore::new(&tmp);

        let public = store
            .init(
@@ -346,7 +414,7 @@ mod tests {
    #[test]
    fn test_init_no_passphrase() {
        let tmp = tempfile::tempdir().unwrap();
-
        let store = Keystore::new(&tmp.path());
+
        let store = Keystore::new(&tmp);

        let public = store.init("test", None, ec25519::Seed::default()).unwrap();
        assert_eq!(public, store.public_key().unwrap().unwrap());
@@ -359,7 +427,7 @@ mod tests {
    #[test]
    fn test_signer() {
        let tmp = tempfile::tempdir().unwrap();
-
        let store = Keystore::new(&tmp.path());
+
        let store = Keystore::new(&tmp);

        let public = store
            .init(
added 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<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);
+
    }
+
}
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 {
@@ -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.
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,
        }
    }