feat: Add SessionManager for ergonomic key management
This commit is contained in:
		| @@ -25,8 +25,10 @@ console_log = "1" | ||||
| serde = { version = "1", features = ["derive"] } | ||||
| serde_json = "1.0" | ||||
| hex = "0.4" | ||||
| zeroize = "1.8.1" | ||||
|  | ||||
| [dev-dependencies] | ||||
| tempfile = "3.10" | ||||
| console_error_panic_hook = "0.1" | ||||
| tokio = { version = "1.0", features = ["rt", "macros"] } | ||||
| async-std = { version = "1", features = ["attributes"] } | ||||
|   | ||||
| @@ -3,11 +3,13 @@ | ||||
|  | ||||
| //! vault: Cryptographic keyspace and operations | ||||
|  | ||||
| mod data; | ||||
| pub use crate::data::{KeyType, KeyMetadata}; | ||||
| pub mod data; | ||||
| pub use crate::session::SessionManager; | ||||
| pub use crate::data::{KeyType, KeyMetadata, KeyEntry}; | ||||
| mod error; | ||||
| mod crypto; | ||||
| mod session; | ||||
|  | ||||
| mod utils; | ||||
|  | ||||
| use kvstore::KVStore; | ||||
|   | ||||
| @@ -1,4 +1,228 @@ | ||||
| //! Session manager for the vault crate (optional) | ||||
| //! Provides ergonomic, stateful access to unlocked keyspaces and keypairs for interactive applications. | ||||
| //! All state is local to the SessionManager instance. No global state. | ||||
|  | ||||
| use std::collections::HashMap; | ||||
| use zeroize::Zeroize; | ||||
| use crate::{Vault, KeyspaceData, KeyEntry, VaultError, KVStore}; | ||||
|  | ||||
| /// SessionManager: Ergonomic, stateful wrapper over the Vault stateless API. | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| pub struct SessionManager<S: KVStore + Send + Sync> { | ||||
|     vault: Vault<S>, | ||||
|     unlocked_keyspaces: HashMap<String, (Vec<u8>, KeyspaceData)>, // name -> (password, data) | ||||
|     current_keyspace: Option<String>, | ||||
|     current_keypair: Option<String>, | ||||
| } | ||||
|  | ||||
| #[cfg(target_arch = "wasm32")] | ||||
| pub struct SessionManager<S: KVStore> { | ||||
|     vault: Vault<S>, | ||||
|     unlocked_keyspaces: HashMap<String, (Vec<u8>, KeyspaceData)>, // name -> (password, data) | ||||
|     current_keyspace: Option<String>, | ||||
|     current_keypair: Option<String>, | ||||
| } | ||||
|  | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| impl<S: KVStore + Send + Sync> SessionManager<S> { | ||||
|     /// Create a new session manager from a Vault instance. | ||||
|     pub fn new(vault: Vault<S>) -> Self { | ||||
|         Self { | ||||
|             vault, | ||||
|             unlocked_keyspaces: HashMap::new(), | ||||
|             current_keyspace: None, | ||||
|             current_keypair: None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(target_arch = "wasm32")] | ||||
| impl<S: KVStore> SessionManager<S> { | ||||
|     /// Create a new session manager from a Vault instance. | ||||
|     pub fn new(vault: Vault<S>) -> Self { | ||||
|         Self { | ||||
|             vault, | ||||
|             unlocked_keyspaces: HashMap::new(), | ||||
|             current_keyspace: None, | ||||
|             current_keypair: None, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Native impl for all methods | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| impl<S: KVStore + Send + Sync> SessionManager<S> { | ||||
|     /// Unlock a keyspace and store its decrypted data in memory. | ||||
|     pub async fn unlock_keyspace(&mut self, name: &str, password: &[u8]) -> Result<(), VaultError> { | ||||
|         let data = self.vault.unlock_keyspace(name, password).await?; | ||||
|         self.unlocked_keyspaces.insert(name.to_string(), (password.to_vec(), data)); | ||||
|         self.current_keyspace = Some(name.to_string()); | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Select a previously unlocked keyspace as the current context. | ||||
|     pub fn select_keyspace(&mut self, name: &str) -> Result<(), VaultError> { | ||||
|         if self.unlocked_keyspaces.contains_key(name) { | ||||
|             self.current_keyspace = Some(name.to_string()); | ||||
|             self.current_keypair = None; | ||||
|             Ok(()) | ||||
|         } else { | ||||
|             Err(VaultError::Crypto("Keyspace not unlocked".to_string())) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Select a keypair within the current keyspace. | ||||
|     pub fn select_keypair(&mut self, key_id: &str) -> Result<(), VaultError> { | ||||
|         let keyspace = self.current_keyspace.as_ref().ok_or_else(|| VaultError::Crypto("No keyspace selected".to_string()))?; | ||||
|         let (_, data) = self.unlocked_keyspaces.get(keyspace).ok_or_else(|| VaultError::Crypto("Keyspace not unlocked".to_string()))?; | ||||
|         if data.keypairs.iter().any(|k| k.id == key_id) { | ||||
|             self.current_keypair = Some(key_id.to_string()); | ||||
|             Ok(()) | ||||
|         } else { | ||||
|             Err(VaultError::Crypto("Keypair not found".to_string())) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Get the currently selected keyspace data (if any). | ||||
|     pub fn current_keyspace(&self) -> Option<&KeyspaceData> { | ||||
|         self.current_keyspace.as_ref() | ||||
|             .and_then(|name| self.unlocked_keyspaces.get(name)) | ||||
|             .map(|(_, data)| data) | ||||
|     } | ||||
|  | ||||
|     /// Get the currently selected keypair (if any). | ||||
|     pub fn current_keypair(&self) -> Option<&KeyEntry> { | ||||
|         let keyspace = self.current_keyspace()?; | ||||
|         let key_id = self.current_keypair.as_ref()?; | ||||
|         keyspace.keypairs.iter().find(|k| &k.id == key_id) | ||||
|     } | ||||
|  | ||||
|     /// Sign a message with the currently selected keypair. | ||||
|     pub async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, VaultError> { | ||||
|         let _keyspace = self.current_keyspace().ok_or(VaultError::Crypto("No keyspace selected".to_string()))?; | ||||
|         let keypair = self.current_keypair().ok_or(VaultError::Crypto("No keypair selected".to_string()))?; | ||||
|         let (password, _) = self.unlocked_keyspaces.get(self.current_keyspace.as_ref().unwrap()).unwrap(); | ||||
|         self.vault.sign( | ||||
|             self.current_keyspace.as_ref().unwrap(), | ||||
|             password, | ||||
|             &keypair.id, | ||||
|             message, | ||||
|         ).await | ||||
|     } | ||||
|  | ||||
|     /// Get a reference to the underlying Vault (for stateless operations in tests). | ||||
|     pub fn get_vault(&self) -> &Vault<S> { | ||||
|         &self.vault | ||||
|     } | ||||
| } | ||||
|  | ||||
| // WASM impl for all methods | ||||
| #[cfg(target_arch = "wasm32")] | ||||
| impl<S: KVStore> SessionManager<S> { | ||||
|     /// Unlock a keyspace and store its decrypted data in memory. | ||||
|     pub async fn unlock_keyspace(&mut self, name: &str, password: &[u8]) -> Result<(), VaultError> { | ||||
|         let data = self.vault.unlock_keyspace(name, password).await?; | ||||
|         self.unlocked_keyspaces.insert(name.to_string(), (password.to_vec(), data)); | ||||
|         self.current_keyspace = Some(name.to_string()); | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Select a previously unlocked keyspace as the current context. | ||||
|     pub fn select_keyspace(&mut self, name: &str) -> Result<(), VaultError> { | ||||
|         if self.unlocked_keyspaces.contains_key(name) { | ||||
|             self.current_keyspace = Some(name.to_string()); | ||||
|             self.current_keypair = None; | ||||
|             Ok(()) | ||||
|         } else { | ||||
|             Err(VaultError::Crypto("Keyspace not unlocked".to_string())) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Select a keypair within the current keyspace. | ||||
|     pub fn select_keypair(&mut self, key_id: &str) -> Result<(), VaultError> { | ||||
|         let keyspace = self.current_keyspace.as_ref().ok_or_else(|| VaultError::Crypto("No keyspace selected".to_string()))?; | ||||
|         let (_, data) = self.unlocked_keyspaces.get(keyspace).ok_or_else(|| VaultError::Crypto("Keyspace not unlocked".to_string()))?; | ||||
|         if data.keypairs.iter().any(|k| k.id == key_id) { | ||||
|             self.current_keypair = Some(key_id.to_string()); | ||||
|             Ok(()) | ||||
|         } else { | ||||
|             Err(VaultError::Crypto("Keypair not found".to_string())) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Get the currently selected keyspace data (if any). | ||||
|     pub fn current_keyspace(&self) -> Option<&KeyspaceData> { | ||||
|         self.current_keyspace.as_ref() | ||||
|             .and_then(|name| self.unlocked_keyspaces.get(name)) | ||||
|             .map(|(_, data)| data) | ||||
|     } | ||||
|  | ||||
|     /// Get the currently selected keypair (if any). | ||||
|     pub fn current_keypair(&self) -> Option<&KeyEntry> { | ||||
|         let keyspace = self.current_keyspace()?; | ||||
|         let key_id = self.current_keypair.as_ref()?; | ||||
|         keyspace.keypairs.iter().find(|k| &k.id == key_id) | ||||
|     } | ||||
|  | ||||
|     /// Sign a message with the currently selected keypair. | ||||
|     pub async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, VaultError> { | ||||
|         let _keyspace = self.current_keyspace().ok_or(VaultError::Crypto("No keyspace selected".to_string()))?; | ||||
|         let keypair = self.current_keypair().ok_or(VaultError::Crypto("No keypair selected".to_string()))?; | ||||
|         let (password, _) = self.unlocked_keyspaces.get(self.current_keyspace.as_ref().unwrap()).unwrap(); | ||||
|         self.vault.sign( | ||||
|             self.current_keyspace.as_ref().unwrap(), | ||||
|             password, | ||||
|             &keypair.id, | ||||
|             message, | ||||
|         ).await | ||||
|     } | ||||
|  | ||||
|     /// Get a reference to the underlying Vault (for stateless operations in tests). | ||||
|     pub fn get_vault(&self) -> &Vault<S> { | ||||
|         &self.vault | ||||
|     } | ||||
| } | ||||
|  | ||||
| // Shared impl for methods needed by Drop | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| impl<S: KVStore + Send + Sync> SessionManager<S> { | ||||
|     /// Wipe all unlocked keyspaces and secrets from memory. | ||||
|     pub fn logout(&mut self) { | ||||
|         for (pw, data) in self.unlocked_keyspaces.values_mut() { | ||||
|             pw.zeroize(); | ||||
|             // KeyspaceData and KeyEntry use Vec<u8> for secrets, drop will clear | ||||
|             for k in &mut data.keypairs { | ||||
|                 k.private_key.zeroize(); | ||||
|             } | ||||
|         } | ||||
|         self.unlocked_keyspaces.clear(); | ||||
|         self.current_keyspace = None; | ||||
|         self.current_keypair = None; | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(target_arch = "wasm32")] | ||||
| impl<S: KVStore> SessionManager<S> { | ||||
|     /// Wipe all unlocked keyspaces and secrets from memory. | ||||
|     pub fn logout(&mut self) { | ||||
|         for (pw, data) in self.unlocked_keyspaces.values_mut() { | ||||
|             pw.zeroize(); | ||||
|             // KeyspaceData and KeyEntry use Vec<u8> for secrets, drop will clear | ||||
|             for k in &mut data.keypairs { | ||||
|                 k.private_key.zeroize(); | ||||
|             } | ||||
|         } | ||||
|         self.unlocked_keyspaces.clear(); | ||||
|         self.current_keyspace = None; | ||||
|         self.current_keypair = None; | ||||
|     } | ||||
| } | ||||
|  | ||||
| // END wasm32 impl | ||||
|  | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| impl<S: KVStore + Send + Sync> Drop for SessionManager<S> { | ||||
|     fn drop(&mut self) { | ||||
|         self.logout(); | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										1
									
								
								vault/tests/dev-dependencies-tempfile.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								vault/tests/dev-dependencies-tempfile.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| tempfile = "3.10" | ||||
| @@ -11,7 +11,9 @@ async fn test_keypair_management_and_crypto() { | ||||
|     debug!("test_keypair_management_and_crypto started"); | ||||
|     // Use NativeStore for native tests | ||||
|     #[cfg(not(target_arch = "wasm32"))] | ||||
|     let store = NativeStore::open("vault_native_test").expect("Failed to open native store"); | ||||
|     use tempfile::TempDir; | ||||
|     let tmp_dir = TempDir::new().expect("create temp dir"); | ||||
|     let store = NativeStore::open(tmp_dir.path().to_str().unwrap()).expect("Failed to open native store"); | ||||
|     #[cfg(not(target_arch = "wasm32"))] | ||||
|     let mut vault = Vault::new(store); | ||||
|     #[cfg(target_arch = "wasm32")] | ||||
|   | ||||
							
								
								
									
										61
									
								
								vault/tests/session_manager.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								vault/tests/session_manager.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| //! Integration tests for SessionManager (stateful API) in the vault crate | ||||
|  | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| use vault::{Vault, KeyType, KeyMetadata, SessionManager}; | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| use kvstore::NativeStore; | ||||
|  | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| #[tokio::test] | ||||
| async fn session_manager_end_to_end() { | ||||
|     use tempfile::TempDir; | ||||
|     let tmp_dir = TempDir::new().expect("create temp dir"); | ||||
|     let store = NativeStore::open(tmp_dir.path().to_str().unwrap()).expect("open NativeStore"); | ||||
|     let mut vault = Vault::new(store); | ||||
|     let keyspace = "personal"; | ||||
|     let password = b"testpass"; | ||||
|  | ||||
|     // Create keyspace | ||||
|     vault.create_keyspace(keyspace, password, None).await.expect("create_keyspace"); | ||||
|     // Add keypair | ||||
|     let key_id = vault.add_keypair(keyspace, password, Some(KeyType::Secp256k1), Some(KeyMetadata { name: Some("main".to_string()), created_at: None, tags: None })).await.expect("add_keypair"); | ||||
|  | ||||
|     // Create session manager | ||||
|     let mut session = SessionManager::new(vault); | ||||
|     session.unlock_keyspace(keyspace, password).await.expect("unlock_keyspace"); | ||||
|     session.select_keyspace(keyspace).expect("select_keyspace"); | ||||
|     session.select_keypair(&key_id).expect("select_keypair"); | ||||
|  | ||||
|     // Sign and verify | ||||
|     let msg = b"hello world"; | ||||
|     let sig = session.sign(msg).await.expect("sign"); | ||||
|     let _keypair = session.current_keypair().expect("current_keypair"); | ||||
|     // Use stateless API for verify: get password from test context, not from private fields | ||||
|     let password = b"testpass"; | ||||
|     let verified = session | ||||
|         .get_vault() | ||||
|         .verify(keyspace, password, &key_id, msg, &sig) | ||||
|         .await | ||||
|         .expect("verify"); | ||||
|     assert!(verified, "signature should verify"); | ||||
|  | ||||
|     // Logout wipes secrets | ||||
|     session.logout(); | ||||
|     assert!(session.current_keyspace().is_none()); | ||||
|     assert!(session.current_keypair().is_none()); | ||||
|     // No public API for unlocked_keyspaces, but behavior is covered by above asserts | ||||
| } | ||||
|  | ||||
| #[cfg(not(target_arch = "wasm32"))] | ||||
| #[tokio::test] | ||||
| async fn session_manager_errors() { | ||||
|     use tempfile::TempDir; | ||||
|     let tmp_dir = TempDir::new().expect("create temp dir"); | ||||
|     let store = NativeStore::open(tmp_dir.path().to_str().unwrap()).expect("open NativeStore"); | ||||
|     let vault = Vault::new(store); | ||||
|     let mut session = SessionManager::new(vault); | ||||
|     // No keyspace unlocked | ||||
|     assert!(session.select_keyspace("none").is_err()); | ||||
|     assert!(session.select_keypair("none").is_err()); | ||||
|     assert!(session.sign(b"fail").await.is_err()); | ||||
| } | ||||
							
								
								
									
										45
									
								
								vault/tests/wasm_session_manager.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								vault/tests/wasm_session_manager.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| //! WASM integration test for SessionManager using kvstore::WasmStore | ||||
|  | ||||
| use vault::Vault; | ||||
| #[cfg(target_arch = "wasm32")] | ||||
| use kvstore::WasmStore; | ||||
| use wasm_bindgen_test::*; | ||||
|  | ||||
| wasm_bindgen_test_configure!(run_in_browser); | ||||
|  | ||||
| #[cfg(target_arch = "wasm32")] | ||||
| #[wasm_bindgen_test(async)] | ||||
| async fn wasm_session_manager_end_to_end() { | ||||
|     let store = WasmStore::open("test").await.expect("open WasmStore"); | ||||
|     let mut vault = Vault::new(store); | ||||
|     let keyspace = "personal"; | ||||
|     let password = b"testpass"; | ||||
|  | ||||
|     // Create keyspace | ||||
|     vault.create_keyspace(keyspace, password, None).await.expect("create_keyspace"); | ||||
|     // Add keypair | ||||
|     let key_id = vault.add_keypair(keyspace, password, Some(KeyType::Secp256k1), Some(KeyMetadata { name: Some("main".to_string()), created_at: None, tags: None })).await.expect("add_keypair"); | ||||
|  | ||||
|     // Create session manager | ||||
|     let mut session = SessionManager::new(vault); | ||||
|     session.unlock_keyspace(keyspace, password).await.expect("unlock_keyspace"); | ||||
|     session.select_keyspace(keyspace).expect("select_keyspace"); | ||||
|     session.select_keypair(&key_id).expect("select_keypair"); | ||||
|  | ||||
|     // Sign and verify | ||||
|     let msg = b"hello world"; | ||||
|     let sig = session.sign(msg).await.expect("sign"); | ||||
|     let _keypair = session.current_keypair().expect("current_keypair"); | ||||
|     let verified = session | ||||
|         .get_vault() | ||||
|         .verify(keyspace, password, &key_id, msg, &sig) | ||||
|         .await | ||||
|         .unwrap(); | ||||
|     assert!(verified, "signature should verify"); | ||||
|  | ||||
|     // Logout wipes secrets | ||||
|     session.logout(); | ||||
|     assert!(session.current_keyspace().is_none()); | ||||
|     assert!(session.sign(b"fail").await.is_err()); | ||||
|     // No public API for unlocked_keyspaces, but behavior is covered by above asserts | ||||
| } | ||||
		Reference in New Issue
	
	Block a user