From 6c7b0de5c04e4a763cdd1b31cf0fbbeed5df1b43 Mon Sep 17 00:00:00 2001 From: "Neal H. Walfield" Date: Sat, 18 Mar 2023 21:03:16 +0100 Subject: [PATCH] Support addressing recipients by email address and User ID - Extend `sq encrypt` with the `--recipient-email` and `--recipient-userid` arguments to allow the caller to designate a certificate by email address or User ID, respectively. An email address or User ID is considered to designate a certificate, if the binding between the email address or User ID and the certificate can be authenticated using the web of trust. - Add support for the web of trust using the `sequoia-wot` crate. - Add a top-level option, `--trust-root`, to allow the user to specify trust roots. --- Cargo.lock | 74 +++++++++++++ Cargo.toml | 2 +- NEWS | 5 + src/sq.rs | 249 +++++++++++++++++++++++++++++++++++++++++- src/sq_cli/encrypt.rs | 16 +++ src/sq_cli/mod.rs | 12 ++ tests/sq-encrypt.rs | 207 +++++++++++++++++++++++++++++++++++ 7 files changed, 561 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9e674a9..13b06c52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -477,6 +477,16 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "clap_mangen" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc872a4bca8ddf10be882b81d36f1c2817e43c5c59862ac25f401af581dc4181" +dependencies = [ + "clap 4.0.32", + "roff", +] + [[package]] name = "cmac" version = "0.5.1" @@ -818,6 +828,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dot-writer" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d1b11bd5e7e98406c6ff39fbc94d6e910a489b978ce7f17c19fce91a1195b7a" + [[package]] name = "dyn-clone" version = "1.0.5" @@ -920,6 +936,16 @@ dependencies = [ "syn", ] +[[package]] +name = "enumber" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa35b49b30d8f4219e279f22c4b7c899aa7f98f475da4eff84b75f17ba11ed19" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "env_logger" version = "0.9.0" @@ -2821,6 +2847,20 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "sequoia-policy-config" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f41f8b29fdc21666e6a49d7d7a9c4396f83b11052de0e5434b35aebf302075" +dependencies = [ + "anyhow", + "chrono", + "sequoia-openpgp", + "serde", + "thiserror", + "toml", +] + [[package]] name = "sequoia-sq" version = "0.28.0" @@ -2843,6 +2883,7 @@ dependencies = [ "sequoia-cert-store", "sequoia-net", "sequoia-openpgp", + "sequoia-wot", "serde", "serde_json", "subplot-build", @@ -2852,6 +2893,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "sequoia-wot" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb08c5484e6265aa8cd19d523c09e88f575fc040bf9e6dfdbbeeff3ed0ba07d0" +dependencies = [ + "anyhow", + "chrono", + "clap 4.0.32", + "clap_complete", + "clap_mangen", + "crossbeam", + "dot-writer", + "enumber", + "lazy_static", + "num_cpus", + "openpgp-cert-d", + "sequoia-cert-store", + "sequoia-openpgp", + "sequoia-policy-config", + "thiserror", + "tokio", +] + [[package]] name = "serde" version = "1.0.137" @@ -3430,6 +3495,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "tower-service" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 420dbb11..b9b59c4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,13 +34,13 @@ dirs = "4" sequoia-openpgp = { version = "1.13", default-features = false, features = ["compression-deflate"] } sequoia-autocrypt = { version = "0.25", default-features = false, optional = true } sequoia-net = { version = "0.26", default-features = false } -#sequoia-wot = { version = "0.6" } anyhow = "1.0.18" chrono = "0.4.10" clap = { version = "4", features = ["derive", "env", "wrap_help"] } itertools = "0.10" once_cell = "1.17" sequoia-cert-store = "0.2" +sequoia-wot = "0.7" tempfile = "3.1" term_size = "0.3" tokio = { version = "1.13.1" } diff --git a/NEWS b/NEWS index e6931e0d..678e64a5 100644 --- a/NEWS +++ b/NEWS @@ -34,6 +34,11 @@ option replaces the various subcommand's `--time` argument as well as `sq key generate` and `sq key userid add`'s `--creation-time` arguments. + - Add top-level option, `--trust-root`, to allow the user to + specify trust roots. + - Extend `sq encrypt` to allow addressing recipients by User ID + (`--recipient-userid`) or email address (`--recipient-email`). + Only User IDs that can be fully authenticated are considered. * Deprecated functionality - `sq key generate --creation-time TIME` is deprecated in favor of `sq key generate --time TIME`. diff --git a/src/sq.rs b/src/sq.rs index 716b391b..f049ca76 100644 --- a/src/sq.rs +++ b/src/sq.rs @@ -27,6 +27,7 @@ use openpgp::{ }; use openpgp::{armor, Cert}; use openpgp::crypto::Password; +use openpgp::Fingerprint; use openpgp::packet::prelude::*; use openpgp::parse::{Parse, PacketParser, PacketParserResult}; use openpgp::packet::signature::subpacket::NotationData; @@ -35,10 +36,15 @@ use openpgp::serialize::{Serialize, stream::{Message, Armorer}}; use openpgp::cert::prelude::*; use openpgp::policy::StandardPolicy as P; use openpgp::types::KeyFlags; +use openpgp::types::RevocationStatus; use sequoia_cert_store as cert_store; use cert_store::Store; use cert_store::store::StoreError; +use cert_store::store::UserIDQueryParams; + +use sequoia_wot as wot; +use wot::store::Store as _; use clap::FromArgMatches; use crate::sq_cli::packet; @@ -572,9 +578,9 @@ impl<'store> Config<'store> { if let Some(keyflags) = keyflags.as_ref() { certs.retain(|cert| { - // XXX: Respect any subcommand-specific - // reference time. - let vc = match cert.with_policy(&self.policy, None) { + let vc = match cert.with_policy( + &self.policy, self.time) + { Ok(vc) => vc, Err(err) => { let err = err.context( @@ -636,6 +642,235 @@ impl<'store> Config<'store> { Ok(results) } + /// Looks up certificates by User ID or email address. + /// + /// This only returns certificates that can be authenticate for + /// the specified User ID (or email address, if `email` is true). + /// If no certificate can be authenticated for some User ID, + /// returns an error. If multiple certificates can be + /// authenticated for a given User ID or email address, then + /// returns them all. + fn lookup_by_userid(&self, trust_roots: &[Fingerprint], + userid: &[String], email: bool) + -> Result> + { + if userid.is_empty() { + return Ok(Vec::new()) + } + + let cert_store = self.cert_store_or_else()?; + + // Build a WoT network. + + let cert_store = wot::store::CertStore::from_store( + cert_store, &self.policy, self.time); + let n = wot::Network::new(&cert_store)?; + let mut q = wot::QueryBuilder::new(&n); + q.roots(wot::Roots::new(trust_roots.iter().cloned())); + let q = q.build(); + + let mut results: Vec = Vec::new(); + // We try hard to not just stop at the first error, but lint + // the input so that the user gets as much feedback as + // possible. The first error that we encounter is saved here, + // and returned. The rest are printed directly. + let mut error: Option = None; + + // Iterate over each User ID address, find any certificates + // associated with the User ID, validate the certificates, and + // finally authenticate them for the User ID. + for userid in userid.iter() { + let matches: Vec<(Fingerprint, UserID)> = if email { + if let Err(err) = UserIDQueryParams::is_email(userid) { + eprintln!("{:?} is not a valid email address", userid); + if error.is_none() { + error = Some(err); + } + + continue; + } + + // Get all certificates that are associated with the email + // address. + cert_store.lookup_synopses_by_email(userid) + } else { + let userid = UserID::from(&userid[..]); + cert_store.lookup_synopses_by_userid(userid.clone()) + .into_iter() + .map(|fpr| (fpr, userid.clone())) + .collect() + }; + + if matches.is_empty() { + if error.is_none() { + error = Some(anyhow::anyhow!( + "No certificates are associated with {:?}", + userid)); + } + continue; + } + + struct Entry { + fpr: Fingerprint, + userid: UserID, + cert: Result, + } + let entries = matches.into_iter().map(|(fpr, userid)| { + // We've got a match, or two, or three... Lookup the certs. + let cert = match cert_store.lookup_by_cert_fpr(&fpr) { + Ok(cert) => cert, + Err(err) => { + let err = err.context(format!( + "Error fetching {} ({:?})", + fpr, String::from_utf8_lossy(userid.value()))); + return Entry { fpr, userid, cert: Err(err), }; + } + }; + + // Parse the LazyCerts. + let cert = match cert.into_owned().into_cert() { + Ok(cert) => cert, + Err(err) => { + let err = err.context(format!( + "Error parsing {} ({:?})", + fpr, String::from_utf8_lossy(userid.value()))); + return Entry { fpr, userid, cert: Err(err), }; + } + }; + + // Check the certs for validity. + let vc = match cert.with_policy(&self.policy, self.time) { + Ok(vc) => vc, + Err(err) => { + let err = err.context(format!( + "Certificate {} ({:?}) is invalid", + fpr, String::from_utf8_lossy(userid.value()))); + return Entry { fpr, userid, cert: Err(err) }; + } + }; + + if let Err(err) = vc.alive() { + let err = err.context(format!( + "Certificate {} ({:?}) is invalid", + fpr, String::from_utf8_lossy(userid.value()))); + return Entry { fpr, userid, cert: Err(err), }; + } + + if let RevocationStatus::Revoked(_) = vc.revocation_status() { + let err = anyhow::anyhow!( + "Certificate {} ({:?}) is revoked", + fpr, String::from_utf8_lossy(userid.value())); + return Entry { fpr, userid, cert: Err(err), }; + } + + if let Some(ua) = vc.userids().find(|ua| { + ua.userid() == &userid + }) + { + if let RevocationStatus::Revoked(_) = ua.revocation_status() { + let err = anyhow::anyhow!( + "User ID {:?} on certificate {} is revoked", + String::from_utf8_lossy(userid.value()), fpr); + return Entry { fpr, userid, cert: Err(err), }; + } + } + + // Authenticate the bindings. + let paths = q.authenticate( + &userid, cert.fingerprint(), + // XXX: Make this user configurable. + wot::FULLY_TRUSTED); + let r = if paths.amount() < wot::FULLY_TRUSTED { + Err(anyhow::anyhow!( + "{}, {:?} cannot be authenticated at the \ + required level ({} of {}). After checking \ + that {} really controls {}, you could certify \ + their certificate by running \ + `sq certify MY_KEY.pgp {} {}`.", + cert.fingerprint(), + String::from_utf8_lossy(userid.value()), + paths.amount(), wot::FULLY_TRUSTED, + String::from_utf8_lossy(userid.value()), + cert.fingerprint(), + cert.fingerprint(), + String::from_utf8_lossy(userid.value()))) + } else { + Ok(cert) + }; + + Entry { fpr, userid, cert: r, } + }); + + // Partition into good (successfully authenticated) and + // bad (an error occurred). + let (good, bad): (Vec, _) + = entries.partition(|entry| entry.cert.is_ok()); + + if good.is_empty() { + // We've only got errors. + + let err = if bad.is_empty() { + // We got nothing :/. + if email { + anyhow::anyhow!( + "No known certificates have the email address {:?}", + userid) + } else { + anyhow::anyhow!( + "No known certificates have the User ID {:?}", + userid) + } + } else { + if email { + anyhow::anyhow!( + "None of the certificates with the email \ + address {:?} can be authenticated using \ + the configured trust model", + userid) + } else { + anyhow::anyhow!( + "None of the certificates with the User ID \ + {:?} can be authenticated using \ + the configured trust model", + userid) + } + }; + + eprintln!("{:?}:\n", err); + if error.is_none() { + error = Some(err); + } + + // Print the errors. + for (i, Entry { fpr, userid, cert }) in bad.into_iter().enumerate() { + eprintln!("{}. When considering {} ({}):", + i + 1, fpr, + String::from_utf8_lossy(userid.value())); + let err = match cert { + Ok(_) => unreachable!(), + Err(err) => err, + }; + + print_error_chain(&err); + } + } else { + // We have at least one authenticated certificate. + // Silently ignore any errors. + results.extend( + good.into_iter().filter_map(|Entry { cert, .. }| { + cert.ok() + })); + } + } + + if let Some(error) = error { + Err(error) + } else { + Ok(results) + } + } + + /// Looks up a certificate. /// /// Like `lookup`, but looks up a certificate, which must be @@ -765,6 +1000,14 @@ fn main() -> Result<()> { true, false) .context("--recipient-cert")?); + recipients.extend( + config.lookup_by_userid(&c.trust_roots, + &command.recipients_email, true) + .context("--recipient-email")?); + recipients.extend( + config.lookup_by_userid(&c.trust_roots, + &command.recipients_userid, false) + .context("--recipient-userid")?); let mut input = open_or_stdin(command.io.input.as_deref())?; let output = config.create_or_stdout_pgp( diff --git a/src/sq_cli/encrypt.rs b/src/sq_cli/encrypt.rs index 96359b43..5dda4631 100644 --- a/src/sq_cli/encrypt.rs +++ b/src/sq_cli/encrypt.rs @@ -45,6 +45,21 @@ pub struct Command { help = "Emits binary data", )] pub binary: bool, + + #[clap( + long = "recipient-email", + value_name = "EMAIL", + help = "Encrypts to all certificates that can be authenticated \ + for the specified email address", + )] + pub recipients_email: Vec, + #[clap( + long = "recipient-userid", + value_name = "USERID", + help = "Encrypts to all certificates that can be authenticated \ + for the specified User ID", + )] + pub recipients_userid: Vec, #[clap( long = "recipient-cert", value_name = "FINGERPRINT|KEYID", @@ -57,6 +72,7 @@ pub struct Command { help = "Encrypts to all certificates in CERT_RING_FILE", )] pub recipients_file: Vec, + #[clap( long = "signer-file", value_name = "KEY_FILE", diff --git a/src/sq_cli/mod.rs b/src/sq_cli/mod.rs index 3cf9ef6b..66ff1001 100644 --- a/src/sq_cli/mod.rs +++ b/src/sq_cli/mod.rs @@ -6,6 +6,9 @@ use clap::{Command, CommandFactory, Parser, Subcommand}; #[cfg(feature = "autocrypt")] pub mod autocrypt; +use sequoia_openpgp as openpgp; +use openpgp::Fingerprint; + pub mod armor; pub mod certify; pub mod dane; @@ -149,6 +152,15 @@ $ sq --time 20130721T0550+0200 verify msg.pgp ", )] pub time: Option, + #[clap( + long = "trust-root", + value_name = "FINGERPRINT|KEYID", + help = "Considers the specified certificate to be a trust root", + long_help = "Considers the specified certificate to be a trust root. \ + Trust roots are used by trust models, e.g., the web of \ + trust, to authenticate certificates and User IDs." + )] + pub trust_roots: Vec, #[clap(subcommand)] pub subcommand: SqSubcommands, } diff --git a/tests/sq-encrypt.rs b/tests/sq-encrypt.rs index 127422a6..d96a3458 100644 --- a/tests/sq-encrypt.rs +++ b/tests/sq-encrypt.rs @@ -87,4 +87,211 @@ mod integration { Ok(()) } + + #[test] + fn sq_encrypt_recipient_userid() -> Result<()> + { + let dir = TempDir::new()?; + + let certd = dir.path().join("cert.d").display().to_string(); + std::fs::create_dir(&certd).expect("mkdir works"); + + let alice_pgp = dir.path().join("alice.pgp").display().to_string(); + let bob_pgp = dir.path().join("bob.pgp").display().to_string(); + + // Generate the keys. + let mut cmd = Command::cargo_bin("sq")?; + cmd.args(["--cert-store", &certd, + "key", "generate", + "--expires", "never", + "--userid", "", + "--export", &alice_pgp]); + cmd.assert().success(); + let alice = Cert::from_file(&alice_pgp)?; + + let bob_userids = &[ + "", + "Bob ", + "", + ]; + let bob_emails = &[ + "bob@some.org", + "bob@other.org", + ]; + + let bob_certified_userids = &[ + "Bob ", + ]; + let bob_certified_emails = &[ + "bob@other.org", + ]; + + let mut cmd = Command::cargo_bin("sq")?; + cmd.args(["--cert-store", &certd, + "key", "generate", + "--expires", "never", + "--export", &bob_pgp]); + for userid in bob_userids.iter() { + cmd.args(["--userid", userid]); + } + cmd.assert().success(); + let bob = Cert::from_file(&bob_pgp)?; + + // Import the certificates. + let mut cmd = Command::cargo_bin("sq")?; + cmd.args(["--cert-store", &certd, + "import", &alice_pgp]); + cmd.assert().success(); + + let mut cmd = Command::cargo_bin("sq")?; + cmd.args(["--cert-store", &certd, + "import", &bob_pgp]); + cmd.assert().success(); + + const MESSAGE: &[u8] = &[0x42; 24 * 1024 + 23]; + let encrypt = |trust_roots: &[&str], + recipients: &[(&str, &str)], + decryption_keys: &[&str]| + { + let mut cmd = Command::cargo_bin("sq").unwrap(); + cmd.args(["--cert-store", &certd]); + for trust_root in trust_roots { + cmd.args(["--trust-root", trust_root]); + } + cmd.arg("encrypt"); + + // Make a string for debugging. + let mut cmd_display = "sq encrypt".to_string(); + + for (option, recipient) in recipients.iter() { + cmd.args([option, recipient]); + + cmd_display.push_str(" "); + cmd_display.push_str(option); + cmd_display.push_str(" "); + cmd_display.push_str(recipient); + } + cmd.write_stdin(MESSAGE); + + let output = cmd.output().expect("success"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if decryption_keys.is_empty() { + assert!(! output.status.success(), + "'{}' should have failed\nstdout:\n{}\nstderr:\n{}", + cmd_display, stdout, stderr); + } else { + assert!(output.status.success(), + "'{}' should have succeeded\nstdout:\n{}\nstderr:\n{}", + cmd_display, stdout, stderr); + + for key in decryption_keys.iter() { + let mut cmd = Command::cargo_bin("sq").unwrap(); + cmd.args(["--no-cert-store", + "decrypt", + "--recipient-file", + &key]) + .write_stdin(stdout.as_bytes()); + + let output = cmd.output().expect("success"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), + "'{}' decryption should succeed\nstdout:\n{}\nstderr:\n{}", + cmd_display, stdout, stderr); + } + } + }; + + // Encryption by fingerprint should work. + encrypt(&[], + &[("--recipient-cert", &bob.fingerprint().to_string())], + &[&bob_pgp]); + + // Encryption by email address and user id should fail if the + // binding can't be authenticated. + for email in bob_emails.iter() { + encrypt(&[], + &[("--recipient-email", email)], + &[]); + } + for userid in bob_userids.iter() { + encrypt(&[], + &[("--recipient-userid", userid)], + &[]); + } + + // Alice certifies Bob's certificate. + for userid in bob_certified_userids { + let mut cmd = Command::cargo_bin("sq")?; + cmd.args(["--cert-store", &certd, + "certify", &alice_pgp, &bob_pgp, userid]); + + let output = cmd.output().expect("success"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(output.status.success(), + "'sq certify {} ...' should have succeeded\ + \nstdout:\n{}\nstderr:\n{}", + userid, stdout, stderr); + let mut cmd = Command::cargo_bin("sq")?; + cmd.args(["--cert-store", &certd, + "import"]) + .write_stdin(stdout.as_bytes()); + cmd.assert().success(); + } + + // Still don't use a trust root. This should still fail. + for email in bob_emails.iter() { + encrypt(&[], + &[("--recipient-email", email)], + &[]); + } + for userid in bob_userids.iter() { + encrypt(&[], + &[("--recipient-userid", userid)], + &[]); + } + + // Make Alice the trust root. This should succeed. + for email in bob_emails.iter() { + if bob_certified_emails.contains(email) { + encrypt(&[&alice.fingerprint().to_string()], + &[("--recipient-email", email)], + &[ &bob_pgp ]); + } else { + encrypt(&[&alice.fingerprint().to_string()], + &[("--recipient-email", email)], + &[]); + } + } + for userid in bob_userids.iter() { + if bob_certified_userids.contains(userid) { + encrypt(&[&alice.fingerprint().to_string()], + &[("--recipient-userid", userid)], + &[ &bob_pgp ]); + } else { + encrypt(&[&alice.fingerprint().to_string()], + &[("--recipient-userid", userid)], + &[]); + } + } + + // Make Bob a trust root. This should succeed for all + // self-signed user ids. + for email in bob_emails.iter() { + encrypt(&[&bob.fingerprint().to_string()], + &[("--recipient-email", email)], + &[&bob_pgp]); + } + for userid in bob_userids.iter() { + encrypt(&[&bob.fingerprint().to_string()], + &[("--recipient-userid", userid)], + &[&bob_pgp]); + } + + Ok(()) + } }