Skip to main content

hardware_enclave/
capabilities.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4#[cfg(target_os = "macos")]
5use std::collections::HashMap;
6#[cfg(target_os = "macos")]
7use std::sync::{Mutex, OnceLock};
8
9use crate::types::{AccessPolicy, BackendKind};
10
11pub use crate::internal::core::signing::is_binary_signed;
12
13/// True iff the running binary has the named keychain-access-groups entitlement.
14/// On macOS, runs `codesign -d --entitlements -` and checks for the group string.
15/// On other platforms, always returns false.
16///
17/// The result is cached for the process lifetime. A binary that is re-signed while
18/// running will not see the updated entitlement in the cache until the next process start.
19pub fn has_keychain_entitlement(group: &str) -> bool {
20    #[cfg(target_os = "macos")]
21    {
22        static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
23        let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
24        let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
25        if let Some(&result) = guard.get(group) {
26            return result;
27        }
28        let result = check_entitlement_macos(group);
29        guard.insert(group.to_string(), result);
30        result
31    }
32    #[cfg(not(target_os = "macos"))]
33    {
34        let _ = group;
35        false
36    }
37}
38
39#[cfg(target_os = "macos")]
40fn check_entitlement_macos(group: &str) -> bool {
41    let exe = match std::env::current_exe() {
42        Ok(e) => e,
43        Err(_) => return false,
44    };
45    // Use absolute path to avoid PATH manipulation.
46    // NOTE: `is_binary_signed()` in crate::internal::core::signing also invokes
47    // `codesign --verify` but in a different crate. That invocation should
48    // similarly be updated to use an absolute path — tracked as a follow-up
49    // in enclaveapp-core (out of scope here per constraint 4).
50    let output = std::process::Command::new("/usr/bin/codesign")
51        .args(["-d", "--entitlements", "-", "--xml"])
52        .arg(&exe)
53        .output();
54    match output {
55        Ok(o) => {
56            let stdout = String::from_utf8_lossy(&o.stdout);
57            let stderr = String::from_utf8_lossy(&o.stderr);
58            stdout.contains(group) || stderr.contains(group)
59        }
60        Err(_) => false,
61    }
62}
63
64/// Full description of the security tier available to the current binary on the current platform.
65#[derive(Debug, Clone)]
66pub struct SecurityCapabilities {
67    /// Binary is code-signed. When false, app_name has `-unsigned` appended.
68    pub binary_signed: bool,
69    /// Hardware security backend detected.
70    pub backend: BackendKind,
71    /// Effective keychain access group, if any.
72    pub effective_keychain_group: Option<String>,
73    /// Keychain items are bound to this binary's code signature.
74    pub code_signature_binding: bool,
75    /// User-presence gates the keychain wrapping key.
76    pub keychain_user_presence: bool,
77    /// Platform can enforce user-presence at hardware/OS level.
78    pub hardware_presence: bool,
79    /// Presence prompts can be cached across operations within a TTL.
80    pub presence_caching: bool,
81    /// Effective app_name after -unsigned suffix applied (if applicable).
82    pub effective_app_name: String,
83    /// Features requested that were silently downgraded.
84    pub downgraded_features: Vec<String>,
85    /// Recommended AccessPolicy for new keys given the current security tier.
86    pub recommended_access_policy: AccessPolicy,
87}
88
89/// Query capabilities without creating any handles.
90pub fn security_capabilities(app_name: &str) -> SecurityCapabilities {
91    let signed = is_binary_signed();
92    let effective_app_name = crate::internal::core::signing::ensure_safe_app_name(app_name);
93    let backend = detect_backend();
94
95    #[cfg(target_os = "macos")]
96    let hardware_presence = crate::internal::apple::touch_id_available();
97    #[cfg(target_os = "windows")]
98    let hardware_presence = true;
99    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
100    let hardware_presence = false;
101
102    let presence_caching = cfg!(target_os = "macos");
103
104    let recommended_access_policy = if signed {
105        AccessPolicy::None
106    } else {
107        AccessPolicy::Any
108    };
109
110    SecurityCapabilities {
111        binary_signed: signed,
112        backend,
113        effective_keychain_group: None,
114        code_signature_binding: false,
115        keychain_user_presence: false,
116        hardware_presence,
117        presence_caching,
118        effective_app_name,
119        downgraded_features: Vec::new(),
120        recommended_access_policy,
121    }
122}
123
124#[allow(clippy::needless_return, unreachable_code)]
125fn detect_backend() -> BackendKind {
126    #[cfg(target_os = "macos")]
127    {
128        return BackendKind::SecureEnclave;
129    }
130    #[cfg(target_os = "windows")]
131    {
132        return BackendKind::Tpm;
133    }
134    #[cfg(target_os = "linux")]
135    {
136        if crate::internal::wsl::is_wsl() {
137            return BackendKind::TpmBridge;
138        }
139        return BackendKind::Keyring;
140    }
141    #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
142    BackendKind::Keyring
143}
144
145#[cfg(test)]
146#[allow(clippy::unwrap_used)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn is_binary_signed_returns_false_in_cargo_test() {
152        // cargo test runs from /target/ so is_binary_signed() must return false.
153        assert!(!is_binary_signed());
154    }
155
156    #[test]
157    fn has_keychain_entitlement_returns_false_for_unknown_group() {
158        // An unsigned test binary never has any keychain entitlement.
159        assert!(!has_keychain_entitlement("com.example.nonexistent.group"));
160    }
161
162    #[test]
163    fn security_capabilities_does_not_panic() {
164        let caps = security_capabilities("testapp");
165        assert!(!caps.effective_app_name.is_empty());
166        // In test context, binary is unsigned → -unsigned suffix applied
167        assert!(
168            caps.effective_app_name.ends_with("-unsigned"),
169            "unsigned binary should have -unsigned suffix, got: {}",
170            caps.effective_app_name
171        );
172        assert!(!caps.binary_signed);
173    }
174}