diff options
author | Neal H. Walfield <neal@pep.foundation> | 2022-01-06 16:56:39 +0100 |
---|---|---|
committer | Neal H. Walfield <neal@pep.foundation> | 2022-01-14 11:05:56 +0100 |
commit | a4cfd15805a543a327d2242f9c0f2b653a11ee55 (patch) | |
tree | 3f01ee175695d1b16030b7b86250e6590fa40a28 | |
parent | 14bcf8a292e8a5ace5462456a743ea00e0bc7ab9 (diff) |
sq: Implement sq revoke certificate.
- Add support for revoking certificates to sq.
-rw-r--r-- | Cargo.lock | 63 | ||||
-rw-r--r-- | sq/Cargo.toml | 1 | ||||
-rw-r--r-- | sq/src/commands/mod.rs | 45 | ||||
-rw-r--r-- | sq/src/commands/revoke.rs | 163 | ||||
-rw-r--r-- | sq/src/sq-usage.rs | 190 | ||||
-rw-r--r-- | sq/src/sq.rs | 81 | ||||
-rw-r--r-- | sq/src/sq_cli.rs | 149 | ||||
-rw-r--r-- | sq/tests/sq-revoke.rs | 393 |
8 files changed, 1084 insertions, 1 deletions
@@ -114,6 +114,20 @@ dependencies = [ ] [[package]] +name = "assert_cmd" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93ae1ddd39efd67689deb1979d80bad3bf7f2b09c6e6117c8d1f2443b5e2f83e" +dependencies = [ + "bstr", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] name = "atty" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -750,6 +764,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" [[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] name = "digest" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2195,6 +2215,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] +name = "predicates" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5aab5be6e4732b473071984b3164dbbfb7a3674d30ea5ff44410b6bcd960c3c" +dependencies = [ + "difflib", + "itertools 0.10.1", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da1c2388b1513e1b605fcec39a95e0a9e8ef088f71443ef37099fa9ae6673fcb" + +[[package]] +name = "predicates-tree" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d86de6de25020a36c6d3643a86d9a6a9f552107c0559c60ea03551b5e16c032" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] name = "proc-macro-error" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2739,6 +2786,7 @@ version = "0.25.0" dependencies = [ "anyhow", "assert_cli", + "assert_cmd", "buffered-reader", "chrono", "clap", @@ -3205,6 +3253,12 @@ dependencies = [ ] [[package]] +name = "termtree" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507e9898683b6c43a9aa55b64259b721b52ba226e0f3779137e50ad114a4c90b" + +[[package]] name = "textwrap" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3599,6 +3653,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" [[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + +[[package]] name = "walkdir" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/sq/Cargo.toml b/sq/Cargo.toml index ef10d218..c9d65136 100644 --- a/sq/Cargo.toml +++ b/sq/Cargo.toml @@ -46,6 +46,7 @@ subplot-build = "0.1.0" [dev-dependencies] assert_cli = "0.6" +assert_cmd = "2.0.4" subplotlib = "0.1.0" fehler = "1.0.0" diff --git a/sq/src/commands/mod.rs b/sq/src/commands/mod.rs index e4ae6482..35caaa30 100644 --- a/sq/src/commands/mod.rs +++ b/sq/src/commands/mod.rs @@ -42,6 +42,8 @@ pub mod decrypt; pub use self::decrypt::decrypt; pub mod sign; pub use self::sign::sign; +pub mod revoke; +pub use self::revoke::revoke_certificate; pub mod dump; pub use self::dump::dump; mod inspect; @@ -121,6 +123,49 @@ fn get_signing_keys<C>(certs: &[C], p: &dyn Policy, KeyFlags::empty().set_signing()) } +/// Returns suitable certification keys from a given list of Certs. +/// +/// This returns one key for each Cert. If a Cert doesn't have an +/// appropriate key, then this returns an error. +fn get_certification_keys<C>(certs: &[C], p: &dyn Policy, + private_key_store: Option<&str>, + timestamp: Option<SystemTime>) + -> Result<Vec<Box<dyn crypto::Signer + Send + Sync>>> + where C: std::borrow::Borrow<Cert> +{ + get_keys(certs, p, private_key_store, timestamp, + KeyFlags::empty().set_certification()) +} + +// Returns the smallest valid certificate. +// +// Given a certificate, returns the smallest valid certificate that is +// still technically valid according to RFC 4880 and popular OpenPGP +// implementations. +// +// In particular, this function extracts the primary key, the primary +// User ID, and the active binding signature. If there is no valid +// User ID, it returns the active direct key signature. +pub fn cert_stub(cert: Cert, + policy: &dyn Policy, + timestamp: Option<SystemTime>) + -> Result<Cert> +{ + let vc = cert.with_policy(policy, timestamp)?; + + let mut packets = Vec::with_capacity(4); + packets.push(Packet::from(vc.primary_key().key().clone())); + if let Ok(uid) = vc.primary_userid() { + packets.push(Packet::from(uid.userid().clone())); + packets.push(Packet::from(uid.binding_signature().clone())); + } else { + packets.push( + Packet::from(vc.primary_key().binding_signature().clone())); + } + + Ok(Cert::from_packets(packets.into_iter())?) +} + pub struct EncryptOpts<'a> { pub policy: &'a dyn Policy, pub private_key_store: Option<&'a str>, diff --git a/sq/src/commands/revoke.rs b/sq/src/commands/revoke.rs new file mode 100644 index 00000000..b6dadf63 --- /dev/null +++ b/sq/src/commands/revoke.rs @@ -0,0 +1,163 @@ +use anyhow::Context as _; +use std::time::SystemTime; + +use sequoia_openpgp as openpgp; +use openpgp::armor; +use openpgp::cert::CertRevocationBuilder; +use openpgp::Packet; +use openpgp::packet::signature::subpacket::NotationData; +use openpgp::Result; +use openpgp::serialize::Serialize; +use openpgp::types::ReasonForRevocation; +use crate::{ + commands::cert_stub, + Config, +}; + +pub struct RevokeOpts<'a> { + pub config: Config<'a>, + pub private_key_store: Option<&'a str>, + pub cert: openpgp::Cert, + pub secret: Option<openpgp::Cert>, + pub binary: bool, + pub time: Option<SystemTime>, + pub reason: ReasonForRevocation, + pub message: &'a str, + pub notations: &'a [(bool, NotationData)], +} + +pub fn revoke_certificate(opts: RevokeOpts) -> Result<()> +{ + let config = opts.config; + let private_key_store = opts.private_key_store; + let cert = opts.cert; + let secret = opts.secret; + let binary = opts.binary; + let time = opts.time; + let reason = opts.reason; + let message = opts.message; + let notations = opts.notations; + + let mut output = config.create_or_stdout_safe(None)?; + + let (secret, mut signer) = if let Some(secret) = secret.as_ref() { + if let Ok(keys) = super::get_certification_keys(&[ secret ], + &config.policy, + private_key_store, + time) { + assert_eq!(keys.len(), 1); + (secret, keys.into_iter().next().expect("have one")) + } else { + return Err(anyhow::anyhow!("\ +No certification key found: the key specified with --revocation-key \ +does not contain a certification key with secret key material")); + + } + } else { + if let Ok(keys) = super::get_certification_keys(&[ &cert ], + &config.policy, + private_key_store, + time) { + assert_eq!(keys.len(), 1); + (&cert, keys.into_iter().next().expect("have one")) + } else { + return Err(anyhow::anyhow!("\ +No certification key found: --revocation-key not provided and the +certificate to revoke does not contain a certification key with secret +key material")); + } + }; + + let first_party = secret.fingerprint() == cert.fingerprint(); + + let mut rev = CertRevocationBuilder::new() + .set_reason_for_revocation(reason, message.as_bytes())?; + if let Some(time) = time { + rev = rev.set_signature_creation_time(time)?; + } + for (critical, notation) in notations { + rev = rev.add_notation(notation.name(), + notation.value(), + Some(notation.flags().clone()), + *critical)?; + } + let rev = rev.build(&mut signer, &cert, None)?; + let rev = Packet::Signature(rev); + + let packets: Vec<Packet> = if first_party { + vec![ rev ] + } else { + cert_stub(cert.clone(), &config.policy, time) + // If we fail to minimize the the certificate, just use as + // it. + .unwrap_or_else(|_err| cert.clone()) + // Now add the revocation certificate. + .insert_packets(rev)? + .into_packets() + .collect() + }; + + if binary { + for p in packets { + p.serialize(&mut output) + .context("serializing revocation certificate")?; + } + } else { + // Add some helpful ASCII-armor comments. + let mut revoker_fpr = None; + let mut revoker_uid = None; + + if ! first_party { + if let Ok(secret) = secret.with_policy(&config.policy, time) { + if let Ok(uid) = secret.primary_userid() { + revoker_uid = Some(uid); + } + } + + revoker_fpr = Some(secret.fingerprint()); + } + + let preface = match (revoker_fpr, revoker_uid) { + (Some(fpr), Some(uid)) => { + let uid = String::from_utf8_lossy(uid.value()); + // Truncate it, if it is too long. + if uid.len() > 40 { + &uid[..40] + } else { + &uid + }; + + vec![format!("Revocation certificate by {}", + fpr.to_spaced_hex()), + format!("({:?}) for:", uid)] + } + (Some(fpr), None) => { + vec![format!("Revocation certificate by {} for:", + fpr.to_spaced_hex())] + } + (_, _) => { + vec![("Revocation certificate for:".into())] + } + }; + + let headers = cert.armor_headers(); + let headers: Vec<_> = preface + .iter() + .map(|s| ("Comment", s.as_str())) + .chain( + headers + .iter() + .map(|value| ("Comment", value.as_str()))) + .collect(); + + let mut writer = armor::Writer::with_headers( + &mut output, armor::Kind::PublicKey, headers)?; + for p in packets { + p.serialize(&mut writer) + .context("serializing revocation certificate")?; + } + writer.finalize()?; + } + + Ok(()) +} diff --git a/sq/src/sq-usage.rs b/sq/src/sq-usage.rs index 3fed5e03..f3ac305b 100644 --- a/sq/src/sq-usage.rs +++ b/sq/src/sq-usage.rs @@ -52,6 +52,7 @@ //! dearmor Converts ASCII to binary //! inspect Inspects data, like file(1) //! packet Low-level packet manipulation +//! revoke Generates revocation certificates //! help Prints this message or the help of the given subcommand(s) //! ``` //! @@ -1618,6 +1619,195 @@ //! # Then join only a subset of these packets //! $ sq packet join juliet.pgp-[0-3]* //! ``` +//! +//! ## Subcommand revoke +//! +//! ```text +//! +//! Generates revocation certificates. +//! +//! A revocation certificate indicates that a certificate, a subkey, a +//! User ID, or a signature should not be used anymore. +//! +//! A revocation certificate includes two fields, a type and a +//! human-readable explanation, which allows the issuer to indicate why +//! the revocation certificate was issued. It is important to set the +//! type field accurately as this allows an OpenPGP implementation to +//! better reason about artifacts whose validity relies on the revoked +//! object. For instance, if a certificate is retired, it is reasonable +//! to consider signatures that it made prior to its retirement as still +//! being valid. However, if a certificate's secret key material is +//! compromised, any signatures that it made should be considered +//! potentially forged, as they could have been made by an attacker and +//! backdated. +//! +//! As the intent of a revocation certificate is to stop others from using +//! a certificate, it is necessary to distribute the revocation +//! certificate. One effective way to do this is to upload the revocation +//! certificate to a keyserver. +//! +//! USAGE: +//! sq revoke <SUBCOMMAND> +//! +//! FLAGS: +//! -h, --help +//! Prints help information +//! +//! +//! SUBCOMMANDS: +//! certificate Revoke a certificate +//! help Prints this message or the help of the given +//! subcommand(s) +//! +//! EXAMPLES: +//! +//! # Revoke a certificate. +//! $ sq revoke certificate --time 20220101 --certificate juliet.pgp \ +//! compromised "My parents went through my things, and found my backup." +//! ``` +//! +//! ### Subcommand revoke certificate +//! +//! ```text +//! +//! Revokes a certificate +//! +//! Creates a revocation certificate for the certificate. +//! +//! If "--revocation-key" is provided, then that key is used to create +//! the signature. If that key is different from the certificate being +//! revoked, this creates a third-party revocation. This is normally only +//! useful if the owner of the certificate designated the key to be a +//! designated revoker. +//! +//! If "--revocation-key" is not provided, then the certificate must +//! include a certification-capable key. +//! +//! USAGE: +//! sq revoke certificate [FLAGS] [OPTIONS] <REASON> <MESSAGE> +//! +//! FLAGS: +//! -B, --binary +//! Emits binary data +//! +//! -h, --help +//! Prints help information +//! +//! -V, --version +//! Prints version information +//! +//! +//! OPTIONS: +//! --certificate <FILE> +//! +//! Reads the certificate to revoke from FILE or stdin, if omitted. It +//! is +//! an error for the file to contain more than one certificate. +//! --notation <NAME> <VALUE> +//! +//! Adds a notation to the certification. A user-defined notation's +//! name +//! must be of the form "name@a.domain.you.control.org". If the +//! notation's name starts with a !, then the notation is marked as +//! being +//! critical. If a consumer of a signature doesn't understand a +//! critical +//! notation, then it will ignore the signature. The notation is marked +//! as being human readable. +//! --private-key-store <KEY_STORE> +//! Provides parameters for private key store +//! +//! --revocation-key <FILE> +//! +//! Signs the revocation certificate using KEY. If the key is different +//! from the certificate, this creates a third-party revocation. If +//! this +//! option is not provided, and the certificate includes secret key +//! material, +//! then that key is used to sign the revocation certificate. +//! -t, --time <TIME> +//! +//! Chooses keys valid at the specified time and sets the revocation +//! certificate's creation time +//! +//! ARGS: +//! <REASON> +//! +//! The reason for the revocation. This must be either: compromised, +//! superseded, retired, or unspecified: +//! +//! - compromised means that the secret key material may have been +//! compromised. Prefer this value if you suspect that the secret +//! key +//! has been leaked. +//! +//! - superseded means that the owner of the certificate has replaced +//! it +//! with a new certificate. Prefer "compromised" if the secret key +//! material has been compromised even if the certificate is also +//! being replaced! You should include the fingerprint of the new +//! certificate in the message. +//! +//! - retired means that this certificate should not be used anymore, +//! and there is no replacement. This is appropriate when someone +//! leaves an organisation. Prefer "compromised" if the secret key +//! material has been compromised even if the certificate is also +//! being retired! You should include how to contact the owner, or +//! who to contact instead in the message. +//! +//! - unspecified means that none of the three other three reasons +//! apply. OpenPGP implementations conservatively treat this type +//! of +//! revocation similar to a compromised key. +//! +//! If the reason happened in the past, you should specify that using +//! the +//! --time argument. This allows OpenPGP implementations to more +//! accurately reason about objects whose validity depends on the +//! validity +//! of the certificate. [possible values: compromised, superseded, +//! retired, unspecified] +//! <MESSAGE> +//! +//! A short, explanatory text that is shown to a viewer of the +//! revocation +//! certificate. It explains why the certificate has been revoked. For +//! instance, if Alice has created a new key, she would generate a +//! 'superceded' revocation certificate for her old key, and might +//! include +//! the message "I've created a new certificate, FINGERPRINT, please use +//! that in the future." +//! ``` +//! +//! ### Subcommand revoke EXAMPLES: +//! +//! ```text +//! +//! USAGE: +//! sq revoke <SUBCOMMAND> +//! +//! For more information try --help +//! ``` +//! +//! ### Subcommand revoke # +//! +//! ```text +//! +//! USAGE: +//! sq revoke <SUBCOMMAND> +//! +//! For more information try --help +//! ``` +//! +//! ### Subcommand revoke compromised +//! +//! ```text +//! +//! USAGE: +//! sq revoke <SUBCOMMAND> +//! +//! For more information try --help +//! ``` #![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 a7996c69..55317375 100644 --- a/sq/src/sq.rs +++ b/sq/src/sq.rs @@ -18,6 +18,7 @@ use crate::openpgp::{armor, Cert}; use crate::openpgp::crypto::Password; use crate::openpgp::fmt::hex; use crate::openpgp::types::KeyFlags; +use crate::openpgp::types::ReasonForRevocation; use crate::openpgp::packet::prelude::*; use crate::openpgp::parse::{Parse, PacketParser, PacketParserResult}; use crate::openpgp::packet::signature::subpacket::NotationData; @@ -698,6 +699,86 @@ fn main() -> Result<()> { ("key", Some(m)) => commands::key::dispatch(config, m)?, + ("revoke", Some(m)) => match m.subcommand() { + ("certificate", Some(m)) => { + let input = m.value_of("input"); + let input = open_or_stdin(input)?; + let cert = CertParser::from_reader(input)?.collect::<Vec<_>>(); + let cert = match cert.len() { + 0 => Err(anyhow::anyhow!("No certificates provided."))?, + 1 => cert.into_iter().next().expect("have one")?, + _ => Err( + anyhow::anyhow!("Multiple certificates provided."))?, + }; + + let secret: Option<&str> = m.value_of("secret-key-file"); + let secret = load_certs(secret.into_iter())?; + if secret.len() > 1 { + Err(anyhow::anyhow!("Multiple secret keys provided."))?; + } + let secret = secret.into_iter().next(); + + let private_key_store = m.value_of("private-key-store"); + + let binary = m.is_present("binary"); + + let time = if let Some(time) = m.value_of("time") { + Some(parse_iso8601(time, chrono::NaiveTime::from_hms(0, 0, 0)) + .context(format!("Bad value passed to --time: {:?}", + time))?.into()) + } else { + None + }; + + let reason = m.value_of("reason").expect("required"); + let reason = match &*reason { + "compromised" => ReasonForRevocation::KeyCompromised, + "superseded" => ReasonForRevocation::KeySuperseded, + "retired" => ReasonForRevocation::KeyRetired, + "unspecified" => ReasonForRevocation::Unspecified, + _ => panic!("invalid values should be caught by clap"), + }; + + let message: &str = m.value_of("message").expect("required"); + + // Each --notation takes two values. The iterator + // returns them one at a time, however. + let mut notations: Vec<(bool, NotationData)> = Vec::new(); + if let Some(mut n) = m.values_of("notation") { + while let Some(name) = n.next() { + let value = n.next().unwrap(); + + let (critical, name) = if !name.is_empty() + && name.starts_with('!') + { + (true, &name[1..]) + } else { + (false, name) + }; + + notations.push( + (critical, + NotationData::new( + name, value, + NotationDataFlags::empty().set_human_readable()))); + } + } + + commands::revoke_certificate(commands::revoke::RevokeOpts { + config, + private_key_store, + cert, + secret, + binary, + time, + notations: ¬ations, + reason, + message, + })?; + } + _ => unreachable!(), + }, + #[cfg(feature = "net")] ("wkd", Some(m)) => commands::net::dispatch_wkd(config, m)?, diff --git a/sq/src/sq_cli.rs b/sq/src/sq_cli.rs index d8198f6f..64c35de1 100644 --- a/sq/src/sq_cli.rs +++ b/sq/src/sq_cli.rs @@ -1295,7 +1295,154 @@ $ sq packet join juliet.pgp-[0-3]* .help("Selects the kind of armor header")) .arg(Arg::with_name("binary") .short("B").long("binary") - .help("Emits binary data")))); + .help("Emits binary data"))) + ) + .subcommand(SubCommand::with_name("revoke") + .display_order(700) + .about("Generates revocation certificates") + .long_about( + " +Generates revocation certificates. + +A revocation certificate indicates that a certificate, a subkey, a +User ID, or a signature should not be used anymore. + +A revocation certificate includes two fields, a type and a +human-readable explanation, which allows the issuer to indicate why +the revocation certificate was issued. It is important to set the +type field accurately as this allows an OpenPGP implementation to +better reason about artifacts whose validity relies on the revoked +object. For instance, if a certificate is retired, it is reasonable +to consider signatures that it made prior to its retirement as still +being valid. However, if a certificate's secret key material is +compromised, any signatures that it made should be considered +potentially forged, as they could have been made by an attacker and +backdated. + +As the intent of a revocation certificate is to stop others from using +a certificate, it is necessary to distribute the revocation +certificate. One effective way to do this is to upload the revocation +certificate to a keyserver. +") + .after_help( +"EXAMPLES: + +# Revoke a certificate. +$ sq revoke certificate --time 20220101 --certificate juliet.pgp \\ + compromised \"My parents went through my things, and found my backup.\" +") + .setting(AppSettings::SubcommandRequiredElseHelp) + .subcommand(SubCommand::with_name("certificate") + .display_order(100) + .about("Revoke a certificate") + .long_about(" +Revokes a certificate + +Creates a revocation certificate for the certificate. + +If \"--revocation-key\" is provided, then that key is used to create +the signature. If that key is different from the certificate being +revoked, this creates a third-party revocation. This is normally only +useful if the owner of the certificate designated the key to be a +designated revoker. + +If \"--revocation-key\" is not provided, then the certificate must +include a certification-capable key.") + + .arg(Arg::with_name("input") + .value_name("FILE") + .long("certificate") + .alias("cert") + .help("The certificate to revoke") + .long_help(" +Reads the certificate to revoke from FILE or stdin, if omitted. It is +an error for the file to contain more than one certificate.") + ) + .arg(Arg::with_name("secret-key-file") + .long("revocation-key").value_name("FILE") + .help("Signs the revocation certificate using KEY") + .long_help(" +Signs the revocation certificate using KEY. If the key is different +from the certificate, this creates a third-party revocation. If this +option is not provided, and the certificate i |