diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index ef2715f831..82a801dee8 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -401,6 +401,7 @@ pub const WORDINGS: &[&str] = &[ "The timestamp token could not be read ({}).", "The document has been altered or corrupted since the timestamp was applied.", "The timestamp authority's certificate was not valid at the time of timestamping.", + "The timestamp authority's certificate is not issued for time stamping.", "This document has not been modified since this timestamp was applied.", "The document has been modified since this timestamp was applied, but the changes are permitted ({}).", "The document has been altered since this timestamp was applied in ways it does not permit ({}).", @@ -771,13 +772,16 @@ 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, Some(t.gen_time)).iter().any(|x| trust.trusts(x))); - if trusted_tsa { + let tsa = t.signer_certificate(); + let trusted_tsa = tsa + .as_ref() + .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 { + unverified_time = Some(t.gen_time); + } else if tsa.as_ref().is_some_and(Certificate::may_timestamp) { info.timestamp_time = Some(t.gen_time); } else { - unverified_time = Some(t.gen_time); + info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); } } } @@ -1085,6 +1089,10 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig info.status = Status::Unknown; info.details.push(Detail::plain("The timestamp authority's certificate was not valid at the time of timestamping.")); } + if !cert.may_timestamp() { + info.status = Status::Unknown; + info.details.push(Detail::plain("The timestamp authority's certificate is not issued for time stamping.")); + } } info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) { Modification::None @@ -1112,9 +1120,10 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig pool.extend(trust.certs.iter().cloned()); // A trusted authority whose certificate was not valid at the time it stamped proves // nothing either (its detail is already recorded above): never upgrade that to Valid. - let in_validity = token.signer_certificate().is_some_and(|c| c.valid_at(token.gen_time)); + // Nor does one whose certificate is not issued for time stamping. + let may_stamp = token.signer_certificate().is_some_and(|c| c.valid_at(token.gen_time) && c.may_timestamp()); let trusted_tsa = token.signer_certificate().is_some_and(|c| build_chain(&c, &pool, Some(token.gen_time)).iter().any(|x| trust.trusts(x))); - if trusted_tsa && in_validity { + if trusted_tsa && may_stamp { info.status = Status::Valid; info.details.push(Detail::plain("The timestamp token is valid and its authority is trusted.")); } else if trusted_tsa { diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 56ec8fbddc..5dd03d086d 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -165,6 +165,9 @@ const PROCESSED_EXTENSIONS: [&str; 11] = [ "1.3.6.1.5.5.7.1.3", ]; +/// id-kp-timeStamping (RFC 5280 §4.2.1.12), required of a timestamp authority (RFC 3161 §2.3). +const EKU_TIME_STAMPING: &str = "1.3.6.1.5.5.7.3.8"; + /// Extended key usages that allow signing documents: anyExtendedKeyUsage, emailProtection, /// codeSigning, documentSigning (RFC 9336), Adobe Authentic Documents and Microsoft document /// signing. @@ -384,6 +387,18 @@ impl Certificate { } } + /// Whether this certificate may stamp trusted time (RFC 3161 §2.3): its extended key usage + /// includes timeStamping, its key usage (if any) allows digital signatures or + /// non-repudiation, and it has no critical extension PdfCraft doesn't process. A certificate + /// issued for another purpose proves who signed, not when. + pub fn may_timestamp(&self) -> bool { + /// keyUsage bits 0 (digitalSignature) and 1 (nonRepudiation). + const SIGNING_BITS: u16 = 0b11; + self.extended_key_usage.as_ref().is_some_and(|e| e.iter().any(|o| o == EKU_TIME_STAMPING)) + && self.key_usage.is_none_or(|u| u & SIGNING_BITS != 0) + && self.unknown_critical.is_empty() + } + /// [`signing_problem`](Self::signing_problem) as a clause that follows the certificate's /// name ("it is not issued for signing documents (…)"), for lists of digital IDs. pub fn signing_problem_clause(&self) -> Option { diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 29f61305f8..f7dc33f685 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -384,6 +384,38 @@ struct TestTsa { time: Time, } +/// A timestamp authority's digital ID: a self-signed certificate valid from October 2026 for +/// ten years, whose critical extended key usage is time stamping alone, as RFC 3161 requires. +fn tsa_id() -> DigitalId { + tsa_id_with(&["1.3.6.1.5.5.7.3.8"]) +} + +/// [`tsa_id`] with these extended key usages. +fn tsa_id_with(ekus: &[&str]) -> DigitalId { + let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap(); + let name = Name::build("Test TSA", "", "PdfCraft Tests", "", "US"); + let alg = key.preferred_digest(); + let sig_alg = key.signature_algorithm(alg); + let from = Time { year: 2026, month: 10, day: 2, hour: 0, minute: 0, second: 0 }; + let until = Time { year: 2036, ..from }; + let oids: Vec> = ekus.iter().map(|o| der::oid(o)).collect(); + let oids: Vec<&[u8]> = oids.iter().map(Vec::as_slice).collect(); + let eku = der::seq(&[&der::oid("2.5.29.37"), &der::boolean(true), &der::octets(&der::seq(&oids))]); + let tbs = der::seq(&[ + &der::explicit(0, &der::int(2)), + &der::uint(&[0x31]), + &sig_alg, + &name.raw, + &der::seq(&[&from.encode(), &until.encode()]), + &name.raw, + &key.public_key().spki(), + &der::explicit(3, &der::seq(&[&eku])), + ]); + let signature = key.sign(alg, &tbs).unwrap(); + let certificate = Certificate::parse(&der::seq(&[&tbs, &sig_alg, &der::bit_string(&signature)])).unwrap(); + DigitalId { key, certificate, chain: Vec::new(), friendly_name: None } +} + impl TimestampAuthority for TestTsa { fn timestamp(&self, request: &[u8]) -> Result, SignError> { let q = pdfcraft_sign::timestamp::parse_request(request)?; @@ -404,7 +436,7 @@ impl TimestampAuthority for TestTsa { fn signing_with_a_timestamp_embeds_a_verified_rfc3161_token() { let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); @@ -443,7 +475,7 @@ fn a_malformed_timestamp_response_fails_signing_without_a_file() { #[test] fn a_document_timestamp_covers_the_file_and_validates() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); @@ -480,7 +512,7 @@ fn a_document_timestamp_covers_the_file_and_validates() { #[test] fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let mut source = open(&fixture()); @@ -559,7 +591,7 @@ fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { use pdfcraft_sign::dss::{self, Evidence}; let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); @@ -1532,7 +1564,7 @@ fn an_untrusted_timestamp_does_not_set_the_validation_time() { // The TSA claims 2027 (inside the signer's validity) but is not trusted; the signer's own // clock says 2040, after the certificate expired. let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, }; let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() }; @@ -1754,7 +1786,7 @@ fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec { #[test] fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = fixture_timestamped_in_a_field(&tsa); @@ -1786,7 +1818,7 @@ fn a_document_timestamp_held_by_a_signature_field_is_checked_as_a_timestamp() { #[test] fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), // Before the TSA certificate's validity (it starts in October 2026). time: Time { year: 2020, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, }; @@ -1802,7 +1834,7 @@ fn a_field_timestamp_from_a_trusted_tsa_outside_its_validity_is_not_valid() { #[test] fn a_standalone_timestamp_typed_sig_by_an_older_writer_is_still_a_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); @@ -2036,7 +2068,7 @@ fn signing_waits_for_a_pending_security_change_to_be_saved() { #[test] fn encrypted_documents_take_a_document_timestamp() { let tsa = TestTsa { - id: pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(), + id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 }, }; let base = protected(pdfcraft_cos::Algorithm::Aes128, FILL_AND_SIGN_ONLY); @@ -2495,3 +2527,23 @@ fn switching_the_trailer_to_another_signed_catalog_is_not_permitted() { assert!(matches!(s.modification, Modification::Disallowed(_)), "{:?}", s.modification); assert_eq!(s.status, Status::Invalid, "{:?}", s.details); } + +/// Only a certificate issued for time stamping makes trusted time. A trusted certificate issued +/// for something else (here e-mail) stamps nothing trusted: neither a document timestamp nor +/// a signature's embedded one. +#[test] +fn a_timestamp_from_a_certificate_not_issued_for_time_stamping_is_not_trusted_time() { + let tsa = TestTsa { id: tsa_id_with(&["1.3.6.1.5.5.7.3.4"]), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; + let stamped = pdfcraft_sign::timestamp_document(&open(&fixture()), &tsa, "D:20261006120000Z").unwrap(); + let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] }; + let s = signatures(&open(&stamped), &stamped, &trust).into_iter().find(|s| s.doc_timestamp).unwrap(); + assert_eq!(s.status, Status::Unknown, "{:?}", s.details); + assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details); + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &tsa).unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone(), tsa.id.certificate.clone()] }; + let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap(); + assert!(s.timestamp); + assert_eq!(s.timestamp_time, None, "{:?}", s.details); + assert!(s.details.iter().any(|d| d.message.contains("not issued for time stamping")), "{:?}", s.details); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1d121c59bc..73b0ffbf18 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2379,6 +2379,7 @@ action wizard Start Iniciar The timestamp token could not be read ({}). No se ha podido leer el sello de tiempo ({}). The document has been altered or corrupted since the timestamp was applied. El documento se ha alterado o se ha dañado desde que se aplicó el sello de tiempo. The timestamp authority's certificate was not valid at the time of timestamping. El certificado de la autoridad de sellado de tiempo no era válido en el momento del sellado. + The timestamp authority's certificate is not issued for time stamping. El certificado de la autoridad de sellado de tiempo no está emitido para sellar el tiempo. This document has not been modified since this timestamp was applied. Este documento no se ha modificado desde que se aplicó este sello de tiempo. The document has been modified since this timestamp was applied, but the changes are permitted ({}). El documento se ha modificado desde que se aplicó este sello de tiempo, pero son cambios que permite ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). El documento se ha alterado desde que se aplicó este sello de tiempo de maneras que no permite ({}). diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8176e498bd..6836f3e0a3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2523,6 +2523,7 @@ animation effects Off Wyłączone The timestamp token could not be read ({}). Nie udało się odczytać tokenu znacznika czasu ({}). The document has been altered or corrupted since the timestamp was applied. Dokument został zmieniony lub uszkodzony po dodaniu znacznika czasu. The timestamp authority's certificate was not valid at the time of timestamping. Certyfikat urzędu znacznika czasu nie był ważny w chwili oznaczania czasem. + The timestamp authority's certificate is not issued for time stamping. Certyfikat urzędu znakowania czasem nie jest wystawiony do znakowania czasem. This document has not been modified since this timestamp was applied. Dokument nie był modyfikowany od dodania tego znacznika czasu. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Dokument zmodyfikowano po dodaniu tego znacznika czasu, ale zmiany są dozwolone ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Dokument zmieniono po dodaniu tego znacznika czasu w sposób, na który znacznik nie pozwala ({}).