All notable changes are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.1.2] — 2026-05-11
Fixed
- PAdES B-B conformance:
SignCore.PDF.sign/2now emits the ESSsigning-certificate-v2signed attribute (id-aa-signingCertificateV2, OID1.2.840.113549.1.9.16.2.47, RFC 5035 §3). ETSI EN 319 142-1 §5.3 requires this attribute for PAdES B-B; without it, Adobe Acrobat refuses to validate the signature (At least one signature is invalid) even when the underlying RSA-PSS math verifies and the leaf certificate chains to a trusted CA. Popplerpdfsigonly checks the signature math, so the gap hid behind unit-level verification and our existing conformance test suite. Output emitted by0.1.0and0.1.1is non-conformant — re-sign affected documents with0.1.2. The minimal-shapeESSCertIDv2(defaultsha-256hashAlgorithm,certHash = SHA-256(leaf_DER),issuerSerialomitted) is what Acrobat and the EU DSS validator accept. SignCore.CMS.SignedAttributes.build/1accepts:leaf_cert_derand:signing_certificateopts.:leaf_cert_dertriggers the new attribute (default-on); passsigning_certificate: falseto suppress it for callers that need bit-for-bit reproducibility with pre-fix output. The XAdES path (<xades:SigningCertificateV2>) already had this; only the CAdES/PAdES side was missing it.
[0.1.1] — 2026-05-08
Fixed
SignCore.PDF.Reader.read_object_body/2tolerates a leading whitespace byte before theN N objheader. Microsoft Print To PDF (a very common Windows authoring path) emits xref offsets that point at a single whitespace byte (\n,\r, or space) immediately preceding the object header. Adobe Reader, Poppler, qpdf and mupdf all accept this shape; we rejected it with:object_header_invalid. The bug surfaced on the first business PDF tested against0.1.0— a Depósito a Plazo Fijo generated by a Chilean bank — and broke both the verify path (viasignature_dicts/1) and the sign path (via Writer's/AcroFormmerge throughread_catalog_body/2). The fix strips leading PDF whitespace off the slice before matching the header, keeping the regex anchored on the digits — no risk of skipping past whitespace inside a header. Reported and fixed by Eduardo Díaz (#21).
Documentation
- README cross-package links rewritten to absolute URLs. The published
0.1.0tarball carried../paths to sibling packages (pkcs11ex,soft_signer) and../docs/specs/specs.md— those don't exist in a single-package tree, so they rendered broken on hexdocs.pm. Now resolved againsthttps://hex.pm/packages/...andhttps://github.com/utaladriz/pkcs11ex/.... @docredefinition fix inSignCore.XML.Builder. Thesignature_value/1@docblock was inserted between an existing@docand itsdef signature/N, so the existing docstring was silently overwritten andsignature/Nshipped to hexdocs.pm with no docs. Reordered so each@docattaches to its intendeddef.#{}interpolation fixed inSignCore.XML.XAdES.qualifying_properties/1docstring. Two un-escaped#{}sequences interpolated to empty in the rendered hexdocs page, leaving cryptic gaps in the option descriptions. Now properly escaped.SignCore.Policy.validate/3references promoted to callback-link syntax inSignCore.PDF.verify/2andSignCore.XML.verify/2moduledocs. ExDoc now auto-resolves the link to the callback definition instead of warning about an unresolved reference.- Dropped unused module attributes
@ds_local_objectand@xades_local_qpfromSignCore.XML(left over from an earlier draft of the verify pipeline that referenced them).
[0.1.0] — 2026-05-07
Initial release. Extracted from the pkcs11ex monorepo.
Added — foundational modules
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 + RFC 7515 attached JWS sign + verify withb64: false,crit: ["b64"], andx5corkidheaders.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+ cached:spki_sha256field for SHA-256 SPKI pinning,validity_window/1,check_validity/2.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.
Added — verify pipeline
SignCore.PDF.verify/2cross-checks CMSsigning-timeagainst the leaf cert's validity window. A signing-time outsidenotBefore..notAftersurfaces 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.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.XML.Builder.signature_value/1— typed builder for the standalone<ds:SignatureValue>element used by the B-T attach path's canonicalisation step.
Added — JWS
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; non-empty → attached (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.
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.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.SignCore.PDF.verify/2now usesSignCore.PDF.Readerto locate the signature dict via the merged xref, replacing the previous regex-over-raw-bytes approach. 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 ignores/ByteRange//Contentstext appearing inside content streams, comments, or trailing free text. Behavior change: trailing free text appended after the signed revision that happens to look like a Sig dict now surfaces as:incremental_update_after_signaturerather than:multiple_signatures_unsupported_in_v1.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.- 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. 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.
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.