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:
parent
355de9038e
commit
4d0078aeff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)]))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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..],
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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())) };
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(<v);
|
||||
// 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, <v, &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(<v), &tsa, "D:20261006120000Z").unwrap();
|
||||
let all = signatures(&open(<a), <a, &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(<v), <v, &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(<v), <v, &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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue