PdfElixide.Signature (pdf_elixide v0.23.0)

Copy Markdown View Source

Digital signatures present in a PDF document.

list/1 reports what each signature in a document claims — who signed, when, why, which bytes the signature covers, and which field it sits in. It reads from either source (source/0), a read-only PdfElixide.Document or a PdfElixide.Editor. unsigned_fields/1 reports signature fields still waiting for one. verify/2 checks a signature against the bytes it covers; certificate/1, timestamp/1, pades_level/1,2,3 and dss/1 expose the certificate, timestamp and long-term-validation material around it.

{:ok, doc} = PdfElixide.Document.open("signed.pdf")
{:ok, [signature]} = PdfElixide.Signature.list(doc)
PdfElixide.Signature.verify(signature, File.read!("signed.pdf"))

Values returned by list/1 come from the document's signature dictionary and are unverified claims. A valid cryptographic signature is not by itself a trust decision or proof that no bytes were appended after signing. The Signatures guide is the end-to-end account of those boundaries, supported algorithms, coverage, certificates, timestamps, PAdES levels, damaged documents and the security store. Signatures are read here and never produced.

list/1, unsigned_fields/1, count/1 and dss/1 — and their bang variants — take a shared read on either source. Every other public function here operates only on signature or security-store structs, plain bytes, or scalar values, so it takes no handle lock. See the Concurrency guide.

Summary

Types

The PAdES baseline level a signature reaches. Each value below names the arity that can report it.

The signature format, from the dictionary's /SubFilter.

t()

One signature dictionary.

What a verification call concluded. Verifying one says what each one does and does not establish.

Functions

The certificate the signature names as its signer.

Same as certificate/1, but raises PdfElixide.Error on failure.

Counts the signatures in the given PDF document or editor.

Same as count/1, but raises PdfElixide.Error on failure.

Whether the signature covers the whole of a file size bytes long.

The archival timestamp the document carries over the whole file.

Whether the document carries an archival timestamp over the whole file.

Reads the document security store, the material a document carries for validating its own signatures later.

Same as dss/1, but raises PdfElixide.Error on failure.

Lists the signatures in the given PDF document or editor.

Same as list/1, but raises PdfElixide.Error on failure.

The PAdES baseline level the signature reaches, judged from the signature alone.

The PAdES baseline level the signature reaches, given the document's security store.

The PAdES baseline level the signature reaches, given the document's security store and the document's own bytes.

Same as pades_level/1, but raises PdfElixide.Error on failure.

The claimed signing time as a DateTime, or nil when there is none to read.

The RFC 3161 timestamp the signature carries, or nil when it carries none.

Same as timestamp/1, but raises PdfElixide.Error on failure.

Lists the signature fields that carry no signature — the places left to sign.

Verifies the signature against the bytes it covers.

Same as verify/2, but raises PdfElixide.Error on failure.

Verifies the signature blob on its own, without the document.

Verifies that the signature's timestamp was made over that signature.

Types

pades_level()

@type pades_level() :: :b_b | :b_t | :b_lt | :b_lta | nil

The PAdES baseline level a signature reaches. Each value below names the arity that can report it.

  • :b_b — a CAdES baseline signature.
  • :b_t — :b_b and the unsigned attribute that carries an RFC 3161 timestamp token, present beside the signature. The level reports its presence and nothing more; timestamp/1 opens it.
  • :b_lt — :b_t and an entry filed under this signature in the document's security store: the entry's presence, whatever it holds. Reported by pades_level/2 and pades_level/3, having been given that store; pades_level/1 answers :b_t for the same signature.
  • :b_lta — :b_lt and an archival timestamp over the whole file. Only pades_level/3 reports it, having been given the document's bytes; pades_level/2 answers :b_lt for the same signature, and pades_level/1 :b_t. document_timestamp?/1 says what has to hold for that timestamp to count, and document_timestamp/1 hands it back to be verified.
  • nil — not a PAdES signature at all. The levels are defined for :cades_detached and describe nothing else.

