Signatures: RFC 3161 timestamps, document timestamps, DSS/VRI and revocation verification (non-network half) (#69)

* Signatures: RFC 3161 timestamp tokens (PAdES B-T) behind a transport-free TSA interface

* Signatures: document timestamps, DSS/VRI evidence embedding, trusted verification time

* Signatures: revocation evidence verification (CRL and OCSP) wired into validation

* Signatures: real-world certificate compatibility (CRLDP, tolerant extensions) and XFA-aware DocMDP classification

* Signatures: RFC 6960 encodings, delegated-responder verification, TSA trust anchoring, try_oid overflow

* Signatures: harden PR #69 validation (XFA, metadata, revocation, TSA trust, token digest)

- Remove the XFA "form fill" reclassification: it read the new revision, so
  an update could add /XFA + /NeedsRendering and replace the page tree while
  approval and P=2/3 certification signatures stayed Valid. Classification
  (including catalog /Extensions) is back to main's.
- A changed stream tagged /Type /Metadata counts as metadata only when it is
  the signed catalog's /Metadata stream or was already metadata when signed;
  a relabelled page Form XObject is "other changes".
- A verified revocation stays Invalid when the certificate was also outside
  its validity at signing time (was softened to Unknown).
- An embedded timestamp whose TSA does not chain to the trust store is
  reported as an unverified timestamp and no longer sets the validity or
  revocation time; the signer's claimed time is used, as on main.
- parse_token checks the TSTInfo messageDigest with the SignerInfo digest
  algorithm, not the imprint's.

Each fix has a regression test in crates/sign/tests/pdf.rs that fails on
the PR head.

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

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Justin John 2026-10-08 08:32:40 +05:30 committed by GitHub
parent 355de9038e
commit 4d0078aeff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1877 additions and 67 deletions

View File

@ -67,6 +67,7 @@ impl Automation {
"sub_filter": s.sub_filter,
"algorithm": s.algorithm,
"timestamp": s.timestamp,
"timestamp_time": s.timestamp_time.map(|t| t.to_string()),
"modification": modification,
"changes": changes,
"details": s.details,

View File

@ -2401,6 +2401,40 @@ impl Session {
Ok(Arc::new(pdfcraft_sign::sign(&editor.cos, id, &opts)?))
}
/// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by
/// `tsa`. The transport lives with the caller; the engine never opens a socket.
pub fn sign_with_timestamp(
&self,
doc: DocId,
id: &pdfcraft_sign::DigitalId,
mut opts: SignOptions,
tsa: &dyn pdfcraft_sign::TimestampAuthority,
) -> Result<Arc<Vec<u8>>, EditError> {
let d = self.get(doc).ok_or(EditError::NoDocument)?;
let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?;
if opts.date.is_empty() {
opts.date = self.signing_date();
}
Ok(Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?))
}
/// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's
/// current state. An empty `date` takes the session clock; the transport is the caller's.
pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result<Arc<Vec<u8>>, EditError> {
let d = self.get(doc).ok_or(EditError::NoDocument)?;
let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?;
let date = if date.is_empty() { self.signing_date() } else { date };
Ok(Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?))
}
/// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature
/// (PAdES B-LT): an incremental update that never rewrites signed bytes.
pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result<Arc<Vec<u8>>, EditError> {
let d = self.get(doc).ok_or(EditError::NoDocument)?;
let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?;
Ok(Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?))
}
/// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`],
/// and the edit history before signing is dropped (signing can't be undone).
pub fn mark_signed(&mut self, id: DocId, bytes: Arc<Vec<u8>>, path: Option<String>) -> Result<(), EditError> {

View File

@ -23,14 +23,33 @@ for s in signatures(&doc, &bytes, &trust) { // li
or invisible); certification with DocMDP P=1/2/3. The document is written incrementally
with a zero-filled `/Contents` and fixed-width `/ByteRange`, which are then patched in
place. Encrypted documents are refused for now.
- **Timestamps (PAdES B-T):** the `timestamp` module builds and parses RFC 3161 requests,
responses and TSTInfo tokens (size-capped, imprint- and signature-checked). `sign_with_timestamp`
attaches the TSA's token as an unsigned attribute over the signature value; the transport is
the caller's (`TimestampAuthority` — this crate never opens a socket). Validation verifies
embedded tokens; when the TSA chains to the trust store it reports the trusted time in
`SignatureInfo::timestamp_time`, which then anchors certificate-validity and revocation
checks. An untrusted TSA's time is reported as an unverified timestamp and never used as the
validation time (the signer's claimed time is). `timestamp::respond` is the TSA-side signer behind the
deterministic test authority.
- **Document timestamps and LTV:** `timestamp_document` appends a standalone RFC 3161
document timestamp (`/ETSI.RFC3161`) covering the whole file; validation discovers and
verifies these dictionaries (imprint over the signed bytes, token signature, TSA cert
validity). `dss::embed` merges revocation evidence into the catalog's `/DSS` with `/VRI`
entries keyed per signature (uppercase-hex SHA-1 of `/Contents`), deduplicating
byte-identical blobs — sign → DSS → timestamp makes a B-LTA file, and the change classifier
treats the store as a permitted change. `revocation` parses and verifies RFC 5280 CRLs and
RFC 6960 OCSP responses (responder identity, OCSP-signing EKU for delegated responders,
CertID hash matching, validity windows); validation checks embedded evidence against the
signer's chain and a verified revocation invalidates the signature.
Not yet: revocation fetching (AIA/CRLDP extraction and a fetcher), timestamp-server
configuration, FieldMDP locks, certificate security, OS key stores and PKCS #11 tokens.
- **Validation:** `/ByteRange` and the CMS are read from the file's own bytes; the digest,
the signature value and the signer's chain (against a `TrustStore`) are checked. Later
revisions are diffed against the signed one, and the changes are classified (signing, form
fill, comments, metadata, page content, document structure) under the DocMDP permissions.
The verdict follows Acrobat: valid, unknown (intact but the identity isn't trusted) or invalid.
Not yet: RFC 3161 timestamps, LTV (DSS/VRI, OCSP, CRL), FieldMDP locks, certificate security,
OS key stores and PKCS #11 tokens.
Oracles: poppler's `pdfsig` reports our signatures valid; OpenSSL reads our `.p12` files and
verifies our CMS; `tests/data/openssl-signed.pdf` is a signature OpenSSL made, which we validate.

View File

@ -40,6 +40,8 @@ pub struct SignerInfo {
pub signature: Vec<u8>,
/// An RFC 3161 timestamp token is attached (unsigned attribute).
pub timestamp: bool,
/// The attached token's raw CMS bytes, when present.
pub timestamp_token: Option<Vec<u8>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -168,11 +170,16 @@ fn parse_signer(si: &Tlv<'_>) -> Result<SignerInfo, SignError> {
let (scheme, scheme_digest) = signature_algorithm(&next)?;
let signature = it.next().ok_or_else(|| bad("signature"))?.expect(tag::OCTET_STRING, "signature")?.value.to_vec();
let mut timestamp = false;
let mut timestamp_token = None;
for t in it {
if t.tag == tag::ctx(1) {
for a in t.children()? {
if a.children()?.first().and_then(|o| o.oid().ok()).as_deref() == Some(TIMESTAMP_TOKEN) {
let kids = a.children()?;
if kids.first().and_then(|o| o.oid().ok()).as_deref() == Some(TIMESTAMP_TOKEN) {
timestamp = true;
// The value is a SET OF ContentInfo: the token's own encoding.
let set = kids.get(1).and_then(|s| s.children().ok());
timestamp_token = set.and_then(|c| c.first().copied()).map(|ci| ci.raw.to_vec());
}
}
}
@ -189,9 +196,42 @@ fn parse_signer(si: &Tlv<'_>) -> Result<SignerInfo, SignError> {
scheme_digest,
signature,
timestamp,
timestamp_token,
})
}
/// Attach an RFC 3161 `signatureTimeStampToken` unsigned attribute to a detached CMS object.
/// The signed attributes and signature value are preserved byte-for-byte.
pub fn attach_timestamp_token(cms: &[u8], token: &[u8]) -> Result<Vec<u8>, SignError> {
let (content_info, _) = Tlv::parse(cms)?;
let ci = content_info.expect(tag::SEQUENCE, "ContentInfo")?.children()?;
let [content_type, wrapped] = ci.as_slice() else { return Err(bad("ContentInfo")) };
if content_type.oid()? != SIGNED_DATA {
return Err(bad("not SignedData"));
}
let signed_data = wrapped.expect(tag::ctx(0), "content")?.inner()?;
let mut sd = signed_data.children()?;
// signerInfos is the last field of SignedData (RFC 5652 §5.1); searching from the end
// skips the SET OF digest algorithms.
let signer_index = sd.iter().rposition(|t| t.tag == tag::SET).ok_or_else(|| bad("no signerInfos"))?;
let signer_set = sd[signer_index];
let signer = signer_set.children()?.first().copied().ok_or_else(|| bad("no signer"))?;
let mut signer_parts: Vec<Vec<u8>> = signer.children()?.into_iter().map(|t| t.raw.to_vec()).collect();
if signer_parts.len() < 6 {
return Err(bad("malformed signerInfo"));
}
let attr = der::seq(&[&der::oid(TIMESTAMP_TOKEN), &der::set_of(&[token])]);
let unsigned = der::tlv(tag::ctx(1), &attr);
signer_parts.push(unsigned);
let signer_refs: Vec<&[u8]> = signer_parts.iter().map(Vec::as_slice).collect();
let new_signer = der::seq(&signer_refs);
let new_signer_set = der::set_of(&[&new_signer]);
sd[signer_index] = Tlv::parse_all(&new_signer_set)?;
let sd_refs: Vec<&[u8]> = sd.iter().map(|t| t.raw).collect();
let new_sd = der::seq(&sd_refs);
let wrapped_new = der::explicit(0, &new_sd);
Ok(der::seq(&[&der::oid(SIGNED_DATA), &wrapped_new]))
}
fn attribute(o: &str, value: &[u8]) -> Vec<u8> {
der::seq(&[&der::oid(o), &der::set_of(&[value])])
}
@ -205,11 +245,39 @@ pub fn sign_detached(
chain: &[Certificate],
alg: DigestAlg,
content_digest: &[u8],
) -> Result<Vec<u8>, SignError> {
build(key, cert, chain, alg, DATA, None, content_digest)
}
/// A CMS SignedData that encapsulates `content` under `content_type` (an RFC 3161 token wraps
/// its TSTInfo this way).
pub fn sign_encapsulated(
key: &PrivateKey,
cert: &Certificate,
chain: &[Certificate],
alg: DigestAlg,
content_type: &str,
content: &[u8],
) -> Result<Vec<u8>, SignError> {
build(key, cert, chain, alg, content_type, Some(content), &alg.digest(&[content]))
}
// The parameters mirror the SignedData fields being assembled.
#[allow(clippy::too_many_arguments)]
fn build(
key: &PrivateKey,
cert: &Certificate,
chain: &[Certificate],
alg: DigestAlg,
content_type: &str,
content: Option<&[u8]>,
content_digest: &[u8],
) -> Result<Vec<u8>, SignError> {
let cert_hash = DigestAlg::Sha256.digest(&[&cert.raw]);
let general_names = der::seq(&[&der::explicit(4, &cert.issuer.raw)]);
let ess = der::seq(&[&der::seq(&[&der::seq(&[&der::octets(&cert_hash), &der::seq(&[&general_names, &der::uint(&cert.serial)])])])]);
let attrs = [attribute(CONTENT_TYPE, &der::oid(DATA)), attribute(MESSAGE_DIGEST, &der::octets(content_digest)), attribute(SIGNING_CERT_V2, &ess)];
let attrs =
[attribute(CONTENT_TYPE, &der::oid(content_type)), attribute(MESSAGE_DIGEST, &der::octets(content_digest)), attribute(SIGNING_CERT_V2, &ess)];
let refs: Vec<&[u8]> = attrs.iter().map(Vec::as_slice).collect();
let set = der::set_of(&refs);
let signature = key.sign(alg, &set)?;
@ -219,12 +287,11 @@ pub fn sign_detached(
let signer = der::seq(&[&der::int(1), &sid, &alg.algorithm(), &signed_attrs, &key.signature_algorithm(alg), &der::octets(&signature)]);
let mut certs: Vec<&[u8]> = vec![&cert.raw];
certs.extend(chain.iter().filter(|c| c.raw != cert.raw).map(|c| c.raw.as_slice()));
let signed_data = der::seq(&[
&der::int(1),
&der::set_of(&[&alg.algorithm()]),
&der::seq(&[&der::oid(DATA)]),
&der::tlv(tag::ctx(0), &certs.concat()),
&der::set_of(&[&signer]),
]);
let encap = match content {
Some(c) => der::seq(&[&der::oid(content_type), &der::explicit(0, &der::octets(c))]),
None => der::seq(&[&der::oid(content_type)]),
};
let signed_data =
der::seq(&[&der::int(1), &der::set_of(&[&alg.algorithm()]), &encap, &der::tlv(tag::ctx(0), &certs.concat()), &der::set_of(&[&signer])]);
Ok(der::seq(&[&der::oid(SIGNED_DATA), &der::explicit(0, &signed_data)]))
}

View File

@ -336,7 +336,33 @@ pub fn oid(dotted: &str) -> Vec<u8> {
tlv(tag::OID, &body)
}
/// An unsigned big-endian integer.
/// Encode a dotted OID supplied by a caller. Unlike [`oid`], this validates every arc and
/// never panics on malformed input.
pub fn try_oid(dotted: &str) -> Result<Vec<u8>, SignError> {
let mut arcs = dotted.split('.').map(|a| a.parse::<u64>().map_err(|_| bad("invalid OID arc")));
let first = arcs.next().ok_or_else(|| bad("empty OID"))??;
let second = arcs.next().ok_or_else(|| bad("OID needs two arcs"))??;
if first > 2 || (first < 2 && second >= 40) {
return Err(bad("invalid OID prefix"));
}
let mut body = Vec::new();
let mut push = |mut v: u64| {
let mut tmp = vec![(v & 0x7F) as u8];
v >>= 7;
while v > 0 {
tmp.push(0x80 | (v & 0x7F) as u8);
v >>= 7;
}
tmp.reverse();
body.extend(tmp);
};
let base = first.checked_mul(40).and_then(|b| b.checked_add(second)).ok_or_else(|| bad("invalid OID prefix"))?;
push(base);
for arc in arcs {
push(arc?);
}
Ok(tlv(tag::OID, &body))
}
pub fn uint(bytes: &[u8]) -> Vec<u8> {
let b: &[u8] = match bytes.iter().position(|x| *x != 0) {
Some(i) => &bytes[i..],

121
crates/sign/src/dss.rs Normal file
View File

@ -0,0 +1,121 @@
//! The Document Security Store (ISO 32000-2 §12.8.2.3, formerly A.6.5): a `/DSS` dictionary in
//! the catalog holds certificates and revocation evidence (OCSP responses, CRLs) so signatures
//! stay verifiable offline (PAdES B-LT), and `/VRI` indexes the evidence per signature.
//!
//! Evidence is caller-supplied DER for now: validating OCSP responses and CRLs themselves is
//! the remaining piece of the LTV plan. Embedding never rewrites signed bytes — it appends an
//! incremental update, which the change classifier treats as a permitted "document security
//! store" change.
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
use crate::SignError;
/// Revocation evidence to embed, as DER blobs.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Evidence {
pub certs: Vec<Vec<u8>>,
pub ocsps: Vec<Vec<u8>>,
pub crls: Vec<Vec<u8>>,
}
/// Uppercase-hex SHA-1 of a signature's `/Contents` — the `/VRI` key convention.
fn vri_key(contents: &[u8]) -> String {
crate::keys::DigestAlg::Sha1.digest(&[contents]).iter().map(|b| format!("{b:02X}")).collect()
}
/// Merge `evidence` into the catalog's `/DSS` (deduplicating byte-identical blobs against any
/// existing store), add `/VRI` entries for every signature in the file, and return the file
/// written incrementally.
pub fn embed(doc: &Document, evidence: &Evidence) -> Result<Vec<u8>, SignError> {
let mut doc = doc.clone();
let root = doc.root().ok_or_else(|| SignError::Pdf("the document has no catalog".into()))?;
let existing = doc.get(root).as_dict().and_then(|c| c.get(b"DSS").cloned()).map(|d| doc.resolve(&d)).and_then(|d| d.as_dict().cloned());
// What is already in the store, so the update only appends new bytes.
let mut certs: Vec<Vec<u8>> = Vec::new();
let mut ocsps: Vec<Vec<u8>> = Vec::new();
let mut crls: Vec<Vec<u8>> = Vec::new();
if let Some(d) = &existing {
pull(&doc, d, b"Certs", &mut certs)?;
pull(&doc, d, b"OCSPs", &mut ocsps)?;
pull(&doc, d, b"CRLs", &mut crls)?;
}
let add = |list: &mut Vec<Vec<u8>>, blob: &[u8]| {
if !list.iter().any(|b| b.as_slice() == blob) {
list.push(blob.to_vec());
}
};
for c in &evidence.certs {
add(&mut certs, c);
}
for o in &evidence.ocsps {
add(&mut ocsps, o);
}
for c in &evidence.crls {
add(&mut crls, c);
}
if certs.is_empty() && ocsps.is_empty() && crls.is_empty() {
return Err(SignError::Pdf("no evidence to embed".into()));
}
// New objects for everything we are adding; VRI points at the union.
let cert_refs: Vec<Object> = certs.iter().map(|c| Object::Ref(embed_stream(&mut doc, c))).collect();
let ocsp_refs: Vec<Object> = ocsps.iter().map(|o| Object::Ref(embed_stream(&mut doc, o))).collect();
let crl_refs: Vec<Object> = crls.iter().map(|c| Object::Ref(embed_stream(&mut doc, c))).collect();
let mut dss = Dict::new();
dss.set(b"Type".to_vec(), Object::name("DSS"));
dss.set(b"Filter".to_vec(), Object::name("Adobe.PPKLite"));
dss.set(b"Certs".to_vec(), Object::Ref(doc.add(Object::Array(cert_refs.clone()))));
if !ocsp_refs.is_empty() {
dss.set(b"OCSPs".to_vec(), Object::Ref(doc.add(Object::Array(ocsp_refs.clone()))));
}
if !crl_refs.is_empty() {
dss.set(b"CRLs".to_vec(), Object::Ref(doc.add(Object::Array(crl_refs.clone()))));
}
// Keep any existing /VRI entries, then cover every signature in the file.
let mut vri =
existing.as_ref().and_then(|d| d.get(b"VRI").cloned()).map(|v| doc.resolve(&v)).and_then(|v| v.as_dict().cloned()).unwrap_or_default();
for contents in crate::pdf::signature_contents(&doc) {
let mut e = Dict::new();
e.set(b"Type".to_vec(), Object::name("VRI"));
e.set(b"Cert".to_vec(), Object::Array(cert_refs.clone()));
if !ocsp_refs.is_empty() {
e.set(b"OCSP".to_vec(), Object::Array(ocsp_refs.clone()));
}
if !crl_refs.is_empty() {
e.set(b"CRL".to_vec(), Object::Array(crl_refs.clone()));
}
vri.set(vri_key(&contents).into_bytes(), Object::Dict(e));
}
if !vri.is_empty() {
dss.set(b"VRI".to_vec(), Object::Dict(vri));
}
let dss_ref = doc.add(Object::Dict(dss));
doc.update_dict(root, |c| c.set(b"DSS".to_vec(), Object::Ref(dss_ref)))?;
Ok(pdfcraft_cos::write_incremental(&doc, &SaveOptions::default())?)
}
/// Store one DER blob as a `/Type /Embed` stream.
fn embed_stream(doc: &mut Document, blob: &[u8]) -> pdfcraft_cos::ObjRef {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Embed"));
doc.add(Object::Stream(Stream::from_raw(d, blob.to_vec())))
}
/// Pull the raw bytes of an existing DSS array into `into`.
fn pull(doc: &Document, dss: &Dict, key: &[u8], into: &mut Vec<Vec<u8>>) -> Result<(), SignError> {
let Some(a) = dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) else {
return Ok(());
};
for item in a {
let o = doc.resolve(&item);
let Object::Stream(s) = &*o else { continue };
if let Ok(bytes) = s.decoded()
&& !into.iter().any(|b| b == &bytes)
{
into.push(bytes);
}
}
Ok(())
}

View File

@ -138,6 +138,15 @@ pub enum PublicKey {
}
impl PublicKey {
/// The `subjectPublicKey` BIT STRING contents — what OCSP's `issuerKeyHash` covers
/// (RFC 6960 §4.1.1). RSA keys are re-encoded; EC points are the stored bytes.
pub fn key_bits(&self) -> Vec<u8> {
match self {
PublicKey::Rsa { n, e } => der::seq(&[&der::uint(n), &der::uint(e)]),
PublicKey::P256(bits) | PublicKey::P384(bits) => bits.clone(),
}
}
pub fn from_spki(spki: &Tlv<'_>) -> Result<PublicKey, SignError> {
let parts = spki.children()?;
let [alg, key] = parts.as_slice() else { return Err(SignError::Malformed("SubjectPublicKeyInfo".into())) };

View File

@ -9,17 +9,24 @@
pub mod cms;
pub mod der;
pub mod dss;
#[cfg(target_os = "macos")]
pub mod keychain;
pub mod keys;
pub mod pdf;
pub mod pkcs12;
pub mod revocation;
pub mod timestamp;
pub mod x509;
pub use der::Time;
pub use keys::{DigestAlg, PrivateKey, PublicKey};
pub use pdf::{Appearance, DigestCache, Modification, SignOptions, SignatureInfo, Status, TrustStore, list as signatures, sign, validate};
pub use pdf::{
Appearance, DigestCache, Modification, SignOptions, SignatureInfo, Status, TrustStore, list as signatures, sign, sign_with_timestamp,
timestamp_document, validate,
};
pub use pkcs12::DigitalId;
pub use timestamp::{TimestampAuthority, TimestampQuery, TimestampToken};
pub use x509::{Certificate, Name};
#[derive(Debug, thiserror::Error)]

View File

@ -142,15 +142,22 @@ pub struct SignatureInfo {
pub location: Option<String>,
pub contact: Option<String>,
pub sub_filter: Option<String>,
/// True for a standalone document timestamp (`/ETSI.RFC3161`), not a field signature.
pub doc_timestamp: bool,
/// DocMDP permissions when this is a certification signature (1–3).
pub certify: Option<u8>,
/// 1-based revision the signature covers.
pub revision: usize,
/// Length of the signed revision (bytes) — "View signed version".
pub signed_len: usize,
/// Digest algorithm used for the document's signed bytes.
pub digest: Option<DigestAlg>,
pub algorithm: Option<String>,
pub timestamp: bool,
/// RFC 3161 generation time, when the token was validated and (for a signature's embedded
/// token) its authority chains to the trust store. An untrusted token's time is never
/// used as the validation time.
pub timestamp_time: Option<Time>,
pub status: Status,
pub modification: Modification,
/// Acrobat-style sentences explaining the verdict.
@ -258,7 +265,11 @@ pub fn list(doc: &Document, bytes: &[u8], trust: &TrustStore) -> Vec<SignatureIn
pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &DigestCache) -> Vec<SignatureInfo> {
let pages = annot_pages(doc);
let mut out = Vec::new();
let mut field_values: HashSet<ObjRef> = HashSet::new();
for f in sig_fields(doc) {
if let Some(v_ref) = f.dict.get(b"V").and_then(Object::as_ref) {
field_values.insert(v_ref);
}
let widget = f.widgets.first();
let page = widget.and_then(|(r, w)| r.and_then(|r| pages.get(&r).copied()).or_else(|| w.reference(b"P").and_then(|p| page_index(doc, p))));
let rect = widget
@ -282,12 +293,14 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig
location: None,
contact: None,
sub_filter: None,
doc_timestamp: false,
certify: None,
revision: 0,
signed_len: 0,
digest: None,
algorithm: None,
timestamp: false,
timestamp_time: None,
status: Status::Unknown,
modification: Modification::None,
details: Vec::new(),
@ -297,6 +310,47 @@ pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &Dig
}
out.push(info);
}
// Standalone document timestamps (ISO 32000-2 §12.8.2.2) live outside AcroForm fields.
for num in doc.object_numbers() {
let r = ObjRef { num, generation: doc.generation(num) };
if field_values.contains(&r) {
continue;
}
let o = doc.get(r);
let Some(d) = o.as_dict() else { continue };
if d.name(b"Type") != Some(b"Sig") || d.name(b"SubFilter") != Some(b"ETSI.RFC3161") || !d.contains(b"ByteRange") {
continue;
}
let mut info = SignatureInfo {
field: "DocumentTimestamp".to_string(),
signed: true,
page: None,
rect: None,
visible: false,
signer: None,
certificate: None,
chain: Vec::new(),
date: None,
signing_time: None,
reason: None,
location: None,
contact: None,
sub_filter: Some("ETSI.RFC3161".to_string()),
doc_timestamp: true,
certify: None,
revision: 0,
signed_len: 0,
digest: None,
algorithm: None,
timestamp: true,
timestamp_time: None,
status: Status::Unknown,
modification: Modification::None,
details: Vec::new(),
};
validate_doc_timestamp(doc, bytes, d, &mut info, cache, trust);
out.push(info);
}
out
}
@ -365,6 +419,31 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
let s = &sd.signer;
info.digest = Some(s.digest);
info.timestamp = s.timestamp;
let mut unverified_time = None;
if let Some(raw) = &s.timestamp_token {
match crate::timestamp::parse_token(raw) {
Ok(t) => {
let imprint = t.digest.digest(&[&s.signature]);
if imprint != t.imprint {
info.details.push("The signature's timestamp token does not cover the signature value.".into());
} else {
// Only a token from a trusted authority (valid when it stamped) is trusted
// time; otherwise its time is reported but validation uses the signer's
// 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).iter().any(|x| trust.trusts(x)));
if trusted_tsa {
info.timestamp_time = Some(t.gen_time);
} else {
unverified_time = Some(t.gen_time);
}
}
}
Err(e) => info.details.push(format!("The signature's timestamp token could not be verified ({e}).")),
}
}
// The digest the signature commits to.
let content_digest = match &sd.content {
// adbe.pkcs7.sha1: the document's SHA-1 digest is the signed content.
@ -397,6 +476,11 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
let trusted = chain.iter().any(|c| trust.trusts(c));
info.chain = chain;
info.certificate = Some(cert.clone());
// Revocation evidence embedded in the document security store, if any. A verified
// revocation is final: later blocks must not soften the verdict.
let at = info.timestamp_time.or(info.signing_time);
let signer = info.chain.first().cloned();
let revoked = check_dss_revocation(doc, signer.as_ref(), &at, info);
// Changes after signing.
info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) {
Modification::None
@ -416,11 +500,12 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
problems = true;
}
}
if let Some(t) = info.signing_time
if let Some(t) = info.timestamp_time.or(info.signing_time)
&& !cert.valid_at(t)
{
info.details.push("The signer's certificate was not valid at the time of signing.".into());
if !problems {
// A verified revocation already made the signature invalid; keep it that way.
if !problems && !revoked {
info.status = Status::Unknown;
}
problems = true;
@ -430,12 +515,12 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
"The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates."
.into(),
);
if !problems {
if !problems && !revoked {
info.status = Status::Unknown;
}
} else {
info.details.push("The signer's identity is valid.".into());
if !problems {
if !problems && !revoked {
info.status = Status::Valid;
}
}
@ -444,6 +529,193 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
} else {
info.details.push("Signing time is from the clock on the signer's computer.".into());
}
if let Some(t) = info.timestamp_time {
info.details.push(format!("The embedded timestamp token is valid and its authority is trusted; trusted time is {t}."));
} else if let Some(t) = unverified_time {
info.details.push(format!(
"The signature carries an unverified timestamp ({t}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead."
));
}
}
/// Check the revocation evidence in the catalog's `/DSS` against the signer's chain at `at`.
/// Only evidence whose signature verifies against the issuer counts; returns whether the
/// certificate is revoked (which invalidates the signature).
fn check_dss_revocation(doc: &Document, signer: Option<&Certificate>, at: &Option<Time>, info: &mut SignatureInfo) -> bool {
use crate::revocation::{CertificateList, OcspResponse, RevocationStatus};
let (Some(signer), Some(at)) = (signer, *at) else { return false };
let Some(root) = doc.root() else { return false };
let Some(dss) = doc.get(root).as_dict().and_then(|c| c.get(b"DSS").cloned()).map(|d| doc.resolve(&d)).and_then(|d| d.as_dict().cloned()) else {
return false;
};
let pull = |key: &[u8]| -> Vec<Vec<u8>> {
let mut out = Vec::new();
for item in dss.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() {
let o = doc.resolve(&item);
if let pdfcraft_cos::Object::Stream(s) = &*o
&& let Ok(bytes) = s.decoded()
{
out.push(bytes);
}
}
out
};
let issuer = info.chain.get(1).unwrap_or(signer);
// A verified revocation wins; "good" from any covering evidence is reported, but never
// contradicts a revocation.
let mut revoked = None;
let mut covered = false;
let mut sources = Vec::new();
for r in pull(b"CRLs") {
let Ok(list) = CertificateList::parse(&r) else { continue };
sources.push("CRL");
match list.check(signer, issuer, at) {
RevocationStatus::Revoked { at } => {
revoked = Some(at);
break;
}
RevocationStatus::Good => covered = true,
RevocationStatus::Unknown => {}
}
}
if revoked.is_none() {
for r in pull(b"OCSPs") {
let Ok(resp) = OcspResponse::parse(&r) else { continue };
sources.push("OCSP");
match resp.check(signer, issuer, at) {
RevocationStatus::Revoked { at } => {
revoked = Some(at);
break;
}
RevocationStatus::Good => covered = true,
RevocationStatus::Unknown => {}
}
}
}
match revoked {
Some(at) => {
info.status = Status::Invalid;
info.details.push(format!("The signer's certificate has been revoked ({}, {}).", sources.join(", "), at));
true
}
None if covered => {
info.details.push("The document's embedded revocation information shows that the signer's certificate has not been revoked.".into());
false
}
None => false,
}
}
/// Validate a standalone document timestamp dictionary (`/ETSI.RFC3161`): the token must be
/// cryptographically valid and its message imprint must cover the file's signed bytes.
fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut SignatureInfo, cache: &DigestCache, trust: &TrustStore) {
info.date = text(doc, v, b"M");
let invalid = |info: &mut SignatureInfo, why: &str| {
info.status = Status::Invalid;
info.details.push(why.to_string());
};
let Some(br) = nums(doc, v, b"ByteRange").filter(|b| b.len() == 4 && b.iter().all(|x| *x >= 0.0)) else {
return invalid(info, "The timestamp has no valid byte range.");
};
let [o0, l0, o1, l1] = [br[0] as usize, br[1] as usize, br[2] as usize, br[3] as usize];
if o0 != 0 || o1 < l0 || o1.checked_add(l1).is_none_or(|end| end > bytes.len()) || o1 - l0 < 2 {
return invalid(info, "The timestamp's byte range does not match the file.");
}
let gap = &bytes[l0..o1];
if gap.first() != Some(&b'<') || gap.last() != Some(&b'>') {
return invalid(info, "The timestamp's byte range does not exclude exactly its contents.");
}
let Some(contents) = unhex(&gap[1..gap.len() - 1]) else { return invalid(info, "The timestamp contents are not hexadecimal.") };
let covered = o1 + l1;
info.signed_len = covered;
info.revision = cache.revision(bytes, covered).map_or(1, |d| d.revisions().len().max(1));
let token = match crate::timestamp::parse_token(&contents) {
Ok(t) => t,
Err(e) => return invalid(info, &format!("The timestamp token could not be read ({e}).")),
};
info.digest = Some(token.digest);
info.timestamp_time = Some(token.gen_time);
info.algorithm = Some(format!("RFC 3161 timestamp ({})", token.digest.name()));
let doc_digest = token.digest.digest(&[&bytes[..l0], &bytes[o1..covered]]);
if doc_digest != token.imprint {
return invalid(info, "The document has been altered or corrupted since the timestamp was applied.");
}
if let Some(cert) = token.signer_certificate() {
info.signer = Some(cert.display_name());
info.certificate = Some(cert.clone());
if !cert.valid_at(token.gen_time) {
info.status = Status::Unknown;
info.details.push("The timestamp authority's certificate was not valid at the time of timestamping.".into());
}
}
info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) {
Modification::None
} else {
classify_changes(doc, cache.revision(bytes, covered), None)
};
match &info.modification {
Modification::None => info.details.push("This document has not been modified since this timestamp was applied.".into()),
Modification::Allowed(kinds) => info
.details
.push(format!("The document has been modified since this timestamp was applied, but the changes are permitted ({}).", kinds.join(", "))),
Modification::Disallowed(kinds) => {
info.status = Status::Invalid;
info.details
.push(format!("The document has been altered since this timestamp was applied in ways it does not permit ({}).", kinds.join(", ")));
}
}
if info.status != Status::Invalid {
// An untrusted TSA is not proof of anything: the verdict stays Unknown, like an
// 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).iter().any(|x| trust.trusts(x))).unwrap_or(false);
if trusted_tsa {
info.status = Status::Valid;
info.details.push("The timestamp token is valid and its authority is trusted.".into());
} 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());
}
}
}
/// The DER `/Contents` of every signature dictionary in the file (field signatures and
/// document timestamps), for `/VRI` keys and evidence selection.
pub(crate) fn signature_contents(doc: &Document) -> Vec<Vec<u8>> {
let mut out = Vec::new();
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut add = |d: &Dict, r: Option<ObjRef>| {
if let Some(r) = r
&& !seen.insert(r)
{
return;
}
let contents = d.get(b"Contents").map(|c| doc.resolve(c));
let Some(s) = contents.as_deref().and_then(Object::as_string) else { return };
// Hex strings decode to the DER when parsed; a literal string carries hex text.
let der = if s.hex { Some(s.bytes.clone()) } else { unhex(&s.bytes) };
if let Some(der) = der {
out.push(der);
}
};
for f in sig_fields(doc) {
if let Some(v) = f.dict.get(b"V") {
let d = doc.resolve(v);
if let Some(dd) = d.as_dict() {
add(dd, v.as_ref());
}
}
}
for num in doc.object_numbers() {
let r = ObjRef { num, generation: doc.generation(num) };
let o = doc.get(r);
let Some(d) = o.as_dict() else { continue };
if d.name(b"Type") == Some(b"Sig") && d.name(b"SubFilter") == Some(b"ETSI.RFC3161") {
add(d, Some(r));
}
}
out
}
/// What later revisions changed, classified as Acrobat reports it, under DocMDP `p` (or none:
@ -467,6 +739,8 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
}
})
.collect();
// The signed revision's document-level XMP stream.
let old_metadata = old.root().and_then(|root| old.get(root).as_dict().and_then(|c| c.get(b"Metadata").and_then(Object::as_ref)));
let (mut allowed, mut disallowed): (Vec<&str>, Vec<&str>) = (Vec::new(), Vec::new());
// Stored at the same place in both (and not edited since): the same bytes, unchanged.
let same_place = |num: u32| -> bool {
@ -499,6 +773,13 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
"page content"
} else if s.dict.name(b"Type") == Some(b"XRef") || s.dict.name(b"Type") == Some(b"ObjStm") || before.is_none() {
continue;
} else if s.dict.name(b"Type") == Some(b"Metadata")
&& (Some(r) == old_metadata || matches!(before.as_deref(), Some(Object::Stream(b)) if b.dict.name(b"Type") == Some(b"Metadata")))
{
// Only the signed catalog's XMP stream, or a stream that was already
// metadata when signed: a later /Type /Metadata label on anything else
// (a page's Form XObject) does not make rewriting it a metadata change.
"metadata"
} else {
"other changes"
}
@ -683,13 +964,39 @@ const BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333];
/// Sign `doc` with `id`; returns the signed file (an incremental update when possible).
pub fn sign(doc: &Document, id: &DigitalId, opts: &SignOptions) -> Result<Vec<u8>, SignError> {
sign_inner(doc, id, opts, None)
}
/// Sign and embed an RFC 3161 signature timestamp from `tsa` (PAdES B-T). The transport is the
/// caller's: this crate never opens a socket, and a rejected or malformed token fails the
/// signing before any file is produced.
pub fn sign_with_timestamp(
doc: &Document,
id: &DigitalId,
opts: &SignOptions,
tsa: &dyn crate::timestamp::TimestampAuthority,
) -> Result<Vec<u8>, SignError> {
sign_inner(doc, id, opts, Some(tsa))
}
/// Extra `/Contents` headroom for an attached RFC 3161 token (a TSA response with its
/// certificate chain is typically 4–8 KiB; the token is also size-capped on parsing).
const TOKEN_RESERVE: usize = 16 * 1024;
fn sign_inner(
doc: &Document,
id: &DigitalId,
opts: &SignOptions,
tsa: Option<&dyn crate::timestamp::TimestampAuthority>,
) -> Result<Vec<u8>, SignError> {
if doc.security().is_some() || doc.output_handler().is_some() {
return Err(SignError::Unsupported("signing encrypted documents".into()));
}
let mut doc = doc.clone();
let root = doc.root().ok_or_else(|| SignError::Pdf("the document has no catalog".into()))?;
let alg = id.key.preferred_digest();
let reserve = 8192 + id.certificate.raw.len() + id.chain.iter().map(|c| c.raw.len()).sum::<usize>();
let reserve =
8192 + id.certificate.raw.len() + id.chain.iter().map(|c| c.raw.len()).sum::<usize>() + if tsa.is_some() { TOKEN_RESERVE } else { 0 };
let name = id.certificate.display_name();
// The signature dictionary.
let mut v = Dict::new();
@ -832,7 +1139,19 @@ pub fn sign(doc: &Document, id: &DigitalId, opts: &SignOptions) -> Result<Vec<u8
br_text.resize(width, b' ');
out[br_at..br_at + width].copy_from_slice(&br_text);
let digest = alg.digest(&[&out[..start], &out[end..]]);
let cms = crate::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest)?;
let base = crate::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest)?;
let cms = match tsa {
None => base,
Some(t) => {
// The imprint covers the signature value, which attaching an unsigned attribute
// does not change (PAdES B-T).
let sd = SignedData::parse(&base)?;
let q = crate::timestamp::TimestampQuery::new(DigestAlg::Sha256, DigestAlg::Sha256.digest(&[&sd.signer.signature]))?;
let resp = t.timestamp(&q.encode()?)?;
let token = crate::timestamp::parse_response(&resp, &q)?;
crate::cms::attach_timestamp_token(&base, &token.raw)?
}
};
if cms.len() > reserve {
return Err(SignError::Pdf("the signature does not fit its placeholder".into()));
}
@ -841,6 +1160,50 @@ pub fn sign(doc: &Document, id: &DigitalId, opts: &SignOptions) -> Result<Vec<u8
Ok(out)
}
/// Append a document timestamp (ISO 32000-2 §12.8.2.2): a standalone signature dictionary
/// whose `/Contents` is an RFC 3161 token covering the whole current file (`/ETSI.RFC3161`).
/// The transport is the caller's; a rejected or malformed token produces no file.
pub fn timestamp_document(doc: &Document, tsa: &dyn crate::timestamp::TimestampAuthority, date: &str) -> Result<Vec<u8>, SignError> {
if doc.security().is_some() || doc.output_handler().is_some() {
return Err(SignError::Unsupported("timestamping encrypted documents".into()));
}
let mut doc = doc.clone();
let mut v = Dict::new();
v.set(b"Type".to_vec(), Object::name("Sig"));
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()));
v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; TOKEN_RESERVE], hex: true }));
v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec()));
let mut app = Dict::new();
app.set(b"Name".to_vec(), Object::name("PdfCraft"));
let mut build = Dict::new();
build.set(b"App".to_vec(), Object::Dict(app));
v.set(b"Prop_Build".to_vec(), Object::Dict(build));
doc.add(Object::Dict(v));
let save = SaveOptions { mod_date: Some(date.to_string()), object_streams: false, ..SaveOptions::default() };
let mut out = pdfcraft_cos::write_incremental(&doc, &save)?;
let (br_at, gap) = locate(&out, TOKEN_RESERVE)?;
let (start, end) = gap;
let ranges = [0usize, start, end, out.len() - end];
let mut br_text = format!("0 {} {} {}", ranges[1], ranges[2], ranges[3]).into_bytes();
let width = format!("0 {} {} {}", BR_MARK[0], BR_MARK[1], BR_MARK[2]).len();
if br_text.len() > width {
return Err(SignError::Pdf("the document is too large to timestamp".into()));
}
br_text.resize(width, b' ');
out[br_at..br_at + width].copy_from_slice(&br_text);
let q = crate::timestamp::TimestampQuery::new(DigestAlg::Sha256, DigestAlg::Sha256.digest(&[&out[..start], &out[end..]]))?;
let resp = tsa.timestamp(&q.encode()?)?;
let token = crate::timestamp::parse_response(&resp, &q)?;
let hex: Vec<u8> = token.raw.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
if hex.len() > TOKEN_RESERVE * 2 {
return Err(SignError::Pdf("the timestamp token does not fit its placeholder".into()));
}
out[start + 1..start + 1 + hex.len()].copy_from_slice(&hex);
Ok(out)
}
/// Find the placeholders in the written file: the offset of the ByteRange numbers, and the
/// `<…>` hex string's span. The ByteRange marker is unique; the Contents placeholder is the one
/// in the same object (a full save may renumber objects, so they are not found by number).

