Skip to main content

tenzro_keystore_unlock/
lib.rs

1//! Platform-agnostic keystore-unlock abstraction.
2//!
3//! The Tenzro wallet keystore (`tenzro-wallet`) encrypts FROST key shares at
4//! rest with a password (Argon2id). For wallets to *persist* across restarts,
5//! the node must be able to reproduce that password on every launch. WHERE the
6//! password comes from is deployment-specific:
7//!
8//! - **Desktop apps** derive it from a hardware-backed device key gated by
9//!   biometrics (macOS Secure Enclave + Touch ID — see `tenzro-device-key`).
10//! - **Headless / server nodes** read it from an environment variable, a file,
11//!   or a KMS.
12//!
13//! This crate defines only the [`KeystoreUnlocker`] trait plus two trivial,
14//! always-available implementations ([`StaticUnlocker`], [`EnvUnlocker`]). It
15//! deliberately has NO platform dependencies so it compiles everywhere and can
16//! sit in the public API of `tenzro-wallet`/`tenzro-node` without dragging in
17//! `security-framework`. The biometric implementation lives in the separate,
18//! macOS-flavored `tenzro-device-key` crate.
19
20use zeroize::Zeroizing;
21
22/// Errors a [`KeystoreUnlocker`] can surface.
23#[derive(Debug, thiserror::Error)]
24pub enum UnlockError {
25    /// The unlock source is present but the user/operator denied or cancelled
26    /// it (e.g. cancelled the Touch ID prompt).
27    #[error("keystore unlock cancelled")]
28    Cancelled,
29    /// The configured unlock source is missing (e.g. the env var is unset, the
30    /// ciphertext file does not exist yet).
31    #[error("keystore unlock source unavailable: {0}")]
32    Unavailable(String),
33    /// The unlock source failed for an implementation-specific reason
34    /// (hardware error, decryption failure, malformed ciphertext, …).
35    #[error("keystore unlock failed: {0}")]
36    Backend(String),
37}
38
39/// Result alias for unlock operations.
40pub type Result<T> = std::result::Result<T, UnlockError>;
41
42/// Supplies the keystore password used to encrypt/decrypt wallet key shares.
43///
44/// Implementations must return the SAME password bytes on every call for a
45/// given device/deployment, otherwise a keystore written on first run cannot be
46/// decrypted on the next launch. The returned value is wrapped in
47/// [`Zeroizing`] so it is wiped from memory on drop.
48///
49/// Calling `unlock_password` MAY block (a biometric prompt) and MAY prompt the
50/// user, so callers should invoke it once during wallet-service init and reuse
51/// the resulting [`tenzro_wallet`]-side configured password, rather than per
52/// keystore access.
53pub trait KeystoreUnlocker: Send + Sync {
54    /// Resolve the keystore password. See the trait docs for the stability
55    /// contract.
56    fn unlock_password(&self) -> Result<Zeroizing<String>>;
57}
58
59/// A fixed, in-memory password. Useful for tests, ephemeral nodes, or callers
60/// that manage the secret themselves. NOT secure at rest (the password lives in
61/// process memory and wherever the caller sourced it).
62pub struct StaticUnlocker(Zeroizing<String>);
63
64impl StaticUnlocker {
65    /// Wrap an already-known password.
66    pub fn new(password: impl Into<String>) -> Self {
67        Self(Zeroizing::new(password.into()))
68    }
69}
70
71impl KeystoreUnlocker for StaticUnlocker {
72    fn unlock_password(&self) -> Result<Zeroizing<String>> {
73        Ok(self.0.clone())
74    }
75}
76
77/// Reads the keystore password from an environment variable. Intended for
78/// headless/server deployments where the operator injects the secret via the
79/// process environment or an orchestrator secret mount.
80pub struct EnvUnlocker {
81    var: String,
82}
83
84impl EnvUnlocker {
85    /// Read from `var` at unlock time (not construction time), so the value can
86    /// be populated after the unlocker is built.
87    pub fn new(var: impl Into<String>) -> Self {
88        Self { var: var.into() }
89    }
90}
91
92impl KeystoreUnlocker for EnvUnlocker {
93    fn unlock_password(&self) -> Result<Zeroizing<String>> {
94        match std::env::var(&self.var) {
95            Ok(v) if !v.is_empty() => Ok(Zeroizing::new(v)),
96            Ok(_) => Err(UnlockError::Unavailable(format!("${} is empty", self.var))),
97            Err(_) => Err(UnlockError::Unavailable(format!("${} unset", self.var))),
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn static_is_stable() {
108        let u = StaticUnlocker::new("hunter2");
109        assert_eq!(&**u.unlock_password().unwrap(), "hunter2");
110        assert_eq!(
111            u.unlock_password().unwrap(),
112            u.unlock_password().unwrap(),
113            "must return the same password every call"
114        );
115    }
116
117    #[test]
118    fn env_missing_is_unavailable() {
119        let u = EnvUnlocker::new("TENZRO_TEST_UNLOCK_DEFINITELY_UNSET");
120        assert!(matches!(
121            u.unlock_password(),
122            Err(UnlockError::Unavailable(_))
123        ));
124    }
125}