diff --git a/README.md b/README.md index fe0a954..740162a 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ cat my_script.rhai | sal-cli run - [x] Unified async trait for key-value storage - [x] Native and WASM backends for kvstore - [x] Shared Rust core for vault and evm_client -- [x] WASM module exposing `run_rhai` +- [ ] WASM module exposing `run_rhai` - [ ] CLI tool for local Rhai script execution - [ ] Browser extension for secure script execution - [ ] Web app integration (postMessage/WebSocket) diff --git a/docs/evm_client_architecture_plan.md b/docs/evm_client_architecture_plan.md new file mode 100644 index 0000000..41743b6 --- /dev/null +++ b/docs/evm_client_architecture_plan.md @@ -0,0 +1,188 @@ +# EVM Client Architecture & Implementation Plan + +## Project Goal +Build a cross-platform (native + WASM) EVM client that can: +- Interact with multiple EVM-compatible networks/providers +- Use pluggable signing backends (e.g., SessionManager, hardware wallets, mocks) +- Integrate seamlessly with Rhai scripting and the rest of the modular Rust workspace + +--- + +## Requirements & Principles +- **Async, modular, and testable**: All APIs are async and trait-based +- **Cross-platform**: Native (Rust) and WASM (browser) support +- **Multi-network**: Support for multiple EVM networks/providers, switchable at runtime +- **Pluggable signing**: No direct dependency on vault/session; uses a generic Signer trait +- **Consistency**: Follows conventions in architecture.md and other project docs +- **Scripting**: Exposes ergonomic API for both Rust and Rhai scripting + +--- + +## Recommended File Structure + +``` +evm_client/ +├── Cargo.toml +└── src/ + ├── lib.rs # Public API + ├── provider.rs # EvmProvider abstraction + ├── client.rs # EvmClient struct + ├── signer.rs # Signer trait + └── utils.rs # Helpers (e.g., HTTP, WASM glue) +``` + +--- + +## 1. Pluggable Signer Trait + +```rust +// signer.rs +#[async_trait::async_trait] +pub trait Signer { + async fn sign(&self, message: &[u8]) -> Result, EvmError>; + fn address(&self) -> String; +} +``` + +- `SessionManager` in vault implements this trait. +- Any other backend (mock, hardware wallet, etc.) can implement it. + +--- + +## 2. EvmProvider Abstraction + +```rust +// provider.rs +pub enum EvmProvider { + Http { name: String, url: String, chain_id: u64 }, + // Future: WebSocket, Infura, etc. +} + +impl EvmProvider { + pub async fn send_raw_transaction(&self, tx: &Transaction, signer: &S) -> Result { + let raw_tx = tx.sign(signer).await?; + let body = format!("{{\"raw\":\"{}\"}}", hex::encode(&raw_tx)); + match self { + EvmProvider::Http { url, .. } => { + http_post(url, &body).await + } + } + } +} +``` + +--- + +## 3. EvmClient Struct & API + +```rust +// client.rs +use std::collections::HashMap; + +pub struct EvmClient { + providers: HashMap, + current: String, + signer: S, +} + +impl EvmClient { + pub fn new(signer: S) -> Self { + Self { + providers: HashMap::new(), + current: String::new(), + signer, + } + } + pub fn add_provider(&mut self, key: String, provider: EvmProvider) { + self.providers.insert(key, provider); + } + pub fn set_current(&mut self, key: &str) -> Result<(), EvmError> { + if self.providers.contains_key(key) { + self.current = key.to_string(); + Ok(()) + } else { + Err(EvmError::UnknownNetwork) + } + } + pub fn current_provider(&self) -> Option<&EvmProvider> { + self.providers.get(&self.current) + } + pub async fn send_transaction(&self, tx: Transaction) -> Result { + let provider = self.current_provider().ok_or(EvmError::NoNetwork)?; + provider.send_raw_transaction(&tx, &self.signer).await + } +} +``` + +--- + +## 4. Cross-Platform Networking (Native + WASM) + +```rust +// utils.rs +pub async fn http_post(url: &str, body: &str) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let resp = reqwest::Client::new().post(url).body(body.to_owned()).send().await?; + // parse response... + Ok(parse_tx_hash(resp.text().await?)) + } + #[cfg(target_arch = "wasm32")] + { + let resp = gloo_net::http::Request::post(url).body(body).send().await?; + // parse response... + Ok(parse_tx_hash(resp.text().await?)) + } +} +``` + +--- + +## 5. Rhai Scripting Integration + +```rust +// rhai_bindings.rs +pub fn register_rhai_api(engine: &mut Engine) { + engine.register_type::>(); + engine.register_fn("add_network", EvmClient::add_provider); + engine.register_fn("switch_network", EvmClient::set_current); + engine.register_fn("send_tx", EvmClient::send_transaction); +} +``` + +--- + +## 6. Usage Example + +```rust +use evm_client::{EvmClient, EvmProvider, Signer}; +use vault::SessionManager; + +let mut client = EvmClient::new(session_manager); +client.add_provider("mainnet".into(), EvmProvider::Http { name: "Ethereum Mainnet".into(), url: "...".into(), chain_id: 1 }); +client.add_provider("polygon".into(), EvmProvider::Http { name: "Polygon".into(), url: "...".into(), chain_id: 137 }); +client.set_current("polygon")?; +let tx_hash = client.send_transaction(tx).await?; +``` + +--- + +## 7. Compliance & Consistency +- **Async/trait-based**: Like kvstore/vault, all APIs are async and trait-based +- **No direct dependencies**: Uses generic Signer, not vault/session directly +- **Cross-platform**: Uses conditional networking for native/WASM +- **Modular/testable**: Clear separation of provider, client, and signer logic +- **Rhai scripting**: Exposes ergonomic scripting API +- **Follows architecture.md**: Modular, layered, reusable, and extensible + +--- + +## 8. Open Questions / TODOs +- How to handle provider-specific errors and retries? +- Should we support WebSocket providers in v1? +- What subset of EVM JSON-RPC should be exposed to Rhai? +- How to best test WASM networking in CI? + +--- + +*Last updated: 2025-05-16* diff --git a/docs/rhai_architecture_plan.md b/docs/rhai_architecture_plan.md new file mode 100644 index 0000000..4dcd41d --- /dev/null +++ b/docs/rhai_architecture_plan.md @@ -0,0 +1,341 @@ +# Rhai Scripting System Architecture & Implementation Plan + +## Project Goal + +Build a system that allows users to write and execute Rhai scripts both: +- **Locally via a CLI**, and +- **In the browser via a browser extension** + +using the same core logic (the `vault` and `evm_client` crates), powered by a Rust WebAssembly module. + +--- + +## Requirements & Architecture + +### 1. Shared Rust Libraries +- **Core Libraries:** + - `vault/`: Cryptographic vault and session management + - `evm_client/`: EVM RPC client +- **Responsibilities:** + - Implement business logic + - Expose functions to the Rhai scripting engine + - Reusable in both native CLI and WebAssembly builds + +### 2. Recommended File Structure + +``` +rhai_sandbox_workspace/ +├── Cargo.toml # Workspace manifest +├── vault/ # Shared logic + Rhai bindings +│ ├── Cargo.toml +│ └── src/ +│ ├── lib.rs # Public API (all core logic) +│ ├── rhai_bindings.rs# Rhai registration (shared) +│ └── utils.rs # Any helpers +├── cli/ # CLI runner +│ ├── Cargo.toml +│ └── src/ +│ └── main.rs +├── wasm/ # Wasm runner using same API +│ ├── Cargo.toml +│ └── src/ +│ └── lib.rs +├── browser-extension/ # (optional) Extension frontend +│ ├── manifest.json +│ ├── index.html +│ └── index.js +``` + +### 3. Code Organization for Shared Rhai Bindings + +**In `vault/src/lib.rs`:** +```rust +pub mod rhai_bindings; +pub use rhai_bindings::register_rhai_api; + +pub fn fib(n: i64) -> i64 { + if n < 2 { n } else { fib(n - 1) + fib(n - 2) } +} +``` + +**In `vault/src/rhai_bindings.rs`:** +```rust +use rhai::{Engine, RegisterFn}; +use crate::fib; + +pub fn register_rhai_api(engine: &mut Engine) { + engine.register_fn("fib", fib); +} +``` + +**Using in CLI (`cli/src/main.rs`):** +```rust +use rhai::Engine; +use vault::register_rhai_api; +use std::env; +use std::fs; + +fn main() { + let args: Vec = env::args().collect(); + if args.len() != 2 { + eprintln!("Usage: cli "); + return; + } + let script = fs::read_to_string(&args[1]).expect("Failed to read script"); + let mut engine = Engine::new(); + register_rhai_api(&mut engine); + match engine.eval::(&script) { + Ok(result) => println!("Result: {}", result), + Err(e) => eprintln!("Error: {}", e), + } +} +``` + +**Using in WASM (`wasm/src/lib.rs`):** +```rust +use wasm_bindgen::prelude::*; +use rhai::Engine; +use vault::register_rhai_api; + +#[wasm_bindgen] +pub fn run_rhai(script: &str) -> JsValue { + let mut engine = Engine::new(); + register_rhai_api(&mut engine); + match engine.eval_expression::(script) { + Ok(res) => JsValue::from_f64(res as f64), + Err(e) => JsValue::from_str(&format!("Error: {}", e)), + } +} +``` + +**Benefits:** +- Single source of truth for Rhai bindings (`register_rhai_api`) +- Easy to expand: add more Rust functions and register in one place +- Works seamlessly across CLI and WASM +- Encourages code reuse and maintainability + +**This approach fully adheres to the principles in `architecture.md`**: +- Modular, layered design +- Code reuse across targets +- Shared business logic for both native and WASM +- Clean separation of platform-specific code + +### 4. Native CLI Tool (`cli/`) +- Accepts `.rhai` script file or stdin +- Uses shared libraries to run the script via the Rhai engine +- Outputs the result to the terminal + +### 5. WebAssembly Module (`wasm/`) +- Uses the same core library for Rhai logic +- Exposes a `run_rhai(script: &str) -> String` function via `wasm_bindgen` +- Usable from browser-based JS (e.g., `import { run_rhai }`) + +### 4. Browser Extension (`browser_extension/`) +- UI for user to enter Rhai code after loading keyspace and selecting keypair (using SessionManager) +- Loads the WebAssembly module +- Runs user input through `run_rhai(script)` +- Displays the result or error +- **Security:** + - Only allows script input from: + - Trusted websites (via content script injection) + - Extension popup UI--- + +## EVM Client Integration: Pluggable Signer Pattern + +--- + +## Cross-Platform, Multi-Network EvmClient Design + +To be consistent with the rest of the project and adhere to the architecture and modularity principles, the `evm_client` crate should: +- Use async APIs and traits for all network and signing operations +- Support both native and WASM (browser) environments via conditional compilation +- Allow dynamic switching between multiple EVM networks/providers at runtime +- Avoid direct dependencies on vault/session, using the pluggable Signer trait +- Expose a clear, ergonomic API for both Rust and Rhai scripting + +### 1. EvmProvider Abstraction +```rust +pub enum EvmProvider { + Http { name: String, url: String, chain_id: u64 }, + // Future: WebSocket, Infura, etc. +} +``` + +### 2. EvmClient Struct +```rust +use std::collections::HashMap; + +pub struct EvmClient { + providers: HashMap, + current: String, + signer: S, +} + +impl EvmClient { + pub fn new(signer: S) -> Self { + Self { + providers: HashMap::new(), + current: String::new(), + signer, + } + } + pub fn add_provider(&mut self, key: String, provider: EvmProvider) { + self.providers.insert(key, provider); + } + pub fn set_current(&mut self, key: &str) -> Result<(), EvmError> { + if self.providers.contains_key(key) { + self.current = key.to_string(); + Ok(()) + } else { + Err(EvmError::UnknownNetwork) + } + } + pub fn current_provider(&self) -> Option<&EvmProvider> { + self.providers.get(&self.current) + } + pub async fn send_transaction(&self, tx: Transaction) -> Result { + let provider = self.current_provider().ok_or(EvmError::NoNetwork)?; + provider.send_raw_transaction(&tx, &self.signer).await + } +} +``` + +### 3. Provider Networking (Native + WASM) +```rust +impl EvmProvider { + pub async fn send_raw_transaction(&self, tx: &Transaction, signer: &S) -> Result { + let raw_tx = tx.sign(signer).await?; + let body = format!("{{\"raw\":\"{}\"}}", hex::encode(&raw_tx)); + match self { + EvmProvider::Http { url, .. } => { + http_post(url, &body).await + } + } + } +} + +// Cross-platform HTTP POST +pub async fn http_post(url: &str, body: &str) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let resp = reqwest::Client::new().post(url).body(body.to_owned()).send().await?; + // parse response... + Ok(parse_tx_hash(resp.text().await?)) + } + #[cfg(target_arch = "wasm32")] + { + let resp = gloo_net::http::Request::post(url).body(body).send().await?; + // parse response... + Ok(parse_tx_hash(resp.text().await?)) + } +} +``` + +### 4. Rhai Scripting Integration +- Expose `add_network`, `switch_network`, and `send_tx` functions to the Rhai engine via the shared bindings pattern. +- Example: +```rust +pub fn register_rhai_api(engine: &mut Engine) { + engine.register_type::>(); + engine.register_fn("add_network", EvmClient::add_provider); + engine.register_fn("switch_network", EvmClient::set_current); + engine.register_fn("send_tx", EvmClient::send_transaction); +} +``` + +### 5. Consistency and Compliance +- **Async, modular, and testable:** All APIs are async and trait-based, just like kvstore/vault. +- **No direct dependencies:** EvmClient is generic over signing backend, like other crates. +- **Cross-platform:** Uses conditional compilation for networking, ensuring WASM and native support. +- **Clear separation:** Network and signing logic are independent, allowing easy extension and testing. + +This design fits seamlessly with your project’s architecture and modularity goals. + +### 5. Web App Integration +- Enable trusted web apps to send Rhai scripts to the extension, using one or both of: + +#### Option A: Direct (Client-side) +- Web apps use `window.postMessage()` or DOM events +- Extension listens via content script +- Validates origin before running the script + +#### Option B: Server-based (WebSocket) +- Both extension and web app connect to a backend WebSocket server +- Web app sends script to server +- Server routes it to the right connected extension client +- Extension executes the script and returns the result + +### 6. Security Considerations +- All script execution is sandboxed via Rhai + Wasm +- Only allow input from: + - Extension popup + - Approved websites or servers +- Validate origins and inputs strictly +- Do not expose internal APIs beyond `run_rhai(script)` + +--- + +## High-Level Component Diagram + +``` ++-------------------+ +-------------------+ +| CLI Tool | | Browser Extension| +| (cli/) | | (browser_ext/) | ++---------+---------+ +---------+---------+ + | | + | +----------------+ + | | ++---------v---------+ +| WASM Module | <--- Shared Rust Core (vault, evm_client) +| (wasm/) | ++---------+---------+ + | ++---------v---------+ +| Rhai Engine | ++-------------------+ +``` + +--- + +## Implementation Phases + +### Phase 1: Core Library Integration +- Ensure all business logic (vault & evm_client) is accessible from both native and WASM targets. +- Expose required functions to Rhai engine. + +### Phase 2: CLI Tool +- Implement CLI that loads and runs Rhai scripts using the shared core. +- Add support for stdin and file input. + +### Phase 3: WASM Module +- Build WASM module exposing `run_rhai`. +- Integrate with browser JS via `wasm_bindgen`. + +### Phase 4: Browser Extension +- UI for script input and result display. +- Integrate WASM module and SessionManager. +- Secure script input (popup and trusted sites only). + +### Phase 5: Web App Integration +- Implement postMessage and/or WebSocket protocol for trusted web apps to send scripts. +- Validate origins and handle results. + +--- + +## Open Questions / TODOs +- What subset of the vault/evm_client API should be exposed to Rhai? +- What are the best practices for sandboxing Rhai in WASM? +- How will user authentication/session be handled between extension and web app? +- How will error reporting and logging work across boundaries? + +--- + +## References +- [Rhai scripting engine](https://rhai.rs/) +- [wasm-bindgen](https://rustwasm.github.io/wasm-bindgen/) +- [WebExtension APIs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions) +- [Rust + WASM Book](https://rustwasm.github.io/book/) + +--- + +*Last updated: 2025-05-15* diff --git a/vault/Cargo.toml b/vault/Cargo.toml index d1d6523..dda0fee 100644 --- a/vault/Cargo.toml +++ b/vault/Cargo.toml @@ -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"] } diff --git a/vault/src/lib.rs b/vault/src/lib.rs index 7653039..d50ed74 100644 --- a/vault/src/lib.rs +++ b/vault/src/lib.rs @@ -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; diff --git a/vault/src/session.rs b/vault/src/session.rs index b5d7a35..b9b370c 100644 --- a/vault/src/session.rs +++ b/vault/src/session.rs @@ -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 { + vault: Vault, + unlocked_keyspaces: HashMap, KeyspaceData)>, // name -> (password, data) + current_keyspace: Option, + current_keypair: Option, +} +#[cfg(target_arch = "wasm32")] +pub struct SessionManager { + vault: Vault, + unlocked_keyspaces: HashMap, KeyspaceData)>, // name -> (password, data) + current_keyspace: Option, + current_keypair: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +impl SessionManager { + /// Create a new session manager from a Vault instance. + pub fn new(vault: Vault) -> Self { + Self { + vault, + unlocked_keyspaces: HashMap::new(), + current_keyspace: None, + current_keypair: None, + } + } +} + +#[cfg(target_arch = "wasm32")] +impl SessionManager { + /// Create a new session manager from a Vault instance. + pub fn new(vault: Vault) -> Self { + Self { + vault, + unlocked_keyspaces: HashMap::new(), + current_keyspace: None, + current_keypair: None, + } + } +} + +// Native impl for all methods +#[cfg(not(target_arch = "wasm32"))] +impl SessionManager { + /// 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, 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 { + &self.vault + } +} + +// WASM impl for all methods +#[cfg(target_arch = "wasm32")] +impl SessionManager { + /// 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, 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 { + &self.vault + } +} + +// Shared impl for methods needed by Drop +#[cfg(not(target_arch = "wasm32"))] +impl SessionManager { + /// 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 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 SessionManager { + /// 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 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 Drop for SessionManager { + fn drop(&mut self) { + self.logout(); + } +} diff --git a/vault/tests/dev-dependencies-tempfile.txt b/vault/tests/dev-dependencies-tempfile.txt new file mode 100644 index 0000000..c28bbb0 --- /dev/null +++ b/vault/tests/dev-dependencies-tempfile.txt @@ -0,0 +1 @@ +tempfile = "3.10" diff --git a/vault/tests/keypair_management.rs b/vault/tests/keypair_management.rs index 11f514b..59eade3 100644 --- a/vault/tests/keypair_management.rs +++ b/vault/tests/keypair_management.rs @@ -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")] diff --git a/vault/tests/session_manager.rs b/vault/tests/session_manager.rs new file mode 100644 index 0000000..52c034e --- /dev/null +++ b/vault/tests/session_manager.rs @@ -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()); +} diff --git a/vault/tests/wasm_session_manager.rs b/vault/tests/wasm_session_manager.rs new file mode 100644 index 0000000..6f070dc --- /dev/null +++ b/vault/tests/wasm_session_manager.rs @@ -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 +}