M9: the signer's certificate must allow document signing (key usage, EKU, critical extensions) (#749)

* Parity: merge the duplicate form.field-list evidence and notes

#649 and #650 each added `evidence` and `notes` to the same entry, so the
TOML has duplicate keys and `cargo xtask parity` fails on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M9: the signer's certificate must allow document signing (key usage, EKU, critical extensions)

A trusted signer whose certificate may not sign documents was reported
Valid. Validation now checks the signer certificate (RFC 5280 §4.2.1.3,
§4.2.1.12, §4.2): a keyUsage needs digitalSignature or nonRepudiation, an
extended key usage needs a document-signing purpose (any, emailProtection,
codeSigning, RFC 9336 documentSigning, Adobe Authentic Documents, Microsoft
document signing), and no critical extension may be one PdfCraft doesn't
process. Otherwise the verdict is Unknown and the details say why.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* M9: a critical qcStatements extension doesn't make a qualified certificate unusable

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 08:14:55 -04:00
1 parent e4e39f4bef
commit 7a33d4bf25
6 files changed
+126 -2

No files matched your search

+1
View File
@@ -131,6 +131,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
## Log
- **2026-10-10 (M9, signer certificate usage):** A trusted signer's certificate must also allow signing documents (RFC 5280 §4.2.1.3, §4.2.1.12, §4.2). A key usage without digitalSignature or nonRepudiation, an extended key usage with no document-signing purpose (any, emailProtection, codeSigning, RFC 9336 documentSigning, Adobe Authentic Documents, Microsoft document signing), or a critical extension PdfCraft doesn't process makes the verdict Unknown instead of Valid, and the details say which. Certificate policies are accepted as any policy. In-process certificates test each refusal and the accepted cases; a signed PDF from a trusted TLS-server certificate was Valid before and is Unknown now. Still missing: name constraints, and unknown critical extensions on issuer certificates. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M14, side panel width):** The side panel keeps the width it is dragged to. The Bookmarks search row was 2 px wider than the panel, and a resizable egui panel keeps the width its content used, so every repaint (any pointer move) widened it up to 520 px. The Comments reply row did the same in languages with a longer "Post" (Russian, Spanish, …); Post now sits under a full-width reply field. Tests hold both panels' widths steady across 30 repaints. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M8):** Redaction codes. Redaction properties offers "Custom text" or "Redaction code" under "Use overlay text": pick the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and tick codes, and new marks carry them as overlay text in the set's order ("(b)(6), (b)(7)(C)"). `redact_mark` takes `code_set` and `codes`, rejecting unknown sets and codes with the allowed list. Tests cover the code sets, the tool end to end (mark, apply, text) and the dialog; the new label is translated in all 13 UI catalogs. `protect.redaction-codes` is partial: Edit Codes, custom sets and their import/export are still missing. Estimates unchanged.
- **2026-10-10 (M14, platform shortcut labels):** View ▸ Zoom and Page navigation, the rail, find-bar, toolbar and bookmark tooltips, and Help ▸ Keyboard shortcuts write shortcuts for the platform (`Ctrl+1`, `Ctrl+Shift++` off macOS) instead of hard-coded ⌘. Labels follow egui's detected OS, so the web build in a Linux or Windows browser shows Ctrl too. Fit visible is listed once in View. Translated tooltips take a `{key}` placeholder. Overall estimate unchanged (about 30–35%).
+5
View File
@@ -66,6 +66,11 @@ issuers Windows chains its certificate to, without the root.
`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.
- **The signer's certificate must allow signing documents** (`Certificate::signing_problem`): a
key usage needs digitalSignature or nonRepudiation, an extended key usage needs a
document-signing purpose (any, emailProtection, codeSigning, RFC 9336 documentSigning, Adobe
Authentic Documents, Microsoft document signing), and no critical extension may be one PdfCraft
doesn't process. Otherwise the verdict is unknown, never valid, and the details say why.
- **BER as well as DER.** The CMS is read with `der::Tlv::parse_ber`, as Windows CryptoAPI, Adobe
PPKMS, DocuSign, Documenso and `openssl cms -stream` write it: indefinite lengths and an
OCTET STRING split into segments. Only the structure is read that way; certificates and signed
+7
View File
@@ -675,6 +675,13 @@ fn finish_validation(
}
problems = true;
}
if let Some(why) = cert.signing_problem() {
info.details.push(why);
if !problems && !revoked {
info.status = Status::Unknown;
}
problems = true;
}
if !trusted {
info.details.push(
"The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates."
+54
View File
@@ -135,8 +135,36 @@ pub struct Certificate {
pub crl_urls: Vec<String>,
/// The `version` field: 0 for v1, 1 for v2, 2 for v3.
pub version: u64,
/// OIDs of extensions marked critical that PdfCraft does not process (RFC 5280 §4.2).
pub unknown_critical: Vec<String>,
}
/// Extensions PdfCraft reads or may safely ignore when they are critical: the ones
/// `Extensions::read` handles, subject/issuer alternative names, and certificate policies
/// (accepted as any policy).
/// qcStatements (1.3.6.1.5.5.7.1.3) is included: RFC 3739 lets qualified (eIDAS and other national)
/// certificates mark it critical, and it declares the certificate's status rather than limiting
/// what the key may sign.
const PROCESSED_EXTENSIONS: [&str; 11] = [
"2.5.29.19",
"2.5.29.15",
"2.5.29.14",
"2.5.29.35",
"2.5.29.37",
"1.3.6.1.5.5.7.1.1",
"2.5.29.31",
"2.5.29.17",
"2.5.29.18",
"2.5.29.32",
"1.3.6.1.5.5.7.1.3",
];
/// Extended key usages that allow signing documents: anyExtendedKeyUsage, emailProtection,
/// codeSigning, documentSigning (RFC 9336), Adobe Authentic Documents and Microsoft document
/// signing.
const DOCUMENT_SIGNING_EKUS: [&str; 6] =
["2.5.29.37.0", "1.3.6.1.5.5.7.3.4", "1.3.6.1.5.5.7.3.3", "1.3.6.1.5.5.7.3.36", "1.2.840.113583.1.1.5", "1.3.6.1.4.1.311.10.3.12"];
/// The certificate extensions `Certificate::parse` reads, gathered tolerantly: an
/// extension that does not parse leaves its field at the default instead of failing the
/// certificate (real-world issuers use encodings with edge cases, e.g. a single-URI CRL
@@ -265,6 +293,7 @@ impl Certificate {
let subject = Name::parse(&f.next().ok_or_else(|| SignError::Malformed("subject".into()))?)?;
let public_key = PublicKey::from_spki(&f.next().ok_or_else(|| SignError::Malformed("public key".into()))?)?;
let mut ext = Extensions::default();
let mut unknown_critical = Vec::new();
for t in f {
if t.tag != tag::ctx(3) {
continue;
@@ -273,6 +302,10 @@ impl Certificate {
let e = ext_tlv.children()?;
let Some(o) = e.first().and_then(|o| o.oid().ok()) else { continue };
let Some(value) = e.last().filter(|v| v.tag == tag::OCTET_STRING) else { continue };
let critical = e.len() == 3 && e.get(1).is_some_and(|b| b.tag == tag::BOOLEAN && b.value != [0]);
if critical && !PROCESSED_EXTENSIONS.contains(&o.as_str()) {
unknown_critical.push(o.clone());
}
// A malformed or unreadable extension must never reject the whole certificate:
// unreadable ones are skipped and the fields they carry keep their defaults.
ext.read(o.as_str(), value);
@@ -310,9 +343,30 @@ impl Certificate {
ocsp_urls,
crl_urls,
version,
unknown_critical,
})
}
/// Why this certificate may not sign documents, if it may not (RFC 5280 §4.2.1.3,
/// §4.2.1.12, §4.2): a key usage without digitalSignature or nonRepudiation, an extended key
/// usage without a document-signing purpose, or a critical extension PdfCraft doesn't process.
pub fn signing_problem(&self) -> Option<String> {
/// keyUsage bits 0 (digitalSignature) and 1 (nonRepudiation).
const SIGNING_BITS: u16 = 0b11;
if self.key_usage.is_some_and(|u| u & SIGNING_BITS == 0) {
Some(
"The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation)."
.into(),
)
} else if self.extended_key_usage.as_ref().is_some_and(|e| !e.iter().any(|o| DOCUMENT_SIGNING_EKUS.contains(&o.as_str()))) {
Some("The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose).".into())
} else if !self.unknown_critical.is_empty() {
Some(format!("The signer's certificate has critical extensions PdfCraft does not recognize ({}).", self.unknown_critical.join(", ")))
} else {
None
}
}
/// Issued by itself (subject = issuer and its own key verifies it).
pub fn is_self_signed(&self) -> bool {
self.issuer.raw == self.subject.raw && self.signed_by(&self.public_key)
+57
View File
@@ -222,6 +222,63 @@ fn a_v3_self_signed_certificate_without_constraints_is_not_a_root_that_issues()
assert_eq!(names(&build_chain(&l.cert, std::slice::from_ref(&id.cert), None)), ["Leaf"]);
}
const KEY_ENCIPHERMENT: u8 = 0x20;
const SERVER_AUTH: &str = "1.3.6.1.5.5.7.3.1";
fn extended_key_usage(oids: &[&str]) -> Vec<u8> {
let encoded: Vec<Vec<u8>> = oids.iter().map(|o| der::oid(o)).collect();
let refs: Vec<&[u8]> = encoded.iter().map(Vec::as_slice).collect();
der::seq(&[&der::oid("2.5.29.37"), &der::octets(&der::seq(&refs))])
}
fn signer_with(cn: &str, under: &Party, extensions: &[Vec<u8>]) -> Party {
let mut all = vec![basic_constraints(false, None)];
all.extend_from_slice(extensions);
issue(cn, Some(under), FOREVER, &all)
}
#[test]
fn the_signer_certificate_must_allow_document_signing() {
let r = root();
let problem = |exts: &[Vec<u8>]| signer_with("Signer", &r, exts).cert.signing_problem();
// Key usage without digitalSignature or nonRepudiation.
assert!(problem(&[key_usage(KEY_CERT_SIGN)]).is_some_and(|w| w.contains("key usage")));
assert!(problem(&[key_usage(KEY_ENCIPHERMENT)]).is_some());
assert!(problem(&[key_usage(DIGITAL_SIGNATURE)]).is_none());
// nonRepudiation alone is enough.
assert!(problem(&[key_usage(0x40)]).is_none());
// Extended key usage without a document-signing purpose.
assert!(problem(&[extended_key_usage(&[SERVER_AUTH])]).is_some_and(|w| w.contains("extended key usage")));
for ok in ["2.5.29.37.0", "1.3.6.1.5.5.7.3.4", "1.3.6.1.5.5.7.3.36", "1.2.840.113583.1.1.5", "1.3.6.1.4.1.311.10.3.12"] {
assert!(problem(&[extended_key_usage(&[SERVER_AUTH, ok])]).is_none(), "{ok}");
}
// An unknown critical extension refuses; the same extension non-critical does not.
let unknown = "1.2.3.4.5.6";
assert!(problem(&[ext(unknown, &der::octets(&[0]))]).is_some_and(|w| w.contains(unknown)));
assert!(problem(&[der::seq(&[&der::oid(unknown), &der::octets(&der::octets(&[0]))])]).is_none());
// Critical extensions PdfCraft knows, or may accept as they are, don't refuse.
assert!(problem(&[ext("2.5.29.32", &der::seq(&[]))]).is_none());
// No extensions at all, and the digital IDs PdfCraft creates, may sign.
assert!(issue("Plain", Some(&r), FOREVER, &[]).cert.signing_problem().is_none());
let key = PrivateKey::generate_p256().unwrap();
let name = Name::build("Own ID", "", "", "", "US");
assert!(Certificate::self_signed(&name, &key, time(2026), 5, &[1]).unwrap().signing_problem().is_none());
}
#[test]
fn a_trusted_signer_whose_certificate_may_not_sign_is_not_valid() {
let r = root();
let (status, details) =
verdict(signer_with("TLS Server", &r, &[key_usage(DIGITAL_SIGNATURE), extended_key_usage(&[SERVER_AUTH])]), vec![], &r.cert);
assert_eq!(status, Status::Unknown, "{details:?}");
assert!(details.iter().any(|d| d.contains("not issued for signing documents")), "{details:?}");
let (status, details) = verdict(signer_with("Encryption Only", &r, &[key_usage(KEY_ENCIPHERMENT)]), vec![], &r.cert);
assert_eq!(status, Status::Unknown, "{details:?}");
assert!(details.iter().any(|d| d.contains("does not allow digital signatures")), "{details:?}");
let (status, details) = verdict(signer_with("Document Signer", &r, &[key_usage(DIGITAL_SIGNATURE)]), vec![], &r.cert);
assert_eq!(status, Status::Valid, "{details:?}");
}
#[test]
fn unreadable_constraint_extensions_fail_closed() {
// keyUsage present but not a BIT STRING: no usage at all, so no keyCertSign.
+2 -2
View File
@@ -6086,8 +6086,8 @@ milestone = "M9"
status = "shipped"
commands = ["sign.validate"]
tools = ["sign_list"]
evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools"]
notes = ""
evidence = ["crates/sign/tests/pdf.rs::signing_then_validating_with_and_without_trust", "crates/automation/tests/automation.rs::digital_ids_signing_and_validation_through_tools", "crates/sign/tests/chain.rs::a_trusted_signer_whose_certificate_may_not_sign_is_not_valid"]
notes = "The signer's key usage, extended key usage and critical extensions are checked; name constraints are not"
[[feature]]
id = "sign.signatures-panel"