//! VaultServiceHandle — the sole runtime API for the vault. //! //! The `VaultServiceHandle` wraps the vault's state in an //! `Arc>` and provides direct, synchronous method calls //! for the unlock/lock lifecycle, key derivation, and encryption/decryption. //! //! # Lifecycle //! //! ```text //! Unlock(passphrase) //! → validate mnemonic (if restoring) or generate new //! → derive master key from seed //! → store seed in SeedHolder (Zeroize-protected) //! → cache empty (keys derived on demand) //! //! DeriveEd25519/DeriveEncryptionKey/Encrypt/Decrypt //! → require unlocked state (VaultLocked error if locked) //! → derive key, return result //! → optionally cache derived key //! //! Lock //! → zeroize all cached derived keys //! → zeroize seed //! → drop all sensitive material //! → vault returns to locked state //! ``` //! //! # Dispatch //! //! The vault uses **direct method calls** on `VaultServiceHandle` — no actor, //! no message enum, no channels, no serialization (ADR-025). The handle is //! `Arc>` — clone it, share it, call //! methods directly. All methods are synchronous (no `async`, no `.await`). //! The vault does not depend on `tokio` (ADR-025). //! //! # Assembly //! //! The `VaultServiceHandle` is assembled by the CLI binary. The CLI unlocks //! the vault at startup and injects derived/decrypted material into operation //! contexts. No handler crate accesses the vault directly — they receive keys //! through their operation context or via the call protocol. use std::sync::{Arc, RwLock}; use crate::cache::{CacheConfig, CachedKey, KeyCache}; use crate::derivation::{self, DerivationError, PATHS}; use crate::encryption::{self, EncryptedData, EncryptionKey}; use crate::mnemonic::{Language, Mnemonic, Seed}; use crate::protocol::{DerivedKey, KeyType}; /// Handle to a running VaultService for local (in-process) use. /// /// This is the primary API for local secret operations. It wraps the /// service state in an `Arc>` for thread-safe access. #[derive(Clone)] pub struct VaultServiceHandle { inner: Arc>, } /// Internal state of the secret service. struct VaultServiceInner { /// The mnemonic phrase, if unlocked. None if locked. mnemonic: Option, /// The master seed, if unlocked. None if locked. seed: Option, /// Whether the service is unlocked. unlocked: bool, /// TTL-based key cache with LRU eviction. cache: KeyCache, } /// Errors that can occur during vault operations. #[derive(Debug, thiserror::Error)] pub enum VaultServiceError { #[error("vault is locked; call Unlock first")] VaultLocked, #[error("vault is already unlocked")] AlreadyUnlocked, #[error("mnemonic error: {0}")] Mnemonic(String), #[error("derivation error: {0}")] Derivation(String), #[error("encryption error: {0}")] Encryption(String), #[error("invalid path: {0}")] InvalidPath(String), #[error("unsupported key type")] UnsupportedKeyType, } impl From for VaultServiceError { fn from(e: crate::mnemonic::MnemonicError) -> Self { VaultServiceError::Mnemonic(e.to_string()) } } impl From for VaultServiceError { fn from(e: DerivationError) -> Self { VaultServiceError::Derivation(e.to_string()) } } impl From for VaultServiceError { fn from(e: encryption::EncryptionError) -> Self { VaultServiceError::Encryption(e.to_string()) } } impl VaultServiceHandle { /// Create a new VaultServiceHandle in the locked state with default cache config. pub fn new() -> Self { Self::with_cache_config(CacheConfig::default()) } /// Create a new VaultServiceHandle with the given cache configuration. pub fn with_cache_config(config: CacheConfig) -> Self { Self { inner: Arc::new(RwLock::new(VaultServiceInner { mnemonic: None, seed: None, unlocked: false, cache: KeyCache::new(config), })), } } /// Unlock the service with an existing mnemonic phrase. /// /// The passphrase is the BIP39 password (may be empty string for none). /// After unlocking, derive and encrypt/decrypt operations are available. pub fn unlock(&self, phrase: &str, passphrase: Option<&str>) -> Result<(), VaultServiceError> { let mut inner = self.inner.write().unwrap(); if inner.unlocked { return Err(VaultServiceError::AlreadyUnlocked); } let mnemonic = Mnemonic::from_phrase(phrase, Language::English)?; let seed = mnemonic.to_seed(passphrase); inner.mnemonic = Some(mnemonic); inner.seed = Some(seed); inner.unlocked = true; Ok(()) } /// Unlock the service with a new randomly generated mnemonic. /// /// Returns the generated mnemonic phrase. Store this phrase securely — /// it is the root of trust for all derived keys. pub fn unlock_new(&self, word_count: usize) -> Result { let mut inner = self.inner.write().unwrap(); if inner.unlocked { return Err(VaultServiceError::AlreadyUnlocked); } let mnemonic = Mnemonic::generate(word_count)?; let seed = mnemonic.to_seed(None); let phrase = mnemonic.phrase().to_string(); inner.mnemonic = Some(mnemonic); inner.seed = Some(seed); inner.unlocked = true; Ok(phrase) } /// Lock the service, purging the seed and all cached derived keys. /// /// After locking, no derive/encrypt/decrypt operations are possible /// until `unlock` is called again. Calls `zeroize()` on all sensitive /// material per ADR-038. pub fn lock(&self) { let mut inner = self.inner.write().unwrap(); inner.cache.clear(); inner.seed = None; inner.mnemonic = None; inner.unlocked = false; } /// Check whether the service is currently unlocked. pub fn is_unlocked(&self) -> bool { self.inner.read().unwrap().unlocked } /// Derive an Ed25519 keypair at the given path. pub fn derive_ed25519(&self, path: &str) -> Result { let mut inner = self.inner.write().unwrap(); if !inner.unlocked { return Err(VaultServiceError::VaultLocked); } if let Some(cached) = inner.cache.get(path) { return Ok(DerivedKey { key_type: cached.key_type.clone(), private_key: cached.private_key.clone(), public_key: cached.public_key.clone(), }); } let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?; let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?; let private_key = key.private_key().to_vec(); let public_key = key.public_key().to_vec(); let cached = CachedKey::new(KeyType::Ed25519, private_key.clone(), public_key.clone()); inner.cache.insert(path, cached); Ok(DerivedKey { key_type: KeyType::Ed25519, private_key, public_key, }) } /// Derive an AES-256-GCM encryption key at the given path. pub fn derive_encryption_key(&self, path: &str) -> Result { let mut inner = self.inner.write().unwrap(); if !inner.unlocked { return Err(VaultServiceError::VaultLocked); } if let Some(cached) = inner.cache.get(path) { return Ok(DerivedKey { key_type: cached.key_type.clone(), private_key: cached.private_key.clone(), public_key: cached.public_key.clone(), }); } let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?; let key = derivation::derive_path_from_seed(seed.as_bytes(), path)?; let private_key = key.private_key().to_vec(); let public_key = key.public_key().to_vec(); let cached = CachedKey::new(KeyType::Aes256Gcm, private_key.clone(), public_key.clone()); inner.cache.insert(path, cached); Ok(DerivedKey { key_type: KeyType::Aes256Gcm, private_key, public_key, }) } /// Derive a secp256k1 (Ethereum) keypair at the given path. /// /// Uses BIP-0032 derivation (HMAC-SHA512 with "Bitcoin seed") when the /// `secp256k1` feature is enabled. Returns `UnsupportedKeyType` when the /// feature is disabled. pub fn derive_ethereum_key(&self, path: &str) -> Result { #[cfg(feature = "secp256k1")] { let mut inner = self.inner.write().unwrap(); if !inner.unlocked { return Err(VaultServiceError::VaultLocked); } if let Some(cached) = inner.cache.get(path) { return Ok(DerivedKey { key_type: cached.key_type.clone(), private_key: cached.private_key.clone(), public_key: cached.public_key.clone(), }); } let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?; let key = crate::ethereum::derive_secp256k1_path(seed.as_bytes(), path)?; let private_key = key.private_key().to_vec(); let public_key = key.public_key().to_vec(); let cached = CachedKey::new(KeyType::Secp256k1, private_key.clone(), public_key.clone()); inner.cache.insert(path, cached); Ok(DerivedKey { key_type: KeyType::Secp256k1, private_key, public_key, }) } #[cfg(not(feature = "secp256k1"))] { let _ = path; Err(VaultServiceError::UnsupportedKeyType) } } /// Encrypt plaintext using the derived encryption key. /// /// Uses the key at path `m/74'/2'/0'/0'` (PATHS::ENCRYPTION) by default. pub fn encrypt( &self, plaintext: &str, key_version: u32, ) -> Result { let mut inner = self.inner.write().unwrap(); if !inner.unlocked { return Err(VaultServiceError::VaultLocked); } let private_key = if let Some(cached) = inner.cache.get(PATHS::ENCRYPTION) { cached.private_key.clone() } else { let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?; let derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?; let pk = derived.private_key().to_vec(); let pubk = derived.public_key().to_vec(); let cached = CachedKey::new(KeyType::Aes256Gcm, pk.clone(), pubk); inner.cache.insert(PATHS::ENCRYPTION, cached); pk }; let enc_key = EncryptionKey::from_derived_bytes(&private_key, key_version); encryption::encrypt(plaintext, &enc_key).map_err(|e| e.into()) } /// Decrypt an EncryptedData blob using the derived encryption key. pub fn decrypt(&self, encrypted: &EncryptedData) -> Result { let mut inner = self.inner.write().unwrap(); if !inner.unlocked { return Err(VaultServiceError::VaultLocked); } let private_key = if let Some(cached) = inner.cache.get(PATHS::ENCRYPTION) { cached.private_key.clone() } else { let seed = inner.seed.as_ref().ok_or(VaultServiceError::VaultLocked)?; let derived = derivation::derive_path_from_seed(seed.as_bytes(), PATHS::ENCRYPTION)?; let pk = derived.private_key().to_vec(); let pubk = derived.public_key().to_vec(); let cached = CachedKey::new(KeyType::Aes256Gcm, pk.clone(), pubk); inner.cache.insert(PATHS::ENCRYPTION, cached); pk }; let enc_key = EncryptionKey::from_derived_bytes(&private_key, encrypted.key_version); encryption::decrypt(encrypted, &enc_key).map_err(|e| e.into()) } } impl Default for VaultServiceHandle { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_service_starts_locked() { let service = VaultServiceHandle::new(); assert!(!service.is_unlocked()); } #[test] fn test_unlock_new_generates_mnemonic() { let service = VaultServiceHandle::new(); let phrase = service.unlock_new(24).unwrap(); assert!(!phrase.is_empty()); assert!(service.is_unlocked()); } #[test] fn test_lock_purges_state() { let service = VaultServiceHandle::new(); service.unlock_new(24).unwrap(); assert!(service.is_unlocked()); service.lock(); assert!(!service.is_unlocked()); } #[test] fn test_derive_on_locked_fails() { let service = VaultServiceHandle::new(); let result = service.derive_ed25519(PATHS::IDENTITY); assert!(result.is_err()); } #[test] fn test_encrypt_on_locked_fails() { let service = VaultServiceHandle::new(); let result = service.encrypt("secret", 1); assert!(result.is_err()); } #[test] fn test_full_lifecycle() { let service = VaultServiceHandle::new(); assert!(!service.is_unlocked()); assert!(service.derive_ed25519(PATHS::IDENTITY).is_err()); let _phrase = service.unlock_new(24).unwrap(); assert!(service.is_unlocked()); let key = service.derive_ed25519(PATHS::IDENTITY).unwrap(); assert!(!key.private_key.is_empty()); service.lock(); assert!(!service.is_unlocked()); assert!(service.derive_ed25519(PATHS::IDENTITY).is_err()); } #[test] fn test_unlock_with_known_phrase() { let service = VaultServiceHandle::new(); let phrase = service.unlock_new(24).unwrap(); service.lock(); service.unlock(&phrase, None).unwrap(); assert!(service.is_unlocked()); } #[test] fn test_double_unlock_fails() { let service = VaultServiceHandle::new(); service.unlock_new(24).unwrap(); let result = service.unlock_new(12); assert!(result.is_err()); } #[test] fn test_encrypt_decrypt_lifecycle() { let service = VaultServiceHandle::new(); service.unlock_new(24).unwrap(); let plaintext = "my-api-key-12345"; let encrypted = service.encrypt(plaintext, 1).unwrap(); let decrypted = service.decrypt(&encrypted).unwrap(); assert_eq!(decrypted, plaintext); service.lock(); assert!(service.decrypt(&encrypted).is_err()); } #[cfg(feature = "secp256k1")] #[test] fn test_derive_ethereum_key_bip32() { let service = VaultServiceHandle::new(); service.unlock_new(24).unwrap(); let key = service.derive_ethereum_key(PATHS::ETHEREUM).unwrap(); assert_eq!(key.key_type, KeyType::Secp256k1); assert_eq!(key.private_key.len(), 32); assert_eq!(key.public_key.len(), 33); } #[cfg(feature = "secp256k1")] #[test] fn test_ethereum_key_differs_from_ed25519() { let service = VaultServiceHandle::new(); service.unlock_new(24).unwrap(); let eth_key = service.derive_ethereum_key(PATHS::ETHEREUM).unwrap(); let ed_key = service.derive_ed25519(PATHS::IDENTITY).unwrap(); assert_ne!(eth_key.private_key, ed_key.private_key); } #[cfg(not(feature = "secp256k1"))] #[test] fn test_derive_ethereum_key_unsupported_without_feature() { let service = VaultServiceHandle::new(); service.unlock_new(24).unwrap(); let result = service.derive_ethereum_key(PATHS::ETHEREUM); assert!(matches!(result, Err(VaultServiceError::UnsupportedKeyType))); } #[test] fn test_cache_hit_avoids_re_derivation() { let service = VaultServiceHandle::new(); service.unlock_new(24).unwrap(); let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap(); let key2 = service.derive_ed25519(PATHS::IDENTITY).unwrap(); assert_eq!(key1.private_key, key2.private_key); assert_eq!(key1.public_key, key2.public_key); let cache_len = service.inner.read().unwrap().cache.len(); assert_eq!(cache_len, 1); } #[test] fn test_cache_miss_derives_and_caches() { let service = VaultServiceHandle::new(); service.unlock_new(24).unwrap(); assert_eq!(service.inner.read().unwrap().cache.len(), 0); service.derive_ed25519(PATHS::IDENTITY).unwrap(); assert_eq!(service.inner.read().unwrap().cache.len(), 1); } #[test] fn test_expired_entry_evicted_on_access() { let config = crate::cache::CacheConfig::new(std::time::Duration::from_millis(5), 64); let service = VaultServiceHandle::with_cache_config(config); service.unlock_new(24).unwrap(); let key1 = service.derive_ed25519(PATHS::IDENTITY).unwrap(); assert_eq!(service.inner.read().unwrap().cache.len(), 1); std::thread::sleep(std::time::Duration::from_millis(10)); let key2 = service.derive_ed25519(PATHS::IDENTITY).unwrap(); assert_eq!(key1.private_key, key2.private_key); assert_eq!(service.inner.read().unwrap().cache.len(), 1); } #[test] fn test_lru_eviction_when_over_max_entries() { let config = crate::cache::CacheConfig::new(std::time::Duration::from_secs(3600), 2); let service = VaultServiceHandle::with_cache_config(config); service.unlock_new(24).unwrap(); service.derive_ed25519(PATHS::IDENTITY).unwrap(); service.derive_ed25519(PATHS::SSH_HOST).unwrap(); assert_eq!(service.inner.read().unwrap().cache.len(), 2); service.derive_ed25519(PATHS::ENCRYPTION).unwrap(); assert_eq!(service.inner.read().unwrap().cache.len(), 2); let mut inner = service.inner.write().unwrap(); assert!(inner.cache.get(PATHS::IDENTITY).is_none()); assert!(inner.cache.get(PATHS::SSH_HOST).is_some()); assert!(inner.cache.get(PATHS::ENCRYPTION).is_some()); } #[test] fn test_lock_clears_all_cache_entries() { let service = VaultServiceHandle::new(); service.unlock_new(24).unwrap(); service.derive_ed25519(PATHS::IDENTITY).unwrap(); service.derive_ed25519(PATHS::SSH_HOST).unwrap(); assert_eq!(service.inner.read().unwrap().cache.len(), 2); service.lock(); assert_eq!(service.inner.read().unwrap().cache.len(), 0); } #[test] fn test_encrypt_decrypt_uses_cached_encryption_key() { let service = VaultServiceHandle::new(); service.unlock_new(24).unwrap(); let plaintext = "cached-encryption-test"; let encrypted = service.encrypt(plaintext, 1).unwrap(); assert_eq!(service.inner.read().unwrap().cache.len(), 1); let decrypted = service.decrypt(&encrypted).unwrap(); assert_eq!(decrypted, plaintext); assert_eq!(service.inner.read().unwrap().cache.len(), 1); } #[test] fn test_unlock_with_passphrase_produces_different_seed() { let service_a = VaultServiceHandle::new(); let service_b = VaultServiceHandle::new(); let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; service_a.unlock(phrase, None).unwrap(); let key_a = service_a.derive_ed25519(PATHS::IDENTITY).unwrap(); service_a.lock(); service_a.unlock(phrase, Some("TREZOR")).unwrap(); let key_b = service_a.derive_ed25519(PATHS::IDENTITY).unwrap(); assert_ne!( key_a.private_key, key_b.private_key, "Unlock with passphrase must produce different seed than without" ); service_a.lock(); service_b.unlock(phrase, None).unwrap(); let key_c = service_b.derive_ed25519(PATHS::IDENTITY).unwrap(); assert_eq!( key_a.private_key, key_c.private_key, "Unlock with None passphrase must produce same seed as another None passphrase unlock" ); } }