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}