cli: Make key encryption optional

By leaving the passphrase prompt blank, users can create unencrypted
keys.
This commit is contained in:
Alexis Sellier 2023-07-15 14:18:52 +02:00
parent e593191ae2
commit f92308cf25
No known key found for this signature in database
11 changed files with 108 additions and 63 deletions

View File

@ -1,6 +1,7 @@
#![allow(clippy::or_fun_call)] #![allow(clippy::or_fun_call)]
use std::env; use std::env;
use std::ffi::OsString; use std::ffi::OsString;
use std::ops::Not as _;
use std::str::FromStr; use std::str::FromStr;
use anyhow::anyhow; use anyhow::anyhow;
@ -109,22 +110,25 @@ pub fn init(options: Options) -> anyhow::Result<()> {
} else { } else {
term::passphrase_confirm("Enter a passphrase:", RAD_PASSPHRASE) 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 spinner = term::spinner("Creating your Ed25519 keypair...");
let profile = Profile::init(home, alias, passphrase.clone())?; let profile = Profile::init(home, alias, passphrase.clone())?;
spinner.finish(); spinner.finish();
match ssh::agent::Agent::connect() { if let Some(passphrase) = passphrase {
Ok(mut agent) => { match ssh::agent::Agent::connect() {
let mut spinner = term::spinner("Adding your radicle key to ssh-agent..."); Ok(mut agent) => {
if register(&mut agent, &profile, passphrase).is_ok() { let mut spinner = term::spinner("Adding your radicle key to ssh-agent...");
spinner.finish(); if register(&mut agent, &profile, passphrase).is_ok() {
} else { spinner.finish();
spinner.message("Could not register radicle key in ssh-agent."); } else {
spinner.warn(); 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!( 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 /// Try loading the identity's key into SSH Agent, falling back to verifying `RAD_PASSPHRASE` for
/// use. /// use.
pub fn authenticate(options: Options, profile: &Profile) -> anyhow::Result<()> { 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() { match ssh::agent::Agent::connect() {
Ok(mut agent) => { Ok(mut agent) => {
if agent.request_identities()?.contains(&profile.public_key) { 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. // Try RAD_PASSPHRASE fallback.
if let Some(passphrase) = profile::env::passphrase() { 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"))?; .map_err(|_| anyhow!("RAD_PASSPHRASE failed"))?;
return Ok(()); return Ok(());
}; };
@ -191,7 +201,7 @@ pub fn register(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let secret = profile let secret = profile
.keystore .keystore
.secret_key(passphrase) .secret_key(Some(passphrase))
.map_err(|e| { .map_err(|e| {
if e.is_crypto_err() { if e.is_crypto_err() {
anyhow!("could not decrypt secret key: invalid passphrase") anyhow!("could not decrypt secret key: invalid passphrase")

View File

@ -24,10 +24,16 @@ pub fn start(
term::success!("Node is already running"); term::success!("Node is already running");
return Ok(()); return Ok(());
} }
// Ask passphrase here, otherwise it'll be a fatal error when running the daemon let envs = if profile.keystore.is_encrypted()? {
// without `RAD_PASSPHRASE`. To keep things consistent, we also use this in foreground mode. // Ask passphrase here, otherwise it'll be a fatal error when running the daemon
let passphrase = term::io::passphrase(profile::env::RAD_PASSPHRASE) // without `RAD_PASSPHRASE`. To keep things consistent, we also use this in foreground mode.
.context(format!("`{}` must be set", profile::env::RAD_PASSPHRASE))?; 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` // Since we checked that the node is not running, it's safe to use `--force`
// here. // here.
@ -42,7 +48,7 @@ pub fn start(
process::Command::new("radicle-node") process::Command::new("radicle-node")
.args(options) .args(options)
.env(profile::env::RAD_PASSPHRASE, passphrase) .envs(envs)
.stdin(process::Stdio::null()) .stdin(process::Stdio::null())
.stdout(process::Stdio::from(log)) .stdout(process::Stdio::from(log))
.stderr(process::Stdio::null()) .stderr(process::Stdio::null())
@ -52,7 +58,7 @@ pub fn start(
} else { } else {
let mut child = process::Command::new("radicle-node") let mut child = process::Command::new("radicle-node")
.args(options) .args(options)
.env(profile::env::RAD_PASSPHRASE, passphrase) .envs(envs)
.spawn()?; .spawn()?;
child.wait()?; child.wait()?;
@ -139,6 +145,7 @@ pub fn status(node: &Node, profile: &Profile) -> anyhow::Result<()> {
term::success!("Node is {}", term::format::positive("running")); term::success!("Node is {}", term::format::positive("running"));
} else { } else {
term::info!("Node is {}", term::format::negative("stopped")); term::info!("Node is {}", term::format::negative("stopped"));
return Ok(());
} }
let sessions = sessions(node)?; let sessions = sessions(node)?;

View File

@ -15,7 +15,7 @@ pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
} }
let passphrase = passphrase(RAD_PASSPHRASE)?; let passphrase = passphrase(RAD_PASSPHRASE)?;
let spinner = spinner("Unsealing key..."); let spinner = spinner("Unsealing key...");
let signer = MemorySigner::load(&profile.keystore, passphrase)?; let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?;
spinner.finish(); spinner.finish();

View File

@ -14,7 +14,7 @@ ssh = ["radicle-ssh", "ssh-key"]
[dependencies] [dependencies]
amplify = { version = "4.0.0" } 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" } multibase = { version = "0.9.1" }
ec25519 = { version = "0.1.0", features = [] } ec25519 = { version = "0.1.0", features = [] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }

View File

@ -56,11 +56,9 @@ impl Keystore {
/// ///
/// The `comment` is associated with the private key. /// The `comment` is associated with the private key.
/// The `passphrase` is used to encrypt the private key. /// The `passphrase` is used to encrypt the private key.
pub fn init( ///
&self, /// If `passphrase` is `None`, the key is not encrypted.
comment: &str, pub fn init(&self, comment: &str, passphrase: Option<Passphrase>) -> Result<PublicKey, Error> {
passphrase: impl Into<Passphrase>,
) -> Result<PublicKey, Error> {
self.store(keypair::generate(), comment, passphrase) self.store(keypair::generate(), comment, passphrase)
} }
@ -69,12 +67,16 @@ impl Keystore {
&self, &self,
keypair: KeyPair, keypair: KeyPair,
comment: &str, comment: &str,
passphrase: impl Into<Passphrase>, passphrase: Option<Passphrase>,
) -> Result<PublicKey, Error> { ) -> Result<PublicKey, Error> {
let ssh_pair = ssh_key::private::Ed25519Keypair::from_bytes(&keypair)?; let ssh_pair = ssh_key::private::Ed25519Keypair::from_bytes(&keypair)?;
let ssh_pair = ssh_key::private::KeypairData::Ed25519(ssh_pair); let ssh_pair = ssh_key::private::KeypairData::Ed25519(ssh_pair);
let secret = ssh_key::PrivateKey::new(ssh_pair, comment)?; 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 public = secret.public_key();
let path = self.path.join("radicle"); let path = self.path.join("radicle");
@ -111,16 +113,19 @@ impl Keystore {
/// Returns `None` if it wasn't found. /// Returns `None` if it wasn't found.
pub fn secret_key( pub fn secret_key(
&self, &self,
passphrase: Passphrase, passphrase: Option<Passphrase>,
) -> Result<Option<Zeroizing<SecretKey>>, Error> { ) -> Result<Option<Zeroizing<SecretKey>>, Error> {
let path = self.path.join("radicle"); let path = self.path.join("radicle");
if !path.exists() { if !path.exists() {
return Ok(None); return Ok(None);
} }
let encrypted = ssh_key::PrivateKey::read_openssh_file(&path)?; let secret = ssh_key::PrivateKey::read_openssh_file(&path)?;
let secret = encrypted.decrypt(passphrase)?; let secret = if let Some(p) = passphrase {
secret.decrypt(p)?
} else {
secret
};
match secret.key_data() { match secret.key_data() {
ssh_key::private::KeypairData::Ed25519(pair) => { ssh_key::private::KeypairData::Ed25519(pair) => {
Ok(Some(SecretKey::from(pair.to_bytes()).into())) Ok(Some(SecretKey::from(pair.to_bytes()).into()))
@ -128,6 +133,14 @@ impl Keystore {
_ => Err(Error::InvalidKeyType), _ => 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)] #[derive(Debug, Error)]
@ -192,7 +205,10 @@ impl Ecdh for MemorySigner {
impl MemorySigner { impl MemorySigner {
/// Load this signer from a keystore, given a secret key passphrase. /// 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 let public = keystore
.public_key()? .public_key()?
.ok_or_else(|| MemorySignerError::NotFound(keystore.path().to_path_buf()))?; .ok_or_else(|| MemorySignerError::NotFound(keystore.path().to_path_buf()))?;
@ -238,20 +254,38 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_init() { fn test_init_passphrase() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let store = Keystore::new(&tmp.path()); 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_eq!(public, store.public_key().unwrap().unwrap());
assert!(store.is_encrypted().unwrap());
let secret = store let secret = store
.secret_key("hunter".to_owned().into()) .secret_key(Some("hunter".to_owned().into()))
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(PublicKey::from(secret.public_key()), public); 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] #[test]
@ -259,8 +293,10 @@ mod tests {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let store = Keystore::new(&tmp.path()); let store = Keystore::new(&tmp.path());
let public = store.init("test", "hunter".to_owned()).unwrap(); let public = store
let signer = MemorySigner::load(&store, "hunter".to_owned().into()).unwrap(); .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()); assert_eq!(public, *signer.public_key());
} }

View File

@ -46,8 +46,6 @@ pub const CONTRIBUTOR_PATCH_ID: &str = "044b577cc7551cd09d4b2f03566a553762180de4
pub const CONTRIBUTOR_COMMENT_1: &str = "92aab76516ae7f60a9b2952043ba578383de7d46"; pub const CONTRIBUTOR_COMMENT_1: &str = "92aab76516ae7f60a9b2952043ba578383de7d46";
pub const CONTRIBUTOR_COMMENT_2: &str = "cb360eee0ec70563d5c4c3613fdc076648523248"; pub const CONTRIBUTOR_COMMENT_2: &str = "cb360eee0ec70563d5c4c3613fdc076648523248";
const PASSWORD: &str = "radicle";
/// Create a new profile. /// Create a new profile.
pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile { pub fn profile(home: &Path, seed: [u8; 32]) -> radicle::Profile {
let home = Home::new(home).unwrap(); 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)); let keypair = KeyPair::from_seed(Seed::from(seed));
radicle::storage::git::transport::local::register(storage.clone()); radicle::storage::git::transport::local::register(storage.clone());
keystore keystore.store(keypair.clone(), "radicle", None).unwrap();
.store(keypair.clone(), "radicle", PASSWORD.to_owned())
.unwrap();
radicle::Profile { radicle::Profile {
home, home,
@ -85,7 +81,7 @@ pub fn contributor(dir: &Path) -> Context {
let home = dir.join("radicle"); let home = dir.join("radicle");
let profile = profile(home.as_path(), seed); 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) 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"); let workdir = dir.join("hello-world");
env::set_var("RAD_COMMIT_TIME", TIMESTAMP.to_string()); env::set_var("RAD_COMMIT_TIME", TIMESTAMP.to_string());
env::set_var("RAD_PASSPHRASE", PASSWORD);
fs::create_dir_all(&workdir).unwrap(); fs::create_dir_all(&workdir).unwrap();

View File

@ -1,11 +1,10 @@
use std::{env, fs, net, process}; use std::{env, fs, net, process};
use anyhow::{anyhow, Context as _}; use anyhow::anyhow;
use crossbeam_channel as chan; use crossbeam_channel as chan;
use cyphernet::addr::PeerAddr; use cyphernet::addr::PeerAddr;
use localtime::LocalDuration; use localtime::LocalDuration;
use radicle::crypto;
use radicle::node; use radicle::node;
use radicle::prelude::Signer; use radicle::prelude::Signer;
use radicle::profile; use radicle::profile;
@ -126,9 +125,7 @@ fn execute() -> anyhow::Result<()> {
log::info!(target: "node", "Unlocking node keystore.."); log::info!(target: "node", "Unlocking node keystore..");
let passphrase = env::var(profile::env::RAD_PASSPHRASE) let passphrase = profile::env::passphrase();
.map(crypto::ssh::Passphrase::from)
.context(format!("`{}` must be set", profile::env::RAD_PASSPHRASE))?;
let keystore = Keystore::new(&home.keys()); let keystore = Keystore::new(&home.keys());
let signer = MemorySigner::load(&keystore, passphrase)?; let signer = MemorySigner::load(&keystore, passphrase)?;

View File

@ -78,7 +78,7 @@ impl Environment {
/// is required. Use [`Environment::profile`] otherwise. /// is required. Use [`Environment::profile`] otherwise.
pub fn node(&mut self, config: Config) -> Node<MemorySigner> { pub fn node(&mut self, config: Config) -> Node<MemorySigner> {
let profile = self.profile(&config.alias); 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); let tracking_db = profile.home.node().join(TRACKING_DB_FILE);
TrackingStore::Config::open(tracking_db).unwrap(); TrackingStore::Config::open(tracking_db).unwrap();
let addresses_db = profile.home.node().join(ADDRESS_DB_FILE); let addresses_db = profile.home.node().join(ADDRESS_DB_FILE);
@ -109,9 +109,7 @@ impl Environment {
Book::open(addresses_db).unwrap(); Book::open(addresses_db).unwrap();
transport::local::register(storage.clone()); transport::local::register(storage.clone());
keystore keystore.store(keypair.clone(), "radicle", None).unwrap();
.store(keypair.clone(), "radicle", "radicle".to_owned())
.unwrap();
// Ensures that each user has a unique but deterministic public key. // Ensures that each user has a unique but deterministic public key.
self.users += 1; self.users += 1;

View File

@ -216,7 +216,7 @@ pub fn passphrase_confirm<K: AsRef<OsStr>>(
.with_display_mode(inquire::PasswordDisplayMode::Masked) .with_display_mode(inquire::PasswordDisplayMode::Masked)
.with_custom_confirmation_message("Repeat passphrase:") .with_custom_confirmation_message("Repeat passphrase:")
.with_custom_confirmation_error_message("The passphrases don't match.") .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()?, .prompt()?,
)) ))
} }

View File

@ -21,7 +21,7 @@ fn main() -> anyhow::Result<()> {
let passphrase = passphrase.trim().to_owned().into(); let passphrase = passphrase.trim().to_owned().into();
let secret = profile let secret = profile
.keystore .keystore
.secret_key(passphrase)? .secret_key(Some(passphrase))?
.ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.path()))?; .ok_or_else(|| anyhow!("Key not found in {:?}", profile.keystore.path()))?;
agent.register(&secret)?; agent.register(&secret)?;

View File

@ -38,6 +38,7 @@ pub mod env {
/// Passphrase for the encrypted radicle secret key. /// Passphrase for the encrypted radicle secret key.
pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE"; pub const RAD_PASSPHRASE: &str = "RAD_PASSPHRASE";
/// Get the radicle passphrase from the environment.
pub fn passphrase() -> Option<super::Passphrase> { pub fn passphrase() -> Option<super::Passphrase> {
let Ok(passphrase) = std::env::var(RAD_PASSPHRASE) else { let Ok(passphrase) = std::env::var(RAD_PASSPHRASE) else {
return None; return None;
@ -139,11 +140,7 @@ pub struct Profile {
} }
impl Profile { impl Profile {
pub fn init( pub fn init(home: Home, alias: Alias, passphrase: Option<Passphrase>) -> Result<Self, Error> {
home: Home,
alias: Alias,
passphrase: impl Into<Passphrase>,
) -> Result<Self, Error> {
let storage = Storage::open(home.storage())?; let storage = Storage::open(home.storage())?;
let keystore = Keystore::new(&home.keys()); let keystore = Keystore::new(&home.keys());
let public_key = keystore.init("radicle", passphrase)?; let public_key = keystore.init("radicle", passphrase)?;
@ -189,8 +186,13 @@ impl Profile {
} }
pub fn signer(&self) -> Result<Box<dyn Signer>, Error> { 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() { 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()); return Ok(signer.boxed());
} }