SignCore.PDF.Writer (sign_core v0.1.0)

Copy Markdown View Source

Hand-rolled PAdES B-B incremental-update emitter.

The writer takes a base PDF and produces an incremental update with a PKCS#7-detached /Sig dictionary whose /Contents is a fixed-size hex placeholder. The caller then:

  1. signs the bytes the writer reports as :signed_input via Pkcs11ex.sign_bytes/2 (or any equivalent CMS pipeline);
  2. calls inject_signature/2 to splice the resulting CMS DER into the placeholder.

The two-phase shape is mandatory: PAdES /ByteRange covers everything except the hex digits, so the bytes-to-be-signed cannot be known until the placeholder's exact byte offset is fixed.

What the writer emits

Three new objects are appended:

  • the existing catalog re-emitted with /AcroForm extended (or added) so the new /Sig field is reachable from /Root;
  • a signature field annotation (/FT /Sig, /Subtype /Widget, invisible 0×0 rect) referencing the /Sig dict via /V;
  • the /Sig dict itself: /Filter /Adobe.PPKLite, /SubFilter /adbe.pkcs7.detached, fixed-width /ByteRange and /Contents placeholders, plus optional /M, /Reason, /Location.

Followed by a fresh xref subsection (one entry per new object number, plus one for the re-emitted catalog), trailer with /Prev pointing at the original startxref, and the new startxref + %%EOF.

v1 limitations

  • Refuses to merge with an existing /AcroForm. Re-signing a PDF that already has form fields needs full PDF object grammar; out of scope for v1. Returns {:error, {:writer, :existing_acroform_unsupported_in_v1}}.
  • Refuses to operate on PDFs whose /Root catalog uses a cross- reference stream (Reader returns :xref_stream_unsupported).
  • The /Sig field is invisible — no widget rectangle, no rendering hints. Visible signature appearance streams are a Phase 4b.x feature.

Summary

Types

Per-call options

t()

Output of prepare/2. The caller hashes :signed_input, builds a CMS over that hash, and feeds the CMS DER to inject_signature/2.

Functions

Splices cms_der into the /Contents placeholder of a prepared PDF. Returns {:error, {:writer, :cms_der_too_large}} if the DER exceeds the prepared placeholder size; pads with 0x00 bytes (hex 00) otherwise.

Builds the incremental-update bytes and returns the structure describing what to sign.

Types

prepare_opts()

@type prepare_opts() :: [
  placeholder_size: pos_integer(),
  signing_time: DateTime.t(),
  reason: String.t(),
  location: String.t(),
  contact_info: String.t()
]

Per-call options:

  • :placeholder_size — bytes the CMS DER will occupy. The hex placeholder in the PDF is exactly twice this many ASCII chars. Default 8192. Real-world PAdES B-B signatures with a single end-entity cert and no timestamp run ~6 KiB; raise this when embedding LTV material.
  • :signing_timeDateTime.t() for the /M entry in the Sig dict. Default DateTime.utc_now/0. Note: PAdES verifiers generally trust the CMS signing-time attribute, not /M.
  • :reason, :location, :contact_info — optional /Reason, /Location, /ContactInfo entries in the Sig dict.

t()

@type t() :: %SignCore.PDF.Writer{
  byte_range: [non_neg_integer()],
  contents_length: non_neg_integer(),
  contents_offset: non_neg_integer(),
  pdf: binary(),
  placeholder_size: pos_integer(),
  signed_input: binary()
}

Output of prepare/2. The caller hashes :signed_input, builds a CMS over that hash, and feeds the CMS DER to inject_signature/2.

Functions

inject_signature(prepared, cms_der)

@spec inject_signature(t(), binary()) :: {:ok, binary()} | {:error, term()}

Splices cms_der into the /Contents placeholder of a prepared PDF. Returns {:error, {:writer, :cms_der_too_large}} if the DER exceeds the prepared placeholder size; pads with 0x00 bytes (hex 00) otherwise.

prepare(base_pdf, opts \\ [])

@spec prepare(binary(), prepare_opts()) :: {:ok, t()} | {:error, term()}

Builds the incremental-update bytes and returns the structure describing what to sign.