sign_core changelog

Copy Markdown View Source

All notable changes are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[0.1.4] — 2026-05-11

Fixed

  • PDF /ByteRange now excludes the < and > delimiters of the /Contents hex string per PDF 1.7 §12.8.3.3.1 / §7.3.4.3. SignCore.PDF.Writer.prepare/2 was setting byte_range[1] to the offset of the first hex digit (so < ended up as the last byte of the first signed chunk) and byte_range[2] to the offset of > (so > was the first byte of the second). The shape was internally consistent — Poppler pdfsig accepted it because pdfsig only checks that the embedded messageDigest matches whatever bytes /ByteRange says — but every other PAdES implementation (PDFBox, iText, Acrobat, the EU DSS validator) treats the entire <...> value as the unsigned hole and rejects the output. DSS surfaced this as FORMAT_FAILURE with "The /ByteRange dictionary is not consistent!" and "The reference data object is not intact!"; Adobe Acrobat refused to render the Signature Panel and showed At least one signature is invalid. After the fix, pdf[byte_range[1]] == '<' and pdf[byte_range[2] - 1] == '>' — both delimiters are part of the hole, matching the convention every other signer uses. SignCore.PDF.verify/2 reads /ByteRange from the PDF directly, so it verifies signatures emitted by both pre-0.1.4 and post-0.1.4 paths transparently — only the writer side changes.

[0.1.3] — 2026-05-11

Changed

  • SignCore.PDF /SubFilter switched from /adbe.pkcs7.detached (pre-ETSI Adobe legacy) to /ETSI.CAdES.detached per ETSI EN 319 142-1 §6.2.1. Acrobat accepts both, but the legacy value causes the EU DSS validator and other strict eIDAS conformance tools to downgrade the level from "PAdES B-B" to "PKCS#7". The verify path was already SubFilter-agnostic (it locates /Type /Sig dicts directly), so existing PDFs from 0.1.0–0.1.2 continue to verify unchanged — only new output is affected.

Added

  • SignCore.PDF.verify/2 checks the ESS signing-certificate-v2 attribute against the resolved leaf cert per ETSI EN 319 142-1 §6.4, symmetric to what 0.1.2's sign/2 emits. Verifies certHash == SHA-256(leaf_DER) (or the matching SHA-2 hash when an explicit hashAlgorithm is present). Default-on; missing attribute is tolerated by default for backward compatibility with 0.1.0 / 0.1.1 signatures. Strict ETSI conformance via require_signing_certificate_v2: true; opt out entirely with check_signing_certificate_v2: false. Mismatched hash surfaces as :signing_certificate_v2_mismatch; the bind would already be enforced indirectly via IssuerAndSerialNumber, so this is a conformance / hygiene check rather than a new security guarantee.
  • SignCore.CMS.SignedAttributes.verify_signing_certificate_v2/2 — public helper for verify-side ESS signing-certificate-v2 validation. Returns :ok, :missing, or {:error, reason} so callers can apply their own policy. Parses RFC 5035 §3 minimal form, explicit-hashAlgorithm form (SHA-2 family), and tolerates an optional IssuerSerial.

[0.1.2] — 2026-05-11

