* Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil_from_days/days_from_civil copies and the leap-year/month math around them (log timestamp, PDF /Info dates, ASN.1 UTCTime/GeneralizedTime, FormCalc Date/Num2Date, the Combine modified-time stamp, the form date picker, and the demo-PDF timestamps) with chrono as a workspace dependency. The engine's existing chrono (clock) is lifted to the workspace. Every output format is unchanged and pinned by tests. CLAUDE.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Guard chrono date paths: no panic on overflow, no '+' on 5-digit years Systemic chrono follow-ups to the UTC/calendar PR: - logging::timestamp and combine_ui::utc_stamp dropped chrono's From<SystemTime> (which unwraps internally and panics on a clock past year 262143) for the fallible DateTime::from_timestamp, reading an out-of-range clock as the epoch, as the old clamping code did. - timestamp, utc_stamp, pdf_date and the xtask Stamp now write the year with {:04} instead of %Y, which prefixes a '+' before years past 9999; the old hand-rolled code wrote the plain zero-padded year. - forms_ui calendar navigation and length, and formcalc days_in, use checked_add for the December year successor so y + 1 can't overflow at i32::MAX / i64::MAX (reachable from a parsed or persisted date). Docstrings added to the chrono-touched civil_from_days and Time::from_unix for the docstring-coverage gate. Tests cover the out-of-range-epoch and 10000-01-01 cases for each. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * Keep FormCalc dates exact past chrono's range MAX_DAYS accepts day numbers up to 1e8 (years past 270000), but NaiveDate stops near ±262143, so Num2Date(99000000) and Format fell back to 1900-01-01. days_from_civil, civil_from_days and days_in now have chrono convert within the date's 400-year Gregorian cycle and add whole cycles back (via i128), which gives the pre-chrono results for every year. Tests cover accepted inputs past chrono's range on both sides of the epoch, through Num2Date and Format, with values from the old formula. * Bump rustls-webpki and webpki-root-certs for RUSTSEC-2026-0382/0383 rustls-webpki 0.103.15 -> 0.103.16 and webpki-root-certs 1.0.9 -> 1.0.10 (both semver-compatible, lockfile only) drop the distrusted emSign intermediates and root. Only these two entries change; cargo deny check advisories passes. Upstream main fails the same check. * logging/cos/sign/combine/demo: keep extended-year dates, no epoch fallback For consistency with the stricter no-epoch-fallback rule now agreed across the sibling chrono PRs (wordcraft#3427, filmcraft#2662): `DateTime::from_timestamp(..).unwrap_or_default()` changes a timestamp the old code converted past chrono's year 262143 range — it collapses to 1970-01-01 instead of the real proleptic-Gregorian date. The FormCalc Num2Date path was already fixed (FredApps approved); this applies the same fix to every remaining conversion. Each site uses the 400-year Gregorian cycle (146_097 days) with chrono as the only calendar logic: chrono converts the day within one cycle, whole cycles carry in the year (i128, saturated into i64). PDF/ASN.1 format-specific year handling is kept exactly as the old code had it. Verified the cycle result equals the old formula across a wide range and at the extremes. - apps/pdfcraft/src/logging.rs `timestamp`: cycle `civil_from_days(u64)`; pre-1970 epoch clamp and ms/time-of-day format kept. Pins: +10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z (was 1970); checked_add u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch). - crates/cos/src/writer.rs `pdf_date` (i64): cycle converter, no year clamp (old code wrote `y` as-is, so a negative year keeps its sign). Pins: pdf_date(10_000_000_000_000)="D:3188570520174640Z", pdf_date(-10_000_000_000_000)="D:-3149180813061320Z". - crates/sign/src/der.rs `Time::from_unix` (i64): cycle converter; the `year` field is a u32, so the year is cast `as u32` (wrapping) exactly as the old code did — ASN.1 year width preserved byte-for-byte. Pins: from_unix(10e12).year=318857, from_unix(-10e12).year=4_294_652_378, from_unix(i64::MAX).year=219_250_468, from_unix(i64::MIN).year=4_075_720_767. - crates/ui-egui/src/combine_ui.rs `utc_stamp` (u64): cycle converter; pre-1970 epoch clamp kept. Pins: utc_stamp(10e12)="318857-05-20 17:46 UTC", u64::MAX/2 s="292277026596-12-04 15:30 UTC". - xtask/src/demo_pdf/pdf.rs `Stamp::from_unix` (i64): cycle converter, no year clamp. Pins: from_unix(10e12).iso="318857-05-20T17:46:40Z"/.pdf="D:3188570520174640Z", from_unix(-10e12).iso="-314918-08-13T06:13:20Z". cos/sign/xtask built, tested (pdf_dates_are_utc_and_byte_identical, der::tests::times, stamps_round_trip), fmt and clippy clean. pdfcraft-ui-egui and the pdfcraft app can't link here (unrelated LLVM intrinsic bug in the rten-gemm dependency, 1.98.1), so their logging and combine_ui functions + tests were copied verbatim into a scratch crate with chrono 0.4.45 and pass there; fmt --check clean on all five packages. CI builds these on all platforms. * Don't build a year-318857 SystemTime unchecked in the logging test Windows' SystemTime only reaches about year 30828, so UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before timestamp() ran and failed the Windows test job. Build it with checked_add and assert where the platform can hold it. The year-10000 case fits everywhere and stays unconditional. * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does.
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-rson native targets and are refused in the browser (ADR-0009: thersacrate's Marvin advisory, RUSTSEC-2023-0071, has no fix, sorsais 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 aGraphicwhere 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/Contentsand fixed-width/ByteRange, which are then patched in place. Encrypted documents are refused for now. - Timestamps (PAdES B-T): the
timestampmodule builds and parses RFC 3161 requests, responses and TSTInfo tokens (size-capped, imprint- and signature-checked).sign_with_timestampattaches 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 inSignatureInfo::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::respondis the TSA-side signer behind the deterministic test authority. - Document timestamps and LTV:
timestamp_documentappends 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::embedmerges revocation evidence into the catalog's/DSSwith/VRIentries 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,/OCSPsand/VRIwith the shape of their role, and new or only grown since the signature, count (a/DSSentry naming a page's contents, or a/Type /DSSlabel, does not).revocationparses 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:
/ByteRangeand the CMS are read from the file's own bytes; the digest, the signature value and the signer's chain (against aTrustStore) 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_chainonly takes an issuer whose key verifies the certificate below it and that may issue:basicConstraintsCA:TRUE (an old v1 self-signed root without constraints counts),keyCertSignwhen it has a key usage, apathLenConstraintthat 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 andopenssl cms -streamwrite 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
/Metadatastream is the document's XMP, and only objects reached through the/DSSkeys above are the security store. A/Type /Metadataor/Type /DSSlabel 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
DigestInfomay 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 rawr ‖ s.adbe.x509.rsa_sha1is 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) oncrypto-bigint, because the typed APIs of thersacrate 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.