The first three levels are structural rather than verification results: a damaged signature whose timestamp cannot be found still reaches :b_b, a :b_t timestamp attribute is reported present without being opened, and a :b_lt store entry may itself be empty. :b_lta is the exception — the archival timestamp is parsed, and its imprint is checked against the bytes it covers — but even there the token's own signature is not verified and its authority is trusted rather than established; document_timestamp/1 is what reaches it. verify/2 is what checks the signature itself, and no level implies anything about it.

source()

@type source() :: PdfElixide.Document.t() | PdfElixide.Editor.t()

sub_filter()

@type sub_filter() :: :pkcs7_detached | :pkcs7_sha1 | :cades_detached | :rfc3161 | nil

The signature format, from the dictionary's /SubFilter.

nil covers two cases that cannot be told apart here: no /SubFilter at all, and one naming a format this library does not recognize.

verify/2 supports :pkcs7_sha1 but declines :rfc3161, and says why in both cases.

t()

@type t() :: %PdfElixide.Signature{
  byte_range: [integer()],
  contact_info: String.t() | nil,
  contents: binary() | nil,
  field_name: String.t() | nil,
  location: String.t() | nil,
  reason: String.t() | nil,
  signer_name: String.t() | nil,
  signing_time: String.t() | nil,
  sub_filter: sub_filter()
}

One signature dictionary.

Every field is optional, because a signature dictionary need only carry /ByteRange and /Contents.

  • :field_name — the full dotted name of the field this signature sits in, such as "applicant.signature". It is nil when a malformed form leaves the field unnamed or writes it directly into the form rather than by reference; the signature is still reported.
  • :signer_name — the name the signer claimed (/Name). It is the signer's own claim rather than the subject of the certificate the signature carries; certificate/1 is what reaches that.
  • :signing_time — the claimed signing time (/M), as a raw PDF date string such as "D:20230101120000+00'00'". Not parsed into a DateTime, matching PdfElixide.Document.Metadata.
  • :reason, :location, :contact_info — free text supplied by the signer.
  • :sub_filter — the signature format, from /SubFilter. sub_filter/0 says what each value means and what nil covers.
  • :byte_range — the byte offsets and lengths the signature covers, as [start, length, start, length]: everything except the hole holding :contents itself. Normally four integers, but a malformed document can produce any number, so match on it rather than assuming. Pass it to covers_whole_document?/2 to find out whether content was appended after signing.
  • :contents — the raw signature blob (/Contents), typically a few kilobytes per signature and held in memory for as long as the struct is. This is the whole of what the document reserved for it: a signer normally asks for more room than the CMS value needs and pads the remainder with zero bytes, so the DER value is followed by trailing padding a strict decoder will reject. Bound it by that value's own encoded length before decoding it — which verify/2, verify_signer/1 and certificate/1 already do.

verdict()

@type verdict() :: :valid | :invalid | :unknown

What a verification call concluded. Verifying one says what each one does and does not establish.

:unknown is always "the check could not run" rather than a doubt about the document, but what stopped it differs by call: verify/2 has several causes, listed under "Verifying one" in the Signatures guide; verify_signer/1 a signature algorithm or digest this library cannot handle; and verify_timestamp/2 a timestamp naming a digest algorithm it cannot compute. Treat it as unverified either way.

Functions

certificate(signature)

@spec certificate(t()) ::
  {:ok, PdfElixide.Signature.Certificate.t()} | {:error, PdfElixide.Error.t()}

The certificate the signature names as its signer.

Returns a PdfElixide.Signature.Certificate; its :der field carries the original certificate bytes for :public_key or another library:

{:ok, certificate} = PdfElixide.Signature.certificate(signature)
certificate.subject_common_name
#=> "pdf_elixide test signer"

This is the certificate selected for verify/2 and verify_signer/1. For an embedded chain, it matches the signer's issuer and serial number; signatures using another identifier fall back to the first certificate.

Nothing about the certificate is trusted by this call. See Verifying one.

For an :rfc3161 signature the blob is a timestamp token, so the certificate is the timestamp authority's rather than a document signer's.

Reports %PdfElixide.Error{reason: :invalid_pdf} when :contents is absent or malformed, the blob names no signer or X.509 certificate, or the certificate cannot be parsed.

certificate!(signature)

