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)]
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")

View File

@ -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)?;

View File

@ -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();

View File

@ -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"] }

View File

@ -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());
}

View File

@ -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();

View File

@ -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)?;

View File

@ -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;

View File

@ -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()?,
))
}

View File

@ -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)?;

View File

@ -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());
}