diff --git a/Cargo.lock b/Cargo.lock index 00dbe7b3..fde92ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3210,9 +3210,9 @@ dependencies = [ [[package]] name = "sequoia-keystore" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76eaefe78ca373001382f2434ca021145d9b702ba52c04cebc398b8e5956d28a" +checksum = "aa77ac702f6be1489580eb092aa5acae36050db04fa5ae445238a84591e1ad7a" dependencies = [ "anyhow", "capnp", @@ -3272,9 +3272,9 @@ dependencies = [ [[package]] name = "sequoia-keystore-softkeys" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1affc41cb24e491cd38a0a47928bc221b7db4318f0e4b17c49131c9a4a13eb1" +checksum = "6f9707371cae085b6e1cac9e17bf94a19efcdc04da4dba5cbda1cb8f8c0a655a" dependencies = [ "anyhow", "async-trait", diff --git a/src/cli/key.rs b/src/cli/key.rs index 247b7bf0..cb4135b6 100644 --- a/src/cli/key.rs +++ b/src/cli/key.rs @@ -89,6 +89,7 @@ pub enum Subcommands { List(ListCommand), Generate(GenerateCommand), Import(ImportCommand), + Export(ExportCommand), Password(PasswordCommand), Expire(expire::Command), Revoke(RevokeCommand), @@ -335,6 +336,69 @@ pub struct ImportCommand { pub file: Vec, } +const EXPORT_EXAMPLES: Actions = Actions { + actions: &[ + Action::Example(Example { + comment: "\ +Import a certificate.", + command: &[ + "sq", "key", "import", "alice-secret.pgp", + ], + }), + Action::Example(Example { + comment: "\ +Export Alice's certificate with all available secret key material.", + command: &[ + "sq", "key", "export", + "--cert", "EB28F26E2739A4870ECC47726F0073F60FD0CBF0", + ], + }), + Action::Example(Example { + comment: "\ +Export Alice's signing-capable and encryption-capable subkeys, but not \ +her primary key or her authentication-capable subkey.", + command: &[ + "sq", "key", "export", + "--key", "42020B87D51877E5AF8D272124F3955B0B8DECC8", + "--key", "74DCDEAF17D9B995679EB52BA6E65EA2C8497728", + ], + }), + ] +}; +test_examples!(sq_key_export, EXPORT_EXAMPLES); + +#[derive(Debug, Args)] +#[clap( + about = "Export keys from the key store", + after_help = EXPORT_EXAMPLES, +)] +#[clap(group(ArgGroup::new("export").args(&["cert", "key"])))] +pub struct ExportCommand { + #[clap( + long, + value_name = "FINGERPRINT|KEYID", + help = "Export the specified certificate", + long_help = "Export the specified certificate by iterating over the \ + specified certificate's primary key and subkeys and \ + exporting any keys with secret key material. An \ + error is returned if the certificate does not contain \ + any secret key material.", + )] + pub cert: Vec, + + #[clap( + long, + value_name = "FINGERPRINT|KEYID", + help = "Export the specified key", + long_help = "Export the specified key. The entire certificate is \ + exported, but only the specified key's secret key \ + material is exported. An error is returned if the \ + secret key material for the specified key is not \ + available.", + )] + pub key: Vec, +} + #[derive(Debug, Args)] #[clap( name = "password", diff --git a/src/commands/key.rs b/src/commands/key.rs index 732ca1ff..d204c6b9 100644 --- a/src/commands/key.rs +++ b/src/commands/key.rs @@ -9,6 +9,8 @@ use adopt::adopt; mod attest_certifications; use attest_certifications::attest_certifications; mod expire; +mod export; +use export::export; mod import; use import::import; mod list; @@ -29,6 +31,7 @@ pub fn dispatch(config: Config, command: cli::key::Command) -> Result<()> List(c) => list(config, c)?, Generate(c) => generate(config, c)?, Import(c) => import(config, c)?, + Export(c) => export(config, c)?, Password(c) => password(config, c)?, Expire(c) => expire::dispatch(config, c)?, Userid(c) => userid::dispatch(config, c)?, diff --git a/src/commands/key/export.rs b/src/commands/key/export.rs new file mode 100644 index 00000000..d3ad4ab8 --- /dev/null +++ b/src/commands/key/export.rs @@ -0,0 +1,117 @@ +use std::collections::BTreeMap; + +use sequoia_openpgp as openpgp; +use openpgp::cert::amalgamation::key::PrimaryKey; +use openpgp::cert::Cert; +use openpgp::Fingerprint; +use openpgp::packet::Packet; +use openpgp::serialize::Serialize; + +use anyhow::Context; + +use crate::cli; +use crate::Config; +use crate::Result; + +const NULL: openpgp::policy::NullPolicy = + openpgp::policy::NullPolicy::new(); + +pub fn export(config: Config, command: cli::key::ExportCommand) + -> Result<()> +{ + let ks = config.key_store_or_else()?; + let mut ks = ks.lock().unwrap(); + + // If the user asks for multiple keys from the same certificate, + // then we only want to export the certificate once. + let mut certs: BTreeMap = BTreeMap::new(); + + for (export_cert, export) in command.cert.into_iter().map(|kh| (true, kh)) + .chain(command.key.into_iter().map(|kh| (false, kh))) + { + let mut cert = config.lookup_one(&export, None, true)?; + if let Some(c) = certs.remove(&cert.fingerprint()) { + cert = c; + } + + let vc = Cert::with_policy(&cert, config.policy, config.time) + .or_else(|err| { + if export_cert { + Err(err) + } else { + // When exporting by --key, fallback to the null + // policy. It should be possible to export old + // keys, even if the certificate is not considered + // safe any more. + Cert::with_policy(&cert, &NULL, config.time) + } + }) + .with_context(|| { + format!("The certificate {} is not valid under the \ + current policy. Use --key to export \ + specific keys.", + cert.fingerprint()) + })?; + + let mut secret_keys: Vec = Vec::new(); + for loud in [false, true] { + for key in vc.keys() { + if key.has_secret() { + continue; + } + + let key_handle = key.key_handle(); + + if ! export_cert && ! key_handle.aliases(&export) { + continue; + } + + for mut remote in ks.find_key(key_handle)? { + match remote.export() { + Ok(secret_key) => { + if key.primary() { + secret_keys.push( + secret_key.role_into_primary().into()); + } else { + secret_keys.push( + secret_key.role_into_subordinate().into()); + } + break; + } + Err(err) => { + if loud { + eprintln!("Exporting {}: {}", + key.fingerprint(), err); + } + } + } + } + } + + if loud { + return Err(anyhow::anyhow!( + "Failed to export {}: no secret key material is available", + cert.fingerprint())); + } else if ! secret_keys.is_empty() { + break; + } + } + let cert = cert.insert_packets(secret_keys)?; + certs.insert(cert.fingerprint(), cert); + } + + let mut output = openpgp::armor::Writer::new( + std::io::stdout(), + openpgp::armor::Kind::SecretKey)?; + + for (_fpr, cert) in certs.into_iter() { + cert.as_tsk().serialize(&mut output) + .with_context(|| { + format!("Serializing {}", cert.fingerprint()) + })?; + } + + output.finalize()?; + + Ok(()) +} diff --git a/tests/sq-key-import-export.rs b/tests/sq-key-import-export.rs new file mode 100644 index 00000000..19beef0a --- /dev/null +++ b/tests/sq-key-import-export.rs @@ -0,0 +1,172 @@ +use assert_cmd::Command; + +use tempfile::TempDir; + +use sequoia_openpgp as openpgp; +use openpgp::KeyID; +use openpgp::Result; +use openpgp::cert::prelude::*; +use openpgp::packet::Key; +use openpgp::parse::Parse; + +mod integration { + use super::*; + + #[test] + fn sq_key_import_export() -> Result<()> + { + let dir = TempDir::new()?; + + let rev_pgp = dir.path().join("rev.pgp"); + let rev_pgp_str = &*rev_pgp.to_string_lossy(); + + let key_pgp = dir.path().join("key.pgp"); + let key_pgp_str = &*key_pgp.to_string_lossy(); + + // Generate a few keys as red herrings. + for _ in 0..10 { + let mut cmd = Command::cargo_bin("sq")?; + cmd.env("SEQUOIA_HOME", dir.path()); + cmd.args(["--force", "key", "generate", + "--no-userids", + "--rev-cert", &rev_pgp_str]); + cmd.assert().success(); + } + + // Generate a key in a file. + let mut cmd = Command::cargo_bin("sq")?; + cmd.env("SEQUOIA_HOME", dir.path()); + cmd.args(["key", "generate", + "--no-userids", + "--output", &key_pgp_str]); + cmd.assert().success(); + + let cert = Cert::from_file(&key_pgp)?; + assert!(cert.is_tsk()); + + // Import it into the key store. + let mut cmd = Command::cargo_bin("sq")?; + cmd.env("SEQUOIA_HOME", dir.path()); + cmd.args(["key", "import", + &*key_pgp.to_string_lossy()]); + cmd.assert().success(); + + // Export the whole certificate. + for by_fpr in [true, false] { + let mut cmd = Command::cargo_bin("sq")?; + cmd.env("SEQUOIA_HOME", dir.path()); + cmd.args(["key", "export", "--cert", + &if by_fpr { + cert.fingerprint().to_string() + } else { + cert.keyid().to_string() + }]); + let result = cmd.assert().success(); + let stdout = &result.get_output().stdout; + + let got = Cert::from_bytes(stdout).expect("cert"); + assert_eq!(cert, got); + } + + // Export each non-empty subset of keys. + + eprintln!("Certificate:"); + for k in cert.keys() { + eprintln!(" {}", k.fingerprint()); + } + + // Returns the power set excluding the empty set. + fn power_set(set: &[T]) -> Vec> { + let mut power_set: Vec> = Vec::new(); + for element in set.iter() { + power_set.extend( + power_set.clone().into_iter().map(|mut v: Vec| { + v.push(element.clone()); + v + })); + power_set.push(vec![ element.clone() ]); + } + power_set + } + + let keys: Vec> = cert.keys() + .map(|k| { + k.key().clone() + }) + .collect(); + + let key_ids = keys.iter().map(|k| k.fingerprint()).collect::>(); + + for (i, selection) in power_set(&key_ids).into_iter().enumerate() { + for by_fpr in [true, false] { + eprintln!("Test #{}, by {}:", + i + 1, + if by_fpr { "fingerprint" } else { "key ID" }); + eprintln!(" Exporting:"); + for k in selection.iter() { + eprintln!(" {}", k); + } + + // Export the selection. + let mut cmd = Command::cargo_bin("sq")?; + cmd.env("SEQUOIA_HOME", dir.path()); + cmd.args(["key", "export"]); + for id in selection.iter() { + if by_fpr { + cmd.args(["--key", &id.to_string()]); + } else { + cmd.args(["--key", &KeyID::from(id).to_string()]); + } + } + eprintln!(" Running: {:?}", cmd); + let result = cmd.assert().success(); + let stdout = &result.get_output().stdout; + + let got = Cert::from_bytes(stdout).expect("cert"); + + // Make sure we got exactly what we asked for; no + // more, no less. + eprintln!(" Got:"); + + let mut secrets = 0; + for got in got.keys() { + let expected = keys.iter() + .find(|k| k.fingerprint() == got.fingerprint()) + .expect("have key"); + + eprintln!(" {} {} secret key material", + got.fingerprint(), + if got.has_secret() { + "has" + } else { + "doesn't have" + }); + + if let Ok(got) = got.parts_as_secret() { + assert!( + selection.contains(&got.fingerprint()), + "got secret key material \ + for a key we didn't ask for ({})", + got.fingerprint()); + + assert_eq!(expected.parts_as_secret().expect("have secrets"), + got.key()); + + secrets += 1; + } else { + assert!( + ! selection.contains(&got.fingerprint()), + "didn't get secret key material \ + for a key we asked for ({})", + got.fingerprint()); + + assert_eq!(expected, got.key()); + } + } + assert_eq!(secrets, selection.len()); + } + } + + Ok(()) + } +}