PdfElixide.Signature.list/1 reports what each digital signature in a document
claims — who signed, when, why, which bytes the signature covers and which
field it sits in — from a read-only PdfElixide.Document or a
PdfElixide.Editor alike. PdfElixide.Signature.verify/2 checks one of those
claims against the signed bytes.
alias PdfElixide.Document
alias PdfElixide.Signature
path = "path/to/signed.pdf"
doc = Document.open!(path)
signature =
try do
[signature] = Signature.list!(doc)
signature
after
Document.close(doc)
end
signature.signer_name
#=> "Alice Example"
signature.reason
#=> "Approval"
# The claim is checked against the bytes it covers.
Signature.verify!(signature, File.read!(path))
#=> :valid
# And separately: does it cover the whole file?
Signature.covers_whole_document?(signature, File.stat!(path).size)
#=> trueThe struct outlives the handle. list/1 is the only step above that touches the
document; subsequent calls use the struct and the file's bytes after the example
closes the document. The examples below reuse this path and signature.
What a signature claims
Every field PdfElixide.Signature.list/1 reports comes from the signature
dictionary and nothing else. A document altered after signing lists exactly as
it did before:
intact_doc = Document.open!("path/to/signed.pdf")
altered_doc = Document.open!("path/to/signed-then-edited.pdf")
{intact, altered} =
try do
{:ok, [intact]} = Signature.list(intact_doc)
{:ok, [altered]} = Signature.list(altered_doc)
{intact, altered}
after
Document.close(intact_doc)
Document.close(altered_doc)
end
altered == intact
#=> true — the claims are identical
Signature.verify(intact, File.read!("path/to/signed.pdf"))
#=> {:ok, :valid}
Signature.verify(altered, File.read!("path/to/signed-then-edited.pdf"))
#=> {:ok, :invalid}Treat listed fields as claims until they are verified;
PdfElixide.Signature.t/0 says which values the signer supplied.
PdfElixide.Signature.signing_time_utc/1 parses the signer's claimed
:signing_time into a DateTime, answering {:ok, nil} rather than a wrong
date when the claim is not a well-formed one. PdfElixide.Signature.count/1
answers how many signatures a document carries without building their metadata.
Verifying one
PdfElixide.Signature.verify/2 needs the bytes of the file the signature came
from — File.read!/1 for a document opened from a path, or the binary you
handed PdfElixide.Document.from_binary/1. It reads the struct and those
bytes and never a handle, which is why it still works after the document is
closed.
case Signature.verify(signature, bytes) do
{:ok, :valid} -> :covered_bytes_are_intact
{:ok, :invalid} -> :do_not_trust
{:ok, :unknown} -> :could_not_check
{:error, error} -> {:unreadable, error}
end:unknown is the absence of a finding, not a mild failure — the blob parsed but
the check could not run. Its causes are a signature algorithm this library
cannot verify, an unrecognized digest, no content digest to compare against, or
a signature format whose signed content is something other than the bytes
:byte_range covers. Treat it as unverified rather than as a weak :valid.
These are the algorithms that can be checked:
- RSA PKCS#1 v1.5, over SHA-1, SHA-256, SHA-384 or SHA-512.
- RSA-PSS, over SHA-256, SHA-384 or SHA-512.
- ECDSA, over P-256 with SHA-256 or P-384 with SHA-384. The curve and digest must be paired that way.
Anything else — another curve, an Ed25519 key, RSA-PSS over SHA-1 — is
:unknown. RSA-PSS is checked with a salt as long as its digest; a signature
using another permitted salt length is reported :invalid rather than
:unknown.
{:ok, :valid} means the signature from the certificate embedded in the blob
is authentic and the content digest matches the bytes in :byte_range. It does
not establish any of these:
- That the whole file is intact. Bytes outside the declared range are not
checked. Use
PdfElixide.Signature.covers_whole_document?/2separately. - That the signer is trusted. No certificate chain, revocation status or
validity window is checked. Use
PdfElixide.Signature.certificate/1and make that decision with a certificate library and roots you trust. - That the claimed signing time is true.
:signing_timecomes from the signer. A timestamp is a third party's account, and has its own independent checks below.
PdfElixide.Signature.verify_signer/1 checks only whether the blob is
internally consistent and needs no document bytes. It cannot detect appended
document changes: the altered document above still answers :valid because its
signature blob did not change. Use verify/2 whenever the file is available.
Whether it covers the whole file
A verdict covers only the bytes in :byte_range. Check
PdfElixide.Signature.covers_whole_document?/2 separately to detect content
appended after a valid signature.
Signature.covers_whole_document?(signature, File.stat!(path).size)The size is yours to supply, which is what lets the call work from a struct with
no handle behind it. false is one answer to several situations — content
appended after signing, a truncated file, a malformed range, or simply the wrong
size passed in — and it answers rather than raising.
The signer's certificate
PdfElixide.Signature.certificate/1 reads the signer's certificate out of the
blob as a PdfElixide.Signature.Certificate:
{:ok, certificate} = Signature.certificate(signature)
certificate.subject_common_name
#=> "Alice Example"
certificate.not_after
#=> ~U[2027-01-01 00:00:00Z]Use :der with OTP's :public_key or another certificate library to make a
trust decision. PdfElixide.Signature.Certificate.parse/1 reads a certificate
held as DER, such as one from a PdfElixide.Signature.dss/1 store. Nothing here
makes a trust decision: the "Nothing here is a trust decision" section of
PdfElixide.Signature.Certificate says what that means, and its "Names" section
covers how :subject and :issuer are rendered.
To check whether a document was signed with a certificate you hold, read yours
with parse_pem/1 or parse_pkcs12/2 and compare the DER. Both return every
certificate in the file, so look for the signer's among them rather than
relying on its position:
{:ok, mine} =
"signer.p12"
|> File.read!()
|> PdfElixide.Signature.Certificate.parse_pkcs12("passphrase")
Enum.any?(mine, &(&1.der == certificate.der))
#=> trueMatching DER shows only that the certificates are identical; use verify/2 to
check the signature over the document.
PdfElixide.Signature.Certificate.valid_at?/2 checks the certificate's validity
window at a caller-supplied instant. The signer's
PdfElixide.Signature.signing_time_utc/1 is an unverified claim. A timestamp's
:time comes from a third party but is useful only after the checks below
succeed.
Timestamp checks are independent
A timestamp involves three technical checks plus a separate trust decision:
| Question | Call |
|---|---|
| Is there a token at all? | PdfElixide.Signature.timestamp/1 |
| Did the authority issue it? | PdfElixide.Signature.Timestamp.verify/1 |
| Was it made over this signature? | PdfElixide.Signature.verify_timestamp/2 |
| Is that authority one to trust? | Nothing here — yours to decide |
Authority validation and signature-imprint validation are independent:
alias PdfElixide.Signature.Timestamp
{:ok, token} = Signature.timestamp(signature)
# The authority issued the token.
Timestamp.verify(token)
#=> {:ok, :valid}
# The token covers something other than this signature.
Signature.verify_timestamp(signature, File.read!(path))
#=> {:ok, :invalid}PdfElixide.Signature.timestamp/1 answers {:ok, nil} for a signature that
carries no token — a finding, distinct from the error a blob it cannot read
produces. The "What a verdict proves" section of
PdfElixide.Signature.Timestamp says what
PdfElixide.Signature.Timestamp.verify/1 establishes, and its "Tokens that
cannot be verified" section covers the shapes that come back as an error rather
than a verdict.
PAdES baseline levels
The three arities of pades_level differ by available input. Lower arities can
report fewer levels:
doc = Document.open!(path)
{:ok, [signature]} = Signature.list(doc)
{:ok, dss} = Signature.dss(doc)
:ok = Document.close(doc)
bytes = File.read!(path)
Signature.pades_level(signature) #=> {:ok, :b_t} — signature alone
Signature.pades_level(signature, dss) #=> {:ok, :b_lt} — plus the store
Signature.pades_level(signature, dss, bytes) #=> {:ok, :b_lta} — plus the bytesSo PdfElixide.Signature.pades_level/1 cannot report above :b_t however
archival the document is, and /2 cannot reach :b_lta.
PdfElixide.Signature.pades_level/0 says what each level means and how much
of it is a structural observation rather than a verification result.
Material for validating later
A document built for long-term validation carries the certificates, CRLs and
OCSP responses that were current when it was signed.
PdfElixide.Signature.dss/1 reads them.
dss/1 takes a handle and must run before the document is closed. Its result is
a plain value that outlives the handle.
doc = Document.open!(path)
{:ok, dss} = Signature.dss(doc)
:ok = Document.close(doc)
dss.certificates
#=> [<<48, 130, ...>>]
# One signature's slice of the store.
PdfElixide.Signature.DSS.vri_for(dss, signature)
#=> %PdfElixide.Signature.DSS.VRI{...}{:ok, nil} means no material reached the reader, which is not the same as the
document carrying none — PdfElixide.Signature.dss/1 separates the cases. A
store is also read tolerantly, so a short list is not evidence that the document
is short. Nothing in it is validated: the "Nothing here is validated" section of
PdfElixide.Signature.DSS says what that means, and "Decoding a blob" covers
getting a certificate or CRL out of one.
The document's archival timestamp
An archival timestamp is a document-level object rather than a form field, so
PdfElixide.Signature.list/1 never reports it and
PdfElixide.Signature.timestamp/1 — which answers about a signature — does not
reach it. It comes from the file's bytes:
bytes = File.read!(path)
Signature.document_timestamp?(bytes)
#=> true
{:ok, token} = Signature.document_timestamp(bytes)
Timestamp.verify(token)
#=> {:ok, :valid}PdfElixide.Signature.document_timestamp?/1 is the tolerant boolean form:
unreadable bytes and an absent timestamp both answer false.
PdfElixide.Signature.document_timestamp/1 hands the token back so its
signature can be checked and keeps those two cases apart. Both inspect the
document bytes, so use the predicate for its return shape rather than as a
cheaper scan. Finding the timestamp does not verify it — that second step above
remains separate, as it is for a signature's own token.
Places left to sign
PdfElixide.Signature.unsigned_fields/1 names the signature fields still
waiting for a signature. On a well-formed form, it and
PdfElixide.Signature.list/1 partition the named signature fields and can be
used together to determine whether every such field is signed:
doc = Document.open!(path)
try do
Signature.list!(doc) |> Enum.map(& &1.field_name)
#=> ["signature"]
Signature.unsigned_fields!(doc)
#=> ["countersign", "witness", "group.slot"]
after
Document.close(doc)
endA field whose value was cleared is a place left to sign and appears here. A
field the document gives no name is omitted from unsigned_fields/1;
list/1 still reports a signature sitting in one, with :field_name nil.
Flattening takes any signature field away with the rest of the AcroForm — Flattening in the Forms guide has that account.
Damaged documents are refused, not stepped over
Signature reads are stricter than form reads on a damaged document.
PdfElixide.Form.fields/1 steps over a field it cannot read and returns the
ones it reached, so a form whose /Fields names an object the file does not
contain still answers {:ok, []}. PdfElixide.Signature.list/1 refuses that
same document as %PdfElixide.Error{reason: :invalid_pdf}, so an empty list
from list/1 means the document was read and carries no signatures.
The same rule reaches the value a signature field points at. A /V that is not
a signature dictionary is refused, including one naming an object the file does
not contain. The single exception is a /V of null, which is how a cleared
field is spelled — that field is unsigned, so list/1 skips it and
PdfElixide.Signature.unsigned_fields/1 names it.
unsigned_fields/1 reads no signature value, so it does not apply that
value-level validation. A field pointing at something other than a signature
dictionary is omitted; use list/1 to validate the value.
PdfElixide.Signature.list/1 and PdfElixide.Signature.unsigned_fields/1
reject a cyclic or excessively deep field hierarchy the way the form readers do;
see Field hierarchy errors.
Signature fields are not form fields
A signature field is not fillable. PdfElixide.Form omits it whether signed or
unsigned and reports it as not found.
Signature fields in the Forms guide describes that
boundary.
Producing signatures is not offered
Nothing in this library signs a document. Sign with an external tool instead and open the result here: every call in this guide reads a document signed elsewhere. The Concurrency guide describes which signature calls touch a handle and which continue working independently after it is closed.