eIDAS PDF Signatures on Linux

Working eIDAS signatures on Ubuntu with a D-Trust Card 5.1

Here in Germany, running a GmbH means signing more and more things electronically. The Finanzamt wants e-invoices, contracts move to PAdES, and quite nicely the list of paper-only documents is shrinking. Ok, some times to times, you still need to send a fax or fill a form electronically, which creates a PDF, that you need to sign manually and then post the good old way. Still, the direction is clear: every year, a few more things that used to need a wet-ink signature now need a qualified electronic signature (QES) under eIDAS instead.

So, I got a D-Trust Card 5.1 Std. RSA 2cc and a REINER SCT cyberJack RFID standard reader, and I sat down to make it work with Ubuntu 24.04. I must confess: I had no idea I was about to spend a few evenings reading OpenSC source code.

The card that Linux did not want to talk to

The D-Trust Card 5.1 uses CardOS 6.0, and CardOS 6.0 requires a CAN (Card Access Number) to open a PACE secure channel before the card will answer anything useful. This is a nice security feature — the 6-digit CAN is printed on the card itself, so an attacker with stolen card data but no physical card cannot do much — but it needs support all the way down the stack.

OpenSC did not support D-Trust 5.1 until version 0.27.1, released on 2026-03-31. Before that, pkcs15-tool -D would politely answer "Card is invalid or cannot be handled" and that was the end of the conversation. The implementation landed in OpenSC PR #3137, and the release tracking is in issue #3526. It looks simple, but I had to read the code of OpenSC a bit more than I wanted at first, it was segfaulting on me a bit too much. At the end, I found the bug and created my first PR for this project, happy day.

The lesson is simple: if you are buying a qualified signature card today, check the OpenSC changelog before you open the package. And if you see that you are at the bleeding edge, be ready to spend a bit of time to help iron out the bugs. Once OpenSC was able to talk with my card, the next step was to use it for something useful, that is, sign some PDFs.

What I tried that did not work

The obvious first candidate was pdfsig from poppler-utils, driving the card through NSS. On paper it looks clean: register the OpenSC PKCS#11 module in an NSS database, point pdfsig at it, done. In reality, poppler 24.02 cannot express "no database password, but let the reader collect the card PIN on its own pinpad". Every combination of -nss-pwd '', -nss-pwd 'something', and no flag at all fails. The cyberJack reader has a protected authentication path — PINs must be typed on its physical keypad, not on the laptop — and NSS as used by poppler 24.02 does not forward that properly for this card.

Next candidate: pyHanko with --skip-user-pin. pyHanko has excellent PKCS#11 support, and --skip-user-pin sounds exactly like what you want when the reader handles the PIN itself. Except that --skip-user-pin does not mean "let the token handle the PIN", it means "skip C_Login entirely". Without a successful C_Login, the signing key is invisible (it has the private flag), and pyHanko reports Could not find private key with label 'Signaturzertifikat'. Without the flag, pyHanko prompts for a PIN on the keyboard and pushes it into C_Login, which the card rejects because it expects protected authentication path. Heads you lose, tails you lose.

The working recipe

After a lot of reading of pyhanko/sign/pkcs11.py and python-pkcs11/_pkcs11.pyx, the fix turned out to be a single configuration line.

pyHanko does know how to issue C_Login(CKU_USER, NULL, 0) — the call that tells the token "please handle the PIN yourself". It is exposed through a pin-entry mode called DEFER. The catch: the CLI flags can only reach SKIP or PROMPT. The only way to select DEFER is through a YAML config file loaded with --config.

Here is the minimal pyhanko.yml that makes the whole thing work:

pkcs11-setups:
  smartcard:
    module-path: /usr/lib/opensc-pkcs11.so
    slot-no: 1                      # Signature-PIN slot
    cert-label: Signaturzertifikat
    key-label: Signaturschluessel   # private key label differs from cert label!
    prompt-pin: defer               # the setting that changes everything
    other-certs-to-pull: []
    bulk-fetch: false

And the invocation:

pyhanko --config pyhanko.yml sign addsig \
    --field Sig1 \
    pkcs11 --p11-setup smartcard \
    input.pdf input-signed.pdf

The reader blinks, asks for the CAN on its pinpad, then asks for the Signature-PIN, and out comes a signed PDF. pdfsig input-signed.pdf happily reports Signature is Valid. Total document signed.

Two small details that cost me time:

  • The certificate label is Signaturzertifikat but the private key label is Signaturschluessel. pyHanko needs both, separately, via cert-label and key-label; using only one of them gives you a very confusing "key not found" error.
  • The card allows only 3 attempts on the Signature-PIN before it locks. Before running the real sign addsig, write a tiny read-only Python script that opens the token with user_pin=PROTECTED_AUTH and lists the private key labels. A misconfigured run costs you a PIN retry, and you do not want to discover the YAML typo on attempt three.

Adding PAdES and a trusted timestamp

A bare signature is rarely enough for a real invoice. What the EU (and every auditor) expects is a PAdES signature with a trusted RFC 3161 timestamp attached. The good news: pyHanko adds both with two extra flags. The even better news: Sectigo runs a public qualified timestamping authority at http://timestamp.sectigo.com/qualified — no registration, no account, nothing to configure, and it answers in under a second.

pyhanko --config pyhanko.yml sign addsig \
    --field '1/350,40,560,120/Sig1' \
    --style-name default \
    --use-pades \
    --timestamp-url http://timestamp.sectigo.com/qualified \
    pkcs11 --p11-setup smartcard \
    input.pdf input-signed.pdf

The --field syntax PAGE/X1,Y1,X2,Y2/NAME places a visible signature box on the page. A4 is 595×842 points, origin bottom-left, and 350,40,560,120 drops a reasonably sized block in the bottom-right corner of the first page. Use -1 for the last page. And yes, the coordinates must be integers — passing floats gives you a cryptic "Sig field parameters should be four integers" error.

And once you have a signed PDF in your hand, you will want to double-check it against something more authoritative than pdfsig. The EU runs a free signature validation webapp that understands PAdES, walks the whole trust chain, and verifies the timestamp against the EU List of Trusted Lists (LOTL). All green there means a PDF that any auditor in Europe will accept.

What this is worth

At the end, I have a command-line tool that produces qualified, timestamped, PAdES-signed PDFs on Linux, using only open-source software, a €100 reader, and a €150/year card from D-Trust. No virtual machine, no commercial Java signing suite, no "please use Windows", no dependency on a vendor that could decide to discontinue their Linux client next year.

This matters because the list of things that must be signed electronically is only going to grow. Better to have a working, scriptable, reproducible setup on the platform I actually use every day, than to boot a Windows VM every time the Finanzamt invents a new form.

The versions known to work together, as of today:

Enjoy!

Fluid Phase Equilibria, Chemical Properties & Databases
Back to Top