use crate::error::{ButterflyBotError, Result};
use crate::security::cocoon_store;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::rngs::SysRng;
use rand::TryRng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(test)]
use std::path::Path;
#[cfg(test)]
use std::cell::RefCell;
#[cfg(test)]
use std::thread_local;
#[cfg(all(debug_assertions, not(test)))]
use std::sync::{OnceLock as DebugOnceLock, RwLock as DebugRwLock};
const SERVICE: &str = "butterfly-bot";
const TPM_KEK_NAME: &str = "tpm_kek";
#[cfg(not(test))]
const COMPAT_KEK_NAME: &str = "compat_kek";
const TPM_MODE_ENV: &str = "BUTTERFLY_TPM_MODE";
#[cfg(any(target_os = "macos", target_os = "windows", not(test)))]
const PLATFORM_BINDING_NAME: &str = "platform_secure_binding";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TpmMode {
Strict,
Auto,
Compatible,
}
impl TpmMode {
fn as_str(self) -> &'static str {
match self {
Self::Strict => "strict",
Self::Auto => "auto",
Self::Compatible => "compatible",
}
}
}
fn parse_tpm_mode(value: Option<&str>) -> TpmMode {
match value.unwrap_or("auto").trim().to_ascii_lowercase().as_str() {
"strict" => TpmMode::Strict,
"compatible" => TpmMode::Compatible,
_ => TpmMode::Auto,
}
}
fn configured_tpm_mode() -> TpmMode {
parse_tpm_mode(std::env::var(TPM_MODE_ENV).ok().as_deref())
}
#[cfg(not(test))]
fn is_missing_tpm_policy(err: &ButterflyBotError) -> bool {
let lowered = err.to_string().to_ascii_lowercase();
lowered.contains("tpm is required") || lowered.contains("no tpm")
}
fn should_fallback_to_compatible(err: &ButterflyBotError) -> bool {
match err {
ButterflyBotError::SecurityPolicy(_) | ButterflyBotError::SecurityStorage(_) => true,
ButterflyBotError::Runtime(_) => {
let lowered = err.to_string().to_ascii_lowercase();
lowered.contains("tpm") || lowered.contains("keyring") || lowered.contains("secure")
}
_ => false,
}
}
#[cfg(not(test))]
struct CompatibleBackend;
#[cfg(not(test))]
impl CompatibleBackend {
fn keychain_entry() -> Result<keyring::Entry> {
keyring::Entry::new(SERVICE, PLATFORM_BINDING_NAME)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))
}
fn ensure_binding_id(&self) -> Result<String> {
let entry = Self::keychain_entry()?;
match entry.get_password() {
Ok(value) => {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
let generated =
TpmRuntime::<DeviceTpmBackend, KeyringKekStore>::random_secret()?;
entry
.set_password(&generated)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
Ok(generated)
} else {
Ok(trimmed)
}
}
Err(keyring::Error::NoEntry) => {
let generated = TpmRuntime::<DeviceTpmBackend, KeyringKekStore>::random_secret()?;
entry
.set_password(&generated)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
Ok(generated)
}
Err(err) => Err(ButterflyBotError::SecurityStorage(err.to_string())),
}
}
}
#[cfg(not(test))]
impl TpmBackend for CompatibleBackend {
fn is_present(&self) -> bool {
Self::keychain_entry().is_ok()
}
fn fingerprint(&self) -> Result<String> {
let mut hasher = Sha256::new();
hasher.update(b"compatible-keyring-backend");
hasher.update(self.ensure_binding_id()?.as_bytes());
Ok(format!("{:x}", hasher.finalize()))
}
}
const POLICY_VERSION: u8 = 2;
fn missing_tpm_policy_message() -> String {
let base = "TPM is required in strict mode; no TPM device found";
#[cfg(target_os = "linux")]
{
let dev_nodes = list_tpm_nodes_under(PathBuf::from("/dev"));
let sys_nodes = list_tpm_nodes_under(PathBuf::from("/sys/class/tpm"));
if !dev_nodes.is_empty() {
return format!(
"{base}; detected TPM-like nodes in /dev: {}",
dev_nodes.join(", ")
);
}
if !sys_nodes.is_empty() {
return format!(
"{base}; sysfs reports TPM entries ({}) but /dev has none. Check kernel TPM modules (e.g. tpm_crb/tpm_tis) and udev permissions.",
sys_nodes.join(", ")
);
}
format!(
"{base}; probe found no /dev/tpm* or /sys/class/tpm/tpm*. Verify TPM/fTPM is enabled in BIOS/UEFI and Linux TPM drivers are loaded."
)
}
#[cfg(not(target_os = "linux"))]
{
base.to_string()
}
}
#[cfg(target_os = "linux")]
fn list_tpm_nodes_under(root: PathBuf) -> Vec<String> {
let mut nodes: Vec<(u32, String)> = Vec::new();
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let name = match entry.file_name().into_string() {
Ok(value) => value,
Err(_) => continue,
};
if let Some(index) = DeviceTpmBackend::parse_tpm_index(&name, "tpmrm") {
nodes.push((index, name));
continue;
}
if let Some(index) = DeviceTpmBackend::parse_tpm_index(&name, "tpm") {
nodes.push((index, name));
}
}
}
nodes.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
nodes.into_iter().map(|(_, name)| name).collect()
}
#[cfg(all(debug_assertions, not(test)))]
fn debug_available_override_lock() -> &'static DebugRwLock<Option<bool>> {
static OVERRIDE: DebugOnceLock<DebugRwLock<Option<bool>>> = DebugOnceLock::new();
OVERRIDE.get_or_init(|| DebugRwLock::new(None))
}
#[cfg(all(debug_assertions, not(test)))]
fn debug_dek_override_lock() -> &'static DebugRwLock<Option<String>> {
static OVERRIDE: DebugOnceLock<DebugRwLock<Option<String>>> = DebugOnceLock::new();
OVERRIDE.get_or_init(|| DebugRwLock::new(None))
}
#[cfg(all(debug_assertions, not(test)))]
pub fn set_debug_tpm_available_override(value: Option<bool>) {
let lock = debug_available_override_lock();
match lock.write() {
Ok(mut guard) => *guard = value,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = value;
}
}
}
#[cfg(all(debug_assertions, not(test)))]
pub fn set_debug_dek_passphrase_override(value: Option<String>) {
let lock = debug_dek_override_lock();
match lock.write() {
Ok(mut guard) => *guard = value,
Err(poisoned) => {
let mut guard = poisoned.into_inner();
*guard = value;
}
}
}
#[cfg(all(debug_assertions, not(test)))]
fn debug_tpm_available_override() -> Option<bool> {
let lock = debug_available_override_lock();
match lock.read() {
Ok(guard) => *guard,
Err(poisoned) => *poisoned.into_inner(),
}
}
#[cfg(all(debug_assertions, not(test)))]
fn debug_dek_override() -> Option<String> {
let lock = debug_dek_override_lock();
match lock.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
#[cfg(test)]
thread_local! {
static TEST_TPM_AVAILABLE_OVERRIDE: RefCell<Option<bool>> = const { RefCell::new(None) };
static TEST_DEK_OVERRIDE: RefCell<Option<String>> = const { RefCell::new(None) };
}
#[cfg(test)]
pub(crate) fn set_tpm_available_for_tests(value: Option<bool>) {
TEST_TPM_AVAILABLE_OVERRIDE.with(|cell| {
*cell.borrow_mut() = value;
});
}
#[cfg(test)]
pub(crate) fn set_dek_passphrase_for_tests(value: Option<String>) {
TEST_DEK_OVERRIDE.with(|cell| {
*cell.borrow_mut() = value;
});
}
#[cfg(test)]
fn tpm_available_override() -> Option<bool> {
TEST_TPM_AVAILABLE_OVERRIDE.with(|cell| *cell.borrow())
}
#[cfg(test)]
fn dek_override() -> Option<String> {
TEST_DEK_OVERRIDE.with(|cell| cell.borrow().clone())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KeyLifecycleState {
Provision,
Seal,
Unseal,
Use,
Rotate,
Revoke,
Recover,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TpmPolicyState {
version: u8,
fingerprint: String,
lifecycle_state: KeyLifecycleState,
}
trait TpmBackend {
fn is_present(&self) -> bool;
fn fingerprint(&self) -> Result<String>;
}
trait KekStore {
fn get_kek(&self) -> Result<Option<String>>;
fn set_kek(&self, value: &str) -> Result<()>;
fn clear_kek(&self) -> Result<()>;
}
struct DeviceTpmBackend;
impl DeviceTpmBackend {
#[cfg(any(target_os = "linux", test))]
fn parse_tpm_index(name: &str, prefix: &str) -> Option<u32> {
let suffix = name.strip_prefix(prefix)?;
if suffix.is_empty() || !suffix.chars().all(|ch| ch.is_ascii_digit()) {
return None;
}
suffix.parse::<u32>().ok()
}
#[cfg(target_os = "linux")]
fn active_device_path(&self) -> Option<PathBuf> {
let mut rm_nodes: Vec<(u32, PathBuf)> = Vec::new();
let mut direct_nodes: Vec<(u32, PathBuf)> = Vec::new();
if let Ok(entries) = std::fs::read_dir("/dev") {
for entry in entries.flatten() {
let name = match entry.file_name().into_string() {
Ok(value) => value,
Err(_) => continue,
};
if let Some(index) = Self::parse_tpm_index(&name, "tpmrm") {
rm_nodes.push((index, entry.path()));
continue;
}
if let Some(index) = Self::parse_tpm_index(&name, "tpm") {
direct_nodes.push((index, entry.path()));
}
}
}
rm_nodes.sort_by_key(|(index, _)| *index);
if let Some((_, path)) = rm_nodes.into_iter().next() {
return Some(path);
}
direct_nodes.sort_by_key(|(index, _)| *index);
direct_nodes.into_iter().next().map(|(_, path)| path)
}
#[cfg(not(target_os = "linux"))]
fn active_device_path(&self) -> Option<PathBuf> {
None
}
#[cfg(target_os = "linux")]
fn sysfs_tpm_paths(&self) -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(entries) = std::fs::read_dir("/sys/class/tpm") {
for entry in entries.flatten() {
let name = match entry.file_name().into_string() {
Ok(value) => value,
Err(_) => continue,
};
if Self::parse_tpm_index(&name, "tpm").is_some() {
paths.push(entry.path());
}
}
}
paths.sort();
paths
}
#[cfg(target_os = "linux")]
fn sysfs_has_tpm(&self) -> bool {
!self.sysfs_tpm_paths().is_empty()
}
#[cfg(target_os = "linux")]
fn sysfs_uevent_bytes(&self) -> Option<Vec<u8>> {
self.sysfs_tpm_paths().into_iter().find_map(|path| {
let uevent = path.join("device").join("uevent");
std::fs::read(uevent).ok()
})
}
}
#[cfg(target_os = "macos")]
struct AppleSecureBackend;
#[cfg(target_os = "macos")]
impl AppleSecureBackend {
fn keychain_entry() -> Result<keyring::Entry> {
keyring::Entry::new(SERVICE, PLATFORM_BINDING_NAME)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))
}
fn ensure_binding_id(&self) -> Result<String> {
let entry = Self::keychain_entry()?;
match entry.get_password() {
Ok(value) => {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
let generated =
TpmRuntime::<DeviceTpmBackend, KeyringKekStore>::random_secret()?;
entry
.set_password(&generated)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
Ok(generated)
} else {
Ok(trimmed)
}
}
Err(keyring::Error::NoEntry) => {
let generated = TpmRuntime::<DeviceTpmBackend, KeyringKekStore>::random_secret()?;
entry
.set_password(&generated)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
Ok(generated)
}
Err(err) => Err(ButterflyBotError::SecurityStorage(err.to_string())),
}
}
}
#[cfg(target_os = "macos")]
impl TpmBackend for AppleSecureBackend {
fn is_present(&self) -> bool {
Self::keychain_entry().is_ok()
}
fn fingerprint(&self) -> Result<String> {
let mut hasher = Sha256::new();
hasher.update(b"darwin-keychain-secure-backend");
hasher.update(self.ensure_binding_id()?.as_bytes());
Ok(format!("{:x}", hasher.finalize()))
}
}
#[cfg(target_os = "windows")]
struct WindowsSecureBackend;
#[cfg(target_os = "windows")]
impl WindowsSecureBackend {
fn keychain_entry() -> Result<keyring::Entry> {
keyring::Entry::new(SERVICE, PLATFORM_BINDING_NAME)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))
}
fn ensure_binding_id(&self) -> Result<String> {
let entry = Self::keychain_entry()?;
match entry.get_password() {
Ok(value) => {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
let generated =
TpmRuntime::<DeviceTpmBackend, KeyringKekStore>::random_secret()?;
entry
.set_password(&generated)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
Ok(generated)
} else {
Ok(trimmed)
}
}
Err(keyring::Error::NoEntry) => {
let generated = TpmRuntime::<DeviceTpmBackend, KeyringKekStore>::random_secret()?;
entry
.set_password(&generated)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
Ok(generated)
}
Err(err) => Err(ButterflyBotError::SecurityStorage(err.to_string())),
}
}
}
#[cfg(target_os = "windows")]
impl TpmBackend for WindowsSecureBackend {
fn is_present(&self) -> bool {
Self::keychain_entry().is_ok()
}
fn fingerprint(&self) -> Result<String> {
let mut hasher = Sha256::new();
hasher.update(b"windows-keyring-secure-backend");
hasher.update(self.ensure_binding_id()?.as_bytes());
Ok(format!("{:x}", hasher.finalize()))
}
}
impl TpmBackend for DeviceTpmBackend {
fn is_present(&self) -> bool {
#[cfg(target_os = "linux")]
{
self.active_device_path().is_some() || self.sysfs_has_tpm()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
fn fingerprint(&self) -> Result<String> {
let mut hasher = Sha256::new();
#[cfg(target_os = "linux")]
{
if let Some(uevent) = self.sysfs_uevent_bytes() {
hasher.update(b"linux-tpm-uevent-v1");
hasher.update(uevent);
return Ok(format!("{:x}", hasher.finalize()));
}
if let Some(path) = self.active_device_path() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if let Some(index) = Self::parse_tpm_index(name, "tpmrm") {
hasher.update(format!("linux-tpm-index:{index}").as_bytes());
return Ok(format!("{:x}", hasher.finalize()));
}
if let Some(index) = Self::parse_tpm_index(name, "tpm") {
hasher.update(format!("linux-tpm-index:{index}").as_bytes());
return Ok(format!("{:x}", hasher.finalize()));
}
}
hasher.update(path.to_string_lossy().as_bytes());
return Ok(format!("{:x}", hasher.finalize()));
}
if self.sysfs_has_tpm() {
hasher.update(b"linux-tpm-sysfs-present");
return Ok(format!("{:x}", hasher.finalize()));
}
Err(ButterflyBotError::SecurityPolicy(
missing_tpm_policy_message(),
))
}
#[cfg(not(target_os = "linux"))]
{
let path = if let Some(path) = self.active_device_path() {
path
} else {
return Err(ButterflyBotError::SecurityPolicy(
missing_tpm_policy_message(),
));
};
if path.as_os_str().is_empty() {
return Err(ButterflyBotError::SecurityPolicy(
missing_tpm_policy_message(),
));
}
hasher.update(path.to_string_lossy().as_bytes());
Ok(format!("{:x}", hasher.finalize()))
}
}
}
struct KeyringKekStore;
impl KekStore for KeyringKekStore {
fn get_kek(&self) -> Result<Option<String>> {
let entry = keyring::Entry::new(SERVICE, TPM_KEK_NAME)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
match entry.get_password() {
Ok(value) => {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed))
}
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(ButterflyBotError::SecurityStorage(err.to_string())),
}
}
fn set_kek(&self, value: &str) -> Result<()> {
let entry = keyring::Entry::new(SERVICE, TPM_KEK_NAME)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
entry
.set_password(value)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))
}
fn clear_kek(&self) -> Result<()> {
let entry = keyring::Entry::new(SERVICE, TPM_KEK_NAME)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(ButterflyBotError::SecurityStorage(err.to_string())),
}
}
}
#[cfg(not(test))]
struct KeyringCompatibleKekStore;
#[cfg(not(test))]
impl KekStore for KeyringCompatibleKekStore {
fn get_kek(&self) -> Result<Option<String>> {
let entry = keyring::Entry::new(SERVICE, COMPAT_KEK_NAME)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
match entry.get_password() {
Ok(value) => {
let trimmed = value.trim().to_string();
if trimmed.is_empty() {
Ok(None)
} else {
Ok(Some(trimmed))
}
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(err) => Err(ButterflyBotError::SecurityStorage(err.to_string())),
}
}
fn set_kek(&self, value: &str) -> Result<()> {
let entry = keyring::Entry::new(SERVICE, COMPAT_KEK_NAME)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
entry
.set_password(value)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))
}
fn clear_kek(&self) -> Result<()> {
let entry = keyring::Entry::new(SERVICE, COMPAT_KEK_NAME)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(err) => Err(ButterflyBotError::SecurityStorage(err.to_string())),
}
}
}
struct TpmRuntime<'a, B: TpmBackend, K: KekStore> {
backend: &'a B,
kek_store: &'a K,
security_root: PathBuf,
}
impl<'a, B: TpmBackend, K: KekStore> TpmRuntime<'a, B, K> {
fn policy_state_path(&self) -> PathBuf {
self.security_root.join("tpm_policy_state.json")
}
fn wrapped_dek_path(&self) -> PathBuf {
self.security_root.join("wrapped_dek.cocoon")
}
fn fallback_kek_path(&self) -> PathBuf {
self.security_root.join("kek_fallback.txt")
}
fn ensure_root(&self) -> Result<()> {
std::fs::create_dir_all(&self.security_root).map_err(|e| {
ButterflyBotError::SecurityStorage(format!(
"failed to create security root {}: {e}",
self.security_root.to_string_lossy()
))
})
}
fn load_policy_state(&self) -> Result<Option<TpmPolicyState>> {
let path = self.policy_state_path();
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&path).map_err(|e| {
ButterflyBotError::SecurityStorage(format!(
"failed to read TPM policy state {}: {e}",
path.to_string_lossy()
))
})?;
let state: TpmPolicyState = serde_json::from_str(&raw).map_err(|e| {
ButterflyBotError::SecurityStorage(format!(
"failed to parse TPM policy state {}: {e}",
path.to_string_lossy()
))
})?;
Ok(Some(state))
}
fn save_policy_state(&self, state: TpmPolicyState) -> Result<()> {
self.ensure_root()?;
let path = self.policy_state_path();
let payload = serde_json::to_string_pretty(&state)
.map_err(|e| ButterflyBotError::SecurityStorage(e.to_string()))?;
std::fs::write(&path, payload).map_err(|e| {
ButterflyBotError::SecurityStorage(format!(
"failed to write TPM policy state {}: {e}",
path.to_string_lossy()
))
})
}
fn random_secret() -> Result<String> {
let mut bytes = [0u8; 32];
let mut rng = SysRng;
rng.try_fill_bytes(&mut bytes)
.map_err(|e| ButterflyBotError::Runtime(e.to_string()))?;
Ok(URL_SAFE_NO_PAD.encode(bytes))
}
fn lockout_like(err: &ButterflyBotError) -> bool {
let lowered = err.to_string().to_ascii_lowercase();
lowered.contains("lockout") || lowered.contains("locked") || lowered.contains("auth")
}
fn reset_like(err: &ButterflyBotError) -> bool {
let lowered = err.to_string().to_ascii_lowercase();
lowered.contains("tpm reset or reprovision detected")
}
fn missing_tpm_error() -> ButterflyBotError {
ButterflyBotError::SecurityPolicy(missing_tpm_policy_message())
}
fn reset_error(detail: &str) -> ButterflyBotError {
ButterflyBotError::SecurityPolicy(format!(
"TPM reset or reprovision detected ({detail}). Recovery runbook: {}",
recovery_runbook()
))
}
fn lockout_error() -> ButterflyBotError {
ButterflyBotError::SecurityPolicy(format!(
"TPM lockout/auth failure detected. Recovery runbook: {}",
recovery_runbook()
))
}
fn require_present(&self) -> Result<()> {
if self.backend.is_present() {
Ok(())
} else {
Err(Self::missing_tpm_error())
}
}
fn load_kek_mapped(&self) -> Result<String> {
match self.kek_store.get_kek() {
Ok(Some(value)) => return Ok(value),
Ok(None) => {}
Err(err) => {
if Self::lockout_like(&err) {
return Err(Self::lockout_error());
}
}
}
let fallback = self.fallback_kek_path();
if let Ok(raw) = std::fs::read_to_string(&fallback) {
let trimmed = raw.trim().to_string();
if !trimmed.is_empty() {
return Ok(trimmed);
}
}
Err(Self::reset_error("KEK missing from TPM key store"))
}
fn persist_kek_with_fallback(&self, value: &str) -> Result<()> {
if let Err(err) = self.kek_store.set_kek(value) {
if Self::lockout_like(&err) {
return Err(Self::lockout_error());
}
}
self.ensure_root()?;
let path = self.fallback_kek_path();
std::fs::write(&path, value).map_err(|e| {
ButterflyBotError::SecurityStorage(format!(
"failed to write fallback KEK {}: {e}",
path.to_string_lossy()
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
fn verify_or_migrate_policy_fingerprint(
&self,
state: &TpmPolicyState,
current_fingerprint: &str,
) -> Result<()> {
if state.fingerprint == current_fingerprint {
return Ok(());
}
let kek = self.load_kek_mapped()?;
let _ = self.load_unsealed_dek(&kek)?;
self.save_policy_state(TpmPolicyState {
version: POLICY_VERSION,
fingerprint: current_fingerprint.to_string(),
lifecycle_state: KeyLifecycleState::Recover,
})?;
Ok(())
}
fn load_unsealed_dek(&self, kek: &str) -> Result<String> {
let wrapped = self.wrapped_dek_path();
let decoded = cocoon_store::load_secret(&wrapped, kek)
.map_err(|_| Self::reset_error("wrapped DEK decryption failed"))?;
decoded.ok_or_else(|| Self::reset_error("wrapped DEK missing"))
}
fn maybe_archive_stale_artifacts(&self) {
let policy = self.policy_state_path();
let wrapped = self.wrapped_dek_path();
if !policy.exists() && !wrapped.exists() {
return;
}
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|dur| dur.as_secs())
.unwrap_or(0);
let backup_root = self.security_root.join(format!("recovery_backup_{stamp}"));
if std::fs::create_dir_all(&backup_root).is_err() {
return;
}
if policy.exists() {
let _ = std::fs::copy(&policy, backup_root.join("tpm_policy_state.json"));
}
if wrapped.exists() {
let _ = std::fs::copy(&wrapped, backup_root.join("wrapped_dek.cocoon"));
}
}
fn reprovision_after_reset(&self, fingerprint: &str) -> Result<()> {
self.maybe_archive_stale_artifacts();
self.revoke()?;
let kek = Self::random_secret()?;
self.persist_kek_with_fallback(&kek)?;
let dek = Self::random_secret()?;
self.persist_wrapped_dek(&kek, &dek)?;
self.save_policy_state(TpmPolicyState {
version: POLICY_VERSION,
fingerprint: fingerprint.to_string(),
lifecycle_state: KeyLifecycleState::Recover,
})
}
fn persist_wrapped_dek(&self, kek: &str, dek: &str) -> Result<()> {
self.ensure_root()?;
cocoon_store::persist_secret(&self.wrapped_dek_path(), kek, dek)
}
fn provision(&self) -> Result<()> {
self.require_present()?;
let fingerprint = self.backend.fingerprint()?;
let state = self.load_policy_state()?;
if let Some(state) = state {
if let Err(err) = self.verify_or_migrate_policy_fingerprint(&state, &fingerprint) {
if Self::reset_like(&err) {
self.reprovision_after_reset(&fingerprint)?;
return Ok(());
}
return Err(err);
}
let kek = match self.load_kek_mapped() {
Ok(value) => value,
Err(err) if Self::reset_like(&err) => {
self.reprovision_after_reset(&fingerprint)?;
return Ok(());
}
Err(err) => return Err(err),
};
if let Err(err) = self.load_unsealed_dek(&kek) {
if Self::reset_like(&err) {
self.reprovision_after_reset(&fingerprint)?;
return Ok(());
}
return Err(err);
}
return Ok(());
}
let kek = Self::random_secret()?;
self.persist_kek_with_fallback(&kek)?;
let dek = Self::random_secret()?;
self.persist_wrapped_dek(&kek, &dek)?;
self.save_policy_state(TpmPolicyState {
version: POLICY_VERSION,
fingerprint,
lifecycle_state: KeyLifecycleState::Seal,
})?;
Ok(())
}
fn unseal_dek(&self) -> Result<String> {
self.require_present()?;
let mut recovered_once = false;
loop {
self.provision()?;
let state = self
.load_policy_state()?
.ok_or_else(|| Self::reset_error("TPM policy state missing"))?;
let fingerprint = self.backend.fingerprint()?;
if let Err(err) = self.verify_or_migrate_policy_fingerprint(&state, &fingerprint) {
if Self::reset_like(&err) && !recovered_once {
self.reprovision_after_reset(&fingerprint)?;
recovered_once = true;
continue;
}
return Err(err);
}
let kek = match self.load_kek_mapped() {
Ok(value) => value,
Err(err) if Self::reset_like(&err) && !recovered_once => {
self.reprovision_after_reset(&fingerprint)?;
recovered_once = true;
continue;
}
Err(err) => return Err(err),
};
let dek = match self.load_unsealed_dek(&kek) {
Ok(value) => value,
Err(err) if Self::reset_like(&err) && !recovered_once => {
self.reprovision_after_reset(&fingerprint)?;
recovered_once = true;
continue;
}
Err(err) => return Err(err),
};
self.save_policy_state(TpmPolicyState {
version: POLICY_VERSION,
fingerprint,
lifecycle_state: KeyLifecycleState::Use,
})?;
return Ok(dek);
}
}
fn rotate_dek(&self) -> Result<()> {
self.require_present()?;
let state = self
.load_policy_state()?
.ok_or_else(|| Self::reset_error("TPM policy state missing"))?;
let fingerprint = self.backend.fingerprint()?;
if let Err(err) = self.verify_or_migrate_policy_fingerprint(&state, &fingerprint) {
if Self::reset_like(&err) {
self.reprovision_after_reset(&fingerprint)?;
return Ok(());
}
return Err(err);
}
let kek = match self.load_kek_mapped() {
Ok(value) => value,
Err(err) if Self::reset_like(&err) => {
self.reprovision_after_reset(&fingerprint)?;
return Ok(());
}
Err(err) => return Err(err),
};
let new_dek = Self::random_secret()?;
self.persist_wrapped_dek(&kek, &new_dek)?;
self.save_policy_state(TpmPolicyState {
version: POLICY_VERSION,
fingerprint,
lifecycle_state: KeyLifecycleState::Rotate,
})
}
fn revoke(&self) -> Result<()> {
let _ = std::fs::remove_file(self.wrapped_dek_path());
let _ = std::fs::remove_file(self.policy_state_path());
let _ = std::fs::remove_file(self.fallback_kek_path());
self.kek_store.clear_kek()?;
Ok(())
}
}
fn runtime_root() -> PathBuf {
crate::runtime_paths::app_root().join("security")
}
#[cfg(not(test))]
fn compatible_runtime_root() -> PathBuf {
crate::runtime_paths::app_root().join("security_compatible")
}
#[cfg(not(test))]
fn production_runtime_compatible(
) -> TpmRuntime<'static, CompatibleBackend, KeyringCompatibleKekStore> {
static BACKEND: CompatibleBackend = CompatibleBackend;
static STORE: KeyringCompatibleKekStore = KeyringCompatibleKekStore;
TpmRuntime {
backend: &BACKEND,
kek_store: &STORE,
security_root: compatible_runtime_root(),
}
}
#[cfg(target_os = "linux")]
fn production_runtime() -> TpmRuntime<'static, DeviceTpmBackend, KeyringKekStore> {
static BACKEND: DeviceTpmBackend = DeviceTpmBackend;
static STORE: KeyringKekStore = KeyringKekStore;
TpmRuntime {
backend: &BACKEND,
kek_store: &STORE,
security_root: runtime_root(),
}
}
#[cfg(target_os = "macos")]
fn production_runtime() -> TpmRuntime<'static, AppleSecureBackend, KeyringKekStore> {
static BACKEND: AppleSecureBackend = AppleSecureBackend;
static STORE: KeyringKekStore = KeyringKekStore;
TpmRuntime {
backend: &BACKEND,
kek_store: &STORE,
security_root: runtime_root(),
}
}
#[cfg(target_os = "windows")]
fn production_runtime() -> TpmRuntime<'static, WindowsSecureBackend, KeyringKekStore> {
static BACKEND: WindowsSecureBackend = WindowsSecureBackend;
static STORE: KeyringKekStore = KeyringKekStore;
TpmRuntime {
backend: &BACKEND,
kek_store: &STORE,
security_root: runtime_root(),
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn production_runtime() -> TpmRuntime<'static, DeviceTpmBackend, KeyringKekStore> {
static BACKEND: DeviceTpmBackend = DeviceTpmBackend;
static STORE: KeyringKekStore = KeyringKekStore;
TpmRuntime {
backend: &BACKEND,
kek_store: &STORE,
security_root: runtime_root(),
}
}
pub fn require_tpm() -> Result<()> {
#[cfg(all(debug_assertions, not(test)))]
{
if let Some(value) = debug_tpm_available_override() {
return if value {
Ok(())
} else {
Err(ButterflyBotError::SecurityPolicy(
missing_tpm_policy_message(),
))
};
}
}
#[cfg(test)]
{
if let Some(value) = tpm_available_override() {
return if value {
Ok(())
} else {
Err(ButterflyBotError::SecurityPolicy(
missing_tpm_policy_message(),
))
};
}
}
production_runtime().require_present()
}
pub fn tpm_mode() -> &'static str {
configured_tpm_mode().as_str()
}
pub fn tpm_available() -> bool {
require_tpm().is_ok()
}
pub fn resolve_dek_passphrase() -> Result<String> {
#[cfg(all(debug_assertions, not(test)))]
{
if let Some(value) = debug_tpm_available_override() {
if !value {
return Err(ButterflyBotError::SecurityPolicy(
missing_tpm_policy_message(),
));
}
}
if let Some(value) = debug_dek_override() {
return Ok(value);
}
}
#[cfg(test)]
{
if let Some(value) = tpm_available_override() {
if !value {
return Err(ButterflyBotError::SecurityPolicy(
missing_tpm_policy_message(),
));
}
}
if let Some(value) = dek_override() {
return Ok(value);
}
Ok("butterfly-bot-test-dek-passphrase".to_string())
}
#[cfg(not(test))]
{
match configured_tpm_mode() {
TpmMode::Strict => production_runtime().unseal_dek(),
TpmMode::Compatible => production_runtime_compatible().unseal_dek(),
TpmMode::Auto => match production_runtime().unseal_dek() {
Ok(value) => Ok(value),
Err(err) => {
if is_missing_tpm_policy(&err) || should_fallback_to_compatible(&err) {
return production_runtime_compatible().unseal_dek();
}
Err(err)
}
},
}
}
}
pub fn provision_kek_and_dek() -> Result<()> {
production_runtime().provision()
}
pub fn rotate_dek() -> Result<()> {
production_runtime().rotate_dek()
}
pub fn revoke_keys() -> Result<()> {
production_runtime().revoke()
}
pub fn recovery_runbook() -> &'static str {
"1) stop the daemon and UI, 2) verify TPM device presence and ownership, 3) if TPM was reset/reprovisioned, run migration/recovery path and reprovision keys, 4) restore secrets from trusted backup, 5) restart in strict mode and verify checks"
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Clone)]
struct MemoryTpmBackend {
present: bool,
fingerprint: String,
}
impl TpmBackend for MemoryTpmBackend {
fn is_present(&self) -> bool {
self.present
}
fn fingerprint(&self) -> Result<String> {
Ok(self.fingerprint.clone())
}
}
#[derive(Default)]
struct MemoryKekStore {
value: Mutex<Option<String>>,
}
impl KekStore for MemoryKekStore {
fn get_kek(&self) -> Result<Option<String>> {
self.value
.lock()
.map(|guard| guard.clone())
.map_err(|_| ButterflyBotError::Runtime("kek lock poisoned".to_string()))
}
fn set_kek(&self, value: &str) -> Result<()> {
let mut guard = self
.value
.lock()
.map_err(|_| ButterflyBotError::Runtime("kek lock poisoned".to_string()))?;
*guard = Some(value.to_string());
Ok(())
}
fn clear_kek(&self) -> Result<()> {
let mut guard = self
.value
.lock()
.map_err(|_| ButterflyBotError::Runtime("kek lock poisoned".to_string()))?;
*guard = None;
Ok(())
}
}
fn runtime<'a>(
backend: &'a MemoryTpmBackend,
store: &'a MemoryKekStore,
root: &Path,
) -> TpmRuntime<'a, MemoryTpmBackend, MemoryKekStore> {
TpmRuntime {
backend,
kek_store: store,
security_root: root.to_path_buf(),
}
}
#[test]
fn tpm_present_provision_and_unseal_succeeds() {
let temp = tempfile::tempdir().unwrap();
let backend = MemoryTpmBackend {
present: true,
fingerprint: "fp-a".to_string(),
};
let store = MemoryKekStore::default();
let runtime = runtime(&backend, &store, temp.path());
runtime.provision().unwrap();
let dek = runtime.unseal_dek().unwrap();
assert!(!dek.trim().is_empty());
}
#[test]
fn tpm_missing_fails_closed() {
let temp = tempfile::tempdir().unwrap();
let backend = MemoryTpmBackend {
present: false,
fingerprint: "fp-a".to_string(),
};
let store = MemoryKekStore::default();
let runtime = runtime(&backend, &store, temp.path());
let err = runtime.provision().unwrap_err();
assert!(format!("{err}").contains("TPM is required"));
}
#[test]
fn tpm_reset_missing_kek_auto_reprovisions() {
let temp = tempfile::tempdir().unwrap();
let backend = MemoryTpmBackend {
present: true,
fingerprint: "fp-a".to_string(),
};
let store = MemoryKekStore::default();
let runtime = runtime(&backend, &store, temp.path());
runtime.provision().unwrap();
let original_dek = runtime.unseal_dek().unwrap();
store.clear_kek().unwrap();
let recovered_dek = runtime.unseal_dek().unwrap();
assert!(!recovered_dek.trim().is_empty());
assert_eq!(recovered_dek, original_dek);
let state = runtime.load_policy_state().unwrap().unwrap();
assert_eq!(state.version, POLICY_VERSION);
assert_eq!(state.fingerprint, "fp-a");
assert_eq!(state.lifecycle_state, KeyLifecycleState::Use);
}
#[test]
fn tpm_policy_mismatch_auto_recovers_with_continuity() {
let temp = tempfile::tempdir().unwrap();
let backend_a = MemoryTpmBackend {
present: true,
fingerprint: "fp-a".to_string(),
};
let store = MemoryKekStore::default();
let runtime_a = runtime(&backend_a, &store, temp.path());
runtime_a.provision().unwrap();
let backend_b = MemoryTpmBackend {
present: true,
fingerprint: "fp-b".to_string(),
};
let runtime_b = runtime(&backend_b, &store, temp.path());
let dek = runtime_b.unseal_dek().unwrap();
assert!(!dek.trim().is_empty());
let migrated = runtime_b.load_policy_state().unwrap().unwrap();
assert_eq!(migrated.version, POLICY_VERSION);
assert_eq!(migrated.fingerprint, "fp-b");
assert_eq!(migrated.lifecycle_state, KeyLifecycleState::Use);
}
#[test]
fn tpm_policy_mismatch_with_missing_kek_auto_reprovisions() {
let temp = tempfile::tempdir().unwrap();
let backend_a = MemoryTpmBackend {
present: true,
fingerprint: "fp-a".to_string(),
};
let store = MemoryKekStore::default();
let runtime_a = runtime(&backend_a, &store, temp.path());
runtime_a.provision().unwrap();
store.clear_kek().unwrap();
let backend_b = MemoryTpmBackend {
present: true,
fingerprint: "fp-b".to_string(),
};
let runtime_b = runtime(&backend_b, &store, temp.path());
let dek = runtime_b.unseal_dek().unwrap();
assert!(!dek.trim().is_empty());
let state = runtime_b.load_policy_state().unwrap().unwrap();
assert_eq!(state.version, POLICY_VERSION);
assert_eq!(state.fingerprint, "fp-b");
assert_eq!(state.lifecycle_state, KeyLifecycleState::Use);
}
#[test]
fn legacy_policy_mismatch_auto_migrates() {
let temp = tempfile::tempdir().unwrap();
let backend_a = MemoryTpmBackend {
present: true,
fingerprint: "fp-a".to_string(),
};
let store = MemoryKekStore::default();
let runtime_a = runtime(&backend_a, &store, temp.path());
runtime_a.provision().unwrap();
runtime_a
.save_policy_state(TpmPolicyState {
version: 1,
fingerprint: "fp-a".to_string(),
lifecycle_state: KeyLifecycleState::Seal,
})
.unwrap();
let backend_b = MemoryTpmBackend {
present: true,
fingerprint: "fp-b".to_string(),
};
let runtime_b = runtime(&backend_b, &store, temp.path());
let dek = runtime_b.unseal_dek().unwrap();
assert!(!dek.trim().is_empty());
let migrated = runtime_b.load_policy_state().unwrap().unwrap();
assert_eq!(migrated.version, POLICY_VERSION);
assert_eq!(migrated.fingerprint, "fp-b");
}
#[test]
fn parse_tpm_index_accepts_numeric_suffixes() {
assert_eq!(
DeviceTpmBackend::parse_tpm_index("tpmrm0", "tpmrm"),
Some(0)
);
assert_eq!(DeviceTpmBackend::parse_tpm_index("tpm12", "tpm"), Some(12));
}
#[test]
fn parse_tpm_index_rejects_invalid_suffixes() {
assert_eq!(DeviceTpmBackend::parse_tpm_index("tpm", "tpm"), None);
assert_eq!(DeviceTpmBackend::parse_tpm_index("tpmrmx", "tpmrm"), None);
assert_eq!(DeviceTpmBackend::parse_tpm_index("atpm0", "tpm"), None);
}
#[test]
fn configured_tpm_mode_defaults_to_auto() {
assert_eq!(parse_tpm_mode(None), TpmMode::Auto);
}
#[test]
fn configured_tpm_mode_parses_strict_and_compatible() {
assert_eq!(parse_tpm_mode(Some("strict")), TpmMode::Strict);
assert_eq!(parse_tpm_mode(Some("compatible")), TpmMode::Compatible);
assert_eq!(parse_tpm_mode(Some("AUTO")), TpmMode::Auto);
}
#[test]
fn should_fallback_to_compatible_for_security_errors() {
assert!(should_fallback_to_compatible(
&ButterflyBotError::SecurityPolicy("TPM not present".to_string())
));
assert!(should_fallback_to_compatible(
&ButterflyBotError::SecurityStorage("Keyring unavailable".to_string())
));
}
#[test]
fn should_fallback_to_compatible_for_runtime_secure_backend_errors() {
assert!(should_fallback_to_compatible(&ButterflyBotError::Runtime(
"TPM backend unavailable".to_string()
)));
assert!(!should_fallback_to_compatible(&ButterflyBotError::Runtime(
"network timeout".to_string()
)));
}
}