Skip to main content

confium_store_pkcs11/
backend.rs

1//! PKCS#11 backend for [`confium-store`].
2//!
3//! Implements [`StoreBackend`](confium_store::backend::StoreBackend) on top
4//! of the [`cryptoki`] crate (Apache-2.0), giving Confium a
5//! hardware-backed keystore for HSMs (YubiHSM, Thales, Utimaco),
6//! smartcards, and software tokens such as [SoftHSM2].
7//!
8//! The current revision wires the trait, configuration, and
9//! `cryptoki`-level session-establishment plumbing. The actual HSM
10//! object operations (`put_secret`, `get_secret`, …) live on
11//! [`Pkcs11Instance`](crate::Pkcs11Instance) and return
12//! [`NotImplemented`](confium_store::error::Error::NotImplemented) —
13//! see `TODO.roadmap/18-hardware-keystore-backends.md`.
14//!
15//! ## Wire name
16//!
17//! The backend advertises itself as `"pkcs11"` so the FFI create path
18//! can look it up via [`confium_store::backend::find`].
19//!
20//! ## Key-handle semantics
21//!
22//! Like the other hardware backends, the PKCS#11 store does not return
23//! raw key bytes from `get_secret`; it returns the PKCS#11 object
24//! handle (an opaque `*mut c_void`). Signature/KEM plugins that want
25//! to actually use the key invoke the HSM-style `cfmp_sign_withhandle`
26//! symbol described in `TODO.roadmap/18-hardware-keystore-backends.md`.
27//! The skeleton does not yet wire this — every storage operation is a
28//! `NotImplemented` stub; the session plumbing (module load,
29//! initialize, slot resolve, open session, login) is wired for real.
30//!
31//! [SoftHSM2]: https://www.opendnssec.org/softhsm/
32
33use confium_store::backend::{Options, StoreBackend, StoreInstance};
34use confium_store::error::{Error, NotImplementedSnafu, Result};
35use confium_store::register_backend;
36
37use crate::config::Config;
38use crate::error::{IntoStoreError, map_cryptoki};
39use crate::instance::Pkcs11Instance;
40
41/// What is unimplemented in this skeleton. Centralised so the wire
42/// message is consistent across every stub and the tests can match on
43/// the string.
44const SKELETON_NOT_IMPLEMENTED: &str =
45    "pkcs#11 backend (skeleton; HSM object ops land in the next revision)";
46
47/// Factory for the PKCS#11 backend. Stateless — all per-keystore state
48/// lives in [`Pkcs11Instance`].
49///
50/// Construct directly (`Pkcs11Backend`) or look up via the link-time
51/// registry under the wire name `"pkcs11"`.
52#[derive(Debug, Default, Clone, Copy)]
53pub struct Pkcs11Backend;
54
55impl StoreBackend for Pkcs11Backend {
56    fn name(&self) -> &'static str {
57        "pkcs11"
58    }
59
60    fn open(&self, opts: &Options) -> Result<Box<dyn StoreInstance>> {
61        // Parse options eagerly so configuration errors surface at open
62        // time rather than on the first storage call.
63        let config = Config::from_options(opts)?;
64
65        // Load and initialise the PKCS#11 module. `OS_LOCKING_OK` tells
66        // the module it may use the platform's native threading
67        // primitives, which is what makes the resulting client
68        // `Send + Sync`.
69        let client = cryptoki::context::Pkcs11::new(config.pkcs11_module.as_str())
70            .map_err(|e| e.into_store_error("load pkcs11 module"))?;
71        map_cryptoki(
72            client.initialize(cryptoki::context::CInitializeArgs::new(
73                cryptoki::context::CInitializeFlags::OS_LOCKING_OK,
74            )),
75            "initialize",
76        )?;
77
78        // Resolve the slot. If a token label is supplied, we assert it
79        // matches the slot we resolved by id — defensive, so a
80        // misconfiguration surfaces at open time rather than silently
81        // addressing the wrong token.
82        let slot = resolve_slot(&client, &config)?;
83
84        // Open a read/write session so both put and get paths work
85        // against the same handle.
86        let session = map_cryptoki(client.open_rw_session(slot), "open session")?;
87
88        // Log in as the normal user if a PIN was supplied. If absent,
89        // leave the session unauthenticated — operations that require
90        // `CKU_USER` will surface a `Wrapped` cryptoki error at call
91        // time, which is the right behaviour for an operator-prompted
92        // flow.
93        if let Some(pin) = config.pin.as_deref() {
94            let auth = cryptoki::types::AuthPin::new(pin.to_string().into_boxed_str());
95            map_cryptoki(
96                session.login(cryptoki::session::UserType::User, Some(&auth)),
97                "login",
98            )?;
99        }
100
101        Ok(Box::new(Pkcs11Instance::new(config, client, session)))
102    }
103}
104
105register_backend!(Pkcs11Backend);
106
107/// Resolve the configured slot. Uses the explicit `slot_id`; if a
108/// `token_label` is also present, asserts that the slot's token label
109/// matches (defensive — surfaces a misconfiguration at open time
110/// instead of silently addressing the wrong token).
111fn resolve_slot(
112    client: &cryptoki::context::Pkcs11,
113    config: &Config,
114) -> Result<cryptoki::slot::Slot> {
115    let slots = map_cryptoki(client.get_slots_with_token(), "get slots")?;
116    let by_id = *slots
117        .iter()
118        .find(|s| s.id() == config.slot_id)
119        .ok_or_else(|| Error::Wrapped {
120            message: format!(
121                "pkcs#11: no token-present slot with id {} (have: {:?})",
122                config.slot_id,
123                slots.iter().map(|s| s.id()).collect::<Vec<_>>()
124            ),
125        })?;
126
127    if let Some(label) = config.token_label.as_deref() {
128        let info = map_cryptoki(client.get_token_info(by_id), "get token info")?;
129        // PKCS#11 pads the label to 32 bytes with trailing spaces; trim
130        // before comparing.
131        let actual = info.label().trim_end();
132        if actual != label {
133            return Err(Error::Wrapped {
134                message: format!(
135                    "pkcs#11: slot {} token label mismatch: expected {:?}, got {:?}",
136                    config.slot_id, label, actual
137                ),
138            });
139        }
140    }
141
142    Ok(by_id)
143}
144
145/// Helper used by the instance stubs. Kept here (rather than on
146/// `Pkcs11Instance`) so the message source is co-located with the
147/// backend's other skeleton plumbing.
148pub(crate) fn not_implemented() -> Error {
149    NotImplementedSnafu {
150        what: SKELETON_NOT_IMPLEMENTED,
151    }
152    .build()
153}
154
155// SAFETY notes for the trait object: `cryptoki::context::Pkcs11` and
156// `cryptoki::session::Session` are `Send + Sync` per upstream docs
157// (the underlying `C_Initialize(CKF_OS_LOCKING_OK)` call makes the
158// module thread-safe). `Config` is plain owned data. We therefore do
159// not need a manual `unsafe impl Send/Sync`.
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use confium_store::backend::Compartment;
165    use std::collections::HashMap;
166    use std::ffi::c_void;
167
168    /// Sentinel non-null pointer; the backend treats `*mut c_void` as
169    /// opaque in this skeleton so identity does not matter.
170    fn sentinel(n: usize) -> *mut c_void {
171        n as *mut c_void
172    }
173
174    #[test]
175    fn backend_advertises_pkcs11_wire_name() {
176        assert_eq!(Pkcs11Backend.name(), "pkcs11");
177    }
178
179    #[test]
180    fn backend_is_registered() {
181        // The link-time registry must surface the pkcs11 backend by its
182        // wire name so the FFI create path can find it.
183        let backend = confium_store::backend::find("pkcs11").expect("pkcs11 backend registered");
184        assert_eq!(backend.name(), "pkcs11");
185    }
186
187    #[test]
188    fn open_rejects_missing_module_option() {
189        let opts: Options = HashMap::new();
190        let err = match Pkcs11Backend.open(&opts) {
191            Err(e) => e,
192            Ok(_) => panic!("expected config error, got Ok"),
193        };
194        // Config parse failure surfaces as Wrapped.
195        assert!(matches!(err, Error::Wrapped { .. }));
196        assert!(format!("{err}").contains("pkcs11_module"));
197    }
198
199    #[test]
200    fn open_rejects_missing_slot_option() {
201        let mut opts: Options = HashMap::new();
202        opts.insert("pkcs11_module".into(), "/nonexistent/libpkcs11.so".into());
203        let err = match Pkcs11Backend.open(&opts) {
204            Err(e) => e,
205            Ok(_) => panic!("expected config error, got Ok"),
206        };
207        assert!(format!("{err}").contains("slot_id"));
208    }
209
210    #[test]
211    fn not_implemented_carries_skeleton_message() {
212        let err = not_implemented();
213        assert!(matches!(err, Error::NotImplemented { .. }));
214        assert!(format!("{err}").contains("pkcs#11 backend"));
215    }
216
217    // -----------------------------------------------------------------
218    // Integration tests against SoftHSM2.
219    //
220    // These tests exercise the wired-up session plumbing (module load,
221    // initialize, slot resolve, open R/W session, login) against a real
222    // (software) token. They are skipped unless the `TEST_PKCS11_MODULE`
223    // environment variable points at a usable PKCS#11 shared object.
224    // The storage-operation stubs still return `NotImplemented`, so
225    // these tests assert the open path succeeds rather than exercising
226    // real object storage.
227    //
228    // Setup (macOS, with SoftHSM2 from Homebrew):
229    //
230    //   brew install softhsm
231    //   softhsm2-util --init-token --slot 0 --label confium \
232    //     --so-pin 1234 --pin 1234
233    //
234    //   TEST_PKCS11_MODULE=/opt/homebrew/lib/softhsm/libsofthsm2.so \
235    //   TEST_PKCS11_SLOT=0 TEST_PKCS11_PIN=1234 \
236    //     cargo test -p confium-store-pkcs11
237    //
238    // On CI (Linux) the workflow provisions SoftHSM2 and exports the
239    // variables automatically. See
240    // `TODO.roadmap/18-hardware-keystore-backends.md` for the full plan.
241    fn integration_opts() -> Option<Options> {
242        let module = std::env::var_os("TEST_PKCS11_MODULE")?;
243        let slot = std::env::var_os("TEST_PKCS11_SLOT")?;
244        let pin = std::env::var_os("TEST_PKCS11_PIN")?;
245        let mut opts: Options = HashMap::new();
246        opts.insert("pkcs11_module".into(), module.into_string().ok()?);
247        opts.insert("slot_id".into(), slot.into_string().ok()?);
248        opts.insert("pin".into(), pin.into_string().ok()?);
249        if let Some(label) = std::env::var_os("TEST_PKCS11_LABEL") {
250            opts.insert("token_label".into(), label.into_string().ok()?);
251        }
252        Some(opts)
253    }
254
255    #[test]
256    fn open_against_softhsm2_opens_session() {
257        let opts = match integration_opts() {
258            Some(o) => o,
259            None => {
260                eprintln!(
261                    "skipping SoftHSM2 test: set TEST_PKCS11_MODULE, TEST_PKCS11_SLOT, \
262                     TEST_PKCS11_PIN to enable"
263                );
264                return;
265            }
266        };
267        let _store = Pkcs11Backend.open(&opts).expect("open against SoftHSM2");
268    }
269
270    #[test]
271    fn put_secret_against_softhsm2_is_not_implemented_in_skeleton() {
272        let opts = match integration_opts() {
273            Some(o) => o,
274            None => {
275                eprintln!(
276                    "skipping SoftHSM2 test: set TEST_PKCS11_MODULE, TEST_PKCS11_SLOT, \
277                     TEST_PKCS11_PIN to enable"
278                );
279                return;
280            }
281        };
282        let mut store = Pkcs11Backend.open(&opts).expect("open against SoftHSM2");
283        let result = store.put_secret("mod", "app", "k1", sentinel(0x1000));
284        let err = match result {
285            Err(e) => e,
286            Ok(()) => panic!("expected NotImplemented, got Ok"),
287        };
288        assert!(matches!(err, Error::NotImplemented { .. }));
289    }
290
291    #[test]
292    fn enumerate_against_softhsm2_is_not_implemented_in_skeleton() {
293        let opts = match integration_opts() {
294            Some(o) => o,
295            None => {
296                eprintln!(
297                    "skipping SoftHSM2 test: set TEST_PKCS11_MODULE, TEST_PKCS11_SLOT, \
298                     TEST_PKCS11_PIN to enable"
299                );
300                return;
301            }
302        };
303        let store = Pkcs11Backend.open(&opts).expect("open against SoftHSM2");
304        let result = store.enumerate("mod", "app", Compartment::Private);
305        let err = match result {
306            Err(e) => e,
307            Ok(v) => panic!("expected NotImplemented, got {v:?}"),
308        };
309        assert!(matches!(err, Error::NotImplemented { .. }));
310    }
311}