fix(sign): require the time-stamping purpose for trusted timestamps

A timestamp counted as trusted time whenever its signer's certificate
chained to a trusted certificate, whatever the certificate was issued
for. Any key from a trusted CA could then vouch for an arbitrary time.
Trusted time, for a document timestamp and for a signature's embedded
one, now also needs a certificate whose extended key usage includes
timeStamping, whose key usage allows signing, and which has no critical
extension PdfCraft doesn't process (RFC 3161 §2.3). The test timestamp
authority gets such a certificate.
This commit is contained in:
aiongg
2026-10-22 12:13:16 -04:00
committed by Dylan M. Taylor
parent d9cb7c2fe7
commit 4e9d8f9b3b
5 changed files with 94 additions and 16 deletions
+16 -7
View File
@@ -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 {
+15
View File
@@ -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<String> {
+61 -9
View File
@@ -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<Vec<u8>> = 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<Vec<u8>, 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<u8> {
#[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);
}
+1
View File
@@ -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 ({}).
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -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 ({}).
Can't render this file because it contains an unexpected character in line 6 and column 47.