Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: Make key encryption optional
Alexis Sellier committed 2 years ago
commit f92308cf2564f780b4bdd4a103dbf961076faefa
parent e593191ae2f1261743bab2c548792d643394d3c8
11 files changed +108 -63
modified radicle-cli/src/commands/auth.rs
@@ -1,6 +1,7 @@
#![allow(clippy::or_fun_call)]
use std::env;
use std::ffi::OsString;
+
use std::ops::Not as _;
use std::str::FromStr;

use anyhow::anyhow;
@@ -109,22 +110,25 @@ pub fn init(options: Options) -> anyhow::Result<()> {
    } else {
        term::passphrase_confirm("Enter a passphrase:", RAD_PASSPHRASE)
    }?;
+
    let passphrase = passphrase.trim().is_empty().not().then_some(passphrase);
    let spinner = term::spinner("Creating your Ed25519 keypair...");
    let profile = Profile::init(home, alias, passphrase.clone())?;
    spinner.finish();

-
    match ssh::agent::Agent::connect() {
-
        Ok(mut agent) => {
-
            let mut spinner = term::spinner("Adding your radicle key to ssh-agent...");
-
            if register(&mut agent, &profile, passphrase).is_ok() {
-
                spinner.finish();
-
            } else {
-
                spinner.message("Could not register radicle key in ssh-agent.");
-
                spinner.warn();
+
    if let Some(passphrase) = passphrase {
+
        match ssh::agent::Agent::connect() {
+
            Ok(mut agent) => {
+
                let mut spinner = term::spinner("Adding your radicle key to ssh-agent...");
+
                if register(&mut agent, &profile, passphrase).is_ok() {
+
                    spinner.finish();
+
                } else {
+
                    spinner.message("Could not register radicle key in ssh-agent.");
+
                    spinner.warn();
+
                }
            }
+
            Err(e) if e.is_not_running() => {}
+
            Err(e) => Err(e)?,
        }
-
        Err(e) if e.is_not_running() => {}
-
        Err(e) => Err(e)?,
    }

    term::success!(
@@ -144,7 +148,13 @@ pub fn init(options: Options) -> anyhow::Result<()> {
/// Try loading the identity's key into SSH Agent, falling back to verifying `RAD_PASSPHRASE` for
/// use.
pub fn authenticate(options: Options, profile: &Profile) -> anyhow::Result<()> {
-
    // Authenticate with SSH Agent only if it is running.
+
    if !profile.keystore.is_encrypted()? {
+
        term::success!("Authenticated as {}", term::format::tertiary(profile.id()));
+
        return Ok(());
+
    }
+

+
    // If our key is encrypted, we try to authenticate with SSH Agent and
+
    // register it; only if it is running.
    match ssh::agent::Agent::connect() {
        Ok(mut agent) => {
            if agent.request_identities()?.contains(&profile.public_key) {
@@ -174,7 +184,7 @@ pub fn authenticate(options: Options, profile: &Profile) -> anyhow::Result<()> {

    // Try RAD_PASSPHRASE fallback.
    if let Some(passphrase) = profile::env::passphrase() {
-
        ssh::keystore::MemorySigner::load(&profile.keystore, passphrase)
+
        ssh::keystore::MemorySigner::load(&profile.keystore, Some(passphrase))
            .map_err(|_| anyhow!("RAD_PASSPHRASE failed"))?;
        return Ok(());
    };
@@ -191,7 +201,7 @@ pub fn register(
) -> anyhow::Result<()> {
    let secret = profile
        .keystore
-
        .secret_key(passphrase)
+
        .secret_key(Some(passphrase))
        .map_err(|e| {
            if e.is_crypto_err() {
                anyhow!("could not decrypt secret key: invalid passphrase")
modified radicle-cli/src/commands/node/control.rs
@@ -24,10 +24,16 @@ pub fn start(
        term::success!("Node is already running");
        return Ok(());
    }
-
    // Ask passphrase here, otherwise it'll be a fatal error when running the daemon
-
    // without `RAD_PASSPHRASE`. To keep things consistent, we also use this in foreground mode.
-
    let passphrase = term::io::passphrase(profile::env::RAD_PASSPHRASE)
-
        .context(format!("`{}` must be set", profile::env::RAD_PASSPHRASE))?;
+
    let envs = if profile.keystore.is_encrypted()? {
+
        // Ask passphrase here, otherwise it'll be a fatal error when running the daemon
+
        // without `RAD_PASSPHRASE`. To keep things consistent, we also use this in foreground mode.
+
        let passphrase = term::io::passphrase(profile::env::RAD_PASSPHRASE)
+
            .context(format!("`{}` must be set", profile::env::RAD_PASSPHRASE))?;
+

+
        Some((profile::env::RAD_PASSPHRASE, passphrase))
+
    } else {
+
        None
+
    };

    // Since we checked that the node is not running, it's safe to use `--force`
    // here.
@@ -42,7 +48,7 @@ pub fn start(

        process::Command::new("radicle-node")
            .args(options)
-
            .env(profile::env::RAD_PASSPHRASE, passphrase)
+
            .envs(envs)
            .stdin(process::Stdio::null())
            .stdout(process::Stdio::from(log))
            .stderr(process::Stdio::null())
@@ -52,7 +58,7 @@ pub fn start(
    } else {
        let mut child = process::Command::new("radicle-node")
            .args(options)
-
            .env(profile::env::RAD_PASSPHRASE, passphrase)
+
            .envs(envs)
            .spawn()?;

        child.wait()?;
@@ -139,6 +145,7 @@ pub fn status(node: &Node, profile: &Profile) -> anyhow::Result<()> {
        term::success!("Node is {}", term::format::positive("running"));
    } else {
        term::info!("Node is {}", term::format::negative("stopped"));
+
        return Ok(());
    }

    let sessions = sessions(node)?;
modified radicle-cli/src/terminal/io.rs
@@ -15,7 +15,7 @@ pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
    }
    let passphrase = passphrase(RAD_PASSPHRASE)?;
    let spinner = spinner("Unsealing key...");
-
    let signer = MemorySigner::load(&profile.keystore, passphrase)?;
+
    let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?;

    spinner.finish();

modified radicle-crypto/Cargo.toml
@@ -14,7 +14,7 @@ ssh = ["radicle-ssh", "ssh-key"]

[dependencies]
amplify = { version = "4.0.0" }
-
cyphernet = { version = "0.2.0", optional = true }
+
cyphernet = { version = "0.2.0", optional = true, features = ["ed25519"] }
multibase = { version = "0.9.1" }
ec25519 = { version = "0.1.0", features = [] }
serde = { version = "1", features = ["derive"] }
modified radicle-crypto/src/ssh/keystore.rs
@@ -56,11 +56,9 @@ impl Keystore {
    ///
    /// The `comment` is associated with the private key.
    /// The `passphrase` is used to encrypt the private key.
-
    pub fn init(
-
        &self,
-
        comment: &str,
-
        passphrase: impl Into<Passphrase>,
-
    ) -> Result<PublicKey, Error> {
+
    ///
+
    /// If `passphrase` is `None`, the key is not encrypted.
+
    pub fn init(&self, comment: &str, passphrase: Option<Passphrase>) -> Result<PublicKey, Error> {
        self.store(keypair::generate(), comment, passphrase)
    }

@@ -69,12 +67,16 @@ impl Keystore {
        &self,
        keypair: KeyPair,
        comment: &str,
-
        passphrase: impl Into<Passphrase>,
+
        passphrase: Option<Passphrase>,
    ) -> Result<PublicKey, Error> {
        let ssh_pair = ssh_key::private::Ed25519Keypair::from_bytes(&keypair)?;
        let ssh_pair = ssh_key::private::KeypairData::Ed25519(ssh_pair);
        let secret = ssh_key::PrivateKey::new(ssh_pair, comment)?;
-
        let secret = secret.encrypt(ssh_key::rand_core::OsRng, passphrase.into())?;
+
        let secret = if let Some(p) = passphrase {
+
            secret.encrypt(ssh_key::rand_core::OsRng, p)?
+
        } else {
+
            secret
+
        };
        let public = secret.public_key();
        let path = self.path.join("radicle");

@@ -111,16 +113,19 @@ impl Keystore {
    /// Returns `None` if it wasn't found.
    pub fn secret_key(
        &self,
-
        passphrase: Passphrase,
+
        passphrase: Option<Passphrase>,
    ) -> Result<Option<Zeroizing<SecretKey>>, Error> {
        let path = self.path.join("radicle");
        if !path.exists() {
            return Ok(None);
        }

-
        let encrypted = ssh_key::PrivateKey::read_openssh_file(&path)?;
-
        let secret = encrypted.decrypt(passphrase)?;
-

+
        let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
+
        let secret = if let Some(p) = passphrase {
+
            secret.decrypt(p)?
+
        } else {
+
            secret
+
        };
        match secret.key_data() {
            ssh_key::private::KeypairData::Ed25519(pair) => {
                Ok(Some(SecretKey::from(pair.to_bytes()).into()))
@@ -128,6 +133,14 @@ impl Keystore {
            _ => Err(Error::InvalidKeyType),
        }
    }
+

+
    /// 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)?;
+

+
        Ok(secret.is_encrypted())
+
    }
}

#[derive(Debug, Error)]
@@ -192,7 +205,10 @@ impl Ecdh for MemorySigner {

impl MemorySigner {
    /// Load this signer from a keystore, given a secret key passphrase.
-
    pub fn load(keystore: &Keystore, passphrase: Passphrase) -> Result<Self, MemorySignerError> {
+
    pub fn load(
+
        keystore: &Keystore,
+
        passphrase: Option<Passphrase>,
+
    ) -> Result<Self, MemorySignerError> {
        let public = keystore
            .public_key()?
            .ok_or_else(|| MemorySignerError::NotFound(keystore.path().to_path_buf()))?;
@@ -238,20 +254,38 @@ mod tests {
    use super::*;

    #[test]
-
    fn test_init() {
+
    fn test_init_passphrase() {
        let tmp = tempfile::tempdir().unwrap();
        let store = Keystore::new(&tmp.path());

-
        let public = store.init("test", "hunter".to_owned()).unwrap();
+
        let public = store
+
            .init("test", Some("hunter".to_owned().into()))
+
            .unwrap();
        assert_eq!(public, store.public_key().unwrap().unwrap());
+
        assert!(store.is_encrypted().unwrap());

        let secret = store
-
            .secret_key("hunter".to_owned().into())
+
            .secret_key(Some("hunter".to_owned().into()))
            .unwrap()
            .unwrap();
        assert_eq!(PublicKey::from(secret.public_key()), public);

-
        store.secret_key("blunder".to_owned().into()).unwrap_err(); // Wrong passphrase.
+
        store
+
            .secret_key(Some("blunder".to_owned().into()))
+
            .unwrap_err(); // Wrong passphrase.
+
    }
+

+
    #[test]
+
    fn test_init_no_passphrase() {
+
        let tmp = tempfile::tempdir().unwrap();
+
        let store = Keystore::new(&tmp.path());
+

+
        let public = store.init("test", None).unwrap();
+
        assert_eq!(public, store.public_key().unwrap().unwrap());
+
        assert!(!store.is_encrypted().unwrap());
+

+
        let secret = store.secret_key(None).unwrap().unwrap();
+
        assert_eq!(PublicKey::from(secret.public_key()), public);
    }

    #[test]
@@ -259,8 +293,10 @@ mod tests {
        let tmp = tempfile::tempdir().unwrap();
        let store = Keystore::new(&tmp.path());

-
        let public = store.init("test", "hunter".to_owned()).unwrap();
-
        let signer = MemorySigner::load(&store, "hunter".to_owned().into()).unwrap();
+
        let public = store
+
            .init("test", Some("hunter".to_owned().into()))
+
            .unwrap();
+
        let signer = MemorySigner::load(&store, Some("hunter".to_owned().into())).unwrap();

        assert_eq!(public, *signer.public_key());
    }
modified radicle-httpd/src/test.rs
@@ -46,8 +46,6 @@ pub const CONTRIBUTOR_PATCH_ID: &str = "044b577cc7551cd09d4b2f03566a553762180de4
pub const CONTRIBUTOR_COMMENT_1: &str = "92aab76516ae7f60a9b2952043ba578383de7d46";
pub const CONTRIBUTOR_COMMENT_2: &str = "cb360eee0ec70563d5c4c3613fdc076648523248";

-
const PASSWORD: &str = "radicle";
-

/// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
    let home = Home::new(home).unwrap();
@@ -56,9 +54,7 @@ pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
    let keypair = KeyPair::from_seed(Seed::from(seed));

    radicle::storage::git::transport::local::register(storage.clone());
-
    keystore
-
        .store(keypair.clone(), "radicle", PASSWORD.to_owned())
-
        .unwrap();
+
    keystore.store(keypair.clone(), "radicle", None).unwrap();

    radicle::Profile {
        home,
@@ -85,7 +81,7 @@ pub fn contributor(dir: &Path) -> Context {

    let home = dir.join("radicle");
    let profile = profile(home.as_path(), seed);
-
    let signer = MemorySigner::load(&profile.keystore, PASSWORD.to_owned().into()).unwrap();
+
    let signer = MemorySigner::load(&profile.keystore, None).unwrap();

    seed_with_signer(dir, profile, &signer)
}
@@ -102,7 +98,6 @@ fn seed_with_signer<G: Signer>(dir: &Path, profile: radicle::Profile, signer: &G
    let workdir = dir.join("hello-world");

    env::set_var("RAD_COMMIT_TIME", TIMESTAMP.to_string());
-
    env::set_var("RAD_PASSPHRASE", PASSWORD);

    fs::create_dir_all(&workdir).unwrap();

modified radicle-node/src/main.rs
@@ -1,11 +1,10 @@
use std::{env, fs, net, process};

-
use anyhow::{anyhow, Context as _};
+
use anyhow::anyhow;
use crossbeam_channel as chan;
use cyphernet::addr::PeerAddr;
use localtime::LocalDuration;

-
use radicle::crypto;
use radicle::node;
use radicle::prelude::Signer;
use radicle::profile;
@@ -126,9 +125,7 @@ fn execute() -> anyhow::Result<()> {

    log::info!(target: "node", "Unlocking node keystore..");

-
    let passphrase = env::var(profile::env::RAD_PASSPHRASE)
-
        .map(crypto::ssh::Passphrase::from)
-
        .context(format!("`{}` must be set", profile::env::RAD_PASSPHRASE))?;
+
    let passphrase = profile::env::passphrase();
    let keystore = Keystore::new(&home.keys());
    let signer = MemorySigner::load(&keystore, passphrase)?;

modified radicle-node/src/test/environment.rs
@@ -78,7 +78,7 @@ impl Environment {
    /// is required. Use [`Environment::profile`] otherwise.
    pub fn node(&mut self, config: Config) -> Node<MemorySigner> {
        let profile = self.profile(&config.alias);
-
        let signer = MemorySigner::load(&profile.keystore, "radicle".to_owned().into()).unwrap();
+
        let signer = MemorySigner::load(&profile.keystore, None).unwrap();
        let tracking_db = profile.home.node().join(TRACKING_DB_FILE);
        TrackingStore::Config::open(tracking_db).unwrap();
        let addresses_db = profile.home.node().join(ADDRESS_DB_FILE);
@@ -109,9 +109,7 @@ impl Environment {
        Book::open(addresses_db).unwrap();

        transport::local::register(storage.clone());
-
        keystore
-
            .store(keypair.clone(), "radicle", "radicle".to_owned())
-
            .unwrap();
+
        keystore.store(keypair.clone(), "radicle", None).unwrap();

        // Ensures that each user has a unique but deterministic public key.
        self.users += 1;
modified radicle-term/src/io.rs
@@ -216,7 +216,7 @@ pub fn passphrase_confirm<K: AsRef<OsStr>>(
                .with_display_mode(inquire::PasswordDisplayMode::Masked)
                .with_custom_confirmation_message("Repeat passphrase:")
                .with_custom_confirmation_error_message("The passphrases don't match.")
-
                .with_help_message("This passphrase protects your radicle identity")
+
                .with_help_message("Leave this blank to keep your radicle key unencrypted")
                .prompt()?,
        ))
    }
modified radicle-tools/src/rad-agent.rs
@@ -21,7 +21,7 @@ fn main() -> anyhow::Result<()> {
            let passphrase = passphrase.trim().to_owned().into();
            let secret = profile
                .keystore
-
                .secret_key(passphrase)?
+
                .secret_key(Some(passphrase))?
                .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.path()))?;

            agent.register(&secret)?;
modified radicle/src/profile.rs
@@ -38,6 +38,7 @@ pub mod env {
    /// Passphrase for the encrypted radicle secret key.
    pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE";

+
    /// Get the radicle passphrase from the environment.
    pub fn passphrase() -> Option<super::Passphrase> {
        let Ok(passphrase) = std::env::var(RAD_PASSPHRASE) else {
            return None;
@@ -139,11 +140,7 @@ pub struct Profile {
}

impl Profile {
-
    pub fn init(
-
        home: Home,
-
        alias: Alias,
-
        passphrase: impl Into<Passphrase>,
-
    ) -> Result<Self, Error> {
+
    pub fn init(home: Home, alias: Alias, passphrase: Option<Passphrase>) -> Result<Self, Error> {
        let storage = Storage::open(home.storage())?;
        let keystore = Keystore::new(&home.keys());
        let public_key = keystore.init("radicle", passphrase)?;
@@ -189,8 +186,13 @@ impl Profile {
    }

    pub fn signer(&self) -> Result<Box<dyn Signer>, Error> {
+
        if !self.keystore.is_encrypted()? {
+
            let signer = keystore::MemorySigner::load(&self.keystore, None)?;
+
            return Ok(signer.boxed());
+
        }
+

        if let Some(passphrase) = env::passphrase() {
-
            let signer = keystore::MemorySigner::load(&self.keystore, passphrase)?;
+
            let signer = keystore::MemorySigner::load(&self.keystore, Some(passphrase))?;
            return Ok(signer.boxed());
        }