diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index ce60b33c..e102841a 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -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, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 22c1f59e..1fedc040 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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>, 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>, 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>, 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>, path: Option) -> Result<(), EditError> { diff --git a/crates/sign/README.md b/crates/sign/README.md index bd6c40b4..924cb8fd 100644 --- a/crates/sign/README.md +++ b/crates/sign/README.md @@ -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. diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index 4e08f56e..d665b4c5 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -40,6 +40,8 @@ pub struct SignerInfo { pub signature: Vec, /// 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>, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -168,11 +170,16 @@ fn parse_signer(si: &Tlv<'_>) -> Result { 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 { 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, 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> = 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 { 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, 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, 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, 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)])) } diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index a2403f8e..bedbf072 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -336,7 +336,33 @@ pub fn oid(dotted: &str) -> Vec { 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, SignError> { + let mut arcs = dotted.split('.').map(|a| a.parse::().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 { let b: &[u8] = match bytes.iter().position(|x| *x != 0) { Some(i) => &bytes[i..], diff --git a/crates/sign/src/dss.rs b/crates/sign/src/dss.rs new file mode 100644 index 00000000..e095ec74 --- /dev/null +++ b/crates/sign/src/dss.rs @@ -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>, + pub ocsps: Vec>, + pub crls: Vec>, +} + +/// 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, 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::new(); + let mut ocsps: Vec> = Vec::new(); + let mut crls: Vec> = 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>, 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 = certs.iter().map(|c| Object::Ref(embed_stream(&mut doc, c))).collect(); + let ocsp_refs: Vec = ocsps.iter().map(|o| Object::Ref(embed_stream(&mut doc, o))).collect(); + let crl_refs: Vec = 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>) -> 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(()) +} diff --git a/crates/sign/src/keys.rs b/crates/sign/src/keys.rs index 5799195e..84558578 100644 --- a/crates/sign/src/keys.rs +++ b/crates/sign/src/keys.rs @@ -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 { + 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 { let parts = spki.children()?; let [alg, key] = parts.as_slice() else { return Err(SignError::Malformed("SubjectPublicKeyInfo".into())) }; diff --git a/crates/sign/src/lib.rs b/crates/sign/src/lib.rs index e46b2da3..df3c80c6 100644 --- a/crates/sign/src/lib.rs +++ b/crates/sign/src/lib.rs @@ -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)] diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index ddfa3ea7..a701a4df 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -142,15 +142,22 @@ pub struct SignatureInfo { pub location: Option, pub contact: Option, pub sub_filter: Option, + /// 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, /// 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, pub algorithm: Option, 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