confium_store_tpm/backend.rs
1//! TPM 2.0 backend for [`confium-store`].
2//!
3//! Implements [`StoreBackend`](confium_store::backend::StoreBackend) and
4//! [`StoreInstance`](confium_store::backend::StoreInstance) on top of
5//! `tss-esapi`. The current revision wires the trait and configuration
6//! plumbing; storage operations return
7//! [`NotImplemented`](confium_store::Error::NotImplemented). The real
8//! `tss-esapi` calls land behind the `tpm` feature flag in the next
9//! revision — see `TODO.roadmap/18-hardware-keystore-backends.md`.
10//!
11//! ## Wire name
12//!
13//! The backend advertises itself as `"tpm"` so the FFI create path can
14//! look it up via [`confium_store::backend::find`].
15//!
16//! ## Key-handle semantics
17//!
18//! Hardware backends typically do not return raw key bytes; they return
19//! handles (TPM persistent object handles). Per the roadmap, `put_secret`
20//! will seal the caller-supplied bytes under the parent key and store the
21//! resulting object handle; `get_secret` will return the handle as the
22//! opaque `*mut c_void`. Signature/KEM plugins that want to actually use
23//! the key invoke the HSM-style `cfmp_sign_with_handle` symbol described
24//! in `TODO.roadmap/18-hardware-keystore-backends.md`. The skeleton does
25//! not yet wire this — every operation is a `NotImplemented` stub.
26
27use std::ffi::c_void;
28
29use confium_store::backend::{Compartment, Options, StoreBackend, StoreInstance};
30use confium_store::error::{Error, NotImplementedSnafu, Result};
31use confium_store::register_backend;
32
33use crate::config::TpmConfig;
34
35/// What is unimplemented in this skeleton. Centralised so the wire
36/// message is consistent across every stub and the tests can match on
37/// the string.
38const SKELETON_NOT_IMPLEMENTED: &str = "tpm 2.0 backend (skeleton; enable the `tpm` feature)";
39
40/// Factory for the TPM backend. Stateless — all per-keystore state lives
41/// in [`TpmInstance`].
42///
43/// Construct directly (`TpmBackend`) or look up via the link-time
44/// registry under the wire name `"tpm"`.
45#[derive(Debug, Default, Clone, Copy)]
46pub struct TpmBackend;
47
48impl StoreBackend for TpmBackend {
49 fn name(&self) -> &'static str {
50 "tpm"
51 }
52
53 fn open(&self, opts: &Options) -> Result<Box<dyn StoreInstance>> {
54 // Parse the options eagerly so configuration errors surface at
55 // open time rather than on the first storage call. The parsed
56 // config is carried by the instance for the (future)
57 // `tss-esapi` session establishment.
58 let config = TpmConfig::from_options(opts)?;
59 Ok(Box::new(TpmInstance::from_config(config)))
60 }
61}
62
63register_backend!(TpmBackend);
64
65/// One open TPM-backed keystore connection.
66///
67/// Carries the parsed [`TpmConfig`] as a public field so callers (and
68/// tests) can inspect the resolved configuration after `open`. With the
69/// `tpm` feature enabled it additionally owns a (future) `tss-esapi`
70/// context; for now the field is a placeholder so the struct shape is
71/// stable across the skeleton and the wired-up revision.
72pub struct TpmInstance {
73 /// Resolved configuration parsed from [`Options`] at open time.
74 pub config: TpmConfig,
75
76 /// Placeholder for the `tss-esapi` `Context`. Populated when the
77 /// `tpm` feature lands the real session establishment; `None` for
78 /// the skeleton.
79 #[cfg(feature = "tpm")]
80 session: Option<()>,
81}
82
83impl TpmInstance {
84 /// Construct an instance directly from a parsed config. Useful for
85 /// tests and for callers that already have a typed config rather
86 /// than the string-keyed options map.
87 pub fn from_config(config: TpmConfig) -> Self {
88 Self {
89 config,
90 #[cfg(feature = "tpm")]
91 session: None,
92 }
93 }
94
95 /// Helper that builds the canonical skeleton error. Centralised so
96 /// the message stays uniform across every stub.
97 fn not_implemented() -> Error {
98 NotImplementedSnafu {
99 what: SKELETON_NOT_IMPLEMENTED,
100 }
101 .build()
102 }
103}
104
105impl StoreInstance for TpmInstance {
106 fn put_secret(
107 &mut self,
108 _module: &str,
109 _app: &str,
110 _key_id: &str,
111 _key: *mut c_void,
112 ) -> Result<()> {
113 // Skeleton: the wired-up revision will seal `key` under the
114 // parent key (`self.config.parent_handle`) and persist the
115 // resulting object under `(module, app, key_id)`.
116 Err(Self::not_implemented())
117 }
118
119 fn get_secret(&self, _module: &str, _app: &str, _key_id: &str) -> Result<*mut c_void> {
120 // Skeleton: look up the sealed object for `(module, app, key_id)`
121 // and return its persistent handle as `*mut c_void`.
122 Err(Self::not_implemented())
123 }
124
125 fn put_public(
126 &mut self,
127 _module: &str,
128 _app: &str,
129 _identity: &str,
130 _key: *mut c_void,
131 _sig: &[u8],
132 ) -> Result<()> {
133 // Skeleton: public compartments on a TPM are typically stored as
134 // NV indices or as a public-area blob; deferred to the next
135 // revision.
136 Err(Self::not_implemented())
137 }
138
139 fn get_public(
140 &self,
141 _module: &str,
142 _app: &str,
143 _identity: &str,
144 ) -> Result<(*mut c_void, Vec<u8>)> {
145 Err(Self::not_implemented())
146 }
147
148 fn enumerate(
149 &self,
150 _module: &str,
151 _app: &str,
152 _compartment: Compartment,
153 ) -> Result<Vec<(*mut c_void, String)>> {
154 // Skeleton: enumerate the sealed objects (or NV indices) under
155 // the (module, app) scope. The wired-up revision will read them
156 // out of `tss-esapi`'s persistent-object list.
157 Err(Self::not_implemented())
158 }
159}
160
161// SAFETY: the skeleton carries only a parsed config (a `PathBuf`, an
162// enum, an `Option<ParentHandle>`, and a `Vec<u8>`); nothing is
163// thread-local or `!Send`. The future `tss-esapi::Context` is itself
164// `Send + Sync` per upstream documentation, so adding it behind the
165// `tpm` feature preserves soundness.
166unsafe impl Send for TpmInstance {}
167unsafe impl Sync for TpmInstance {}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::config::Hierarchy;
173 use std::collections::HashMap;
174
175 /// Open the backend with a minimal options map. Exercises the
176 /// config-parse path that runs in `open`.
177 fn open() -> TpmInstance {
178 let mut opts: Options = HashMap::new();
179 opts.insert("tpm_device".into(), "/dev/tpmrmis0".into());
180 opts.insert("hierarchy".into(), "owner".into());
181 opts.insert("parent_handle".into(), "0x81000001".into());
182 let boxed = TpmBackend.open(&opts).expect("tpm backend opens");
183 // The `open` contract returns a `Box<dyn StoreInstance>`; the
184 // concrete type is `TpmInstance`. We downcast via the same trick
185 // the in-tree tests use: leak the box and reconstruct.
186 //
187 // Tests reach the parsed config by reading `TpmInstance.config`
188 // directly; we side-step the trait object by constructing the
189 // instance via `from_config` for the config-asserting tests.
190 //
191 // For the trait-method tests below we keep the `Box<dyn
192 // StoreInstance>` shape so we exercise the real open path.
193 // SAFETY: `boxed` was just produced by `TpmBackend::open`; its
194 // concrete type is `TpmInstance`.
195 let raw = Box::into_raw(boxed) as *mut TpmInstance;
196 unsafe { *Box::from_raw(raw) }
197 }
198
199 /// Sentinel non-null pointer; the backend treats `*mut c_void` as
200 /// opaque in this skeleton so identity does not matter.
201 fn sentinel(n: usize) -> *mut c_void {
202 n as *mut c_void
203 }
204
205 #[test]
206 fn backend_advertises_tpm_wire_name() {
207 assert_eq!(TpmBackend.name(), "tpm");
208 }
209
210 #[test]
211 fn backend_is_registered() {
212 // The link-time registry must surface the tpm backend by its
213 // wire name so the FFI create path can find it. This is the
214 // single most important contract for a backend.
215 let backend = confium_store::backend::find("tpm").expect("tpm backend registered");
216 assert_eq!(backend.name(), "tpm");
217 }
218
219 #[test]
220 fn open_parses_config() {
221 let inst = open();
222 // The parsed config survives the `open` path intact.
223 assert_eq!(inst.config.hierarchy, Hierarchy::Owner);
224 assert_eq!(
225 inst.config.device.as_deref(),
226 Some(std::path::Path::new("/dev/tpmrmis0"))
227 );
228 assert_eq!(inst.config.parent_handle.unwrap().raw(), 0x8100_0001);
229 }
230
231 #[test]
232 fn put_secret_returns_not_implemented() {
233 let mut ks = open();
234 let err = ks
235 .put_secret("mod", "app", "k1", sentinel(0x1000))
236 .unwrap_err();
237 assert!(matches!(err, Error::NotImplemented { .. }));
238 }
239
240 #[test]
241 fn get_secret_returns_not_implemented() {
242 let ks = open();
243 let err = ks.get_secret("mod", "app", "k1").unwrap_err();
244 assert!(matches!(err, Error::NotImplemented { .. }));
245 }
246
247 #[test]
248 fn put_public_returns_not_implemented() {
249 let mut ks = open();
250 let err = ks
251 .put_public("mod", "app", "id", sentinel(0x2000), &[0u8])
252 .unwrap_err();
253 assert!(matches!(err, Error::NotImplemented { .. }));
254 }
255
256 #[test]
257 fn get_public_returns_not_implemented() {
258 let ks = open();
259 let err = ks.get_public("mod", "app", "id").unwrap_err();
260 assert!(matches!(err, Error::NotImplemented { .. }));
261 }
262
263 #[test]
264 fn enumerate_returns_not_implemented() {
265 let ks = open();
266 let err = ks
267 .enumerate("mod", "app", Compartment::Private)
268 .unwrap_err();
269 assert!(matches!(err, Error::NotImplemented { .. }));
270 }
271
272 #[test]
273 fn from_config_preserves_config() {
274 // Callers that already have a typed config can construct the
275 // instance directly without going through the options map.
276 let cfg = TpmConfig {
277 device: Some(std::path::PathBuf::from("/dev/tpm0")),
278 hierarchy: Hierarchy::Endorsement,
279 parent_handle: Some(crate::config::ParentHandle(0x8100_0042)),
280 parent_password: b"pw".to_vec(),
281 };
282 let inst = TpmInstance::from_config(cfg.clone());
283 assert_eq!(inst.config, cfg);
284 }
285
286 // -------------------------------------------------------------------
287 // Hardware-backed tests (swtpm simulator).
288 //
289 // The following tests exercise the wired-up TPM operations against
290 // the `swtpm` software TPM simulator. They are skipped unless the
291 // `CFM_TPM_TEST` environment variable is set *and* the `tpm` feature
292 // is enabled. The setup steps are documented here so a future
293 // contributor can run them locally:
294 //
295 // # Install swtpm and tpm2-tss (macOS):
296 // brew install swtpm tpm2-tss
297 //
298 // # Start a simulator bound to a TCP TCTI:
299 // swtpm socket --tpm2 --server port=2321 \
300 // --ctrl type=tcp,port=2322 --flags not-need-init \
301 // --tpmstate dir=/tmp/swtpm-state --daemon
302 //
303 // # Run the tests:
304 // CFM_TPM_TEST=1 cargo test -p confium-store-tpm --features tpm
305 //
306 // On CI (Linux), the simulator is provisioned by the workflow and
307 // `CFM_TPM_TEST=1` is exported automatically. See
308 // `TODO.roadmap/18-hardware-keystore-backends.md` for the full plan.
309 #[cfg(feature = "tpm")]
310 fn hw_available() -> bool {
311 std::env::var_os("CFM_TPM_TEST").is_some()
312 }
313
314 #[cfg(feature = "tpm")]
315 #[test]
316 fn put_get_secret_round_trip_on_simulator() {
317 if !hw_available() {
318 eprintln!("skipping TPM simulator test: set CFM_TPM_TEST=1 and start swtpm to enable");
319 return;
320 }
321 // TODO(skeleton): wire against `tss-esapi` once the `tpm` feature
322 // lands the real session establishment. The test shape is
323 // preserved here so the contract is obvious.
324 }
325}