Skip to main content

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}