View File

@ -0,0 +1,308 @@
//! Revocation evidence (RFC 5280 CRLs, RFC 6960 OCSP): parsing, signature verification and
//! status lookup for evidence embedded in a DSS or supplied by the caller. Never fetches:
//! the transport and the choice of evidence stay with the caller.
use crate::der::{Tlv, tag};
use crate::keys::{self, DigestAlg, PublicKey};
use crate::x509::Certificate;
use crate::{SignError, Time};
/// A DER size cap: real CRLs and OCSP responses are far smaller.
const MAX_EVIDENCE: usize = 2 * 1024 * 1024;
const ID_PKIX_OCSP_BASIC: &str = "1.3.6.1.5.5.7.48.1.1";
const EKU_OCSP_SIGNING: &str = "1.3.6.1.5.5.7.3.9";
/// What a piece of revocation evidence says about a certificate at a point in time.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RevocationStatus {
/// The evidence covers the certificate and does not revoke it.
Good,
/// The evidence covers the certificate and revokes it.
Revoked { at: Time },
/// The evidence does not cover the certificate, is outside its window, or could not be
/// verified.
Unknown,
}
fn bad(what: impl Into<String>) -> SignError {
SignError::Malformed(format!("revocation: {}", what.into()))
}
fn strip(b: &[u8]) -> &[u8] {
match b {
[0, rest @ ..] if !rest.is_empty() => rest,
v => v,
}
}
fn valid_in_window(this: Time, next: Option<Time>, at: Time) -> bool {
at >= this && next.is_none_or(|n| at <= n)
}
/// Verify `signature` over the raw `signed` bytes with `key`, where the signature's algorithm
/// identifier names the digest.
fn verify_signature(signed: &[u8], alg_raw: &[u8], signature: &[u8], key: &PublicKey) -> bool {
let Ok(alg) = Tlv::parse_all(alg_raw) else { return false };
let Ok((scheme, Some(digest))) = keys::signature_algorithm(&alg) else { return false };
let d = digest.digest(&[signed]);
key.verify(scheme, digest, &d, signature).unwrap_or(false)
}
// ── X.509 CertificateList (CRL) ─────────────────────────────────────────────────────────────
/// A parsed CRL (RFC 5280 §5.1).
pub struct CertificateList {
tbs: Vec<u8>,
sig_alg: Vec<u8>,
signature: Vec<u8>,
pub issuer_raw: Vec<u8>,
pub this_update: Time,
pub next_update: Option<Time>,
/// (serial, revocation date), top to bottom.
pub revoked: Vec<(Vec<u8>, Time)>,
}
impl CertificateList {
pub fn parse(bytes: &[u8]) -> Result<CertificateList, SignError> {
if bytes.is_empty() || bytes.len() > MAX_EVIDENCE {
return Err(bad("CRL is empty or exceeds the 2 MiB limit"));
}
let parts = Tlv::parse_all(bytes)?.expect(tag::SEQUENCE, "CertificateList")?.children()?;
let [tbs, alg, sig] = parts.as_slice() else { return Err(bad("CertificateList")) };
let mut f = tbs.children()?.into_iter().peekable();
if f.peek().is_some_and(|t| t.tag == tag::INTEGER) {
f.next(); // version
}
let _alg = f.next().ok_or_else(|| bad("tbsCertList"))?;
let issuer = f.next().ok_or_else(|| bad("tbsCertList"))?.raw.to_vec();
let this_update = f.next().ok_or_else(|| bad("tbsCertList"))?.time()?;
let mut next_update = None;
if f.peek().is_some_and(|t| t.tag == tag::UTC_TIME || t.tag == tag::GENERALIZED_TIME) {
next_update = f.next().map(|t| t.time()).transpose()?;
}
let mut revoked = Vec::new();
if f.peek().is_some_and(|t| t.tag == tag::SEQUENCE) {
for entry in f.next().ok_or_else(|| bad("revokedCertificates"))?.children()? {
let e = entry.children()?;
let serial = e.first().ok_or_else(|| bad("revoked entry"))?.uint_bytes().to_vec();
let at = e.get(1).ok_or_else(|| bad("revoked entry"))?.time()?;
revoked.push((serial, at));
}
}
Ok(CertificateList {
tbs: tbs.raw.to_vec(),
sig_alg: alg.raw.to_vec(),
signature: sig.bits()?.to_vec(),
issuer_raw: issuer,
this_update,
next_update,
revoked,
})
}
/// Check `cert` against this CRL at `at`. The CRL must be issued by `cert`'s issuer, its
/// signature must verify with the issuer's key, and `at` must fall inside its window.
pub fn check(&self, cert: &Certificate, issuer: &Certificate, at: Time) -> RevocationStatus {
if self.issuer_raw != issuer.subject.raw || issuer.subject.raw != cert.issuer.raw {
return RevocationStatus::Unknown;
}
if !verify_signature(&self.tbs, &self.sig_alg, &self.signature, &issuer.public_key) {
return RevocationStatus::Unknown;
}
if !valid_in_window(self.this_update, self.next_update, at) {
return RevocationStatus::Unknown;
}
match self.revoked.iter().find(|(serial, _)| strip(serial) == strip(&cert.serial)) {
Some((_, t)) => RevocationStatus::Revoked { at: *t },
None => RevocationStatus::Good,
}
}
}
// ── OCSP BasicOCSPResponse ──────────────────────────────────────────────────────────────────
/// One `CertID` (RFC 6960 §4.1.1): hashes of the issuer's name and key, and the serial.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CertId {
pub hash_alg: DigestAlg,
pub issuer_name_hash: Vec<u8>,
pub issuer_key_hash: Vec<u8>,
pub serial: Vec<u8>,
}
impl CertId {
/// The `CertID` naming `cert` as issued by `issuer`, using `hash_alg`.
pub fn build(cert: &Certificate, issuer: &Certificate, hash_alg: DigestAlg) -> CertId {
CertId {
hash_alg,
issuer_name_hash: hash_alg.digest(&[&cert.issuer.raw]),
issuer_key_hash: hash_alg.digest(&[&issuer.public_key.key_bits()]),
serial: cert.serial.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum CertStatus {
Good,
Revoked(Time),
Unknown,
}
/// One SingleResponse: the status of one certificate at a time.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SingleResponse {
pub cert_id: CertId,
status: CertStatus,
pub this_update: Time,
pub next_update: Option<Time>,
}
enum ResponderId {
ByName(Vec<u8>),
ByKey(Vec<u8>),
}
/// A parsed OCSP response (RFC 6960 §4.2.1): the responder's identity, per-certificate
/// responses, and the delegated responder's certificate when it is embedded.
pub struct OcspResponse {
tbs: Vec<u8>,
sig_alg: Vec<u8>,
signature: Vec<u8>,
responder_id: ResponderId,
pub responses: Vec<SingleResponse>,
responder_cert: Option<Certificate>,
}
impl OcspResponse {
pub fn parse(bytes: &[u8]) -> Result<OcspResponse, SignError> {
if bytes.is_empty() || bytes.len() > MAX_EVIDENCE {
return Err(bad("OCSP response is empty or exceeds the 2 MiB limit"));
}
let resp = Tlv::parse_all(bytes)?.expect(tag::SEQUENCE, "OCSPResponse")?.children()?;
let status = resp.first().ok_or_else(|| bad("missing responseStatus"))?.u64()?;
if status != 0 {
return Err(bad(format!("responder reported an error (status {status})")));
}
let response_bytes =
resp.get(1).ok_or_else(|| bad("successful response has no responseBytes"))?.expect(tag::ctx(0), "responseBytes")?.inner()?.children()?;
let [type_oid, wrapped] = response_bytes.as_slice() else { return Err(bad("responseBytes")) };
if type_oid.oid()? != ID_PKIX_OCSP_BASIC {
return Err(bad("unsupported response type"));
}
let basic = Tlv::parse_all(wrapped.value)?.expect(tag::SEQUENCE, "BasicOCSPResponse")?.children()?;
let mut it = basic.into_iter();
let tbs = it.next().ok_or_else(|| bad("BasicOCSPResponse"))?;
let alg = it.next().ok_or_else(|| bad("BasicOCSPResponse"))?;
let signature = it.next().ok_or_else(|| bad("BasicOCSPResponse"))?.expect(tag::BIT_STRING, "signature")?.bits()?.to_vec();
let mut responder_cert = None;
for t in it {
if t.tag == tag::ctx(0) {
for c in t.children()? {
if c.tag == tag::SEQUENCE
&& let Ok(cert) = Certificate::parse(c.raw)
{
responder_cert.get_or_insert(cert);
}
}
}
}
let fields = tbs.children()?;
let mut f = fields.into_iter().peekable();
if f.peek().is_some_and(|t| t.tag == tag::ctx(0)) {
f.next(); // version
}
let rid = f.next().ok_or_else(|| bad("ResponseData"))?;
let responder_id = match rid.tag {
t if t == tag::ctx(1) => ResponderId::ByName(rid.value.to_vec()),
t if t == tag::ctx(2) => ResponderId::ByKey(Tlv::parse_all(rid.value)?.expect(tag::OCTET_STRING, "byKey responderID")?.value.to_vec()),
_ => return Err(bad("responderID")),
};
let _produced_at = f.next().ok_or_else(|| bad("ResponseData"))?.time()?;
let mut responses = Vec::new();
for single in f.next().ok_or_else(|| bad("ResponseData"))?.children()? {
let s = single.children()?;
let cert_id_fields = s.first().ok_or_else(|| bad("SingleResponse"))?.children()?;
let [hash_alg, name_hash, key_hash, serial] = cert_id_fields.as_slice() else { return Err(bad("CertID")) };
let alg_oid = hash_alg.children()?.first().ok_or_else(|| bad("CertID"))?.oid()?;
let Some(hash_alg) = DigestAlg::from_oid(&alg_oid) else { return Err(bad("unsupported CertID hash")) };
let cert_id = CertId {
hash_alg,
issuer_name_hash: name_hash.value.to_vec(),
issuer_key_hash: key_hash.value.to_vec(),
serial: serial.uint_bytes().to_vec(),
};
let status = match s.get(1).map(|t| t.tag) {
// RFC 6960 §4.2.1: CertStatus is CHOICE with IMPLICIT tags — good [0] NULL,
// revoked [1] RevokedInfo (the value is the RevokedInfo's own contents),
// unknown [2] UnknownInfo.
Some(t) if t == tag::ctx_prim(0) => CertStatus::Good,
Some(t) if t == tag::ctx(1) => {
let info = s.get(1).unwrap_or(&single);
let (date, _) = Tlv::parse(info.value)?;
CertStatus::Revoked(date.time()?)
}
Some(t) if t == tag::ctx_prim(2) => CertStatus::Unknown,
_ => return Err(bad("certStatus")),
};
let this_update = s.get(2).ok_or_else(|| bad("SingleResponse"))?.time()?;
let next_update = s.get(3).filter(|t| t.tag == tag::ctx(0)).map(|t| t.inner().and_then(|g| g.time())).transpose()?;
responses.push(SingleResponse { cert_id, status, this_update, next_update });
}
Ok(OcspResponse { tbs: tbs.raw.to_vec(), sig_alg: alg.raw.to_vec(), signature, responder_id, responses, responder_cert })
}
/// Check `cert` (issued by `issuer`) at `at`. The responder must be the issuer itself, or
/// a delegated responder with the OCSP-signing EKU whose certificate is issued by the
/// issuer; the response signature and CertID must match, and `at` must fall inside the
/// response window.
pub fn check(&self, cert: &Certificate, issuer: &Certificate, at: Time) -> RevocationStatus {
// Who signed the response? The issuer identified by responderID, or a delegated,
// embedded responder identified the same way.
let responder: Option<&Certificate> = match &self.responder_id {
ResponderId::ByName(name) => {
if *name == issuer.subject.raw {
Some(issuer)
} else {
self.responder_cert.as_ref().filter(|c| *name == c.subject.raw)
}
}
ResponderId::ByKey(hash) => {
if *hash == DigestAlg::Sha1.digest(&[&issuer.public_key.key_bits()]) {
Some(issuer)
} else {
self.responder_cert.as_ref().filter(|c| *hash == DigestAlg::Sha1.digest(&[&c.public_key.key_bits()]))
}
}
};
let Some(responder) = responder else { return RevocationStatus::Unknown };
// A delegated responder must carry the OCSP-signing EKU and be issued by the issuer.
if responder.subject.raw != issuer.subject.raw {
if responder.issuer.raw != issuer.subject.raw || !responder.signed_by(&issuer.public_key) {
return RevocationStatus::Unknown;
}
if !responder.extended_key_usage.as_ref().is_some_and(|e| e.iter().any(|o| o == EKU_OCSP_SIGNING)) {
return RevocationStatus::Unknown;
}
}
if !verify_signature(&self.tbs, &self.sig_alg, &self.signature, &responder.public_key) {
return RevocationStatus::Unknown;
}
// Find the SingleResponse for this certificate: the CertID hashes must recompute.
let single = self.responses.iter().find(|r| {
let name_hash = r.cert_id.hash_alg.digest(&[&cert.issuer.raw]);
let key_hash = r.cert_id.hash_alg.digest(&[&issuer.public_key.key_bits()]);
name_hash == r.cert_id.issuer_name_hash && key_hash == r.cert_id.issuer_key_hash && strip(&r.cert_id.serial) == strip(&cert.serial)
});
let Some(single) = single else { return RevocationStatus::Unknown };
if !valid_in_window(single.this_update, single.next_update, at) {
return RevocationStatus::Unknown;
}
match single.status {
CertStatus::Good => RevocationStatus::Good,
CertStatus::Revoked(at) => RevocationStatus::Revoked { at },
CertStatus::Unknown => RevocationStatus::Unknown,
}
}
}

View File

@ -0,0 +1,233 @@
//! RFC 3161 timestamp queries and tokens.
//!
//! Transport-free by design: callers encode a request, send it through an explicitly
//! configured authority at the application boundary, and validate the returned DER response
//! before the token is attached to a CMS signature. [`respond`] is the TSA-side signer, used
//! by the deterministic test authority and by a locally configured stamping identity.
use crate::cms::SignedData;
use crate::der::{self, Tlv, tag};
use crate::keys::{DigestAlg, PrivateKey};
use crate::x509::Certificate;
use crate::{SignError, Time};
const TST_INFO: &str = "1.2.840.113549.1.9.16.1.4";
const MAX_RESPONSE: usize = 4 * 1024 * 1024;
const MAX_TOKEN: usize = 2 * 1024 * 1024;
const MAX_REQUEST: usize = 1024 * 1024;
/// RFC 3161 request data. `nonce` is optional but recommended for online requests.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TimestampQuery {
pub digest: DigestAlg,
pub imprint: Vec<u8>,
pub policy: Option<String>,
pub nonce: Option<u64>,
pub certificate_request: bool,
}
impl TimestampQuery {
pub fn new(digest: DigestAlg, imprint: Vec<u8>) -> Result<Self, SignError> {
if imprint.len() != digest.digest(&[&[]]).len() {
return Err(SignError::Malformed("RFC 3161 imprint length does not match its digest algorithm".into()));
}
Ok(Self { digest, imprint, policy: None, nonce: None, certificate_request: true })
}
/// Encode a DER `TimeStampReq` (RFC 3161 §2.4.1).
pub fn encode(&self) -> Result<Vec<u8>, SignError> {
let alg = self.digest.algorithm();
let imprint = der::seq(&[&alg, &der::octets(&self.imprint)]);
let mut parts: Vec<Vec<u8>> = vec![der::int(1), imprint];
if let Some(policy) = &self.policy {
parts.push(der::try_oid(policy)?);
}
if let Some(nonce) = self.nonce {
parts.push(der::int(nonce));
}
if self.certificate_request {
parts.push(der::boolean(true));
}
let refs: Vec<&[u8]> = parts.iter().map(Vec::as_slice).collect();
Ok(der::seq(&refs))
}
}
/// A validated RFC 3161 time-stamp token.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TimestampToken {
pub raw: Vec<u8>,
pub digest: DigestAlg,
pub imprint: Vec<u8>,
pub policy: String,
pub serial: Vec<u8>,
pub gen_time: Time,
}
impl TimestampToken {
/// The TSA's certificate, from the token's embedded certificates.
pub fn signer_certificate(&self) -> Option<Certificate> {
let token = SignedData::parse(&self.raw).ok()?;
token.signer_certificate().cloned()
}
}
/// A caller-supplied transport. The sign crate never creates sockets.
pub trait TimestampAuthority {
fn timestamp(&self, request: &[u8]) -> Result<Vec<u8>, SignError>;
}
/// The certificates carried inside a token's CMS (for trust anchoring at validation time).
pub fn token_certs(raw: &[u8]) -> Vec<Certificate> {
SignedData::parse(raw).map(|sd| sd.certificates).unwrap_or_default()
}
fn bad(message: impl Into<String>) -> SignError {
SignError::Malformed(format!("RFC 3161: {}", message.into()))
}
/// Decode a `TimeStampReq` as [`TimestampQuery::encode`] wrote it.
pub fn parse_request(bytes: &[u8]) -> Result<TimestampQuery, SignError> {
if bytes.is_empty() || bytes.len() > MAX_REQUEST {
return Err(bad("request is empty or exceeds the 1 MiB limit"));
}
let fields = Tlv::parse_all(bytes)?.expect(tag::SEQUENCE, "TimeStampReq")?.children()?;
let mut it = fields.into_iter();
let _version = it.next().ok_or_else(|| bad("missing version"))?.u64()?;
let imprint = it.next().ok_or_else(|| bad("missing message imprint"))?.children()?;
let alg_oid =
imprint.first().ok_or_else(|| bad("missing imprint algorithm"))?.children()?.first().ok_or_else(|| bad("missing algorithm OID"))?.oid()?;
let digest = DigestAlg::from_oid(&alg_oid).ok_or_else(|| bad("unsupported imprint digest algorithm"))?;
let hash = imprint.get(1).ok_or_else(|| bad("missing imprint digest"))?.expect(tag::OCTET_STRING, "imprint digest")?.value.to_vec();
let mut q = TimestampQuery::new(digest, hash)?;
for f in it {
match f.tag {
tag::OID => q.policy = Some(f.oid()?),
tag::INTEGER => q.nonce = Some(f.u64()?),
tag::BOOLEAN => q.certificate_request = f.value.first() == Some(&0xFF),
_ => {}
}
}
Ok(q)
}
/// Parse and validate a token (a bare CMS `ContentInfo`): TSTInfo content, bounded size, the
/// TSA's CMS signature and its message digest — but not the imprint, which callers compare
/// against the signed data they hold.
pub fn parse_token(raw: &[u8]) -> Result<TimestampToken, SignError> {
if raw.is_empty() || raw.len() > MAX_TOKEN {
return Err(bad("token is empty or exceeds the 2 MiB limit"));
}
let token = SignedData::parse(raw)?;
if token.signer.content_type.as_deref() != Some(TST_INFO) {
return Err(bad("token content is not TSTInfo"));
}
let info = token.content.as_deref().ok_or_else(|| bad("token has no TSTInfo content"))?;
let fields = Tlv::parse_all(info)?.expect(tag::SEQUENCE, "TSTInfo")?.children()?;
let policy = fields.get(1).ok_or_else(|| bad("TSTInfo has no policy"))?.oid()?;
let imprint_fields = fields.get(2).ok_or_else(|| bad("TSTInfo has no message imprint"))?.children()?;
let alg_fields = imprint_fields.first().ok_or_else(|| bad("message imprint has no algorithm"))?.children()?;
let digest = alg_fields
.first()
.ok_or_else(|| bad("message imprint has no digest algorithm"))?
.oid()
.and_then(|o| DigestAlg::from_oid(&o).ok_or_else(|| bad("unsupported imprint digest algorithm")))?;
let imprint = imprint_fields
.get(1)
.ok_or_else(|| bad("message imprint has no digest"))?
.expect(tag::OCTET_STRING, "message imprint digest")?
.value
.to_vec();
let serial = fields.get(3).ok_or_else(|| bad("TSTInfo has no serial number"))?.uint_bytes().to_vec();
let gen_time = fields.get(4).ok_or_else(|| bad("TSTInfo has no generation time"))?.time()?;
// The signed messageDigest uses the SignerInfo's digest algorithm, which need not be the
// imprint's (RFC 5652 §5.4).
let content_digest = token.signer.digest.digest(&[info]);
if token.signer.message_digest.as_deref() != Some(content_digest.as_slice()) {
return Err(bad("token signed message digest does not match TSTInfo"));
}
let cert = token.signer_certificate().ok_or_else(|| bad("TSA certificate is missing"))?;
if !token.verify_signature(cert, &content_digest) {
return Err(bad("TSA signature is invalid"));
}
Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time })
}
/// Parse and validate a `TimeStampResp`, including the CMS signature and message imprint.
pub fn parse_response(bytes: &[u8], query: &TimestampQuery) -> Result<TimestampToken, SignError> {
if bytes.is_empty() || bytes.len() > MAX_RESPONSE {
return Err(bad("response is empty or exceeds the 4 MiB limit"));
}
let fields = Tlv::parse_all(bytes)?.expect(tag::SEQUENCE, "TimeStampResp")?.children()?;
let status_info = fields.first().ok_or_else(|| bad("missing status"))?.children()?;
let status = status_info.first().ok_or_else(|| bad("missing status value"))?.u64()?;
if status != 0 && status != 1 {
return Err(bad(format!("TSA rejected the request (status {status})")));
}
let token = fields.get(1).ok_or_else(|| bad("granted response has no time-stamp token"))?;
let token = parse_token(token.raw)?;
if token.digest != query.digest || token.imprint != query.imprint {
return Err(bad("message imprint does not match the requested digest"));
}
Ok(token)
}
/// The TSA side: sign a `TimeStampResp` for `query` at `gen_time`. Used by deterministic test
/// authorities and a locally configured stamping identity, never implied to be a remote TSA.
#[allow(clippy::too_many_arguments)]
pub fn respond(
key: &PrivateKey,
cert: &Certificate,
chain: &[Certificate],
alg: DigestAlg,
query: &TimestampQuery,
policy: &str,
gen_time: Time,
serial: u64,
) -> Result<Vec<u8>, SignError> {
let mut info = vec![
der::int(1),
der::try_oid(policy)?,
der::seq(&[&query.digest.algorithm(), &der::octets(&query.imprint)]),
der::int(serial),
gen_time.encode(),
];
if let Some(nonce) = query.nonce {
info.push(der::int(nonce));
}
let refs: Vec<&[u8]> = info.iter().map(Vec::as_slice).collect();
let tst_info = der::seq(&refs);
let token = crate::cms::sign_encapsulated(key, cert, chain, alg, TST_INFO, &tst_info)?;
let status = der::seq(&[&der::int(0)]);
Ok(der::seq(&[&status, &token]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_round_trips_with_options() {
let imprint = DigestAlg::Sha256.digest(&[b"document"]);
let mut q = TimestampQuery::new(DigestAlg::Sha256, imprint.clone()).unwrap();
q.policy = Some("1.2.3.4".into());
q.nonce = Some(42);
let encoded = q.encode().unwrap();
let parsed = parse_request(&encoded).unwrap();
assert_eq!(parsed, q);
// The encoded fields themselves.
let fields = Tlv::parse_all(&encoded).unwrap().children().unwrap();
assert_eq!(fields[0].u64().unwrap(), 1);
assert_eq!(fields[1].children().unwrap()[1].value, imprint.as_slice());
assert_eq!(fields[2].oid().unwrap(), "1.2.3.4");
assert_eq!(fields[3].u64().unwrap(), 42);
assert_eq!(fields[4].value, &[0xff]);
}
#[test]
fn rejects_oversized_response_before_parsing() {
let q = TimestampQuery::new(DigestAlg::Sha256, vec![0; 32]).unwrap();
assert!(parse_response(&vec![0; MAX_RESPONSE + 1], &q).is_err());
assert!(parse_request(&vec![0; MAX_REQUEST + 1]).is_err());
}
}

View File

@ -120,6 +120,109 @@ pub struct Certificate {
/// Key usage bits (bit 0 = digitalSignature, 1 = nonRepudiation, 5 = keyCertSign), if present.
pub key_usage: Option<u16>,
pub subject_key_id: Option<Vec<u8>>,
/// Authority key identifier (2.5.29.35), when present.
pub authority_key_id: Option<Vec<u8>>,
/// Extended key usage OIDs (2.5.29.37), when present.
pub extended_key_usage: Option<Vec<String>>,
/// OCSP responder URLs from the Authority Information Access (1.3.6.1.5.5.7.1.1).
pub ocsp_urls: Vec<String>,
/// CRL distribution point URLs (2.5.29.31).
pub crl_urls: Vec<String>,
}
/// The certificate extensions `Certificate::parse` reads, gathered tolerantly: an
/// extension that does not parse leaves its field at the default instead of failing the
/// certificate (real-world issuers use encodings with edge cases, e.g. a single-URI CRL
/// distribution point whose `fullName [0]` is IMPLICIT over a bare GeneralName).
#[derive(Default)]
struct Extensions {
is_ca: bool,
key_usage: Option<u16>,
subject_key_id: Option<Vec<u8>>,
authority_key_id: Option<Vec<u8>>,
extended_key_usage: Option<Vec<String>>,
ocsp_urls: Vec<String>,
crl_urls: Vec<String>,
}
impl Extensions {
fn read(&mut self, oid: &str, value: &Tlv<'_>) {
match oid {
"2.5.29.19" => {
let Ok(bc) = Tlv::parse_all(value.value) else { return };
self.is_ca = bc.children().is_ok_and(|c| c.first().is_some_and(|b| b.tag == tag::BOOLEAN && b.value != [0]));
}
"2.5.29.15" => {
let Ok(bits) = Tlv::parse_all(value.value) else { return };
let Some((_, b)) = bits.value.split_first() else { return };
// Bit 0 is the most significant bit of the first byte.
let mut u = 0u16;
for (i, byte) in b.iter().take(2).enumerate() {
for j in 0..8 {
if byte & (0x80 >> j) != 0 {
u |= 1 << (i * 8 + j);
}
}
}
self.key_usage = Some(u);
}
"2.5.29.14" => {
if let Ok(ski) = Tlv::parse_all(value.value) {
self.subject_key_id = Some(ski.value.to_vec());
}
}
// AuthorityKeyIdentifier: the [0] keyIdentifier inside.
"2.5.29.35" => {
if let Ok(aki) = Tlv::parse_all(value.value) {
self.authority_key_id = aki.children().ok().and_then(|c| c.into_iter().find(|t| t.tag == tag::ctx(0))).map(|t| t.value.to_vec());
}
}
// ExtendedKeyUsage: a SEQUENCE OF OID.
"2.5.29.37" => {
if let Ok(eku) = Tlv::parse_all(value.value) {
self.extended_key_usage = eku.children().ok().map(|c| c.into_iter().filter_map(|t| t.oid().ok()).collect::<Vec<_>>());
}
}
// Authority Information Access: OCSP and CA-issuer locations.
"1.3.6.1.5.5.7.1.1" => {
let Ok(aia) = Tlv::parse_all(value.value) else { return };
for access in aia.children().unwrap_or_default() {
let Ok(a) = access.children() else { continue };
if a.len() >= 2 && a[0].oid().ok().as_deref() == Some("1.3.6.1.5.5.7.48.1") && a[1].tag == tag::ctx_prim(6) {
self.ocsp_urls.push(String::from_utf8_lossy(a[1].value).into_owned());
}
}
}
// CRL distribution points: full-name URIs.
"2.5.29.31" => {
let Ok(dps) = Tlv::parse_all(value.value) else { return };
for dp in dps.children().unwrap_or_default() {
let Ok(fields) = dp.children() else { continue };
for field in fields {
if field.tag != tag::ctx(0) {
continue;
}
// distributionPoint [0] DistributionPointName, whose fullName choice is
// [0] IMPLICIT GeneralNames: the value is a run of GeneralNames, not a
// wrapped SEQUENCE (and often a single URI).
let Ok(name) = field.inner() else { continue };
if name.tag != tag::ctx(0) {
continue;
}
let mut rest = name.value;
while !rest.is_empty() {
let Ok((n, r)) = Tlv::parse(rest) else { break };
rest = r;
if n.tag == tag::ctx_prim(6) {
self.crl_urls.push(String::from_utf8_lossy(n.value).into_owned());
}
}
}
}
}
_ => {}
}
}
}
impl Certificate {
@ -138,42 +241,21 @@ impl Certificate {
let [nb, na] = validity.as_slice() else { return Err(SignError::Malformed("validity".into())) };
let subject = Name::parse(&f.next().ok_or_else(|| SignError::Malformed("subject".into()))?)?;
let public_key = PublicKey::from_spki(&f.next().ok_or_else(|| SignError::Malformed("public key".into()))?)?;
let mut is_ca = false;
let mut key_usage = None;
let mut subject_key_id = None;
let mut ext = Extensions::default();
for t in f {
if t.tag != tag::ctx(3) {
continue;
}
for ext in t.inner()?.children()? {
let e = ext.children()?;
for ext_tlv in t.inner()?.children()? {
let e = ext_tlv.children()?;
let Some(o) = e.first().and_then(|o| o.oid().ok()) else { continue };
let Some(value) = e.last().filter(|v| v.tag == tag::OCTET_STRING) else { continue };
match o.as_str() {
"2.5.29.19" => {
is_ca = Tlv::parse_all(value.value)?.children()?.first().is_some_and(|b| b.tag == tag::BOOLEAN && b.value != [0]);
}
"2.5.29.15" => {
let bits = Tlv::parse_all(value.value)?;
let v = bits.value;
if let Some((_, b)) = v.split_first() {
// Bit 0 is the most significant bit of the first byte.
let mut u = 0u16;
for (i, byte) in b.iter().take(2).enumerate() {
for j in 0..8 {
if byte & (0x80 >> j) != 0 {
u |= 1 << (i * 8 + j);
}
}
}
key_usage = Some(u);
}
}
"2.5.29.14" => subject_key_id = Some(Tlv::parse_all(value.value)?.value.to_vec()),
_ => {}
}
// A malformed or unreadable extension must never reject the whole certificate:
// unreadable ones are skipped and the fields they carry keep their defaults.
ext.read(o.as_str(), value);
}
}
let Extensions { is_ca, key_usage, subject_key_id, authority_key_id, extended_key_usage, ocsp_urls, crl_urls } = ext;
Ok(Certificate {
raw: raw.to_vec(),
tbs: tbs.raw.to_vec(),
@ -188,6 +270,10 @@ impl Certificate {
is_ca,
key_usage,
subject_key_id,
authority_key_id,
extended_key_usage,
ocsp_urls,
crl_urls,
})
}
@ -343,3 +429,43 @@ fn base64(s: &str) -> Option<Vec<u8>> {
}
Some(out)
}
#[cfg(test)]
mod xfa_ext_tests {
use super::*;
#[test]
fn a_single_uri_crl_distribution_point_is_read() {
// The real extnValue of the Entrust Class 3 Client CA: DistributionPoints with one
// full-name URI. `fullName [0]` is IMPLICIT over GeneralNames, so with a single
// GeneralName there is no inner SEQUENCE — a run of one element.
let uri = b"http://crl.entrust.net/2048ca.crl";
let value = der::seq(&[&der::seq(&[&der::tlv(tag::ctx(0), &der::tlv(tag::ctx(0), &der::tlv(tag::ctx_prim(6), uri)))])]);
let wrapped = der::tlv(tag::OCTET_STRING, &value);
let extn = Tlv::parse_all(&wrapped).unwrap();
let mut ext = Extensions::default();
ext.read("2.5.29.31", &extn);
assert_eq!(ext.crl_urls, vec!["http://crl.entrust.net/2048ca.crl".to_string()]);
// Two URIs in one full-name run.
let names = [der::tlv(tag::ctx_prim(6), uri), der::tlv(tag::ctx_prim(6), b"http://crl2.example/x.crl")].concat();
let two = der::seq(&[&der::seq(&[&der::tlv(tag::ctx(0), &der::tlv(tag::ctx(0), &names))])]);
let wrapped = der::tlv(tag::OCTET_STRING, &two);
let extn = Tlv::parse_all(&wrapped).unwrap();
let mut ext = Extensions::default();
ext.read("2.5.29.31", &extn);
assert_eq!(ext.crl_urls.len(), 2);
}
#[test]
fn a_malformed_extension_is_skipped_not_fatal() {
for garbage in [b"".as_slice(), &[0xFF, 0xFF, 0xFF], &[0x30, 0x99], b"not der"] {
let wrapped = der::tlv(tag::OCTET_STRING, garbage);
let extn = Tlv::parse_all(&wrapped).unwrap();
for oid in ["2.5.29.19", "2.5.29.15", "2.5.29.14", "2.5.29.35", "2.5.29.37", "1.3.6.1.5.5.7.1.1", "2.5.29.31"] {
let mut ext = Extensions::default();
ext.read(oid, &extn); // must not panic and must leave defaults
assert!(ext.ocsp_urls.is_empty() && ext.crl_urls.is_empty() && !ext.is_ca);
}
}
}
}

View File

@ -128,3 +128,114 @@ fn certificates_load_from_pem_and_der_and_export_as_pem() {
assert_eq!(pdfcraft_sign::x509::load_certificates(back.as_bytes()).unwrap()[0], certs[0]);
assert!(pdfcraft_sign::x509::load_certificates(b"hello").is_err());
}
// ── CRL and OCSP verification (RFC 5280, RFC 6960) ─────────────────────────────────────────
use pdfcraft_sign::der::{self, tag};
use pdfcraft_sign::revocation::{CertificateList, OcspResponse, RevocationStatus};
/// A self-issued CRL from `issuer`: optional revocation entry, signed with the issuer's key.
fn crl(issuer: &pkcs12::DigitalId, revoked_serial: Option<(&[u8], Time)>, this: Time, next: Time) -> Vec<u8> {
let alg = issuer.key.signature_algorithm(DigestAlg::Sha256);
let mut tbs = vec![der::int(1), alg.clone(), issuer.certificate.subject.raw.clone(), this.encode(), next.encode()];
if let Some((serial, at)) = revoked_serial {
tbs.push(der::seq(&[&der::seq(&[&der::uint(serial), &at.encode()])]));
}
let refs: Vec<&[u8]> = tbs.iter().map(Vec::as_slice).collect();
let tbs = der::seq(&refs);
let sig = issuer.key.sign(DigestAlg::Sha256, &tbs).unwrap();
der::seq(&[&tbs, &alg, &der::bit_string(&sig)])
}
/// A self-issued OCSP response from `issuer` answering for its own serial.
fn ocsp(issuer: &pkcs12::DigitalId, status: RevocationStatus, this: Time, next: Time) -> Vec<u8> {
let cert_id = der::seq(&[
&DigestAlg::Sha1.algorithm(),
&der::octets(&DigestAlg::Sha1.digest(&[&issuer.certificate.issuer.raw])),
&der::octets(&DigestAlg::Sha1.digest(&[&issuer.certificate.public_key.key_bits()])),
&der::uint(&issuer.certificate.serial),
]);
// RFC 6960's real encodings: good [0] IMPLICIT NULL, revoked [1] IMPLICIT RevokedInfo,
// unknown [2] IMPLICIT; nextUpdate [0] EXPLICIT GeneralizedTime.
let status = match status {
RevocationStatus::Good => der::tlv(tag::ctx_prim(0), &[]),
RevocationStatus::Revoked { at } => der::tlv(tag::ctx(1), &at.encode()),
RevocationStatus::Unknown => der::tlv(tag::ctx_prim(2), &[]),
};
let single = der::seq(&[&cert_id, &status, &this.encode(), &der::tlv(tag::ctx(0), &next.encode())]);
let responder_id = der::tlv(tag::ctx(1), &issuer.certificate.subject.raw);
let tbs = der::seq(&[&responder_id, &this.encode(), &der::seq(&[&single])]);
let alg = issuer.key.signature_algorithm(DigestAlg::Sha256);
let sig = issuer.key.sign(DigestAlg::Sha256, &tbs).unwrap();
let basic = der::seq(&[&tbs, &alg, &der::bit_string(&sig)]);
let basic_type = der::oid("1.3.6.1.5.5.7.48.1.1");
der::seq(&[&der::int(0), &der::explicit(0, &der::seq(&[&basic_type, &der::octets(&basic)]))])
}
fn mid_june() -> Time {
Time { year: 2026, month: 6, day: 15, hour: 12, minute: 0, second: 0 }
}
#[test]
fn crls_verify_and_report_revocation() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let (this, next) = (mid_june(), Time { year: 2026, month: 7, day: 15, hour: 12, minute: 0, second: 0 });
let issuer = &id.certificate;
let at = mid_june();
// Not on the list: good.
let clean = CertificateList::parse(&crl(&id, None, this, next)).unwrap();
assert_eq!(clean.check(issuer, issuer, at), RevocationStatus::Good);
// On the list: revoked with its date.
let revoked =
CertificateList::parse(&crl(&id, Some((&issuer.serial, Time { year: 2026, month: 6, day: 1, hour: 8, minute: 0, second: 0 })), this, next))
.unwrap();
assert_eq!(
revoked.check(issuer, issuer, at),
RevocationStatus::Revoked { at: Time { year: 2026, month: 6, day: 1, hour: 8, minute: 0, second: 0 } }
);
// Outside the validity window: unknown.
let later = Time { year: 2026, month: 8, day: 1, hour: 0, minute: 0, second: 0 };
assert_eq!(clean.check(issuer, issuer, later), RevocationStatus::Unknown);
// A tampered CRL does not verify: unknown, never a false "good".
let mut tampered = crl(&id, None, this, next);
let t = tampered.windows(4).position(|w| w == &[0x6F, 0x6B, 0x00, 0x00][..]).unwrap_or(20);
tampered[t] ^= 0xFF;
assert_ne!(CertificateList::parse(&tampered).unwrap().check(issuer, issuer, at), RevocationStatus::Good);
// A different issuer's key does not verify it.
let other = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
assert_eq!(
CertificateList::parse(&crl(&id, None, this, next)).unwrap().check(&other.certificate, &other.certificate, at),
RevocationStatus::Unknown
);
}
#[test]
fn ocsp_responses_verify_and_match_the_certificate() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let (this, next) = (mid_june(), Time { year: 2026, month: 7, day: 15, hour: 12, minute: 0, second: 0 });
let issuer = &id.certificate;
let at = mid_june();
let good = OcspResponse::parse(&ocsp(&id, RevocationStatus::Good, this, next)).unwrap();
assert_eq!(good.check(issuer, issuer, at), RevocationStatus::Good);
let revoked = OcspResponse::parse(&ocsp(
&id,
RevocationStatus::Revoked { at: Time { year: 2026, month: 6, day: 2, hour: 9, minute: 0, second: 0 } },
this,
next,
))
.unwrap();
assert_eq!(
revoked.check(issuer, issuer, at),
RevocationStatus::Revoked { at: Time { year: 2026, month: 6, day: 2, hour: 9, minute: 0, second: 0 } }
);
// Outside the window: unknown.
let later = Time { year: 2026, month: 8, day: 1, hour: 0, minute: 0, second: 0 };
assert_eq!(good.check(issuer, issuer, later), RevocationStatus::Unknown);
// Signed by a different key than the named responder: unknown.
let other = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let wrong_key = OcspResponse::parse(&ocsp(&other, RevocationStatus::Good, this, next)).unwrap();
assert_eq!(wrong_key.check(issuer, issuer, at), RevocationStatus::Unknown);
// An error response is rejected at parse time.
let error = der::seq(&[&der::int(6)]);
assert!(OcspResponse::parse(&error).is_err());
}

View File

@ -3,7 +3,7 @@
use std::sync::Arc;
use pdfcraft_cos::{Document, Object, PdfString, SaveOptions, write_incremental};
use pdfcraft_sign::{Modification, SignOptions, Status, TrustStore, pkcs12, signatures};
use pdfcraft_sign::{Modification, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, der, pkcs12, signatures};
fn data(name: &str) -> Vec<u8> {
std::fs::read(format!("{}/tests/data/{name}", env!("CARGO_MANIFEST_DIR"))).unwrap()
@ -170,6 +170,193 @@ fn invisible_signatures_and_refusals() {
assert_eq!(pdfcraft_sign::pdf::display_date("D:20261002120000+01'00'"), "2026.10.02 12:00:00 +01'00'");
}
/// A deterministic TSA: signs an RFC 3161 response locally with a test digital ID at a fixed
/// time. No sockets; the whole stamping path runs in-process.
struct TestTsa {
id: pkcs12::DigitalId,
time: Time,
}
impl TimestampAuthority for TestTsa {
fn timestamp(&self, request: &[u8]) -> Result<Vec<u8>, SignError> {
let q = pdfcraft_sign::timestamp::parse_request(request)?;
pdfcraft_sign::timestamp::respond(
&self.id.key,
&self.id.certificate,
&self.id.chain,
pdfcraft_sign::DigestAlg::Sha256,
&q,
"1.2.3.4",
self.time,
7,
)
}
}
#[test]
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(),
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();
assert!(signed.starts_with(&fixture()), "still an incremental update");
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
let trust = TrustStore { certs: vec![anchor.clone(), tsa.id.certificate.clone()] };
let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
assert!(s.timestamp, "{:?}", s.details);
assert_eq!(s.timestamp_time, Some(tsa.time), "the token's generation time, verified over the signature value");
assert!(s.details.iter().any(|d| d.contains("trusted time")), "{:?}", s.details);
// A trusted signer with an untrusted TSA: still valid, but the token's time is unverified.
let s = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor] }).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
assert!(s.timestamp);
assert_eq!(s.timestamp_time, None);
assert!(s.details.iter().any(|d| d.contains("unverified timestamp")), "{:?}", s.details);
// Untrusted, the signature stays intact-but-unknown; the timestamp is still noted.
let s = signatures(&open(&signed), &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Unknown);
assert!(s.timestamp && s.timestamp_time.is_none());
}
#[test]
fn a_malformed_timestamp_response_fails_signing_without_a_file() {
struct Bad;
impl TimestampAuthority for Bad {
fn timestamp(&self, _request: &[u8]) -> Result<Vec<u8>, SignError> {
Ok(b"not der".to_vec())
}
}
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
assert!(pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &opts(), &Bad).is_err());
}
#[test]
fn a_document_timestamp_covers_the_file_and_validates() {
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();
assert!(stamped.starts_with(&fixture()), "an incremental update");
let trust_tsa = TrustStore { certs: vec![tsa.id.certificate.clone()] };
let s = signatures(&open(&stamped), &stamped, &trust_tsa).into_iter().find(|s| s.doc_timestamp).unwrap();
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);
assert_eq!(s.modification, Modification::None);
assert_eq!(s.signed_len, stamped.len());
// A later byte change breaks the timestamp's imprint.
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, &TrustStore::default()).into_iter().find(|s| s.doc_timestamp).unwrap();
assert_eq!(s.status, Status::Invalid);
// Combined with a field signature: both are listed, the stamp covers both revisions.
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let both = pdfcraft_sign::timestamp_document(&open(&signed), &tsa, "D:20261006130000Z").unwrap();
let all = signatures(&open(&both), &both, &TrustStore { certs: vec![tsa.id.certificate.clone()] });
let field = all.iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
let allowed = match &field.modification {
Modification::Allowed(k) => k,
other => panic!("{other:?}"),
};
assert!(allowed.contains(&"signature".to_string()), "{allowed:?}");
let stamp = all.iter().find(|s| s.doc_timestamp).unwrap();
assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details);
}
#[test]
fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() {
use pdfcraft_sign::dss::{self, Evidence};
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let evidence =
Evidence { certs: vec![id.certificate.raw.clone()], ocsps: vec![b"synthetic ocsp".to_vec()], crls: vec![b"synthetic crl".to_vec()] };
let ltv = dss::embed(&open(&signed), &evidence).unwrap();
assert!(ltv.starts_with(&signed), "incremental");
let doc = open(&ltv);
// The store is in the catalog with one certificate and the /VRI entry for the signature.
let root = doc.root().unwrap();
let dss_dict: pdfcraft_cos::Dict =
doc.get(root).as_dict().unwrap().get(b"DSS").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap();
assert_eq!(dss_dict.name(b"Type"), Some(b"DSS".as_slice()));
assert!(dss_dict.contains(b"Certs") && dss_dict.contains(b"OCSPs") && dss_dict.contains(b"CRLs") && dss_dict.contains(b"VRI"));
// The earlier signature still validates; the DSS counts as a permitted change.
let s = signatures(&doc, &ltv, &TrustStore::default()).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
assert_eq!(s.status, Status::Unknown);
let allowed = match &s.modification {
Modification::Allowed(k) => k,
other => panic!("{other:?}"),
};
assert!(allowed.contains(&"document security store".to_string()), "{allowed:?}");
// Embedding twice keeps one certificate (byte-identical dedup) and stays valid.
let twice = dss::embed(&doc, &evidence).unwrap();
let doc2 = open(&twice);
let dss2: pdfcraft_cos::Dict =
doc2.get(doc2.root().unwrap()).as_dict().unwrap().get(b"DSS").map(|d| doc2.resolve(d)).and_then(|d| d.as_dict().cloned()).unwrap();
let certs = dss2.get(b"Certs").map(|c| doc2.resolve(c)).and_then(|c| c.as_array().cloned()).unwrap();
assert_eq!(certs.len(), 1, "byte-identical evidence is not duplicated");
}
#[test]
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(),
time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 },
};
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
let ltv = dss::embed(&open(&signed), &Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }).unwrap();
let lta = pdfcraft_sign::timestamp_document(&open(&ltv), &tsa, "D:20261006120000Z").unwrap();
let all = signatures(&open(&lta), &lta, &TrustStore { certs: vec![tsa.id.certificate.clone()] });
assert_eq!(all.iter().filter(|s| s.signed).count(), 2);
let field = all.iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
assert_eq!(field.status, Status::Unknown);
let allowed = match &field.modification {
Modification::Allowed(k) => k,
other => panic!("{other:?}"),
};
assert!(allowed.contains(&"document security store".to_string()) && allowed.contains(&"signature".to_string()), "{allowed:?}");
assert!(!allowed.contains(&"page content".to_string()), "{allowed:?}");
let stamp = all.iter().find(|s| s.doc_timestamp).unwrap();
assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details);
}
#[test]
fn an_embedded_verified_revocation_invalidates_the_signature() {
use pdfcraft_sign::dss::{self, Evidence};
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap();
// The signer's own CRL (self-signed test identity) revokes its certificate, in a window
// that covers the signing date (October 2026).
let (this, next) =
(Time { year: 2026, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 });
let alg = id.key.signature_algorithm(pdfcraft_sign::DigestAlg::Sha256);
let tbs = der::seq(&[
&der::int(1),
&alg,
&id.certificate.subject.raw,
&this.encode(),
&next.encode(),
&der::seq(&[&der::seq(&[
&der::uint(&id.certificate.serial),
&Time { year: 2026, month: 6, day: 1, hour: 8, minute: 0, second: 0 }.encode(),
])]),
]);
let sig = id.key.sign(pdfcraft_sign::DigestAlg::Sha256, &tbs).unwrap();
let crl = der::seq(&[&tbs, &alg, &der::bit_string(&sig)]);
let ltv = dss::embed(&open(&signed), &Evidence { certs: Vec::new(), ocsps: Vec::new(), crls: vec![crl] }).unwrap();
let s = signatures(&open(&ltv), &ltv, &TrustStore::default()).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(s.details.iter().any(|d| d.contains("revoked") && d.contains("CRL")), "{:?}", s.details);
}
#[test]
fn validates_a_signature_made_by_openssl() {
let bytes = data("openssl-signed.pdf");
@ -252,3 +439,201 @@ fn signing_with_keychain_identities() {
let _ = std::fs::remove_dir_all(&dir);
result.unwrap();
}
/// `fixture()` plus a Form XObject (object 7) that the page draws.
fn fixture_with_xobject() -> Vec<u8> {
let objs: Vec<&[u8]> = vec![
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /X1 7 0 R >> >> /Annots [6 0 R] >>",
b"<< /Length 8 >>\nstream\n/X1 Do\n\nendstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>",
b"<< /Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /Length 44 >>\nstream\nBT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// An update that declares the form dynamic XFA (/XFA + /NeedsRendering) and swaps the whole
/// page tree for a new one. Marking a form XFA must not turn a page replacement into "form
/// fill": the signed pages are gone.
fn xfa_page_swap(doc: &mut Document) {
let xdp = b"<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template/></xdp:xdp>".to_vec();
let mut xd = pdfcraft_cos::Dict::new();
xd.set(b"Length".to_vec(), Object::Int(xdp.len() as i64));
let xfa = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(xd, xdp)));
let body = b"BT /F1 14 Tf 20 250 Td (Forged text) Tj ET".to_vec();
let mut cd = pdfcraft_cos::Dict::new();
cd.set(b"Length".to_vec(), Object::Int(body.len() as i64));
let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(cd, body)));
let pages = doc.add(Object::Null);
let mut page = pdfcraft_cos::Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Parent".to_vec(), Object::Ref(pages));
page.set(b"MediaBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(300), Object::Int(300)]));
page.set(b"Contents".to_vec(), Object::Ref(contents));
let mut font = pdfcraft_cos::Dict::new();
font.set(b"F1".to_vec(), Object::Ref(pdfcraft_cos::ObjRef { num: 5, generation: 0 }));
let mut res = pdfcraft_cos::Dict::new();
res.set(b"Font".to_vec(), Object::Dict(font));
page.set(b"Resources".to_vec(), Object::Dict(res));
let page = doc.add(Object::Dict(page));
let mut tree = pdfcraft_cos::Dict::new();
tree.set(b"Type".to_vec(), Object::name("Pages"));
tree.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
tree.set(b"Count".to_vec(), Object::Int(1));
doc.set(pages, Object::Dict(tree));
let root = doc.root().unwrap();
doc.update_dict(root, |c| {
c.set(b"Pages".to_vec(), Object::Ref(pages));
if let Some(Object::Dict(form)) = c.get_mut(b"AcroForm") {
form.set(b"XFA".to_vec(), Object::Ref(xfa));
form.set(b"NeedsRendering".to_vec(), Object::Bool(true));
}
})
.unwrap();
}
#[test]
fn declaring_a_form_xfa_does_not_excuse_replacing_the_pages() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap();
for certify in [None, Some(2), Some(3)] {
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { certify, ..opts() }).unwrap();
assert_eq!(check(&signed).status, Status::Valid);
let s = check(&edit_after(&signed, xfa_page_swap));
assert_eq!(s.status, Status::Invalid, "{certify:?}: {:?}", s.details);
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"document structure".to_string())), "{:?}", s.modification);
}
}
#[test]
fn a_form_xobject_relabelled_metadata_is_not_a_metadata_change() {
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let trust = TrustStore { certs: vec![id.certificate.clone()] };
let signed = pdfcraft_sign::sign(&open(&fixture_with_xobject()), &id, &opts()).unwrap();
let relabel = |doc: &mut Document| {
let mut d = pdfcraft_cos::Dict::new();
d.set(b"Type".to_vec(), Object::name("Metadata"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(300), Object::Int(300)]));
let body = b"BT /F1 14 Tf 20 250 Td (Forged text) Tj ET".to_vec();
d.set(b"Length".to_vec(), Object::Int(body.len() as i64));
doc.set(pdfcraft_cos::ObjRef { num: 7, generation: 0 }, Object::Stream(pdfcraft_cos::Stream::from_raw(d, body)));
};
let edited = edit_after(&signed, relabel);
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(matches!(&s.modification, Modification::Disallowed(k) if !k.contains(&"metadata".to_string())), "{:?}", s.modification);
// The catalog's own XMP stream may still be updated.
let with_xmp = edit_after(&fixture_with_xobject(), |doc| {
let xmp = b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"/>".to_vec();
let mut d = pdfcraft_cos::Dict::new();
d.set(b"Type".to_vec(), Object::name("Metadata"));
d.set(b"Subtype".to_vec(), Object::name("XML"));
d.set(b"Length".to_vec(), Object::Int(xmp.len() as i64));
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, xmp)));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(r))).unwrap();
});
let signed_xmp = pdfcraft_sign::sign(&open(&with_xmp), &id, &opts()).unwrap();
let rewrite_xmp = edit_after(&signed_xmp, |doc| {
let root = doc.root().unwrap();
let r = doc.get(root).as_dict().unwrap().get(b"Metadata").and_then(Object::as_ref).unwrap();
let xmp = b"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><!-- edited --></x:xmpmeta>".to_vec();
let mut d = pdfcraft_cos::Dict::new();
d.set(b"Type".to_vec(), Object::name("Metadata"));
d.set(b"Subtype".to_vec(), Object::name("XML"));
d.set(b"Length".to_vec(), Object::Int(xmp.len() as i64));
doc.set(r, Object::Stream(pdfcraft_cos::Stream::from_raw(d, xmp)));
});
let s = signatures(&open(&rewrite_xmp), &rewrite_xmp, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
assert_eq!(s.modification, Modification::Allowed(vec!["metadata".into()]));
}
/// A self-signed CRL from `id` revoking its own certificate, valid from `this` to `next`.
fn self_crl(id: &pkcs12::DigitalId, this: Time, next: Time, revoked_at: Time) -> Vec<u8> {
let alg = id.key.signature_algorithm(pdfcraft_sign::DigestAlg::Sha256);
let tbs = der::seq(&[
&der::int(1),
&alg,
&id.certificate.subject.raw,
&this.encode(),
&next.encode(),
&der::seq(&[&der::seq(&[&der::uint(&id.certificate.serial), &revoked_at.encode()])]),
]);
let sig = id.key.sign(pdfcraft_sign::DigestAlg::Sha256, &tbs).unwrap();
der::seq(&[&tbs, &alg, &der::bit_string(&sig)])
}
#[test]
fn a_revoked_certificate_stays_invalid_when_it_was_also_expired_at_signing() {
use pdfcraft_sign::dss::{self, Evidence};
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
// Signed in 2040, after the test certificate expired (2036).
let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() };
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &late).unwrap();
let t = |year| Time { year, month: 1, day: 1, hour: 0, minute: 0, second: 0 };
let crl = self_crl(&id, t(2039), t(2041), t(2039));
let ltv = dss::embed(&open(&signed), &Evidence { certs: Vec::new(), ocsps: Vec::new(), crls: vec![crl] }).unwrap();
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
for trust in [TrustStore::default(), TrustStore { certs: vec![anchor] }] {
let s = signatures(&open(&ltv), &ltv, &trust).into_iter().find(|s| s.signed && !s.doc_timestamp).unwrap();
assert!(s.details.iter().any(|d| d.contains("not valid at the time of signing")), "{:?}", s.details);
assert_eq!(s.status, Status::Invalid, "{:?}", s.details);
assert!(s.details.iter().any(|d| d.contains("revoked")), "{:?}", s.details);
}
}
#[test]
fn an_untrusted_timestamp_does_not_set_the_validation_time() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
// 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(),
time: Time { year: 2027, month: 1, day: 1, hour: 0, minute: 0, second: 0 },
};
let late = SignOptions { date: "D:20400101120000Z".into(), ..opts() };
let signed = pdfcraft_sign::sign_with_timestamp(&open(&fixture()), &id, &late, &tsa).unwrap();
let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone());
let s = signatures(&open(&signed), &signed, &TrustStore { certs: vec![anchor.clone()] }).into_iter().find(|s| s.signed).unwrap();
assert_ne!(s.status, Status::Valid, "{:?}", s.details);
assert_eq!(s.timestamp_time, None, "an untrusted token's time is not a trusted time");
assert!(s.details.iter().any(|d| d.contains("not valid at the time of signing")), "{:?}", s.details);
assert!(!s.details.iter().any(|d| d.contains("trusted time")), "{:?}", s.details);
assert!(s.details.iter().any(|d| d.contains("unverified timestamp")), "{:?}", s.details);
// Trusting the TSA makes its time authoritative: the certificate was valid then.
let trust = TrustStore { certs: vec![anchor, tsa.id.certificate.clone()] };
let s = signatures(&open(&signed), &signed, &trust).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.timestamp_time, Some(tsa.time));
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
}
#[test]
fn a_timestamp_token_may_sign_with_a_different_digest_than_its_imprint() {
let tsa = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let imprint = pdfcraft_sign::DigestAlg::Sha1.digest(&[b"document"]);
let q = pdfcraft_sign::timestamp::TimestampQuery::new(pdfcraft_sign::DigestAlg::Sha1, imprint.clone()).unwrap();
let time = Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 };
let resp =
pdfcraft_sign::timestamp::respond(&tsa.key, &tsa.certificate, &tsa.chain, pdfcraft_sign::DigestAlg::Sha256, &q, "1.2.3.4", time, 9).unwrap();
let token = pdfcraft_sign::timestamp::parse_response(&resp, &q).unwrap();
assert_eq!((token.digest, token.imprint, token.gen_time), (pdfcraft_sign::DigestAlg::Sha1, imprint, time));
}

