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