Skip to main content

fiverify

Offline validation of France Identité attestation PDFs — the PAdES-T qualified electronic seal issued by the French Ministry of the Interior.

Offline by default: nothing opens a socket unless you ask for it. Trust anchors, validation time and revocation data are inputs.

Install

Not on PyPI. Install from a clone or an unpacked copy:

cd fiverify
python3 -m venv venv
./venv/bin/pip install .          # or: -e . to work on it

That puts fi-verify in venv/bin/. Run it as ./venv/bin/fi-verify, or activate the environment first (source venv/bin/activate) and just use fi-verify. Without a virtualenv, pip install --user . works too.

Python 3.11+. Pulls in asn1crypto and cryptography; nothing else.

Quick start

./venv/bin/fi-verify attestation.pdf --fetch-revocation
from fiverify import Status, verify_file

report = verify_file("attestation.pdf", fetch_revocation=True)

if report.status is Status.PASSED:
    print("valid", report.signatures[0].details["gen_time"])
else:
    print(report.status.value, [c.summary for c in report.failures()])

That is the complete check: signature, coverage, timestamp, chain to the eIDAS anchor, and revocation. --fetch-revocation downloads the CRLs the certificates name — the one thing here that uses the network. Drop it and everything else still runs offline.

examples/ holds a signed document and two forgeries of it, to try without a real attestation.

Choosing how to handle revocation

Revocation is the only check needing data from outside the document, so it is the only real configuration decision. Four ways, in order of least to most trusting:

CLI API
Fetch CRLs now --fetch-revocation fetch_revocation=True
Use CRLs you already have --crl ./crls/ revocation=RevocationStore.from_paths([...])
Accept that it is unknown --accept-unchecked-revocation accept_unchecked_revocation=True
Demand post-dated CRLs --strict-revocation strict_revocation=True
Refuse to guess (default)

The default returns INDETERMINATE, never PASSED: with no revocation data the tool cannot honestly say a certificate was good. The other three all make PASSED reachable — the first two by actually establishing it, the third by recording that you accepted the gap.

--crl takes a file or a directory, repeatably. Certificates say where their CRLs live, and details.revocation_urls in the report repeats those URLs so you know what to fetch. A CRL is believed only if it matches the issuer, carries a signature that verifies, is in scope, and is fresh enough to speak to the moment in question.

Revocation is evaluated at the timestamp, not now — a certificate revoked after a document was sealed does not invalidate that seal, and the report says so rather than failing.

A CRL answers for that moment if it was issued at or after it — CRLs are cumulative, so a later one still lists an earlier revocation — or if its own validity window covers it, which is what a CA asserts by publishing a nextUpdate. The second case matters in practice: a document sealed this afternoon will usually be checked against a CRL published this morning, and no CRL that post-dates the seal exists yet. Requiring one would make every freshly signed document INDETERMINATE until the CA's next publication.

If you need the stronger guarantee — archival or long-term validation, where you want revocation data that provably post-dates the signature — ask for it:

fi-verify doc.pdf --crl ./crls/ --strict-revocation

Then a CRL merely in force over the timestamp is not enough, and the result is INDETERMINATE / TRY_LATER with the message naming what would settle it.

To keep the network out of the process entirely, fetch separately:

from fiverify import verify_pdf
from fiverify.online import fetch_for_pdf     # the only socket in the project

data = open("attestation.pdf", "rb").read()
store, errors = fetch_for_pdf(data)
report = verify_pdf(data, revocation=store)

Identity attributes

The attestation carries the holder's details as named fields in a PDF object — familyName, givenName, birthdate, birthplace, validityDate and the rest. They are not extracted unless you ask:

fi-verify attestation.pdf --attributes
report = verify_file("attestation.pdf", extract_attributes=True)
report.attributes            # {"familyName": "...", ...} or None

They are only ever returned from bytes a signature covers. If coverage, the message digest or the signature itself did not pass, report.attributes stays None and the report says why — because a document can be forged so that the seal still verifies while the content changed underneath it, which is exactly what examples/forged-append.pdf does. Reading fields out of such a file and believing them is the mistake this refuses to let you make.

Values are decoded properly: UTF-16 with or without a byte-order mark, PDF literal strings with escaped or nested parentheses, and octal escapes. The carrying object is found by its contents, not by a fixed object number.

Reading the report

verify_pdf(data, *, profile=None, at=None, strict=False, extra_anchors=None, revocation=None, fetch_revocation=False, accept_unchecked_revocation=False) takes bytes; verify_file(path, **kw) is the same over a path.

report.status                            # PASSED | FAILED | INDETERMINATE
report.caveats                           # what was not checked
report.attributes                        # identity fields, if asked and verified
report.failures()                        # [Check(...), ...]
report.signatures[0].details             # signer, chain, gen_time, algorithms, …
report.to_dict()                         # JSON-safe

Results are three-valued, per ETSI EN 319 102-1. Every Check carries an id, a severity (CRITICAL / POLICY / INFO), an ETSI sub_indication, and the evidence behind it. strict=True makes profile expectations fatal rather than indeterminate.

Whatever was not checked stays in report.caveats, and the CLI renders it inline — so a PASSED that skipped revocation says so wherever the report goes:

result: PASSED  (revocation status not checked)

With revocation actually established, caveats is empty.

Exit codes: 0 passed, 1 failed, 2 indeterminate, 3 usage or I/O error. Add --json for the full report, -v to include passing informational checks.

Profiles

Everything document-specific is data, in src/fiverify/profiles/france-identite.toml — policy OIDs, algorithm allow-lists, signingTime handling, qcStatements expectations, and trust anchors with their SHA-256 pins. A CA rollover is a profile change, not a code change.