Fixed

  • PAdES B-B conformance: SignCore.PDF.sign/2 now emits the ESS signing-certificate-v2 signed attribute (id-aa-signingCertificateV2, OID 1.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. Poppler pdfsig only checks the signature math, so the gap hid behind unit-level verification and our existing conformance test suite. Output emitted by 0.1.0 and 0.1.1 is non-conformant — re-sign affected documents with 0.1.2. The minimal-shape ESSCertIDv2 (default sha-256 hashAlgorithm, certHash = SHA-256(leaf_DER), issuerSerial omitted) is what Acrobat and the EU DSS validator accept.
  • SignCore.CMS.SignedAttributes.build/1 accepts :leaf_cert_der and :signing_certificate opts. :leaf_cert_der triggers the new attribute (default-on); pass signing_certificate: false to 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/2 tolerates a leading whitespace byte before the N N obj header. 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 against 0.1.0 — a Depósito a Plazo Fijo generated by a Chilean bank — and broke both the verify path (via signature_dicts/1) and the sign path (via Writer's /AcroForm merge through read_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.0 tarball 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 against https://hex.pm/packages/... and https://github.com/utaladriz/pkcs11ex/....
  • @doc redefinition fix in SignCore.XML.Builder. The signature_value/1 @doc block was inserted between an existing @doc and its def signature/N, so the existing docstring was silently overwritten and signature/N shipped to hexdocs.pm with no docs. Reordered so each @doc attaches to its intended def.
  • #{} interpolation fixed in SignCore.XML.XAdES.qualifying_properties/1 docstring. 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/3 references promoted to callback-link syntax in SignCore.PDF.verify/2 and SignCore.XML.verify/2 moduledocs. ExDoc now auto-resolves the link to the callback definition instead of warning about an unresolved reference.
  • Dropped unused module attributes @ds_local_object and @xades_local_qp from SignCore.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.Signer protocol — 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 of xmerl_c14n (BSD-2) at lib/sign_core/xml/c14n/ — the upstream Hex package crashes on OTP 28's xmlAttribute shapes for unprefixed attributes; the patch is a single fallback clause in do_canonical_name/3, documented inline.
  • SignCore.JWS — RFC 7797 detached + RFC 7515 attached JWS sign + verify with b64: false, crit: ["b64"], and x5c or kid headers.
  • SignCore.CMS — RFC 5652 CMS / SignedData encoding (used by PDF). SignedAttributes, SignedData (with parser), UnsignedAttributes (for B-T id-aa-signatureTimeStampToken), Codec, OIDs, Parsed struct.
  • SignCore.X509 — thin wrapper around OTP's :public_key-decoded X.509 certificates. from_der/1 + cached :spki_sha256 field for SHA-256 SPKI pinning, validity_window/1, check_validity/2.
  • SignCore.Policy — pluggable trust policy behaviour. SignCore.Policy.Allow (test-only) and SignCore.Policy.PinnedRegistry (default — SPKI-pinned allowlist).
  • SignCore.Algorithm — algorithm-adapter behaviour with SignCore.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_id metadata.

Added — verify pipeline

Added — JWS

  • SignCore.JWS.sign/2 :attached opt — 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 drops b64/crit and the signing input becomes <header_b64>.<payload_b64> per RFC 7515.
  • SignCore.JWS.sign/2 optional :x5c with kid — when :extra_headers carries a kid, :x5c may be omitted. The header includes kid (RFC 7515 §4.1.4) instead of x5c; verifiers look up the cert by kid.
  • SignCore.JWS.verify/3 auto-detection of attached vs detached. Empty middle segment → detached path; non-empty → attached (extract payload from middle, optionally cross-check against caller-supplied payload arg). Detached without payload returns :missing_payload; attached with mismatched supplied payload returns :payload_mismatch.
  • SignCore.JWS.verify/3 :kid_certs opt%{kid_string => leaf_der} map for kid-based identity resolution. Bypasses policy.resolve/2 (the :kid_certs map IS the operator-supplied allowlist) but still runs policy.validate/3 to derive the subject_id.

Changed

  • SignCore.PDF.sign/2 and SignCore.XML.sign/2 reject missing :alg. Both used to default silently to :PS256; the inconsistency with SignCore.JWS.sign/2 and Pkcs11ex.sign_bytes/2 (which already rejected) let caller typos (:algg) slip through. Now consistent across all four entry points: explicit :alg or {:error, :missing_alg}. Pre-publish breaking change.
  • SignCore.CMS.SignedData.parse/1 disambiguates "issuer found, serial mismatched" from "leaf not in chain". Returns :signer_serial_mismatch for the former (cert rotation without SignerInfo update) vs the prior generic :leaf_certificate_not_found_in_chain.
  • SignCore.XML element / attribute name comparisons handle xmerl's atom + charlist + binary shapes uniformly. Pre-fix, the helpers assumed atoms only; charlist-named attributes silently returned nil, causing downstream :digest_mismatch failures with no clear cause.
  • SignCore.XML.verify/2 no longer raises on malformed base64. The five sites that previously called Base.decode64!/1 (X.509 certs in <ds:KeyInfo>, <ds:SignatureValue>, <xades:CertDigest>, <xades:IssuerSerialV2>, reference digest values) now use Base.decode64/1 and surface tagged errors (:invalid_x5c, :invalid_signature_value, :xades_invalid_cert_digest, :xades_invalid_issuer_serial_v2, :invalid_reference_digest) through the verify pipeline's with chain. Sender-supplied untrusted input must not crash through to telemetry callers.
  • SignCore.XML.sign/2 splice_signature/3 now 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/2 B-T attach path no longer destructures {:ok, _} = ... from Canonicalizer.parse/1 and canonicalize/2. The previous canonical_signature_value/1 would crash on parse / canonicalisation failure; it now propagates the error through the surrounding :bt_failed wrapper.
  • SignCore.PDF.verify/2 now uses SignCore.PDF.Reader to 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 / /Contents from within the bounded Sig dict body. Tolerates arbitrary PDF whitespace inside the dict (the old regex required exactly one ASCII space) and ignores /ByteRange//Contents text 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_signature rather than :multiple_signatures_unsupported_in_v1.
  • SignCore.PDF.verify/2 malformed-CMS handling tightened. The trailing-zero-padding stripper now propagates {:error, :malformed_signature_contents} when /Contents doesn't begin with a SEQUENCE tag, instead of silently passing the malformed bytes to the CMS parser.
  • SignCore.JWS.sign/2 switched to a positive opt-allowlist for signer-forwarded options. Only :signer, :module, :slot_id, :pin, :key_label flow through to Layer 2; new JWS-internal opts no longer leak into the signer pipeline by default.
  • Telemetry :error_class for :missing_x5c / :invalid_x5c / :disallowed_alg is now :input instead of :jws for both SignCore.PDF and SignCore.XML. The previous :jws classification 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/1 flattened. The previous with-inside-cond-inside-case-inside-rescue layout is replaced with a single with-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 pdfsig accepts B-B + B-T PDFs.
  • libxmlsec1 xmlsec1 --verify accepts B-B + B-T XML.

Architectural invariants

  • No software signing in this package. sign_core builds 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.Policy before 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.