feat: Add SessionManager for ergonomic key management
This commit is contained in:
		| @@ -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) | ||||
|   | ||||
							
								
								
									
										188
									
								
								docs/evm_client_architecture_plan.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								docs/evm_client_architecture_plan.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Vec<u8>, 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<S: Signer>(&self, tx: &Transaction, signer: &S) -> Result<TxHash, EvmError> { | ||||
|         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<S: Signer> { | ||||
|     providers: HashMap<String, EvmProvider>, | ||||
|     current: String, | ||||
|     signer: S, | ||||
| } | ||||
|  | ||||
| impl<S: Signer> EvmClient<S> { | ||||
|     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<TxHash, EvmError> { | ||||
|         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<TxHash, EvmError> { | ||||
|     #[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::<EvmClient<MySigner>>(); | ||||
|     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* | ||||
							
								
								
									
										341
									
								
								docs/rhai_architecture_plan.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										341
									
								
								docs/rhai_architecture_plan.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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<String> = env::args().collect(); | ||||
|     if args.len() != 2 { | ||||
|         eprintln!("Usage: cli <script.rhai>"); | ||||
|         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::<i64>(&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::<i64>(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<S: Signer> { | ||||
|     providers: HashMap<String, EvmProvider>, | ||||
|     current: String, | ||||
|     signer: S, | ||||
| } | ||||
|  | ||||
| impl<S: Signer> EvmClient<S> { | ||||
|     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<TxHash, EvmError> { | ||||
|         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<S: Signer>(&self, tx: &Transaction, signer: &S) -> Result<TxHash, EvmError> { | ||||
|         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<TxHash, EvmError> { | ||||
|     #[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::<EvmClient<MySigner>>(); | ||||
|     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* | ||||
| @@ -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