PKCS#11 Tokens
Enterprise multi-token PKCS#11 signing with Pkcs11Manager — discover, unlock, and sign with typed PIN retry UX
For smart-card / USB-token signing across one or more vendor modules, Pkcs11Manager is an
integration-first, thread-safe façade. Vendor driver .dll / .sos are not thread-safe, so all
module access is funnelled to a single dedicated worker thread (an actor). Callers hold a
cheap-clone Pkcs11Manager handle they can Clone into other threads — the worker serialises every
call, so a thread-unsafe driver can never be entered concurrently.
The three-layer flow
Any UI can wrap the manager because discovery is split from authentication: you can list tokens and certificates without a PIN, and only unlock the one the user actually chooses.
use mudrit_keystore::{Pkcs11Manager, CertFilter, Pin};
use mudrit_pdfsign::{sign_pdf, SignConfig};
let mgr = Pkcs11Manager::new([
"C:/Windows/System32/eps2003csp11v2.dll",
"/usr/lib/opensc-pkcs11.so",
]);
// (1) connected tokens — label / model / serial / PIN flags / cert count — NO PIN
let tokens = mgr.list_tokens()?;
// (2) public certificates on a token, filtered — NO PIN
let certs = mgr.list_certificates(&tokens[0].token, &CertFilter::any().signing_only(true))?;
// (3) log in and get a Box<dyn Signer>
let signer = mgr.unlock(&tokens[0].token, &certs[0].serial, Some(Pin::new("123456")))?;
let signed = sign_pdf(&pdf, signer.as_ref(), &cfg)?;| Layer | Call | PIN? | Returns |
|---|---|---|---|
| 1 | list_tokens() | no | Vec<TokenSummary> — hardware info, PIN flags, certificate count |
| 2 | list_certificates(&token, &filter) | no | Vec<TokenCert> — the public certificates matching your CertFilter |
| 3 | unlock(&token, serial, pin) | yes | Box<dyn Signer> for sign_pdf |
A TokenSummary reports label, manufacturer, model, serial, login_required,
user_pin_initialized, protected_auth_path, a PinStatus, and certificate_count, plus the
stable token: TokenRef you pass back into the other calls.
PINs are zeroized and never logged
Build a Pin from &str / String (or pin.into()); it is wiped from memory on drop and does not
Debug-print its value. The manager emits tracing spans/events on the
mudrit_keystore::pkcs11_manager target (worker lifecycle, unlock/sign/logout, token plug/remove) —
but it is only the tracing facade: nothing is recorded until you install a subscriber, and
PINs are never among the recorded fields. For a protected-auth-path (PIN-pad) token, pass
unlock(.., None) — the device collects the PIN itself.
Wrong-PIN retry UX
PKCS#11 exposes PIN state as flags, not an exact remaining count. A failed unlock returns the
token's real flags so you can build an amber / red / locked prompt:
use mudrit_keystore::Error;
match mgr.unlock(&token, &serial, Some(Pin::new(entered))) {
Ok(signer) => { /* proceed to sign */ }
Err(Error::WrongPin { final_try: true, .. }) => show_red("Wrong PIN — LAST attempt before lock"),
Err(Error::WrongPin { count_low: true, .. }) => show_amber("Wrong PIN — only a few attempts remain"),
Err(Error::WrongPin { .. }) => show_amber("Wrong PIN — try again"),
Err(Error::PinLocked) => show_locked("PIN locked — unlock on the device"),
Err(e) => show_red(&e.to_string()),
}pin_status(&token) re-reads the flags on demand (e.g. to refresh the UI after a wrong attempt).
See Error Handling for the full variant list.
Lifecycle, auto-logout, and single-use
The worker stays alive while any Pkcs11Manager clone or an unlocked signer exists; it ends —
logging the token out — when the last one drops. Two options tighten that further:
use std::time::Duration;
let mgr = Pkcs11Manager::builder()
.module("/usr/lib/opensc-pkcs11.so")
.auto_logout(Duration::from_secs(60)) // drop an idle session after 60s of no signing
.build();
// one PIN entry ⇒ exactly one signature: the token logs out after the first sign()
let signer = mgr.unlock_once(&token, &serial, Some(Pin::new("123456")))?;builder().auto_logout(d)— the worker sweeps and drops any session idle longer thand.unlock_once(...)— a single-use signer; the token is logged out after the first signature (defence-in-depth — one PIN entry, one signature).
Discovery helpers
| Method | Purpose |
|---|---|
module_status() | Per-module load result (path, loaded, error) — which modules loaded and why others did not |
find_certificate(serial) | Locate a certificate by serial across all tokens → Option<(TokenRef, TokenCert)> |
unlock_by_serial(serial, pin) | Find and unlock in one call, searching every token |
pin_status(&token) | Refresh the token's PIN flags |
for m in mgr.module_status()? {
println!("{}: {}", m.path, if m.loaded { "loaded" } else { m.error.as_deref().unwrap_or("failed") });
}Hotplug — watch_tokens
For a live UI, watch_tokens() returns a change-driven stream: a fresh TokenSummary list each time
a token is plugged in or removed (polled internally — far cheaper than polling yourself). The stream
ends when the returned receiver is dropped.
let rx = mgr.watch_tokens();
for tokens in rx { // one message per plug / remove event
ui.refresh(&tokens);
}Across modules without the manager — Pkcs11MultiStore
Pkcs11MultiStore discovers and signs across several PKCS#11 modules at once — e.g. an ePass token
and a vendor token from different manufacturers, each with its own driver. Listing reads certificates
as public objects, so no PIN is needed; modules with no token (or that fail to load) are skipped
so the others still list. Signing targets one certificate by serial — the matching module is found
automatically and only that token is logged in.
use mudrit_keystore::Pkcs11MultiStore;
let store = Pkcs11MultiStore::new(["/path/a.so", "/path/b.dll"]);
for c in store.list()? { // NO PIN
println!("{} — {} ({})", c.serial, c.cn, c.module);
}
let signer = store.signer(&serial, "123456")?; // unlocks only the matched tokenFor a single module, Pkcs11Signer::open(dll, pin, serial) signs directly, and Pkcs11KeyStore is
the Java-KeyStore-style wrapper (aliases() → signer(alias)).
ECDSA on a token
Tokens sign RSA PKCS#1 v1.5 always, RSA-PSS where the device supports it (re-verified against
the certificate, never silently downgraded to PKCS#1), and ECDSA P-256 / P-384 / P-521. For
ECDSA the token signs the pre-computed hash with CKM_ECDSA and returns raw r‖s, which the SDK
DER-encodes into a SEQUENCE { r, s } and re-verifies against the leaf certificate before use —
so a device that returns an unexpected format, or a curve it cannot actually do, is rejected with a
clear error rather than emitting an Adobe-invalid file. The ECDSA device path is validated end-to-end
against SoftHSM2 for all three curves.
Some tokens ship a broken EC implementation
A few hardware tokens advertise CKM_ECDSA but generate keys that are not usable P-256. Always probe
your actual device (pkcs11_ecdsa_probe) before relying on the EC path in production.
Provisioning tools write to the token — never a production DSC
pkcs11_provision and pkcs11_gen_ecdsa create objects on the token (an on-device key plus a
self-signed test certificate) and require the PIN via the PKCS11_PIN environment variable, never on
the command line. They are for populating a dedicated SoftHSM or isolated test token — the
resulting self-signed cert is a test artefact, never a production DSC. Do not run them against a
real signing token.