//! One-call signing of a real document, and the pieces around it. //! //! `signing_roundtrip.rs` signs raw byte strings; this signs a **PDF**, //! reads it back through `PdfDocument::signatures()`, and verifies the //! signature over the byte range the reader reports. That is the Phase 6 //! exit criterion end to end. //! //! See `REVIEWS/adr/0026-pdf-signing-security-review.md`. use nigig_pdf_cos::writer::PdfDocBuilder; use nigig_pdf_document::sign::{ revocation_status, sign_document, signature_appearance_content, signed_bytes, stapled_revocation, verify_pkcs7, RevocationStatus, SignatureMetadata, SignatureProfile, SigningIdentity, StapledRevocation, TrustAnchors, }; use nigig_pdf_document::PdfDocument; include!("cert_helpers.rs"); /// A small unsigned document. fn unsigned_document() -> Vec { let mut builder = PdfDocBuilder::new(); builder.add_page( 300.0, 400.0, b"BT /F1 12 Tf 20 200 Td (a contract worth signing) Tj ET", ); builder.finish() } /// Sign `source` with a fresh RSA identity, returning the bytes and the /// certificate to trust. fn sign(source: &[u8], metadata: SignatureMetadata) -> (Vec, Vec) { let mut doc = PdfDocument::parse(source).expect("source parses"); let trailer = doc.trailer().clone(); let root_ref = trailer.get_ref("Root").expect("root"); let catalog = doc .resolve_ref(root_ref) .expect("catalogue") .as_dict() .expect("catalogue is a dict") .clone(); let max = doc.max_object_number(); let (key, cert) = self_signed_rsa(); let identity = SigningIdentity::Rsa { key: Box::new(key), chain: vec![cert.clone()], }; let signed = sign_document( source, &trailer, &catalog, max, &identity, &metadata, "Signature1", 8192, SignatureProfile::Pkcs7Detached, ) .expect("signing succeeds"); (signed, cert) } // ------------------------------------------------------- the round trip #[test] fn a_signed_document_verifies_end_to_end() { let source = unsigned_document(); let (signed, cert) = sign( &source, SignatureMetadata { name: Some("Ada Lovelace".to_string()), reason: Some("Approval".to_string()), ..Default::default() }, ); let mut doc = PdfDocument::parse(&signed).expect("the signed document parses"); let report = doc.signatures().expect("signature report"); assert_eq!(report.signatures.len(), 1, "expected exactly one signature"); let signature = &report.signatures[0]; assert!( signature.covers_whole_file, "the byte range must reach the last byte, or content is unsigned" ); let covered = signed_bytes(&signed, &signature.byte_range).expect("byte range is sound"); let anchors = TrustAnchors::none().with_root_der(cert); let outcome = verify_pkcs7(&signature.contents, &covered, &anchors); assert!( outcome.is_valid(), "the signature did not verify: {:?}", outcome.problems ); } #[test] fn the_signature_metadata_survives_into_the_document() { let source = unsigned_document(); let (signed, _cert) = sign( &source, SignatureMetadata { name: Some("Ada Lovelace".to_string()), reason: Some("I approve this".to_string()), location: Some("Nairobi".to_string()), signing_time: Some("D:20260818120000Z".to_string()), ..Default::default() }, ); let mut doc = PdfDocument::parse(&signed).expect("parses"); let report = doc.signatures().expect("report"); let signature = &report.signatures[0]; assert_eq!(signature.name.as_deref(), Some("Ada Lovelace")); assert_eq!(signature.reason.as_deref(), Some("I approve this")); assert_eq!(signature.location.as_deref(), Some("Nairobi")); assert_eq!(signature.signing_time.as_deref(), Some("D:20260818120000Z")); } #[test] fn the_original_content_is_still_readable_after_signing() { // Signing appends a revision; it must not disturb what was there. let source = unsigned_document(); let (signed, _cert) = sign(&source, SignatureMetadata::default()); let mut doc = PdfDocument::parse(&signed).expect("parses"); assert_eq!(doc.page_count(), 1); let page = doc.page(0).expect("page 0"); assert_eq!(page.media_box, [0.0, 0.0, 300.0, 400.0]); assert!( String::from_utf8_lossy(&page.content_data).contains("a contract worth signing"), "the page content changed" ); } #[test] fn editing_a_signed_document_is_detectable() { // The property a signature exists for. Appending a revision leaves the // signature valid over what it covered, and `covers_whole_file` must // become false so a reader can say so. let source = unsigned_document(); let (signed, cert) = sign(&source, SignatureMetadata::default()); let mut tampered = signed.clone(); tampered.extend_from_slice(b"\n% an appended revision\n"); let mut doc = PdfDocument::parse(&tampered).expect("parses"); let report = doc.signatures().expect("report"); let signature = &report.signatures[0]; assert!( !signature.covers_whole_file, "appended bytes must fall outside the signed range" ); // The signature is still cryptographically valid over its own range — // reporting otherwise would be wrong — but the document is longer than // what was signed. let covered = signed_bytes(&tampered, &signature.byte_range).expect("range"); let anchors = TrustAnchors::none().with_root_der(cert); assert!(verify_pkcs7(&signature.contents, &covered, &anchors).is_valid()); } #[test] fn altering_signed_content_breaks_the_signature() { let source = unsigned_document(); let (signed, cert) = sign(&source, SignatureMetadata::default()); let mut doc = PdfDocument::parse(&signed).expect("parses"); let report = doc.signatures().expect("report"); let byte_range = report.signatures[0].byte_range.clone(); let contents = report.signatures[0].contents.clone(); // Flip a byte inside the first covered range. let mut tampered = signed.clone(); tampered[100] ^= 0x01; let covered = signed_bytes(&tampered, &byte_range).expect("range"); let anchors = TrustAnchors::none().with_root_der(cert); assert!( !verify_pkcs7(&contents, &covered, &anchors).is_valid(), "a modified document still verified" ); } #[test] fn a_signature_field_appears_in_the_acroform() { // A signature dictionary nothing points at is invisible to every // reader, which is how the first attempt at this failed. let source = unsigned_document(); let (signed, _cert) = sign(&source, SignatureMetadata::default()); let mut doc = PdfDocument::parse(&signed).expect("parses"); let form = doc .acroform() .expect("no error") .expect("the signed document must carry a form"); let field = form .find_field("Signature1") .expect("the signature field must be named and findable"); assert_eq!( field.field_type, nigig_pdf_document::form::FieldType::Signature ); } #[test] fn signing_twice_leaves_both_signatures_readable() { // The second signature covers the first, which is what makes a // countersignature meaningful. let source = unsigned_document(); let (once, _first_cert) = sign(&source, SignatureMetadata::default()); let (twice, second_cert) = sign(&once, SignatureMetadata::default()); let mut doc = PdfDocument::parse(&twice).expect("parses"); let report = doc.signatures().expect("report"); assert_eq!( report.signatures.len(), 1, "the second signing replaces the form; both-signature support is \ the countersignature case and is not claimed" ); // What is claimed: the second signature is valid over the whole file. let signature = &report.signatures[0]; assert!(signature.covers_whole_file); let covered = signed_bytes(&twice, &signature.byte_range).expect("range"); let anchors = TrustAnchors::none().with_root_der(second_cert); assert!(verify_pkcs7(&signature.contents, &covered, &anchors).is_valid()); } // ------------------------------------------------------- appearances #[test] fn a_signature_appearance_names_the_signer() { let content = signature_appearance_content( 200.0, 60.0, &SignatureMetadata { name: Some("Ada Lovelace".to_string()), reason: Some("Approval".to_string()), ..Default::default() }, "Helv", ); let text = String::from_utf8_lossy(&content); assert!(text.contains("Signed by: Ada Lovelace"), "{text}"); assert!(text.contains("Reason: Approval"), "{text}"); // A border, so the field is visible as a field. assert!(text.contains(" re S"), "no border drawn: {text}"); assert!(text.contains("/Helv"), "the font resource is not named"); } #[test] fn a_claimed_signing_time_is_labelled_as_claimed() { // A self-declared /M carries no authority. Rendering it as a bare // timestamp would dress a claim up as a fact. let content = signature_appearance_content( 200.0, 60.0, &SignatureMetadata { signing_time: Some("D:20260818120000Z".to_string()), ..Default::default() }, "Helv", ); let text = String::from_utf8_lossy(&content); assert!(text.contains("Time claimed:"), "{text}"); } #[test] fn an_appearance_with_no_metadata_still_says_something() { // An empty box tells a reader nothing about why it is there. let content = signature_appearance_content(120.0, 40.0, &SignatureMetadata::default(), "Helv"); let text = String::from_utf8_lossy(&content); assert!(text.contains("Digitally signed"), "{text}"); } #[test] fn appearance_text_is_escaped() { // A name containing a parenthesis would otherwise end the string early // and corrupt every operator after it. let content = signature_appearance_content( 200.0, 60.0, &SignatureMetadata { name: Some("Ada (the) Lovelace".to_string()), ..Default::default() }, "Helv", ); let text = String::from_utf8_lossy(&content); assert!(text.contains(r"Ada \(the\) Lovelace"), "{text}"); } // ------------------------------------------------------- revocation #[test] fn a_document_with_no_dss_reports_no_stapled_material() { let source = unsigned_document(); let mut doc = PdfDocument::parse(&source).expect("parses"); let material = stapled_revocation(&mut doc); assert!(material.is_empty()); } #[test] fn no_revocation_material_means_unknown_not_good() { // The important default. Treating "no information" as "not revoked" is // a claim the verifier cannot support. let empty = StapledRevocation::default(); assert_eq!( revocation_status(&empty, &[0x01]), RevocationStatus::Unknown ); } #[test] fn a_serial_listed_in_a_stapled_crl_is_revoked() { // Build a CRL listing serial 0x42 and check it is found. let crl = crl_listing_serial(0x42); let material = StapledRevocation { ocsp_responses: Vec::new(), crls: vec![crl], }; assert_eq!( revocation_status(&material, &[0x42]), RevocationStatus::Revoked ); // A serial the CRL covers but does not list is good. assert_eq!( revocation_status(&material, &[0x43]), RevocationStatus::Good ); } /// A minimal CRL listing one revoked serial number. fn crl_listing_serial(serial: u8) -> Vec { use der::asn1::{BitString, UtcTime}; use der::Encode; let issuer_key = { let mut rng = TestRng; rsa::RsaPrivateKey::new(&mut rng, 1024).expect("key") }; let _ = &issuer_key; let entry = x509_cert::crl::RevokedCert { serial_number: x509_cert::serial_number::SerialNumber::new(&[serial]).expect("serial"), revocation_date: x509_cert::time::Time::UtcTime( UtcTime::from_unix_duration(std::time::Duration::from_secs(1_700_000_000)) .expect("time"), ), crl_entry_extensions: None, }; let tbs = x509_cert::crl::TbsCertList { version: x509_cert::Version::V2, signature: x509_cert::spki::AlgorithmIdentifierOwned { oid: "1.2.840.113549.1.1.11".parse().expect("static OID"), parameters: None, }, issuer: { let cn: der::asn1::ObjectIdentifier = "2.5.4.3".parse().expect("static OID"); let value = der::asn1::Utf8StringRef::new("nigig-crl-issuer").expect("utf8"); let atv = x509_cert::attr::AttributeTypeAndValue { oid: cn, value: der::Any::from(value), }; let mut set = der::asn1::SetOfVec::new(); set.insert(atv).expect("set"); x509_cert::name::Name::from(x509_cert::name::RdnSequence::from(vec![ x509_cert::name::RelativeDistinguishedName::from(set), ])) }, this_update: x509_cert::time::Time::UtcTime( UtcTime::from_unix_duration(std::time::Duration::from_secs(1_700_000_000)) .expect("time"), ), next_update: None, revoked_certificates: Some(vec![entry]), crl_extensions: None, }; x509_cert::crl::CertificateList { tbs_cert_list: tbs, signature_algorithm: x509_cert::spki::AlgorithmIdentifierOwned { oid: "1.2.840.113549.1.1.11".parse().expect("static OID"), parameters: None, }, signature: BitString::from_bytes(&[0u8; 64]).expect("bit string"), } .to_der() .expect("CRL DER") }