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}