use std::sync::Mutex;
use std::time::Instant;
use serde::Serialize;
use tracing::{info, warn};
use zeroize::Zeroize;
use crate::error::{AppError, tee_attestation_error};
pub struct MnemonicExportGuard {
inner: Mutex<GuardState>,
}
struct GuardState {
entropy: Option<[u8; 32]>,
created_at: Instant,
window_secs: u64,
exported: bool,
}
impl Drop for GuardState {
fn drop(&mut self) {
self.wipe_entropy();
}
}
impl GuardState {
fn wipe_entropy(&mut self) {
if let Some(ref mut e) = self.entropy {
e.zeroize();
}
self.entropy = None;
}
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct MnemonicExportResponse {
pub mnemonic: String,
pub window_remaining_secs: u64,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct MnemonicExportStatus {
pub window_active: bool,
pub already_exported: bool,
pub entropy_available: bool,
pub window_remaining_secs: u64,
}
impl MnemonicExportGuard {
pub fn new(entropy: [u8; 32], window_secs: u64) -> Self {
info!(
window_secs,
"mnemonic export guard created — window open for {window_secs}s"
);
Self {
inner: Mutex::new(GuardState {
entropy: Some(entropy),
created_at: Instant::now(),
window_secs,
exported: false,
}),
}
}
pub fn empty() -> Self {
Self {
inner: Mutex::new(GuardState {
entropy: None,
created_at: Instant::now(),
window_secs: 0,
exported: false,
}),
}
}
pub fn status(&self) -> MnemonicExportStatus {
let guard = self.inner.lock().unwrap();
let elapsed = guard.created_at.elapsed().as_secs();
let window_active =
guard.entropy.is_some() && !guard.exported && elapsed < guard.window_secs;
let remaining = if window_active {
guard.window_secs.saturating_sub(elapsed)
} else {
0
};
MnemonicExportStatus {
window_active,
already_exported: guard.exported,
entropy_available: guard.entropy.is_some(),
window_remaining_secs: remaining,
}
}
pub fn export(&self) -> Result<MnemonicExportResponse, AppError> {
let mut guard = self.inner.lock().unwrap();
let entropy = match guard.entropy {
Some(e) => e,
None => {
return Err(tee_attestation_error(
"no mnemonic available — entropy only exists on first boot",
));
}
};
if guard.exported {
return Err(tee_attestation_error(
"mnemonic already exported — one-time operation",
));
}
let elapsed = guard.created_at.elapsed().as_secs();
if elapsed >= guard.window_secs {
guard.wipe_entropy();
warn!("mnemonic export attempted after window expired — entropy zeroed");
return Err(tee_attestation_error(format!(
"mnemonic export window expired ({elapsed}s elapsed, window was {}s)",
guard.window_secs
)));
}
let mnemonic = bip39::Mnemonic::from_entropy(&entropy)
.map_err(|e| tee_attestation_error(format!("failed to derive mnemonic: {e}")))?;
let remaining = guard.window_secs.saturating_sub(elapsed);
guard.exported = true;
guard.wipe_entropy();
info!(
remaining_secs = remaining,
"mnemonic exported to authenticated super admin — entropy zeroed"
);
let mut mnemonic_str = mnemonic.to_string();
let response = MnemonicExportResponse {
mnemonic: mnemonic_str.clone(),
window_remaining_secs: remaining,
};
mnemonic_str.zeroize();
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_ENTROPY: [u8; 32] = [0x42; 32];
#[test]
fn first_export_within_window_succeeds_then_burns_entropy() {
let g = MnemonicExportGuard::new(TEST_ENTROPY, 60);
let resp = g.export().expect("first export within window must succeed");
assert_eq!(resp.mnemonic.split_whitespace().count(), 24);
assert!(resp.window_remaining_secs <= 60);
let s = g.status();
assert!(!s.entropy_available, "entropy must be wiped after export");
assert!(s.already_exported, "exported flag must be sticky");
assert!(!s.window_active);
}
#[test]
fn second_export_after_first_rejected() {
let g = MnemonicExportGuard::new(TEST_ENTROPY, 60);
let _ = g.export().unwrap();
let err = g
.export()
.expect_err("second export must be refused — one-time operation");
let msg = format!("{err}");
assert!(
msg.contains("already exported")
|| msg.contains("no mnemonic available")
|| msg.contains("entropy only exists on first boot"),
"error must indicate the export is exhausted: got {msg}"
);
}
#[test]
fn empty_guard_rejects_export_with_no_entropy_message() {
let g = MnemonicExportGuard::empty();
let err = g.export().expect_err("no entropy → export must fail");
let msg = format!("{err}");
assert!(
msg.contains("no mnemonic available")
|| msg.contains("entropy only exists on first boot"),
"error must explain why entropy is absent: got {msg}"
);
let s = g.status();
assert!(!s.entropy_available);
assert!(!s.window_active);
assert!(!s.already_exported);
}
#[test]
fn export_after_window_expired_zeroes_entropy_and_fails() {
let g = MnemonicExportGuard::new(TEST_ENTROPY, 0);
let err = g
.export()
.expect_err("zero-second window must reject export immediately");
let msg = format!("{err}");
assert!(msg.contains("window expired"), "got: {msg}");
let s = g.status();
assert!(
!s.entropy_available,
"expired-window path must zero entropy, status says {s:?}"
);
}
#[test]
fn drop_zeros_entropy() {
let mut state = GuardState {
entropy: Some(TEST_ENTROPY),
created_at: Instant::now(),
window_secs: 60,
exported: false,
};
state.wipe_entropy();
assert!(
state.entropy.is_none(),
"wipe_entropy must clear the Option"
);
state.wipe_entropy();
assert!(state.entropy.is_none());
}
#[test]
fn status_reflects_window_state_correctly() {
let g = MnemonicExportGuard::new(TEST_ENTROPY, 3600);
let s = g.status();
assert!(s.window_active, "fresh guard with 1h window must be active");
assert!(s.entropy_available);
assert!(!s.already_exported);
assert!(s.window_remaining_secs <= 3600);
assert!(
s.window_remaining_secs > 3590,
"remaining ≈ window for fresh guard"
);
let g0 = MnemonicExportGuard::new(TEST_ENTROPY, 0);
let s0 = g0.status();
assert!(!s0.window_active, "0-second window is never active");
assert_eq!(s0.window_remaining_secs, 0);
}
}