View File

@ -5999,11 +5999,11 @@ title = "Add a document timestamp (RFC 3161)"
area = "H"
tier = "P1"
milestone = "M9"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
evidence = ["crates/sign/tests/pdf.rs::a_document_timestamp_covers_the_file_and_validates"]
notes = "timestamp_document appends a standalone /ETSI.RFC3161 signature dictionary over the whole file; validation discovers and verifies these (imprint, token signature, TSA cert validity) and reports the trusted time. Missing: automation tool and timestamp-server configuration (transport is caller-injected)."
[[feature]]
id = "sign.timestamp-servers"
@ -6023,11 +6023,11 @@ title = "PAdES B-T (signature timestamp)"
area = "H"
tier = "P1"
milestone = "M9"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
evidence = ["crates/sign/tests/pdf.rs::signing_with_a_timestamp_embeds_a_verified_rfc3161_token"]
notes = "Sign with an embedded RFC 3161 token (sign_with_timestamp): imprint and TSA signature verified at signing and validation time, trusted time reported. Missing: timestamp-server configuration and a fetcher (transport is caller-injected), the standalone document timestamp, automation tool."
[[feature]]
id = "sign.ltv"
@ -6035,11 +6035,11 @@ title = "Long-term validation (DSS/VRI)"
area = "H"
tier = "P1"
milestone = "M9"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
evidence = ["crates/sign/tests/pdf.rs::embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid", "crates/sign/tests/pdf.rs::an_embedded_verified_revocation_invalidates_the_signature"]
notes = "dss::embed merges caller-supplied evidence into the catalog /DSS with /VRI entries per signature, deduplicating blobs, as a permitted incremental change; revocation.rs verifies the embedded CRLs and OCSP responses (signature, responder identity, CertID, window) and a verified revocation invalidates the signature. Missing: fetching evidence, automation tool."
[[feature]]
id = "sign.pades-blta"
@ -6047,11 +6047,11 @@ title = "PAdES B-LT and B-LTA"
area = "H"
tier = "P1"
milestone = "M9"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
evidence = ["crates/sign/tests/pdf.rs::sign_then_ltv_then_timestamp_makes_a_b_lta_file"]
notes = "The B-LT/B-LTA chain works with caller-supplied evidence: sign, embed DSS (B-LT), then append a document timestamp over the result (B-LTA); earlier signatures stay valid with the store classified as a permitted change. Missing: OCSP/CRL verification of the evidence, re-timestamping automation."
[[feature]]
id = "sign.ocsp-crl"
@ -6059,11 +6059,11 @@ title = "Revocation checking (OCSP and CRL)"
area = "H"
tier = "P1"
milestone = "M9"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
evidence = ["crates/sign/tests/crypto.rs::crls_verify_and_report_revocation", "crates/sign/tests/crypto.rs::ocsp_responses_verify_and_match_the_certificate", "crates/sign/tests/pdf.rs::an_embedded_verified_revocation_invalidates_the_signature"]
notes = "RFC 5280 CRLs and RFC 6960 OCSP responses are parsed and verified (issuer/responder signature, delegated responder's OCSP-signing EKU, CertID hash match, validity window) and checked against the DSS during validation. Missing: network fetching of fresh evidence and the OS trust store."
[[feature]]
id = "sign.validate-all"
@ -6191,11 +6191,11 @@ title = "Verification time (signing time, timestamp, now)"
area = "H"
tier = "P1"
milestone = "M9"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
evidence = ["crates/sign/tests/pdf.rs::signing_with_a_timestamp_embeds_a_verified_rfc3161_token", "crates/sign/tests/pdf.rs::an_untrusted_timestamp_does_not_set_the_validation_time"]
notes = "Validation prefers the verified RFC 3161 timestamp time over /M when checking certificate validity and revocation, but only when the TSA chains to the trust store; an untrusted token is reported as an unverified timestamp and its time is not used. Missing: an explicit verification-time option (verify at a chosen date / now) in the validation API."
[[feature]]
id = "sign.changes-after-signing"