dc24306af1
- When emitting revocation certificates, emit the revocation signature with enough context so that it is a well-formed TPK, i.e. include the primary key, the component to be revoked (if revoking a user ID or subkey), and the revocation signature. - Having a partial TPK instead of a bare revocation makes handling it much easier, as it can be stored and transported like any cert. It also gives the recipient of the certificate more context, and simplifies merging it into a database of certs. - Previously, there was a bug in sq where we would emit secret key material when emitting revocation certificates. The reason for that was that the certificate was first converted to a packet stream, and then each packet serialized. In contrast, if a Cert is serialized, no secrets are emitted unless the programmer opts in. In a way, this is the more comprehensive fix for the problem, as it leverages sequoia-openpgp's mechanisms to protect secret key material. - See #160.
517 lines
16 KiB
Rust
517 lines
16 KiB
Rust
use assert_cmd::Command;
|
|
|
|
use chrono::Duration;
|
|
use openpgp::packet::Key;
|
|
use openpgp::parse::Parse;
|
|
use openpgp::types::ReasonForRevocation;
|
|
use openpgp::types::RevocationStatus;
|
|
use openpgp::types::SignatureType;
|
|
use openpgp::Cert;
|
|
use openpgp::Result;
|
|
use sequoia_openpgp as openpgp;
|
|
|
|
mod common;
|
|
use common::compare_notations;
|
|
use common::sq_key_generate;
|
|
use common::STANDARD_POLICY;
|
|
|
|
#[test]
|
|
fn sq_key_subkey_generate_authentication_subkey() -> Result<()> {
|
|
let (tmpdir, path, _) = sq_key_generate(None).unwrap();
|
|
let output = path.parent().unwrap().join("new_key.pgp");
|
|
|
|
let mut cmd = Command::cargo_bin("sq")?;
|
|
cmd.args([
|
|
"--no-cert-store",
|
|
"key",
|
|
"subkey",
|
|
"add",
|
|
"--output",
|
|
&output.to_string_lossy(),
|
|
"--can-authenticate",
|
|
&path.to_string_lossy(),
|
|
]);
|
|
cmd.assert().success();
|
|
|
|
let cert = Cert::from_file(&output)?;
|
|
let valid_cert = cert.with_policy(STANDARD_POLICY, None)?;
|
|
|
|
assert_eq!(
|
|
valid_cert.keys().filter(|x| x.for_authentication()).count(),
|
|
2
|
|
);
|
|
tmpdir.close()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn sq_key_subkey_generate_encryption_subkey() -> Result<()> {
|
|
let (tmpdir, path, _) = sq_key_generate(None).unwrap();
|
|
let output = path.parent().unwrap().join("new_key.pgp");
|
|
|
|
let mut cmd = Command::cargo_bin("sq")?;
|
|
cmd.args([
|
|
"--no-cert-store",
|
|
"key",
|
|
"subkey",
|
|
"add",
|
|
"--output",
|
|
&output.to_string_lossy(),
|
|
"--can-encrypt=universal",
|
|
&path.to_string_lossy(),
|
|
]);
|
|
cmd.assert().success();
|
|
|
|
let cert = Cert::from_file(&output)?;
|
|
let valid_cert = cert.with_policy(STANDARD_POLICY, None)?;
|
|
|
|
assert_eq!(
|
|
valid_cert
|
|
.keys()
|
|
.filter(|x| x.for_storage_encryption())
|
|
.count(),
|
|
2
|
|
);
|
|
assert_eq!(
|
|
valid_cert
|
|
.keys()
|
|
.filter(|x| x.for_transport_encryption())
|
|
.count(),
|
|
2
|
|
);
|
|
tmpdir.close()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn sq_key_subkey_generate_signing_subkey() -> Result<()> {
|
|
let (tmpdir, path, _) = sq_key_generate(None).unwrap();
|
|
let output = path.parent().unwrap().join("new_key.pgp");
|
|
|
|
let mut cmd = Command::cargo_bin("sq")?;
|
|
cmd.args([
|
|
"--no-cert-store",
|
|
"key",
|
|
"subkey",
|
|
"add",
|
|
"--output",
|
|
&output.to_string_lossy(),
|
|
"--can-sign",
|
|
&path.to_string_lossy(),
|
|
]);
|
|
cmd.assert().success();
|
|
|
|
let cert = Cert::from_file(&output)?;
|
|
let valid_cert = cert.with_policy(STANDARD_POLICY, None)?;
|
|
|
|
assert_eq!(valid_cert.keys().filter(|x| x.for_signing()).count(), 2);
|
|
tmpdir.close()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn sq_key_subkey_revoke() -> Result<()> {
|
|
let (tmpdir, path, time) = sq_key_generate(None)?;
|
|
|
|
let cert = Cert::from_file(&path)?;
|
|
let valid_cert = cert.with_policy(STANDARD_POLICY, Some(time.into()))?;
|
|
let fingerprint = valid_cert.clone().fingerprint();
|
|
let subkey: Key<_, _> = valid_cert
|
|
.with_policy(STANDARD_POLICY, Some(time.into()))
|
|
.unwrap()
|
|
.keys()
|
|
.subkeys()
|
|
.nth(0)
|
|
.unwrap()
|
|
.key()
|
|
.clone();
|
|
let subkey_fingerprint = subkey.fingerprint();
|
|
let message = "message";
|
|
|
|
// revoke for various reasons, with or without notations added, or with
|
|
// a revocation whose reference time is one hour after the creation of the
|
|
// certificate
|
|
for (reason, reason_str, notations, revocation_time) in [
|
|
(
|
|
ReasonForRevocation::KeyCompromised,
|
|
"compromised",
|
|
None,
|
|
None,
|
|
),
|
|
(
|
|
ReasonForRevocation::KeyCompromised,
|
|
"compromised",
|
|
None,
|
|
Some(time + Duration::hours(1)),
|
|
),
|
|
(
|
|
ReasonForRevocation::KeyCompromised,
|
|
"compromised",
|
|
Some(&[("foo", "bar"), ("hallo@sequoia-pgp.org", "VALUE")]),
|
|
None,
|
|
),
|
|
(ReasonForRevocation::KeyRetired, "retired", None, None),
|
|
(
|
|
ReasonForRevocation::KeyRetired,
|
|
"retired",
|
|
None,
|
|
Some(time + Duration::hours(1)),
|
|
),
|
|
(
|
|
ReasonForRevocation::KeyRetired,
|
|
"retired",
|
|
Some(&[("foo", "bar"), ("hallo@sequoia-pgp.org", "VALUE")]),
|
|
None,
|
|
),
|
|
(ReasonForRevocation::KeySuperseded, "superseded", None, None),
|
|
(
|
|
ReasonForRevocation::KeySuperseded,
|
|
"superseded",
|
|
None,
|
|
Some(time + Duration::hours(1)),
|
|
),
|
|
(
|
|
ReasonForRevocation::KeySuperseded,
|
|
"superseded",
|
|
Some(&[("foo", "bar"), ("hallo@sequoia-pgp.org", "VALUE")]),
|
|
None,
|
|
),
|
|
(ReasonForRevocation::Unspecified, "unspecified", None, None),
|
|
(
|
|
ReasonForRevocation::Unspecified,
|
|
"unspecified",
|
|
None,
|
|
Some(time + Duration::hours(1)),
|
|
),
|
|
(
|
|
ReasonForRevocation::Unspecified,
|
|
"unspecified",
|
|
Some(&[("foo", "bar"), ("hallo@sequoia-pgp.org", "VALUE")]),
|
|
None,
|
|
),
|
|
] {
|
|
let revocation = &path.parent().unwrap().join(format!(
|
|
"revocation_{}_{}_{}.rev",
|
|
reason_str,
|
|
if notations.is_some() {
|
|
"notations"
|
|
} else {
|
|
"no_notations"
|
|
},
|
|
if revocation_time.is_some() {
|
|
"time"
|
|
} else {
|
|
"no_time"
|
|
}
|
|
));
|
|
|
|
let mut cmd = Command::cargo_bin("sq")?;
|
|
cmd.args([
|
|
"--no-cert-store",
|
|
"key",
|
|
"subkey",
|
|
"revoke",
|
|
"--output",
|
|
&revocation.to_string_lossy(),
|
|
"--certificate-file",
|
|
&path.to_string_lossy(),
|
|
&subkey_fingerprint.to_string(),
|
|
reason_str,
|
|
message,
|
|
]);
|
|
if let Some(notations) = notations {
|
|
for (k, v) in notations {
|
|
cmd.args(["--notation", k, v]);
|
|
}
|
|
}
|
|
if let Some(time) = revocation_time {
|
|
cmd.args([
|
|
"--time",
|
|
&time.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
|
]);
|
|
}
|
|
let output = cmd.output()?;
|
|
if !output.status.success() {
|
|
panic!("sq exited with non-zero status code: {:?}", output.stderr);
|
|
}
|
|
|
|
// whether we found a revocation signature
|
|
let mut found_revoked = false;
|
|
|
|
// read revocation cert
|
|
let rev = Cert::from_file(&revocation)?;
|
|
assert!(! rev.is_tsk());
|
|
|
|
// and merge it into the certificate.
|
|
let cert = cert.clone().merge_public(rev)?;
|
|
let valid_cert =
|
|
cert.with_policy(STANDARD_POLICY, revocation_time.map(Into::into))?;
|
|
valid_cert
|
|
.with_policy(STANDARD_POLICY, revocation_time.map(Into::into))
|
|
.unwrap()
|
|
.keys()
|
|
.subkeys()
|
|
.for_each(|x| {
|
|
if x.fingerprint() == subkey_fingerprint {
|
|
let status = x.revocation_status(
|
|
STANDARD_POLICY,
|
|
revocation_time.map(Into::into),
|
|
);
|
|
|
|
// the subkey is revoked
|
|
assert!(matches!(status, RevocationStatus::Revoked(_)));
|
|
|
|
if let RevocationStatus::Revoked(sigs) = status {
|
|
// there is only one signature packet
|
|
assert_eq!(sigs.len(), 1);
|
|
let sig = sigs.into_iter().next().unwrap();
|
|
|
|
// it is a subkey revocation
|
|
assert_eq!(sig.typ(), SignatureType::SubkeyRevocation);
|
|
|
|
// the issuer is the certificate owner
|
|
assert_eq!(
|
|
sig.get_issuers().into_iter().next().as_ref(),
|
|
Some(&fingerprint.clone().into())
|
|
);
|
|
|
|
// our reason for revocation and message matches
|
|
assert_eq!(
|
|
sig.reason_for_revocation(),
|
|
Some((reason, message.as_bytes()))
|
|
);
|
|
|
|
// the notations of the revocation match the ones
|
|
// we passed in
|
|
assert!(compare_notations(sig, notations).is_ok());
|
|
|
|
found_revoked = true;
|
|
}
|
|
}
|
|
});
|
|
|
|
if !found_revoked {
|
|
panic!("the revoked subkey is not found in the revocation cert");
|
|
}
|
|
}
|
|
|
|
tmpdir.close()?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn sq_key_subkey_revoke_thirdparty() -> Result<()> {
|
|
let (tmpdir, path, time) = sq_key_generate(None)?;
|
|
let (thirdparty_tmpdir, thirdparty_path, thirdparty_time) =
|
|
sq_key_generate(Some(&["bob <bob@example.org"]))?;
|
|
|
|
let cert = Cert::from_file(&path)?;
|
|
let valid_cert = cert.with_policy(STANDARD_POLICY, Some(time.into()))?;
|
|
let subkey: Key<_, _> = valid_cert
|
|
.with_policy(STANDARD_POLICY, Some(time.into()))
|
|
.unwrap()
|
|
.keys()
|
|
.subkeys()
|
|
.nth(0)
|
|
.unwrap()
|
|
.key()
|
|
.clone();
|
|
let subkey_fingerprint = subkey.fingerprint();
|
|
|
|
let thirdparty_cert = Cert::from_file(&thirdparty_path)?;
|
|
let thirdparty_valid_cert = thirdparty_cert
|
|
.with_policy(STANDARD_POLICY, Some(thirdparty_time.into()))?;
|
|
let thirdparty_fingerprint = thirdparty_valid_cert.clone().fingerprint();
|
|
|
|
let message = "message";
|
|
|
|
// revoke for various reasons, with or without notations added, or with
|
|
// a revocation whose reference time is one hour after the creation of the
|
|
// certificate
|
|
for (reason, reason_str, notations, revocation_time) in [
|
|
(
|
|
ReasonForRevocation::KeyCompromised,
|
|
"compromised",
|
|
None,
|
|
None,
|
|
),
|
|
(
|
|
ReasonForRevocation::KeyCompromised,
|
|
"compromised",
|
|
None,
|
|
Some(thirdparty_time + Duration::hours(1)),
|
|
),
|
|
(
|
|
ReasonForRevocation::KeyCompromised,
|
|
"compromised",
|
|
Some(&[("foo", "bar"), ("hallo@sequoia-pgp.org", "VALUE")]),
|
|
None,
|
|
),
|
|
(ReasonForRevocation::KeyRetired, "retired", None, None),
|
|
(
|
|
ReasonForRevocation::KeyRetired,
|
|
"retired",
|
|
None,
|
|
Some(thirdparty_time + Duration::hours(1)),
|
|
),
|
|
(
|
|
ReasonForRevocation::KeyRetired,
|
|
"retired",
|
|
Some(&[("foo", "bar"), ("hallo@sequoia-pgp.org", "VALUE")]),
|
|
None,
|
|
),
|
|
(ReasonForRevocation::KeySuperseded, "superseded", None, None),
|
|
(
|
|
ReasonForRevocation::KeySuperseded,
|
|
"superseded",
|
|
None,
|
|
Some(thirdparty_time + Duration::hours(1)),
|
|
),
|
|
(
|
|
ReasonForRevocation::KeySuperseded,
|
|
"superseded",
|
|
Some(&[("foo", "bar"), ("hallo@sequoia-pgp.org", "VALUE")]),
|
|
None,
|
|
),
|
|
(ReasonForRevocation::Unspecified, "unspecified", None, None),
|
|
(
|
|
ReasonForRevocation::Unspecified,
|
|
"unspecified",
|
|
None,
|
|
Some(thirdparty_time + Duration::hours(1)),
|
|
),
|
|
(
|
|
ReasonForRevocation::Unspecified,
|
|
"unspecified",
|
|
Some(&[("foo", "bar"), ("hallo@sequoia-pgp.org", "VALUE")]),
|
|
None,
|
|
),
|
|
] {
|
|
let revocation = &path.parent().unwrap().join(format!(
|
|
"revocation_{}_{}_{}.rev",
|
|
reason_str,
|
|
if notations.is_some() {
|
|
"notations"
|
|
} else {
|
|
"no_notations"
|
|
},
|
|
if revocation_time.is_some() {
|
|
"time"
|
|
} else {
|
|
"no_time"
|
|
}
|
|
));
|
|
|
|
let mut cmd = Command::cargo_bin("sq")?;
|
|
cmd.args([
|
|
"--no-cert-store",
|
|
"key",
|
|
"subkey",
|
|
"revoke",
|
|
"--output",
|
|
&revocation.to_string_lossy(),
|
|
"--certificate-file",
|
|
&path.to_string_lossy(),
|
|
"--revocation-file",
|
|
&thirdparty_path.to_string_lossy(),
|
|
&subkey_fingerprint.to_string(),
|
|
reason_str,
|
|
message,
|
|
]);
|
|
if let Some(notations) = notations {
|
|
for (k, v) in notations {
|
|
cmd.args(["--notation", k, v]);
|
|
}
|
|
}
|
|
if let Some(time) = revocation_time {
|
|
cmd.args([
|
|
"--time",
|
|
&time.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
|
]);
|
|
}
|
|
let output = cmd.output()?;
|
|
if !output.status.success() {
|
|
panic!("sq exited with non-zero status code: {:?}", output.stderr);
|
|
}
|
|
|
|
// whether we found a revocation signature
|
|
let mut found_revoked = false;
|
|
|
|
// read revocation cert
|
|
let rev = Cert::from_file(&revocation)?;
|
|
assert!(! rev.is_tsk());
|
|
|
|
// and merge it into the certificate.
|
|
let cert = cert.clone().merge_public(rev)?;
|
|
let valid_cert =
|
|
cert.with_policy(STANDARD_POLICY, revocation_time.map(Into::into))?;
|
|
|
|
assert_eq!(valid_cert.userids().count(), 1);
|
|
valid_cert
|
|
.with_policy(STANDARD_POLICY, revocation_time.map(Into::into))
|
|
.unwrap()
|
|
.keys()
|
|
.subkeys()
|
|
.for_each(|x| {
|
|
if x.fingerprint() == subkey_fingerprint {
|
|
if let RevocationStatus::CouldBe(sigs) = x
|
|
.revocation_status(
|
|
STANDARD_POLICY,
|
|
revocation_time.map(Into::into),
|
|
)
|
|
{
|
|
// there is only one signature packet
|
|
assert_eq!(sigs.len(), 1);
|
|
let sig = sigs.into_iter().next().unwrap();
|
|
|
|
// it is a subkey revocation
|
|
assert_eq!(sig.typ(), SignatureType::SubkeyRevocation);
|
|
|
|
// the issuer is a thirdparty revoker
|
|
assert_eq!(
|
|
sig.get_issuers().into_iter().next().as_ref(),
|
|
Some(&thirdparty_fingerprint.clone().into())
|
|
);
|
|
|
|
// the revocation can be verified
|
|
if sig
|
|
.clone()
|
|
.verify_subkey_revocation(
|
|
&thirdparty_cert.primary_key(),
|
|
&cert.primary_key(),
|
|
&subkey,
|
|
)
|
|
.is_err()
|
|
{
|
|
panic!("revocation is not valid")
|
|
}
|
|
|
|
// our reason for revocation and message matches
|
|
assert_eq!(
|
|
sig.reason_for_revocation(),
|
|
Some((reason, message.as_bytes()))
|
|
);
|
|
|
|
// the notations of the revocation match the ones
|
|
// we passed in
|
|
assert!(compare_notations(sig, notations).is_ok());
|
|
|
|
found_revoked = true;
|
|
} else {
|
|
panic!("there are no signatures in {:?}", x);
|
|
}
|
|
}
|
|
});
|
|
|
|
if !found_revoked {
|
|
panic!("the revoked subkey is not found in the revocation cert");
|
|
}
|
|
}
|
|
|
|
tmpdir.close()?;
|
|
thirdparty_tmpdir.close()?;
|
|
|
|
Ok(())
|
|
}
|