All notable changes are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
SignCore.X509.validity_window/1— returns{not_before, not_after}asDateTimes decoded from the cert's TBS validity field.SignCore.X509.check_validity/2— checks whether a givenDateTimefalls within the cert's validity window. Returns:okor{:error, :cert_not_yet_valid | :cert_expired | :cert_validity_unparseable}.SignCore.PDF.verify/2cross-checks CMSsigning-timeagainst the leaf cert's validity window. A signing-time outsidenotBefore..notAfternow surfaces as:cert_expired/:cert_not_yet_valid. Default-on; opt out withcheck_signing_time: false. Passrequire_signing_time: trueto also reject CMS envelopes that omit the attribute.SignCore.XML.verify/2cross-checks<xades:SigningTime>against the leaf cert's validity window. Same opt surface as the PDF path.
Changed
SignCore.PDF.sign/2andSignCore.XML.sign/2reject missing:alg. Both used to default silently to:PS256; the inconsistency withSignCore.JWS.sign/2andPkcs11ex.sign_bytes/2(which already rejected) let caller typos (:algg) slip through. Now consistent across all four entry points: explicit:algor{:error, :missing_alg}. Pre-publish breaking change.SignCore.CMS.SignedData.parse/1disambiguates "issuer found, serial mismatched" from "leaf not in chain". Returns:signer_serial_mismatchfor the former (cert rotation without SignerInfo update) vs the prior generic:leaf_certificate_not_found_in_chain. Helps debug a real class of operator error.SignCore.XMLelement / attribute name comparisons handle xmerl's atom + charlist + binary shapes uniformly. Pre-fix, the helpers assumed atoms only; charlist-named attributes silently returnednil, causing downstream:digest_mismatchfailures with no clear cause.SignCore.XML.verify/2no longer raises on malformed base64. The five sites that previously calledBase.decode64!/1(X.509 certs in<ds:KeyInfo>,<ds:SignatureValue>,<xades:CertDigest>,<xades:IssuerSerialV2>, reference digest values) now useBase.decode64/1and surface tagged errors (:invalid_x5c,:invalid_signature_value,:xades_invalid_cert_digest,:xades_invalid_issuer_serial_v2,:invalid_reference_digest) through the verify pipeline'swithchain. Sender-supplied untrusted input must not crash through to telemetry callers.SignCore.XML.sign/2splice_signature/3now ignores closing-tag matches inside XML comments and CDATA sections. Previously, the splice picked the LAST</root>substring in the document — a comment legitimately containing</root>would shift the splice onto the wrong byte position. The new path collects byte ranges occupied by<!-- ... -->and<![CDATA[ ... ]]>blocks and rejects matches that fall inside them.SignCore.XML.sign/2B-T attach path no longer destructures{:ok, _} = ...fromCanonicalizer.parse/1andcanonicalize/2. The previouscanonical_signature_value/1would crash on parse / canonicalisation failure; it now propagates the error through the surrounding:bt_failedwrapper.
Added
SignCore.XML.Builder.signature_value/1— typed builder for the standalone<ds:SignatureValue>element used by the B-T attach path's canonicalisation step.
Changed
- Telemetry
:error_classfor:missing_x5c/:invalid_x5c/:disallowed_algis now:inputinstead of:jwsfor bothSignCore.PDFandSignCore.XML. The previous:jwsclassification leaked the JWS spec name into PDF and XML telemetry, misattributing format-shared input-validation errors. The atom names themselves (e.g.:missing_x5c) are unchanged. SignCore.X509.from_der/1now caches the SHA-256 SPKI pin on the struct (:spki_sha256field).spki_sha256/1is now a constant-time field read; the second ASN.1 decode pass per call is gone. Construction does the work once; verify and registry lookups pay only the field access.Pkcs11ex.Audit.Anchor.RFC3161.extract_token/1flattened. The previouswith-inside-cond-inside-case-inside-rescuelayout is replaced with a singlewith-pipeline plus two small helpers (check_status/1,extract_tst_tlv/1). Functionally identical; readability is now appropriate for a security-relevant codepath.SignCore.PDF.verify/2now usesSignCore.PDF.Readerto locate the signature dict via the merged xref, replacing the previous regex-over-raw-bytes approach. The new path:- Walks every revision's xref, takes the newest indirect-object offset per number, scans bodies for
/Type /Sig, and parses/ByteRange//Contentsfrom within the bounded Sig dict body. - Tolerates arbitrary PDF whitespace inside the dict — the old regex required exactly one ASCII space and would silently miss legitimate dicts emitted by Adobe / iText / DSS.
- Ignores
/ByteRange//Contentstext appearing inside content streams, comments, or trailing free text. The old regex counted those as signatures, leading to false:multiple_signatures_unsupported_in_v1rejections on legitimate third-party PDFs. - Behavior change: trailing free text appended after the signed revision that happens to look like a Sig dict now surfaces as
:incremental_update_after_signature(the canonical append-attack signal) rather than:multiple_signatures_unsupported_in_v1. The dedicated multi-sig rejection now requires real indirect objects with/Type /Sigin xref.
- Walks every revision's xref, takes the newest indirect-object offset per number, scans bodies for
SignCore.PDF.verify/2malformed-CMS handling tightened. The trailing-zero-padding stripper now propagates{:error, :malformed_signature_contents}when/Contentsdoesn't begin with a SEQUENCE tag, instead of silently passing the malformed bytes to the CMS parser.SignCore.JWS.sign/2switched to a positive opt-allowlist for signer-forwarded options. Only:signer,:module,:slot_id,:pin,:key_labelflow through to Layer 2; new JWS-internal opts no longer leak into the signer pipeline by default.
Added
SignCore.PDF.Reader.merged_xref_offsets/1— newest-revision-wins merge of xref tables across all revisions.SignCore.PDF.Reader.read_dict_at/2— read the dict body at an indirect-object offset.SignCore.PDF.Reader.signature_dicts/1— enumerate{object_number, dict_body}pairs for every indirect object carrying/Type /Sig. Used bySignCore.PDF.verify/2.SignCore.JWS.sign/2:attachedopt — produce attached JWS (RFC 7515 form:<header>.<payload_b64>.<sig>) instead of the default detached (RFC 7797 form:<header>..<sig>). When attached, the protected header dropsb64/critand the signing input becomes<header_b64>.<payload_b64>per RFC 7515.SignCore.JWS.sign/2optional:x5cwithkid— when:extra_headerscarries akid,:x5cmay be omitted. The header includeskid(RFC 7515 §4.1.4) instead ofx5c; verifiers look up the cert bykid.SignCore.JWS.verify/3auto-detection of attached vs detached. Empty middle segment → detached path (current behavior). Non-empty middle segment → attached path (extract payload from middle, optionally cross-check against caller-suppliedpayloadarg). Detached without payload returns:missing_payload; attached with mismatched supplied payload returns:payload_mismatch.SignCore.JWS.verify/3:kid_certsopt —%{kid_string => leaf_der}map for kid-based identity resolution. Bypassespolicy.resolve/2(the:kid_certsmap IS the operator-supplied allowlist) but still runspolicy.validate/3to derive thesubject_id.
[0.1.0]
Initial release. Extracted from the pkcs11ex monorepo.
Added
SignCore.Signerprotocol — pluggable signer abstraction. Implementations carry whatever state is needed to produce a raw signature over arbitrary bytes (a PKCS#11 slot reference, a loaded PKCS#12 bundle, a cloud KMS handle, etc.). The format adapters dispatch via this protocol and don't know about specific provider types.SignCore.PDF— PAdES B-B and B-T sign + verify. 6-step verify pipeline with allowlist-before-math gate, append-attack detection (:incremental_update_after_signature),messageDigest/ signature math checks. Hand-rolled CMS encoder over OTP's'CryptographicMessageSyntax-2009'codec.SignCore.XML— XAdES B-B and B-T sign + verify on top of W3C XML-DSig. Exclusive XML Canonicalization 1.0;<xades:SigningCertificateV2>with RFC 5035 IssuerSerial; XAdES<UnsignedSignatureProperties>for B-T timestamps. Vendored + patched copy ofxmerl_c14n(BSD-2) atlib/sign_core/xml/c14n/— the upstream Hex package crashes on OTP 28'sxmlAttributeshapes for unprefixed attributes; the patch is a single fallback clause indo_canonical_name/3, documented inline.SignCore.JWS— RFC 7797 detached JWS sign + verify withb64: false,crit: ["b64"], andx5cheaders.SignCore.CMS— RFC 5652 CMS / SignedData encoding (used by PDF).SignedAttributes,SignedData(with parser),UnsignedAttributes(for B-Tid-aa-signatureTimeStampToken),Codec,OIDs,Parsedstruct.SignCore.X509— thin wrapper around OTP's:public_key-decoded X.509 certificates.from_der/1+spki_sha256/1for SHA-256 SPKI pinning.SignCore.Policy— pluggable trust policy behaviour.SignCore.Policy.Allow(test-only) andSignCore.Policy.PinnedRegistry(default — SPKI-pinned allowlist).SignCore.Algorithm— algorithm-adapter behaviour withSignCore.Algorithm.PS256(RSASSA-PSS / SHA-256 / MGF1-SHA-256 / sLen=32).Telemetry events —
[:pkcs11ex, :sign | :verify, :start | :stop | :exception]with:format,:alg,:encoding_context,:signer,:byte_count, and on success:subject_idmetadata.
Conformance
The shipped output validates under standards-compliant external verifiers:
- Poppler
pdfsigaccepts B-B + B-T PDFs. - libxmlsec1
xmlsec1 --verifyaccepts B-B + B-T XML.
Architectural invariants
- No software signing in this package.
sign_corebuilds the bytes-to-be-signed and assembles the output, but never produces a signature. That's the signer's job. - Allowlist before math. Every verify path resolves the sender's certificate against
SignCore.Policybefore doing any cryptographic verification. - Append-attack detection. PAdES verify checks
c + d == byte_size(pdf)before parsing the CMS — bytes appended after the signed range are refused.