M9: chain building checks CA constraints, key usage, path length and validity (#620)
* M9: chain building checks CA constraints, key usage, path length and validity build_chain linked certificates by name and signature only. An issuer must also be allowed to issue: basicConstraints CA:TRUE (an old v1 self-signed root without constraints still anchors), keyCertSign when there is a key usage, a pathLenConstraint that allows the CAs below it, and validity at the signing time (or the trusted timestamp / token time) when it is known. Without this, an ordinary end-entity certificate of a trusted CA, embedded in a PDF, could "issue" a certificate for any identity and chain to the trusted root. The signature details now say why an issuer was refused. Tests issue certificates in-process and cover each refusal, plus a signed PDF whose signer chains through an end-entity certificate (Unknown, not Valid). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXAstazhuAFyakty2m93Af * M9: unreadable keyUsage, pathLen or basicConstraints fail closed; only v1/v2 self-signed certificates count as old roots - a present keyUsage that doesn't parse allows no usage (it was unrestricted) - a present pathLenConstraint that doesn't parse allows no CA below (it was unlimited) - basicConstraints counts as present before it is parsed, so a garbled one can't make a certificate look like an old root - the self-signed-without-basicConstraints exception applies to v1/v2 certificates only - restores the load_certificates doc comment Tests build v1 certificates when they have no extensions, as real old roots are. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * M9: clippy: slice from a reference in the chain test --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
b0548e64a7
commit
8b04e3cba4
|
|
@ -168,6 +168,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
|||
|
||||
Newest first. One line per session: the date, what moved, and the new overall percentage.
|
||||
|
||||
- **2026-10-09 (M9, review of #144):** Chain building checks what an issuer may do. `x509::build_chain` linked certificates by name and signature only, so an ordinary subscriber certificate embedded in a PDF, or one in the trusted list, could "issue" a certificate for any identity and chain to a trusted root. An issuer now needs basicConstraints CA:TRUE (an old v1 self-signed root without constraints still anchors), keyCertSign when it has a key usage, a pathLenConstraint that allows the CAs below it, and validity at the signing time (or the trusted timestamp/token time) when known; the signature's details say why an issuer was refused. Negative tests issue certificates in-process and cover each refusal, plus a signed PDF whose signer chains through an end-entity certificate (Unknown, not Valid). Still missing: key usage and extended key usage of the signer, name constraints and unknown critical extensions. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%.
|
||||
|
||||
- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ ECDSA P-256/P-384; the store integration is tested with software-backed keys.
|
|||
revisions are diffed against the signed one, and the changes are classified (signing, form
|
||||
fill, comments, metadata, page content, document structure) under the DocMDP permissions.
|
||||
The verdict follows Acrobat: valid, unknown (intact but the identity isn't trusted) or invalid.
|
||||
- **Chains are checked, not just linked.** `x509::build_chain` only takes an issuer whose key
|
||||
verifies the certificate below it *and* that may issue: `basicConstraints` CA:TRUE (an old v1
|
||||
self-signed root without constraints counts), `keyCertSign` when it has a key usage, a
|
||||
`pathLenConstraint` that allows the CAs below it, and, given the signing time, validity then.
|
||||
Certificates embedded in a document are in the pool too, so an ordinary subscriber certificate
|
||||
can never vouch for another one; when an issuer is refused, the signature's details say why.
|
||||
|
||||
Oracles: poppler's `pdfsig` reports our signatures valid; OpenSSL reads our `.p12` files and
|
||||
verifies our CMS; `tests/data/openssl-signed.pdf` is a signature OpenSSL made, which we validate.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use crate::cms::SignedData;
|
|||
use crate::der::Time;
|
||||
use crate::keys::DigestAlg;
|
||||
use crate::pkcs12::DigitalId;
|
||||
use crate::x509::{Certificate, build_chain};
|
||||
use crate::x509::{Certificate, build_chain, build_chain_noted};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pdf_discovery_tests.rs"]
|
||||
|
|
@ -536,8 +536,9 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
|||
// own claimed time, as without a timestamp.
|
||||
let mut pool = crate::timestamp::token_certs(raw);
|
||||
pool.extend(trust.certs.iter().cloned());
|
||||
let trusted_tsa =
|
||||
t.signer_certificate().is_some_and(|c| c.valid_at(t.gen_time) && build_chain(&c, &pool).iter().any(|x| trust.trusts(x)));
|
||||
let trusted_tsa = t
|
||||
.signer_certificate()
|
||||
.is_some_and(|c| c.valid_at(t.gen_time) && build_chain(&c, &pool, Some(t.gen_time)).iter().any(|x| trust.trusts(x)));
|
||||
if trusted_tsa {
|
||||
info.timestamp_time = Some(t.gen_time);
|
||||
} else {
|
||||
|
|
@ -576,13 +577,20 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
|||
info.signing_time = s.signing_time.or_else(|| info.date.as_deref().and_then(Time::from_pdf));
|
||||
let mut pool = sd.certificates.clone();
|
||||
pool.extend(trust.certs.iter().cloned());
|
||||
let chain: Vec<Certificate> = build_chain(&cert, &pool).into_iter().cloned().collect();
|
||||
// The time the chain is judged at: trusted timestamp, else the signer's claimed time.
|
||||
let at = info.timestamp_time.or(info.signing_time);
|
||||
let (chain, refused) = build_chain_noted(&cert, &pool, at);
|
||||
let chain: Vec<Certificate> = chain.into_iter().cloned().collect();
|
||||
let trusted = chain.iter().any(|c| trust.trusts(c));
|
||||
info.chain = chain;
|
||||
info.certificate = Some(cert.clone());
|
||||
if let Some(why) = refused
|
||||
&& !trusted
|
||||
{
|
||||
info.details.push(why);
|
||||
}
|
||||
// Revocation evidence embedded in the document security store, if any. A verified
|
||||
// revocation is final: later blocks must not soften the verdict.
|
||||
let at = info.timestamp_time.or(info.signing_time);
|
||||
let signer = info.chain.first().cloned();
|
||||
let revoked = check_dss_revocation(doc, signer.as_ref(), &at, info);
|
||||
// Changes after signing.
|
||||
|
|
@ -773,7 +781,8 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig
|
|||
// ordinary signature from an unknown signer.
|
||||
let mut pool = crate::timestamp::token_certs(&contents);
|
||||
pool.extend(trust.certs.iter().cloned());
|
||||
let trusted_tsa = token.signer_certificate().map(|c| build_chain(&c, &pool).iter().any(|x| trust.trusts(x))).unwrap_or(false);
|
||||
let trusted_tsa =
|
||||
token.signer_certificate().map(|c| build_chain(&c, &pool, Some(token.gen_time)).iter().any(|x| trust.trusts(x))).unwrap_or(false);
|
||||
if trusted_tsa {
|
||||
info.status = Status::Valid;
|
||||
info.details.push("The timestamp token is valid and its authority is trusted.".into());
|
||||
|
|
|
|||
|
|
@ -117,6 +117,11 @@ pub struct Certificate {
|
|||
pub public_key: PublicKey,
|
||||
/// Basic constraints: a CA certificate.
|
||||
pub is_ca: bool,
|
||||
/// Whether the certificate has a basicConstraints extension at all (old v1 roots don't).
|
||||
pub has_basic_constraints: bool,
|
||||
/// Basic constraints `pathLenConstraint`: how many CA certificates may follow this one
|
||||
/// before the end-entity certificate.
|
||||
pub path_len: Option<u32>,
|
||||
/// Key usage bits (bit 0 = digitalSignature, 1 = nonRepudiation, 5 = keyCertSign), if present.
|
||||
pub key_usage: Option<u16>,
|
||||
pub subject_key_id: Option<Vec<u8>>,
|
||||
|
|
@ -128,6 +133,8 @@ pub struct Certificate {
|
|||
pub ocsp_urls: Vec<String>,
|
||||
/// CRL distribution point URLs (2.5.29.31).
|
||||
pub crl_urls: Vec<String>,
|
||||
/// The `version` field: 0 for v1, 1 for v2, 2 for v3.
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
/// The certificate extensions `Certificate::parse` reads, gathered tolerantly: an
|
||||
|
|
@ -137,6 +144,8 @@ pub struct Certificate {
|
|||
#[derive(Default)]
|
||||
struct Extensions {
|
||||
is_ca: bool,
|
||||
has_basic_constraints: bool,
|
||||
path_len: Option<u32>,
|
||||
key_usage: Option<u16>,
|
||||
subject_key_id: Option<Vec<u8>>,
|
||||
authority_key_id: Option<Vec<u8>>,
|
||||
|
|
@ -149,10 +158,21 @@ impl Extensions {
|
|||
fn read(&mut self, oid: &str, value: &Tlv<'_>) {
|
||||
match oid {
|
||||
"2.5.29.19" => {
|
||||
// Present at all, even when it doesn't parse: a malformed basicConstraints must
|
||||
// not make a certificate look like an old v1 root (which may issue). Unparsed,
|
||||
// it stays "not a CA".
|
||||
self.has_basic_constraints = true;
|
||||
let Ok(bc) = Tlv::parse_all(value.value) else { return };
|
||||
self.is_ca = bc.children().is_ok_and(|c| c.first().is_some_and(|b| b.tag == tag::BOOLEAN && b.value != [0]));
|
||||
let fields = bc.children().unwrap_or_default();
|
||||
self.is_ca = fields.first().is_some_and(|b| b.tag == tag::BOOLEAN && b.value != [0]);
|
||||
// pathLenConstraint is the INTEGER after the optional cA BOOLEAN. One that is
|
||||
// present but unreadable (negative, too long) allows no CA below, not any number.
|
||||
self.path_len = fields.iter().find(|t| t.tag == tag::INTEGER).map(|t| t.u64().map_or(0, |n| u32::try_from(n).unwrap_or(u32::MAX)));
|
||||
}
|
||||
"2.5.29.15" => {
|
||||
// A keyUsage that is present restricts the key even when it doesn't parse: an
|
||||
// unreadable one allows no usage at all, never every usage.
|
||||
self.key_usage = Some(0);
|
||||
let Ok(bits) = Tlv::parse_all(value.value) else { return };
|
||||
let Some((_, b)) = bits.value.split_first() else { return };
|
||||
// Bit 0 is the most significant bit of the first byte.
|
||||
|
|
@ -231,8 +251,11 @@ impl Certificate {
|
|||
let parts = cert.children()?;
|
||||
let [tbs, sig_alg, sig] = parts.as_slice() else { return Err(SignError::Malformed("Certificate".into())) };
|
||||
let mut f = tbs.children()?.into_iter().peekable();
|
||||
// version [0] EXPLICIT INTEGER DEFAULT v1 (0); v3 is 2. Unreadable counts as v3, the
|
||||
// stricter reading (no v1-root exception in `may_issue`).
|
||||
let mut version = 0u64;
|
||||
if f.peek().is_some_and(|t| t.tag == tag::ctx(0)) {
|
||||
f.next();
|
||||
version = f.next().and_then(|v| v.inner().ok()).and_then(|v| v.u64().ok()).unwrap_or(2);
|
||||
}
|
||||
let serial = f.next().ok_or_else(|| SignError::Malformed("serial".into()))?.expect(tag::INTEGER, "serial")?.value.to_vec();
|
||||
let _inner_alg = f.next();
|
||||
|
|
@ -255,7 +278,17 @@ impl Certificate {
|
|||
ext.read(o.as_str(), value);
|
||||
}
|
||||
}
|
||||
let Extensions { is_ca, key_usage, subject_key_id, authority_key_id, extended_key_usage, ocsp_urls, crl_urls } = ext;
|
||||
let Extensions {
|
||||
is_ca,
|
||||
has_basic_constraints,
|
||||
path_len,
|
||||
key_usage,
|
||||
subject_key_id,
|
||||
authority_key_id,
|
||||
extended_key_usage,
|
||||
ocsp_urls,
|
||||
crl_urls,
|
||||
} = ext;
|
||||
Ok(Certificate {
|
||||
raw: raw.to_vec(),
|
||||
tbs: tbs.raw.to_vec(),
|
||||
|
|
@ -268,12 +301,15 @@ impl Certificate {
|
|||
not_after: na.time()?,
|
||||
public_key,
|
||||
is_ca,
|
||||
has_basic_constraints,
|
||||
path_len,
|
||||
key_usage,
|
||||
subject_key_id,
|
||||
authority_key_id,
|
||||
extended_key_usage,
|
||||
ocsp_urls,
|
||||
crl_urls,
|
||||
version,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -289,6 +325,17 @@ impl Certificate {
|
|||
key.verify(scheme, digest, &digest.digest(&[&self.tbs]), &self.signature).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether this certificate may issue certificates (RFC 5280 §4.2.1.9, §4.2.1.3): it is a
|
||||
/// CA by its basic constraints, and if it has a key usage, that includes `keyCertSign`. A
|
||||
/// self-signed v1 or v2 certificate (an old root, which predates extensions) with no basic
|
||||
/// constraints counts as a CA; a v3 certificate must say it is one.
|
||||
pub fn may_issue(&self) -> bool {
|
||||
/// keyUsage bit 5.
|
||||
const KEY_CERT_SIGN: u16 = 1 << 5;
|
||||
let old_root = self.version < 2 && !self.has_basic_constraints && self.is_self_signed();
|
||||
(self.is_ca || old_root) && self.key_usage.is_none_or(|u| u & KEY_CERT_SIGN != 0)
|
||||
}
|
||||
|
||||
/// Valid at `t`.
|
||||
pub fn valid_at(&self, t: Time) -> bool {
|
||||
self.not_before <= t && t <= self.not_after
|
||||
|
|
@ -348,21 +395,53 @@ impl Certificate {
|
|||
}
|
||||
}
|
||||
|
||||
/// The chain from `leaf` up through `pool`, as far as issuers can be found and their keys
|
||||
/// verify the certificate below. Stops at a self-signed certificate.
|
||||
pub fn build_chain<'a>(leaf: &'a Certificate, pool: &'a [Certificate]) -> Vec<&'a Certificate> {
|
||||
/// The chain from `leaf` up through `pool`, as far as issuers can be found and may issue: each
|
||||
/// issuer's key verifies the certificate below it, it is a CA allowed to sign certificates, its
|
||||
/// `pathLenConstraint` allows the CA certificates below it, and (when `at` is given) it was valid
|
||||
/// then. Stops at a self-signed certificate. Certificates embedded in a document are in `pool`
|
||||
/// too, so an ordinary end-entity certificate must never be accepted as an issuer.
|
||||
pub fn build_chain<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: Option<Time>) -> Vec<&'a Certificate> {
|
||||
build_chain_noted(leaf, pool, at).0
|
||||
}
|
||||
|
||||
/// [`build_chain`], and why it stopped where it did if a certificate that matched the issuer by
|
||||
/// name and signature was refused.
|
||||
pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: Option<Time>) -> (Vec<&'a Certificate>, Option<String>) {
|
||||
let mut chain = vec![leaf];
|
||||
let mut refused = None;
|
||||
while chain.len() < 10 {
|
||||
let Some(&last) = chain.last() else { break };
|
||||
if last.issuer.raw == last.subject.raw {
|
||||
break;
|
||||
}
|
||||
let Some(issuer) = pool.iter().find(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by(&c.public_key)) else {
|
||||
break;
|
||||
};
|
||||
// CA certificates between `last` and the leaf: what a candidate's pathLenConstraint limits.
|
||||
let below = chain.len() - 1;
|
||||
let mut found = None;
|
||||
for c in pool.iter().filter(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by(&c.public_key)) {
|
||||
let why = if !c.may_issue() {
|
||||
Some("is not a CA certificate that may issue certificates")
|
||||
} else if c.path_len.is_some_and(|n| below > n as usize) {
|
||||
Some("does not allow this many CA certificates below it (path length)")
|
||||
} else if at.is_some_and(|t| !c.valid_at(t)) {
|
||||
Some("was not valid at the time of signing")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match why {
|
||||
None => {
|
||||
found = Some(c);
|
||||
break;
|
||||
}
|
||||
Some(why) => {
|
||||
refused = Some(format!("The certificate of {} {why}, so it is not used to vouch for {}.", c.display_name(), last.display_name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(issuer) = found else { break };
|
||||
refused = None;
|
||||
chain.push(issuer);
|
||||
}
|
||||
chain
|
||||
(chain, refused)
|
||||
}
|
||||
|
||||
/// Certificates from a file: DER, or PEM with one or more `CERTIFICATE` blocks (`.cer`, `.crt`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
//! Chain building: which certificates may vouch for which (RFC 5280 §6.1, the parts that matter
|
||||
//! for trust). Certificates are issued in-process with chosen extensions, so every refusal is
|
||||
//! tested on its own.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
use pdfcraft_sign::der::{self, Time, tag};
|
||||
use pdfcraft_sign::pkcs12::DigitalId;
|
||||
use pdfcraft_sign::x509::{Certificate, Name, build_chain, build_chain_noted};
|
||||
use pdfcraft_sign::{PrivateKey, SignOptions, Status, TrustStore, signatures};
|
||||
|
||||
fn time(year: u32) -> Time {
|
||||
Time { year, month: 6, day: 1, hour: 12, minute: 0, second: 0 }
|
||||
}
|
||||
|
||||
fn ext(oid: &str, value: &[u8]) -> Vec<u8> {
|
||||
der::seq(&[&der::oid(oid), &der::boolean(true), &der::octets(value)])
|
||||
}
|
||||
|
||||
/// basicConstraints: `cA` (omitted when false, as DER does) and an optional `pathLenConstraint`.
|
||||
fn basic_constraints(ca: bool, path_len: Option<u64>) -> Vec<u8> {
|
||||
let mut parts: Vec<Vec<u8>> = Vec::new();
|
||||
if ca {
|
||||
parts.push(der::boolean(true));
|
||||
}
|
||||
if let Some(n) = path_len {
|
||||
parts.push(der::int(n));
|
||||
}
|
||||
let refs: Vec<&[u8]> = parts.iter().map(Vec::as_slice).collect();
|
||||
ext("2.5.29.19", &der::seq(&refs))
|
||||
}
|
||||
|
||||
const DIGITAL_SIGNATURE: u8 = 0x80;
|
||||
const KEY_CERT_SIGN: u8 = 0x04;
|
||||
|
||||
fn key_usage(bits: u8) -> Vec<u8> {
|
||||
ext("2.5.29.15", &der::tlv(tag::BIT_STRING, &[1, bits]))
|
||||
}
|
||||
|
||||
struct Party {
|
||||
name: Name,
|
||||
key: PrivateKey,
|
||||
cert: Certificate,
|
||||
}
|
||||
|
||||
/// A certificate for a fresh key, signed by `issuer` (or by itself).
|
||||
fn issue(cn: &str, issuer: Option<&Party>, valid: (u32, u32), extensions: &[Vec<u8>]) -> Party {
|
||||
let key = PrivateKey::generate_p256().unwrap();
|
||||
let name = Name::build(cn, "", "Chain Tests", "", "US");
|
||||
let signer = issuer.map_or(&key, |i| &i.key);
|
||||
let issuer_name = issuer.map_or(&name, |i| &i.name);
|
||||
let alg = signer.preferred_digest();
|
||||
let sig_alg = signer.signature_algorithm(alg);
|
||||
let exts: Vec<&[u8]> = extensions.iter().map(Vec::as_slice).collect();
|
||||
let mut fields: Vec<Vec<u8>> = Vec::new();
|
||||
// v3 when there are extensions; with none, a v1 certificate (no version field), as old roots.
|
||||
if !exts.is_empty() {
|
||||
fields.push(der::explicit(0, &der::int(2)));
|
||||
}
|
||||
fields.extend([
|
||||
der::uint(&[1 + cn.len() as u8]),
|
||||
sig_alg.clone(),
|
||||
issuer_name.raw.clone(),
|
||||
der::seq(&[&time(valid.0).encode(), &time(valid.1).encode()]),
|
||||
name.raw.clone(),
|
||||
key.public_key().spki(),
|
||||
]);
|
||||
if !exts.is_empty() {
|
||||
fields.push(der::explicit(3, &der::seq(&exts)));
|
||||
}
|
||||
let refs: Vec<&[u8]> = fields.iter().map(Vec::as_slice).collect();
|
||||
let tbs = der::seq(&refs);
|
||||
let signature = signer.sign(alg, &tbs).unwrap();
|
||||
let cert = Certificate::parse(&der::seq(&[&tbs, &sig_alg, &der::bit_string(&signature)])).unwrap();
|
||||
Party { name, key, cert }
|
||||
}
|
||||
|
||||
const FOREVER: (u32, u32) = (2020, 2040);
|
||||
|
||||
fn root() -> Party {
|
||||
issue("Test Root", None, FOREVER, &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN)])
|
||||
}
|
||||
|
||||
fn ca(cn: &str, under: &Party, path_len: Option<u64>) -> Party {
|
||||
issue(cn, Some(under), FOREVER, &[basic_constraints(true, path_len), key_usage(KEY_CERT_SIGN)])
|
||||
}
|
||||
|
||||
fn end_entity(cn: &str, under: &Party) -> Party {
|
||||
issue(cn, Some(under), FOREVER, &[basic_constraints(false, None), key_usage(DIGITAL_SIGNATURE)])
|
||||
}
|
||||
|
||||
fn names(chain: &[&Certificate]) -> Vec<String> {
|
||||
chain.iter().map(|c| c.subject.common_name().unwrap_or_default().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_proper_chain_is_built() {
|
||||
let r = root();
|
||||
let i = ca("Intermediate", &r, None);
|
||||
let l = end_entity("Leaf", &i);
|
||||
let pool = [i.cert.clone(), r.cert.clone()];
|
||||
assert_eq!(names(&build_chain(&l.cert, &pool, None)), ["Leaf", "Intermediate", "Test Root"]);
|
||||
assert_eq!(names(&build_chain(&l.cert, &pool, Some(time(2026)))), ["Leaf", "Intermediate", "Test Root"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_end_entity_certificate_cannot_issue() {
|
||||
let r = root();
|
||||
let e = end_entity("Ordinary Subscriber", &r);
|
||||
// A certificate its holder made with the end-entity key.
|
||||
let forged = end_entity("Bank AG", &e);
|
||||
let pool = [e.cert.clone(), r.cert.clone()];
|
||||
let (chain, why) = build_chain_noted(&forged.cert, &pool, None);
|
||||
assert_eq!(names(&chain), ["Bank AG"], "the subscriber certificate does not vouch for what it signed");
|
||||
assert!(why.is_some_and(|w| w.contains("not a CA")), "the reason is reported");
|
||||
// Neither does one without basicConstraints (not a v1 root: it has an issuer).
|
||||
let plain = issue("No Constraints", Some(&r), FOREVER, &[]);
|
||||
let under_plain = end_entity("Under Plain", &plain);
|
||||
let pool = [plain.cert.clone(), r.cert.clone()];
|
||||
assert_eq!(names(&build_chain(&under_plain.cert, &pool, None)), ["Under Plain"]);
|
||||
// CA:TRUE does not help when keyUsage leaves out keyCertSign…
|
||||
let signing_only = issue("Signing Only CA", Some(&r), FOREVER, &[basic_constraints(true, None), key_usage(DIGITAL_SIGNATURE)]);
|
||||
let below = end_entity("Below Signing Only", &signing_only);
|
||||
let pool = [signing_only.cert.clone(), r.cert.clone()];
|
||||
assert_eq!(names(&build_chain(&below.cert, &pool, None)), ["Below Signing Only"]);
|
||||
// …and a CA with no keyUsage at all is fine.
|
||||
let any_usage = issue("Any Usage CA", Some(&r), FOREVER, &[basic_constraints(true, None)]);
|
||||
let below = end_entity("Below Any Usage", &any_usage);
|
||||
let pool = [any_usage.cert.clone(), r.cert.clone()];
|
||||
assert_eq!(names(&build_chain(&below.cert, &pool, None)), ["Below Any Usage", "Any Usage CA", "Test Root"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_length_constraint_limits_the_cas_below() {
|
||||
let r = root();
|
||||
let i1 = ca("Constrained", &r, Some(0));
|
||||
let i2 = ca("Sub CA", &i1, None);
|
||||
let l = end_entity("Leaf", &i2);
|
||||
let pool = [i1.cert.clone(), i2.cert.clone(), r.cert.clone()];
|
||||
// pathLen 0: no CA may follow it, so Sub CA's issuer is refused and the chain stops there.
|
||||
assert_eq!(names(&build_chain(&l.cert, &pool, None)), ["Leaf", "Sub CA"]);
|
||||
// pathLen 0 directly above the leaf is fine, and pathLen 1 allows one CA below.
|
||||
let direct = end_entity("Direct Leaf", &i1);
|
||||
assert_eq!(names(&build_chain(&direct.cert, &pool, None)), ["Direct Leaf", "Constrained", "Test Root"]);
|
||||
let i1 = ca("Constrained 1", &r, Some(1));
|
||||
let i2 = ca("Sub CA 1", &i1, None);
|
||||
let l = end_entity("Leaf 1", &i2);
|
||||
let pool = [i1.cert.clone(), i2.cert.clone(), r.cert.clone()];
|
||||
assert_eq!(names(&build_chain(&l.cert, &pool, None)), ["Leaf 1", "Sub CA 1", "Constrained 1", "Test Root"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issuers_must_have_been_valid_when_it_matters() {
|
||||
let r = root();
|
||||
let old = issue("Short Lived CA", Some(&r), (2020, 2022), &[basic_constraints(true, None), key_usage(KEY_CERT_SIGN)]);
|
||||
let l = end_entity("Leaf", &old);
|
||||
let pool = [old.cert.clone(), r.cert.clone()];
|
||||
assert_eq!(names(&build_chain(&l.cert, &pool, Some(time(2021)))), ["Leaf", "Short Lived CA", "Test Root"]);
|
||||
let (chain, why) = build_chain_noted(&l.cert, &pool, Some(time(2023)));
|
||||
assert_eq!(names(&chain), ["Leaf"], "expired by then");
|
||||
assert!(why.is_some_and(|w| w.contains("not valid")));
|
||||
assert_eq!(names(&build_chain(&l.cert, &pool, Some(time(2019)))), ["Leaf"], "not yet valid");
|
||||
// With no time to judge by, nothing is refused on validity.
|
||||
assert_eq!(names(&build_chain(&l.cert, &pool, None)).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_old_v1_self_signed_root_without_constraints_still_anchors() {
|
||||
// No extensions at all: basicConstraints absent, as in v1 roots.
|
||||
let r = issue("Old V1 Root", None, FOREVER, &[]);
|
||||
assert!(r.cert.is_self_signed() && !r.cert.has_basic_constraints && r.cert.may_issue());
|
||||
let i = ca("Under V1", &r, None);
|
||||
let l = end_entity("Leaf", &i);
|
||||
let pool = [i.cert.clone(), r.cert.clone()];
|
||||
assert_eq!(names(&build_chain(&l.cert, &pool, None)), ["Leaf", "Under V1", "Old V1 Root"]);
|
||||
// A self-signed certificate that says CA:FALSE is not a root that can issue.
|
||||
let not_a_ca = issue("Self Signed Leaf", None, FOREVER, &[basic_constraints(false, None)]);
|
||||
assert!(!not_a_ca.cert.may_issue());
|
||||
}
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
std::fs::read(format!("{}/tests/data/openssl-signed.pdf", env!("CARGO_MANIFEST_DIR"))).unwrap()
|
||||
}
|
||||
|
||||
/// Sign a small PDF with `leaf`, embedding `embedded`, and validate it trusting only `anchor`.
|
||||
fn verdict(leaf: Party, embedded: Vec<Certificate>, anchor: &Certificate) -> (Status, Vec<String>) {
|
||||
let id = DigitalId { key: leaf.key, certificate: leaf.cert, chain: embedded, friendly_name: None };
|
||||
let doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
let opts = SignOptions { page: 0, rect: None, date: "D:20260601120000Z".into(), ..SignOptions::default() };
|
||||
let signed = pdfcraft_sign::sign(&doc, &id, &opts).unwrap();
|
||||
let trust = TrustStore { certs: vec![anchor.clone()] };
|
||||
let s = signatures(&Document::open(Arc::new(signed.clone())).unwrap(), &signed, &trust).into_iter().rfind(|s| s.signed).unwrap();
|
||||
(s.status, s.details)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_signature_chaining_through_an_ordinary_certificate_is_not_trusted() {
|
||||
let r = root();
|
||||
// The root vouches for a CA, which vouches for the signer: valid.
|
||||
let i = ca("Issuing CA", &r, None);
|
||||
let l = end_entity("Honest Signer", &i);
|
||||
let (status, details) = verdict(l, vec![i.cert.clone()], &r.cert);
|
||||
assert_eq!(status, Status::Valid, "{details:?}");
|
||||
// The root vouched for an ordinary subscriber, who signed the "signer" certificate with the
|
||||
// subscriber's own key and embedded it: the root must not vouch for that.
|
||||
let subscriber = end_entity("Ordinary Subscriber", &r);
|
||||
let forged = end_entity("Forged Identity", &subscriber);
|
||||
let (status, details) = verdict(forged, vec![subscriber.cert.clone()], &r.cert);
|
||||
assert_eq!(status, Status::Unknown, "{details:?}");
|
||||
assert!(details.iter().any(|d| d.contains("not a CA")), "{details:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v3_self_signed_certificate_without_constraints_is_not_a_root_that_issues() {
|
||||
// A self-signed v3 ID with no basicConstraints and no key usage (only a key identifier): it
|
||||
// may sign documents, but it may not vouch for anyone else.
|
||||
let id = issue("Self Signed V3 ID", None, FOREVER, &[ext("2.5.29.14", &der::octets(&[1, 2, 3, 4]))]);
|
||||
assert!(id.cert.is_self_signed() && !id.cert.has_basic_constraints && id.cert.version == 2);
|
||||
assert!(!id.cert.may_issue());
|
||||
let l = end_entity("Leaf", &id);
|
||||
assert_eq!(names(&build_chain(&l.cert, std::slice::from_ref(&id.cert), None)), ["Leaf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_constraint_extensions_fail_closed() {
|
||||
// keyUsage present but not a BIT STRING: no usage at all, so no keyCertSign.
|
||||
let bad_usage = ext("2.5.29.15", &[0xff, 0x00]);
|
||||
let c = issue("Bad Usage CA", Some(&root()), FOREVER, &[basic_constraints(true, None), bad_usage]);
|
||||
assert_eq!(c.cert.key_usage, Some(0));
|
||||
assert!(!c.cert.may_issue());
|
||||
// pathLenConstraint present but negative: no CA may follow, not any number.
|
||||
let negative = ext("2.5.29.19", &der::seq(&[&der::boolean(true), &der::tlv(tag::INTEGER, &[0xff])]));
|
||||
let c = issue("Negative PathLen CA", Some(&root()), FOREVER, &[negative]);
|
||||
assert!(c.cert.is_ca);
|
||||
assert_eq!(c.cert.path_len, Some(0));
|
||||
// basicConstraints present but unparsable: present, not a CA, so not an "old v1 root".
|
||||
let garbage = ext("2.5.29.19", &[0x30, 0x05, 0x01]);
|
||||
let c = issue("Garbled Constraints", None, FOREVER, &[garbage]);
|
||||
assert!(c.cert.has_basic_constraints && !c.cert.is_ca && !c.cert.may_issue());
|
||||
}
|
||||
Loading…
Reference in New Issue