Skip to main content

hardware_enclave/internal/app_storage/
mod.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4//! High-level application storage for hardware-backed key management.
5//!
6//! This crate provides shared platform detection, key initialization, and
7//! encrypt/decrypt/sign wrapping that all consuming applications need.
8//! It replaces the per-app `secure_storage` modules in awsenc and sso-jwt,
9//! and the platform detection logic in sshenc.
10//!
11//! # Usage
12//!
13//! For encryption (awsenc, sso-jwt):
14//! ```ignore
15//! use crate::internal::app_storage::{
16//!     AccessPolicy, AppEncryptionStorage, EncryptionStorage, StorageConfig,
17//!     WindowsSoftwareFallback,
18//! };
19//!
20//! let storage = AppEncryptionStorage::init(StorageConfig {
21//!     app_name: "myapp".into(),
22//!     key_label: "cache-key".into(),
23//!     access_policy: AccessPolicy::BiometricOnly,
24//!     extra_bridge_paths: vec![],
25//!     keys_dir: None,
26//!     force_keyring: false,
27//!     wrapping_key_user_presence: false,
28//!     wrapping_key_cache_ttl: std::time::Duration::ZERO,
29//!     keychain_access_group: None,
30//!     prefer_windows_hello_ux: false,
31//!     windows_software_fallback: WindowsSoftwareFallback::Disabled,
32//!     dpapi_app_key: None,
33//! })?;
34//!
35//! let ciphertext = storage.encrypt(b"secret")?;
36//! let plaintext = storage.decrypt(&ciphertext)?;
37//! # Ok::<(), crate::internal::app_storage::StorageError>(())
38//! ```
39//!
40//! For signing (sshenc):
41//! ```ignore
42//! use crate::internal::app_storage::{
43//!     AccessPolicy, AppSigningBackend, StorageConfig, WindowsSoftwareFallback,
44//! };
45//!
46//! let backend = AppSigningBackend::init(StorageConfig {
47//!     app_name: "sshenc".into(),
48//!     key_label: "default".into(),
49//!     access_policy: AccessPolicy::None,
50//!     extra_bridge_paths: vec![],
51//!     keys_dir: None,
52//!     force_keyring: false,
53//!     wrapping_key_user_presence: false,
54//!     wrapping_key_cache_ttl: std::time::Duration::ZERO,
55//!     keychain_access_group: None,
56//!     prefer_windows_hello_ux: false,
57//!     windows_software_fallback: WindowsSoftwareFallback::Disabled,
58//!     dpapi_app_key: None,
59//! })?;
60//!
61//! // Use the underlying signer/key_manager for operations.
62//! let signer = backend.signer();
63//! let key_manager = backend.key_manager();
64//! # Ok::<(), crate::internal::app_storage::StorageError>(())
65//! ```
66#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
67
68#[cfg(target_os = "linux")]
69mod backend_marker;
70pub mod encryption;
71pub mod error;
72#[cfg(feature = "mock")]
73pub mod mock;
74pub mod platform;
75pub mod signing;
76
77// Re-export primary types for consumers.
78pub use encryption::{AppEncryptionStorage, EncryptionStorage};
79pub use error::{Result, StorageError};
80#[cfg(feature = "mock")]
81pub use mock::MockEncryptionStorage;
82pub use platform::BackendKind;
83pub use signing::AppSigningBackend;
84
85// Re-export core types so consumers don't need a separate enclaveapp-core dep.
86pub use crate::internal::core::metadata::KeyMeta;
87pub use crate::internal::core::traits::{EnclaveEncryptor, EnclaveKeyManager, EnclaveSigner};
88pub use crate::internal::core::types::{AccessPolicy, KeyType};
89
90/// Policy for using a software-backed Windows credential store when
91/// the TPM-backed Platform Crypto Provider cannot create or open a key.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum WindowsSoftwareFallback {
94    /// Never fall back from Windows TPM-backed storage.
95    Disabled,
96    /// Fall back only when enclave detects both a TPM failure
97    /// and a VM environment. This is intended for virtualized hosts
98    /// that lack TPM 2.0 passthrough, while keeping physical machines
99    /// fail-closed instead of silently downgrading.
100    VmOnly,
101}
102
103/// Configuration for initializing application storage.
104#[derive(Debug, Clone)]
105pub struct StorageConfig {
106    /// Application name (e.g., "awsenc", "sso-jwt", "sshenc").
107    /// Used to namespace keys and locate config directories.
108    pub app_name: String,
109    /// Key label (e.g., "cache-key", "default").
110    pub key_label: String,
111    /// Access policy for key operations.
112    pub access_policy: AccessPolicy,
113    /// Extra WSL bridge paths beyond the auto-derived defaults.
114    /// The standard discovery and auto-derived trusted paths are tried first.
115    /// These must be explicit absolute override paths for app-specific legacy locations.
116    pub extra_bridge_paths: Vec<String>,
117    /// Override the keys directory (default: `~/.config/<app_name>/keys/`).
118    /// sshenc uses `~/.sshenc/keys/` which differs from the standard layout.
119    pub keys_dir: Option<std::path::PathBuf>,
120    /// Force the software keyring backend, bypassing WSL bridge detection and
121    /// libtss2 TPM probing. Linux only — ignored on macOS and Windows.
122    /// Useful for testing the keyring path from WSL environments.
123    pub force_keyring: bool,
124    /// (macOS only) Protect the wrapping-key keychain item with a
125    /// `SecAccessControl(.userPresence)` flag so access is gated on
126    /// Touch ID / device passcode instead of the legacy code-signature
127    /// ACL. Trades a one-time LocalAuthentication prompt per process
128    /// (combined with `wrapping_key_cache_ttl`) for the elimination of
129    /// the "Always Allow" dialog that otherwise re-appears on every
130    /// unsigned-binary rebuild. Default: `false`.
131    pub wrapping_key_user_presence: bool,
132    /// (macOS only) How long the process may re-use a loaded wrapping
133    /// key without another keychain round-trip (and, on user-presence
134    /// items, another LocalAuthentication prompt). `Duration::ZERO`
135    /// disables the cache. Default: `ZERO`.
136    pub wrapping_key_cache_ttl: std::time::Duration,
137    /// (macOS only) Data Protection keychain access group, in
138    /// `<TEAMID>.<group>` form. When `Some`, wrapping-key items are
139    /// stored in the modern Data Protection keychain (which actually
140    /// accepts the `.userPresence` ACL — the legacy keychain rejects
141    /// it with `errSecParam` -50). The calling binary MUST be
142    /// codesigned with a `keychain-access-groups` entitlement listing
143    /// the same group, otherwise SecItemAdd returns
144    /// `errSecMissingEntitlement` -34018 and the bridge falls back to
145    /// the legacy keychain (no userPresence gate).
146    ///
147    /// When `None` (default), the legacy keychain is used directly,
148    /// which accepts unsigned callers but rejects userPresence ACLs.
149    /// Default: `None`.
150    pub keychain_access_group: Option<String>,
151    /// (Windows only) Surface a Windows Hello biometric/PIN prompt at
152    /// encrypt/decrypt time instead of the legacy `NCRYPT_UI_PROTECT_KEY_FLAG`
153    /// CryptUI password protector dialog. When `true`:
154    ///
155    /// - The TPM encryption key is created WITHOUT `NCRYPT_UI_PROTECT_KEY_FLAG`,
156    ///   so the OS does not surface the legacy password dialog at finalize
157    ///   or at sign/decrypt time.
158    /// - `NCryptCreatePersistedKey`, `NCryptFinalizeKey`, and `NCryptOpenKey`
159    ///   are all invoked with `NCRYPT_SILENT_FLAG` so the KSP cannot
160    ///   surface its own UI; if it would need to, the call fails with
161    ///   `NTE_SILENT_CONTEXT` rather than showing a surprise dialog.
162    /// - Each encrypt and decrypt is gated by
163    ///   `Windows.Security.Credentials.UI.UserConsentVerifier.RequestVerificationAsync(...)`,
164    ///   which fires the modern Windows Hello biometric/PIN UI.
165    /// - The verification is cached for `wrapping_key_cache_ttl` so repeated
166    ///   operations within the window do not re-prompt.
167    /// - **When Hello is not enrolled** (`DeviceNotPresent` /
168    ///   `NotConfiguredForUser` / `DisabledByPolicy`) the gate falls back to
169    ///   a Windows account-password prompt (`CredUIPromptForWindowsCredentialsW`
170    ///   validated via `LogonUserW`) so a user-presence check is still
171    ///   enforced. If neither Hello nor a verifiable password is available
172    ///   (headless session, passwordless account) the gate degrades to no
173    ///   prompt; the bundle is TPM-encrypted regardless. See
174    ///   `crate::internal::windows::password_gate`.
175    ///
176    /// **AccessPolicy override:** When this flag is `true` the
177    /// [`StorageConfig::access_policy`] field is **overridden to
178    /// `AccessPolicy::None` at the OS-level key creation step**
179    /// (the on-disk meta records `None`). The Hello consent prompt is
180    /// the application-level access enforcement; the TPM key itself
181    /// carries no OS-mediated UI policy. Callers that pass
182    /// `BiometricOnly` together with `prefer_windows_hello_ux: true`
183    /// are getting **soft Hello gating, not hardware-enforced
184    /// biometric**. That trade-off is intentional and is logged at
185    /// `tracing::info` level so the override is auditable.
186    ///
187    /// **Threat-model target:** *same-UID file-on-disk attackers*
188    /// (backup tools, AV upload agents, OneDrive sync of the profile
189    /// dir, accidental git commits, colleagues `cat`-ing the
190    /// credential file, supply-chain dependencies that scan `$HOME`).
191    /// The TPM-resident wrapping key makes the on-disk ciphertext
192    /// useless without invoking the TPM operation on the original
193    /// machine while authenticated as the original user. A stolen
194    /// file is just ciphertext. This is a major upgrade over the
195    /// `chmod 0600` posture that preceded it.
196    ///
197    /// **Out of scope:** same-UID active malware (code execution as
198    /// the same user). `UserConsentVerifier`'s `Verified` Boolean is
199    /// a user-mode result consumed by the calling process; same-UID
200    /// code can hook it or call `NCryptSecretAgreement` on the TPM
201    /// key directly. That attacker class has higher-leverage paths
202    /// regardless (reading process memory after legitimate unlock,
203    /// keystroke capture, etc.), so the soft gate is a UX consent
204    /// signal, not a hard cryptographic boundary against malware.
205    ///
206    /// No-op on non-Windows platforms. Default: `false`.
207    pub prefer_windows_hello_ux: bool,
208    /// (Windows only) Whether a VM host without usable TPM 2.0
209    /// may use a per-user DPAPI-backed software key instead of failing.
210    ///
211    /// The downgrade decision itself is made inside the enclave crate's
212    /// Windows backend using local machine signals: this field only
213    /// opts the application into the policy. There is no environment
214    /// variable override for production binaries.
215    pub windows_software_fallback: WindowsSoftwareFallback,
216    /// (Windows DPAPI fallback only) Application-layer AES-256-GCM key
217    /// applied around DPAPI when the DPAPI software fallback is in use.
218    ///
219    /// When `Some`, the P-256 private key is wrapped in AES-256-GCM with
220    /// this key before being handed to `CryptProtectData`. A generic
221    /// per-user DPAPI oracle (a same-user process that calls
222    /// `CryptUnprotectData` on every file it finds) recovers an encrypted
223    /// blob rather than the raw P-256 key; the attacker must also extract
224    /// this key from the calling binary.
225    ///
226    /// **What this provides:** defeats automated DPAPI oracle tools that
227    /// do not carry knowledge of the embedding binary.
228    /// **What this does not provide:** protection against a targeted
229    /// attacker who has a copy of the binary and can extract this constant
230    /// via static analysis or a debugger.
231    ///
232    /// Should be a compile-time constant embedded in the calling binary as
233    /// a `[u8; 32]` decimal byte array (not hex, not base64) to avoid
234    /// triggering source-code secret scanners.
235    ///
236    /// No-op on TPM-backed Windows, macOS, and Linux paths. Default: `None`.
237    pub dpapi_app_key: Option<[u8; 32]>,
238}
239
240/// Environment variable that, when the `mock` cargo feature is
241/// compiled in **and** this var is set to a non-empty value, forces
242/// [`create_encryption_storage`] to return a [`MockEncryptionStorage`]
243/// instead of the real platform backend.
244///
245/// **Security:** the env-var check below is feature-gated to `mock`.
246/// Release binaries built without the feature ignore the variable
247/// entirely — no runtime path leads to the mock backend, so setting
248/// the variable in production does nothing. Only `cargo test` builds
249/// (where downstream `[dev-dependencies]` turn the feature on) read
250/// this variable.
251///
252/// This exists for CI environments that cannot satisfy a real
253/// hardware-backed backend — typically GitHub Actions macOS runners,
254/// which would otherwise block on a login-keychain ACL confirmation
255/// prompt.
256#[cfg(feature = "mock")]
257pub const MOCK_STORAGE_ENV: &str = "ENCLAVEAPP_MOCK_STORAGE";
258
259/// Create encryption storage with automatic platform detection.
260///
261/// When built with the `mock` feature (test builds only), honours
262/// [`MOCK_STORAGE_ENV`]: a non-empty value routes through
263/// [`MockEncryptionStorage`]. Release builds have the feature off,
264/// so this function unconditionally returns the real backend —
265/// there is no runtime switch that could downgrade production
266/// security.
267pub fn create_encryption_storage(mut config: StorageConfig) -> Result<Box<dyn EncryptionStorage>> {
268    config.app_name = crate::internal::core::signing::ensure_safe_app_name(&config.app_name);
269    #[cfg(feature = "mock")]
270    {
271        if let Ok(val) = std::env::var(MOCK_STORAGE_ENV) {
272            if !val.is_empty() {
273                tracing::warn!(
274                    app = %config.app_name,
275                    "{MOCK_STORAGE_ENV} is set — returning MockEncryptionStorage (no hardware backing)"
276                );
277                return Ok(Box::new(MockEncryptionStorage::for_app(&config.app_name)));
278            }
279        }
280    }
281    let storage = AppEncryptionStorage::init(config)?;
282    Ok(Box::new(storage))
283}
284
285#[cfg(test)]
286#[allow(clippy::unwrap_used)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn storage_config_debug() {
292        let config = StorageConfig {
293            app_name: "test".into(),
294            key_label: "key".into(),
295            access_policy: AccessPolicy::None,
296            extra_bridge_paths: vec![],
297            keys_dir: None,
298            force_keyring: false,
299            wrapping_key_user_presence: false,
300            wrapping_key_cache_ttl: std::time::Duration::ZERO,
301            keychain_access_group: None,
302            prefer_windows_hello_ux: false,
303            windows_software_fallback: WindowsSoftwareFallback::Disabled,
304            dpapi_app_key: None,
305        };
306        let debug = format!("{config:?}");
307        assert!(debug.contains("test"));
308        assert!(debug.contains("key"));
309    }
310
311    #[test]
312    fn storage_config_clone() {
313        let config = StorageConfig {
314            app_name: "test".into(),
315            key_label: "key".into(),
316            access_policy: AccessPolicy::BiometricOnly,
317            extra_bridge_paths: vec!["/custom/path".into()],
318            keys_dir: Some(std::path::PathBuf::from("/custom/keys")),
319            force_keyring: false,
320            wrapping_key_user_presence: false,
321            wrapping_key_cache_ttl: std::time::Duration::ZERO,
322            keychain_access_group: None,
323            prefer_windows_hello_ux: false,
324            windows_software_fallback: WindowsSoftwareFallback::Disabled,
325            dpapi_app_key: None,
326        };
327        let cloned = config.clone();
328        assert_eq!(cloned.app_name, "test");
329        assert_eq!(cloned.key_label, "key");
330        assert_eq!(cloned.access_policy, AccessPolicy::BiometricOnly);
331        assert_eq!(cloned.extra_bridge_paths.len(), 1);
332    }
333
334    #[test]
335    fn storage_error_display() {
336        let err = StorageError::NotAvailable;
337        assert_eq!(err.to_string(), "hardware security module not available");
338
339        let err = StorageError::EncryptionFailed("bad key".into());
340        assert_eq!(err.to_string(), "encryption failed: bad key");
341
342        let err = StorageError::DecryptionFailed("corrupt".into());
343        assert_eq!(err.to_string(), "decryption failed: corrupt");
344
345        let err = StorageError::SigningFailed("timeout".into());
346        assert_eq!(err.to_string(), "signing failed: timeout");
347
348        let err = StorageError::KeyInitFailed("no hardware".into());
349        assert_eq!(err.to_string(), "key initialization failed: no hardware");
350
351        let err = StorageError::KeyNotFound("missing".into());
352        assert_eq!(err.to_string(), "key not found: missing");
353
354        let err = StorageError::PolicyMismatch("None vs BiometricOnly".into());
355        assert_eq!(
356            err.to_string(),
357            "key policy mismatch: None vs BiometricOnly"
358        );
359
360        let err = StorageError::PlatformError("unsupported".into());
361        assert_eq!(err.to_string(), "platform error: unsupported");
362    }
363
364    #[test]
365    fn re_exports_work() {
366        // Verify core types are re-exported.
367        let _ = AccessPolicy::None;
368        let _ = AccessPolicy::Any;
369        let _ = AccessPolicy::BiometricOnly;
370        let _ = AccessPolicy::PasswordOnly;
371        let _ = KeyType::Signing;
372        let _ = KeyType::Encryption;
373        let _ = BackendKind::SecureEnclave;
374    }
375
376    #[test]
377    fn storage_config_default_field_values() {
378        let config = StorageConfig {
379            app_name: "myapp".into(),
380            key_label: "default".into(),
381            access_policy: AccessPolicy::None,
382            extra_bridge_paths: vec![],
383            keys_dir: None,
384            force_keyring: false,
385            wrapping_key_user_presence: false,
386            wrapping_key_cache_ttl: std::time::Duration::ZERO,
387            keychain_access_group: None,
388            prefer_windows_hello_ux: false,
389            windows_software_fallback: WindowsSoftwareFallback::Disabled,
390            dpapi_app_key: None,
391        };
392        assert_eq!(config.app_name, "myapp");
393        assert_eq!(config.key_label, "default");
394        assert_eq!(config.access_policy, AccessPolicy::None);
395        assert!(config.extra_bridge_paths.is_empty());
396        assert!(config.keys_dir.is_none());
397        assert!(!config.force_keyring);
398        assert!(!config.wrapping_key_user_presence);
399        assert_eq!(config.wrapping_key_cache_ttl, std::time::Duration::ZERO);
400        assert!(config.keychain_access_group.is_none());
401    }
402
403    #[test]
404    fn storage_config_with_access_group() {
405        let config = StorageConfig {
406            app_name: "app".into(),
407            key_label: "key".into(),
408            access_policy: AccessPolicy::Any,
409            extra_bridge_paths: vec![],
410            keys_dir: None,
411            force_keyring: false,
412            wrapping_key_user_presence: true,
413            wrapping_key_cache_ttl: std::time::Duration::from_secs(30),
414            keychain_access_group: Some("TEAMID.com.example".into()),
415            prefer_windows_hello_ux: false,
416            windows_software_fallback: WindowsSoftwareFallback::Disabled,
417            dpapi_app_key: None,
418        };
419        assert!(config.wrapping_key_user_presence);
420        assert_eq!(
421            config.wrapping_key_cache_ttl,
422            std::time::Duration::from_secs(30)
423        );
424        assert_eq!(
425            config.keychain_access_group.as_deref(),
426            Some("TEAMID.com.example")
427        );
428    }
429
430    #[test]
431    fn storage_config_with_keys_dir_override() {
432        let dir = std::path::PathBuf::from("/custom/keys");
433        let config = StorageConfig {
434            app_name: "app".into(),
435            key_label: "key".into(),
436            access_policy: AccessPolicy::None,
437            extra_bridge_paths: vec!["/extra/path".into()],
438            keys_dir: Some(dir.clone()),
439            force_keyring: true,
440            wrapping_key_user_presence: false,
441            wrapping_key_cache_ttl: std::time::Duration::ZERO,
442            keychain_access_group: None,
443            prefer_windows_hello_ux: false,
444            windows_software_fallback: WindowsSoftwareFallback::Disabled,
445            dpapi_app_key: None,
446        };
447        assert_eq!(config.keys_dir.as_ref(), Some(&dir));
448        assert!(config.force_keyring);
449        assert_eq!(config.extra_bridge_paths.len(), 1);
450    }
451
452    #[cfg(feature = "mock")]
453    #[test]
454    fn mock_storage_env_constant_is_non_empty() {
455        assert!(!MOCK_STORAGE_ENV.is_empty());
456        assert!(MOCK_STORAGE_ENV.contains("ENCLAVEAPP"));
457    }
458}