summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNeal H. Walfield <neal@pep.foundation>2022-01-06 16:56:39 +0100
committerNeal H. Walfield <neal@pep.foundation>2022-01-14 11:05:56 +0100
commita4cfd15805a543a327d2242f9c0f2b653a11ee55 (patch)
tree3f01ee175695d1b16030b7b86250e6590fa40a28
parent14bcf8a292e8a5ace5462456a743ea00e0bc7ab9 (diff)
sq: Implement sq revoke certificate.
- Add support for revoking certificates to sq.
-rw-r--r--Cargo.lock63
-rw-r--r--sq/Cargo.toml1
-rw-r--r--sq/src/commands/mod.rs45
-rw-r--r--sq/src/commands/revoke.rs163
-rw-r--r--sq/src/sq-usage.rs190
-rw-r--r--sq/src/sq.rs81
-rw-r--r--sq/src/sq_cli.rs149
-rw-r--r--sq/tests/sq-revoke.rs393
8 files changed, 1084 insertions, 1 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 8cec8fc1..8696fdf5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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: &notations,
+ 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