@spec certificate!(t()) :: PdfElixide.Signature.Certificate.t()

Same as certificate/1, but raises PdfElixide.Error on failure.

count(arg1)

@spec count(source()) :: {:ok, non_neg_integer()} | {:error, PdfElixide.Error.t()}

Counts the signatures in the given PDF document or editor.

Answers 0 for a document with no signatures and counts exactly what list/1 lists, without materializing the signature metadata. Use list/1 when those details are needed too.

count!(source)

@spec count!(source()) :: non_neg_integer()

Same as count/1, but raises PdfElixide.Error on failure.

covers_whole_document?(signature, size)

@spec covers_whole_document?(t(), non_neg_integer()) :: boolean()

Whether the signature covers the whole of a file size bytes long.

true means a structurally valid byte range starts at byte zero and ends at size. This checks coverage only; verify/2 checks the signature, and answers only about the range this reports on.

size is the length of the file being checked — File.stat!/1 for a path, Kernel.byte_size/1 for a binary. The check reads the struct alone and touches no handle. Measure the file you mean to check: after an incremental PdfElixide.Editor.save/3 that is the file just written, not the one the editor opened, and false there means content was appended after signing.

size = File.stat!("signed.pdf").size
PdfElixide.Signature.covers_whole_document?(signature, size)
#=> true

false can mean content was appended after signing, the file was truncated, size is wrong, or :byte_range is malformed. Malformed ranges answer false rather than raising.

document_timestamp(pdf_bytes)

@spec document_timestamp(binary()) ::
  {:ok, PdfElixide.Signature.Timestamp.t() | nil}
  | {:error, PdfElixide.Error.t()}

The archival timestamp the document carries over the whole file.

{:ok, timestamp} = PdfElixide.Signature.document_timestamp(File.read!("archived.pdf"))
PdfElixide.Signature.Timestamp.verify(timestamp)
#=> {:ok, :valid}

What has to hold for a timestamp to be reported is in document_timestamp?/1. This is the same answer with the token attached, and it is how the token is reached at all: an archival timestamp is a document-level object rather than a form field, so list/1 does not report one and timestamp/1 — which answers about a signature — reaches the timestamp inside a signature instead.

Returns {:ok, %PdfElixide.Signature.Timestamp{}} for a document carrying one and {:ok, nil} when the criteria in document_timestamp?/1 are not met. Only bytes that will not parse as a PDF, or a matching token stating a generation time no date can represent, reach {:error, %PdfElixide.Error{}}.

PdfElixide.Signature.Timestamp says what verifying that token does and does not prove.

document_timestamp!(pdf_bytes)

@spec document_timestamp!(binary()) :: PdfElixide.Signature.Timestamp.t() | nil

Same as document_timestamp/1, but raises PdfElixide.Error on failure.

document_timestamp?(pdf_bytes)

@spec document_timestamp?(binary()) :: boolean()

Whether the document carries an archival timestamp over the whole file.

PdfElixide.Signature.document_timestamp?(File.read!("archived.pdf"))
#=> true

true means the document carries a /Type /DocTimeStamp object whose /SubFilter is /ETSI.RFC3161 and which satisfies all three of:

  • its byte range starts at byte zero and ends at the last byte of the file, and the only bytes it excludes are its own /Contents;
  • its /Contents is a CMS-wrapped RFC 3161 token that declares itself one and carries a signer — a blob that merely decodes as a timestamp is refused;
  • that token's message imprint is the digest of the bytes the range covers.

Changing a covered byte is enough to lose it. true also means the timestamp covers whatever signatures the document already carried, and pades_level/3 uses this result for the :b_lta level.

The token's own signature is not checked here; document_timestamp/1 hands it back so PdfElixide.Signature.Timestamp.verify/1 can, and that module says what a verdict still does not prove.

pdf_bytes must be the document's own bytes. Bytes that do not parse as a PDF answer false, as a document carrying no archival timestamp does; document_timestamp/1 keeps the two apart. Establishing an absence reads the whole file, so the cost scales with the document rather than with one object.

dss(arg1)

@spec dss(source()) ::
  {:ok, PdfElixide.Signature.DSS.t() | nil} | {:error, PdfElixide.Error.t()}

