Files
pdfcraft/crates/sign
Chiranjeevi Uandskylar taylor-barrick 5fdd820bdb fix(ui): validated signature display, snapshot tab reuse (#1895)
* fix(ui): validated signature display, snapshot tab reuse

Signature widgets with layered appearances baked the unverified state
into the page, so a valid signature kept reading 'Signature Not Verified'
while the banner and panel said valid. Validated verdicts are now drawn
into the icon and caption layers in the display copy only: the signed
bytes are untouched, saving keeps the original appearance, and files
without those layers or without a valid verdict render exactly as before.

Other valid signatures still get the verdict mark over their widget,
sharing one style with the Signatures panel, and hide it again once the
appearance itself shows the verdict.

View signed version and View revision opened a new tab on every click,
and every tab showed the document title, so repeats piled up identical
tabs. Repeats now reuse the snapshot tab, snapshot tabs read like their
source plus the suffix in the tab strip, window title and Combine picker,
and carry a notice bar naming the revision with a Close that asks about
saving first.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Address review comments on validated appearances and trust refresh

Count every field when checking shared appearance streams, so a
caption shared with an unvalidated field is never rewritten for the
valid one alone.

Pick the icon slot by drawing instead of length, and only when a
single layer qualifies. Bare rectangles never count as the icon, and
ambiguous or missing slots fall back to the overlay badge.

Revalidate and invalidate together through one helper, so trusting a
certificate always drops rendered pages. Trust alone still marks
nothing dirty.

Tests: mixed-verdict sharing, longer decorative borders, competing
and doubled layers, capture-before preservation, trust invalidation
without zooming, and signed Session round trips with rendering,
saving and passwords.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Prove the icon slot geometrically and cover real layered signatures

A lone line drawing is still not the icon: frames and borders span the
box while the status mark lives in a corner of it. The icon now has to
sit strictly inside the appearance frame and cover at most half of it;
anything else falls back to the overlay badge. Widgets without a
measurable frame veto the rewrite the same way.

Tests: parsed extents and inhabitant checks as units, both reported
border repros plus a frameless widget, and real layered signatures
built with cms::sign_detached through Session open, trust, rendering,
save and reopen, plain and password-protected.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Identify the icon by shape instead of geometry

A lone line drawing is still not the icon, but size and position were
the wrong signals: the real status mark is large, and geometry blocked
it. Frames, borders and panels are rectangles by nature while the mark
is not, so a drawing made only of axis-aligned rectangles is decoration
however it is stroked. Singularity, shared-stream and template checks
are unchanged; anything ambiguous still falls back to the overlay badge.

Tests: rectangularity as a unit, nested line-drawn frames, and a
triangular fixture mark in place of the rectangular one.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Budget the appearance walk and keep the marks with the bytes

The walk that finds a signature's icon and caption capped depth,
layers and fields, but not the work: a dictionary whose entries all
point at the next one is visited once per entry per level, so a small
hostile file never finishes opening. The walk now counts its visits
and gives up, and a field whose tree cannot be read to the end stops
the whole pass, because a layer we never read may be the one two
fields share and a rewritten shared layer puts one field's verdict on
another field's page. The sharing count is exact again with that: two
widgets of one field pointing at the same appearance claim it once,
or the field vetoes its own rewrite.

A mark used to outlive the bytes it described. A failed display write
left the page showing the file's own caption while the verdict still
claimed its state was drawn; a metadata edit dropped the mark while
the page kept drawing the state, so the badge landed on top of it. The
write fallback now drops the marks and logs the loss, and metadata
carries them over.

Tests: a self-referential appearance tree, which never finished before
the budget, two widgets sharing one appearance, a metadata edit that
keeps its mark, a save that goes through the writer instead of
short-circuiting on the opened file, and the snapshot bar's Close
button pressed for real.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* Cite the new regressions in parity, restore main's installer note

The appearance entry now cites the bounded walk and the save through
the writer, and says what a tree that cannot be read to the end does.
The installer entry carried main's older text, which still claims
signed installers are planned; main's newer note is taken verbatim.

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>

* M9: preserve rounded decoration beside raster status glyphs

* M9: place appearance progress beside signature history

* M9: retain appearances with ambiguous curved layers

* M9: keep signature verdicts in viewer overlays

Defer generic appearance rewriting: caption text and a unique drawing do not establish an icon role. Preserve original artwork for saves, image and text exports, and both print modes while retaining snapshot reuse and trust refresh. Cover chamfered borders and mixed signer/caption streams through signed Session round trips.

---------

Signed-off-by: Chiranjeevi U <244287281+chiranjeevi-max@users.noreply.github.com>
Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
2026-10-15 19:16:48 -06:00
..

pdfcraft-sign

Layer L4: digital signatures (execution plan M9; ISO 32000-2 §12.8; PAdES, ETSI EN 319 142).

let id = pkcs12::open(&std::fs::read("me.p12")?, "password")?;          // a digital ID
let signed = sign(&doc, &id, &SignOptions { page: 0, rect: Some(r), date, ..Default::default() })?;
for s in signatures(&doc, &bytes, &trust) {                                // list + validate
    println!("{}: {} — {:?}", s.field, s.summary(), s.details);
}
  • Formats, on RustCrypto primitives: a small DER reader and writer (der), X.509 certificates (x509), CMS SignedData (cms), and PKCS #12 digital ID files (pkcs12): PBES2 (PBKDF2 + AES/3DES), the legacy SHA-1 3DES/RC2 schemes, and MAC checks. Writing uses OpenSSL 3's defaults (AES-256-CBC, HMAC-SHA-256).
  • Keys: RSA, ECDSA P-256 and P-384. Verification uses RustCrypto everywhere. RSA private-key operations (signing, key generation) use aws-lc-rs on native targets and are refused in the browser (ADR-0009: the rsa crate's Marvin advisory, RUSTSEC-2023-0071, has no fix, so rsa is only used to verify). ECDSA signs with deterministic nonces (RFC 6979).
  • Signing: PAdES B-B (ETSI.CAdES.detached, signing-certificate-v2, SHA-256/384, no SHA-1). An existing unsigned field or a new one (visible with Acrobat's name-and-details appearance, or invisible); certification with DocMDP P=1/2/3. A visible signature can show a Graphic where the large name would be: drawn strokes, filled outlines (a typed signature) or RGBA pixels (an image XObject with a soft mask), checked and size-capped first. 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: only objects reached through /Certs, /CRLs, /OCSPs and /VRI with the shape of their role, and new or only grown since the signature, count (a /DSS entry naming a page's contents, or a /Type /DSS label, does not). 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, direct PKCS #11 access to tokens. macOS Keychain and Windows Current User Personal (My) store identities sign through ExternalKey without exporting private keys. Windows CNG supports RSA PKCS #1 v1.5 and ECDSA P-256/P-384; the store integration is tested with software-backed keys. A smart card or token whose driver registers a CNG key storage provider is such an identity too: the key is opened for signing without the silent flag, so the provider shows its own PIN dialog (checked by hand with a SafeNet token; CI has no hardware). A store identity signs with the issuers Windows chains its certificate to, without the root. Store certificates that can't sign (no private key, another key type, a key CNG can't open) are returned by windows::list as unusable with the reason and the Windows error code, shown greyed in the Sign dialog and by sign_windows_ids, instead of being left out silently; a key that needs a prompt to open is listed and asks at signing (issue #179).

  • 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.
  • Chains are checked, not just linked. x509::build_chain only takes an issuer whose key verifies the certificate below it and that may issue: basicConstraints CA:TRUE (an old v1 self-signed root without constraints counts), keyCertSign when it has a key usage, a pathLenConstraint that allows the CAs below it, and, given the signing time, validity then. Certificates embedded in a document are in the pool too, so an ordinary subscriber certificate can never vouch for another one; when an issuer is refused, the signature's details say why.
  • The signer's certificate must allow signing documents (Certificate::signing_problem): a key usage needs digitalSignature or nonRepudiation, an extended key usage needs a document-signing purpose (any, emailProtection, codeSigning, RFC 9336 documentSigning, Adobe Authentic Documents, Microsoft document signing), and no critical extension may be one PdfCraft doesn't process. Otherwise the verdict is unknown, never valid, and the details say why.
  • BER as well as DER. The CMS is read with der::Tlv::parse_ber, as Windows CryptoAPI, Adobe PPKMS, DocuSign, Documenso and openssl cms -stream write it: indefinite lengths and an OCTET STRING split into segments. Only the structure is read that way; certificates and signed attributes are still verified over their own exact bytes (signed attributes written with an indefinite length are re-encoded as DER first, as RFC 5652 §5.4 has them signed). Nesting is bounded, and the details say that the signature was BER. High tag numbers are not read.
  • Later changes are classified narrowly. Only the catalog's own /Metadata stream is the document's XMP, and only objects reached through the /DSS keys above are the security store. A /Type /Metadata or /Type /DSS label on any other dictionary or stream (a page's Form XObject, say) makes nothing permitted, at every DocMDP level.
  • Strict in what it checks, tolerant where signers differ. RSA PKCS #1 signatures are checked against the one encoding the digest should have, built here and compared byte for byte; the DigestInfo may omit its NULL parameter, as older signers write it (RFC 8017 App. B.1), but a signature over the bare digest, which doesn't say which hash it uses, is refused. A signature value must be below the modulus (RFC 8017 §5.2.2). RSA-PSS uses the salt length it declares. ECDSA is read as DER, non-minimal DER or raw r ‖ s. adbe.x509.rsa_sha1 is validated. What the check tolerated is listed in the signature's details: a DigestInfo without its NULL, an RSA value that isn't modulus-length, ECDSA that isn't canonical DER, a SignerInfo whose digest algorithm differs from the one the signature algorithm names. What PdfCraft can't check (an unknown algorithm or curve) is unknown, never invalid.
  • Weak algorithms still validate, and say so. SHA-1 and RIPEMD-160 signatures are common in documents signed years ago, so they validate (new signatures never use SHA-1), but the signature's details say that the algorithm is weak and shouldn't be relied on.
  • Validation algorithms: digests SHA-1, SHA-224/256/384/512, SHA-512/224, SHA-512/256, SHA3-224/256/384/512 and RIPEMD-160. RSA PKCS #1 v1.5 and RSASSA-PSS (the hash, MGF1 hash and salt length the signature declares), ECDSA on P-256, P-384, P-521, brainpoolP256r1, P384r1 and P512r1 (also BSI "plain" ECDSA), and Ed25519 (RFC 8419, over the signed attributes). Signing: RSA, P-256, P-384 with SHA-256/384/512. The RSA padding checks and brainpoolP512r1 are written here (rsa_pad, ec512) on crypto-bigint, because the typed APIs of the rsa crate and RustCrypto don't cover them.

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.