Mudrit
Guides

PKCS#11 Tokens

Enterprise multi-token PKCS#11 signing with Pkcs11Manager — discover, unlock, and sign with typed PIN retry UX

Part of mudrit-keystore

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)?;
LayerCallPIN?Returns
1list_tokens()noVec<TokenSummary> — hardware info, PIN flags, certificate count
2list_certificates(&token, &filter)noVec<TokenCert> — the public certificates matching your CertFilter
3unlock(&token, serial, pin)yesBox<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 than d.
  • 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

MethodPurpose
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 token

For 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.

Next

On this page