Reads the document security store, the material a document carries for validating its own signatures later.

{:ok, doc} = PdfElixide.Document.open("signed.pdf")
{:ok, dss} = PdfElixide.Signature.dss(doc)
length(dss.certificates)
#=> 1

Returns {:ok, %PdfElixide.Signature.DSS{}} for a document carrying a store, or {:ok, nil} for one that does not. Reading from an editor reads the document as it was opened.

{:ok, nil} is also the answer for a document whose store is there but yields nothing — every entry unreadable, or the whole /DSS a reference to an object that is not in the file. The two cannot be told apart here, so treat nil as "no material reached me" rather than as "this document carries none".

Nothing in the store is validated, and its presence proves nothing about the signatures beside it. PdfElixide.Signature.DSS says what that means and how to decode a blob.

dss!(source)

@spec dss!(source()) :: PdfElixide.Signature.DSS.t() | nil

Same as dss/1, but raises PdfElixide.Error on failure.

list(arg1)

@spec list(source()) :: {:ok, [t()]} | {:error, PdfElixide.Error.t()}

Lists the signatures in the given PDF document or editor.

Signatures come back in document order, each naming the field it sits in. A document with no AcroForm, no signature fields, or only unsigned ones answers {:ok, []}; unsigned_fields/1 is what reports the last of those.

Reading from an editor reads the document as it was opened.

These are the signer's claims rather than findings; verify/2 is what checks one against the bytes it covers.

list!(source)

@spec list!(source()) :: [t()]

Same as list/1, but raises PdfElixide.Error on failure.

pades_level(signature)

@spec pades_level(t()) :: {:ok, pades_level()} | {:error, PdfElixide.Error.t()}

The PAdES baseline level the signature reaches, judged from the signature alone.

Same as pades_level/2 with no security store, and capped at :b_t for that reason — see there for what each answer means. Reach for the two-argument form when the document is at hand and :b_lt matters.

{:ok, [signature]} = PdfElixide.Signature.list(doc)
PdfElixide.Signature.pades_level(signature)
#=> {:ok, :b_t}

pades_level(signature, dss)

@spec pades_level(t(), PdfElixide.Signature.DSS.t() | nil) ::
  {:ok, pades_level()} | {:error, PdfElixide.Error.t()}

The PAdES baseline level the signature reaches, given the document's security store.

{:ok, [signature]} = PdfElixide.Signature.list(doc)
{:ok, dss} = PdfElixide.Signature.dss(doc)
PdfElixide.Signature.pades_level(signature, dss)
#=> {:ok, :b_lt}

:b_t means the unsigned attribute that carries an RFC 3161 timestamp token is present beside the signature value; :b_b means it is not. :b_lt adds that dss carries an entry filed under this signature, which is where the material for judging it after its certificates expire would be kept.

Pass nil for dss — what pades_level/1 does — when there is no store, or when the distinction does not matter.

Capped at :b_lt: reach for pades_level/3 when the document's bytes are at hand and :b_lta matters.

Nothing here is verified. See pades_level/0 for the exact meaning and limitations of each answer, timestamp/1 to read the timestamp, verify_timestamp/2 to check its attachment, PdfElixide.Signature.Timestamp.verify/1 to check its authenticity, and PdfElixide.Signature.DSS for the store material.

Reports %PdfElixide.Error{reason: :invalid_pdf} when a :cades_detached signature carries no :contents.

pades_level(signature, dss, pdf_bytes)

@spec pades_level(t(), PdfElixide.Signature.DSS.t() | nil, binary()) ::
  {:ok, pades_level()} | {:error, PdfElixide.Error.t()}

The PAdES baseline level the signature reaches, given the document's security store and the document's own bytes.

{:ok, [signature]} = PdfElixide.Signature.list(doc)
{:ok, dss} = PdfElixide.Signature.dss(doc)
PdfElixide.Signature.pades_level(signature, dss, File.read!("signed.pdf"))
#=> {:ok, :b_lta}

Same as pades_level/2, but reports :b_lta when a :b_lt signature's document carries an archival timestamp over the whole file.

