pub type DevicePublicKey = [u8; 64];
pub type DeviceSignature = [u8; 64];
#[derive(Debug, thiserror::Error)]
pub enum DeviceKeyError {
#[error("secure enclave error: {0}")]
Enclave(String),
#[error("attestation not supported by this backend")]
AttestationUnsupported,
#[error("key not found: {0}")]
NotFound(String),
}
pub type Result<T> = std::result::Result<T, DeviceKeyError>;
#[derive(Debug, Clone)]
pub struct Attestation {
pub backend: &'static str,
pub evidence: Vec<u8>,
pub public_key: DevicePublicKey,
}
pub trait DeviceKey: Send + Sync {
fn label(&self) -> &str;
fn public_key(&self) -> Result<DevicePublicKey>;
fn sign_prehash(&self, hash: &[u8; 32]) -> Result<DeviceSignature>;
fn wrap_secret(&self, secret: &[u8]) -> Result<Vec<u8>>;
fn unwrap_secret(&self, ciphertext: &[u8]) -> Result<zeroize::Zeroizing<Vec<u8>>>;
fn attest(&self) -> Result<Attestation> {
Err(DeviceKeyError::AttestationUnsupported)
}
}
pub fn open(label: &str) -> Result<Box<dyn DeviceKey>> {
backend::open(label)
}
pub fn create(label: &str) -> Result<Box<dyn DeviceKey>> {
backend::create(label)
}
pub fn delete(label: &str) -> Result<()> {
backend::delete(label)
}
mod unlocker;
pub use unlocker::SecureEnclaveUnlocker;
pub mod pq_companion;
pub use pq_companion::PqCompanion;
#[cfg(any(target_os = "macos", target_os = "ios"))]
mod backend {
pub use super::macos::{create, delete, open};
}
#[cfg(not(any(target_os = "macos", target_os = "ios")))]
mod backend {
use super::{DeviceKey, DeviceKeyError, Result};
const STUB: &str = "device-key backend not yet wired for this platform";
pub fn open(_label: &str) -> Result<Box<dyn DeviceKey>> {
Err(DeviceKeyError::Enclave(STUB.into()))
}
pub fn create(_label: &str) -> Result<Box<dyn DeviceKey>> {
Err(DeviceKeyError::Enclave(STUB.into()))
}
pub fn delete(_label: &str) -> Result<()> {
Err(DeviceKeyError::Enclave(STUB.into()))
}
}
#[cfg(any(target_os = "macos", target_os = "ios"))]
mod macos {
use super::{
Attestation, DeviceKey, DeviceKeyError, DevicePublicKey, DeviceSignature, Result,
};
use security_framework::access_control::{ProtectionMode, SecAccessControl};
use security_framework::item::{
ItemClass, ItemSearchOptions, Limit, Location, Reference, SearchResult,
};
use security_framework::key::{Algorithm, GenerateKeyOptions, KeyType, SecKey, Token};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
const SAC_USER_PRESENCE: usize = 1 << 0;
const SAC_PRIVATE_KEY_USAGE: usize = 1 << 30;
const ECIES: Algorithm = Algorithm::ECIESEncryptionStandardVariableIVX963SHA256AESGCM;
const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300;
fn handles() -> &'static Mutex<HashMap<String, SecKey>> {
static HANDLES: OnceLock<Mutex<HashMap<String, SecKey>>> = OnceLock::new();
HANDLES.get_or_init(|| Mutex::new(HashMap::new()))
}
fn cache_handle(label: &str, key: SecKey) {
if let Ok(mut map) = handles().lock() {
map.insert(label.to_string(), key);
}
}
fn lookup_handle(label: &str) -> Option<SecKey> {
handles().lock().ok()?.get(label).cloned()
}
fn forget_handle(label: &str) {
if let Ok(mut map) = handles().lock() {
map.remove(label);
}
}
pub(super) struct MacEnclaveKey {
label: String,
key: SecKey,
}
impl MacEnclaveKey {
fn raw_public_key(key: &SecKey) -> Result<DevicePublicKey> {
let pk = key
.public_key()
.ok_or_else(|| DeviceKeyError::Enclave("no public key on SecKey".into()))?;
let cfdata = pk
.external_representation()
.ok_or_else(|| DeviceKeyError::Enclave("public key not exportable".into()))?;
let bytes = cfdata.bytes();
if bytes.len() != 65 || bytes[0] != 0x04 {
return Err(DeviceKeyError::Enclave(format!(
"unexpected public key format: len={} prefix=0x{:02x}",
bytes.len(),
bytes.first().copied().unwrap_or(0)
)));
}
let mut out = [0u8; 64];
out.copy_from_slice(&bytes[1..]);
Ok(out)
}
fn der_to_raw_signature(der: &[u8]) -> Result<DeviceSignature> {
fn read_int(p: &[u8]) -> Result<(&[u8], &[u8])> {
if p.len() < 2 || p[0] != 0x02 {
return Err(DeviceKeyError::Enclave("bad DER INTEGER tag".into()));
}
let len = p[1] as usize;
if p.len() < 2 + len {
return Err(DeviceKeyError::Enclave("DER INTEGER truncated".into()));
}
Ok((&p[2..2 + len], &p[2 + len..]))
}
if der.len() < 2 || der[0] != 0x30 {
return Err(DeviceKeyError::Enclave("bad DER SEQUENCE".into()));
}
let body = &der[2..];
let (r, rest) = read_int(body)?;
let (s, _) = read_int(rest)?;
let mut out = [0u8; 64];
let r_strip = if r.len() == 33 && r[0] == 0 { &r[1..] } else { r };
let s_strip = if s.len() == 33 && s[0] == 0 { &s[1..] } else { s };
if r_strip.len() > 32 || s_strip.len() > 32 {
return Err(DeviceKeyError::Enclave("ECDSA scalar > 32 bytes".into()));
}
out[32 - r_strip.len()..32].copy_from_slice(r_strip);
out[64 - s_strip.len()..64].copy_from_slice(s_strip);
Ok(out)
}
}
impl DeviceKey for MacEnclaveKey {
fn label(&self) -> &str {
&self.label
}
fn public_key(&self) -> Result<DevicePublicKey> {
Self::raw_public_key(&self.key)
}
fn sign_prehash(&self, hash: &[u8; 32]) -> Result<DeviceSignature> {
let der = self
.key
.create_signature(Algorithm::ECDSASignatureDigestX962SHA256, hash)
.map_err(|e| DeviceKeyError::Enclave(format!("create_signature: {}", e)))?;
Self::der_to_raw_signature(&der)
}
fn wrap_secret(&self, secret: &[u8]) -> Result<Vec<u8>> {
let pubkey = self
.key
.public_key()
.ok_or_else(|| DeviceKeyError::Enclave("no public key on SecKey".into()))?;
pubkey
.encrypt_data(ECIES, secret)
.map_err(|e| DeviceKeyError::Enclave(format!("encrypt_data: {}", e)))
}
fn unwrap_secret(&self, ciphertext: &[u8]) -> Result<zeroize::Zeroizing<Vec<u8>>> {
let pt = self
.key
.decrypt_data(ECIES, ciphertext)
.map_err(|e| DeviceKeyError::Enclave(format!("decrypt_data: {}", e)))?;
Ok(zeroize::Zeroizing::new(pt))
}
fn attest(&self) -> Result<Attestation> {
Err(DeviceKeyError::AttestationUnsupported)
}
}
pub fn open(label: &str) -> Result<Box<dyn DeviceKey>> {
if let Some(key) = lookup_handle(label) {
return Ok(Box::new(MacEnclaveKey {
label: label.to_string(),
key,
}));
}
let mut search = ItemSearchOptions::new();
search
.class(ItemClass::key())
.label(label)
.load_refs(true)
.limit(Limit::Max(1));
if let Ok(results) = search.search() {
for r in results {
if let SearchResult::Ref(Reference::Key(key)) = r {
cache_handle(label, key.clone());
return Ok(Box::new(MacEnclaveKey {
label: label.to_string(),
key,
}));
}
}
}
Err(DeviceKeyError::NotFound(label.to_string()))
}
pub fn create(label: &str) -> Result<Box<dyn DeviceKey>> {
let make_opts = |persistent: bool| {
let acl = SecAccessControl::create_with_protection(
Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly),
SAC_USER_PRESENCE | SAC_PRIVATE_KEY_USAGE,
)
.ok();
let mut opts = GenerateKeyOptions::default();
opts.set_key_type(KeyType::ec())
.set_size_in_bits(256)
.set_label(label.to_string())
.set_token(Token::SecureEnclave);
if let Some(acl) = acl {
opts.set_access_control(acl);
}
if persistent {
opts.set_location(Location::DefaultFileKeychain);
}
opts
};
let key = match SecKey::new(&make_opts(true)) {
Ok(k) => k,
Err(persistent_err) => {
tracing::warn!(
label = %label,
error = %persistent_err,
"persistent Secure Enclave key creation in the file keychain failed; \
falling back to a session-only key — wallet will NOT persist across \
restarts in this context"
);
SecKey::new(&make_opts(false))
.map_err(|e| DeviceKeyError::Enclave(format!("SecKey::new: {}", e)))?
}
};
cache_handle(label, key.clone());
Ok(Box::new(MacEnclaveKey {
label: label.to_string(),
key,
}))
}
pub fn delete(label: &str) -> Result<()> {
forget_handle(label);
let mut search = ItemSearchOptions::new();
search.class(ItemClass::key()).label(label);
match search.delete() {
Ok(()) => Ok(()),
Err(e) if e.code() == ERR_SEC_ITEM_NOT_FOUND => Ok(()),
Err(e) => Err(DeviceKeyError::Enclave(format!("SecItemDelete: {}", e))),
}
}
}