SignCore.PDF (sign_core v0.1.0)

Copy Markdown View Source

PAdES (PDF Advanced Electronic Signature) format adapter — Phase 4a.

Sign

SignCore.PDF.sign(pdf_bytes,
  module: pkcs11_module,
  slot_id: slot_id,
  pin: "1234",
  key_label: "platform-signing-key",
  alg: :PS256,
  x5c: [leaf_der, intermediate_der, root_der]
)

Returns a binary containing the original PDF plus an incremental update with a /Sig field whose /Contents is the CMS SignedData produced by the HSM. The output validates as PAdES B-B against Poppler pdfsig and BouncyCastle verifypdf.

Pipeline:

  1. SignCore.PDF.Writer.prepare/2 allocates the incremental update with a fixed-width /Contents placeholder. Returns signed_input — the bytes the CMS will hash.
  2. SHA-256 over signed_input becomes the messageDigest PKCS#9 attribute. signed_attrs (content-type, message-digest, signing-time) are DER-encoded as a SET-OF.
  3. The DER-encoded signed_attrs is the to-be-signed input. It routes through Pkcs11ex.sign_bytes/2 → Layer 2 → NIF → cryptoki → HSM. Software signing is never used.
  4. SignCore.CMS.SignedData.build/3 assembles the ContentInfo with the HSM-produced raw signature, the supplied :x5c chain, and the appropriate signature-algorithm OID for the requested :alg.
  5. SignCore.PDF.Writer.inject_signature/2 splices the CMS DER into the placeholder.

v1 limitations

  • :PS256 and :RS256 only. (:PS256 emits the canonical RSASSA-PSS-params for SHA-256 / MGF1-SHA-256 / sLen=32, which OpenSSL and BouncyCastle accept.)
  • The base PDF must not already carry an /AcroForm; re-signing a PDF with form fields is a Phase 4b enhancement.
  • Visible signature appearance streams are out of scope.
  • verify/2 is part of step 9 in the Phase 4a punch-list and currently still returns :not_implemented_in_v1.

Summary

Types

Result of sign/2. Failure carries the responsible class as the wrapper.

Result of verify/2. Currently always :not_implemented_in_v1.

Functions

Sign a PDF with PAdES B-B.

Verify a PAdES-signed PDF.

Types

sign_result()

@type sign_result() :: {:ok, binary()} | {:error, term()}

Result of sign/2. Failure carries the responsible class as the wrapper.

verify_result()

@type verify_result() :: {:ok, subject_id :: term()} | {:error, term()}

Result of verify/2. Currently always :not_implemented_in_v1.

Functions

sign(pdf_bytes, opts)

@spec sign(
  binary(),
  keyword()
) :: sign_result()

Sign a PDF with PAdES B-B.

Required options:

  • :x5c — the signing chain. Either a single leaf DER or a list [leaf_der, intermediate_der, ..., root_der]. The first element MUST correspond to the HSM key being used.
  • Plus the PKCS#11 keying opts (:module, :slot_id, :pin, :key_label, or the canonical :signer form) — these are forwarded verbatim to Pkcs11ex.sign_bytes/2.

Optional:

  • :alg:PS256 (default) or :RS256.
  • :signing_timeDateTime.t() used both for the CMS signing-time attribute and the PDF /M entry. Defaults to DateTime.utc_now/0.
  • :placeholder_size, :reason, :location, :contact_info — forwarded to SignCore.PDF.Writer.prepare/2.

Errors propagate from each pipeline stage; see docs/specs/api.md §4.1 for the full taxonomy.

verify(pdf_bytes, opts \\ [])

@spec verify(
  binary(),
  keyword()
) :: verify_result()

Verify a PAdES-signed PDF.

Returns {:ok, subject_id} where subject_id is whatever the configured SignCore.Policy.validate/3 returned. The verify pipeline runs in this order — every step is a checkpoint that can refuse the signature with the documented error class:

  1. Locate the (single) /Sig dict in the file: extract /ByteRange [a b c d] and /Contents <hex>. v1 refuses PDFs carrying more than one /Sig (multi-signature is post-v1).
  2. Append-attack detection. Refuse if c + d does not equal the file's total byte length — bytes beyond the signed range can carry an attacker-crafted incremental update that the original signature cannot cover by definition. Surfaces as :incremental_update_after_signature.
  3. Strip the trailing zero-padding from the hex-decoded /Contents blob using the CMS SEQUENCE length prefix; parse the result via SignCore.CMS.SignedData.parse/1.
  4. Allowlist gate (architectural invariant). Synthesise a JOSE-style header from the embedded x5c chain and run it through the configured SignCore.Policypolicy.resolve/2 then policy.validate/3. The candidate chain is untrusted input until both succeed. No cryptographic check has happened yet.
  5. Reconstruct signed_input = pdf[a..a+b) ++ pdf[c..c+d), hash with SHA-256, compare against the CMS messageDigest PKCS#9 attribute. A mismatch surfaces as :message_digest_mismatch and is the canonical tampered-byte signal — any modification inside the signed byte range invalidates the digest before the math runs.
  6. Mathematically verify the embedded raw signature over the DER-encoded signedAttrs against the leaf's SPKI. Failure is :signature_invalid.

Failures from step 4 short-circuit before any signature math, so callers cannot use verify/2 as a CPU-bound oracle on attacker- supplied certificates. Failures from step 2 short-circuit even earlier, so an append-attack PDF is refused before any CMS work.