M9: validate a document timestamp that sits in a signature field (#621)

* M9: validate a document timestamp that sits in a signature field

Documenso (and others) store the document timestamp as an AcroForm signature
field (`Timestamp_1`) whose /V is a /Type /DocTimeStamp dictionary. Field
values only went through the signature validator, which read the RFC 3161
token as a CMS signature, compared its TSTInfo with the document digest and
reported "altered or corrupted since the signature was applied" for an
untouched file.

- A field value with /SubFilter /ETSI.RFC3161 is validated as a timestamp.
- Standalone timestamps are found with /Type /DocTimeStamp (ISO 32000-2
  12.8.5) as well as the /Type /Sig older writers used; our own
  timestamp_document now writes /DocTimeStamp. The discovery cache (#419)
  selects its candidates with the same test, so it sees both.
- The discovery test that lists a signature field holding a timestamp used to
  assert `doc_timestamp == false`, which is the bug above; it asserts true now.
  What that test is about (the field is listed once, not also as a standalone
  timestamp) is unchanged.

Regression tests build the same structure with a test TSA; no third-party
file is needed, and they fail without the fix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* M9: a trusted TSA whose certificate wasn't valid when it stamped leaves a document timestamp Unknown, never Valid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Thorsten Hirsch 2026-10-10 00:42:37 +02:00 committed by GitHub
parent d90b3f897f
commit 0079bead14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 136 additions and 7 deletions

View File

@ -170,6 +170,8 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- **2026-10-09 (M9, review of #144):** Chain building checks what an issuer may do. `x509::build_chain` linked certificates by name and signature only, so an ordinary subscriber certificate embedded in a PDF, or one in the trusted list, could "issue" a certificate for any identity and chain to a trusted root. An issuer now needs basicConstraints CA:TRUE (an old v1 self-signed root without constraints still anchors), keyCertSign when it has a key usage, a pathLenConstraint that allows the CAs below it, and validity at the signing time (or the trusted timestamp/token time) when known; the signature's details say why an issuer was refused. Negative tests issue certificates in-process and cover each refusal, plus a signed PDF whose signer chains through an end-entity certificate (Unknown, not Valid). Still missing: key usage and extended key usage of the signer, name constraints and unknown critical extensions. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M9):** A document timestamp stored as a signature field (Documenso writes `Timestamp_1` that way, its `/V` a `/Type /DocTimeStamp` dictionary) was read as a CMS signature and reported "altered or corrupted since the signature was applied" on an untouched file. A field value with `/SubFilter /ETSI.RFC3161` is validated as a timestamp now, and standalone timestamps are found by `/DocTimeStamp` (ISO 32000-2 §12.8.5) as well as the `/Type /Sig` older writers used; `timestamp_document` writes `/DocTimeStamp`. Regression tests build the same structure with a test TSA. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%.
- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).

View File

@ -56,7 +56,7 @@ struct TimestampCandidates {
const DISCOVERY_CACHE_LIMIT: usize = 4096;
fn is_document_timestamp(object: &Object) -> bool {
object.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Sig") && d.name(b"SubFilter") == Some(b"ETSI.RFC3161") && d.contains(b"ByteRange"))
object.as_dict().is_some_and(|d| is_doc_timestamp(d) && d.contains(b"ByteRange"))
}
/// Only dictionary/null edits have a local effect on candidate membership. Changes to an
@ -473,7 +473,23 @@ fn unhex(s: &[u8]) -> Option<Vec<u8>> {
digits.chunks(2).map(|p| Some(val(p[0])? << 4 | p.get(1).map_or(Some(0), |c| val(*c))?)).collect()
}
/// A document timestamp dictionary: `/SubFilter /ETSI.RFC3161`, typed `/DocTimeStamp`
/// (ISO 32000-2 §12.8.5) or, as older writers have it, `/Sig`.
fn is_doc_timestamp(d: &Dict) -> bool {
d.name(b"SubFilter") == Some(b"ETSI.RFC3161") && matches!(d.name(b"Type"), Some(b"DocTimeStamp" | b"Sig"))
}
fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, info: &mut SignatureInfo, cache: &DigestCache) {
// A document timestamp can also be the value of a signature field (Documenso writes
// `Timestamp_1` that way). Its `/Contents` is a bare RFC 3161 token, not a signature over the
// document, so it must not be read as one.
if is_doc_timestamp(v) {
info.sub_filter = Some("ETSI.RFC3161".to_string());
info.doc_timestamp = true;
info.timestamp = true;
validate_doc_timestamp(doc, bytes, v, info, cache, trust);
return;
}
info.date = text(doc, v, b"M");
info.reason = text(doc, v, b"Reason");
info.location = text(doc, v, b"Location");
@ -781,11 +797,15 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig
// ordinary signature from an unknown signer.
let mut pool = crate::timestamp::token_certs(&contents);
pool.extend(trust.certs.iter().cloned());
let trusted_tsa =
token.signer_certificate().map(|c| build_chain(&c, &pool, Some(token.gen_time)).iter().any(|x| trust.trusts(x))).unwrap_or(false);
if trusted_tsa {
// 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));
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 {
info.status = Status::Valid;
info.details.push("The timestamp token is valid and its authority is trusted.".into());
} else if trusted_tsa {
info.status = Status::Unknown;
} else {
info.status = Status::Unknown;
info.details.push("The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates.".into());
@ -822,7 +842,7 @@ pub(crate) fn signature_contents(doc: &Document) -> Vec<Vec<u8>> {
}
for (r, o) in doc.scan_objects() {
let Some(d) = o.as_dict() else { continue };
if d.name(b"Type") == Some(b"Sig") && d.name(b"SubFilter") == Some(b"ETSI.RFC3161") {
if is_doc_timestamp(d) {
add(d, Some(r));
}
}
@ -1280,7 +1300,7 @@ pub fn timestamp_document(doc: &Document, tsa: &dyn crate::timestamp::TimestampA
}
let mut doc = doc.clone();
let mut v = Dict::new();
v.set(b"Type".to_vec(), Object::name("Sig"));
v.set(b"Type".to_vec(), Object::name("DocTimeStamp"));
v.set(b"Filter".to_vec(), Object::name("Adobe.PPKLite"));
v.set(b"SubFilter".to_vec(), Object::name("ETSI.RFC3161"));
v.set(b"ByteRange".to_vec(), Object::Array([0].iter().chain(BR_MARK.iter()).map(|n| Object::Int(*n)).collect()));

View File

@ -113,7 +113,9 @@ fn field_membership_is_refreshed_even_when_discovery_is_cached() {
let listed = list_cached(&doc, doc.bytes(), &TrustStore::default(), &cache);
assert_eq!(listed.len(), 1, "field values must not be duplicated as standalone timestamps");
assert_eq!(listed[0].field, "Approval");
assert!(!listed[0].doc_timestamp);
// A field whose value is a document timestamp (Documenso writes `Timestamp_1` so) is still
// checked as a timestamp: its /Contents is a bare RFC 3161 token, not a signature.
assert!(listed[0].doc_timestamp);
doc.update_dict(doc.root().unwrap(), |d| {
d.remove(b"AcroForm");
})

View File

@ -776,3 +776,108 @@ fn signing_with_windows_store_identities() {
assert!(out.status.success(), "test certificate remains: {thumbprint}");
}
}
/// Documenso-style: an AcroForm signature field `Timestamp_1` whose `/V` is the document
/// timestamp dictionary (`/Type /DocTimeStamp`, `/SubFilter /ETSI.RFC3161`).
fn fixture_timestamped_in_a_field(tsa: &TestTsa) -> Vec<u8> {
let mut value_num = 0;
let base = edit_after(&fixture(), |doc| {
let field = doc.add(Object::Null);
// `timestamp_document` adds its dictionary next.
value_num = field.num + 1;
let at = |num| Object::Ref(pdfcraft_cos::ObjRef { num, generation: 0 });
let mut widget = pdfcraft_cos::Dict::new();
for (k, v) in [
("Type", Object::name("Annot")),
("Subtype", Object::name("Widget")),
("FT", Object::name("Sig")),
("T", Object::String(PdfString::text("Timestamp_1"))),
("Rect", Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(0), Object::Int(0)])),
("P", at(3)),
("F", Object::Int(132)),
("V", at(value_num)),
] {
widget.set(k.as_bytes().to_vec(), v);
}
doc.set(field, Object::Dict(widget));
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
if let Some(Object::Dict(form)) = c.get_mut(b"AcroForm")
&& let Some(Object::Array(fields)) = form.get_mut(b"Fields")
{
fields.push(Object::Ref(field));
}
})
.unwrap();
});
let stamped = pdfcraft_sign::timestamp_document(&open(&base), tsa, "D:20261006120000Z").unwrap();
let held = open(&stamped).get(pdfcraft_cos::ObjRef { num: value_num, generation: 0 });
assert_eq!(held.as_dict().and_then(|d| d.name(b"Type")), Some(&b"DocTimeStamp"[..]), "the field holds the timestamp");
stamped
}
#[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(),
time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 },
};
let stamped = fixture_timestamped_in_a_field(&tsa);
let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] };
let all = signatures(&open(&stamped), &stamped, &trust);
let stamps: Vec<_> = all.iter().filter(|s| s.signed).collect();
assert_eq!(stamps.len(), 1, "listed once, not as field and as standalone: {:?}", all.iter().map(|s| &s.field).collect::<Vec<_>>());
let s = stamps[0];
assert_eq!(s.field, "Timestamp_1");
assert!(s.doc_timestamp && s.timestamp);
assert_eq!(s.sub_filter.as_deref(), Some("ETSI.RFC3161"));
assert_eq!(s.timestamp_time, Some(tsa.time));
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
// Without trusting the TSA the stamp is intact but unknown, never "altered".
let s = signatures(&open(&stamped), &stamped, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
assert!(!s.details.iter().any(|d| d.contains("altered or corrupted")), "{:?}", s.details);
// A changed byte in what it covers is still caught, and reported as the timestamp's.
let mut tampered = stamped.clone();
let i = tampered.windows(13).position(|w| w == b"Contract text").unwrap();
tampered[i] = b'K';
let s = signatures(&open(&tampered), &tampered, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid);
assert!(s.details.iter().any(|d| d.contains("since the timestamp was applied")), "{:?}", s.details);
}
/// A trusted timestamp authority whose certificate was not yet valid when it stamped proves
/// nothing: the field timestamp stays Unknown, never Valid.
#[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(),
// 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 },
};
let stamped = fixture_timestamped_in_a_field(&tsa);
let trust = TrustStore { certs: vec![tsa.id.certificate.clone()] };
let s = signatures(&open(&stamped), &stamped, &trust).into_iter().find(|s| s.signed).unwrap();
assert!(s.doc_timestamp);
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
assert!(s.details.iter().any(|d| d.contains("was not valid at the time of timestamping")), "{:?}", s.details);
assert!(!s.details.iter().any(|d| d.contains("its authority is trusted")), "{:?}", s.details);
}
#[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(),
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 s = signatures(&open(&stamped), &stamped, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).unwrap();
assert_eq!(s.status, Status::Unknown, "{:?}", s.details);
// The same bytes with `/Type /Sig` in place of `/Type /DocTimeStamp` (padded to the same
// length): the imprint no longer matches, but it is still found as a timestamp.
let mut old = stamped.clone();
let at = old.windows(19).position(|w| w == b"/Type /DocTimeStamp").unwrap();
old[at..at + 19].copy_from_slice(b"/Type /Sig ");
let s = signatures(&open(&old), &old, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).expect("found as a timestamp");
assert_eq!(s.status, Status::Invalid);
}