diff --git a/radicle-cli/src/commands/auth.rs b/radicle-cli/src/commands/auth.rs index c9b71940..b63d0717 100644 --- a/radicle-cli/src/commands/auth.rs +++ b/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") diff --git a/radicle-cli/src/commands/node/control.rs b/radicle-cli/src/commands/node/control.rs index 56df0399..f65f9f33 100644 --- a/radicle-cli/src/commands/node/control.rs +++ b/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)?; diff --git a/radicle-cli/src/terminal/io.rs b/radicle-cli/src/terminal/io.rs index 5619a09c..5774e711 100644 --- a/radicle-cli/src/terminal/io.rs +++ b/radicle-cli/src/terminal/io.rs @@ -15,7 +15,7 @@ pub fn signer(profile: &Profile) -> anyhow::Result> { } 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(); diff --git a/radicle-crypto/Cargo.toml b/radicle-crypto/Cargo.toml index da935004..90592a1e 100644 --- a/radicle-crypto/Cargo.toml +++ b/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"] } diff --git a/radicle-crypto/src/ssh/keystore.rs b/radicle-crypto/src/ssh/keystore.rs index f1ed9044..72d7bd9c 100644 --- a/radicle-crypto/src/ssh/keystore.rs +++ b/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, - ) -> Result { + /// + /// If `passphrase` is `None`, the key is not encrypted. + pub fn init(&self, comment: &str, passphrase: Option) -> Result { self.store(keypair::generate(), comment, passphrase) } @@ -69,12 +67,16 @@ impl Keystore { &self, keypair: KeyPair, comment: &str, - passphrase: impl Into, + passphrase: Option, ) -> Result { 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, ) -> Result>, 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 { + 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 { + pub fn load( + keystore: &Keystore, + passphrase: Option, + ) -> Result { 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()); } diff --git a/radicle-httpd/src/test.rs b/radicle-httpd/src/test.rs index 82967df0..7438dee2 100644 --- a/radicle-httpd/src/test.rs +++ b/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(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(); diff --git a/radicle-node/src/main.rs b/radicle-node/src/main.rs index d10db8ac..1e50f73f 100644 --- a/radicle-node/src/main.rs +++ b/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)?; diff --git a/radicle-node/src/test/environment.rs b/radicle-node/src/test/environment.rs index c17153ec..f212ed9b 100644 --- a/radicle-node/src/test/environment.rs +++ b/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 { 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; diff --git a/radicle-term/src/io.rs b/radicle-term/src/io.rs index a8fce6f2..68e0eb8e 100644 --- a/radicle-term/src/io.rs +++ b/radicle-term/src/io.rs @@ -216,7 +216,7 @@ pub fn passphrase_confirm>( .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()?, )) } diff --git a/radicle-tools/src/rad-agent.rs b/radicle-tools/src/rad-agent.rs index 15216efb..82d7005f 100644 --- a/radicle-tools/src/rad-agent.rs +++ b/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)?; diff --git a/radicle/src/profile.rs b/radicle/src/profile.rs index 0ff10cff..88678a21 100644 --- a/radicle/src/profile.rs +++ b/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 { 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, - ) -> Result { + pub fn init(home: Home, alias: Alias, passphrase: Option) -> Result { 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, 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()); }