pdf_bytes must be the bytes from which the signature was read. What makes an archival timestamp count — and what it is still not evidence of — is in document_timestamp?/1; bytes that do not parse as a PDF leave the answer at :b_lt rather than failing, as that predicate does.

pades_level!(signature)

@spec pades_level!(t()) :: pades_level()

Same as pades_level/1, but raises PdfElixide.Error on failure.

pades_level!(signature, dss)

@spec pades_level!(t(), PdfElixide.Signature.DSS.t() | nil) :: pades_level()

Same as pades_level/2, but raises PdfElixide.Error on failure.

pades_level!(signature, dss, pdf_bytes)

@spec pades_level!(t(), PdfElixide.Signature.DSS.t() | nil, binary()) :: pades_level()

Same as pades_level/3, but raises PdfElixide.Error on failure.

signing_time_utc(signature)

@spec signing_time_utc(t()) ::
  {:ok, DateTime.t() | nil} | {:error, PdfElixide.Error.t()}

The claimed signing time as a DateTime, or nil when there is none to read.

{:ok, [signature]} = PdfElixide.Signature.list(doc)
PdfElixide.Signature.signing_time_utc(signature)
#=> {:ok, ~U[2026-08-23 07:50:03Z]}

This parses :signing_time, applies its UTC offset, and returns the resulting instant in UTC. {:ok, nil} means the claim is absent, unreadable, invalid, or outside DateTime's range; inspect :signing_time to distinguish absence.

Like :signing_time itself, this is the signer's own claim and is compared to nothing. timestamp/1 reaches a third party's account of when the signature existed, which is the thing to weigh it against.

signing_time_utc!(signature)

@spec signing_time_utc!(t()) :: DateTime.t() | nil

Same as signing_time_utc/1, but raises PdfElixide.Error on failure.

timestamp(signature)

@spec timestamp(t()) ::
  {:ok, PdfElixide.Signature.Timestamp.t() | nil}
  | {:error, PdfElixide.Error.t()}

The RFC 3161 timestamp the signature carries, or nil when it carries none.

A PAdES B-T signature holds a timestamp token beside the signature, in the CMS unsigned attributes; a :rfc3161 signature — a document timestamp — is one. Both arrive here as a PdfElixide.Signature.Timestamp, which is where the token's fields, and what verifying it does and does not prove, are described.

{:ok, [signature]} = PdfElixide.Signature.list(doc)
{:ok, timestamp} = PdfElixide.Signature.timestamp(signature)
timestamp.time
#=> ~U[2026-08-23 07:50:03Z]

{:ok, nil} means the signature was read and carries no timestamp attribute, which is the ordinary shape for pades_level/1's :b_b. It is never the answer for a signature that could not be read: a document claiming a timestamp it cannot produce, and a signature blob too damaged to parse, are both errors rather than nil.

Reports %PdfElixide.Error{reason: :invalid_pdf} when the signature has no :contents, when :contents is not a CMS blob, and when the timestamp attribute is present but holds no token or one that will not parse.

Reading does not verify attachment or authenticity. Use verify_timestamp/2 for the former and PdfElixide.Signature.Timestamp.verify/1 for the latter.

timestamp!(signature)

@spec timestamp!(t()) :: PdfElixide.Signature.Timestamp.t() | nil

Same as timestamp/1, but raises PdfElixide.Error on failure.

unsigned_fields(arg1)

@spec unsigned_fields(source()) ::
  {:ok, [String.t()]} | {:error, PdfElixide.Error.t()}

Lists the signature fields that carry no signature — the places left to sign.

{:ok, doc} = PdfElixide.Document.open("half_signed.pdf")
PdfElixide.Signature.unsigned_fields(doc)
#=> {:ok, ["witness.signature"]}

Names are the full dotted ones list/1 reports in :field_name, in document order. On a well-formed form, the two calls account for every named place to sign; a cleared value counts as unsigned.

Grouping and unnamed fields are omitted, as are fields holding something other than a signature dictionary. The "Damaged documents are refused, not stepped over" section of the Signatures guide covers malformed fields and the damaged hierarchies this call refuses.

Reading from an editor reads the document as it was opened; nothing this library does adds or fills a signature field.

