The signing saves wrote with default options, so a redaction mark added
and cleared in the session (or a comment added and deleted) reached the
file through a signature, a timestamped signature, embedded LTV evidence
or a document timestamp, with its QuadPoints, overlay text and authors.
A signed revision can't be changed afterwards, so nothing could take it
out again.
`sign`, `sign_with_timestamp` and `dss::embed` now save with
`drop_unlinked`: everything they add is reachable (the signature through
the field's `/V`, the store from the catalog), and the ByteRange
placeholder is found in the written bytes.
`timestamp_document` can't: its `/DocTimeStamp` dictionary has no field
pointing to it, so the option would drop it and every document timestamp
would fail with "ByteRange placeholder not found". It frees the leftovers
first with the new `Document::free_unlinked_additions`, which frees
exactly what the option leaves out, and then adds the dictionary.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M9: visible signatures read upright on rotated pages
A visible digital signature placed on a page with /Rotate was drawn in
user space, so its graphic and details turned with the page: on scans
stored landscape with /Rotate 270 the signature came out sideways.
Stamps (#1398) and Fill & Sign marks (#2392) were already corrected;
signatures were not.
A new signature widget on a turned page gets /MK /R equal to the page
rotation (inherited rotation included, via pdfcraft_model::pages), and
its appearance is laid out in the box as displayed (sides swapped for a
quarter turn) with a /Matrix from pdfcraft_model::view_matrix_for that
maps it exactly onto /Rect. Signing an existing field follows that
field's own /MK /R, so a field without one is drawn as before. Pages
that are not turned produce the same bytes as before.
Tests: sign crate (layout equals the upright box's, /MK /R, /Matrix,
inherited /Rotate, existing fields with and without /MK /R, malformed
/MK /R) and an engine render test on 0/90/180/270 pages with the
quadrant image. Both fail with the fix reverted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep one evidence/notes pair in the sign.digital parity entry
---------
Co-authored-by: vjeko2404 <vjekoslav@outlook.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* test(sign): serialize the Keychain search-list tests
Both ignored Keychain tests save the user's keychain search list, change it
and restore what they saved. Run together (`--ignored` runs them in
parallel), one could restore its list while the other needed its temporary
keychain, or put back a keychain the other had already deleted. A shared
lock now runs them one at a time.
With the lock, the issuer test still failed about one run in ten when it
came second: the Security framework caches the search list in-process and
learns of `security list-keychains` asynchronously, so `keychain::find` did
not yet look in the new keychain. The test now waits up to five seconds for
the identity to appear.
Follows up a CodeRabbit review comment on #3716.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(sign): restore the keychain search list before deleting the keychain
If deleting the temporary keychain panicked, the test left it on the user's
search list, and the next Keychain test (which accepts the poisoned lock)
saved that list and later restored it. Restore the list first.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(sign): fail when the keychain search list isn't restored
Both Keychain tests ignored the result of the `security list-keychains` that
restores the user's search list, so a failed restore went unnoticed and left
the deleted temporary keychain on the list. They now check it after deleting
the keychain.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* test(sign): keep the issuer test's keychain at one path
trustd remembers which keychain file it found an issuer in. Each run of
`keychain_identities_carry_their_issuers` created its keychain at a path with
the process id and deleted it afterwards, so the next run's trust evaluation
kept looking in the deleted file and built an empty chain, until trustd
forgot it. The test failed every run after the first for a while.
The keychain now lives at the same path every run, and a copy left by a
killed run is deleted first.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: ffishman <20211677+ffishman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Redact: refuse Redact marks written in place; remove replies and pop-ups listed on
other pages; delete form fields by object, not name; resolve Do by its last operand
like the renderer; sanitize drops PCAdded and flattens widgets before hidden-content
cleanup. Sign: freed signed objects and edits to an already-signed widget are
modifications. Cos: only reading trusts /Type /XRef to skip encryption.
The evidence caps counted repeated references to one stream, so a /DSS
array could list one benign stream 1024 times ahead of a revoking CRL,
and the check never reached it. Repeats are skipped before the entry
cap counts anything, and when a cap still stops an array being read in
full without a revocation found, the signature's status is unknown
rather than valid, with the reason in its details.
Review follow-up: an indirect /Annots array that drops a signed
annotation while gaining a signature widget was judged by the widget
alone, so a form-fill certification let a signed comment be deleted.
A removed comment is now a comment change, and a removed widget is a
change no permission covers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
PAdES LTV writers add the /DSS and, in the same revision, declare the
ESIC developer extension in the catalog's /Extensions (ISO 32000-2
§7.12). Any catalog change outside a short key list was "document
structure", so the signature such an update protects showed as invalid.
/Extensions may now gain declarations after signing; one that was
signed may still not change or go.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A page whose /Annots is an object of its own keeps its dictionary when a
later revision adds a signature widget or a comment: only the array
changes. That array fell through to "other changes", so countersigning
such a document (as many signing services do) turned every earlier
signature invalid, including PdfCraft's own second signature.
A changed array that was a signed page's /Annots is now classified like
an inline /Annots: by the annotations it gained (signature, form fill or
comments), under the same DocMDP rules.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
parse_response compared only the digest algorithm and imprint with the
query, and the token parser dropped the TSTInfo nonce. A genuine token for
the same imprint from another request or under another policy was
accepted. The token keeps its nonce, and a requested nonce or policy must
match.
A timestamp counted as trusted time whenever its signer's certificate
chained to a trusted certificate, whatever the certificate was issued
for. Any key from a trusted CA could then vouch for an arbitrary time.
Trusted time, for a document timestamp and for a signature's embedded
one, now also needs a certificate whose extended key usage includes
timeStamping, whose key usage allows signing, and which has no critical
extension PdfCraft doesn't process (RFC 3161 §2.3). The test timestamp
authority gets such a certificate.
Chain building accepted an intermediate CA without looking at its critical
extensions, which were checked only on the signer's certificate. A
subordinate CA limited by critical name constraints could vouch for a
signer outside its namespace, because PdfCraft does not enforce those
constraints. A CA certificate (other than a self-signed root, whose
limits are the trust store's business) with a critical extension
PdfCraft doesn't process is refused as an issuer, and the reason is
reported.
Revision checks compared objects only. A later revision that pointed the
trailer at another catalog already present in the signed file changed no
object, so the signature stayed valid while the reader showed a different
document. A /Root that differs from the signed revision's is now a
disallowed change to the document structure.
An OCSP response's signer counted as the issuer itself whenever its
certificate carried the issuer's subject name. A response naming its
responder by key hash could embed a certificate for any key with the
issuer's name copied in, skip the delegation checks, and mark a valid
certificate revoked. Whether the responder is the issuer now follows from
how it was selected (the issuer's own name or key hash); every embedded
certificate must be signed by the issuer and carry the OCSP-signing
purpose.
* Build each crate's integration tests as one binary
Every tests/*.rs file is its own executable that links the whole crate
graph. Move each crate's test files into tests/it/ as modules of one
tests/it/main.rs, for apps/pdfcraft, apps/pdfcraft-cli, automation, cos,
crypt, engine, render, sign and ui-egui. ui-egui's ui_group binary and
the support modules move into tests/it/ too. Crates with a single test
file keep it. The test executables go from 150 to 52, and every crate
runs the same number of tests as before.
ui-egui perf keeps its own binary (own process): its frame-time budgets
would charge each measurement for the other tests running beside it.
What the move needs:
- include_str!/include_bytes! and #[path] paths gain one "../".
- ui-egui: one GPU lock in tests/it/main.rs replaces the five per-file
ones (content, document_links, editing, fill_sign, view), so one
process creates one wgpu device at a time. The drop-line test in ui.rs
and the screenshot blocks behind PDFCRAFT_*_SHOT(S) and
CRAFT_UI_DOCKING_DIR, which render without holding it, take it too.
- pdfcraft winit_wayland_scroll: the vendored winit source it compiles
names crate::event, so that shim lives in main.rs.
- parity evidence names the new paths.
* Name the one-binary test paths in docs and comments
- READMEs, docs, ATTRIBUTION and source comments name tests/it/<file>.rs.
- The oracle README's example filters with --test it encryption::qpdf_agrees.
- pdfcraft's GPU lock comment points at the UI tests' shared lock in
crates/ui-egui/tests/it/main.rs.
Conflicts:
- crates/sign/src/pdf.rs: details switch to Inter as the PR does; drawn labels keep main's
Helvetica check and refusal, so the PR's label-driven switch to Inter is dropped. Doc comment
merged.
- crates/sign/tests/pdf.rs: both tests kept.
A Keychain identity came back with an empty `chain`, so a signature made with
it carried only the signer's certificate. A validator that doesn't already
hold the intermediates (Acrobat, or PdfCraft on another machine) then can't
build the path to the root. The Windows certificate store identities already
carry the chain Windows builds.
`keychain::find` now asks the Security framework for the chain (SecTrust with
the basic X.509 policy, no network fetch) and keeps every certificate but the
signer's. It is best effort: a certificate macOS can't chain still signs.
`chain-legacy.p12` is `chain.p12` re-exported in the legacy format that
`security import` accepts, for a new ignored Keychain test.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The bundle's encapContentInfo must start with its eContentType, and the
optional [0] certificates and [1] CRLs appear at most once each, in that
order (RFC 5652 §5.1; CodeRabbit on #3561).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A .p7b must be SignedData with version, digestAlgorithms,
encapContentInfo, optional certificates and CRLs, then signerInfos
(RFC 5652 §5.1), and nothing may follow it (CodeRabbit on #3561).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A PEM file whose last block never ends is now an error instead of a
shorter certificate list (CodeRabbit on #3561). The test's doc comment
for the bundle builder is back above its function.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
load_certificates now reads PKCS #7 certificate bundles (degenerate
SignedData, RFC 5652 §5.2) in DER and as PEM PKCS7/CMS blocks, the way
Windows, Acrobat and certificate authorities export chains. PEM END
lines must match their BEGIN label.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A content-type attribute value or an eContentType that was not an OID
was read as missing, which skipped the check that the two match. Such a
CMS is now not read, and neither is one without an eContentType.
A PDF signature's eContentType must also be data. With a content-type
attribute this already follows from the checks on the attribute.
Without one, a signature whose eContentType named another type could
still validate as a signature of the document. It is now invalid.
The PDF test now covers a wrong eContentType and a repeated
content-type attribute, and the README says that a missing
content-type attribute is read anyway, with a note.
RFC 5652 allows one content-type and one message-digest attribute in
the signed attributes, each with one value. The parser kept the last
attribute and the first value, so a CMS with two of either could mean
one thing here and another in a different reader. Such a CMS is now
not read.
The content-type attribute must also name the type of the encapsulated
content. The signature covers the attribute but not eContentType, so a
CMS where the two differ is not read either.
verify_noting adds the note only when the signature verifies. The test
uses a synthetic 1024-bit key and its PKCS #1 v1.5 signature: the valid
signature carries the note, and one that doesn't verify has none.
describe() named an RSA key by its modulus length in bytes times
eight, so a 2047-bit key read "RSA 2048-bit" next to the new warning
that it is shorter than 2048 bits, and a modulus with a leading zero
byte read 8 bits too long. It now uses rsa_bits, as weakness() does.
A CAC puts its encryption, logon and signing certificates in the store
under the same name, so the digital ID list showed several identical
entries and only one of them makes a signature that validates. Grey out
the ones whose key usage or extended key usage doesn't allow document
signing, with the reason, the same way an ID without a usable key is
already treated.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
With signed attributes, the signature value is made over the attributes,
and their messageDigest is what ties it to the document. The digest was
compared only when the attribute was present, and the content-type
attribute was never checked. RFC 5652 section 5.3 requires both.
A signature whose signed attributes have no messageDigest, or name a
content type other than id-data, is now invalid. A missing content type
is reported and the signature is read anyway. A signature that still
validates with an RSA key under 2048 bits now gets a note, as SHA-1 and
RIPEMD-160 do. The four new sentences are in WORDINGS and in Spanish.
A certification signature's /P was read from the current document, so
a later revision could rewrite P 1 to P 3 and have prohibited changes
judged as permitted. Rewriting the existing SigRef/TransformParams was
itself classified as a signature change, allowed at every level.
/P is now taken from the signature in the signed revision (the one
whose ByteRange ends that revision), and a changed Sig, DocTimeStamp,
SigRef or TransformParams that existed when signed is no longer a
signature change.
Test: a P 1 certification relaxed to P 3 in a later revision, plus a
comment, was Valid with the changes permitted; it is now Invalid.
The IRCC IMM forms are certified (DocMDP P=2) and then saved once more
with Reader Extensions: a later revision adds the UR3 usage-rights
signature and rewrites the XFA `datasets` packet. The diff classified any
rewritten stream that wasn't page content or the catalog's XMP as "other
changes", so these untouched forms validated as invalid.
A stream the signed revision's /AcroForm /XFA array names `datasets` or
`form` (the form's data and state) is now a form fill when rewritten,
permitted at P=2 and P=3 and refused at P=1 as before. The roles come from
the signed revision, so a later revision that names the template
"datasets" doesn't make rewriting it a form fill.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
* M9: harden .p12 digital IDs (#1847)
New digital IDs are written with 600,000 PBKDF2-HMAC-SHA-256 rounds (OWASP)
for both encrypted parts and the MAC, instead of 2048. The MAC gets the same
count because checking it alone tells whether a guessed password is right.
Opening a .p12 now spends at most 10 million rounds of key derivation across
the whole file, charged before any hashing. The file chose its own iteration
counts unchecked (`u64 as u32`), so a crafted file could hang the password
check for about an hour, or wrap 2^32+1 to one round.
Creating an ID needs at least 8 characters (NIST SP 800-63B), from one
constant, pkcs12::MIN_PASSWORD_CHARS, shared by the app, sign_id_create and
its MCP schema. Opening an existing ID takes any password.
* Refresh catalog hashes after merging main
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A rewritten resources dictionary, font, XObject or graphics state of a
signed page changes what the page shows. It was judged by the keys its
new version carried, so adding /FT or /Subtype /Widget made it a
permitted form fill. The roles now come from the signed revision's own
object graph, walked from each page's resources with bounds; a category
dictionary that only gained a name is judged as before.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M9: signature labels follow the interface language, and one the font can't draw is refused, never written as ?
The labels of a visible signature ("Digitally signed by", "Reason: ", ...) were written in WinAnsi unchecked, and every caller passed the English defaults. Now:
- sign: a drawn label is checked as the details are, and one Helvetica can't draw is refused (SignError::Unsupported) naming the label and the character. AppearanceLabels::fields and ::first_undrawable say which labels a caller can use.
- ui-egui: signing stamps the labels in the interface language when Helvetica can draw all of them, otherwise all in English (never a mix) with a note under the preview. The preview uses the same labels, the stamp's line layout and the signing time.
- automation: sign_document takes optional labels and appearance; a label the font can't draw, an unknown key or one without a visible signature is InvalidArgs.
- Catalogs: the five labels and the note in every language; refresh hashes.
Follows up the note on #1112.
* Refresh catalog hashes after merging main
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
To classify what later revisions changed, the signed bytes were reopened
with Document::open, without a password, so for a file opened with a
nonempty user password the earlier revision was unreadable and the earlier
signature became Invalid as soon as it was counter-signed. Document::
open_with_same_credentials opens such bytes with the current document's own
security handler when their /Encrypt and /ID are the same (no password is
needed again or kept), and the signature validator uses it.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A malformed EKU left extended_key_usage at None, which reads as "no
restriction", so an issuer-signed certificate with a broken EKU could reach a
Valid verdict; EKU being on the processed-critical list let a critical one
through as well. Like keyUsage, a present EKU now restricts the key even when
it doesn't parse: it must be a SEQUENCE whose members are all OIDs, otherwise
it allows no purpose.
Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>