collective

Browse / github.com/storytold/pdfcraft / patch

Linux: sign with PKCS #11 token identities (p11-kit)

Linux: sign with PKCS #11 token identities (p11-kit) ## What Signing with keys that live on PKCS #11 tokens — Rutoken, JaCarta and the software tokens Linux deployments actually use — through the same digital-id flow the macOS Keychain and the Windows CNG store already plug into: - **Discovery**: the p11-kit proxy (`p11-kit-proxy.so`), the system-wide aggregate every major distro configures, with `PDFCRAFT_PKCS11_MODULE` as the escape hatch (colon-separated, like `p11-kit`'s own conventions). - **`sign::pkcs11::list()`**: every certificate whose token holds a matching private key. Tokens with `CKF_LOGIN_REQUIRED` list their certificates too —

Status
open
Score
0.00
Adopters
0 other operators
Author
Bra1nZ (imported from pull request #4968, open)
Base
64e0868b844e0230027a493c90d6e4bdc7af086f
Size
+532 −14 in 9 files
Review
pending
Shared
2026-10-28
Id
9a614b6ada7dca568b01f5a4

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock crates/sign/Cargo.toml crates/sign/src/lib.rs crates/sign/src/pkcs11.rs crates/sign/src/rsa_pad.rs crates/sign/tests/it/main.rs crates/sign/tests/it/pkcs11.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/sign_ui.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -229,7 +229,7 @@ dependencies = [  "objc2-foundation 0.3.2",  "parking_lot",  "percent-encoding",- "windows-sys 0.59.0",+ "windows-sys 0.61.2",  "wl-clipboard-rs",  "x11rb", ]@@ -1373,6 +1373,28 @@ dependencies = [  "rand_core 0.10.1", ] +[[package]]+name = "cryptoki"+version = "0.12.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "625cf4599c43d69a16996ce2573fc80745a0b0300c962f0bfa11d55b6cab2b2d"+dependencies = [+ "bitflags 2.13.2",+ "cryptoki-sys",+ "libloading",+ "log",+ "secrecy",+]++[[package]]+name = "cryptoki-sys"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f1fd850498411e4057f1cba79e6e2bc7cbe960544c1046ab46d4685c403a1121"+dependencies = [+ "libloading",+]+ [[package]] name = "ctutils" version = "0.4.5"@@ -1970,7 +1992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [  "libc",- "windows-sys 0.59.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -3963,7 +3985,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [  "libc",- "windows-sys 0.59.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -4453,6 +4475,7 @@ dependencies = [  "cbc",  "chrono",  "crypto-bigint",+ "cryptoki",  "des",  "ecdsa",  "ed25519-dalek",@@ -5094,7 +5117,7 @@ dependencies = [  "once_cell",  "socket2",  "tracing",- "windows-sys 0.59.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -5615,7 +5638,7 @@ dependencies = [  "errno",  "libc",  "linux-raw-sys 0.12.1",- "windows-sys 0.59.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -5685,7 +5708,7 @@ dependencies = [  "security-framework",  "security-framework-sys",  "webpki-root-certs",- "windows-sys 0.59.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -5780,6 +5803,15 @@ dependencies = [  "zeroize", ] +[[package]]+name = "secrecy"+version = "0.10.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"+dependencies = [+ "zeroize",+]+ [[package]] name = "security-framework" version = "3.7.0"@@ -6572,7 +6604,7 @@ dependencies = [  "getrandom 0.4.3",  "once_cell",  "rustix 1.1.5",- "windows-sys 0.59.0",+ "windows-sys 0.61.2", ]  [[package]]@@ -7684,7 +7716,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [- "windows-sys 0.59.0",+ "windows-sys 0.61.2", ]  [[package]]diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml--- a/crates/sign/Cargo.toml+++ b/crates/sign/Cargo.toml@@ -53,6 +53,11 @@ rustls-cng = { version = "0.7.1", default-features = false } # Signing with Keychain identities (MIT OR Apache-2.0). security-framework = "3.7" +[target.'cfg(target_os = "linux")'.dependencies]+# Signing with PKCS #11 token keys — Rutoken, JaCarta and friends (Apache-2.0/MIT).+# A safe wrapper over the C API, in the same spirit as rustls-cng and security-framework.+cryptoki = "0.12.1"+ [target.'cfg(target_arch = "wasm32")'.dependencies] # crypto-bigint asks for randomness; in the browser it comes from Web Crypto. getrandom = { version = "0.4", features = ["wasm_js"] }diff --git a/crates/sign/src/lib.rs b/crates/sign/src/lib.rs--- a/crates/sign/src/lib.rs+++ b/crates/sign/src/lib.rs@@ -16,6 +16,8 @@ pub mod graphic; pub mod keychain; pub mod keys; pub mod pdf;+#[cfg(target_os = "linux")]+pub mod pkcs11; pub mod pkcs12; pub mod revocation; mod rsa_pad;diff --git a/crates/sign/src/pkcs11.rs b/crates/sign/src/pkcs11.rsnew file mode 100644--- /dev/null+++ b/crates/sign/src/pkcs11.rs@@ -0,0 +1,377 @@+//! Digital IDs from PKCS #11 tokens and software modules: a certificate with a private key+//! that never leaves the device, only signs on request. This is the common language of the+//! smart cards and cryptographic modules used on Linux (Rutoken, JaCarta, NSS's software+//! token, …), coordinated system-wide by p11-kit.+//!+//! The module talks to tokens through the [`cryptoki`] wrapper (the way `security-framework`+//! wraps the macOS Keychain and `rustls-cng` wraps the Windows store): enumerate slots →+//! certificates → the private key matching each certificate by its PKCS #11 `CKA_ID`.+//! Signing goes through [`crate::keys::ExternalKey`], like those stores: PdfCraft never sees+//! the key material, it only asks the device to sign. A token that requires authentication+//! is given its PIN through [`find_with_pin`]; sessions the environment has already+//! authenticated sign without one.+//!+//! RSA (PKCS #1 v1.5) and ECDSA keys sign. GOST keys (Rutoken ECP and friends) enumerate+//! but are left out of the list for now: the signature formats above only carry RSA/ECDSA+//! algorithm identifiers, and GOST signing arrives with the GOST integration.++use std::sync::{Mutex, OnceLock};++use cryptoki::context::{CInitializeArgs, CInitializeFlags, Pkcs11};+use cryptoki::error::{Error, RvError};+use cryptoki::mechanism::Mechanism;+use cryptoki::object::{Attribute, AttributeType, KeyType, ObjectClass, ObjectHandle};+use cryptoki::session::{Session, UserType};+use cryptoki::slot::Slot;+use cryptoki::types::AuthPin;++use crate::keys::{DigestAlg, ExternalKey, PrivateKey, PublicKey};+use crate::{Certificate, DigitalId, SignError};++/// Loads a PKCS #11 module and initializes it for multi-threaded use.+fn load(path: &str) -> Result<Pkcs11, SignError> {+    let client = Pkcs11::new(path).map_err(|e| SignError::Crypto(format!("{}: {e}", module_name(path))))?;+    match client.initialize(CInitializeArgs::new(CInitializeFlags::OS_LOCKING_OK)) {+        Ok(()) => {}+        // The proxy (or another library in the process) may have initialized the module+        // already; that state is exactly the one we want.+        Err(Error::Pkcs11(RvError::CryptokiAlreadyInitialized, _)) => {}+        Err(e) => return Err(SignError::Crypto(format!("{}: {e}", module_name(path)))),+    }+    Ok(client)+}++fn module_name(path: &str) -> &str {+    path.rsplit('/').next().unwrap_or(path)+}++fn module_paths() -> Vec<String> {+    if let Ok(list) = std::env::var("PDFCRAFT_PKCS11_MODULE") {+        return list.split(':').filter(|p| !p.is_empty()).map(String::from).collect();+    }+    // The p11-kit proxy is the system-wide aggregate; `p11-kit list-modules` shows what it+    // covers. Its install path varies by multiarch tuple.+    ["/usr/lib/x86_64-linux-gnu", "/usr/lib/aarch64-linux-gnu", "/usr/lib64", "/usr/lib"]+        .iter()+        .map(|dir| format!("{dir}/p11-kit-proxy.so"))+        .collect()+}++/// Loaded modules, initialized once and kept for the process.+fn modules() -> Vec<Pkcs11> {+    static LOADED: OnceLock<Mutex<Vec<(String, Option<Pkcs11>)>>> = OnceLock::new();+    let loaded = LOADED.get_or_init(|| Mutex::new(Vec::new()));+    let mut guard = match loaded.lock() {+        Ok(g) => g,+        Err(_) => return Vec::new(),+    };+    let mut out = Vec::new();+    for path in module_paths() {+        if let Some((_, client)) = guard.iter().find(|(p, _)| *p == path) {+            if let Some(client) = client {+                out.push(client.clone());+            }+            continue;+        }+        let client = load(&path).ok();+        if let Some(client) = &client {+            out.push(client.clone());+        }+        guard.push((path, client));+    }+    out+}++/// The X.509 certificate objects on this session the app could use.+fn token_certificates(session: &Session) -> Vec<(Certificate, Vec<u8>)> {+    let Ok(objects) = session.find_objects(&[Attribute::Class(ObjectClass::CERTIFICATE)]) else { return Vec::new() };+    let mut out = Vec::new();+    for object in objects {+        let Ok(attributes) = session.get_attributes(object, &[AttributeType::Value, AttributeType::Id]) else { continue };+        let mut value: Option<Vec<u8>> = None;+        let mut id = Vec::new();+        for attribute in attributes {+            match attribute {+                Attribute::Value(bytes) => value = Some(bytes),+                Attribute::Id(bytes) => id = bytes,+                _ => {}+            }+        }+        let Some(value) = value else { continue };+        if value.is_empty() {+            continue;+        }+        let Ok(certificate) = Certificate::parse(&value) else { continue };+        // Keys PdfCraft can't represent yet (GOST, Ed25519 on tokens) are left for their+        // stage; an identity must be able to sign.+        if matches!(certificate.public_key, PublicKey::Unsupported { .. }) {+            continue;+        }+        out.push((certificate, id));+    }+    out+}++/// The private key on this session matching `ckaid`, with whether it is an RSA key.+fn session_key(session: &Session, ckaid: &[u8]) -> Option<(ObjectHandle, bool)> {+    let objects = session.find_objects(&[Attribute::Class(ObjectClass::PRIVATE_KEY), Attribute::Id(ckaid.to_vec())]).ok()?;+    let object = *objects.first()?;+    let Ok(attributes) = session.get_attributes(object, &[AttributeType::KeyType]) else { return Some((object, false)) };+    let key_type = attributes.first().and_then(|a| match a {+        Attribute::KeyType(k) => Some(*k),+        _ => None,+    });+    // An unknown key type is left to the sign call to refuse.+    match key_type {+        None => Some((object, false)),+        Some(k) if k == KeyType::RSA || k == KeyType::EC => Some((object, k == KeyType::RSA)),+        Some(_) => None,+    }+}++/// The `pkcs11:<SHA-256 of the certificate>` reference PdfCraft keeps for an identity.+pub fn reference(c: &Certificate) -> String {+    let d = DigestAlg::Sha256.digest(&[&c.raw]);+    format!("pkcs11:{}", d.iter().map(|b| format!("{b:02x}")).collect::<String>())+}++fn trimmed_label(label: &str) -> String {+    label.trim_end_matches(['\0', ' ']).trim().to_string()+}++/// The other certificates on the same token (the issuers), best effort like the Keychain's.+fn token_chain(session: &Session, signer: &Certificate) -> Vec<Certificate> {+    token_certificates(session).into_iter().filter(|(c, _)| c.raw != signer.raw).map(|(c, _)| c).collect()+}++/// The signing identities across all PKCS #11 modules the environment configures: a+/// certificate with a matching private key that may sign. Tokens that need authentication+/// still list their certificates (they are readable); signing asks for the PIN then.+pub fn list() -> Result<Vec<DigitalId>, SignError> {+    let mut out: Vec<DigitalId> = Vec::new();+    let mut seen: Vec<String> = Vec::new();+    let mut any_module = false;+    for client in modules() {+        any_module = true;+        let Ok(slots) = client.get_slots_with_token() else { continue };+        for slot in slots {+            let Ok(token) = client.get_token_info(slot) else { continue };+            let token_label = trimmed_label(token.label());+            let login_required = token.login_required();+            // Some modules (SoftHSM among them) only take a login on a read-write session.+            let Ok(session) = client.open_rw_session(slot) else { continue };+            for (certificate, id) in token_certificates(&session) {+                if session_key(&session, &id).is_none() && !login_required {+                    // No visible key: without login, keys are hidden (CKA_PRIVATE), so a+                    // token that requires login keeps the pairing unproven until the PIN+                    // flow at sign time; a token that doesn't simply has no key.+                    continue;+                }+                let key_reference = reference(&certificate);+                if seen.contains(&key_reference) {+                    continue; // The proxy may surface a module's token more than once.+                }+                seen.push(key_reference);+                let chain = token_chain(&session, &certificate);+                out.push(DigitalId {+                    key: PrivateKey::external(+                        certificate.public_key.clone(),+                        std::sync::Arc::new(Pkcs11Key { module: client.clone(), slot, ckaid: id.clone(), pin: Mutex::new(None) }),+                    ),+                    certificate,+                    chain,+                    friendly_name: (!token_label.is_empty()).then(|| token_label.clone()),+                });+            }+        }+    }+    if !any_module {+        return Err(SignError::Crypto("no PKCS #11 module was found (p11-kit or PDFCRAFT_PKCS11_MODULE)".into()));+    }+    Ok(out)+}++/// The identity a `pkcs11:` reference (or a certificate common name) names, optionally+/// signed in with its PIN for tokens that require it.+pub fn find_with_pin(reference_or_name: &str, pin: Option<&str>) -> Result<DigitalId, SignError> {+    for client in modules() {+        let Ok(slots) = client.get_slots_with_token() else { continue };+        for slot in slots {+            let Ok(token) = client.get_token_info(slot) else { continue };+            let login_required = token.login_required();+            let Ok(session) = client.open_rw_session(slot) else { continue };+            if let Some(pin) = pin {+                // Log in before the search when the token wants it: the private keys are+                // hidden (CKA_PRIVATE) until then. A wrong PIN surfaces at the sign call,+                // so a lookup that finds nothing shouldn't mask why.+                if login_required {+                    let _ = login(&session, pin);+                }+            }+            for (certificate, id) in token_certificates(&session) {+                let by_name = certificate.subject.common_name() == Some(reference_or_name.trim_start_matches("pkcs11:"));+                if reference(&certificate) != reference_or_name && !by_name {+                    continue;+                }+                if session_key(&session, &id).is_none() && !login_required {+                    continue; // The key isn't there and can't be hiding behind a login.+                }+                let chain = token_chain(&session, &certificate);+                return Ok(DigitalId {+                    key: PrivateKey::external(+                        certificate.public_key.clone(),+                        std::sync::Arc::new(Pkcs11Key { module: client.clone(), slot, ckaid: id.clone(), pin: Mutex::new(pin.map(String::from)) }),+                    ),+                    certificate,+                    chain,+                    friendly_name: None,+                });+            }+        }+    }+    Err(SignError::Crypto(format!("no PKCS #11 identity {reference_or_name}")))+}++/// The identity a reference (or certificate common name) names, without a PIN.+pub fn find(reference_or_name: &str) -> Result<DigitalId, SignError> {+    find_with_pin(reference_or_name, None)+}++/// Signs in on the session, mapping the PIN errors a UI can act on.+fn login(session: &Session, pin: &str) -> Result<(), SignError> {+    match session.login(UserType::User, Some(&AuthPin::new(pin.into()))) {+        Ok(()) => Ok(()),+        Err(Error::Pkcs11(RvError::UserAlreadyLoggedIn, _)) => Ok(()),+        Err(Error::Pkcs11(RvError::PinIncorrect, _)) => Err(SignError::Crypto("the PIN is incorrect".into())),+        Err(Error::Pkcs11(RvError::PinLocked, _)) => Err(SignError::Crypto("too many failed PIN attempts; the token is locked".into())),+        Err(e) => Err(SignError::Crypto(format!("the token rejected the sign-in ({e})"))),+    }+}++/// A key that stays on the token: PdfCraft asks the device to sign, session by session.+struct Pkcs11Key {+    module: Pkcs11,+    slot: Slot,+    ckaid: Vec<u8>,+    /// The PIN, when `find_with_pin` was given one; sessions opened at sign time log in+    /// with it when the token requires authentication.+    pin: Mutex<Option<String>>,+}++impl ExternalKey for Pkcs11Key {+    /// Sign `msg`, hashing it with `alg` (PKCS #1 v1.5 for RSA, DER-encoded ECDSA).+    fn sign(&self, alg: DigestAlg, msg: &[u8]) -> Result<Vec<u8>, SignError> {+        // One retry: a session can go stale (the token was re-plugged, the daemon reset+        // it); a fresh session on the second pass picks the key up again.+        let mut last = Err(SignError::Crypto("the token didn't sign".into()));+        for attempt in 0..2 {+            match self.sign_once(alg, msg) {+                Ok(sig) => return Ok(sig),+                Err(SignError::Crypto(e)) if attempt == 0 && e.starts_with("session") => last = Err(SignError::Crypto(e)),+                Err(e) => return Err(e),+            }+        }+        last+    }+}++impl Pkcs11Key {+    fn sign_once(&self, alg: DigestAlg, msg: &[u8]) -> Result<Vec<u8>, SignError> {+        let Ok(token) = self.module.get_token_info(self.slot) else { return Err(SignError::Crypto("the token is not present".into())) };+        // Some modules (SoftHSM among them) only take a login on a read-write session.+        let session = self.module.open_rw_session(self.slot).map_err(|e| SignError::Crypto(format!("session: {e}")))?;+        if token.login_required() {+            let pin = self.pin.lock().ok().and_then(|p| p.clone());+            if let Some(pin) = pin {+                login(&session, &pin)?;+            }+            // Without a PIN the sign call below refuses with a message the UI can act on.+        }+        let Some((key, rsa)) = session_key(&session, &self.ckaid) else {+            return Err(SignError::Crypto("the token lost its private key".into()));+        };+        let digest = alg.digest(&[msg]);+        let data: Vec<u8> = if rsa { crate::rsa_pad::digest_info(alg, &digest, true) } else { digest };+        let mechanism = if rsa { Mechanism::RsaPkcs } else { Mechanism::Ecdsa };+        let signature = session.sign(&mechanism, key, &data).map_err(|e| match e {+            Error::Pkcs11(RvError::UserNotLoggedIn, _) => SignError::Crypto("the token requires its PIN to sign".into()),+            other => SignError::Crypto(format!("the token didn't sign ({other})")),+        })?;+        if rsa {+            return Ok(signature);+        }+        // ECDSA signatures come back as raw `r || s`; PdfCraft carries DER.+        ecdsa_raw_to_der(&signature).ok_or_else(|| SignError::Crypto("the token returned a malformed ECDSA signature".into()))+    }+}++/// `r || s` of equal halves → DER `SEQUENCE { r INTEGER, s INTEGER }`.+fn ecdsa_raw_to_der(raw: &[u8]) -> Option<Vec<u8>> {+    if raw.is_empty() || raw.len() % 2 != 0 {+        return None;+    }+    let half = raw.len() / 2;+    let (r, s) = (der_integer(&raw[..half])?, der_integer(&raw[half..])?);+    let len = r.len() + s.len();+    let mut out = Vec::with_capacity(len + 4);+    out.push(0x30);+    if len < 128 {+        out.push(len as u8);+    } else {+        out.push(0x81);+        out.push(len as u8);+    }+    out.extend(r);+    out.extend(s);+    Some(out)+}++fn der_integer(bytes: &[u8]) -> Option<Vec<u8>> {+    let start = bytes.iter().position(|b| *b != 0)?;+    let body = &bytes[start..];+    let needs_pad = body[0] & 0x80 != 0;+    let len = body.len() + usize::from(needs_pad);+    if len >= 128 {+        return None; // A signature half is far shorter than 128 bytes.+    }+    let mut out = Vec::with_capacity(len + 2);+    out.push(0x02);+    out.push(len as u8);+    if needs_pad {+        out.push(0);+    }+    out.extend_from_slice(body);+    Some(out)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn ecdsa_raw_signature_becomes_der() {+        // r = 1, s = 0x81 (needs a zero pad): minimal encodings.+        let raw = [0, 0, 0, 1, 0, 0, 0, 0x81];+        // r = 1 → `02 01 01`; s = 0x81 → `02 02 00 81`; SEQUENCE of the two = 7 bytes.+        let der = ecdsa_raw_to_der(&raw).expect("encodes");+        assert_eq!(der, vec![0x30, 0x07, 0x02, 0x01, 0x01, 0x02, 0x02, 0x00, 0x81]);+    }++    #[test]+    fn ecdsa_raw_signature_rejects_odd_input() {+        assert!(ecdsa_raw_to_der(&[1, 2, 3]).is_none());+        assert!(ecdsa_raw_to_der(&[]).is_none());+    }++    #[test]+    fn reference_is_a_sha256_fingerprint() {+        // The test CA fixture, PEM-wrapped.+        let pem = include_str!("../tests/data/ca.crt.pem");+        let b64: String = pem.lines().filter(|l| !l.starts_with("-----")).collect();+        let der = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).expect("PEM decodes");+        let cert = crate::x509::Certificate::parse(&der).expect("test CA parses");+        let r = reference(&cert);+        assert!(r.starts_with("pkcs11:"), "{r}");+        assert_eq!(r.len(), "pkcs11:".len() + 64);+    }+}diff --git a/crates/sign/src/rsa_pad.rs b/crates/sign/src/rsa_pad.rs--- a/crates/sign/src/rsa_pad.rs+++ b/crates/sign/src/rsa_pad.rs@@ -41,7 +41,7 @@ fn encoded_message(n: &[u8], e: &[u8], sig: &[u8], notes: &mut Vec<&'static str> }  /// `DigestInfo ::= SEQUENCE { AlgorithmIdentifier, OCTET STRING }`.-fn digest_info(alg: DigestAlg, digest: &[u8], null_params: bool) -> Vec<u8> {+pub(crate) fn digest_info(alg: DigestAlg, digest: &[u8], null_params: bool) -> Vec<u8> {     let id = der::algorithm(alg.oid(), null_params.then(der::null).as_deref());     der::seq(&[&id, &der::octets(digest)]) }diff --git a/crates/sign/tests/it/main.rs b/crates/sign/tests/it/main.rs--- a/crates/sign/tests/it/main.rs+++ b/crates/sign/tests/it/main.rs@@ -6,4 +6,6 @@ mod chain; mod cms; mod crypto; mod pdf;+#[cfg(target_os = "linux")]+mod pkcs11; mod store_keys;diff --git a/crates/sign/tests/it/pkcs11.rs b/crates/sign/tests/it/pkcs11.rsnew file mode 100644--- /dev/null+++ b/crates/sign/tests/it/pkcs11.rs@@ -0,0 +1,52 @@+//! The Linux PKCS #11 module (`src/pkcs11.rs`) against a real module. The test bench is+//! SoftHSM 2 with the RSA key and certificate of `tests/data/rsa-aes.p12` imported under+//! `CKA_ID AABB` and the user PIN `1234` (the token is named `test`); a real Rutoken or+//! JaCarta works the same way. The tests are skipped unless `PDFCRAFT_PKCS11_MODULE` names+//! a module to read (the unit tests stay green on every machine and in CI).++use pdfcraft_sign::keys::{DigestAlg, Scheme};+use pdfcraft_sign::pkcs11;++/// `PDFCRAFT_PKCS11_MODULE`, or `None` (after a skip note) when the bench isn't set up.+fn bench() -> Option<String> {+    match std::env::var("PDFCRAFT_PKCS11_MODULE") {+        Ok(m) if !m.is_empty() => Some(m),+        _ => {+            eprintln!("skipped: PDFCRAFT_PKCS11_MODULE is not set (no PKCS #11 test bench)");+            None+        }+    }+}++#[test]+fn lists_the_token_identity() {+    let Some(_) = bench() else { return };+    let ids = pkcs11::list().expect("the module lists identities");+    let id = ids.iter().find(|id| id.certificate.subject.common_name() == Some("Test Signer RSA")).expect("the imported identity is listed");+    assert_eq!(id.certificate.subject.common_name(), Some("Test Signer RSA"));+}++#[test]+fn the_token_signs_and_the_certificate_verifies() {+    let Some(_) = bench() else { return };+    let ids = pkcs11::list().expect("the module lists identities");+    let id = ids.iter().find(|id| id.certificate.subject.common_name() == Some("Test Signer RSA")).expect("the imported identity is listed");+    let with_pin = pkcs11::find_with_pin(&pkcs11::reference(&id.certificate), Some("1234")).expect("signs in with the PIN");+    let msg = b"a message for the token to sign";+    let sig = with_pin.key.sign(DigestAlg::Sha256, msg).expect("the token signs");+    let digest = DigestAlg::Sha256.digest(&[msg]);+    let ok = with_pin.certificate.public_key.verify(Scheme::RsaPkcs1, DigestAlg::Sha256, &digest, &sig).expect("verifies");+    assert!(ok, "the token's signature matches the certificate's public key");+}++#[test]+fn find_by_reference_and_common_name() {+    let Some(_) = bench() else { return };+    let ids = pkcs11::list().expect("the module lists identities");+    let id = ids.iter().find(|id| id.certificate.subject.common_name() == Some("Test Signer RSA")).expect("the imported identity is listed");+    let reference = pkcs11::reference(&id.certificate);+    assert!(reference.starts_with("pkcs11:"));+    assert!(pkcs11::find(&reference).is_ok(), "found by its pkcs11: reference");+    assert!(pkcs11::find("Test Signer RSA").is_ok(), "found by common name");+    assert!(pkcs11::find("pkcs11:no-such-identity").is_err(), "an unknown reference is refused, not a panic");+}diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -1229,7 +1229,9 @@ impl PdfCraftApp {             tags_state: Default::default(),             a11y_skipped: Default::default(),             alt_draft: Default::default(),-            os_key_store_ids: false,+            // The OS key store listing is rolled out per platform; on Linux the PKCS #11+            // tokens (Rutoken, JaCarta, …) are the desktop signing story.+            os_key_store_ids: cfg!(target_os = "linux"),             cert_viewer: None,             space_audit: Vec::new(),             space_audit_of: None,diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs--- a/crates/ui-egui/src/sign_ui.rs+++ b/crates/ui-egui/src/sign_ui.rs@@ -249,6 +249,18 @@ fn windows_id(reference: &str) -> Result<DigitalId, String> {     Err(format!("{reference}: Windows certificate store identities are only available on Windows")) } +/// A PKCS #11 token identity by its `pkcs11:` reference, signed in with the PIN when one is+/// typed (tokens that don't need a PIN ignore it).+fn pkcs11_id(reference: &str, pin: Option<&str>) -> Result<DigitalId, String> {+    #[cfg(target_os = "linux")]+    return sign::pkcs11::find_with_pin(reference, pin).map_err(|e| e.to_string());+    #[cfg(not(target_os = "linux"))]+    {+        let _ = pin;+        Err(format!("{reference}: PKCS #11 token identities are only available on Linux"))+    }+}+ pub fn entry_for(path: &str, c: &Certificate) -> DigitalIdEntry {     DigitalIdEntry {         path: path.to_string(),@@ -297,7 +309,7 @@ impl PdfCraftApp {      /// List OS key store signing identities (after the file-based IDs).     fn refresh_os_key_store_ids(&mut self) {-        self.digital_ids.retain(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:"));+        self.digital_ids.retain(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:") && !e.path.starts_with("pkcs11:"));         #[cfg(target_os = "macos")]         if self.os_key_store_ids {             match sign::keychain::identities(None) {@@ -314,6 +326,25 @@ impl PdfCraftApp {                 Err(e) => self.notify_fmt("The Keychain's digital IDs couldn't be listed: {e}", &[("e", &e.to_string())]),             }         }+        #[cfg(target_os = "linux")]+        if self.os_key_store_ids {+            match sign::pkcs11::list() {+                Ok(ids) => {+                    for id in ids {+                        let mut entry = entry_for(&sign::pkcs11::reference(&id.certificate), &id.certificate);+                        // The token's label ("Rutoken ECP #2", "test", …).+                        if let Some(name) = id.friendly_name {+                            entry.name = name;+                        }+                        self.digital_ids.push(entry);+                    }+                }+                // No module (no p11-kit, no PDFCRAFT_PKCS11_MODULE): most Linux machines+                // have no token, that's not an error worth a notice.+                Err(sign::SignError::Crypto(e)) if e.contains("no PKCS #11 module") => {}+                Err(e) => self.notify_fmt("The token digital IDs couldn't be listed: {e}", &[("e", &e.to_string())]),+            }+        }         #[cfg(target_os = "windows")]         if self.os_key_store_ids {             match sign::windows::list() {@@ -428,6 +459,9 @@ impl PdfCraftApp {             keychain_id(&entry.path)?         } else if entry.path.starts_with("windows:") {             windows_id(&entry.path)?+        } else if entry.path.starts_with("pkcs11:") {+            let pin = d.password.trim();+            pkcs11_id(&entry.path, (!pin.is_empty()).then_some(pin))?         } else {             let bytes = std::fs::read(&entry.path).map_err(|e| format!("{}: {e}", entry.path))?;             sign::pkcs12::open(&bytes, &d.password).map_err(|e| match e {@@ -715,6 +749,8 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {                 tl!("Keychain  ·  ").to_string()             } else if e.path.starts_with("windows:") {                 tl!("Windows store  ·  ").to_string()+            } else if e.path.starts_with("pkcs11:") {+                tl!("PKCS #11 token  ·  ").to_string()             } else {                 String::new()             };@@ -764,7 +800,9 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {         });         ui.add_space(10.0);     }-    let removable = d.selected.filter(|&i| ids.get(i).is_some_and(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:")));+    let removable = d.selected.filter(|&i| {+        ids.get(i).is_some_and(|e| !e.path.starts_with("keychain:") && !e.path.starts_with("windows:") && !e.path.starts_with("pkcs11:"))+    });     ui.horizontal(|ui| {         if widgets::pill_button(ui, tl!("Configure New Digital ID"), false).clicked() {             d.step = SignStep::Configure;@@ -1008,7 +1046,7 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {             &[("verb", if d.certify.is_some() { tl!("Certify") } else { tl!("Sign") }), ("name", &entry.name)],         ),     );-    let in_os_key_store = entry.path.starts_with("keychain:") || entry.path.starts_with("windows:");+    let in_os_key_store = entry.path.starts_with("keychain:") || entry.path.starts_with("windows:") || entry.path.starts_with("pkcs11:");     let mut close = false;     let mut go = false;     #[cfg(not(target_arch = "wasm32"))]@@ -1083,7 +1121,15 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {         let l = ui.label(tl!("Location"));         ui.add(crate::widgets::line(&mut d.location).hint_text(tl!("Optional")).desired_width(260.0)).labelled_by(l.id);         ui.end_row();-        if in_os_key_store {+        if entry.path.starts_with("pkcs11:") {+            let l = ui.label(tl!("Token PIN"));+            let r = ui.add(crate::widgets::line(&mut d.password).password(true).desired_width(200.0)).labelled_by(l.id);+            if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {+                go = true;+            }+            ui.label("");+            ui.label(egui::RichText::new(tl!("The key is on a PKCS #11 token; the PIN unlocks it for this signing.")).small().color(t.text_muted));+        } else if in_os_key_store {             ui.label("");             ui.label(                 egui::RichText::new(if entry.path.starts_with("windows:") {