Skip to main content

tenzro_device_key/
lib.rs

1//! Hardware-backed device keys for Tenzro.
2//!
3//! A device key is a non-extractable P-256 keypair living in the platform's
4//! hardware-rooted secure element. On macOS/iOS that is the Secure Enclave
5//! (via `security-framework` + `kSecAttrTokenIDSecureEnclave`), gated by Touch
6//! ID / Face ID. The private key never enters process memory.
7//!
8//! Two capabilities:
9//! 1. **Signing** ([`DeviceKey::sign_prehash`]) — sign a 32-byte digest; the
10//!    OS triggers the user-presence ceremony.
11//! 2. **Secret wrapping** ([`wrap_secret`] / [`unwrap_secret`]) — ECIES-encrypt
12//!    an arbitrary secret to the device public key (no biometrics), and decrypt
13//!    it with the private key (biometrics). The decrypt output is STABLE across
14//!    calls, which makes this the right primitive for deriving a persistent
15//!    keystore password — see [`SecureEnclaveUnlocker`].
16//!
17//! ## Where the key is persisted
18//!
19//! The Secure-Enclave key is persisted in the legacy file-based (login)
20//! keychain via `Location::DefaultFileKeychain`, which sets
21//! `kSecAttrIsPermanent=true` *without* the `kSecUseDataProtectionKeychain`
22//! attribute. Only the data-protection keychain requires a
23//! `keychain-access-groups` entitlement backed by a provisioning profile; the
24//! file keychain does not, so a plain Developer-ID build can persist the key
25//! and re-`open()` it by label across process restarts. Touch ID / Face ID
26//! still gates every signing operation via the key's `SecAccessControl`, and
27//! AMFI does not SIGKILL the app. See Apple TN3137 ("On Mac keychains").
28//!
29//! Cross-restart *persistence of a secret* therefore works: [`SecureEnclaveUnlocker`]
30//! creates the key once, wraps the keystore password to its public key, and a
31//! later run reopens the key by label to unwrap the on-disk ciphertext. The
32//! live `SecKey` handle is additionally cached in-process to avoid repeated
33//! keychain queries within a run; in the rare context where file-keychain
34//! persistence fails, `create()` falls back to a non-persistent session key
35//! that still supports same-session create→sign→wrap/unwrap.
36
37/// Raw uncompressed SEC1 P-256 public key (`x ‖ y`, no `0x04` prefix).
38pub type DevicePublicKey = [u8; 64];
39
40/// Raw `r ‖ s` ECDSA-P-256 signature (no DER wrapper).
41pub type DeviceSignature = [u8; 64];
42
43#[derive(Debug, thiserror::Error)]
44pub enum DeviceKeyError {
45    #[error("secure enclave error: {0}")]
46    Enclave(String),
47    #[error("attestation not supported by this backend")]
48    AttestationUnsupported,
49    #[error("key not found: {0}")]
50    NotFound(String),
51}
52
53pub type Result<T> = std::result::Result<T, DeviceKeyError>;
54
55/// Hardware attestation evidence. Format is backend-specific.
56#[derive(Debug, Clone)]
57pub struct Attestation {
58    pub backend: &'static str,
59    pub evidence: Vec<u8>,
60    pub public_key: DevicePublicKey,
61}
62
63/// One hardware-resident P-256 keypair. The label is the user-facing key tag;
64/// the private key bytes are never returned by any method.
65pub trait DeviceKey: Send + Sync {
66    /// Stable identifier the backend uses to look up the key.
67    fn label(&self) -> &str;
68
69    /// Raw `x ‖ y` P-256 public key (64 bytes).
70    fn public_key(&self) -> Result<DevicePublicKey>;
71
72    /// Sign a 32-byte SHA-256 prehash. Returns raw `r ‖ s` (64 bytes).
73    fn sign_prehash(&self, hash: &[u8; 32]) -> Result<DeviceSignature>;
74
75    /// ECIES-encrypt `secret` to THIS key's public key. Does NOT trigger a
76    /// biometric prompt (encryption only needs the public key). The ciphertext
77    /// can only be recovered by this same hardware key via [`Self::unwrap_secret`].
78    fn wrap_secret(&self, secret: &[u8]) -> Result<Vec<u8>>;
79
80    /// ECIES-decrypt a ciphertext produced by [`Self::wrap_secret`]. Triggers
81    /// the user-presence ceremony. Output is stable across calls.
82    fn unwrap_secret(&self, ciphertext: &[u8]) -> Result<zeroize::Zeroizing<Vec<u8>>>;
83
84    /// Hardware attestation over the public key. Optional.
85    fn attest(&self) -> Result<Attestation> {
86        Err(DeviceKeyError::AttestationUnsupported)
87    }
88}
89
90/// Open an existing key by label, or return `NotFound`.
91pub fn open(label: &str) -> Result<Box<dyn DeviceKey>> {
92    backend::open(label)
93}
94
95/// Create a new biometry-gated, non-extractable P-256 key under `label`.
96pub fn create(label: &str) -> Result<Box<dyn DeviceKey>> {
97    backend::create(label)
98}
99
100/// Delete a key. Idempotent.
101pub fn delete(label: &str) -> Result<()> {
102    backend::delete(label)
103}
104
105mod unlocker;
106pub use unlocker::SecureEnclaveUnlocker;
107
108pub mod pq_companion;
109pub use pq_companion::PqCompanion;
110
111// ---- Backend dispatch ------------------------------------------------------
112
113#[cfg(any(target_os = "macos", target_os = "ios"))]
114mod backend {
115    pub use super::macos::{create, delete, open};
116}
117
118#[cfg(not(any(target_os = "macos", target_os = "ios")))]
119mod backend {
120    use super::{DeviceKey, DeviceKeyError, Result};
121    const STUB: &str = "device-key backend not yet wired for this platform";
122    pub fn open(_label: &str) -> Result<Box<dyn DeviceKey>> {
123        Err(DeviceKeyError::Enclave(STUB.into()))
124    }
125    pub fn create(_label: &str) -> Result<Box<dyn DeviceKey>> {
126        Err(DeviceKeyError::Enclave(STUB.into()))
127    }
128    pub fn delete(_label: &str) -> Result<()> {
129        Err(DeviceKeyError::Enclave(STUB.into()))
130    }
131}
132
133// ---- macOS / iOS Secure Enclave backend -----------------------------------
134
135#[cfg(any(target_os = "macos", target_os = "ios"))]
136mod macos {
137    use super::{
138        Attestation, DeviceKey, DeviceKeyError, DevicePublicKey, DeviceSignature, Result,
139    };
140    use security_framework::access_control::{ProtectionMode, SecAccessControl};
141    use security_framework::item::{
142        ItemClass, ItemSearchOptions, Limit, Location, Reference, SearchResult,
143    };
144    use security_framework::key::{Algorithm, GenerateKeyOptions, KeyType, SecKey, Token};
145    use std::collections::HashMap;
146    use std::sync::{Mutex, OnceLock};
147
148    // kSecAccessControlUserPresence = 1<<0, kSecAccessControlPrivateKeyUsage = 1<<30.
149    const SAC_USER_PRESENCE: usize = 1 << 0;
150    const SAC_PRIVATE_KEY_USAGE: usize = 1 << 30;
151
152    // ECIES variant: ephemeral-static ECDH (X9.63 KDF, SHA-256) + AES-GCM.
153    // Decryption output is deterministic for a given (key, ciphertext).
154    const ECIES: Algorithm = Algorithm::ECIESEncryptionStandardVariableIVX963SHA256AESGCM;
155
156    // errSecItemNotFound — returned by SecItemDelete when nothing matches.
157    const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300;
158
159    // The persisted SE key normally round-trips through the file keychain, but
160    // we also cache the live `SecKey` handle from `create()`/`open()` here so
161    // repeated calls within a session skip the keychain query (and so a
162    // same-session create→sign still works even in the rare non-persistent
163    // fallback). `SecKey` is `Send + Sync + Clone` (a CFType). Keyed by label.
164    fn handles() -> &'static Mutex<HashMap<String, SecKey>> {
165        static HANDLES: OnceLock<Mutex<HashMap<String, SecKey>>> = OnceLock::new();
166        HANDLES.get_or_init(|| Mutex::new(HashMap::new()))
167    }
168
169    fn cache_handle(label: &str, key: SecKey) {
170        if let Ok(mut map) = handles().lock() {
171            map.insert(label.to_string(), key);
172        }
173    }
174
175    fn lookup_handle(label: &str) -> Option<SecKey> {
176        handles().lock().ok()?.get(label).cloned()
177    }
178
179    fn forget_handle(label: &str) {
180        if let Ok(mut map) = handles().lock() {
181            map.remove(label);
182        }
183    }
184
185    pub(super) struct MacEnclaveKey {
186        label: String,
187        key: SecKey,
188    }
189
190    impl MacEnclaveKey {
191        fn raw_public_key(key: &SecKey) -> Result<DevicePublicKey> {
192            let pk = key
193                .public_key()
194                .ok_or_else(|| DeviceKeyError::Enclave("no public key on SecKey".into()))?;
195            let cfdata = pk
196                .external_representation()
197                .ok_or_else(|| DeviceKeyError::Enclave("public key not exportable".into()))?;
198            let bytes = cfdata.bytes();
199            if bytes.len() != 65 || bytes[0] != 0x04 {
200                return Err(DeviceKeyError::Enclave(format!(
201                    "unexpected public key format: len={} prefix=0x{:02x}",
202                    bytes.len(),
203                    bytes.first().copied().unwrap_or(0)
204                )));
205            }
206            let mut out = [0u8; 64];
207            out.copy_from_slice(&bytes[1..]);
208            Ok(out)
209        }
210
211        fn der_to_raw_signature(der: &[u8]) -> Result<DeviceSignature> {
212            fn read_int(p: &[u8]) -> Result<(&[u8], &[u8])> {
213                if p.len() < 2 || p[0] != 0x02 {
214                    return Err(DeviceKeyError::Enclave("bad DER INTEGER tag".into()));
215                }
216                let len = p[1] as usize;
217                if p.len() < 2 + len {
218                    return Err(DeviceKeyError::Enclave("DER INTEGER truncated".into()));
219                }
220                Ok((&p[2..2 + len], &p[2 + len..]))
221            }
222            if der.len() < 2 || der[0] != 0x30 {
223                return Err(DeviceKeyError::Enclave("bad DER SEQUENCE".into()));
224            }
225            let body = &der[2..];
226            let (r, rest) = read_int(body)?;
227            let (s, _) = read_int(rest)?;
228            let mut out = [0u8; 64];
229            let r_strip = if r.len() == 33 && r[0] == 0 { &r[1..] } else { r };
230            let s_strip = if s.len() == 33 && s[0] == 0 { &s[1..] } else { s };
231            if r_strip.len() > 32 || s_strip.len() > 32 {
232                return Err(DeviceKeyError::Enclave("ECDSA scalar > 32 bytes".into()));
233            }
234            out[32 - r_strip.len()..32].copy_from_slice(r_strip);
235            out[64 - s_strip.len()..64].copy_from_slice(s_strip);
236            Ok(out)
237        }
238    }
239
240    impl DeviceKey for MacEnclaveKey {
241        fn label(&self) -> &str {
242            &self.label
243        }
244
245        fn public_key(&self) -> Result<DevicePublicKey> {
246            Self::raw_public_key(&self.key)
247        }
248
249        fn sign_prehash(&self, hash: &[u8; 32]) -> Result<DeviceSignature> {
250            let der = self
251                .key
252                .create_signature(Algorithm::ECDSASignatureDigestX962SHA256, hash)
253                .map_err(|e| DeviceKeyError::Enclave(format!("create_signature: {}", e)))?;
254            Self::der_to_raw_signature(&der)
255        }
256
257        fn wrap_secret(&self, secret: &[u8]) -> Result<Vec<u8>> {
258            // Encrypt to our OWN public key, derived from the live handle via
259            // SecKeyCopyPublicKey. No biometric prompt — encryption only needs
260            // the public key.
261            let pubkey = self
262                .key
263                .public_key()
264                .ok_or_else(|| DeviceKeyError::Enclave("no public key on SecKey".into()))?;
265            pubkey
266                .encrypt_data(ECIES, secret)
267                .map_err(|e| DeviceKeyError::Enclave(format!("encrypt_data: {}", e)))
268        }
269
270        fn unwrap_secret(&self, ciphertext: &[u8]) -> Result<zeroize::Zeroizing<Vec<u8>>> {
271            let pt = self
272                .key
273                .decrypt_data(ECIES, ciphertext)
274                .map_err(|e| DeviceKeyError::Enclave(format!("decrypt_data: {}", e)))?;
275            Ok(zeroize::Zeroizing::new(pt))
276        }
277
278        fn attest(&self) -> Result<Attestation> {
279            Err(DeviceKeyError::AttestationUnsupported)
280        }
281    }
282
283    pub fn open(label: &str) -> Result<Box<dyn DeviceKey>> {
284        // Path 1: in-process cache — a fast path within the session that
285        // created or last opened the key, avoiding a keychain round-trip.
286        if let Some(key) = lookup_handle(label) {
287            return Ok(Box::new(MacEnclaveKey {
288                label: label.to_string(),
289                key,
290            }));
291        }
292
293        // Path 2: keychain search of the legacy file-based (login) keychain,
294        // where `create()` persists the key. We deliberately do NOT call
295        // `.ignore_legacy_keychains()` — that flag sets
296        // `kSecUseDataProtectionKeychain=true` on the query, which would search
297        // the data-protection keychain (empty here) and miss the persisted key.
298        // This is the path that enables cross-RESTART open without a
299        // provisioning profile.
300        let mut search = ItemSearchOptions::new();
301        search
302            .class(ItemClass::key())
303            .label(label)
304            .load_refs(true)
305            .limit(Limit::Max(1));
306        if let Ok(results) = search.search() {
307            for r in results {
308                if let SearchResult::Ref(Reference::Key(key)) = r {
309                    cache_handle(label, key.clone());
310                    return Ok(Box::new(MacEnclaveKey {
311                        label: label.to_string(),
312                        key,
313                    }));
314                }
315            }
316        }
317
318        Err(DeviceKeyError::NotFound(label.to_string()))
319    }
320
321    pub fn create(label: &str) -> Result<Box<dyn DeviceKey>> {
322        // Attempt PERSISTENT creation first: `set_location(DefaultFileKeychain)`
323        // sets `kSecAttrIsPermanent=true` so the key survives restarts and can
324        // be reopened by label, WITHOUT pushing `kSecUseDataProtectionKeychain`.
325        // Persisting a Secure-Enclave key in the legacy file-based (login)
326        // keychain needs neither a `keychain-access-groups` entitlement nor an
327        // embedded provisioning profile — those are required only by the
328        // data-protection keychain. Touch ID still gates every signing op via
329        // the SecAccessControl below; AMFI does not SIGKILL a Developer-ID build
330        // for using the file keychain. See Apple TN3137. If creation still fails
331        // (e.g. a sandboxed/headless context), we fall back to a NON-persistent
332        // session key cached in-process so dev builds keep working.
333        let make_opts = |persistent: bool| {
334            let acl = SecAccessControl::create_with_protection(
335                Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly),
336                SAC_USER_PRESENCE | SAC_PRIVATE_KEY_USAGE,
337            )
338            .ok();
339            let mut opts = GenerateKeyOptions::default();
340            opts.set_key_type(KeyType::ec())
341                .set_size_in_bits(256)
342                .set_label(label.to_string())
343                .set_token(Token::SecureEnclave);
344            if let Some(acl) = acl {
345                opts.set_access_control(acl);
346            }
347            if persistent {
348                opts.set_location(Location::DefaultFileKeychain);
349            }
350            opts
351        };
352
353        let key = match SecKey::new(&make_opts(true)) {
354            Ok(k) => k,
355            Err(persistent_err) => {
356                tracing::warn!(
357                    label = %label,
358                    error = %persistent_err,
359                    "persistent Secure Enclave key creation in the file keychain failed; \
360                     falling back to a session-only key — wallet will NOT persist across \
361                     restarts in this context"
362                );
363                SecKey::new(&make_opts(false))
364                    .map_err(|e| DeviceKeyError::Enclave(format!("SecKey::new: {}", e)))?
365            }
366        };
367
368        cache_handle(label, key.clone());
369
370        Ok(Box::new(MacEnclaveKey {
371            label: label.to_string(),
372            key,
373        }))
374    }
375
376    pub fn delete(label: &str) -> Result<()> {
377        // Drop the in-process handle cache so a later `open()` doesn't resurrect
378        // a stale reference to a key we're deleting.
379        forget_handle(label);
380
381        // Remove the persisted key from the file keychain. As with `open()` we
382        // search the legacy keychain (no `.ignore_legacy_keychains()`). Missing
383        // the item is fine — deletion is idempotent.
384        let mut search = ItemSearchOptions::new();
385        search.class(ItemClass::key()).label(label);
386        match search.delete() {
387            Ok(()) => Ok(()),
388            Err(e) if e.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(()),
389            Err(e) => Err(DeviceKeyError::Enclave(format!("SecItemDelete: {}", e))),
390        }
391    }
392}