fi-verify doc.pdf --profile my.toml --anchor extra-ca.pem --at 2027-01-01

Two profiles ship. france-identite asserts the expectations above. observe asserts none of them and only reports what a document contains — use it to check the profile against a document of your own, or to work out what changed after a rollover:

fi-verify doc.pdf --profile src/fiverify/profiles/observe.toml --json

Values such as the signature-policy and TSA-policy OIDs are recorded in details whether or not a profile constrains them.

Trust anchors

Chains terminate at AC SERVEUR CACHET EIDAS 2025, which issues both the sealing certificate and the MI-19 timestamping certificate. It is the eIDAS trust anchor itself — listed as a service in the French Trusted List — so paths stop there rather than at a root CA.

A copy ships in src/fiverify/anchors/, and the profile pins its SHA-256:

96693ee8a4310e9c21893021b536e28d844a3587789b4c3d6c436e456db71871

An anchor whose fingerprint is not pinned by its profile is refused at load time.

Do not take that copy on trust — reproduce it. tools/tsl-anchors.py downloads the list, matches every bundled anchor by fingerprint, and reports the service type and status the list gives it:

$ python3 tools/tsl-anchors.py --verify
[ok  ] AC SERVEUR CACHET EIDAS 2025
       96693ee8a4310e9c21893021b536e28d844a3587789b4c3d6c436e456db71871
       type=QC status=granted

Exit status is non-zero if an anchor is missing or no longer granted. Use --tsl-file to work from a copy you downloaded yourself, --tsl for a different list. The provenance, all public:

EU LOTL   https://ec.europa.eu/tools/lotl/eu-lotl.xml
FR TSL    https://messervices.cyber.gouv.fr/visas/tl-fr_v6.xml
  Ministère de l'Intérieur
    AC Serveur Cachet eIDAS 2025                 QC   granted
    Horodatage du Ministère de l'Intérieur MI-19 QTST granted

When the CA rolls over, fetch the new anchor and pin it:

python3 tools/tsl-anchors.py --list                     # find the service
python3 tools/tsl-anchors.py --match "CACHET EIDAS" \
    -o src/fiverify/anchors                             # writes PEM, prints sha256

Then add the filename to [trust].anchors and the fingerprint to [trust].pinned_sha256. To validate against an anchor without editing a profile, pass --anchor path.pem or extra_anchors=[...].

What it checks

pdf.* /ByteRange well-formed, /Contents fills the gap exactly, coverage of the whole file, appended revisions, padding
cms.* messageDigest over the signed bytes, signature arithmetic, contentType, ESS signingCertificateV2 binding, algorithm policy, signingTime, signature-policy and commitment-type attributes
tsp.* timestamp present, imprint over this signature, the token's own signature, TSA EKU critical, TSA policy, stated accuracy, TSA chain
chain.* path to a trusted eIDAS anchor, every certificate valid at the timestamp, not at the wall clock
signer.* qcStatements — QcCompliance, QcSSCD, QcType eSeal — and key usage
revocation.* CRL status of the signer and each CA, evaluated at the timestamp

Two rules govern the byte handling:

  • /Contents padding is split off using the outer DER length. A CMS can legitimately end in 0x00, so trailing zeroes must never be stripped.
  • The signed bytes are used verbatim. Any canonicalisation — openssl without -binary, for instance — makes a valid signature fail.

What it does not do

  • OCSP. CRLs only. A responder URL is reported, not queried.
  • Embedded revocation data. A PAdES-LT document security store (/DSS) is not read, so long-term validation of an old document depends on a CRL that still lists it — a CA may drop entries once a certificate expires. For recent documents this is not a problem; for archival ones it is the reason PAdES-LTA exists.
  • PDF object-graph parsing. Signature discovery works on bytes, so incremental-update difference analysis is out of scope: a revision appended after signing is reported as uncovered content, not classified. For single-signature documents that is the right call; for counter-signed ones it will need building out.

Tests

pip install pytest && python3 -m pytest

75 tests, offline, no fixtures checked in. tests/fixtures.py generates the corpus against a throwaway PKI: tampered content, forged revisions, shifted /ByteRange, non-zero padding, substituted timestamps, expired certificates, missing attributes, CRLs that are stale, mis-signed or out of scope, and identity fields that must not be handed over. Every signature-arithmetic verdict is cross-checked against openssl in tests/test_oracle.py.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fiverify-0.1.0.tar.gz (43.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fiverify-0.1.0-py3-none-any.whl (36.1 kB view details)

Uploaded Python 3

File details

Details for the file fiverify-0.1.0.tar.gz.

File metadata

  • Download URL: fiverify-0.1.0.tar.gz
  • Upload date:
  • Size: 43.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.6

File hashes

Hashes for fiverify-0.1.0.tar.gz
Algorithm Hash digest
SHA256 763bb3b84b71b62819f47e04a27c0d395bdadd62cf691e43545376f6b5ccec6d
MD5 6705ea9335cda266b5111e889e1cc52f
BLAKE2b-256 c5e659c7e8bc54ac4ba32324d94c514b6cb25d8bdda3a6a5d0d55cd72a241d92

See more details on using hashes here.

File details

Details for the file fiverify-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: fiverify-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 36.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.14.6

File hashes

Hashes for fiverify-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 122a80e184e667e7159066abcae2857bcd657b9b8cf57c8b457cc1c79ffde53e
MD5 2ebc20587fb66a591c54ab6f1c5efcd8
BLAKE2b-256 ec6089839175355a184298d3a082818351695105c8bdb621bd66d9b74a3b7b3e

See more details on using hashes here.

Release history Release notifications | RSS feed

0.2.0

2 files

0.1.1

2 files

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page