diff options
-rw-r--r-- | sq/src/commands/certify.rs | 142 | ||||
-rw-r--r-- | sq/src/commands/mod.rs | 1 | ||||
-rw-r--r-- | sq/src/sq-usage.rs | 49 | ||||
-rw-r--r-- | sq/src/sq.rs | 5 | ||||
-rw-r--r-- | sq/src/sq_cli.rs | 84 | ||||
-rw-r--r-- | sq/tests/sq-certify.rs | 166 |
6 files changed, 447 insertions, 0 deletions
diff --git a/sq/src/commands/certify.rs b/sq/src/commands/certify.rs new file mode 100644 index 00000000..22a0772e --- /dev/null +++ b/sq/src/commands/certify.rs @@ -0,0 +1,142 @@ +use std::time::{SystemTime, Duration}; + +use sequoia_openpgp as openpgp; +use openpgp::Result; +use openpgp::cert::prelude::*; +use openpgp::packet::prelude::*; +use openpgp::parse::Parse; +use openpgp::policy::Policy; +use openpgp::serialize::Serialize; +use openpgp::types::SignatureType; + +use crate::parse_duration; +use crate::SECONDS_IN_YEAR; + +pub fn certify(p: &impl Policy, m: &clap::ArgMatches, _force: bool) + -> Result<()> +{ + let certifier = m.value_of("certifier").unwrap(); + let cert = m.value_of("certificate").unwrap(); + let userid = m.value_of("userid").unwrap(); + + let certifier = Cert::from_file(certifier)?; + let cert = Cert::from_file(cert)?; + let vc = cert.with_policy(p, None)?; + + let trust_depth: u8 = m.value_of("depth") + .map(|s| s.parse()).unwrap_or(Ok(0))?; + let trust_amount: u8 = m.value_of("amount") + .map(|s| s.parse()).unwrap_or(Ok(120))?; + let regex = m.values_of("regex").map(|v| v.collect::<Vec<_>>()) + .unwrap_or(vec![]); + if trust_depth == 0 && regex.len() > 0 { + return Err( + anyhow::format_err!("A regex only makes sense \ + if the trust depth is greater than 0")); + } + + let local = m.is_present("local"); + let non_revocable = m.is_present("non-revocable"); + let expires = m.value_of("expires"); + let expires_in = m.value_of("expires-in"); + + + // Find the matching User ID. + let mut u = None; + for ua in vc.userids() { + if let Ok(a_userid) = std::str::from_utf8(ua.userid().value()) { + if a_userid == userid { + u = Some(ua.userid()); + break; + } + } + } + + let userid = if let Some(userid) = u { + userid + } else { + eprintln!("User ID: '{}' not found.\nValid User IDs:", userid); + let mut have_valid = false; + for ua in vc.userids() { + if let Ok(u) = std::str::from_utf8(ua.userid().value()) { + have_valid = true; + eprintln!(" - {}", u); + } + } + if ! have_valid { + eprintln!(" - Certificate has no valid User IDs."); + } + return Err(anyhow::format_err!("No matching User ID found")); + }; + + // Create the certification. + let mut builder + = SignatureBuilder::new(SignatureType::GenericCertification); + + if trust_depth != 0 || trust_amount != 120 { + builder = builder.set_trust_signature(trust_depth, trust_amount)?; + } + + for regex in regex { + builder = builder.add_regular_expression(regex)?; + } + + if local { + builder = builder.set_exportable_certification(false)?; + } + + if non_revocable { + builder = builder.set_revocable(false)?; + } + + match (expires, expires_in) { + (None, None) => + // Default expiration. + builder = builder.set_signature_validity_period( + Duration::new(5 * SECONDS_IN_YEAR, 0))?, + (Some(t), None) if t == "never" => + // The default is no expiration; there is nothing to do. + (), + (Some(t), None) => { + let now = builder.signature_creation_time() + .unwrap_or_else(std::time::SystemTime::now); + let expiration = SystemTime::from( + crate::parse_iso8601(t, chrono::NaiveTime::from_hms(0, 0, 0))?); + let validity = expiration.duration_since(now)?; + builder = builder.set_signature_creation_time(now)? + .set_signature_validity_period(validity)?; + }, + (None, Some(d)) if d == "never" => + // The default is no expiration; there is nothing to do. + (), + (None, Some(d)) => { + let d = parse_duration(d)?; + builder = builder.set_signature_validity_period(d)?; + }, + (Some(_), Some(_)) => unreachable!("conflicting args"), + } + + + // Sign it. + let mut signer = certifier.primary_key().key().clone() + .parts_into_secret()?.into_keypair()?; + + let certification = builder + .sign_userid_binding( + &mut signer, + cert.primary_key().component(), + userid)?; + let cert = cert.insert_packets(certification.clone())?; + assert!(cert.clone().into_packets().any(|p| { + match p { + Packet::Signature(sig) => sig == certification, + _ => false, + } + })); + + + // And export it. + cert.armored().serialize(&mut std::io::stdout())?; + + Ok(()) +} diff --git a/sq/src/commands/mod.rs b/sq/src/commands/mod.rs index 83a4f398..45c1201c 100644 --- a/sq/src/commands/mod.rs +++ b/sq/src/commands/mod.rs @@ -44,6 +44,7 @@ pub use self::merge_signatures::merge_signatures; pub mod certring; #[cfg(feature = "net")] pub mod net; +pub mod certify; /// Returns suitable signing keys from a given list of Certs. fn get_signing_keys(certs: &[openpgp::Cert], p: &dyn Policy, diff --git a/sq/src/sq-usage.rs b/sq/src/sq-usage.rs index 3c9d7d50..79b82885 100644 --- a/sq/src/sq-usage.rs +++ b/sq/src/sq-usage.rs @@ -35,6 +35,7 @@ //! dearmor Removes ASCII Armor from a file //! inspect Inspects a sequence of OpenPGP packets //! packet OpenPGP Packet manipulation +//! certify Certify a User ID for a Certificate //! help Prints this message or the help of the given //! subcommand(s) //! ``` @@ -791,6 +792,54 @@ //! ARGS: //! <FILE> Sets the input file to use //! ``` +//! +//! ## Subcommand certify +//! +//! ```text +//! Certify a User ID for a Certificate +//! +//! USAGE: +//! sq certify [FLAGS] [OPTIONS] <CERTIFIER> <CERTIFICATE> <USERID> +//! +//! FLAGS: +//! -h, --help Prints help information +//! -l, --local Makes the certification a local certification. +//! Normally, local certifications are not exported. +//! --non-revocable Marks the certification as being non-revocable. That +//! is, you cannot later revoke this certification. This +//! should normally only be used with an expiration. +//! -V, --version Prints version information +//! +//! OPTIONS: +//! -a, --amount <TRUST_AMOUNT> +//! The amount of trust. Values between 1 and 120 are meaningful. 120 +//! means fully trusted. Values less than 120 indicate the degree of +//! trust. 60 is usually used for partially trusted. The default is +//! 120. +//! -d, --depth <TRUST_DEPTH> +//! The trust depth (sometimes referred to as the trust level). 0 means +//! a normal certification of <CERTIFICATE, USERID>. 1 means +//! CERTIFICATE is also a trusted introducer, 2 means CERTIFICATE is a +//! meta-trusted introducer, etc. The default is 0. +//! --expires <TIME> +//! Absolute time when the certification should expire, or 'never'. +//! +//! --expires-in <DURATION> +//! Relative time when the certification should expire. Either +//! 'N[ymwd]', for N years, months, weeks, or days, or 'never'. The +//! default is 5 years. +//! -r, --regex <REGEX>... +//! Adds a regular expression to constrain what a trusted introducer can +//! certify. The regular expression must match the certified User ID in +//! all intermediate introducers, and the certified certificate. +//! Multiple regular expressions may be specified. In that case, at +//! least one must match. +//! +//! ARGS: +//! <CERTIFIER> The key to certify the certificate. +//! <CERTIFICATE> The certificate to certify. +//! <USERID> The User ID to certify. +//! ``` #![doc(html_favicon_url = "https://docs.sequoia-pgp.org/favicon.png")] #![doc(html_logo_url = "https://docs.sequoia-pgp.org/logo.svg")] diff --git a/sq/src/sq.rs b/sq/src/sq.rs index b9d8e6ef..6a9f9a13 100644 --- a/sq/src/sq.rs +++ b/sq/src/sq.rs @@ -573,6 +573,11 @@ fn main() -> Result<()> { #[cfg(feature = "net")] ("wkd", Some(m)) => commands::net::dispatch_wkd(config, m)?, + + ("certify", Some(m)) => { + commands::certify::certify(policy, m, force)?; + }, + _ => unreachable!(), } diff --git a/sq/src/sq_cli.rs b/sq/src/sq_cli.rs index 692a1937..da527804 100644 --- a/sq/src/sq_cli.rs +++ b/sq/src/sq_cli.rs @@ -461,6 +461,90 @@ pub fn configure(app: App<'static, 'static>) -> App<'static, 'static> { from stdin)"))) ) + + .subcommand(SubCommand::with_name("certify") + .about("Certify a User ID for a Certificate") + .arg(Arg::with_name("depth") + .value_name("TRUST_DEPTH") + .help("The trust depth (sometimes referred to as \ + the trust level). 0 means a normal \ + certification of <CERTIFICATE, USERID>. \ + 1 means CERTIFICATE is also a trusted \ + introducer, 2 means CERTIFICATE is a \ + meta-trusted introducer, etc. The \ + default is 0.") + .long("depth") + .short("d")) + .arg(Arg::with_name("amount") + .value_name("TRUST_AMOUNT") + .help("The amount of trust. \ + Values between 1 and 120 are meaningful. \ + 120 means fully trusted. \ + Values less than 120 indicate the degree \ + of trust. 60 is usually used for partially \ + trusted. The default is 120.") + .long("amount") + .short("a")) + .arg(Arg::with_name("regex") + .value_name("REGEX") + .help("Adds a regular expression to constrain \ + what a trusted introducer can certify. \ + The regular expression must match \ + the certified User ID in all intermediate \ + introducers, and the certified certificate. \ + Multiple regular expressions may be \ + specified. In that case, at least \ + one must match.") + .long("regex") + .short("r") + .multiple(true)) + .arg(Arg::with_name("local") + .help("Makes the certification a local \ + certification. Normally, local \ + certifications are not exported.") + .long("local") + .short("l")) + .arg(Arg::with_name("non-revocable") + .help("Marks the certification as being non-revocable. \ + That is, you cannot later revoke this \ + certification. This should normally only \ + be used with an expiration.") + .long("non-revocable")) + + .group(ArgGroup::with_name("expiration-group") + .args(&["expires", "expires-in"])) + .arg(Arg::with_name("expires") + .value_name("TIME") + .long("expires") + .help("Absolute time when the certification should \ + expire, or 'never'.")) + .arg(Arg::with_name("expires-in") + .value_name("DURATION") + .long("expires-in") + // Catch negative numbers. + .allow_hyphen_values(true) + .help("Relative time when the certification should \ + expire. Either 'N[ymwd]', for N years, \ + months, weeks, or days, or 'never'. The \ + default is 5 years.")) + + .arg(Arg::with_name("certifier") + .value_name("CERTIFIER") + .required(true) + .index(1) + .help("The key to certify the certificate.")) + .arg(Arg::with_name("certificate") + .value_name("CERTIFICATE") + .required(true) + .index(2) + .help("The certificate to certify.")) + .arg(Arg::with_name("userid") + .value_name("USERID") + .required(true) + .index(3) + .help("The User ID to certify.")) + ) + .subcommand(SubCommand::with_name("packet") .display_order(610) .about("OpenPGP Packet manipulation") diff --git a/sq/tests/sq-certify.rs b/sq/tests/sq-certify.rs new file mode 100644 index 00000000..7dbc8d99 --- /dev/null +++ b/sq/tests/sq-certify.rs @@ -0,0 +1,166 @@ +use std::fs::File; +use std::time::Duration; + +use assert_cli; +use assert_cli::Assert; +use tempfile; +use tempfile::TempDir; + +use sequoia_openpgp as openpgp; +use openpgp::Result; +use openpgp::cert::prelude::*; +use openpgp::parse::Parse; +use openpgp::serialize::Serialize; +use openpgp::policy::StandardPolicy; + +#[test] +fn sq_certify() -> Result<()> { + let tmp_dir = TempDir::new().unwrap(); + let alice_pgp = tmp_dir.path().join("alice.pgp"); + let bob_pgp = tmp_dir.path().join("bob.pgp"); + + let (alice, _) = + CertBuilder::general_purpose(None, Some("alice@example.org")) + .generate()?; + let mut file = File::create(&alice_pgp)?; + alice.as_tsk().serialize(&mut file)?; + + let (bob, _) = + CertBuilder::general_purpose(None, Some("bob@example.org")) + .generate()?; + let mut file = File::create(&bob_pgp)?; + bob.serialize(&mut file)?; + + + // A simple certification. + Assert::cargo_binary("sq") + .with_args( + &["certify", + alice_pgp.to_str().unwrap(), + bob_pgp.to_str().unwrap(), + "bob@example.org", + ]) + .stdout().satisfies(|output| { + let p = &StandardPolicy::new(); + + let cert = Cert::from_bytes(output).unwrap(); + let vc = cert.with_policy(p, None).unwrap(); + + for ua in vc.userids() { + if ua.userid().value() == b"bob@example.org" { + let certifications: Vec<_> + = ua.certifications().collect(); + assert_eq!(certifications.len(), 1); + let c = certifications[0]; + + assert_eq!(c.trust_signature(), None); + assert_eq!(c.regular_expressions().count(), 0); + assert_eq!(c.revocable().unwrap_or(true), true); + assert_eq!(c.exportable_certification().unwrap_or(true), true); + // By default, we set a duration. + assert!(c.signature_validity_period().is_some()); + + return true; + } + } + + false + }, + "Bad certification") + .unwrap(); + + // No expiry. + Assert::cargo_binary("sq") + .with_args( + &["certify", + alice_pgp.to_str().unwrap(), + bob_pgp.to_str().unwrap(), + "bob@example.org", + "--expires", "never" + ]) + .stdout().satisfies(|output| { + let p = &StandardPolicy::new(); + + let cert = Cert::from_bytes(output).unwrap(); + let vc = cert.with_policy(p, None).unwrap(); + + for ua in vc.userids() { + if ua.userid().value() == b"bob@example.org" { + let certifications: Vec<_> + = ua.certifications().collect(); + assert_eq!(certifications.len(), 1); + let c = certifications[0]; + + assert_eq!(c.trust_signature(), None); + assert_eq!(c.regular_expressions().count(), 0); + assert_eq!(c.revocable().unwrap_or(true), true); + assert_eq!(c.exportable_certification().unwrap_or(true), true); + assert!(c.signature_validity_period().is_none()); + + return true; + } + } + + false + }, + "Bad certification") + .unwrap(); + + // Have alice certify bob@example.org for 0xB0B. + Assert::cargo_binary("sq") + .with_args( + &["certify", + alice_pgp.to_str().unwrap(), + bob_pgp.to_str().unwrap(), + "bob@example.org", + "--depth", "10", + "--amount", "5", + "--regex", "a", + "--regex", "b", + "--local", + "--non-revocable", + "--expires-in", "1d", + ]) + .stdout().satisfies(|output| { + let p = &StandardPolicy::new(); + + let cert = Cert::from_bytes(output).unwrap(); + let vc = cert.with_policy(p, None).unwrap(); + + for ua in vc.userids() { + if ua.userid().value() == b"bob@example.org" { + let certifications: Vec<_> + = ua.certifications().collect(); + assert_eq!(certifications.len(), 1); + let c = certifications[0]; + + assert_eq!(c.trust_signature(), Some((10, 5))); + assert_eq!(&c.regular_expressions().collect::<Vec<_>>()[..], + &[ b"a", b"b" ]); + assert_eq!(c.revocable(), Some(false)); + assert_eq!(c.exportable_certification(), Some(false)); + assert_eq!(c.signature_validity_period(), + Some(Duration::new(24 * 60 * 60, 0))); + + return true; + } + } + + false + }, + "Bad certification") + .unwrap(); + + // It should fail if the User ID doesn't exist. + Assert::cargo_binary("sq") + .with_args( + &["certify", + alice_pgp.to_str().unwrap(), + bob_pgp.to_str().unwrap(), + "bob", + ]) + .fails() + .unwrap(); + + Ok(()) +} |