Files
pdfcraft/crates/sign/src/pdf.rs
T

963 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Signatures in a PDF (ISO 32000-2 §12.8): listing and validating signature fields, and
//! signing with an incremental save.
//!
//! **Validation** reads `/ByteRange` and the CMS from the file's own bytes (not from the parsed
//! string, which a security handler would have transformed), recomputes the digest, checks the
//! signature and the signer's chain against the trust store, and diffs later revisions against
//! the signed one to classify changes made after signing (form fill, comments, further
//! signatures, or anything else) under the DocMDP permissions.
//!
//! **Signing** adds or fills a signature field, writes the document incrementally with a
//! zero-filled `/Contents` and a fixed-width `/ByteRange`, then patches both in place: the
//! PAdES B-B recipe (`ETSI.CAdES.detached`).
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString, SaveOptions, Stream};
use crate::SignError;
use crate::cms::SignedData;
use crate::der::Time;
use crate::keys::DigestAlg;
use crate::pkcs12::DigitalId;
use crate::x509::{Certificate, build_chain};
/// Digests of signed byte ranges (and parsed signed revisions), kept across revalidations of
/// one document: incremental edits only append to the file, so the signed bytes and their
/// digest don't change, and rehashing a large file on every edit was the expensive part of
/// validation (120 MB: 84 ms per comment edit before, 6 ms after). Keyed by the ranges, the
/// algorithm and a sampled fingerprint of the covered bytes; use one cache per document, whose
/// bytes only ever grow.
#[derive(Debug, Default)]
pub struct DigestCache {
map: std::sync::Mutex<HashMap<RangeKey, Vec<u8>>>,
/// Signed revisions opened for change classification, by length and fingerprint.
revisions: std::sync::Mutex<HashMap<(usize, [u8; 32]), Document>>,
}
/// (end of first range, start of second, end of second, algorithm, fingerprint).
type RangeKey = (usize, usize, usize, DigestAlg, [u8; 32]);
/// 64 KiB sampled from across `covered`, plus its length.
fn fingerprint(covered: &[u8]) -> [u8; 32] {
let mut parts: Vec<&[u8]> = (0..64)
.map(|i| {
let at = covered.len().saturating_sub(1024) * i / 63;
&covered[at..(at + 1024).min(covered.len())]
})
.collect();
let len = (covered.len() as u64).to_be_bytes();
parts.push(&len);
DigestAlg::Sha256.digest(&parts).try_into().unwrap_or([0; 32])
}
impl DigestCache {
/// The signed revision `bytes[..end]`, parsed (and kept).
fn revision(&self, bytes: &[u8], end: usize) -> Option<Document> {
let key = (end, fingerprint(&bytes[..end]));
if let Some(d) = self.revisions.lock().ok().and_then(|m| m.get(&key).cloned()) {
return Some(d);
}
let d = Document::open(Arc::new(bytes[..end].to_vec())).ok()?;
if let Ok(mut m) = self.revisions.lock() {
if m.len() > 8 {
m.clear();
}
m.insert(key, d.clone());
}
Some(d)
}
fn digest(&self, alg: DigestAlg, bytes: &[u8], l0: usize, o1: usize, end: usize) -> Vec<u8> {
let key = (l0, o1, end, alg, fingerprint(&bytes[..end]));
if let Some(d) = self.map.lock().ok().and_then(|m| m.get(&key).cloned()) {
return d;
}
let d = alg.digest(&[&bytes[..l0], &bytes[o1..end]]);
if let Ok(mut m) = self.map.lock() {
if m.len() > 64 {
m.clear();
}
m.insert(key, d.clone());
}
d
}
}
/// Certificates the user trusts for signing (Acrobat: Trusted Certificates).
#[derive(Clone, Debug, Default)]
pub struct TrustStore {
pub certs: Vec<Certificate>,
}
impl TrustStore {
fn trusts(&self, c: &Certificate) -> bool {
self.certs.iter().any(|t| t.raw == c.raw)
}
}
/// Acrobat's three verdicts.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
/// Intact, signed by a trusted identity, and any later changes are permitted.
Valid,
/// Intact, but the signer's identity can't be established (not trusted), or validity
/// couldn't be determined.
Unknown,
/// The signed bytes changed, the signature doesn't verify, or later changes aren't permitted.
Invalid,
}
/// Changes made after signing.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Modification {
/// The signature covers the whole file.
None,
/// Later revisions only make changes the signature permits (listed).
Allowed(Vec<String>),
/// Later revisions make changes it doesn't permit (listed).
Disallowed(Vec<String>),
}
/// One signature field.
#[derive(Clone, Debug)]
pub struct SignatureInfo {
/// Fully qualified field name.
pub field: String,
pub signed: bool,
/// 0-based page of its widget, and the widget rectangle (user space).
pub page: Option<usize>,
pub rect: Option<[f64; 4]>,
pub visible: bool,
/// The signer: the certificate's common name, else `/Name`.
pub signer: Option<String>,
pub certificate: Option<Certificate>,
/// The chain as built (signer first).
pub chain: Vec<Certificate>,
/// `/M` as written, and the signing time used for checks.
pub date: Option<String>,
pub signing_time: Option<Time>,
pub reason: Option<String>,
pub location: Option<String>,
pub contact: Option<String>,
pub sub_filter: Option<String>,
/// 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,
pub digest: Option<DigestAlg>,
pub algorithm: Option<String>,
pub timestamp: bool,
pub status: Status,
pub modification: Modification,
/// Acrobat-style sentences explaining the verdict.
pub details: Vec<String>,
}
impl SignatureInfo {
/// The one-line summary under the signature in the Signatures panel.
pub fn summary(&self) -> &'static str {
if !self.signed {
return "Unsigned signature field";
}
match self.status {
Status::Valid => "Signature is valid",
Status::Unknown => "Signature validity is unknown",
Status::Invalid => "Signature is invalid",
}
}
}
fn text(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
let o = doc.resolve(d.get(key)?);
let s = match &*o {
Object::String(s) => s.to_text(),
Object::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
let s = s.trim_matches('\0').trim().to_string();
(!s.is_empty()).then_some(s)
}
struct Field {
name: String,
/// The field dictionary (and its widget(s)).
r: Option<ObjRef>,
dict: Dict,
widgets: Vec<(Option<ObjRef>, Dict)>,
}
/// Every signature field, depth first in `/Fields` order.
fn sig_fields(doc: &Document) -> Vec<Field> {
let mut out = Vec::new();
let Some(root) = doc.root() else { return out };
let Some(af) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()) else {
return out;
};
let Some(fields) = af.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { return out };
let mut seen = HashSet::new();
let mut stack: Vec<(Object, String, Option<Vec<u8>>)> = fields.into_iter().rev().map(|f| (f, String::new(), None)).collect();
while let Some((o, prefix, inherited_ft)) = stack.pop() {
if let Some(r) = o.as_ref()
&& !seen.insert(r)
{
continue;
}
let Some(d) = doc.resolve(&o).as_dict().cloned() else { continue };
let part = text(doc, &d, b"T");
let name = match (&part, prefix.is_empty()) {
(Some(p), true) => p.clone(),
(Some(p), false) => format!("{prefix}.{p}"),
(None, _) => prefix.clone(),
};
let ft = d.name(b"FT").map(<[u8]>::to_vec).or(inherited_ft);
let kids: Vec<Object> = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
// Kids with a /T are fields; others are this field's widgets.
let (sub, widgets): (Vec<Object>, Vec<Object>) = kids.into_iter().partition(|k| doc.resolve(k).as_dict().is_some_and(|kd| kd.contains(b"T")));
for k in sub.into_iter().rev() {
stack.push((k, name.clone(), ft.clone()));
}
if ft.as_deref() == Some(b"Sig") && part.is_some() {
let mut ws: Vec<(Option<ObjRef>, Dict)> =
widgets.iter().filter_map(|w| doc.resolve(w).as_dict().cloned().map(|wd| (w.as_ref(), wd))).collect();
if d.name(b"Subtype") == Some(b"Widget") {
ws.insert(0, (o.as_ref(), d.clone()));
}
out.push(Field { name, r: o.as_ref(), dict: d, widgets: ws });
}
}
out
}
/// Which page holds each annotation.
fn annot_pages(doc: &Document) -> HashMap<ObjRef, usize> {
let mut map = HashMap::new();
for (i, p) in printcraft_annot::page_refs(doc).unwrap_or_default().into_iter().enumerate() {
if let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a))).and_then(|a| a.as_array().cloned()) {
for r in a.iter().filter_map(Object::as_ref) {
map.insert(r, i);
}
}
}
map
}
fn nums(doc: &Document, d: &Dict, key: &[u8]) -> Option<Vec<f64>> {
doc.resolve(d.get(key)?).as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())
}
/// List and validate every signature field. `bytes` is the file as stored (the ranges index it).
pub fn list(doc: &Document, bytes: &[u8], trust: &TrustStore) -> Vec<SignatureInfo> {
list_cached(doc, bytes, trust, &DigestCache::default())
}
/// [`list`], reusing digests from `cache`.
pub fn list_cached(doc: &Document, bytes: &[u8], trust: &TrustStore, cache: &DigestCache) -> Vec<SignatureInfo> {
let pages = annot_pages(doc);
let mut out = Vec::new();
for f in sig_fields(doc) {
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
.and_then(|(_, w)| nums(doc, w, b"Rect"))
.filter(|r| r.len() == 4)
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
let visible = rect.is_some_and(|r| r[2] - r[0] > 0.5 && r[3] - r[1] > 0.5);
let v = f.dict.get(b"V").map(|v| doc.resolve(v)).and_then(|v| v.as_dict().cloned());
let mut info = SignatureInfo {
field: f.name.clone(),
signed: v.is_some(),
page,
rect,
visible,
signer: None,
certificate: None,
chain: Vec::new(),
date: None,
signing_time: None,
reason: None,
location: None,
contact: None,
sub_filter: None,
certify: None,
revision: 0,
signed_len: 0,
digest: None,
algorithm: None,
timestamp: false,
status: Status::Unknown,
modification: Modification::None,
details: Vec::new(),
};
if let Some(v) = v {
validate_into(doc, bytes, trust, &v, &mut info, cache);
}
out.push(info);
}
out
}
fn page_index(doc: &Document, p: ObjRef) -> Option<usize> {
printcraft_annot::page_refs(doc).ok()?.iter().position(|r| *r == p)
}
/// Validate one signature dictionary.
pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) -> Option<SignatureInfo> {
list(doc, bytes, trust).into_iter().find(|s| s.field == field)
}
fn unhex(s: &[u8]) -> Option<Vec<u8>> {
let digits: Vec<u8> = s.iter().copied().filter(|c| !c.is_ascii_whitespace()).collect();
let val = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
digits.chunks(2).map(|p| Some(val(p[0])? << 4 | p.get(1).map_or(Some(0), |c| val(*c))?)).collect()
}
fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, info: &mut SignatureInfo, cache: &DigestCache) {
info.date = text(doc, v, b"M");
info.reason = text(doc, v, b"Reason");
info.location = text(doc, v, b"Location");
info.contact = text(doc, v, b"ContactInfo");
info.sub_filter = v.name(b"SubFilter").map(|s| String::from_utf8_lossy(s).into_owned());
info.signer = text(doc, v, b"Name");
info.certify = v
.get(b"Reference")
.map(|r| doc.resolve(r))
.and_then(|r| r.as_array().cloned())
.into_iter()
.flatten()
.filter_map(|r| doc.resolve(&r).as_dict().cloned())
.find(|r| r.name(b"TransformMethod") == Some(b"DocMDP"))
.map(|r| {
r.get(b"TransformParams").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().and_then(|p| p.int(b"P"))).unwrap_or(2).clamp(1, 3) as u8
});
let invalid = |info: &mut SignatureInfo, why: &str| {
info.status = Status::Invalid;
info.details.push(why.to_string());
};
// Byte ranges: [0 a b c] with the gap holding exactly the hex /Contents.
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 signature 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 signature'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 signature's byte range does not exclude exactly its contents.");
}
let Some(contents) = unhex(&gap[1..gap.len() - 1]) else { return invalid(info, "The signature contents are not hexadecimal.") };
let covered = o1 + l1;
info.signed_len = covered;
// The signed revision's number: its cross-reference sections (1 for a reconstructed file).
info.revision = cache.revision(bytes, covered).map_or(1, |d| d.revisions().len().max(1));
if info.sub_filter.as_deref() == Some("adbe.x509.rsa_sha1") {
info.details.push("This signature uses the legacy adbe.x509.rsa_sha1 format, which PrintCraft does not validate yet.".into());
return;
}
let sd = match SignedData::parse(&contents) {
Ok(sd) => sd,
Err(e) => return invalid(info, &format!("The signature could not be read ({e}).")),
};
let s = &sd.signer;
info.digest = Some(s.digest);
info.timestamp = s.timestamp;
// 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.
Some(c) => {
if *c != cache.digest(DigestAlg::Sha1, bytes, l0, o1, covered) {
return invalid(info, "The document has been altered or corrupted since the signature was applied.");
}
s.digest.digest(&[c])
}
None => cache.digest(s.digest, bytes, l0, o1, covered),
};
if let Some(md) = &s.message_digest
&& *md != content_digest
{
return invalid(info, "The document has been altered or corrupted since the signature was applied.");
}
let Some(cert) = sd.signer_certificate().cloned() else {
return invalid(info, "The signer's certificate is not in the signature.");
};
info.algorithm = Some(format!("{} with {}", cert.public_key.describe(), s.scheme_digest.unwrap_or(s.digest).name()));
info.signer = Some(cert.display_name());
if !sd.verify_signature(&cert, &content_digest) {
info.certificate = Some(cert);
return invalid(info, "The signature value does not match the signer's certificate: the signature is corrupt.");
}
info.signing_time = s.signing_time.or_else(|| info.date.as_deref().and_then(Time::from_pdf));
let mut pool = sd.certificates.clone();
pool.extend(trust.certs.iter().cloned());
let chain: Vec<Certificate> = build_chain(&cert, &pool).into_iter().cloned().collect();
let trusted = chain.iter().any(|c| trust.trusts(c));
info.chain = chain;
info.certificate = Some(cert.clone());
// Changes after signing.
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), info.certify)
};
let mut problems = false;
match &info.modification {
Modification::None => info.details.push("This document has not been modified since this signature was applied.".into()),
Modification::Allowed(kinds) => info
.details
.push(format!("The document has been modified since this signature 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 signature was applied in ways it does not permit ({}).", kinds.join(", ")));
problems = true;
}
}
if let Some(t) = 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 {
info.status = Status::Unknown;
}
problems = true;
}
if !trusted {
info.details.push(
"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 {
info.status = Status::Unknown;
}
} else {
info.details.push("The signer's identity is valid.".into());
if !problems {
info.status = Status::Valid;
}
}
if info.timestamp {
info.details.push("The signature includes an embedded timestamp.".into());
} else {
info.details.push("Signing time is from the clock on the signer's computer.".into());
}
}
/// What later revisions changed, classified as Acrobat reports it, under DocMDP `p` (or none:
/// an approval signature permits form fill, comments and further signatures).
fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Modification {
let Some(old) = old else {
return Modification::Disallowed(vec!["the signed version could not be read".into()]);
};
let old_content: HashSet<ObjRef> = printcraft_annot::page_refs(&old)
.unwrap_or_default()
.into_iter()
.flat_map(|pg| {
let c = old.get(pg).as_dict().and_then(|d| d.get(b"Contents").cloned());
match c {
Some(Object::Ref(r)) => match &*old.get(r) {
Object::Array(a) => a.iter().filter_map(Object::as_ref).chain([r]).collect::<Vec<_>>(),
_ => vec![r],
},
Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(),
_ => Vec::new(),
}
})
.collect();
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 {
use printcraft_cos::XrefEntry;
if doc.is_edited(num) {
return false;
}
match (doc.xref_entry(num), old.xref_entry(num)) {
(Some(a @ XrefEntry::InFile { .. }), Some(b)) => a == b,
(Some(a @ XrefEntry::InStream { stream, .. }), Some(b)) => {
a == b && !doc.is_edited(stream) && doc.xref_entry(stream) == old.xref_entry(stream)
}
_ => false,
}
};
for num in doc.object_numbers() {
if same_place(num) {
continue;
}
let r = ObjRef { num, generation: doc.generation(num) };
let new = doc.get(r);
let before = old.try_get(num).ok().filter(|o| !matches!(**o, Object::Null));
if before.as_deref() == Some(&*new) {
continue;
}
let kind = match &*new {
Object::Dict(d) => change_kind(doc, &old, d, before.as_deref().and_then(Object::as_dict)),
Object::Stream(s) => {
if old_content.contains(&r) {
"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 {
"other changes"
}
}
_ if before.is_none() => continue,
_ => "other changes",
};
let ok = match (kind, p) {
("signature" | "document security store", _) => true,
("form fill", Some(2 | 3) | None) => true,
("comments", Some(3) | None) => true,
// Every save updates /Info (ModDate), signing included.
("metadata", _) => true,
_ => false,
};
let list = if ok { &mut allowed } else { &mut disallowed };
if !list.contains(&kind) {
list.push(kind);
}
}
let own = |v: Vec<&str>| v.into_iter().map(str::to_string).collect::<Vec<_>>();
if disallowed.is_empty() { Modification::Allowed(own(allowed)) } else { Modification::Disallowed(own(disallowed)) }
}
/// What adding `added` annotations amounts to: signing (signature widgets only), form fill
/// (other widgets) or comments.
fn added_annots(doc: &Document, added: &[ObjRef]) -> &'static str {
let kinds: Vec<(bool, bool)> = added
.iter()
.map(|r| {
let o = doc.get(*r);
let d = o.as_dict();
(d.is_some_and(|d| d.name(b"Subtype") == Some(b"Widget")), d.is_some_and(|d| d.name(b"FT") == Some(b"Sig")))
})
.collect();
if !kinds.is_empty() && kinds.iter().all(|(w, s)| *w && *s) {
"signature"
} else if !kinds.is_empty() && kinds.iter().all(|(w, _)| *w) {
"form fill"
} else {
"comments"
}
}
fn refs(doc: &Document, o: Option<&Object>) -> Vec<ObjRef> {
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_ref).collect())).unwrap_or_default()
}
/// The interactive form dictionary of a document, resolved.
fn acroform(doc: &Document) -> Option<Dict> {
let c = doc.get(doc.root()?).as_dict()?.get(b"AcroForm").cloned()?;
doc.resolve(&c).as_dict().cloned()
}
/// An AcroForm change that only adds signature fields (and sets `/SigFlags`) is signing.
/// Without `before`, the signed revision's AcroForm (wherever it was) is the reference.
fn acroform_kind(doc: &Document, old: &Document, now: &Dict, before: Option<&Dict>) -> &'static str {
let fallback = acroform(old).unwrap_or_default();
let before = before.unwrap_or(&fallback);
let strip = |d: &Dict| -> Vec<(Vec<u8>, Object)> {
let mut v: Vec<(Vec<u8>, Object)> =
d.iter().filter(|(k, _)| !matches!(k.as_slice(), b"Fields" | b"SigFlags")).map(|(k, o)| (k.to_vec(), o.clone())).collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
};
if strip(now) != strip(before) {
return "form fill";
}
let old_fields = refs(old, before.get(b"Fields"));
let added: Vec<ObjRef> = refs(doc, now.get(b"Fields")).into_iter().filter(|r| !old_fields.contains(r)).collect();
if added.is_empty() || added_annots(doc, &added) == "signature" { "signature" } else { "form fill" }
}
fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) -> &'static str {
let ty = d.name(b"Type");
let without = |x: &Dict, keys: &[&[u8]]| -> Vec<(Vec<u8>, Object)> {
let mut v: Vec<(Vec<u8>, Object)> = x.iter().filter(|(k, _)| !keys.contains(&k.as_slice())).map(|(k, o)| (k.to_vec(), o.clone())).collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
};
match ty {
Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") => return "signature",
Some(b"DSS") => return "document security store",
Some(b"Catalog") => {
let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering"];
return match before {
Some(b) if without(d, keys) != without(b, keys) => "document structure",
Some(b) => {
if d.get(b"AcroForm") != b.get(b"AcroForm") {
// Compare the forms themselves, inline or not.
let now = acroform(doc).unwrap_or_default();
acroform_kind(doc, old, &now, None)
} else if b.get(b"Perms") != d.get(b"Perms") {
"signature"
} else {
"metadata"
}
}
None => "form fill",
};
}
Some(b"Page") => {
return match before {
Some(b) if without(d, &[b"Annots"]) != without(b, &[b"Annots"]) => "page content",
Some(b) => {
let was = refs(old, b.get(b"Annots"));
let added: Vec<ObjRef> = refs(doc, d.get(b"Annots")).into_iter().filter(|r| !was.contains(r)).collect();
added_annots(doc, &added)
}
None => "comments",
};
}
Some(b"Pages") => return if before.is_some() { "pages added or removed" } else { "document structure" },
Some(b"Metadata") => return "metadata",
_ => {}
}
match d.name(b"Subtype") {
Some(b"Widget") => return if d.name(b"FT") == Some(b"Sig") { "signature" } else { "form fill" },
Some(b"Link") => return "links",
Some(_) if ty == Some(b"Annot") || d.contains(b"Rect") => return "comments",
_ => {}
}
if d.name(b"FT") == Some(b"Sig") {
return "signature";
}
if d.contains(b"Fields") {
return acroform_kind(doc, old, d, before);
}
if d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids")) || d.contains(b"DR") {
return "form fill";
}
if d.contains(b"Producer") || d.contains(b"ModDate") || d.contains(b"CreationDate") {
return "metadata";
}
if before.is_none() {
// New objects referenced by changed ones (fonts, appearance resources).
return "form fill";
}
"other changes"
}
// ── signing ─────────────────────────────────────────────────────────────────────────────────
/// What the visible signature shows (Acrobat: Configure Signature Appearance).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Appearance {
pub name: bool,
pub date: bool,
pub reason: bool,
pub location: bool,
pub distinguished_name: bool,
pub labels: bool,
}
impl Default for Appearance {
fn default() -> Self {
Self { name: true, date: true, reason: false, location: false, distinguished_name: false, labels: true }
}
}
#[derive(Clone, Debug, Default)]
pub struct SignOptions {
/// Sign this existing, unsigned signature field.
pub field: Option<String>,
/// Or create a field on this page (0-based) with this rectangle (`None`: invisible).
pub page: usize,
pub rect: Option<[f64; 4]>,
/// Name for a new field (default `Signature1`, `Signature2`…).
pub new_field_name: Option<String>,
pub reason: Option<String>,
pub location: Option<String>,
pub contact: Option<String>,
/// The signing time as a PDF date (`/M`), from the caller's clock.
pub date: String,
/// Certify with these DocMDP permissions (1 no changes, 2 form fill and signing, 3 also comments).
pub certify: Option<u8>,
pub appearance: Appearance,
}
/// Placeholder `/ByteRange` values: fixed width, patched after writing.
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> {
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 name = id.certificate.display_name();
// The signature dictionary.
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.CAdES.detached"));
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; reserve], hex: true }));
v.set(b"M".to_vec(), PdfString::literal(opts.date.as_bytes().to_vec()));
v.set(b"Name".to_vec(), PdfString::text(&name));
for (k, val) in [(&b"Reason"[..], &opts.reason), (b"Location", &opts.location), (b"ContactInfo", &opts.contact)] {
if let Some(s) = val.as_deref().filter(|s| !s.trim().is_empty()) {
v.set(k.to_vec(), PdfString::text(s));
}
}
let mut app = Dict::new();
app.set(b"Name".to_vec(), Object::name("PrintCraft"));
let mut build = Dict::new();
build.set(b"App".to_vec(), Object::Dict(app));
v.set(b"Prop_Build".to_vec(), Object::Dict(build));
if let Some(p) = opts.certify {
let mut tp = Dict::new();
tp.set(b"Type".to_vec(), Object::name("TransformParams"));
tp.set(b"P".to_vec(), Object::Int(p.clamp(1, 3) as i64));
tp.set(b"V".to_vec(), Object::name("1.2"));
let mut sr = Dict::new();
sr.set(b"Type".to_vec(), Object::name("SigRef"));
sr.set(b"TransformMethod".to_vec(), Object::name("DocMDP"));
sr.set(b"TransformParams".to_vec(), Object::Dict(tp));
v.set(b"Reference".to_vec(), Object::Array(vec![Object::Dict(sr)]));
}
let sig = doc.add(Object::Dict(v.clone()));
if opts.certify.is_some() {
// Catalog /Perms /DocMDP points at the certification signature.
let mut perms = Dict::new();
perms.set(b"DocMDP".to_vec(), Object::Ref(sig));
doc.update_dict(root, |c| c.set(b"Perms".to_vec(), Object::Dict(perms)))?;
}
// The field and its widget.
let pages = printcraft_annot::page_refs(&doc).map_err(|e| SignError::Pdf(e.to_string()))?;
let existing = opts
.field
.as_deref()
.map(|n| sig_fields(&doc).into_iter().find(|f| f.name == n).ok_or_else(|| SignError::Pdf(format!("there is no signature field {n:?}"))));
let (widget, rect) = match existing {
Some(f) => {
let f = f?;
if f.dict.contains(b"V") {
return Err(SignError::Pdf(format!("the field {:?} is already signed", f.name)));
}
let fr = f.r.ok_or_else(|| SignError::Pdf("the field is not an indirect object".into()))?;
doc.update_dict(fr, |d| d.set(b"V".to_vec(), Object::Ref(sig)))?;
let (wr, wd) = f.widgets.first().cloned().ok_or_else(|| SignError::Pdf("the field has no widget".into()))?;
let rect = nums(&doc, &wd, b"Rect").filter(|r| r.len() == 4).map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
(wr.unwrap_or(fr), rect.unwrap_or([0.0; 4]))
}
None => {
let page = *pages.get(opts.page).ok_or_else(|| SignError::Pdf(format!("page {} does not exist", opts.page + 1)))?;
let taken: HashSet<String> = sig_fields(&doc).into_iter().map(|f| f.name).collect();
let fname = opts
.new_field_name
.clone()
.unwrap_or_else(|| (1..).map(|i| format!("Signature{i}")).find(|n| !taken.contains(n)).unwrap_or_default());
let rect = opts.rect.unwrap_or([0.0; 4]);
let mut w = Dict::new();
w.set(b"Type".to_vec(), Object::name("Annot"));
w.set(b"Subtype".to_vec(), Object::name("Widget"));
w.set(b"FT".to_vec(), Object::name("Sig"));
w.set(b"T".to_vec(), PdfString::text(&fname));
w.set(b"V".to_vec(), Object::Ref(sig));
w.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|x| Object::Real(*x)).collect()));
w.set(b"P".to_vec(), Object::Ref(page));
// Print + Locked.
w.set(b"F".to_vec(), Object::Int(4 | 128));
let wr = doc.add(Object::Dict(w));
// Page /Annots (indirect arrays are updated in place).
let pd = doc.get(page).as_dict().cloned().unwrap_or_default();
match pd.get(b"Annots") {
Some(Object::Ref(ar)) => {
let mut a = doc.get(*ar).as_array().cloned().unwrap_or_default();
a.push(Object::Ref(wr));
doc.set(*ar, Object::Array(a));
}
other => {
let mut a = other.and_then(|o| o.as_array().cloned()).unwrap_or_default();
a.push(Object::Ref(wr));
doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(a)))?;
}
}
// AcroForm /Fields and /SigFlags (signatures exist, append only).
let catalog = doc.get(root).as_dict().cloned().unwrap_or_default();
let (af_ref, mut af) = match catalog.get(b"AcroForm") {
Some(Object::Ref(r)) => (Some(*r), doc.get(*r).as_dict().cloned().unwrap_or_default()),
Some(Object::Dict(d)) => (None, d.clone()),
_ => (None, Dict::new()),
};
let mut fields = match af.get(b"Fields") {
Some(Object::Ref(r)) => doc.get(*r).as_array().cloned().unwrap_or_default(),
Some(Object::Array(a)) => a.clone(),
_ => Vec::new(),
};
fields.push(Object::Ref(wr));
af.set(b"Fields".to_vec(), Object::Array(fields));
af.set(b"SigFlags".to_vec(), Object::Int(3));
match af_ref {
Some(r) => doc.set(r, Object::Dict(af)),
// Inline (or new) AcroForms stay in the catalog.
None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?,
}
(wr, rect)
}
};
if opts.field.is_some() {
doc.update_dict(root, |c| {
if let Some(Object::Dict(af)) = c.get_mut(b"AcroForm") {
af.set(b"SigFlags".to_vec(), Object::Int(3));
}
})?;
if let Some(Object::Ref(r)) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) {
doc.update_dict(r, |af| af.set(b"SigFlags".to_vec(), Object::Int(3)))?;
}
}
// The appearance.
let ap = appearance(rect, &name, &id.certificate, opts);
let ap_ref = doc.add(Object::Stream(ap));
let mut apd = Dict::new();
apd.set(b"N".to_vec(), Object::Ref(ap_ref));
doc.update_dict(widget, |w| w.set(b"AP".to_vec(), Object::Dict(apd)))?;
// Write with the placeholders, then patch them.
let save = SaveOptions { mod_date: Some(opts.date.clone()), object_streams: false, ..SaveOptions::default() };
let mut out = printcraft_cos::write_incremental(&doc, &save)?;
let (br_at, gap) = locate(&out, 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 sign".into()));
}
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)?;
if cms.len() > reserve {
return Err(SignError::Pdf("the signature does not fit its placeholder".into()));
}
let hex: Vec<u8> = cms.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect();
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).
fn locate(out: &[u8], reserve: usize) -> Result<(usize, (usize, usize)), SignError> {
let mark = format!("0 {} {} {}", BR_MARK[0], BR_MARK[1], BR_MARK[2]);
let hits: Vec<usize> = out.windows(mark.len()).enumerate().filter(|(_, w)| *w == mark.as_bytes()).map(|(i, _)| i).collect();
let [br] = hits.as_slice() else { return Err(SignError::Pdf("ByteRange placeholder not found".into())) };
let start = out[..*br].windows(3).rposition(|w| w == b"obj").ok_or_else(|| SignError::Pdf("signature object not found".into()))?;
let end = br + out[*br..].windows(6).position(|w| w == b"endobj").ok_or_else(|| SignError::Pdf("unterminated signature object".into()))?;
let region = &out[start..end];
let mut zeros = vec![b'<'];
zeros.extend(std::iter::repeat_n(b'0', reserve * 2));
zeros.push(b'>');
let c = region.windows(zeros.len()).position(|w| w == zeros.as_slice()).ok_or_else(|| SignError::Pdf("Contents placeholder not found".into()))?;
Ok((*br, (start + c, start + c + zeros.len())))
}
/// "2026.10.02 14:03:11 +02'00'" from a PDF date.
pub fn display_date(pdf: &str) -> String {
let s = pdf.strip_prefix("D:").unwrap_or(pdf);
let g = |r: std::ops::Range<usize>| s.get(r).unwrap_or("00");
let tz = match s.get(14..).filter(|z| !z.is_empty()) {
Some(z) if z.starts_with('Z') => " Z".to_string(),
Some(z) => format!(" {z}"),
None => String::new(),
};
format!("{}.{}.{} {}:{}:{}{tz}", g(0..4), g(4..6), g(6..8), g(8..10), g(10..12), g(12..14))
}
/// The visible signature: the signer's name large on the left, the details on the right
/// (Acrobat's standard layout), in Helvetica.
fn appearance(rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Stream {
use printcraft_fonts::{helvetica_width, literal, win_ansi, wrap};
let (w, h) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0));
let a = &opts.appearance;
let mut lines: Vec<String> = Vec::new();
let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() };
if a.name {
lines.push(if a.labels { "Digitally signed by".into() } else { String::new() });
lines.push(name.to_string());
}
if a.distinguished_name {
lines.push(label("DN: ", &cert.subject.display()));
}
if a.reason
&& let Some(r) = opts.reason.as_deref().filter(|r| !r.is_empty())
{
lines.push(label("Reason: ", r));
}
if a.location
&& let Some(l) = opts.location.as_deref().filter(|l| !l.is_empty())
{
lines.push(label("Location: ", l));
}
if a.date {
lines.push(label("Date: ", &display_date(&opts.date)));
}
lines.retain(|l| !l.is_empty());
let mut out = Vec::new();
if w > 1.0 && h > 1.0 {
let pad = (h * 0.06).clamp(1.0, 6.0);
let (left_w, right_x) = if a.name { (w * 0.5, w * 0.5 + pad) } else { (0.0, pad) };
let right_w = (w - right_x - pad).max(1.0);
out.extend_from_slice(b"BT\n0 g\n");
if a.name {
// The name fills the left half.
let mut size = (h * 0.4).min(36.0);
let fit = (left_w - 2.0 * pad).max(1.0);
let longest = helvetica_width(name, 1.0).max(0.01);
size = size.min(fit / longest).max(4.0);
let words = wrap(name, size, fit);
let total = words.len() as f64 * size * 1.1;
let mut y = (h + total) / 2.0 - size * 0.85;
for l in &words {
out.extend(format!("/Helv {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(pad), fmt(y)).bytes());
out.extend(literal(&win_ansi(l)));
out.extend_from_slice(b" Tj\n");
y -= size * 1.1;
}
}
// The details, shrunk to fit the right side.
let mut size = (h / (lines.len().max(1) as f64 * 1.15)).min(12.0);
let wrapped = loop {
let all: Vec<String> = lines.iter().flat_map(|l| wrap(l, size, right_w)).collect();
if all.len() as f64 * size * 1.15 <= h - 2.0 * pad || size <= 3.0 {
break all;
}
size *= 0.9;
};
let mut y = h - pad - size * 0.9;
for l in &wrapped {
out.extend(format!("/Helv {} Tf 1 0 0 1 {} {} Tm ", fmt(size), fmt(right_x), fmt(y)).bytes());
out.extend(literal(&win_ansi(l)));
out.extend_from_slice(b" Tj\n");
y -= size * 1.15;
}
out.extend_from_slice(b"ET\n");
}
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, w, h].iter().map(|x| Object::Real(*x)).collect()));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
d.set(b"Resources".to_vec(), Object::Dict(res));
Stream::flate(d, &out)
}
fn fmt(v: f64) -> String {
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}