unsigned_fields!(source)

@spec unsigned_fields!(source()) :: [String.t()]

Same as unsigned_fields/1, but raises PdfElixide.Error on failure.

verify(signature, pdf_bytes)

@spec verify(t(), binary()) :: {:ok, verdict()} | {:error, PdfElixide.Error.t()}

Verifies the signature against the bytes it covers.

pdf_bytes must be the exact bytes of the file the signature came from — File.read!/1 for a document opened from a path, or the binary given to PdfElixide.Document.from_binary/2 or PdfElixide.Editor.from_binary/2. The check reads the struct and these bytes and touches no handle, so it works after the document is closed.

doc = PdfElixide.Document.open!("signed.pdf")
{:ok, [signature]} = PdfElixide.Signature.list(doc)
PdfElixide.Signature.verify(signature, File.read!("signed.pdf"))
#=> {:ok, :valid}

The verdict covers the range in :byte_range and nothing else, so {:ok, :valid} is not "this file is unchanged": pair it with covers_whole_document?/2. See Verifying one.

For :pkcs7_sha1, {:ok, :valid} means both that the signer signed the encapsulated SHA-1 digest and that it matches the covered bytes. A blob with no such digest answers {:ok, :unknown}.

An :rfc3161 signature answers {:ok, :unknown} without being checked; timestamp/1 reads it and verify_timestamp/2 checks its attachment.

Reports %PdfElixide.Error{reason: :invalid_pdf} rather than a verdict when the signature has no :contents, its :byte_range is not four non-negative integers, that range reaches past pdf_bytes, or :contents is not a CMS blob.

verify!(signature, pdf_bytes)

@spec verify!(t(), binary()) :: verdict()

Same as verify/2, but raises PdfElixide.Error on failure.

verify_signer(signature)

@spec verify_signer(t()) :: {:ok, verdict()} | {:error, PdfElixide.Error.t()}

Verifies the signature blob on its own, without the document.

This checks that the signed attributes inside :contents carry an authentic signature from the certificate embedded beside them. It does not compare the content digest those attributes carry against any document, so a file altered after signing answers {:ok, :valid} here while verify/2 answers {:ok, :invalid} for the same signature.

It is meaningful for every sub_filter/0, including the one verify/2 declines to check. Prefer verify/2 when the covered bytes are available. Neither function makes a trust claim about the certificate; see Verifying one.

verify_signer!(signature)

@spec verify_signer!(t()) :: verdict()

Same as verify_signer/1, but raises PdfElixide.Error on failure.

verify_timestamp(signature, pdf_bytes)

@spec verify_timestamp(t(), binary()) ::
  {:ok, verdict()} | {:error, PdfElixide.Error.t()}

Verifies that the signature's timestamp was made over that signature.

A timestamp token names a digest of whatever was timestamped, and until that digest is compared to something the token is a valid timestamp over unknown bytes. This is the comparison. What it is made against depends on which shape the signature is:

  • a B-T signature — :cades_detached or :pkcs7_detached carrying a timestamp in its CMS unsigned attributes — is timestamped over its own signature value, the bytes inside the blob that the signer's key produced;
  • an :rfc3161 signature is a document timestamp, and its token is made over the bytes its :byte_range covers.

pdf_bytes is used only for an :rfc3161 signature. A B-T signature carries everything the check needs, so the argument is ignored for that shape.

{:ok, [signature]} = PdfElixide.Signature.list(doc)
PdfElixide.Signature.verify_timestamp(signature, File.read!("signed.pdf"))
#=> {:ok, :valid}

Attachment is one of the three questions a timestamp raises; Timestamp checks are independent says what the other two are and which call answers each.

Reports %PdfElixide.Error{reason: :not_found} when the signature carries no timestamp at all — timestamp/1 is what asks that — and %PdfElixide.Error{reason: :invalid_pdf} when the signature has no :contents, when :contents is not a CMS blob or holds a token that will not parse, or when :byte_range does not lie within pdf_bytes.

verify_timestamp!(signature, pdf_bytes)

@spec verify_timestamp!(t(), binary()) :: verdict()

Same as verify_timestamp/2, but raises PdfElixide.Error on failure.