diff --git a/crates/sign/README.md b/crates/sign/README.md index c3462fa93..7775f3dfe 100644 --- a/crates/sign/README.md +++ b/crates/sign/README.md @@ -83,6 +83,10 @@ needs a prompt to open is listed and asks at signing (issue #179). attributes are still verified over their own exact bytes (signed attributes written with an indefinite length are re-encoded as DER first, as RFC 5652 §5.4 has them signed). Nesting is bounded, and the details say that the signature was BER. High tag numbers are not read. +- **One content type, one message digest.** A CMS whose signed attributes repeat the content type + or the message digest, or give either of them more or fewer than one value, is not read (RFC + 5652 §11.1, §11.2). Neither is one whose content-type attribute differs from `eContentType`: + the signature covers the attribute but not `eContentType`. - **Later changes are classified narrowly.** Only the catalog's own `/Metadata` stream is the document's XMP, and only objects reached through the `/DSS` keys above are the security store. A `/Type /Metadata` or `/Type /DSS` *label* on any other dictionary or stream (a page's Form diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index f1f17e4d4..37122d9c7 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -80,6 +80,7 @@ impl SignedData { let _version = it.next(); let _digest_algs = it.next(); let encap = it.next().ok_or_else(|| bad("encapContentInfo"))?.children()?; + let encap_type = encap.first().and_then(|t| t.oid().ok()); let content = match encap.get(1) { Some(c) if c.tag == tag::ctx(0) => { // BER signers may split the content into a constructed OCTET STRING. @@ -111,7 +112,16 @@ impl SignedData { } let infos = signer_infos.ok_or_else(|| bad("no signerInfos"))?.children()?; let si = infos.first().ok_or_else(|| bad("no signer"))?; - Ok(SignedData { certificates, signer: parse_signer(si)?, content, quirks }) + let signer = parse_signer(si)?; + // RFC 5652 §11.1: the content-type attribute names the type of the encapsulated content. + // The signature covers the attribute but not `eContentType`, so when they differ, one of + // them was written wrong or changed after signing. + if let (Some(signed), Some(encap)) = (&signer.content_type, &encap_type) + && signed != encap + { + return Err(bad("the signed content type is not the encapsulated content type")); + } + Ok(SignedData { certificates, signer, content, quirks }) } /// The signer's certificate among those carried. @@ -204,10 +214,24 @@ fn parse_signer(si: &Tlv<'_>) -> Result { if let Some(t) = set.first_mut() { *t = tag::SET; } + let (mut seen_type, mut seen_digest) = (false, false); for a in next.children()? { let p = a.children()?; let Some(o) = p.first().and_then(|o| o.oid().ok()) else { continue }; - let value = p.get(1).and_then(|v| v.children().ok()).and_then(|v| v.into_iter().next()); + let values = p.get(1).and_then(|v| v.children().ok()).unwrap_or_default(); + // RFC 5652 §11.1 and §11.2: one content-type and one message-digest attribute, each + // with one value. With a second one, two readers could each take a different value. + let (seen, what) = match o.as_str() { + CONTENT_TYPE => (Some(&mut seen_type), "content type"), + MESSAGE_DIGEST => (Some(&mut seen_digest), "message digest"), + _ => (None, ""), + }; + if let Some(seen) = seen + && (std::mem::replace(seen, true) || values.len() != 1) + { + return Err(bad(&format!("the signed attributes must hold one {what} with one value"))); + } + let value = values.into_iter().next(); match (o.as_str(), value) { (MESSAGE_DIGEST, Some(v)) => message_digest = Some(v.value.to_vec()), (CONTENT_TYPE, Some(v)) => content_type = v.oid().ok(), diff --git a/crates/sign/tests/cms.rs b/crates/sign/tests/cms.rs new file mode 100644 index 000000000..2fa1b42bf --- /dev/null +++ b/crates/sign/tests/cms.rs @@ -0,0 +1,104 @@ +//! What a CMS's signed attributes may hold. RFC 5652 allows one content-type and one +//! message-digest attribute, each with one value, and the content type must be the type of the +//! encapsulated content. +//! +//! These go through `SignedData::parse`, the way a PDF's signature reaches it. Each CMS is built +//! here as the signer builds it, so a case differs from a valid one only where it says. + +use pdfcraft_sign::cms::SignedData; +use pdfcraft_sign::der::{self, tag}; +use pdfcraft_sign::keys::DigestAlg; +use pdfcraft_sign::{SignError, pkcs12}; + +const SIGNED_DATA: &str = "1.2.840.113549.1.7.2"; +const DATA: &str = "1.2.840.113549.1.7.1"; +const TST_INFO: &str = "1.2.840.113549.1.9.16.1.4"; +const CONTENT_TYPE: &str = "1.2.840.113549.1.9.3"; +const MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4"; + +fn identity() -> pkcs12::DigitalId { + let p12 = std::fs::read(format!("{}/tests/data/rsa-aes.p12", env!("CARGO_MANIFEST_DIR"))).unwrap(); + pkcs12::open(&p12, "test").unwrap() +} + +/// A signed attribute: its type and the SET of its values. +fn attribute(oid: &str, values: &[&[u8]]) -> Vec { + der::seq(&[&der::oid(oid), &der::set_of(values)]) +} + +/// A SignedData whose encapsulated content has the type `encap_type` and whose one signer +/// signed the attributes `attrs`. +fn signed_data(id: &pkcs12::DigitalId, encap_type: &str, attrs: &[Vec]) -> Vec { + let alg = DigestAlg::Sha256; + let refs: Vec<&[u8]> = attrs.iter().map(Vec::as_slice).collect(); + let set = der::set_of(&refs); + let signature = id.key.sign(alg, &set).unwrap(); + let mut signed_attrs = set; + signed_attrs[0] = tag::ctx(0); + let cert = &id.certificate; + let sid = der::seq(&[&cert.issuer.raw, &der::uint(&cert.serial)]); + let signer = der::seq(&[&der::int(1), &sid, &alg.algorithm(), &signed_attrs, &id.key.signature_algorithm(alg), &der::octets(&signature)]); + let encap = der::seq(&[&der::oid(encap_type)]); + let signed_data = + der::seq(&[&der::int(1), &der::set_of(&[&alg.algorithm()]), &encap, &der::tlv(tag::ctx(0), &cert.raw), &der::set_of(&[&signer])]); + der::seq(&[&der::oid(SIGNED_DATA), &der::explicit(0, &signed_data)]) +} + +/// Why `SignedData::parse` refuses `cms`. +fn refused(cms: &[u8]) -> String { + match SignedData::parse(cms) { + Err(SignError::Malformed(why)) => why, + other => panic!("expected the CMS to be refused, got {other:?}"), + } +} + +#[test] +fn signed_attributes_hold_one_content_type_and_one_message_digest() { + let id = identity(); + let digest = DigestAlg::Sha256.digest(&[b"document"]); + let (data, tst, md, other_md) = (der::oid(DATA), der::oid(TST_INFO), der::octets(&digest), der::octets(&[0; 32])); + let content_type = attribute(CONTENT_TYPE, &[&data]); + let message_digest = attribute(MESSAGE_DIGEST, &[&md]); + + // As a signer writes them: read, and the signature over them verifies. + let sd = SignedData::parse(&signed_data(&id, DATA, &[content_type.clone(), message_digest.clone()])).unwrap(); + assert_eq!(sd.signer.content_type.as_deref(), Some(DATA)); + assert_eq!(sd.signer.message_digest.as_deref(), Some(digest.as_slice())); + assert!(sd.verify_signature(&id.certificate, &digest).unwrap()); + + // A second attribute of either kind, with the same value or another one, and an attribute + // with two values or none. + for (attrs, what) in [ + (vec![content_type.clone(), content_type.clone(), message_digest.clone()], "content type"), + (vec![content_type.clone(), attribute(CONTENT_TYPE, &[&tst]), message_digest.clone()], "content type"), + (vec![attribute(CONTENT_TYPE, &[&data, &tst]), message_digest.clone()], "content type"), + (vec![attribute(CONTENT_TYPE, &[]), message_digest.clone()], "content type"), + (vec![content_type.clone(), message_digest.clone(), message_digest.clone()], "message digest"), + (vec![content_type.clone(), message_digest.clone(), attribute(MESSAGE_DIGEST, &[&other_md])], "message digest"), + (vec![content_type.clone(), attribute(MESSAGE_DIGEST, &[&md, &other_md])], "message digest"), + (vec![content_type.clone(), attribute(MESSAGE_DIGEST, &[])], "message digest"), + ] { + let why = refused(&signed_data(&id, DATA, &attrs)); + assert!(why.contains(&format!("one {what} with one value")), "{why}"); + } +} + +#[test] +fn the_signed_content_type_is_the_type_of_the_encapsulated_content() { + let id = identity(); + let digest = DigestAlg::Sha256.digest(&[b"document"]); + let message_digest = attribute(MESSAGE_DIGEST, &[&der::octets(&digest)]); + let signed_as = |oid: &str| attribute(CONTENT_TYPE, &[&der::oid(oid)]); + + // A PDF signature names data in both places, and an RFC 3161 token names TSTInfo in both. + SignedData::parse(&signed_data(&id, DATA, &[signed_as(DATA), message_digest.clone()])).unwrap(); + SignedData::parse(&signed_data(&id, TST_INFO, &[signed_as(TST_INFO), message_digest.clone()])).unwrap(); + + // The signature does not cover eContentType: here it was changed from data to signedData + // (the last byte of the OID), and the signed attributes still say data. + let why = refused(&signed_data(&id, SIGNED_DATA, &[signed_as(DATA), message_digest.clone()])); + assert!(why.contains("not the encapsulated content type"), "{why}"); + + // Without a content-type attribute there is nothing to compare here. + SignedData::parse(&signed_data(&id, SIGNED_DATA, std::slice::from_ref(&message_digest))).unwrap(); +}