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.rs
new 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.rs
new 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:") {
