feat: Add SessionManager for ergonomic key management
This commit is contained in:
parent
791752c3a5
commit
73233ec69b
@ -111,7 +111,7 @@ cat my_script.rhai | sal-cli run
|
|||||||
- [x] Unified async trait for key-value storage
|
- [x] Unified async trait for key-value storage
|
||||||
- [x] Native and WASM backends for kvstore
|
- [x] Native and WASM backends for kvstore
|
||||||
- [x] Shared Rust core for vault and evm_client
|
- [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
|
- [ ] CLI tool for local Rhai script execution
|
||||||
- [ ] Browser extension for secure script execution
|
- [ ] Browser extension for secure script execution
|
||||||
- [ ] Web app integration (postMessage/WebSocket)
|
- [ ] 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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
zeroize = "1.8.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
tempfile = "3.10"
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
tokio = { version = "1.0", features = ["rt", "macros"] }
|
tokio = { version = "1.0", features = ["rt", "macros"] }
|
||||||
async-std = { version = "1", features = ["attributes"] }
|
async-std = { version = "1", features = ["attributes"] }
|
||||||
|
@ -3,11 +3,13 @@
|
|||||||
|
|
||||||
//! vault: Cryptographic keyspace and operations
|
//! vault: Cryptographic keyspace and operations
|
||||||
|
|
||||||
mod data;
|
pub mod data;
|
||||||
pub use crate::data::{KeyType, KeyMetadata};
|
pub use crate::session::SessionManager;
|
||||||
|
pub use crate::data::{KeyType, KeyMetadata, KeyEntry};
|
||||||
mod error;
|
mod error;
|
||||||
mod crypto;
|
mod crypto;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use kvstore::KVStore;
|
use kvstore::KVStore;
|
||||||
|
@ -1,4 +1,228 @@
|
|||||||
//! Session manager for the vault crate (optional)
|
//! 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");
|
debug!("test_keypair_management_and_crypto started");
|
||||||
// Use NativeStore for native tests
|
// Use NativeStore for native tests
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
let mut vault = Vault::new(store);
|
let mut vault = Vault::new(store);
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user