Compare commits
22 Commits
main
...
main_brows
Author | SHA1 | Date | |
---|---|---|---|
|
b0b6359be1 | ||
|
536c077fbf | ||
|
31975aa9d3 | ||
|
087720f61f | ||
|
c2c5be3409 | ||
|
37764e3861 | ||
|
5bc205b2f7 | ||
|
beba294054 | ||
|
0224755ba3 | ||
|
44b4dfd6a7 | ||
|
1e52c572d2 | ||
|
1f2d7e3fec | ||
|
ed76ba3d8d | ||
13945a8725 | |||
19f46d6edb | |||
85a15edaec | |||
017fc897f4 | |||
03533f9216 | |||
73233ec69b | |||
791752c3a5 | |||
cea2d7e655 | |||
7d7f94f114 |
2
.cargo/config.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[target.wasm32-unknown-unknown]
|
||||||
|
rustflags = ["--cfg", 'getrandom_backend="wasm_js"']
|
4
.gitignore
vendored
@ -5,3 +5,7 @@
|
|||||||
# Ignore IDE files
|
# Ignore IDE files
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Ignore test databases
|
||||||
|
/vault/vault_native_test/
|
||||||
|
node_modules/
|
@ -3,5 +3,7 @@ resolver = "2"
|
|||||||
members = [
|
members = [
|
||||||
"kvstore",
|
"kvstore",
|
||||||
"vault",
|
"vault",
|
||||||
"evm_client"
|
"evm_client",
|
||||||
|
"wasm_app",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
31
Makefile
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Makefile to run all browser (WASM) tests for kvstore, vault, and evm_client
|
||||||
|
|
||||||
|
BROWSER ?= firefox
|
||||||
|
|
||||||
|
.PHONY: test-browser-all test-browser-kvstore test-browser-vault test-browser-evm-client build-wasm-app build-hero-vault-extension
|
||||||
|
|
||||||
|
test-browser-all: test-browser-kvstore test-browser-vault test-browser-evm-client
|
||||||
|
|
||||||
|
# Run browser tests for kvstore
|
||||||
|
|
||||||
|
test-browser-kvstore:
|
||||||
|
cd kvstore && wasm-pack test --headless --$(BROWSER)
|
||||||
|
|
||||||
|
# Run browser tests for vault
|
||||||
|
|
||||||
|
test-browser-vault:
|
||||||
|
cd vault && wasm-pack test --headless --$(BROWSER)
|
||||||
|
|
||||||
|
# Run browser tests for evm_client
|
||||||
|
|
||||||
|
test-browser-evm-client:
|
||||||
|
cd evm_client && wasm-pack test --headless --$(BROWSER)
|
||||||
|
|
||||||
|
# Build wasm_app as a WASM library
|
||||||
|
build-wasm-app:
|
||||||
|
cd wasm_app && wasm-pack build --target web
|
||||||
|
|
||||||
|
# Build Hero Vault extension: wasm, copy, then extension
|
||||||
|
build-hero-vault-extension:
|
||||||
|
cd wasm_app && wasm-pack build --target web
|
||||||
|
cd hero_vault_extension && npm run build
|
215
README.md
@ -1,24 +1,134 @@
|
|||||||
# Modular Rust System: Key-Value Store, Vault, and EVM Client
|
# Modular Rust System: Key-Value Store, Vault, and EVM Client
|
||||||
|
|
||||||
This repository implements a modular, async, and cross-platform cryptographic stack in Rust. It is designed for use in both native (desktop/server) and WASM (browser) environments, supporting secure storage, cryptographic operations, and EVM (Ethereum) client functionality.
|
A modular, async, and cross-platform cryptographic stack in Rust. Built for both native (desktop/server) and WASM (browser) environments, this system provides secure storage, cryptographic operations, and Ethereum (EVM) client functionality—all with a focus on extensibility, testability, and scripting.
|
||||||
|
|
||||||
## Crate Overview
|
## Crate Overview
|
||||||
|
|
||||||
- **kvstore/**: Async key-value store trait and implementations (native: `sled`, WASM: IndexedDB).
|
- **kvstore/**: Async key-value store trait and implementations (native: `sled`, WASM: IndexedDB)
|
||||||
- **vault/**: Cryptographic vault for managing encrypted keyspaces and key operations. Uses `kvstore` for persistence.
|
- **vault/**: Cryptographic vault for encrypted keyspaces, key management, and signing; uses `kvstore` for persistence
|
||||||
- **evm_client/**: EVM RPC client, integrates with `vault` for signing and secure key management.
|
- **evm_client/**: Async EVM RPC client, integrates with `vault` for secure signing; supports trait-based signers and modular providers
|
||||||
- **cli_app/**: (Planned) Command-line interface for scripting and automation.
|
- **cli_app/** _(planned)_: Command-line interface for scripting, automation, and Rhai scripting
|
||||||
- **web_app/**: (Planned) WASM web app exposing the same APIs to JavaScript or browser scripting.
|
- **web_app/** _(planned)_: WASM web app exposing APIs to JavaScript/browser scripting
|
||||||
|
- **wasm/** _(planned)_: WebAssembly module for browser/extension integration
|
||||||
|
- **browser_extension/** _(planned)_: Browser extension for secure scripting and automation
|
||||||
|
- **rhai scripting** _(planned)_: Unified scripting API for both CLI and browser (see [`docs/rhai_architecture_plan.md`](docs/rhai_architecture_plan.md))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is Rust Conditional Compilation?
|
||||||
|
|
||||||
|
Rust's conditional compilation allows you to write code that only gets included for certain platforms or configurations. This is done using attributes like `#[cfg(target_arch = "wasm32")]` for WebAssembly, or `#[cfg(not(target_arch = "wasm32"))]` for native platforms. It enables a single codebase to support multiple targets (such as desktop and browser) with platform-specific logic where needed.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```rust
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
// This code only compiles for WebAssembly targets
|
||||||
|
```
|
||||||
|
|
||||||
|
## What is WASM (WebAssembly)?
|
||||||
|
|
||||||
|
WebAssembly (WASM) is a binary instruction format for a stack-based virtual machine. It allows code written in languages like Rust, C, or C++ to run at near-native speed in web browsers and other environments. In this project, WASM enables the cryptographic vault to work securely inside the browser, exposing Rust functions to JavaScript and web applications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
> **Note:** Some directories are planned for future extensibility and scripting, and may not exist yet in the current workspace.
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── kvstore/ # Key-value store trait and backends
|
||||||
|
├── vault/ # Cryptographic vault (shared core)
|
||||||
|
├── evm_client/ # EVM RPC client (shared core)
|
||||||
|
├── cli_app/ # Command-line tool for Rhai scripts (planned)
|
||||||
|
├── web_app/ # WASM web app exposing APIs (planned)
|
||||||
|
├── wasm/ # WebAssembly module for browser/extension (planned)
|
||||||
|
├── browser_extension/ # Extension source (planned)
|
||||||
|
├── docs/ # Architecture & usage docs
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture Highlights
|
## Architecture Highlights
|
||||||
- **Async everywhere:** All APIs are async and runtime-agnostic.
|
- **Modular and async:** All APIs are async and runtime-agnostic (works with both native and WASM targets)
|
||||||
- **Conditional backends:** Uses Cargo features and `cfg` to select the appropriate backend for each environment.
|
- **Conditional backends:** Uses Cargo features and `cfg` for platform-specific storage/networking
|
||||||
- **Secure by design:** Vault encrypts all key material at rest and leverages modern cryptography.
|
- **Secure by design:** Vault encrypts all key material at rest using modern cryptography
|
||||||
- **Tested natively and in browser:** WASM and native backends are both covered by tests.
|
- **Extensible:** Trait-based APIs for signers and providers, ready for scripting and new integrations
|
||||||
|
- **Tested everywhere:** Native and browser (WASM) backends are covered by automated tests and a unified Makefile
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conditional Compilation & WASM Support
|
||||||
|
|
||||||
|
This project makes extensive use of Rust's conditional compilation to support both native and WebAssembly (WASM) environments with a single codebase. Key points:
|
||||||
|
|
||||||
|
- **Platform-specific code:**
|
||||||
|
- Rust's `#[cfg(target_arch = "wasm32")]` attribute is used to write WASM-specific code, while `#[cfg(not(target_arch = "wasm32"))]` is for native targets.
|
||||||
|
- This pattern is used for struct definitions, method implementations, and even module imports.
|
||||||
|
|
||||||
|
- **Example: SessionManager**
|
||||||
|
```rust
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub struct SessionManager<S: KVStore + Send + Sync> { ... }
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub struct SessionManager<S: KVStore> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **WASM Bindings:**
|
||||||
|
- The `wasm_app` crate uses `wasm-bindgen` to expose Rust functions to JavaScript, enabling browser integration.
|
||||||
|
- Functions are annotated with `#[wasm_bindgen]` and exported for use in JS/TS.
|
||||||
|
|
||||||
|
- **Storage Backends:**
|
||||||
|
- Native uses `sled` or other file-based stores.
|
||||||
|
- WASM uses IndexedDB (via `kvstore::wasm::WasmStore`).
|
||||||
|
|
||||||
|
- **Building for WASM:**
|
||||||
|
- Use `wasm-pack build --target web` to build the WASM package.
|
||||||
|
- Serve the resulting files with a static server for browser use.
|
||||||
|
|
||||||
|
- **Testing:**
|
||||||
|
- Both native and WASM tests are supported. WASM tests can be run in headless browsers using `wasm-pack test --headless --firefox` or similar commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Building and Testing
|
## Building and Testing
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
- Rust (latest stable recommended)
|
||||||
|
- wasm-pack (for browser tests)
|
||||||
|
- Firefox or Chrome (for browser testing)
|
||||||
|
|
||||||
|
### Native Build & Test
|
||||||
|
```sh
|
||||||
|
cargo build
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browser (WASM) Tests
|
||||||
|
Run all browser tests for all modules:
|
||||||
|
```sh
|
||||||
|
make test-browser-all
|
||||||
|
```
|
||||||
|
Or run for a specific module:
|
||||||
|
```sh
|
||||||
|
make test-browser-kvstore
|
||||||
|
make test-browser-vault
|
||||||
|
make test-browser-evm-client
|
||||||
|
```
|
||||||
|
Set `BROWSER=chrome` to use Chrome instead of Firefox.
|
||||||
|
|
||||||
|
## Scripting & Extensibility
|
||||||
|
- **Rhai scripting**: The architecture is ready for ergonomic scripting via Rhai, both in CLI and browser (see [`docs/rhai_architecture_plan.md`](docs/rhai_architecture_plan.md))
|
||||||
|
- **Browser extension**: Planned support for browser extension scripting and secure key usage
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- [Architecture Overview](docs/architecture.md)
|
||||||
|
- [EVM Client Plan](docs/evm_client_architecture_plan.md)
|
||||||
|
- [Rhai Scripting Plan](docs/rhai_architecture_plan.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For questions, contributions, or more details, see the architecture docs or open an issue!
|
||||||
|
|
||||||
- Rust (latest stable recommended)
|
- Rust (latest stable recommended)
|
||||||
- For WASM: `wasm-pack`, Firefox or Chrome (for browser tests)
|
- For WASM: `wasm-pack`, Firefox or Chrome (for browser tests)
|
||||||
|
|
||||||
@ -33,25 +143,92 @@ cd kvstore
|
|||||||
wasm-pack test --headless --firefox --features web
|
wasm-pack test --headless --firefox --features web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
# Rhai Scripting System
|
||||||
|
|
||||||
|
A unified system for writing and executing [Rhai](https://rhai.rs/) scripts, powered by shared Rust core logic. Supports both local CLI execution and secure browser extension use, with the same business logic compiled to WebAssembly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Goals
|
||||||
|
- **Write and run Rhai scripts** both locally (CLI) and in the browser (extension).
|
||||||
|
- **Reuse the same Rust core logic** (vault, evm_client) across all platforms.
|
||||||
|
- **Sandboxed, secure script execution** in both native and WASM environments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
- **Shared Rust Crates:**
|
||||||
|
- `vault/` and `evm_client/` implement business logic and expose APIs to Rhai.
|
||||||
|
- All logic is reusable in both native and WASM builds.
|
||||||
|
- **CLI Tool (`cli/`):**
|
||||||
|
- Runs Rhai scripts from files or stdin using the shared core.
|
||||||
|
- Outputs results to the terminal.
|
||||||
|
- **WebAssembly Module (`wasm/`):**
|
||||||
|
- Exposes `run_rhai(script: &str) -> String` via `wasm-bindgen`.
|
||||||
|
- Usable from browser JS and the extension.
|
||||||
|
- **Browser Extension (`browser_extension/`):**
|
||||||
|
- UI for entering and running Rhai scripts securely in the browser.
|
||||||
|
- Loads the WASM module and displays results.
|
||||||
|
- **Web App Integration:**
|
||||||
|
- Trusted web apps can send scripts to the extension for execution (via postMessage or WebSocket, with strict origin checks).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### CLI
|
||||||
|
```
|
||||||
|
sal-cli run my_script.rhai
|
||||||
|
# or
|
||||||
|
cat my_script.rhai | sal-cli run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Browser/Extension
|
||||||
|
- Enter Rhai code in the extension popup or trusted website.
|
||||||
|
- Extension loads the WASM module and calls `run_rhai(script)`.
|
||||||
|
- Result is displayed in the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- All script execution is sandboxed via Rhai + WASM.
|
||||||
|
- Only accepts input from:
|
||||||
|
- Extension popup UI
|
||||||
|
- Approved websites (via content script)
|
||||||
|
- Trusted backend server (if using WebSocket)
|
||||||
|
- Strict origin and input validation.
|
||||||
|
- No internal APIs exposed beyond `run_rhai(script)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── kvstore/ # Key-value store trait and backends
|
├── kvstore/ # Key-value store trait and backends
|
||||||
├── vault/ # Cryptographic vault
|
├── vault/ # Cryptographic vault (shared core)
|
||||||
├── evm_client/ # EVM RPC client
|
├── evm_client/ # EVM RPC client (shared core)
|
||||||
├── cli_app/ # CLI (planned)
|
├── cli/ # Command-line tool for Rhai scripts
|
||||||
├── web_app/ # Web app (planned)
|
├── wasm/ # WebAssembly module for browser/extension
|
||||||
├── docs/ # Architecture docs
|
├── browser_extension/ # Extension source
|
||||||
|
├── docs/ # Architecture & usage docs
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
- [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
|
||||||
- [ ] Cryptographic vault with password-protected keyspace
|
- [x] Shared Rust core for vault and evm_client
|
||||||
- [ ] EVM client with vault integration
|
- [ ] WASM module exposing `run_rhai`
|
||||||
- [ ] CLI and web app targets
|
- [ ] CLI tool for local Rhai script execution
|
||||||
- [ ] Full end-to-end integration
|
- [ ] Browser extension for secure script execution
|
||||||
|
- [ ] Web app integration (postMessage/WebSocket)
|
||||||
|
- [ ] Full end-to-end integration and security review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
MIT OR Apache-2.0
|
MIT OR Apache-2.0
|
||||||
|
|
||||||
|
48
build.sh
Executable file
@ -0,0 +1,48 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Main build script for Hero Vault Extension
|
||||||
|
# This script handles the complete build process in one step
|
||||||
|
|
||||||
|
set -e # Exit on any error
|
||||||
|
|
||||||
|
# Colors for better readability
|
||||||
|
GREEN="\033[0;32m"
|
||||||
|
BLUE="\033[0;34m"
|
||||||
|
RESET="\033[0m"
|
||||||
|
|
||||||
|
echo -e "${BLUE}=== Building Hero Vault Extension ===${RESET}"
|
||||||
|
|
||||||
|
# Step 1: Build the WASM package
|
||||||
|
echo -e "${BLUE}Building WASM package...${RESET}"
|
||||||
|
cd "$(dirname "$0")/wasm_app" || exit 1
|
||||||
|
wasm-pack build --target web
|
||||||
|
echo -e "${GREEN}✓ WASM build successful!${RESET}"
|
||||||
|
|
||||||
|
# Step 2: Build the frontend extension
|
||||||
|
echo -e "${BLUE}Building frontend extension...${RESET}"
|
||||||
|
cd ../hero_vault_extension || exit 1
|
||||||
|
|
||||||
|
# Copy WASM files to the extension's public directory
|
||||||
|
echo "Copying WASM files..."
|
||||||
|
mkdir -p public/wasm
|
||||||
|
cp ../wasm_app/pkg/wasm_app* public/wasm/
|
||||||
|
cp ../wasm_app/pkg/*.d.ts public/wasm/
|
||||||
|
cp ../wasm_app/pkg/package.json public/wasm/
|
||||||
|
|
||||||
|
# Build the extension without TypeScript checking
|
||||||
|
echo "Building extension..."
|
||||||
|
export NO_TYPECHECK=true
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Ensure the background script is properly built
|
||||||
|
echo "Building background script..."
|
||||||
|
node scripts/build-background.js
|
||||||
|
echo -e "${GREEN}✓ Frontend build successful!${RESET}"
|
||||||
|
|
||||||
|
echo -e "${GREEN}=== Build Complete ===${RESET}"
|
||||||
|
echo "Extension is ready in: $(pwd)/dist"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}To load the extension in Chrome:${RESET}"
|
||||||
|
echo "1. Go to chrome://extensions/"
|
||||||
|
echo "2. Enable Developer mode (toggle in top-right)"
|
||||||
|
echo "3. Click 'Load unpacked'"
|
||||||
|
echo "4. Select the 'dist' directory: $(pwd)/dist"
|
316
crypto_vault_extension/background.js
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
let vault = null;
|
||||||
|
let isInitialized = false;
|
||||||
|
let currentSession = null;
|
||||||
|
let keepAliveInterval = null;
|
||||||
|
let sessionTimeoutDuration = 15; // Default 15 seconds
|
||||||
|
let sessionTimeoutId = null; // Background timer
|
||||||
|
let popupPort = null; // Track popup connection
|
||||||
|
|
||||||
|
// Utility function to convert Uint8Array to hex
|
||||||
|
function toHex(uint8Array) {
|
||||||
|
return Array.from(uint8Array)
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
// Background session timeout management
|
||||||
|
async function loadTimeoutSetting() {
|
||||||
|
const result = await chrome.storage.local.get(['sessionTimeout']);
|
||||||
|
sessionTimeoutDuration = result.sessionTimeout || 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSessionTimeout() {
|
||||||
|
clearSessionTimeout();
|
||||||
|
|
||||||
|
if (currentSession && sessionTimeoutDuration > 0) {
|
||||||
|
sessionTimeoutId = setTimeout(async () => {
|
||||||
|
if (vault && currentSession) {
|
||||||
|
// Lock the session
|
||||||
|
vault.lock_session();
|
||||||
|
await sessionManager.clear();
|
||||||
|
// Notify popup if it's open
|
||||||
|
if (popupPort) {
|
||||||
|
popupPort.postMessage({
|
||||||
|
type: 'sessionTimeout',
|
||||||
|
message: 'Session timed out due to inactivity'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, sessionTimeoutDuration * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionTimeout() {
|
||||||
|
if (sessionTimeoutId) {
|
||||||
|
clearTimeout(sessionTimeoutId);
|
||||||
|
sessionTimeoutId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSessionTimeout() {
|
||||||
|
if (currentSession) {
|
||||||
|
startSessionTimeout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session persistence functions
|
||||||
|
async function saveSession(keyspace) {
|
||||||
|
currentSession = { keyspace, timestamp: Date.now() };
|
||||||
|
|
||||||
|
// Save to both session and local storage for better persistence
|
||||||
|
await chrome.storage.session.set({ cryptoVaultSession: currentSession });
|
||||||
|
await chrome.storage.local.set({ cryptoVaultSessionBackup: currentSession });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSession() {
|
||||||
|
// Try session storage first
|
||||||
|
let result = await chrome.storage.session.get(['cryptoVaultSession']);
|
||||||
|
if (result.cryptoVaultSession) {
|
||||||
|
currentSession = result.cryptoVaultSession;
|
||||||
|
return currentSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to local storage
|
||||||
|
result = await chrome.storage.local.get(['cryptoVaultSessionBackup']);
|
||||||
|
if (result.cryptoVaultSessionBackup) {
|
||||||
|
currentSession = result.cryptoVaultSessionBackup;
|
||||||
|
// Restore to session storage
|
||||||
|
await chrome.storage.session.set({ cryptoVaultSession: currentSession });
|
||||||
|
return currentSession;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearSession() {
|
||||||
|
currentSession = null;
|
||||||
|
await chrome.storage.session.remove(['cryptoVaultSession']);
|
||||||
|
await chrome.storage.local.remove(['cryptoVaultSessionBackup']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep service worker alive
|
||||||
|
function startKeepAlive() {
|
||||||
|
if (keepAliveInterval) {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
keepAliveInterval = setInterval(() => {
|
||||||
|
chrome.storage.session.get(['keepAlive']).catch(() => {});
|
||||||
|
}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopKeepAlive() {
|
||||||
|
if (keepAliveInterval) {
|
||||||
|
clearInterval(keepAliveInterval);
|
||||||
|
keepAliveInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consolidated session management
|
||||||
|
const sessionManager = {
|
||||||
|
async save(keyspace) {
|
||||||
|
await saveSession(keyspace);
|
||||||
|
startKeepAlive();
|
||||||
|
await loadTimeoutSetting();
|
||||||
|
startSessionTimeout();
|
||||||
|
},
|
||||||
|
async clear() {
|
||||||
|
await clearSession();
|
||||||
|
stopKeepAlive();
|
||||||
|
clearSessionTimeout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function restoreSession() {
|
||||||
|
const session = await loadSession();
|
||||||
|
if (session && vault) {
|
||||||
|
// Check if the session is still valid by testing if vault is unlocked
|
||||||
|
const isUnlocked = vault.is_unlocked();
|
||||||
|
if (isUnlocked) {
|
||||||
|
// Restart keep-alive for restored session
|
||||||
|
startKeepAlive();
|
||||||
|
return session;
|
||||||
|
} else {
|
||||||
|
await sessionManager.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import WASM module functions
|
||||||
|
import init, * as wasmFunctions from './wasm/wasm_app.js';
|
||||||
|
|
||||||
|
// Initialize WASM module
|
||||||
|
async function initVault() {
|
||||||
|
try {
|
||||||
|
if (vault && isInitialized) return vault;
|
||||||
|
|
||||||
|
// Initialize with the WASM file
|
||||||
|
const wasmUrl = chrome.runtime.getURL('wasm/wasm_app_bg.wasm');
|
||||||
|
await init(wasmUrl);
|
||||||
|
|
||||||
|
// Use imported functions directly
|
||||||
|
vault = wasmFunctions;
|
||||||
|
isInitialized = true;
|
||||||
|
|
||||||
|
// Try to restore previous session
|
||||||
|
await restoreSession();
|
||||||
|
|
||||||
|
return vault;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize CryptoVault:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Consolidated message handlers
|
||||||
|
const messageHandlers = {
|
||||||
|
createKeyspace: async (request) => {
|
||||||
|
await vault.create_keyspace(request.keyspace, request.password);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
initSession: async (request) => {
|
||||||
|
await vault.init_session(request.keyspace, request.password);
|
||||||
|
await sessionManager.save(request.keyspace);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
isUnlocked: () => ({ success: true, unlocked: vault.is_unlocked() }),
|
||||||
|
|
||||||
|
addKeypair: async (request) => {
|
||||||
|
const result = await vault.add_keypair(request.keyType, request.metadata);
|
||||||
|
return { success: true, result };
|
||||||
|
},
|
||||||
|
|
||||||
|
listKeypairs: async () => {
|
||||||
|
if (!vault.is_unlocked()) {
|
||||||
|
return { success: false, error: 'Session is not unlocked' };
|
||||||
|
}
|
||||||
|
const keypairsRaw = await vault.list_keypairs();
|
||||||
|
const keypairs = typeof keypairsRaw === 'string' ? JSON.parse(keypairsRaw) : keypairsRaw;
|
||||||
|
return { success: true, keypairs };
|
||||||
|
},
|
||||||
|
|
||||||
|
selectKeypair: (request) => {
|
||||||
|
vault.select_keypair(request.keyId);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentKeypairMetadata: () => ({ success: true, metadata: vault.current_keypair_metadata() }),
|
||||||
|
|
||||||
|
getCurrentKeypairPublicKey: () => ({ success: true, publicKey: toHex(vault.current_keypair_public_key()) }),
|
||||||
|
|
||||||
|
sign: async (request) => {
|
||||||
|
const signature = await vault.sign(new Uint8Array(request.message));
|
||||||
|
return { success: true, signature };
|
||||||
|
},
|
||||||
|
|
||||||
|
encrypt: async (request) => {
|
||||||
|
if (!vault.is_unlocked()) {
|
||||||
|
return { success: false, error: 'Session is not unlocked' };
|
||||||
|
}
|
||||||
|
const messageBytes = new TextEncoder().encode(request.message);
|
||||||
|
const encryptedData = await vault.encrypt_data(messageBytes);
|
||||||
|
const encryptedMessage = btoa(String.fromCharCode(...new Uint8Array(encryptedData)));
|
||||||
|
return { success: true, encryptedMessage };
|
||||||
|
},
|
||||||
|
|
||||||
|
decrypt: async (request) => {
|
||||||
|
if (!vault.is_unlocked()) {
|
||||||
|
return { success: false, error: 'Session is not unlocked' };
|
||||||
|
}
|
||||||
|
const encryptedBytes = new Uint8Array(atob(request.encryptedMessage).split('').map(c => c.charCodeAt(0)));
|
||||||
|
const decryptedData = await vault.decrypt_data(encryptedBytes);
|
||||||
|
const decryptedMessage = new TextDecoder().decode(new Uint8Array(decryptedData));
|
||||||
|
return { success: true, decryptedMessage };
|
||||||
|
},
|
||||||
|
|
||||||
|
verify: async (request) => {
|
||||||
|
const metadata = vault.current_keypair_metadata();
|
||||||
|
if (!metadata) {
|
||||||
|
return { success: false, error: 'No keypair selected' };
|
||||||
|
}
|
||||||
|
const isValid = await vault.verify(new Uint8Array(request.message), request.signature);
|
||||||
|
return { success: true, isValid };
|
||||||
|
},
|
||||||
|
|
||||||
|
lockSession: async () => {
|
||||||
|
vault.lock_session();
|
||||||
|
await sessionManager.clear();
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatus: async () => {
|
||||||
|
const status = vault ? vault.is_unlocked() : false;
|
||||||
|
const session = await loadSession();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status,
|
||||||
|
session: session ? { keyspace: session.keyspace } : null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timeout management handlers
|
||||||
|
resetTimeout: async () => {
|
||||||
|
resetSessionTimeout();
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTimeout: async (request) => {
|
||||||
|
sessionTimeoutDuration = request.timeout;
|
||||||
|
await chrome.storage.local.set({ sessionTimeout: request.timeout });
|
||||||
|
resetSessionTimeout(); // Restart with new duration
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle messages from popup and content scripts
|
||||||
|
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||||
|
const handleRequest = async () => {
|
||||||
|
try {
|
||||||
|
if (!vault) {
|
||||||
|
await initVault();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = messageHandlers[request.action];
|
||||||
|
if (handler) {
|
||||||
|
return await handler(request);
|
||||||
|
} else {
|
||||||
|
throw new Error('Unknown action: ' + request.action);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleRequest().then(sendResponse);
|
||||||
|
return true; // Keep the message channel open for async response
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize vault when extension starts
|
||||||
|
chrome.runtime.onStartup.addListener(() => {
|
||||||
|
initVault();
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
initVault();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle popup connection for keep-alive and timeout notifications
|
||||||
|
chrome.runtime.onConnect.addListener((port) => {
|
||||||
|
if (port.name === 'popup') {
|
||||||
|
// Track popup connection
|
||||||
|
popupPort = port;
|
||||||
|
|
||||||
|
// If we have an active session, ensure keep-alive is running
|
||||||
|
if (currentSession) {
|
||||||
|
startKeepAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
port.onDisconnect.addListener(() => {
|
||||||
|
// Popup closed, clear reference and stop keep-alive
|
||||||
|
popupPort = null;
|
||||||
|
stopKeepAlive();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
BIN
crypto_vault_extension/icons/icon128.png
Normal file
After Width: | Height: | Size: 7.5 KiB |
BIN
crypto_vault_extension/icons/icon16.png
Normal file
After Width: | Height: | Size: 676 B |
BIN
crypto_vault_extension/icons/icon32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
crypto_vault_extension/icons/icon36.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
crypto_vault_extension/icons/icon48.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
247
crypto_vault_extension/js/errorHandler.js
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
class CryptoVaultError extends Error {
|
||||||
|
constructor(message, code, retryable = false, userMessage = null) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CryptoVaultError';
|
||||||
|
this.code = code;
|
||||||
|
this.retryable = retryable;
|
||||||
|
this.userMessage = userMessage || message;
|
||||||
|
this.timestamp = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_CODES = {
|
||||||
|
NETWORK_ERROR: 'NETWORK_ERROR',
|
||||||
|
TIMEOUT_ERROR: 'TIMEOUT_ERROR',
|
||||||
|
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
||||||
|
INVALID_PASSWORD: 'INVALID_PASSWORD',
|
||||||
|
SESSION_EXPIRED: 'SESSION_EXPIRED',
|
||||||
|
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||||
|
CRYPTO_ERROR: 'CRYPTO_ERROR',
|
||||||
|
INVALID_SIGNATURE: 'INVALID_SIGNATURE',
|
||||||
|
ENCRYPTION_FAILED: 'ENCRYPTION_FAILED',
|
||||||
|
INVALID_INPUT: 'INVALID_INPUT',
|
||||||
|
MISSING_KEYPAIR: 'MISSING_KEYPAIR',
|
||||||
|
INVALID_FORMAT: 'INVALID_FORMAT',
|
||||||
|
WASM_ERROR: 'WASM_ERROR',
|
||||||
|
STORAGE_ERROR: 'STORAGE_ERROR',
|
||||||
|
UNKNOWN_ERROR: 'UNKNOWN_ERROR'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
[ERROR_CODES.NETWORK_ERROR]: 'Connection failed. Please check your internet connection and try again.',
|
||||||
|
[ERROR_CODES.TIMEOUT_ERROR]: 'Operation timed out. Please try again.',
|
||||||
|
[ERROR_CODES.SERVICE_UNAVAILABLE]: 'Service is temporarily unavailable. Please try again later.',
|
||||||
|
|
||||||
|
[ERROR_CODES.INVALID_PASSWORD]: 'Invalid password. Please check your password and try again.',
|
||||||
|
[ERROR_CODES.SESSION_EXPIRED]: 'Your session has expired. Please log in again.',
|
||||||
|
[ERROR_CODES.UNAUTHORIZED]: 'You are not authorized to perform this action.',
|
||||||
|
|
||||||
|
[ERROR_CODES.CRYPTO_ERROR]: 'Cryptographic operation failed. Please try again.',
|
||||||
|
[ERROR_CODES.INVALID_SIGNATURE]: 'Invalid signature. Please verify your input.',
|
||||||
|
[ERROR_CODES.ENCRYPTION_FAILED]: 'Encryption failed. Please try again.',
|
||||||
|
|
||||||
|
[ERROR_CODES.INVALID_INPUT]: 'Invalid input. Please check your data and try again.',
|
||||||
|
[ERROR_CODES.MISSING_KEYPAIR]: 'No keypair selected. Please select a keypair first.',
|
||||||
|
[ERROR_CODES.INVALID_FORMAT]: 'Invalid data format. Please check your input.',
|
||||||
|
|
||||||
|
[ERROR_CODES.WASM_ERROR]: 'System error occurred. Please refresh and try again.',
|
||||||
|
[ERROR_CODES.STORAGE_ERROR]: 'Storage error occurred. Please try again.',
|
||||||
|
[ERROR_CODES.UNKNOWN_ERROR]: 'An unexpected error occurred. Please try again.'
|
||||||
|
};
|
||||||
|
|
||||||
|
const RETRYABLE_ERRORS = new Set([
|
||||||
|
ERROR_CODES.NETWORK_ERROR,
|
||||||
|
ERROR_CODES.TIMEOUT_ERROR,
|
||||||
|
ERROR_CODES.SERVICE_UNAVAILABLE,
|
||||||
|
ERROR_CODES.WASM_ERROR,
|
||||||
|
ERROR_CODES.STORAGE_ERROR
|
||||||
|
]);
|
||||||
|
|
||||||
|
function classifyError(error) {
|
||||||
|
const errorMessage = getErrorMessage(error);
|
||||||
|
|
||||||
|
if (errorMessage.includes('fetch') || errorMessage.includes('network') || errorMessage.includes('connection')) {
|
||||||
|
return new CryptoVaultError(
|
||||||
|
errorMessage,
|
||||||
|
ERROR_CODES.NETWORK_ERROR,
|
||||||
|
true,
|
||||||
|
ERROR_MESSAGES[ERROR_CODES.NETWORK_ERROR]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes('password') || errorMessage.includes('Invalid password')) {
|
||||||
|
return new CryptoVaultError(
|
||||||
|
errorMessage,
|
||||||
|
ERROR_CODES.INVALID_PASSWORD,
|
||||||
|
false,
|
||||||
|
ERROR_MESSAGES[ERROR_CODES.INVALID_PASSWORD]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes('session') || errorMessage.includes('not unlocked') || errorMessage.includes('expired')) {
|
||||||
|
return new CryptoVaultError(
|
||||||
|
errorMessage,
|
||||||
|
ERROR_CODES.SESSION_EXPIRED,
|
||||||
|
false,
|
||||||
|
ERROR_MESSAGES[ERROR_CODES.SESSION_EXPIRED]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes('decryption error') || errorMessage.includes('aead::Error')) {
|
||||||
|
return new CryptoVaultError(
|
||||||
|
errorMessage,
|
||||||
|
ERROR_CODES.CRYPTO_ERROR,
|
||||||
|
false,
|
||||||
|
'Invalid password or corrupted data. Please check your password.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes('Crypto error') || errorMessage.includes('encryption')) {
|
||||||
|
return new CryptoVaultError(
|
||||||
|
errorMessage,
|
||||||
|
ERROR_CODES.CRYPTO_ERROR,
|
||||||
|
false,
|
||||||
|
ERROR_MESSAGES[ERROR_CODES.CRYPTO_ERROR]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes('No keypair selected')) {
|
||||||
|
return new CryptoVaultError(
|
||||||
|
errorMessage,
|
||||||
|
ERROR_CODES.MISSING_KEYPAIR,
|
||||||
|
false,
|
||||||
|
ERROR_MESSAGES[ERROR_CODES.MISSING_KEYPAIR]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage.includes('wasm') || errorMessage.includes('WASM')) {
|
||||||
|
return new CryptoVaultError(
|
||||||
|
errorMessage,
|
||||||
|
ERROR_CODES.WASM_ERROR,
|
||||||
|
true,
|
||||||
|
ERROR_MESSAGES[ERROR_CODES.WASM_ERROR]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CryptoVaultError(
|
||||||
|
errorMessage,
|
||||||
|
ERROR_CODES.UNKNOWN_ERROR,
|
||||||
|
false,
|
||||||
|
ERROR_MESSAGES[ERROR_CODES.UNKNOWN_ERROR]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error) {
|
||||||
|
if (!error) return 'Unknown error';
|
||||||
|
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.error) {
|
||||||
|
return getErrorMessage(error.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === 'object') {
|
||||||
|
try {
|
||||||
|
const stringified = JSON.stringify(error);
|
||||||
|
if (stringified && stringified !== '{}') {
|
||||||
|
return stringified;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silently handle JSON stringify errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withRetry(operation, options = {}) {
|
||||||
|
const {
|
||||||
|
maxRetries = 3,
|
||||||
|
baseDelay = 1000,
|
||||||
|
maxDelay = 10000,
|
||||||
|
backoffFactor = 2,
|
||||||
|
onRetry = null
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let lastError;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
const classifiedError = classifyError(error);
|
||||||
|
lastError = classifiedError;
|
||||||
|
|
||||||
|
if (attempt === maxRetries || !classifiedError.retryable) {
|
||||||
|
throw classifiedError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(baseDelay * Math.pow(backoffFactor, attempt), maxDelay);
|
||||||
|
|
||||||
|
if (onRetry) {
|
||||||
|
onRetry(attempt + 1, delay, classifiedError);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeOperation(operation, options = {}) {
|
||||||
|
const {
|
||||||
|
loadingElement = null,
|
||||||
|
successMessage = null,
|
||||||
|
showRetryProgress = false,
|
||||||
|
onProgress = null
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (loadingElement) {
|
||||||
|
setButtonLoading(loadingElement, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await withRetry(operation, {
|
||||||
|
...options,
|
||||||
|
onRetry: (attempt, delay, error) => {
|
||||||
|
if (showRetryProgress && onProgress) {
|
||||||
|
onProgress(`Retrying... (${attempt}/${options.maxRetries || 3})`);
|
||||||
|
}
|
||||||
|
if (options.onRetry) {
|
||||||
|
options.onRetry(attempt, delay, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (successMessage) {
|
||||||
|
showToast(successMessage, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.userMessage || error.message, 'error');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (loadingElement) {
|
||||||
|
setButtonLoading(loadingElement, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.CryptoVaultError = CryptoVaultError;
|
||||||
|
window.ERROR_CODES = ERROR_CODES;
|
||||||
|
window.classifyError = classifyError;
|
||||||
|
window.getErrorMessage = getErrorMessage;
|
||||||
|
window.withRetry = withRetry;
|
||||||
|
window.executeOperation = executeOperation;
|
47
crypto_vault_extension/manifest.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "CryptoVault",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Secure cryptographic key management and signing in your browser",
|
||||||
|
|
||||||
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"activeTab"
|
||||||
|
],
|
||||||
|
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"32": "icons/icon32.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
},
|
||||||
|
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"32": "icons/icon32.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"web_accessible_resources": [
|
||||||
|
{
|
||||||
|
"resources": [
|
||||||
|
"wasm/*.wasm",
|
||||||
|
"wasm/*.js"
|
||||||
|
],
|
||||||
|
"matches": ["<all_urls>"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline';"
|
||||||
|
}
|
||||||
|
}
|
203
crypto_vault_extension/popup.html
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="styles/popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<div class="logo-icon">🔐</div>
|
||||||
|
<h1>CryptoVault</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<div class="settings-container">
|
||||||
|
<button id="settingsToggle" class="btn-icon-only" title="Settings">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="settings-dropdown hidden" id="settingsDropdown">
|
||||||
|
<div class="settings-item">
|
||||||
|
<label for="timeoutInput">Session Timeout</label>
|
||||||
|
<div class="timeout-input-group">
|
||||||
|
<input type="number" id="timeoutInput" min="3" max="300" value="15">
|
||||||
|
<span>seconds</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="themeToggle" class="btn-icon-only" title="Switch to dark mode">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Create/Login Section -->
|
||||||
|
<section class="section" id="authSection">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Access Your Vault</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="keyspaceInput">Keyspace Name</label>
|
||||||
|
<input type="text" id="keyspaceInput" placeholder="Enter keyspace name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="passwordInput">Password</label>
|
||||||
|
<input type="password" id="passwordInput" placeholder="Enter password">
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button id="createKeyspaceBtn" class="btn btn-secondary">Create New</button>
|
||||||
|
<button id="loginBtn" class="btn btn-primary">Unlock</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Main Vault Section -->
|
||||||
|
<section class="section hidden" id="vaultSection">
|
||||||
|
<!-- Status Section -->
|
||||||
|
<div class="vault-status" id="vaultStatus">
|
||||||
|
<div class="status-indicator" id="statusIndicator">
|
||||||
|
<span id="statusText"></span>
|
||||||
|
<button id="lockBtn" class="btn btn-ghost btn-small hidden">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||||
|
<circle cx="12" cy="16" r="1"></circle>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||||
|
</svg>
|
||||||
|
Lock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vault-header">
|
||||||
|
<h2>Your Keypairs</h2>
|
||||||
|
<button id="toggleAddKeypairBtn" class="btn btn-primary">
|
||||||
|
<span class="btn-icon">+</span>
|
||||||
|
Add Keypair
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Keypair Form (Hidden by default) -->
|
||||||
|
<div class="card add-keypair-form hidden" id="addKeypairCard">
|
||||||
|
<div class="form-header">
|
||||||
|
<h3>Add New Keypair</h3>
|
||||||
|
<button id="cancelAddKeypairBtn" class="btn-close" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="keyTypeSelect">Key Type</label>
|
||||||
|
<select id="keyTypeSelect" class="select">
|
||||||
|
<option value="Secp256k1">Secp256k1</option>
|
||||||
|
<option value="Ed25519">Ed25519</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="keyNameInput">Keypair Name</label>
|
||||||
|
<input type="text" id="keyNameInput" placeholder="Enter a name for your keypair">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="addKeypairBtn" class="btn btn-primary">Create Keypair</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Keypairs List -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Keypairs</h3>
|
||||||
|
<div id="keypairsList" class="keypairs-list">
|
||||||
|
<div class="loading">Loading keypairs...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Crypto Operations -->
|
||||||
|
<div class="card">
|
||||||
|
<h3>Crypto Operations</h3>
|
||||||
|
|
||||||
|
<!-- Operation Tabs -->
|
||||||
|
<div class="operation-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="encrypt">Encrypt</button>
|
||||||
|
<button class="tab-btn" data-tab="decrypt">Decrypt</button>
|
||||||
|
<button class="tab-btn" data-tab="sign">Sign</button>
|
||||||
|
<button class="tab-btn" data-tab="verify">Verify</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Encrypt Tab -->
|
||||||
|
<div class="tab-content active" id="encrypt-tab">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="encryptMessageInput">Message to Encrypt</label>
|
||||||
|
<textarea id="encryptMessageInput" placeholder="Enter message to encrypt..." rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<button id="encryptBtn" class="btn btn-primary" disabled>Encrypt Message</button>
|
||||||
|
|
||||||
|
<div class="encrypt-result hidden" id="encryptResult">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decrypt Tab -->
|
||||||
|
<div class="tab-content" id="decrypt-tab">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="encryptedMessageInput">Encrypted Message</label>
|
||||||
|
<textarea id="encryptedMessageInput" placeholder="Enter encrypted message..." rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<button id="decryptBtn" class="btn btn-primary" disabled>Decrypt Message</button>
|
||||||
|
|
||||||
|
<div class="decrypt-result hidden" id="decryptResult">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sign Tab -->
|
||||||
|
<div class="tab-content" id="sign-tab">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="messageInput">Message to Sign</label>
|
||||||
|
<textarea id="messageInput" placeholder="Enter your message here..." rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<button id="signBtn" class="btn btn-primary" disabled>Sign Message</button>
|
||||||
|
|
||||||
|
<div class="signature-result hidden" id="signatureResult">
|
||||||
|
<label>Signature:</label>
|
||||||
|
<div class="signature-container">
|
||||||
|
<code id="signatureValue">-</code>
|
||||||
|
<button id="copySignatureBtn" class="btn-copy" title="Copy to clipboard">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Verify Tab -->
|
||||||
|
<div class="tab-content" id="verify-tab">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="verifyMessageInput">Original Message</label>
|
||||||
|
<textarea id="verifyMessageInput" placeholder="Enter the original message..." rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="signatureToVerifyInput">Signature</label>
|
||||||
|
<input type="text" id="signatureToVerifyInput" placeholder="Enter signature to verify...">
|
||||||
|
</div>
|
||||||
|
<button id="verifyBtn" class="btn btn-primary" disabled>Verify Signature</button>
|
||||||
|
|
||||||
|
<div class="verify-result hidden" id="verifyResult">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enhanced JavaScript modules -->
|
||||||
|
<script src="js/errorHandler.js"></script>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
934
crypto_vault_extension/popup.js
Normal file
@ -0,0 +1,934 @@
|
|||||||
|
// Consolidated toast system
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
document.querySelector('.toast-notification')?.remove();
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: '<polyline points="20,6 9,17 4,12"></polyline>',
|
||||||
|
error: '<circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line>',
|
||||||
|
info: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>'
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast = Object.assign(document.createElement('div'), {
|
||||||
|
className: `toast-notification toast-${type}`,
|
||||||
|
innerHTML: `
|
||||||
|
<div class="toast-icon">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
${icons[type] || icons.info}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="toast-content"><div class="toast-message">${message}</div></div>
|
||||||
|
`
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.classList.add('toast-show'), 10);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (toast.parentElement) {
|
||||||
|
toast.classList.add('toast-hide');
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced loading states for buttons
|
||||||
|
function setButtonLoading(button, loading = true) {
|
||||||
|
if (loading) {
|
||||||
|
button.dataset.originalText = button.textContent;
|
||||||
|
button.classList.add('loading');
|
||||||
|
button.disabled = true;
|
||||||
|
} else {
|
||||||
|
button.classList.remove('loading');
|
||||||
|
button.disabled = false;
|
||||||
|
if (button.dataset.originalText) {
|
||||||
|
button.textContent = button.dataset.originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced response error handling
|
||||||
|
function getResponseError(response, operation = 'operation') {
|
||||||
|
if (!response) {
|
||||||
|
return `Failed to ${operation}: No response received`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.success === false || response.error) {
|
||||||
|
const errorMsg = getErrorMessage(response.error, `${operation} failed`);
|
||||||
|
|
||||||
|
// Handle specific error types
|
||||||
|
if (errorMsg.includes('decryption error') || errorMsg.includes('aead::Error')) {
|
||||||
|
return 'Invalid password or corrupted keyspace data';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMsg.includes('Crypto error')) {
|
||||||
|
return 'Keyspace not found or corrupted. Try creating a new one.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMsg.includes('not unlocked') || errorMsg.includes('session')) {
|
||||||
|
return 'Session expired. Please login again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorMsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Failed to ${operation}: Unknown error`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSection(sectionId) {
|
||||||
|
document.querySelectorAll('.section').forEach(s => s.classList.add('hidden'));
|
||||||
|
document.getElementById(sectionId).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, isConnected = false) {
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
const statusSection = document.getElementById('vaultStatus');
|
||||||
|
const lockBtn = document.getElementById('lockBtn');
|
||||||
|
|
||||||
|
if (isConnected && text) {
|
||||||
|
// Show keyspace name and status section
|
||||||
|
statusText.textContent = text;
|
||||||
|
statusSection.classList.remove('hidden');
|
||||||
|
if (lockBtn) {
|
||||||
|
lockBtn.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message handling
|
||||||
|
async function sendMessage(action, data = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.runtime.sendMessage({ action, ...data }, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
async function copyToClipboard(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
showToast('Copied to clipboard!', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Failed to copy', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert string to Uint8Array
|
||||||
|
function stringToUint8Array(str) {
|
||||||
|
return Array.from(new TextEncoder().encode(str));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM Elements
|
||||||
|
const elements = {
|
||||||
|
// Authentication elements
|
||||||
|
keyspaceInput: document.getElementById('keyspaceInput'),
|
||||||
|
passwordInput: document.getElementById('passwordInput'),
|
||||||
|
createKeyspaceBtn: document.getElementById('createKeyspaceBtn'),
|
||||||
|
loginBtn: document.getElementById('loginBtn'),
|
||||||
|
|
||||||
|
// Header elements
|
||||||
|
lockBtn: document.getElementById('lockBtn'),
|
||||||
|
themeToggle: document.getElementById('themeToggle'),
|
||||||
|
settingsToggle: document.getElementById('settingsToggle'),
|
||||||
|
settingsDropdown: document.getElementById('settingsDropdown'),
|
||||||
|
timeoutInput: document.getElementById('timeoutInput'),
|
||||||
|
|
||||||
|
// Keypair management elements
|
||||||
|
toggleAddKeypairBtn: document.getElementById('toggleAddKeypairBtn'),
|
||||||
|
addKeypairCard: document.getElementById('addKeypairCard'),
|
||||||
|
keyTypeSelect: document.getElementById('keyTypeSelect'),
|
||||||
|
keyNameInput: document.getElementById('keyNameInput'),
|
||||||
|
addKeypairBtn: document.getElementById('addKeypairBtn'),
|
||||||
|
cancelAddKeypairBtn: document.getElementById('cancelAddKeypairBtn'),
|
||||||
|
keypairsList: document.getElementById('keypairsList'),
|
||||||
|
|
||||||
|
// Crypto operation elements - Sign tab
|
||||||
|
messageInput: document.getElementById('messageInput'),
|
||||||
|
signBtn: document.getElementById('signBtn'),
|
||||||
|
signatureResult: document.getElementById('signatureResult'),
|
||||||
|
copySignatureBtn: document.getElementById('copySignatureBtn'),
|
||||||
|
|
||||||
|
// Crypto operation elements - Encrypt tab
|
||||||
|
encryptMessageInput: document.getElementById('encryptMessageInput'),
|
||||||
|
encryptBtn: document.getElementById('encryptBtn'),
|
||||||
|
encryptResult: document.getElementById('encryptResult'),
|
||||||
|
|
||||||
|
// Crypto operation elements - Decrypt tab
|
||||||
|
encryptedMessageInput: document.getElementById('encryptedMessageInput'),
|
||||||
|
decryptBtn: document.getElementById('decryptBtn'),
|
||||||
|
decryptResult: document.getElementById('decryptResult'),
|
||||||
|
|
||||||
|
// Crypto operation elements - Verify tab
|
||||||
|
verifyMessageInput: document.getElementById('verifyMessageInput'),
|
||||||
|
signatureToVerifyInput: document.getElementById('signatureToVerifyInput'),
|
||||||
|
verifyBtn: document.getElementById('verifyBtn'),
|
||||||
|
verifyResult: document.getElementById('verifyResult'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global state variables
|
||||||
|
let currentKeyspace = null;
|
||||||
|
let selectedKeypairId = null;
|
||||||
|
let backgroundPort = null;
|
||||||
|
let sessionTimeoutDuration = 15; // Default 15 seconds
|
||||||
|
|
||||||
|
// Session timeout management
|
||||||
|
function handleError(error, context, shouldShowToast = true) {
|
||||||
|
const errorMessage = error?.message || 'An unexpected error occurred';
|
||||||
|
|
||||||
|
if (shouldShowToast) {
|
||||||
|
showToast(`${context}: ${errorMessage}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInput(value, fieldName, options = {}) {
|
||||||
|
const { minLength = 1, maxLength = 1000, required = true } = options;
|
||||||
|
|
||||||
|
if (required && (!value || !value.trim())) {
|
||||||
|
showToast(`${fieldName} is required`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && value.length < minLength) {
|
||||||
|
showToast(`${fieldName} must be at least ${minLength} characters`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && value.length > maxLength) {
|
||||||
|
showToast(`${fieldName} must be less than ${maxLength} characters`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async function loadTimeoutSetting() {
|
||||||
|
const result = await chrome.storage.local.get(['sessionTimeout']);
|
||||||
|
sessionTimeoutDuration = result.sessionTimeout || 15;
|
||||||
|
if (elements.timeoutInput) {
|
||||||
|
elements.timeoutInput.value = sessionTimeoutDuration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSessionTimeout() {
|
||||||
|
const result = await chrome.storage.local.get(['sessionTimedOut']);
|
||||||
|
if (result.sessionTimedOut) {
|
||||||
|
// Clear the flag
|
||||||
|
await chrome.storage.local.remove(['sessionTimedOut']);
|
||||||
|
// Show timeout notification
|
||||||
|
showToast('Session timed out due to inactivity', 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function saveTimeoutSetting(timeout) {
|
||||||
|
sessionTimeoutDuration = timeout;
|
||||||
|
await sendMessage('updateTimeout', { timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetSessionTimeout() {
|
||||||
|
if (currentKeyspace) {
|
||||||
|
await sendMessage('resetTimeout');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme management
|
||||||
|
function initializeTheme() {
|
||||||
|
const savedTheme = localStorage.getItem('cryptovault-theme') || 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
updateThemeIcon(savedTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||||
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
|
||||||
|
document.documentElement.setAttribute('data-theme', newTheme);
|
||||||
|
localStorage.setItem('cryptovault-theme', newTheme);
|
||||||
|
updateThemeIcon(newTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings dropdown management
|
||||||
|
function toggleSettingsDropdown() {
|
||||||
|
const dropdown = elements.settingsDropdown;
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSettingsDropdown() {
|
||||||
|
const dropdown = elements.settingsDropdown;
|
||||||
|
if (dropdown) {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateThemeIcon(theme) {
|
||||||
|
const themeToggle = elements.themeToggle;
|
||||||
|
if (!themeToggle) return;
|
||||||
|
|
||||||
|
if (theme === 'dark') {
|
||||||
|
// Bright sun SVG for dark theme
|
||||||
|
themeToggle.innerHTML = `
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="5"></circle>
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||||
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
themeToggle.title = 'Switch to light mode';
|
||||||
|
} else {
|
||||||
|
// Dark crescent moon SVG for light theme
|
||||||
|
themeToggle.innerHTML = `
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
themeToggle.title = 'Switch to dark mode';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Establish connection to background script for keep-alive
|
||||||
|
function connectToBackground() {
|
||||||
|
backgroundPort = chrome.runtime.connect({ name: 'popup' });
|
||||||
|
|
||||||
|
// Listen for messages from background script
|
||||||
|
backgroundPort.onMessage.addListener((message) => {
|
||||||
|
if (message.type === 'sessionTimeout') {
|
||||||
|
// Update UI state to reflect locked session
|
||||||
|
currentKeyspace = null;
|
||||||
|
selectedKeypairId = null;
|
||||||
|
setStatus('', false);
|
||||||
|
showSection('authSection');
|
||||||
|
clearVaultState();
|
||||||
|
|
||||||
|
// Clear form inputs
|
||||||
|
if (elements.keyspaceInput) elements.keyspaceInput.value = '';
|
||||||
|
if (elements.passwordInput) elements.passwordInput.value = '';
|
||||||
|
|
||||||
|
// Show timeout notification
|
||||||
|
showToast(message.message, 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
backgroundPort.onDisconnect.addListener(() => {
|
||||||
|
backgroundPort = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
|
// Initialize theme first
|
||||||
|
initializeTheme();
|
||||||
|
|
||||||
|
// Load timeout setting
|
||||||
|
await loadTimeoutSetting();
|
||||||
|
|
||||||
|
// Ensure lock button starts hidden
|
||||||
|
const lockBtn = document.getElementById('lockBtn');
|
||||||
|
if (lockBtn) {
|
||||||
|
lockBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to background script for keep-alive
|
||||||
|
connectToBackground();
|
||||||
|
|
||||||
|
// Consolidated event listeners
|
||||||
|
const eventMap = {
|
||||||
|
createKeyspaceBtn: createKeyspace,
|
||||||
|
loginBtn: login,
|
||||||
|
lockBtn: lockSession,
|
||||||
|
themeToggle: toggleTheme,
|
||||||
|
settingsToggle: toggleSettingsDropdown,
|
||||||
|
toggleAddKeypairBtn: toggleAddKeypairForm,
|
||||||
|
addKeypairBtn: addKeypair,
|
||||||
|
cancelAddKeypairBtn: hideAddKeypairForm,
|
||||||
|
signBtn: signMessage,
|
||||||
|
encryptBtn: encryptMessage,
|
||||||
|
decryptBtn: decryptMessage,
|
||||||
|
verifyBtn: verifySignature
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(eventMap).forEach(([elementKey, handler]) => {
|
||||||
|
elements[elementKey]?.addEventListener('click', handler);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tab functionality
|
||||||
|
initializeTabs();
|
||||||
|
|
||||||
|
// Additional event listeners
|
||||||
|
elements.copySignatureBtn?.addEventListener('click', () => {
|
||||||
|
copyToClipboard(document.getElementById('signatureValue')?.textContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.messageInput?.addEventListener('input', () => {
|
||||||
|
if (elements.signBtn) {
|
||||||
|
elements.signBtn.disabled = !elements.messageInput.value.trim() || !selectedKeypairId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout setting event listener
|
||||||
|
elements.timeoutInput?.addEventListener('change', async (e) => {
|
||||||
|
const timeout = parseInt(e.target.value);
|
||||||
|
if (timeout >= 3 && timeout <= 300) {
|
||||||
|
await saveTimeoutSetting(timeout);
|
||||||
|
} else {
|
||||||
|
e.target.value = sessionTimeoutDuration; // Reset to current value if invalid
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activity detection - reset timeout on any interaction
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
resetSessionTimeout();
|
||||||
|
|
||||||
|
// Close settings dropdown if clicking outside
|
||||||
|
if (!elements.settingsToggle?.contains(e.target) &&
|
||||||
|
!elements.settingsDropdown?.contains(e.target)) {
|
||||||
|
closeSettingsDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', resetSessionTimeout);
|
||||||
|
document.addEventListener('input', resetSessionTimeout);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && !elements.addKeypairCard?.classList.contains('hidden')) {
|
||||||
|
hideAddKeypairForm();
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && e.target === elements.keyNameInput && elements.keyNameInput.value.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
addKeypair();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for existing session
|
||||||
|
await checkExistingSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function checkExistingSession() {
|
||||||
|
try {
|
||||||
|
const response = await sendMessage('getStatus');
|
||||||
|
if (response && response.success && response.status && response.session) {
|
||||||
|
// Session is active
|
||||||
|
currentKeyspace = response.session.keyspace;
|
||||||
|
elements.keyspaceInput.value = currentKeyspace;
|
||||||
|
setStatus(currentKeyspace, true);
|
||||||
|
showSection('vaultSection');
|
||||||
|
await loadKeypairs();
|
||||||
|
} else {
|
||||||
|
// No active session
|
||||||
|
setStatus('', false);
|
||||||
|
showSection('authSection');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setStatus('', false);
|
||||||
|
showSection('authSection');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle add keypair form
|
||||||
|
function toggleAddKeypairForm() {
|
||||||
|
const isHidden = elements.addKeypairCard.classList.contains('hidden');
|
||||||
|
if (isHidden) {
|
||||||
|
showAddKeypairForm();
|
||||||
|
} else {
|
||||||
|
hideAddKeypairForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddKeypairForm() {
|
||||||
|
elements.addKeypairCard.classList.remove('hidden');
|
||||||
|
elements.keyNameInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAddKeypairForm() {
|
||||||
|
elements.addKeypairCard.classList.add('hidden');
|
||||||
|
|
||||||
|
// Clear the form
|
||||||
|
elements.keyNameInput.value = '';
|
||||||
|
elements.keyTypeSelect.selectedIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab functionality
|
||||||
|
function initializeTabs() {
|
||||||
|
const tabContainer = document.querySelector('.operation-tabs');
|
||||||
|
|
||||||
|
if (tabContainer) {
|
||||||
|
// Use event delegation for better performance
|
||||||
|
tabContainer.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('tab-btn')) {
|
||||||
|
handleTabSwitch(e.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize input validation
|
||||||
|
initializeInputValidation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabSwitch(clickedTab) {
|
||||||
|
const targetTab = clickedTab.getAttribute('data-tab');
|
||||||
|
const tabButtons = document.querySelectorAll('.tab-btn');
|
||||||
|
const tabContents = document.querySelectorAll('.tab-content');
|
||||||
|
|
||||||
|
// Remove active class from all tabs and contents
|
||||||
|
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||||
|
tabContents.forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
|
// Add active class to clicked tab and corresponding content
|
||||||
|
clickedTab.classList.add('active');
|
||||||
|
const targetContent = document.getElementById(`${targetTab}-tab`);
|
||||||
|
if (targetContent) {
|
||||||
|
targetContent.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear results when switching tabs
|
||||||
|
clearTabResults();
|
||||||
|
|
||||||
|
// Update button states
|
||||||
|
updateButtonStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTabResults() {
|
||||||
|
// Hide all result sections (with null checks)
|
||||||
|
if (elements.signatureResult) {
|
||||||
|
elements.signatureResult.classList.add('hidden');
|
||||||
|
elements.signatureResult.innerHTML = '';
|
||||||
|
}
|
||||||
|
if (elements.encryptResult) {
|
||||||
|
elements.encryptResult.classList.add('hidden');
|
||||||
|
elements.encryptResult.innerHTML = '';
|
||||||
|
}
|
||||||
|
if (elements.decryptResult) {
|
||||||
|
elements.decryptResult.classList.add('hidden');
|
||||||
|
elements.decryptResult.innerHTML = '';
|
||||||
|
}
|
||||||
|
if (elements.verifyResult) {
|
||||||
|
elements.verifyResult.classList.add('hidden');
|
||||||
|
elements.verifyResult.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeInputValidation() {
|
||||||
|
// Sign tab validation (with null checks)
|
||||||
|
if (elements.messageInput) {
|
||||||
|
elements.messageInput.addEventListener('input', updateButtonStates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt tab validation (with null checks)
|
||||||
|
if (elements.encryptMessageInput) {
|
||||||
|
elements.encryptMessageInput.addEventListener('input', updateButtonStates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt tab validation (with null checks)
|
||||||
|
if (elements.encryptedMessageInput) {
|
||||||
|
elements.encryptedMessageInput.addEventListener('input', updateButtonStates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tab validation (with null checks)
|
||||||
|
if (elements.verifyMessageInput) {
|
||||||
|
elements.verifyMessageInput.addEventListener('input', updateButtonStates);
|
||||||
|
}
|
||||||
|
if (elements.signatureToVerifyInput) {
|
||||||
|
elements.signatureToVerifyInput.addEventListener('input', updateButtonStates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtonStates() {
|
||||||
|
// Sign button (with null checks)
|
||||||
|
if (elements.signBtn && elements.messageInput) {
|
||||||
|
elements.signBtn.disabled = !elements.messageInput.value.trim() || !selectedKeypairId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt button (with null checks) - only needs message and keyspace session
|
||||||
|
if (elements.encryptBtn && elements.encryptMessageInput) {
|
||||||
|
elements.encryptBtn.disabled = !elements.encryptMessageInput.value.trim() || !currentKeyspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt button (with null checks) - only needs encrypted message and keyspace session
|
||||||
|
if (elements.decryptBtn && elements.encryptedMessageInput) {
|
||||||
|
elements.decryptBtn.disabled = !elements.encryptedMessageInput.value.trim() || !currentKeyspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify button (with null checks) - only needs message and signature
|
||||||
|
if (elements.verifyBtn && elements.verifyMessageInput && elements.signatureToVerifyInput) {
|
||||||
|
elements.verifyBtn.disabled = !elements.verifyMessageInput.value.trim() ||
|
||||||
|
!elements.signatureToVerifyInput.value.trim() ||
|
||||||
|
!selectedKeypairId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all vault-related state and UI
|
||||||
|
function clearVaultState() {
|
||||||
|
// Clear all crypto operation inputs (with null checks)
|
||||||
|
if (elements.messageInput) elements.messageInput.value = '';
|
||||||
|
if (elements.encryptMessageInput) elements.encryptMessageInput.value = '';
|
||||||
|
if (elements.encryptedMessageInput) elements.encryptedMessageInput.value = '';
|
||||||
|
if (elements.verifyMessageInput) elements.verifyMessageInput.value = '';
|
||||||
|
if (elements.signatureToVerifyInput) elements.signatureToVerifyInput.value = '';
|
||||||
|
|
||||||
|
// Clear all result sections
|
||||||
|
clearTabResults();
|
||||||
|
|
||||||
|
// Clear signature value with null check
|
||||||
|
const signatureValue = document.getElementById('signatureValue');
|
||||||
|
if (signatureValue) signatureValue.textContent = '';
|
||||||
|
|
||||||
|
// Clear selected keypair state
|
||||||
|
selectedKeypairId = null;
|
||||||
|
updateButtonStates();
|
||||||
|
|
||||||
|
// Hide add keypair form if open
|
||||||
|
hideAddKeypairForm();
|
||||||
|
|
||||||
|
// Clear keypairs list
|
||||||
|
if (elements.keypairsList) {
|
||||||
|
elements.keypairsList.innerHTML = '<div class="loading">Loading keypairs...</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation utilities
|
||||||
|
const validateAuth = () => {
|
||||||
|
const keyspace = elements.keyspaceInput.value.trim();
|
||||||
|
const password = elements.passwordInput.value.trim();
|
||||||
|
|
||||||
|
if (!validateInput(keyspace, 'Keyspace name', { minLength: 1, maxLength: 100 })) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateInput(password, 'Password', { minLength: 1, maxLength: 1000 })) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { keyspace, password };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createKeyspace() {
|
||||||
|
const auth = validateAuth();
|
||||||
|
if (!auth) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeOperation(
|
||||||
|
async () => {
|
||||||
|
const response = await sendMessage('createKeyspace', auth);
|
||||||
|
if (response?.success) {
|
||||||
|
clearVaultState();
|
||||||
|
await login(); // Auto-login after creation
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
throw new Error(getResponseError(response, 'create keyspace'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loadingElement: elements.createKeyspaceBtn,
|
||||||
|
successMessage: 'Keyspace created successfully!',
|
||||||
|
maxRetries: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Create keyspace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
const auth = validateAuth();
|
||||||
|
if (!auth) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executeOperation(
|
||||||
|
async () => {
|
||||||
|
const response = await sendMessage('initSession', auth);
|
||||||
|
if (response?.success) {
|
||||||
|
currentKeyspace = auth.keyspace;
|
||||||
|
setStatus(auth.keyspace, true);
|
||||||
|
showSection('vaultSection');
|
||||||
|
clearVaultState();
|
||||||
|
await loadKeypairs();
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
throw new Error(getResponseError(response, 'login'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loadingElement: elements.loginBtn,
|
||||||
|
successMessage: 'Logged in successfully!',
|
||||||
|
maxRetries: 2
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Create keyspace');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockSession() {
|
||||||
|
try {
|
||||||
|
await sendMessage('lockSession');
|
||||||
|
currentKeyspace = null;
|
||||||
|
selectedKeypairId = null;
|
||||||
|
setStatus('', false);
|
||||||
|
showSection('authSection');
|
||||||
|
|
||||||
|
// Clear all form inputs
|
||||||
|
elements.keyspaceInput.value = '';
|
||||||
|
elements.passwordInput.value = '';
|
||||||
|
clearVaultState();
|
||||||
|
|
||||||
|
showToast('Session locked', 'info');
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Error: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addKeypair() {
|
||||||
|
const keyType = elements.keyTypeSelect.value;
|
||||||
|
const keyName = elements.keyNameInput.value.trim();
|
||||||
|
|
||||||
|
if (!keyName) {
|
||||||
|
showToast('Please enter a name for the keypair', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeOperation(
|
||||||
|
async () => {
|
||||||
|
const metadata = JSON.stringify({ name: keyName });
|
||||||
|
const response = await sendMessage('addKeypair', { keyType, metadata });
|
||||||
|
|
||||||
|
if (response?.success) {
|
||||||
|
hideAddKeypairForm();
|
||||||
|
await loadKeypairs();
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
throw new Error(getResponseError(response, 'add keypair'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loadingElement: elements.addKeypairBtn,
|
||||||
|
successMessage: 'Keypair added successfully!'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKeypairs() {
|
||||||
|
const response = await sendMessage('listKeypairs');
|
||||||
|
|
||||||
|
if (response && response.success) {
|
||||||
|
renderKeypairs(response.keypairs);
|
||||||
|
} else {
|
||||||
|
const errorMsg = getResponseError(response, 'load keypairs');
|
||||||
|
const container = elements.keypairsList;
|
||||||
|
container.innerHTML = '<div class="empty-state">Failed to load keypairs. Try refreshing.</div>';
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderKeypairs(keypairs) {
|
||||||
|
const container = elements.keypairsList;
|
||||||
|
|
||||||
|
// Simple array handling
|
||||||
|
const keypairArray = Array.isArray(keypairs) ? keypairs : [];
|
||||||
|
|
||||||
|
if (keypairArray.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No keypairs found. Add one above.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = keypairArray.map((keypair) => {
|
||||||
|
const metadata = typeof keypair.metadata === 'string'
|
||||||
|
? JSON.parse(keypair.metadata)
|
||||||
|
: keypair.metadata;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="keypair-item" data-keypair-id="${keypair.id}" role="button" tabindex="0">
|
||||||
|
<div class="keypair-info">
|
||||||
|
<div class="keypair-header">
|
||||||
|
<div class="keypair-name">${metadata.name || 'Unnamed'}</div>
|
||||||
|
<div class="keypair-type">${keypair.key_type}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add event listeners to all keypair cards
|
||||||
|
container.querySelectorAll('.keypair-item').forEach(card => {
|
||||||
|
card.addEventListener('click', (e) => {
|
||||||
|
const keypairId = e.currentTarget.getAttribute('data-keypair-id');
|
||||||
|
selectKeypair(keypairId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add keyboard support
|
||||||
|
card.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
const keypairId = e.currentTarget.getAttribute('data-keypair-id');
|
||||||
|
selectKeypair(keypairId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore selection state if there was a previously selected keypair
|
||||||
|
if (selectedKeypairId) {
|
||||||
|
updateKeypairSelection(selectedKeypairId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectKeypair(keyId) {
|
||||||
|
// Don't show loading overlay for selection - it's too disruptive
|
||||||
|
try {
|
||||||
|
// Update visual state immediately for better UX
|
||||||
|
updateKeypairSelection(keyId);
|
||||||
|
|
||||||
|
await sendMessage('selectKeypair', { keyId });
|
||||||
|
selectedKeypairId = keyId;
|
||||||
|
|
||||||
|
// Get keypair details for internal use (but don't show the card)
|
||||||
|
const metadataResponse = await sendMessage('getCurrentKeypairMetadata');
|
||||||
|
const publicKeyResponse = await sendMessage('getCurrentKeypairPublicKey');
|
||||||
|
|
||||||
|
if (metadataResponse && metadataResponse.success && publicKeyResponse && publicKeyResponse.success) {
|
||||||
|
// Enable sign button if message is entered
|
||||||
|
updateButtonStates();
|
||||||
|
} else {
|
||||||
|
// Handle metadata or public key fetch failure
|
||||||
|
const metadataError = getResponseError(metadataResponse, 'get keypair metadata');
|
||||||
|
const publicKeyError = getResponseError(publicKeyResponse, 'get public key');
|
||||||
|
const errorMsg = metadataResponse && !metadataResponse.success ? metadataError : publicKeyError;
|
||||||
|
|
||||||
|
updateKeypairSelection(null);
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = getErrorMessage(error, 'Failed to select keypair');
|
||||||
|
// Revert visual state if there was an error
|
||||||
|
updateKeypairSelection(null);
|
||||||
|
showToast(errorMsg, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKeypairSelection(selectedId) {
|
||||||
|
// Remove previous selection styling from all keypair items
|
||||||
|
const allKeypairs = document.querySelectorAll('.keypair-item');
|
||||||
|
allKeypairs.forEach(item => {
|
||||||
|
item.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add selection styling to the selected keypair (if any)
|
||||||
|
if (selectedId) {
|
||||||
|
const selectedKeypair = document.querySelector(`[data-keypair-id="${selectedId}"]`);
|
||||||
|
if (selectedKeypair) {
|
||||||
|
selectedKeypair.classList.add('selected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared templates
|
||||||
|
const copyIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const createResultContainer = (label, value, btnId) => `
|
||||||
|
<label>${label}:</label>
|
||||||
|
<div class="signature-container">
|
||||||
|
<code id="${value}Value">${value}</code>
|
||||||
|
<button id="${btnId}" class="btn-copy" title="Copy">${copyIcon}</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Unified crypto operation handler
|
||||||
|
async function performCryptoOperation(config) {
|
||||||
|
const { input, validation, action, resultElement, button, successMsg, resultProcessor } = config;
|
||||||
|
|
||||||
|
if (!validation()) {
|
||||||
|
showToast(config.errorMsg, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeOperation(
|
||||||
|
async () => {
|
||||||
|
const response = await sendMessage(action, input());
|
||||||
|
if (response?.success) {
|
||||||
|
resultElement.classList.remove('hidden');
|
||||||
|
resultElement.innerHTML = resultProcessor(response);
|
||||||
|
|
||||||
|
// Add copy button listener if result has copy button
|
||||||
|
const copyBtn = resultElement.querySelector('.btn-copy');
|
||||||
|
if (copyBtn && config.copyValue) {
|
||||||
|
copyBtn.addEventListener('click', () => copyToClipboard(config.copyValue(response)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
throw new Error(getResponseError(response, action));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ loadingElement: button, successMessage: successMsg }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crypto operation functions using shared templates
|
||||||
|
const signMessage = () => performCryptoOperation({
|
||||||
|
validation: () => elements.messageInput.value.trim() && selectedKeypairId,
|
||||||
|
errorMsg: 'Please enter a message and select a keypair',
|
||||||
|
action: 'sign',
|
||||||
|
input: () => ({ message: stringToUint8Array(elements.messageInput.value.trim()) }),
|
||||||
|
resultElement: elements.signatureResult,
|
||||||
|
button: elements.signBtn,
|
||||||
|
successMsg: 'Message signed successfully!',
|
||||||
|
copyValue: (response) => response.signature,
|
||||||
|
resultProcessor: (response) => createResultContainer('Signature', response.signature, 'copySignatureBtn')
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptMessage = () => performCryptoOperation({
|
||||||
|
validation: () => elements.encryptMessageInput.value.trim() && currentKeyspace,
|
||||||
|
errorMsg: 'Please enter a message and ensure you are connected to a keyspace',
|
||||||
|
action: 'encrypt',
|
||||||
|
input: () => ({ message: elements.encryptMessageInput.value.trim() }),
|
||||||
|
resultElement: elements.encryptResult,
|
||||||
|
button: elements.encryptBtn,
|
||||||
|
successMsg: 'Message encrypted successfully!',
|
||||||
|
copyValue: (response) => response.encryptedMessage,
|
||||||
|
resultProcessor: (response) => createResultContainer('Encrypted Message', response.encryptedMessage, 'copyEncryptedBtn')
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptMessage = () => performCryptoOperation({
|
||||||
|
validation: () => elements.encryptedMessageInput.value.trim() && currentKeyspace,
|
||||||
|
errorMsg: 'Please enter encrypted message and ensure you are connected to a keyspace',
|
||||||
|
action: 'decrypt',
|
||||||
|
input: () => ({ encryptedMessage: elements.encryptedMessageInput.value.trim() }),
|
||||||
|
resultElement: elements.decryptResult,
|
||||||
|
button: elements.decryptBtn,
|
||||||
|
successMsg: 'Message decrypted successfully!',
|
||||||
|
copyValue: (response) => response.decryptedMessage,
|
||||||
|
resultProcessor: (response) => createResultContainer('Decrypted Message', response.decryptedMessage, 'copyDecryptedBtn')
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifySignature = () => performCryptoOperation({
|
||||||
|
validation: () => elements.verifyMessageInput.value.trim() && elements.signatureToVerifyInput.value.trim() && selectedKeypairId,
|
||||||
|
errorMsg: 'Please enter message, signature, and select a keypair',
|
||||||
|
action: 'verify',
|
||||||
|
input: () => ({
|
||||||
|
message: stringToUint8Array(elements.verifyMessageInput.value.trim()),
|
||||||
|
signature: elements.signatureToVerifyInput.value.trim()
|
||||||
|
}),
|
||||||
|
resultElement: elements.verifyResult,
|
||||||
|
button: elements.verifyBtn,
|
||||||
|
successMsg: null,
|
||||||
|
resultProcessor: (response) => {
|
||||||
|
const isValid = response.isValid;
|
||||||
|
const icon = isValid
|
||||||
|
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="20,6 9,17 4,12"></polyline>
|
||||||
|
</svg>`
|
||||||
|
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||||
|
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||||
|
</svg>`;
|
||||||
|
const text = isValid ? 'Signature is valid' : 'Signature is invalid';
|
||||||
|
return `<div class="verification-status ${isValid ? 'valid' : 'invalid'}">
|
||||||
|
<span class="verification-icon">${icon}</span>
|
||||||
|
<span>${text}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
});
|
1072
crypto_vault_extension/styles/popup.css
Normal file
861
crypto_vault_extension/wasm/wasm_app.js
Normal file
@ -0,0 +1,861 @@
|
|||||||
|
let wasm;
|
||||||
|
|
||||||
|
function addToExternrefTable0(obj) {
|
||||||
|
const idx = wasm.__externref_table_alloc();
|
||||||
|
wasm.__wbindgen_export_2.set(idx, obj);
|
||||||
|
return idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(f, args) {
|
||||||
|
try {
|
||||||
|
return f.apply(this, args);
|
||||||
|
} catch (e) {
|
||||||
|
const idx = addToExternrefTable0(e);
|
||||||
|
wasm.__wbindgen_exn_store(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
|
||||||
|
|
||||||
|
if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); };
|
||||||
|
|
||||||
|
let cachedUint8ArrayMemory0 = null;
|
||||||
|
|
||||||
|
function getUint8ArrayMemory0() {
|
||||||
|
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||||
|
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachedUint8ArrayMemory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringFromWasm0(ptr, len) {
|
||||||
|
ptr = ptr >>> 0;
|
||||||
|
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikeNone(x) {
|
||||||
|
return x === undefined || x === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArrayU8FromWasm0(ptr, len) {
|
||||||
|
ptr = ptr >>> 0;
|
||||||
|
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||||
|
}
|
||||||
|
|
||||||
|
let WASM_VECTOR_LEN = 0;
|
||||||
|
|
||||||
|
const cachedTextEncoder = (typeof TextEncoder !== 'undefined' ? new TextEncoder('utf-8') : { encode: () => { throw Error('TextEncoder not available') } } );
|
||||||
|
|
||||||
|
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
|
||||||
|
? function (arg, view) {
|
||||||
|
return cachedTextEncoder.encodeInto(arg, view);
|
||||||
|
}
|
||||||
|
: function (arg, view) {
|
||||||
|
const buf = cachedTextEncoder.encode(arg);
|
||||||
|
view.set(buf);
|
||||||
|
return {
|
||||||
|
read: arg.length,
|
||||||
|
written: buf.length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function passStringToWasm0(arg, malloc, realloc) {
|
||||||
|
|
||||||
|
if (realloc === undefined) {
|
||||||
|
const buf = cachedTextEncoder.encode(arg);
|
||||||
|
const ptr = malloc(buf.length, 1) >>> 0;
|
||||||
|
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||||
|
WASM_VECTOR_LEN = buf.length;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let len = arg.length;
|
||||||
|
let ptr = malloc(len, 1) >>> 0;
|
||||||
|
|
||||||
|
const mem = getUint8ArrayMemory0();
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (; offset < len; offset++) {
|
||||||
|
const code = arg.charCodeAt(offset);
|
||||||
|
if (code > 0x7F) break;
|
||||||
|
mem[ptr + offset] = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset !== len) {
|
||||||
|
if (offset !== 0) {
|
||||||
|
arg = arg.slice(offset);
|
||||||
|
}
|
||||||
|
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||||
|
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||||
|
const ret = encodeString(arg, view);
|
||||||
|
|
||||||
|
offset += ret.written;
|
||||||
|
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
WASM_VECTOR_LEN = offset;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedDataViewMemory0 = null;
|
||||||
|
|
||||||
|
function getDataViewMemory0() {
|
||||||
|
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||||
|
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||||
|
}
|
||||||
|
return cachedDataViewMemory0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
|
||||||
|
? { register: () => {}, unregister: () => {} }
|
||||||
|
: new FinalizationRegistry(state => {
|
||||||
|
wasm.__wbindgen_export_5.get(state.dtor)(state.a, state.b)
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeMutClosure(arg0, arg1, dtor, f) {
|
||||||
|
const state = { a: arg0, b: arg1, cnt: 1, dtor };
|
||||||
|
const real = (...args) => {
|
||||||
|
// First up with a closure we increment the internal reference
|
||||||
|
// count. This ensures that the Rust closure environment won't
|
||||||
|
// be deallocated while we're invoking it.
|
||||||
|
state.cnt++;
|
||||||
|
const a = state.a;
|
||||||
|
state.a = 0;
|
||||||
|
try {
|
||||||
|
return f(a, state.b, ...args);
|
||||||
|
} finally {
|
||||||
|
if (--state.cnt === 0) {
|
||||||
|
wasm.__wbindgen_export_5.get(state.dtor)(a, state.b);
|
||||||
|
CLOSURE_DTORS.unregister(state);
|
||||||
|
} else {
|
||||||
|
state.a = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
real.original = state;
|
||||||
|
CLOSURE_DTORS.register(real, state, state);
|
||||||
|
return real;
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugString(val) {
|
||||||
|
// primitive types
|
||||||
|
const type = typeof val;
|
||||||
|
if (type == 'number' || type == 'boolean' || val == null) {
|
||||||
|
return `${val}`;
|
||||||
|
}
|
||||||
|
if (type == 'string') {
|
||||||
|
return `"${val}"`;
|
||||||
|
}
|
||||||
|
if (type == 'symbol') {
|
||||||
|
const description = val.description;
|
||||||
|
if (description == null) {
|
||||||
|
return 'Symbol';
|
||||||
|
} else {
|
||||||
|
return `Symbol(${description})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type == 'function') {
|
||||||
|
const name = val.name;
|
||||||
|
if (typeof name == 'string' && name.length > 0) {
|
||||||
|
return `Function(${name})`;
|
||||||
|
} else {
|
||||||
|
return 'Function';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// objects
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
const length = val.length;
|
||||||
|
let debug = '[';
|
||||||
|
if (length > 0) {
|
||||||
|
debug += debugString(val[0]);
|
||||||
|
}
|
||||||
|
for(let i = 1; i < length; i++) {
|
||||||
|
debug += ', ' + debugString(val[i]);
|
||||||
|
}
|
||||||
|
debug += ']';
|
||||||
|
return debug;
|
||||||
|
}
|
||||||
|
// Test for built-in
|
||||||
|
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
|
||||||
|
let className;
|
||||||
|
if (builtInMatches && builtInMatches.length > 1) {
|
||||||
|
className = builtInMatches[1];
|
||||||
|
} else {
|
||||||
|
// Failed to match the standard '[object ClassName]'
|
||||||
|
return toString.call(val);
|
||||||
|
}
|
||||||
|
if (className == 'Object') {
|
||||||
|
// we're a user defined class or Object
|
||||||
|
// JSON.stringify avoids problems with cycles, and is generally much
|
||||||
|
// easier than looping through ownProperties of `val`.
|
||||||
|
try {
|
||||||
|
return 'Object(' + JSON.stringify(val) + ')';
|
||||||
|
} catch (_) {
|
||||||
|
return 'Object';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// errors
|
||||||
|
if (val instanceof Error) {
|
||||||
|
return `${val.name}: ${val.message}\n${val.stack}`;
|
||||||
|
}
|
||||||
|
// TODO we could test for more things here, like `Set`s and `Map`s.
|
||||||
|
return className;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create and unlock a new keyspace with the given name and password
|
||||||
|
* @param {string} keyspace
|
||||||
|
* @param {string} password
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function create_keyspace(keyspace, password) {
|
||||||
|
const ptr0 = passStringToWasm0(keyspace, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ptr1 = passStringToWasm0(password, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.create_keyspace(ptr0, len0, ptr1, len1);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize session with keyspace and password
|
||||||
|
* @param {string} keyspace
|
||||||
|
* @param {string} password
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function init_session(keyspace, password) {
|
||||||
|
const ptr0 = passStringToWasm0(keyspace, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ptr1 = passStringToWasm0(password, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.init_session(ptr0, len0, ptr1, len1);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock the session (zeroize password and session)
|
||||||
|
*/
|
||||||
|
export function lock_session() {
|
||||||
|
wasm.lock_session();
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeFromExternrefTable0(idx) {
|
||||||
|
const value = wasm.__wbindgen_export_2.get(idx);
|
||||||
|
wasm.__externref_table_dealloc(idx);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get metadata of the currently selected keypair
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export function current_keypair_metadata() {
|
||||||
|
const ret = wasm.current_keypair_metadata();
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public key of the currently selected keypair as Uint8Array
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export function current_keypair_public_key() {
|
||||||
|
const ret = wasm.current_keypair_public_key();
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if a keyspace is currently unlocked
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function is_unlocked() {
|
||||||
|
const ret = wasm.is_unlocked();
|
||||||
|
return ret !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all keypairs from the current session
|
||||||
|
* Returns an array of keypair objects with id, type, and metadata
|
||||||
|
* Select keypair for the session
|
||||||
|
* @param {string} key_id
|
||||||
|
*/
|
||||||
|
export function select_keypair(key_id) {
|
||||||
|
const ptr0 = passStringToWasm0(key_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.select_keypair(ptr0, len0);
|
||||||
|
if (ret[1]) {
|
||||||
|
throw takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List keypairs in the current session's keyspace
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export function list_keypairs() {
|
||||||
|
const ret = wasm.list_keypairs();
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a keypair to the current keyspace
|
||||||
|
* @param {string | null} [key_type]
|
||||||
|
* @param {string | null} [metadata]
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export function add_keypair(key_type, metadata) {
|
||||||
|
var ptr0 = isLikeNone(key_type) ? 0 : passStringToWasm0(key_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
var len0 = WASM_VECTOR_LEN;
|
||||||
|
var ptr1 = isLikeNone(metadata) ? 0 : passStringToWasm0(metadata, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
var len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.add_keypair(ptr0, len0, ptr1, len1);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function passArray8ToWasm0(arg, malloc) {
|
||||||
|
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||||
|
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||||
|
WASM_VECTOR_LEN = arg.length;
|
||||||
|
return ptr;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Sign message with current session
|
||||||
|
* @param {Uint8Array} message
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export function sign(message) {
|
||||||
|
const ptr0 = passArray8ToWasm0(message, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.sign(ptr0, len0);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signature with the current session's selected keypair
|
||||||
|
* @param {Uint8Array} message
|
||||||
|
* @param {string} signature
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export function verify(message, signature) {
|
||||||
|
const ptr0 = passArray8ToWasm0(message, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ptr1 = passStringToWasm0(signature, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.verify(ptr0, len0, ptr1, len1);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data using the current session's keyspace symmetric cipher
|
||||||
|
* @param {Uint8Array} data
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export function encrypt_data(data) {
|
||||||
|
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.encrypt_data(ptr0, len0);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data using the current session's keyspace symmetric cipher
|
||||||
|
* @param {Uint8Array} encrypted
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
export function decrypt_data(encrypted) {
|
||||||
|
const ptr0 = passArray8ToWasm0(encrypted, wasm.__wbindgen_malloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.decrypt_data(ptr0, len0);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the scripting environment (must be called before run_rhai)
|
||||||
|
*/
|
||||||
|
export function init_rhai_env() {
|
||||||
|
wasm.init_rhai_env();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Securely run a Rhai script in the extension context (must be called only after user approval)
|
||||||
|
* @param {string} script
|
||||||
|
* @returns {any}
|
||||||
|
*/
|
||||||
|
export function run_rhai(script) {
|
||||||
|
const ptr0 = passStringToWasm0(script, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len0 = WASM_VECTOR_LEN;
|
||||||
|
const ret = wasm.run_rhai(ptr0, len0);
|
||||||
|
if (ret[2]) {
|
||||||
|
throw takeFromExternrefTable0(ret[1]);
|
||||||
|
}
|
||||||
|
return takeFromExternrefTable0(ret[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function __wbg_adapter_32(arg0, arg1, arg2) {
|
||||||
|
wasm.closure121_externref_shim(arg0, arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function __wbg_adapter_35(arg0, arg1, arg2) {
|
||||||
|
wasm.closure150_externref_shim(arg0, arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function __wbg_adapter_38(arg0, arg1, arg2) {
|
||||||
|
wasm.closure227_externref_shim(arg0, arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function __wbg_adapter_138(arg0, arg1, arg2, arg3) {
|
||||||
|
wasm.closure1879_externref_shim(arg0, arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const __wbindgen_enum_IdbTransactionMode = ["readonly", "readwrite", "versionchange", "readwriteflush", "cleanup"];
|
||||||
|
|
||||||
|
async function __wbg_load(module, imports) {
|
||||||
|
if (typeof Response === 'function' && module instanceof Response) {
|
||||||
|
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||||
|
try {
|
||||||
|
return await WebAssembly.instantiateStreaming(module, imports);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (module.headers.get('Content-Type') != 'application/wasm') {
|
||||||
|
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await module.arrayBuffer();
|
||||||
|
return await WebAssembly.instantiate(bytes, imports);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const instance = await WebAssembly.instantiate(module, imports);
|
||||||
|
|
||||||
|
if (instance instanceof WebAssembly.Instance) {
|
||||||
|
return { instance, module };
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function __wbg_get_imports() {
|
||||||
|
const imports = {};
|
||||||
|
imports.wbg = {};
|
||||||
|
imports.wbg.__wbg_buffer_609cc3eee51ed158 = function(arg0) {
|
||||||
|
const ret = arg0.buffer;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_call_672a4d21634d4a24 = function() { return handleError(function (arg0, arg1) {
|
||||||
|
const ret = arg0.call(arg1);
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_call_7cccdd69e0791ae2 = function() { return handleError(function (arg0, arg1, arg2) {
|
||||||
|
const ret = arg0.call(arg1, arg2);
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_createObjectStore_d2f9e1016f4d81b9 = function() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||||
|
const ret = arg0.createObjectStore(getStringFromWasm0(arg1, arg2), arg3);
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_crypto_574e78ad8b13b65f = function(arg0) {
|
||||||
|
const ret = arg0.crypto;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_error_524f506f44df1645 = function(arg0) {
|
||||||
|
console.error(arg0);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_error_ff4ddaabdfc5dbb3 = function() { return handleError(function (arg0) {
|
||||||
|
const ret = arg0.error;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_getRandomValues_3c9c0d586e575a16 = function() { return handleError(function (arg0, arg1) {
|
||||||
|
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_getRandomValues_b8f5dbd5f3995a9e = function() { return handleError(function (arg0, arg1) {
|
||||||
|
arg0.getRandomValues(arg1);
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_getTime_46267b1c24877e30 = function(arg0) {
|
||||||
|
const ret = arg0.getTime();
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_get_4f73335ab78445db = function(arg0, arg1, arg2) {
|
||||||
|
const ret = arg1[arg2 >>> 0];
|
||||||
|
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
var len1 = WASM_VECTOR_LEN;
|
||||||
|
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||||
|
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_get_67b2ba62fc30de12 = function() { return handleError(function (arg0, arg1) {
|
||||||
|
const ret = Reflect.get(arg0, arg1);
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_get_8da03f81f6a1111e = function() { return handleError(function (arg0, arg1) {
|
||||||
|
const ret = arg0.get(arg1);
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_instanceof_IdbDatabase_a3ef009ca00059f9 = function(arg0) {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = arg0 instanceof IDBDatabase;
|
||||||
|
} catch (_) {
|
||||||
|
result = false;
|
||||||
|
}
|
||||||
|
const ret = result;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_instanceof_IdbFactory_12eaba3366f4302f = function(arg0) {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = arg0 instanceof IDBFactory;
|
||||||
|
} catch (_) {
|
||||||
|
result = false;
|
||||||
|
}
|
||||||
|
const ret = result;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_instanceof_IdbOpenDbRequest_a3416e156c9db893 = function(arg0) {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = arg0 instanceof IDBOpenDBRequest;
|
||||||
|
} catch (_) {
|
||||||
|
result = false;
|
||||||
|
}
|
||||||
|
const ret = result;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_instanceof_IdbRequest_4813c3f207666aa4 = function(arg0) {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = arg0 instanceof IDBRequest;
|
||||||
|
} catch (_) {
|
||||||
|
result = false;
|
||||||
|
}
|
||||||
|
const ret = result;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_length_52b6c4580c5ec934 = function(arg0) {
|
||||||
|
const ret = arg0.length;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_msCrypto_a61aeb35a24c1329 = function(arg0) {
|
||||||
|
const ret = arg0.msCrypto;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_new0_f788a2397c7ca929 = function() {
|
||||||
|
const ret = new Date();
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_new_23a2665fac83c611 = function(arg0, arg1) {
|
||||||
|
try {
|
||||||
|
var state0 = {a: arg0, b: arg1};
|
||||||
|
var cb0 = (arg0, arg1) => {
|
||||||
|
const a = state0.a;
|
||||||
|
state0.a = 0;
|
||||||
|
try {
|
||||||
|
return __wbg_adapter_138(a, state0.b, arg0, arg1);
|
||||||
|
} finally {
|
||||||
|
state0.a = a;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ret = new Promise(cb0);
|
||||||
|
return ret;
|
||||||
|
} finally {
|
||||||
|
state0.a = state0.b = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_new_405e22f390576ce2 = function() {
|
||||||
|
const ret = new Object();
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_new_78feb108b6472713 = function() {
|
||||||
|
const ret = new Array();
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_new_a12002a7f91c75be = function(arg0) {
|
||||||
|
const ret = new Uint8Array(arg0);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_newnoargs_105ed471475aaf50 = function(arg0, arg1) {
|
||||||
|
const ret = new Function(getStringFromWasm0(arg0, arg1));
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_newwithbyteoffsetandlength_d97e637ebe145a9a = function(arg0, arg1, arg2) {
|
||||||
|
const ret = new Uint8Array(arg0, arg1 >>> 0, arg2 >>> 0);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_newwithlength_a381634e90c276d4 = function(arg0) {
|
||||||
|
const ret = new Uint8Array(arg0 >>> 0);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_node_905d3e251edff8a2 = function(arg0) {
|
||||||
|
const ret = arg0.node;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_now_d18023d54d4e5500 = function(arg0) {
|
||||||
|
const ret = arg0.now();
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_objectStoreNames_9bb1ab04a7012aaf = function(arg0) {
|
||||||
|
const ret = arg0.objectStoreNames;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_objectStore_21878d46d25b64b6 = function() { return handleError(function (arg0, arg1, arg2) {
|
||||||
|
const ret = arg0.objectStore(getStringFromWasm0(arg1, arg2));
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_open_88b1390d99a7c691 = function() { return handleError(function (arg0, arg1, arg2) {
|
||||||
|
const ret = arg0.open(getStringFromWasm0(arg1, arg2));
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_open_e0c0b2993eb596e1 = function() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||||
|
const ret = arg0.open(getStringFromWasm0(arg1, arg2), arg3 >>> 0);
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_process_dc0fbacc7c1c06f7 = function(arg0) {
|
||||||
|
const ret = arg0.process;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_push_737cfc8c1432c2c6 = function(arg0, arg1) {
|
||||||
|
const ret = arg0.push(arg1);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_put_066faa31a6a88f5b = function() { return handleError(function (arg0, arg1, arg2) {
|
||||||
|
const ret = arg0.put(arg1, arg2);
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_put_9ef5363941008835 = function() { return handleError(function (arg0, arg1) {
|
||||||
|
const ret = arg0.put(arg1);
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_queueMicrotask_97d92b4fcc8a61c5 = function(arg0) {
|
||||||
|
queueMicrotask(arg0);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_queueMicrotask_d3219def82552485 = function(arg0) {
|
||||||
|
const ret = arg0.queueMicrotask;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_randomFillSync_ac0988aba3254290 = function() { return handleError(function (arg0, arg1) {
|
||||||
|
arg0.randomFillSync(arg1);
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_require_60cc747a6bc5215a = function() { return handleError(function () {
|
||||||
|
const ret = module.require;
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_resolve_4851785c9c5f573d = function(arg0) {
|
||||||
|
const ret = Promise.resolve(arg0);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_result_f29afabdf2c05826 = function() { return handleError(function (arg0) {
|
||||||
|
const ret = arg0.result;
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_set_65595bdd868b3009 = function(arg0, arg1, arg2) {
|
||||||
|
arg0.set(arg1, arg2 >>> 0);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_setonerror_d7e3056cc6e56085 = function(arg0, arg1) {
|
||||||
|
arg0.onerror = arg1;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_setonsuccess_afa464ee777a396d = function(arg0, arg1) {
|
||||||
|
arg0.onsuccess = arg1;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_setonupgradeneeded_fcf7ce4f2eb0cb5f = function(arg0, arg1) {
|
||||||
|
arg0.onupgradeneeded = arg1;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_static_accessor_GLOBAL_88a902d13a557d07 = function() {
|
||||||
|
const ret = typeof global === 'undefined' ? null : global;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_static_accessor_GLOBAL_THIS_56578be7e9f832b0 = function() {
|
||||||
|
const ret = typeof globalThis === 'undefined' ? null : globalThis;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_static_accessor_SELF_37c5d418e4bf5819 = function() {
|
||||||
|
const ret = typeof self === 'undefined' ? null : self;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_static_accessor_WINDOW_5de37043a91a9c40 = function() {
|
||||||
|
const ret = typeof window === 'undefined' ? null : window;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_subarray_aa9065fa9dc5df96 = function(arg0, arg1, arg2) {
|
||||||
|
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_target_0a62d9d79a2a1ede = function(arg0) {
|
||||||
|
const ret = arg0.target;
|
||||||
|
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_then_44b73946d2fb3e7d = function(arg0, arg1) {
|
||||||
|
const ret = arg0.then(arg1);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbg_transaction_d6d07c3c9963c49e = function() { return handleError(function (arg0, arg1, arg2) {
|
||||||
|
const ret = arg0.transaction(arg1, __wbindgen_enum_IdbTransactionMode[arg2]);
|
||||||
|
return ret;
|
||||||
|
}, arguments) };
|
||||||
|
imports.wbg.__wbg_versions_c01dfd4722a88165 = function(arg0) {
|
||||||
|
const ret = arg0.versions;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_cb_drop = function(arg0) {
|
||||||
|
const obj = arg0.original;
|
||||||
|
if (obj.cnt-- == 1) {
|
||||||
|
obj.a = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const ret = false;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_closure_wrapper378 = function(arg0, arg1, arg2) {
|
||||||
|
const ret = makeMutClosure(arg0, arg1, 122, __wbg_adapter_32);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_closure_wrapper549 = function(arg0, arg1, arg2) {
|
||||||
|
const ret = makeMutClosure(arg0, arg1, 151, __wbg_adapter_35);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_closure_wrapper857 = function(arg0, arg1, arg2) {
|
||||||
|
const ret = makeMutClosure(arg0, arg1, 228, __wbg_adapter_38);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_debug_string = function(arg0, arg1) {
|
||||||
|
const ret = debugString(arg1);
|
||||||
|
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||||
|
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_init_externref_table = function() {
|
||||||
|
const table = wasm.__wbindgen_export_2;
|
||||||
|
const offset = table.grow(4);
|
||||||
|
table.set(0, undefined);
|
||||||
|
table.set(offset + 0, undefined);
|
||||||
|
table.set(offset + 1, null);
|
||||||
|
table.set(offset + 2, true);
|
||||||
|
table.set(offset + 3, false);
|
||||||
|
;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_is_function = function(arg0) {
|
||||||
|
const ret = typeof(arg0) === 'function';
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_is_null = function(arg0) {
|
||||||
|
const ret = arg0 === null;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_is_object = function(arg0) {
|
||||||
|
const val = arg0;
|
||||||
|
const ret = typeof(val) === 'object' && val !== null;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_is_string = function(arg0) {
|
||||||
|
const ret = typeof(arg0) === 'string';
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_is_undefined = function(arg0) {
|
||||||
|
const ret = arg0 === undefined;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_json_parse = function(arg0, arg1) {
|
||||||
|
const ret = JSON.parse(getStringFromWasm0(arg0, arg1));
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_json_serialize = function(arg0, arg1) {
|
||||||
|
const obj = arg1;
|
||||||
|
const ret = JSON.stringify(obj === undefined ? null : obj);
|
||||||
|
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||||
|
const len1 = WASM_VECTOR_LEN;
|
||||||
|
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||||
|
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_memory = function() {
|
||||||
|
const ret = wasm.memory;
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_string_new = function(arg0, arg1) {
|
||||||
|
const ret = getStringFromWasm0(arg0, arg1);
|
||||||
|
return ret;
|
||||||
|
};
|
||||||
|
imports.wbg.__wbindgen_throw = function(arg0, arg1) {
|
||||||
|
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||||
|
};
|
||||||
|
|
||||||
|
return imports;
|
||||||
|
}
|
||||||
|
|
||||||
|
function __wbg_init_memory(imports, memory) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function __wbg_finalize_init(instance, module) {
|
||||||
|
wasm = instance.exports;
|
||||||
|
__wbg_init.__wbindgen_wasm_module = module;
|
||||||
|
cachedDataViewMemory0 = null;
|
||||||
|
cachedUint8ArrayMemory0 = null;
|
||||||
|
|
||||||
|
|
||||||
|
wasm.__wbindgen_start();
|
||||||
|
return wasm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSync(module) {
|
||||||
|
if (wasm !== undefined) return wasm;
|
||||||
|
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined') {
|
||||||
|
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||||
|
({module} = module)
|
||||||
|
} else {
|
||||||
|
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imports = __wbg_get_imports();
|
||||||
|
|
||||||
|
__wbg_init_memory(imports);
|
||||||
|
|
||||||
|
if (!(module instanceof WebAssembly.Module)) {
|
||||||
|
module = new WebAssembly.Module(module);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new WebAssembly.Instance(module, imports);
|
||||||
|
|
||||||
|
return __wbg_finalize_init(instance, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function __wbg_init(module_or_path) {
|
||||||
|
if (wasm !== undefined) return wasm;
|
||||||
|
|
||||||
|
|
||||||
|
if (typeof module_or_path !== 'undefined') {
|
||||||
|
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||||
|
({module_or_path} = module_or_path)
|
||||||
|
} else {
|
||||||
|
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module_or_path === 'undefined') {
|
||||||
|
module_or_path = new URL('wasm_app_bg.wasm', import.meta.url);
|
||||||
|
}
|
||||||
|
const imports = __wbg_get_imports();
|
||||||
|
|
||||||
|
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||||
|
module_or_path = fetch(module_or_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
__wbg_init_memory(imports);
|
||||||
|
|
||||||
|
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||||
|
|
||||||
|
return __wbg_finalize_init(instance, module);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initSync };
|
||||||
|
export default __wbg_init;
|
BIN
crypto_vault_extension/wasm/wasm_app_bg.wasm
Normal file
@ -1,384 +0,0 @@
|
|||||||
# Architecture and Implementation Plan for the Rust Modular System
|
|
||||||
|
|
||||||
The system is organized into three core Rust crates (`kvstore`, `vault`, `evm_client`) plus two front‐end targets (a CLI and a WASM web app). The **`kvstore`** crate defines an async `KVStore` trait and provides two implementations: on native platforms it uses **sled**, while in WASM/browser it uses IndexedDB via the `idb` crate (selected by Cargo feature flags or `#[cfg(target_arch = "wasm32")]`). For example, Wire’s core-crypto keystore uses IndexedDB with AES-GCM for WASM and SQLCipher on native platforms. The **`vault`** crate manages an encrypted keyspace of multiple keypairs (password-protected), performing cryptographic operations (sign/verify, sym/asym encryption) and persisting data through `kvstore`. The **`evm_client`** crate handles EVM RPC calls (using `alloy`), depending on `vault` to sign transactions with stored keys. A Rust **CLI** binary ties these together with a Rhai scripting engine: Rhai scripts invoke async APIs via a message-passing pattern. The **browser target** compiles to Wasm (with `wasm-bindgen`); it exposes the same APIs to JavaScript or to Rhai compiled for Wasm.
|
|
||||||
|
|
||||||
## Crate and Module Structure
|
|
||||||
|
|
||||||
* **Cargo workspace**: top-level `Cargo.toml` lists members `kvstore/`, `vault/`, `evm_client/`, `cli_app/`, `web_app/`. Common dev-dependencies and CI config are shared at the workspace root.
|
|
||||||
* **Features & cfg**: In `kvstore`, define Cargo features or use `#[cfg]` to toggle backends. E.g. `cfg(not(target_arch = "wasm32"))` for sled, and `cfg(target_arch = "wasm32")` for IndexedDB. Use `async_trait` for the `KVStore` trait so implementations can be async. Similar conditional compilation applies to any platform-specific code (e.g. using WebCrypto APIs only under WASM).
|
|
||||||
* **Dependencies**:
|
|
||||||
|
|
||||||
* `kvstore` depends on `sled` (native) and `idb` (WASM), and defines `async fn` methods. Blocking DB calls (sled) must be offloaded via a `spawn_blocking` provided by the caller.
|
|
||||||
* `vault` depends on `kvstore` and various crypto crates (e.g. `aes-gcm` or `chacha20poly1305` for symmetric encryption; `k256`/`rust-crypto` for signatures). For WASM compatibility, ensure chosen crypto crates support `wasm32-unknown-unknown`. Keys are encrypted at rest with a password-derived key (AES-256-GCM or similar).
|
|
||||||
* `evm_client` depends on `vault` (for signing) and an Ethereum library (e.g. `alloy` with an async HTTP provider). On WASM, use `wasm-bindgen-futures` to call JavaScript fetch or use a crate like `reqwest` with the `wasm` feature.
|
|
||||||
* The **CLI** (binary) depends on Rhai (`rhai` crate), `tokio` or similar for async execution, and the above libraries. It sets up an async runtime (e.g. Tokio) to run tasks.
|
|
||||||
* The **web\_app** (WASM target) depends on `wasm-bindgen`/`wasm-bindgen-futures` and `vault`/`evm_client`. It uses `wasm-bindgen` to expose Rust functions to JS. Rhai can also be compiled to WASM for scripting in-browser, but must be integrated via the same message-passing pattern (see below).
|
|
||||||
|
|
||||||
## `kvstore` Crate Design
|
|
||||||
|
|
||||||
The `kvstore` crate defines:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[async_trait]
|
|
||||||
pub trait KVStore {
|
|
||||||
async fn get(&self, key: &str) -> Option<Vec<u8>>;
|
|
||||||
async fn put(&self, key: &str, value: &[u8]) -> ();
|
|
||||||
async fn delete(&self, key: &str) -> ();
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
It then provides two modules implementing this trait:
|
|
||||||
|
|
||||||
* **Native backend (sled)**: A wrapper around `sled::Db`. Since `sled` I/O is blocking, each call should be executed in a blocking context (e.g. using `tokio::task::spawn_blocking`) so as not to block the async runtime.
|
|
||||||
* **WASM/browser backend (IndexedDB)**: Uses the `idb` crate (or `web-sys`/`gloo`) to store data in the browser’s IndexedDB. This implementation is inherently async (Promise-based) and works in `wasm32-unknown-unknown`. On compilation, one can use Cargo features like `default-features = false` and `features = ["native", "wasm"]`, or simply `#[cfg]` to select the correct backend.
|
|
||||||
|
|
||||||
Citing best practice: the pattern of having an encrypted keystore use IndexedDB on WASM is standard (e.g. Wire’s core-crypto keystore). We will mirror that by encrypting data before `put`-ting it. The `kvstore` implementation will automatically be runtime-agnostic (using only `std::future::Future` in its APIs).
|
|
||||||
|
|
||||||
## `vault` Crate Design
|
|
||||||
|
|
||||||
The `vault` crate implements a WebAssembly-compatible cryptographic keystore. It manages:
|
|
||||||
|
|
||||||
* **Encrypted keyspace**: A password protects all key material. On open, derive an encryption key (e.g. via scrypt or PBKDF2) and decrypt the stored vault (a blob in `kvstore`). Inside, multiple keypairs (e.g. Ethereum secp256k1 keys, Ed25519 keys, etc.) are stored.
|
|
||||||
* **Crypto APIs**: Expose async functions to create new keys, list keys, and to perform crypto operations: e.g. `async fn sign_transaction(&self, key_id: &str, tx: &Transaction) -> Signature`, `async fn verify(&self, ...) -> bool`, `async fn encrypt(&self, ...)->Ciphertext`, etc.
|
|
||||||
* **Storage**: Internally uses the `kvstore::KVStore` trait to persist the encrypted vault. For example, on each change, it re-encrypts the whole keyspace and `put`s it under a fixed ID key.
|
|
||||||
* **WASM Compatibility**: All operations must compile to Wasm. Use Rust crypto crates compatible with `no_std`/WASM (e.g. `aes-gcm`, `k256`, `rand_core` with `getrandom` support). Alternatively, one could use the browser’s WebCrypto via `wasm-bindgen` for symmetric operations, but for simplicity we can rely on Rust crates (AES-GCM implementations that compile to WASM).
|
|
||||||
|
|
||||||
Internally, `vault` ensures all operations return `Future`s. It will not assume any particular async runtime – for example, file I/O or crypto is fast in memory, but if any blocking work is needed (like PBKDF2 hashing), it should be done via a provided `spawn_blocking` (as recommended by forum answers). On WASM, such heavy work would yield to the JS event loop via `spawn_local` (see below).
|
|
||||||
|
|
||||||
When open, `vault` authenticates the user’s password, loads (via `kvstore`) the encrypted blob of keys, and allows operations. Fig. above illustrates a cryptographic network: keys stored securely (vault) are used for signing on behalf of the user. Internally, best practice is to use an authenticated cipher (e.g. AES-256-GCM) with a strong KDF, as noted in existing systems.
|
|
||||||
|
|
||||||
## `evm_client` Crate Design
|
|
||||||
|
|
||||||
The `evm_client` crate provides async interfaces to interact with an EVM blockchain:
|
|
||||||
|
|
||||||
* **Dependencies**: It uses the `alloy` crate for building transactions, ABI encoding, and an async HTTP provider for RPC calls.
|
|
||||||
* **Signing**: It calls into `vault` when a transaction must be signed. For example, `evm_client.sign_and_send(tx)` will invoke `vault.sign(key_id, tx_bytes)` to get a signature.
|
|
||||||
* **Async RPC**: All RPC calls (`eth_sendRawTransaction`, `eth_call`, etc.) are `async fn`s returning `Future`s. These futures must be runtime-agnostic: they use standard `async/await` and do not tie to Tokio specifically. For HTTP, on native targets use `reqwest` with Tokio, while on WASM use `reqwest` with its `wasm` feature or `gloo-net` with `wasm-bindgen-futures`.
|
|
||||||
* **Configuration**: Provide a flexible config (e.g. chain ID, gas price options) via plain structs. Errors should use a common error enum or `thiserror` crate.
|
|
||||||
* **Features**: Could have a feature flag to choose between `alloy` and `ethers`. Both are fully async.
|
|
||||||
|
|
||||||
The `evm_client` crate itself should be purely async and not block. It will typically run on Tokio in the CLI, and on the browser’s single-threaded event loop with `spawn_local` in the web app.
|
|
||||||
|
|
||||||
## CLI Binary (Rhai Scripting)
|
|
||||||
|
|
||||||
The CLI binary (`cli_app`) binds everything with a user interface. Its design:
|
|
||||||
|
|
||||||
* **Command loop**: On startup it spawns a Rhai `Engine` in a separate OS thread. The main thread runs a Tokio async runtime (or other) to handle network and I/O.
|
|
||||||
* **Message-passing**: Use two MPSC channels: one for messages *to* the engine, and one for replies *from* the engine. According to Rhai’s multi-threaded pattern, we register API functions in the engine that send commands via channel to the main thread. The main thread processes commands (e.g. “sign this tx”, “send transaction”, etc.) using `vault`/`evm_client`, then sends back results.
|
|
||||||
* **Blocking calls**: In Rhai, all calls are blocking from the script’s perspective. Under the hood, the registered API calls serialize the request (e.g. to JSON) and send it on the command channel. The Rhai engine will block until a reply arrives on the reply channel. This pattern ensures the script can call async Rust code seamlessly (step 6–8 in Rhai docs).
|
|
||||||
* **Example flow**: A Rhai script calls `let res = send_tx(data)`. The `send_tx` function (registered in the engine) captures the channel handles, packages `data` into a message, and sends it. The engine thread blocks. The main thread’s async runtime reads the message, calls `evm_client.send_transaction(data).await`, then sends the result back. The Rhai engine thread receives it and returns it to the script.
|
|
||||||
|
|
||||||
This design follows Rhai’s recommended “blocking/async” pattern. It keeps the library usage runtime-agnostic, while allowing user-defined scripts to trigger asynchronous operations.
|
|
||||||
|
|
||||||
## Browser Application (WASM)
|
|
||||||
|
|
||||||
The browser target (`web_app`) is compiled with `wasm-bindgen` to Wasm. It provides the same core functionality via a JS API (or Rhai in WASM). Key points:
|
|
||||||
|
|
||||||
* **Exports**: Use `#[wasm_bindgen]` to expose async functions to JavaScript. For example, expose `async fn create_key(name: String) -> JsValue` that returns a JavaScript `Promise`. The `wasm-bindgen-futures` crate will convert Rust `Future`s into JS Promises automatically.
|
|
||||||
* **Async runtime**: WebAssembly runs on the browser’s single thread. To perform async Rust code, we use `wasm_bindgen_futures::spawn_local` to drive futures on the JS event loop. For example, in an exported function we might do `spawn_local(async move { /* call vault, evm_client */ })`. According to docs, `spawn_local` “runs a Rust `Future` on the current thread” and schedules it as a microtask. This lets our async functions execute to completion without blocking the event loop.
|
|
||||||
* **Promises and interop**: Return types must be `JsValue` or types convertible by `wasm_bindgen`. Complex data (e.g. byte arrays) can be passed as `Uint8Array` or encoded (e.g. hex).
|
|
||||||
* **Rhai in WASM**: Optionally, we can compile Rhai to WebAssembly as well. In that case, we would run the Rhai engine in a WebWorker (since WASM threads are limited) and use `MessageChannel` for communication. The same message-passing pattern applies: a script call in the worker posts a message to the main thread with request data, and awaits a message back. The main thread (browser UI) handles the request using the exposed Rust APIs. This is analogous to the CLI pattern but using Web APIs. (Implementation note: enabling threading in WASM requires `wasm-bindgen` with the `--target bundler` or using `web-sys` `Worker` APIs.)
|
|
||||||
* **Integration tips**: Use the `wasm-bindgen` guide to share data types (strings, structs) between JS and Rust. For async tests, `wasm-bindgen-futures` has examples.
|
|
||||||
|
|
||||||
In summary, the web app compiles the same crates to Wasm and exposes them. The figure above (a network on a globe) conceptually represents the global connectivity: the browser connects to EVM nodes via WebAssembly modules, invoking Rust code. All async boundaries are handled with `spawn_local` and JS Promises (as `wasm-bindgen-futures` outlines).
|
|
||||||
|
|
||||||
## Async and Runtime-Agnostic Best Practices
|
|
||||||
|
|
||||||
Throughout all crates we adhere to runtime-agnostic async principles:
|
|
||||||
|
|
||||||
* **Use `std::future::Future`** in public APIs, not a specific runtime’s types. Internally, any async work (I/O, network) should be done with `async/await`.
|
|
||||||
* **Feature-gate runtime-specific code**: If we need to call `tokio::spawn` or `async-std`, isolate that behind `#[cfg(feature = "tokio")]` or similar. Initially, one can pick one runtime (e.g. Tokio) and make the library depend on it, then add cfg-features later.
|
|
||||||
* **Blocking calls**: Any blocking work (file I/O, heavy crypto) is executed via a passed-in executor (e.g. require a `spawn_blocking: Fn(Box<dyn FnOnce() + Send>)` callback), as recommended by Rust forum advice. This way the library never forces a specific thread pool. For example, in `kvstore`’s sled backend, all operations are done in `spawn_blocking`.
|
|
||||||
* **Testing**: Include tests for both native and WASM targets (using `wasm-pack test` or headless browser tests) to catch platform differences.
|
|
||||||
* **Error handling**: Use `Result` types, with a shared error enum. Avoid panic paths – return errors across FFI boundaries.
|
|
||||||
|
|
||||||
By decoupling logic from the runtime (using channels for Rhai, spawn\_local for WASM, cfg-features for backends), the libraries remain flexible. As one Rust discussion notes, “using `cfg(feature = "...")` to isolate the pieces that have to be runtime specific” is key. We ensure all public async APIs are `async fn` so they can be `await`ed in any context.
|
|
||||||
|
|
||||||
## Workspace Layout and Features
|
|
||||||
|
|
||||||
The recommended workspace layout is:
|
|
||||||
|
|
||||||
```
|
|
||||||
/Cargo.toml # workspace manifest
|
|
||||||
/kvstore/Cargo.toml # kvstore crate
|
|
||||||
/vault/Cargo.toml # vault crate
|
|
||||||
/evm_client/Cargo.toml
|
|
||||||
/cli_app/Cargo.toml # binary (depends on kvstore, vault, evm_client, rhai)
|
|
||||||
/web_app/Cargo.toml # cdylib (wasm) crate (depends on kvstore, vault, evm_client, wasm-bindgen)
|
|
||||||
```
|
|
||||||
|
|
||||||
Each crate’s `Cargo.toml` lists its dependencies. For `kvstore`, an example feature setup:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[features]
|
|
||||||
default = ["native"]
|
|
||||||
native = ["sled"]
|
|
||||||
web = ["idb"]
|
|
||||||
```
|
|
||||||
|
|
||||||
In code:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[cfg(feature = "native")]
|
|
||||||
mod sled_backend;
|
|
||||||
#[cfg(feature = "web")]
|
|
||||||
mod indexeddb_backend;
|
|
||||||
```
|
|
||||||
|
|
||||||
One could also omit features and just use `#[cfg(target_arch = "wasm32")]` for the web backend. The `wasm-bindgen` crate is included under the `web_app` for browser integration.
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
This plan lays out a clear, modular architecture. Diagrams (above) conceptually show how the crates interact: `kvstore` underlies `vault`, which together support `evm_client`; the CLI and WASM targets invoke them asynchronously. We use message-passing (channels) to bridge Rhai scripts with async Rust code, and `spawn_local` in the browser to schedule futures. By following Rust async best practices (runtime-agnostic Futures, careful use of `cfg` and spawn-blocking) and wasm-bindgen conventions, the system will work seamlessly both on the desktop/CLI and in the browser.
|
|
||||||
|
|
||||||
**Sources:** Concepts and patterns are drawn from Rust async and WASM guidelines. For example, using IndexedDB with AES-GCM in WASM keystores is inspired by existing systems. These sources guided the design of a flexible, secure architecture.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 🔐 `kvstore` Crate: Pluggable Key-Value Storage Layer
|
|
||||||
|
|
||||||
**Purpose**: Provide an abstraction for key-value storage with async-compatible traits, supporting both native and WASM environments.
|
|
||||||
|
|
||||||
### Public API
|
|
||||||
|
|
||||||
```rust
|
|
||||||
#[async_trait]
|
|
||||||
pub trait KVStore {
|
|
||||||
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, KVError>;
|
|
||||||
async fn set(&self, key: &str, value: &[u8]) -> Result<(), KVError>;
|
|
||||||
async fn delete(&self, key: &str) -> Result<(), KVError>;
|
|
||||||
async fn exists(&self, key: &str) -> Result<bool, KVError>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**Backends**:
|
|
||||||
|
|
||||||
* **Native**: [`sled`](https://crates.io/crates/sled)
|
|
||||||
* **WASM**: [`idb`](https://crates.io/crates/idb) (IndexedDB)
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
|
|
||||||
* Compile-time target detection via `#[cfg(target_arch = "wasm32")]`
|
|
||||||
* Enables usage in both CLI and browser environments
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ `vault` Crate: Core Cryptography Module
|
|
||||||
|
|
||||||
**Purpose**: Manage secure key storage, cryptographic operations, and password-protected keyspaces.
|
|
||||||
|
|
||||||
### Public API
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct HeroVault;
|
|
||||||
|
|
||||||
impl HeroVault {
|
|
||||||
pub async fn create_keyspace(name: &str, password: &str) -> Result<(), VaultError>;
|
|
||||||
pub async fn load_keyspace(name: &str, password: &str) -> Result<(), VaultError>;
|
|
||||||
pub async fn logout() -> Result<(), VaultError>;
|
|
||||||
|
|
||||||
pub async fn create_keypair(label: &str) -> Result<(), VaultError>;
|
|
||||||
pub async fn select_keypair(label: &str) -> Result<(), VaultError>;
|
|
||||||
pub async fn list_keypairs() -> Result<Vec<String>, VaultError>;
|
|
||||||
pub async fn get_public_key(label: &str) -> Result<Vec<u8>, VaultError>;
|
|
||||||
|
|
||||||
pub async fn sign_message(message: &[u8]) -> Result<Vec<u8>, VaultError>;
|
|
||||||
pub async fn verify_signature(message: &[u8], signature: &[u8], public_key: &[u8]) -> Result<bool, VaultError>;
|
|
||||||
|
|
||||||
pub async fn encrypt(data: &[u8], password: &str) -> Result<Vec<u8>, VaultError>;
|
|
||||||
pub async fn decrypt(data: &[u8], password: &str) -> Result<Vec<u8>, VaultError>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**Security**:
|
|
||||||
|
|
||||||
* All sensitive data encrypted at rest using AES-GCM or ChaCha20-Poly1305
|
|
||||||
* Passwords stretched via Argon2id or PBKDF2
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ `evm_client` Crate: EVM Integration Layer
|
|
||||||
|
|
||||||
**Purpose**: Interact with Ethereum-compatible chains using key material from `vault`.
|
|
||||||
|
|
||||||
### Public API
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct EvmClient;
|
|
||||||
|
|
||||||
impl EvmClient {
|
|
||||||
pub async fn connect(rpc_url: &str) -> Result<Self, EvmError>;
|
|
||||||
pub async fn get_balance(&self, address: &str) -> Result<U256, EvmError>;
|
|
||||||
pub async fn send_transaction(&self, to: &str, value: U256, data: &[u8]) -> Result<TxHash, EvmError>;
|
|
||||||
pub async fn call_contract(&self, to: &str, data: &[u8]) -> Result<Vec<u8>, EvmError>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**Options**:
|
|
||||||
|
|
||||||
* `ethers-rs` (default, mature)
|
|
||||||
* `alloy` (alternative, lightweight and WASM-friendly)([Stack Overflow][1])
|
|
||||||
|
|
||||||
**Usage**:
|
|
||||||
|
|
||||||
* Transaction signing using vault keys
|
|
||||||
* Account management and EIP-1559 support
|
|
||||||
* Modular pluggability to support multiple networks([Medium][2])
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧰 CLI Interface
|
|
||||||
|
|
||||||
**Purpose**: Provide a command-line interface for interacting with the `vault` and `evm_client` crates, with scripting capabilities via Rhai.
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* Built with `rhai` scripting engine for dynamic workflows
|
|
||||||
* Thin wrapper over `vault` and `evm_client`
|
|
||||||
* Exposes custom functions to Rhai:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
fn sign_tx(...) -> Result<String, Box<EvalAltResult>>;
|
|
||||||
fn create_keyspace(...) -> ...;
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
* Asynchronous operations managed via `tokio` or `async-std`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🌐 WebAssembly (Browser) Target
|
|
||||||
|
|
||||||
**Purpose**: Provide a browser-compatible interface for the `vault` and `evm_client` crates, compiled to WebAssembly.
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
* Exposed using `wasm-bindgen`
|
|
||||||
* No Rhai scripting in browser due to native-only dependencies
|
|
||||||
* Interaction model:
|
|
||||||
|
|
||||||
* Expose WebAssembly bindings (async `Promise`-compatible)
|
|
||||||
* Front-end (e.g., React) calls functions via JS bridge
|
|
||||||
* Keyspace and signing operations run within WASM memory
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧠 Rhai Integration Strategy
|
|
||||||
|
|
||||||
* Only used in CLI
|
|
||||||
* Bind only synchronous APIs
|
|
||||||
* Asynchronous work handled by sending commands to a background task([Deno][3])
|
|
||||||
|
|
||||||
```rust
|
|
||||||
rhai.register_fn("sign", move |input: String| -> String {
|
|
||||||
let (tx, rx) = oneshot::channel();
|
|
||||||
command_sender.send(VaultCommand::SignMessage { input, resp: tx });
|
|
||||||
rx.blocking_recv().unwrap()
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Runtime Strategy
|
|
||||||
|
|
||||||
* **Library (`vault`, `kvstore`, `evm_client`)**:
|
|
||||||
|
|
||||||
* Must be async-runtime agnostic
|
|
||||||
* No global runtime should be spawned
|
|
||||||
* Use `async-trait`, `Send + 'static` futures
|
|
||||||
|
|
||||||
* **CLI & Web Targets**:
|
|
||||||
|
|
||||||
* CLI: Use `tokio` or `async-std`
|
|
||||||
* WASM: Use `wasm-bindgen-futures` and `spawn_local`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📐 Architecture Diagram
|
|
||||||
|
|
||||||
```
|
|
||||||
[ CLI (Rhai) ] [ Browser (WASM) ]
|
|
||||||
| |
|
|
||||||
[ Scripts ] [ JS / TS ]
|
|
||||||
| |
|
|
||||||
[ Runtime ] [ wasm-bindgen ]
|
|
||||||
| |
|
|
||||||
[ vault (async) ] [ vault (wasm32) ]
|
|
||||||
| |
|
|
||||||
[ kvstore (sled) ] [ kvstore (idb) ]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Dependency Overview
|
|
||||||
|
|
||||||
| Crate | Key Deps | WASM Support |
|
|
||||||
| ----------- | --------------------- | ---------------------------- |
|
|
||||||
| kvstore | sled, idb | ✅ |
|
|
||||||
| hero\_vault | aes-gcm, argon2, rand | ✅ |
|
|
||||||
| evm\_client | alloy | ✅ |
|
|
||||||
| CLI | rhai, tokio | ❌ |
|
|
||||||
| Web Target | wasm-bindgen, idb | ✅ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Implementation Plan
|
|
||||||
|
|
||||||
1. **Scaffold Crates**:
|
|
||||||
|
|
||||||
* `kvstore`
|
|
||||||
* `vault`
|
|
||||||
* `evm_client`
|
|
||||||
|
|
||||||
2. **Implement `KVStore` Trait**:
|
|
||||||
|
|
||||||
* Implement `sled` backend for native
|
|
||||||
* Implement `idb` backend for WASM
|
|
||||||
|
|
||||||
3. **Develop `vault`**:
|
|
||||||
|
|
||||||
* Implement password-based encrypted keyspaces
|
|
||||||
* Integrate with `kvstore` for persistence
|
|
||||||
* Implement cryptographic operations (signing, encryption, etc.)([GitHub][4])
|
|
||||||
|
|
||||||
4. **Develop `evm_client`**:
|
|
||||||
|
|
||||||
* Integrate with `alloy`
|
|
||||||
* Implement transaction signing using `vault` keys
|
|
||||||
* Implement account management and contract interaction
|
|
||||||
|
|
||||||
5. **Develop CLI Interface**:
|
|
||||||
|
|
||||||
* Integrate `rhai` scripting engine
|
|
||||||
* Expose `vault` and `evm_client` functionalities
|
|
||||||
* Implement message-passing for async operations
|
|
||||||
|
|
||||||
6. **Develop WebAssembly Target**:
|
|
||||||
|
|
||||||
* Compile `vault` and `evm_client` to WASM using `wasm-bindgen`
|
|
||||||
* Expose functionalities to JavaScript
|
|
||||||
* Implement frontend interface (e.g., React)
|
|
||||||
|
|
||||||
7. **Testing and Documentation**:
|
|
||||||
|
|
||||||
* Write unit and integration tests for all functionalities
|
|
||||||
* Document public APIs and usage examples
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This comprehensive plan ensures a modular, secure, and cross-platform cryptographic system, drawing inspiration from the `herocode/webassembly` project. The design facilitates both command-line and browser-based applications, providing
|
|
||||||
|
|
||||||
[1]: https://stackoverflow.com/questions/78979955/how-encrypt-on-blazor-wasm-wpa-using-aes-and-rfc2898?utm_source=chatgpt.com "how encrypt on blazor wasm wpa using Aes and Rfc2898"
|
|
||||||
[2]: https://medium.com/coderhack-com/coderhack-cryptography-libraries-and-uses-in-rust-31957242299f?utm_source=chatgpt.com "Cryptography with rust | by Amay B | CoderHack.com - Medium"
|
|
||||||
[3]: https://deno.com/blog/v1.12?utm_source=chatgpt.com "Deno 1.12 Release Notes"
|
|
||||||
[4]: https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/releases?utm_source=chatgpt.com "Releases · matrix-org/matrix-rust-sdk-crypto-wasm - GitHub"
|
|
79
docs/architecture.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Architecture Overview
|
||||||
|
|
||||||
|
This document describes the architecture and design rationale for the modular Rust system, including the `kvstore`, `vault`, and `evm_client` crates, as well as the CLI and WASM/web-app targets.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Summary](#summary)
|
||||||
|
- [Crate and Module Structure](#crate-and-module-structure)
|
||||||
|
- [Design Rationale](#design-rationale)
|
||||||
|
- [Vault Crate Design](#vault-crate-design)
|
||||||
|
- [Async, WASM, and Integration](#async-wasm-and-integration)
|
||||||
|
- [Diagrams & References](#diagrams--references)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
This system is organized as a Rust workspace with three core crates:
|
||||||
|
- `kvstore`: Abstract, async key-value storage for both native and WASM environments.
|
||||||
|
- `vault`: Manages encrypted keyspaces and cryptographic operations, using `kvstore` for persistence.
|
||||||
|
- `evm_client`: Ethereum RPC client, signs transactions using keys from `vault`.
|
||||||
|
|
||||||
|
Front-end targets:
|
||||||
|
- CLI (with Rhai scripting)
|
||||||
|
- WASM/web-app (with JS interop)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Crate and Module Structure
|
||||||
|
- **Workspace:** Top-level `Cargo.toml` lists all member crates.
|
||||||
|
- **Features & cfg:** Use Cargo features and `#[cfg]` attributes for platform-specific code (e.g., `sled` for native, `idb` for WASM).
|
||||||
|
- **Dependencies:**
|
||||||
|
- `kvstore` uses `sled` (native) and `idb` (WASM).
|
||||||
|
- `vault` uses `kvstore` and crypto crates (`chacha20poly1305`, `pbkdf2`, etc.).
|
||||||
|
- `evm_client` uses `vault` and Ethereum libraries (e.g., `alloy`).
|
||||||
|
- CLI uses Rhai and async runtime (e.g., Tokio).
|
||||||
|
- Web app uses `wasm-bindgen` and exposes Rust APIs to JS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Rationale
|
||||||
|
### Improvements in the New Implementation
|
||||||
|
- **Async/Await API:** All operations are async, supporting non-blocking I/O for both WASM and native environments.
|
||||||
|
- **Backend Abstraction:** The `KVStore` trait abstracts over multiple storage backends, enabling cross-platform support and easier testing.
|
||||||
|
- **Separation of Concerns:**
|
||||||
|
- `kvstore` handles only storage.
|
||||||
|
- `vault` is responsible for encryption, decryption, and password management.
|
||||||
|
- **WASM and Native Support:** Out-of-the-box support for both browser (IndexedDB) and native (sled) environments.
|
||||||
|
- **Cleaner, Testable Design:** Each layer is independently testable and mockable.
|
||||||
|
|
||||||
|
### Why Encryption and Password Protection are in Vault
|
||||||
|
- **Single Responsibility:** `kvstore` is for storage; `vault` handles security.
|
||||||
|
- **Flexibility:** Encryption algorithms and policies can evolve in `vault` without changing storage.
|
||||||
|
- **Security:** Cryptography is isolated in `vault`, reducing attack surface and easing audits.
|
||||||
|
- **Cross-Platform Consistency:** Same vault logic regardless of storage backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vault Crate Design
|
||||||
|
- **Encrypted Keyspace:** Password-protected, supports multiple keypairs (e.g., secp256k1, Ed25519).
|
||||||
|
- **Crypto APIs:** Async functions for key management, signing, encryption/decryption.
|
||||||
|
- **Storage:** Uses `kvstore` for persistence; re-encrypts and stores the keyspace as a blob.
|
||||||
|
- **WASM Compatibility:** Uses Rust crypto crates that support `wasm32-unknown-unknown`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Async, WASM, and Integration
|
||||||
|
- **Exports:** Use `#[wasm_bindgen]` to expose async functions to JS.
|
||||||
|
- **Async runtime:** Use `wasm_bindgen_futures::spawn_local` in the browser.
|
||||||
|
- **Interop:** JS and Rust communicate via Promises and `JsValue`.
|
||||||
|
- **CLI:** Uses Rhai scripting and async runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagrams & References
|
||||||
|
- See included diagrams for crate relationships and message-passing patterns.
|
||||||
|
- Design patterns and best practices are drawn from Rust async and WASM guidelines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This document merges and replaces content from the previous `Architecture.md` and `kvstore-vault-architecture.md`. For further details on implementation, see `vault_impl_plan.md`.*
|
98
docs/build_instructions.md
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
# Plan: Ensuring Native and WASM Builds Work for the Vault/KVStore System
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This document outlines the steps and requirements to guarantee that both native (desktop/server) and WASM (browser) builds of the `vault` and `kvstore` crates work seamlessly, securely, and efficiently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Principles
|
||||||
|
- **Async/Await Everywhere:** All APIs must be async and runtime-agnostic (no Tokio requirement in library code).
|
||||||
|
- **KVStore Trait:** Use an async trait for storage, with platform-specific implementations (sled for native, IndexedDB/idb for WASM).
|
||||||
|
- **Conditional Compilation:** Use `#[cfg(target_arch = "wasm32")]` and `#[cfg(not(target_arch = "wasm32"))]` to select code and dependencies.
|
||||||
|
- **No Blocking in WASM:** All I/O and crypto operations must be async and non-blocking in browser builds.
|
||||||
|
- **WASM-Compatible Crypto:** Only use crypto crates that compile to WASM (e.g., `aes-gcm`, `chacha20poly1305`, `k256`, `rand_core`).
|
||||||
|
- **Separation of Concerns:** All encryption and password logic resides in `vault`, not `kvstore`.
|
||||||
|
- **Stateless and Session APIs:** Provide both stateless (context-passing) and session-based APIs in `vault`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Cargo.toml and Dependency Management
|
||||||
|
- **Native:**
|
||||||
|
- `[target.'cfg(not(target_arch = "wasm32"))'.dependencies]`
|
||||||
|
- `tokio` (with only supported features)
|
||||||
|
- `sled`
|
||||||
|
- **WASM:**
|
||||||
|
- `[target.'cfg(target_arch = "wasm32")'.dependencies]`
|
||||||
|
- `idb`
|
||||||
|
- `wasm-bindgen`, `wasm-bindgen-futures`
|
||||||
|
- **Crypto:**
|
||||||
|
- Only include crates that are WASM-compatible for both targets.
|
||||||
|
- **No unconditional `tokio`** in `vault` or `kvstore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Code Organization
|
||||||
|
- **KVStore Trait:**
|
||||||
|
- Define as async trait (using `async_trait`).
|
||||||
|
- Implement for sled (native) and idb (WASM), using `#[cfg]`.
|
||||||
|
- **Vault:**
|
||||||
|
- All persistence must go through the KVStore trait.
|
||||||
|
- All cryptography must be WASM-compatible.
|
||||||
|
- No direct file or blocking I/O in WASM.
|
||||||
|
- **Runtime:**
|
||||||
|
- Only use `tokio` in binaries or native-specific code.
|
||||||
|
- In WASM, use `wasm-bindgen-futures::spawn_local` for async tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Platform-Specific Guidelines
|
||||||
|
- **Native (Desktop/Server):**
|
||||||
|
- Use `sled` for storage.
|
||||||
|
- Use `tokio::task::spawn_blocking` for blocking I/O if needed.
|
||||||
|
- All async code should work with any runtime.
|
||||||
|
- **WASM (Browser):**
|
||||||
|
- Use `idb` crate for IndexedDB storage.
|
||||||
|
- All code must be non-blocking and compatible with the browser event loop.
|
||||||
|
- Use `wasm-bindgen` and `wasm-bindgen-futures` for JS interop and async.
|
||||||
|
- Expose APIs with `#[wasm_bindgen]` for JS usage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing
|
||||||
|
- **Native:** `cargo test`
|
||||||
|
- **WASM:** `wasm-pack test --headless --firefox` (or `--chrome`) inside the crate directory
|
||||||
|
- **Separate tests** for native and WASM backends in `tests/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Browser (WASM) Testing for evm_client
|
||||||
|
|
||||||
|
To run browser-based tests for `evm_client`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd evm_client
|
||||||
|
wasm-pack test --headless --firefox
|
||||||
|
# or
|
||||||
|
wasm-pack test --headless --chrome
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your crate to WASM and run the tests in a real browser environment.
|
||||||
|
|
||||||
|
## 6. Checklist for Compliance
|
||||||
|
- [ ] No unconditional `tokio` usage in library code
|
||||||
|
- [ ] All dependencies are WASM-compatible (where needed)
|
||||||
|
- [ ] All storage goes through async KVStore trait
|
||||||
|
- [ ] No blocking I/O or native-only APIs in WASM
|
||||||
|
- [ ] All cryptography is WASM-compatible
|
||||||
|
- [ ] Both stateless and session APIs are available in `vault`
|
||||||
|
- [ ] All APIs are async and runtime-agnostic
|
||||||
|
- [ ] Native and WASM tests both pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. References
|
||||||
|
- See `docs/Architecture.md`, `docs/kvstore-vault-architecture.md`, and `docs/vault_impl_plan.md` for architectural background and rationale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
By following this plan, the codebase will be robust, portable, and secure on both native and browser platforms, and will adhere to all project architectural guidelines.
|
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*
|
111
docs/extension_architecture.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# Browser Extension Architecture & Workflow
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The browser extension is the main user interface for interacting with the modular Rust cryptographic stack (vault, EVM client, key-value store) and for executing Rhai scripts securely. It is designed for both local (user-driven) scripting and remote (server-driven) workflows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features & Phases
|
||||||
|
|
||||||
|
### Phase 1: Local Session & Script Execution
|
||||||
|
- **Session Management**: User creates/unlocks a keyspace and selects/creates a keypair. Session state is required for all cryptographic operations.
|
||||||
|
- **Keypair Actions**:
|
||||||
|
- Sign, verify
|
||||||
|
- Asymmetric encrypt/decrypt
|
||||||
|
- Symmetric encrypt/decrypt (arbitrary messages/files, using password-derived key)
|
||||||
|
- Send transaction, check balance (with selected provider)
|
||||||
|
- Execute user-provided Rhai scripts (from extension input box)
|
||||||
|
- Scripts have access to the session manager's signer; explicit per-script approval is required.
|
||||||
|
|
||||||
|
### Phase 2: WebSocket Server Integration
|
||||||
|
- **Connection**: User connects to a websocket server using the selected keypair's public key. Connection persists as long as the extension is loaded (i.e., its background logic/service worker is active), regardless of whether the popup/UI is open.
|
||||||
|
- **Script Delivery & Approval**:
|
||||||
|
- Server can send Rhai scripts (with title, description, tags: `local`/`remote`).
|
||||||
|
- Extension notifies user of incoming scripts, displays metadata, allows viewing and approval.
|
||||||
|
- User must unlock keyspace and select the correct keypair to approve/execute.
|
||||||
|
- For `remote` scripts: user signs the script hash and sends signature to server (for consent/authorization; server may execute script).
|
||||||
|
- For `local` scripts: script executes locally, and the extension logs and reports the result back to the server.
|
||||||
|
- For user-pasted scripts: logs only; server connection not required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Restricting WASM and Session API Access to the Extension
|
||||||
|
|
||||||
|
To ensure that sensitive APIs (such as session state, cryptographic operations, and key management) are accessible **only** from the browser extension and not from arbitrary web pages, follow these best practices:
|
||||||
|
|
||||||
|
1. **Export Only Safe, High-Level APIs**
|
||||||
|
- Use `#[wasm_bindgen]` only on functions you explicitly want to expose to the extension.
|
||||||
|
- Do **not** export internal helpers, state singletons, or low-level APIs.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Safe to export
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn run_rhai(script: &str) -> Result<JsValue, JsValue> {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOT exported: internal state
|
||||||
|
// pub static SESSION_MANAGER: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Do Not Attach WASM Exports to `window` or `globalThis`**
|
||||||
|
- When loading the WASM module in your extension, do not attach its exports to any global object accessible by web pages.
|
||||||
|
- Keep all WASM interaction within the extension’s background/content scripts.
|
||||||
|
|
||||||
|
3. **Validate All Inputs**
|
||||||
|
- Even though only your extension should call WASM APIs, always validate inputs to exported functions to prevent injection or misuse.
|
||||||
|
|
||||||
|
4. **Use Message Passing Carefully**
|
||||||
|
- If you use `postMessage` or similar mechanisms, always check the message origin and type before processing.
|
||||||
|
- Only process messages from trusted origins (e.g., your extension’s own scripts).
|
||||||
|
|
||||||
|
5. **Load WASM in Extension-Only Context**
|
||||||
|
- Load and instantiate the WASM module in a context (such as a background script or content script) that is not accessible to arbitrary websites.
|
||||||
|
- Never inject your WASM module directly into web page scopes.
|
||||||
|
|
||||||
|
#### Example: Secure WASM Export
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Only export high-level, safe APIs
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn run_rhai(script: &str) -> Result<JsValue, JsValue> {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
// Do NOT export SESSION_MANAGER or internal helpers
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example: Secure JS Loading (Extension Only)
|
||||||
|
|
||||||
|
```js
|
||||||
|
// In your extension's background or content script:
|
||||||
|
import init, { run_rhai } from "./your_wasm_module.js";
|
||||||
|
|
||||||
|
// Only your extension's JS can call run_rhai
|
||||||
|
// Do NOT attach run_rhai to window/globalThis
|
||||||
|
```
|
||||||
|
|
||||||
|
By following these guidelines, your WASM session state and sensitive APIs will only be accessible to your browser extension, not to untrusted web pages.
|
||||||
|
|
||||||
|
### Session Password Handling
|
||||||
|
- The extension stores the keyspace password (or a derived key) securely in memory only for the duration of an unlocked session. The password is never persisted or written to disk/storage, and is zeroized from memory immediately upon session lock/logout, following cryptographic best practices (see also Developer Notes below).
|
||||||
|
- **Signer Access**: Scripts can access the session's signer only after explicit user approval per execution.
|
||||||
|
- **Approval Model**: Every script execution (local or remote) requires user approval.
|
||||||
|
- **No global permissions**: Permissions are not granted globally or permanently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI/UX Guidelines
|
||||||
|
- Use any robust, modern, and fast UI framework (React, Svelte, etc.).
|
||||||
|
- Dark mode is recommended.
|
||||||
|
- UI should be responsive, intuitive, and secure.
|
||||||
|
- All cryptographic operations and script executions must be clearly auditable and user-approved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Developer Notes
|
||||||
|
- Extension is the canonical interface for scripting and secure automation.
|
||||||
|
- CLI and additional server features are planned for future phases.
|
||||||
|
- For vault and scripting details, see [rhai_architecture_plan.md].
|
||||||
|
- For EVM client integration, see [evm_client_architecture_plan.md].
|
@ -1,73 +0,0 @@
|
|||||||
# What’s Improved in the New Implementation
|
|
||||||
|
|
||||||
_and why Encryption and Password Protection should be implemented in the Vault crate_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Introduction
|
|
||||||
|
|
||||||
This document compares the old and new designs of the key-value store (kvstore) module, highlights improvements in the new implementation, and explains the architectural decision to move encryption and password protection to the vault crate.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Improvements in the New Implementation
|
|
||||||
|
|
||||||
### a. **Async/Await API**
|
|
||||||
- All operations are asynchronous, enabling non-blocking I/O.
|
|
||||||
- Essential for WASM/browser and scalable server environments.
|
|
||||||
|
|
||||||
### b. **Backend Abstraction**
|
|
||||||
- The `KVStore` trait abstracts over multiple storage backends (native and WASM).
|
|
||||||
- Enables cross-platform support and easier testing.
|
|
||||||
|
|
||||||
### c. **Separation of Concerns**
|
|
||||||
- The storage layer (`kvstore`) is now focused solely on key-value persistence.
|
|
||||||
- No longer mixes storage with cryptography or user authentication.
|
|
||||||
|
|
||||||
### d. **WASM and Native Support**
|
|
||||||
- Out-of-the-box support for both browser (IndexedDB) and native (sled) environments.
|
|
||||||
- Easy to extend with new backends in the future.
|
|
||||||
|
|
||||||
### e. **Cleaner, More Testable Design**
|
|
||||||
- Each layer is independently testable and mockable.
|
|
||||||
- Simpler to reason about and maintain.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Why Encryption and Password Protection Belong in the Vault Crate
|
|
||||||
|
|
||||||
### a. **Single Responsibility Principle**
|
|
||||||
- `kvstore` should only handle storage, not cryptographic operations or user authentication.
|
|
||||||
- `vault` is responsible for security: encryption, decryption, password management.
|
|
||||||
|
|
||||||
### b. **Flexibility and Extensibility**
|
|
||||||
- Different applications may require different encryption schemes or policies.
|
|
||||||
- By implementing encryption in `vault`, you can easily swap algorithms, add multi-user support, or support new crypto features without touching the storage backend.
|
|
||||||
|
|
||||||
### c. **Security Best Practices**
|
|
||||||
- Keeping cryptography separate from storage reduces the attack surface and risk of subtle bugs.
|
|
||||||
- All key material and secrets are encrypted before being handed to the storage layer.
|
|
||||||
|
|
||||||
### d. **Cross-Platform Consistency**
|
|
||||||
- The same vault logic can be used regardless of storage backend (sled, IndexedDB, etc).
|
|
||||||
- Ensures consistent encryption and password handling on all platforms.
|
|
||||||
|
|
||||||
### e. **Easier Upgrades and Auditing**
|
|
||||||
- Security code is isolated in one place (`vault`), making it easier to audit and upgrade.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Summary Table
|
|
||||||
|
|
||||||
| Layer | Responsibility | Encryption | Passwords | Storage Backend |
|
|
||||||
|-----------|------------------------|------------|-----------|----------------|
|
|
||||||
| kvstore | Persistence/Storage | ❌ | ❌ | sled, IndexedDB|
|
|
||||||
| vault | Security, Key Mgmt | ✅ | ✅ | Uses kvstore |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Conclusion
|
|
||||||
|
|
||||||
- The new design is more modular, secure, and maintainable.
|
|
||||||
- Encryption and password logic in `vault` enables strong, flexible security while keeping storage simple and robust.
|
|
||||||
|
|
123
docs/kvstore.md
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
# kvstore Crate Overview
|
||||||
|
|
||||||
|
`kvstore` is a runtime-agnostic, async key-value storage crate designed for both native (using `sled`) and WASM/browser (using IndexedDB via the `idb` crate) environments. It provides a unified API for all platforms, enabling seamless storage abstraction for Rust applications.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Summary](#summary)
|
||||||
|
- [Main Components](#main-components)
|
||||||
|
- [Supported Environments](#supported-environments)
|
||||||
|
- [Quickstart & Usage Examples](#quickstart--usage-examples)
|
||||||
|
- [API Reference](#api-reference)
|
||||||
|
- [More Information](#more-information)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
The `kvstore` crate defines an async trait for key-value storage, with robust implementations for native and browser environments. It is the storage backend for higher-level crates such as `vault`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Main Components
|
||||||
|
- **KVStore Trait**: Async interface for key-value operations (`get`, `set`, `remove`, `contains_key`, `keys`, `clear`).
|
||||||
|
- **NativeStore**: Native backend using `sled` (requires Tokio runtime).
|
||||||
|
- **WasmStore**: WASM/browser backend using IndexedDB via the `idb` crate.
|
||||||
|
- **KVError**: Error type covering I/O, serialization, encryption, and backend-specific issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Environments
|
||||||
|
- **Native:** Uses `sled` for fast, embedded storage. Blocking I/O is offloaded to background threads using Tokio.
|
||||||
|
- **Browser (WASM):** Uses IndexedDB via the `idb` crate. Fully async and Promise-based.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart & Usage Examples
|
||||||
|
|
||||||
|
### Native Example
|
||||||
|
```rust
|
||||||
|
use kvstore::{KVStore, NativeStore};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let store = NativeStore::open("/tmp/mydb").unwrap();
|
||||||
|
store.set("foo", b"bar").await.unwrap();
|
||||||
|
let val = store.get("foo").await.unwrap();
|
||||||
|
println!("Got: {:?}", val);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WASM/Browser Example
|
||||||
|
```rust
|
||||||
|
// In a browser/WASM environment:
|
||||||
|
use kvstore::{KVStore, WasmStore};
|
||||||
|
|
||||||
|
// Must be called from an async context (e.g., JS Promise)
|
||||||
|
let store = WasmStore::open("vault").await.unwrap();
|
||||||
|
store.set("foo", b"bar").await.unwrap();
|
||||||
|
let val = store.get("foo").await.unwrap();
|
||||||
|
// Use the value as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### KVStore Trait
|
||||||
|
```rust
|
||||||
|
#[async_trait]
|
||||||
|
pub trait KVStore {
|
||||||
|
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
|
||||||
|
async fn set(&self, key: &str, value: &[u8]) -> Result<()>;
|
||||||
|
async fn remove(&self, key: &str) -> Result<()>;
|
||||||
|
async fn contains_key(&self, key: &str) -> Result<bool>;
|
||||||
|
async fn keys(&self) -> Result<Vec<String>>;
|
||||||
|
async fn clear(&self) -> Result<()>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### NativeStore
|
||||||
|
```rust
|
||||||
|
pub struct NativeStore { /* ... */ }
|
||||||
|
|
||||||
|
impl NativeStore {
|
||||||
|
pub fn open(path: &str) -> Result<Self>;
|
||||||
|
// Implements KVStore trait
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### WasmStore
|
||||||
|
```rust
|
||||||
|
pub struct WasmStore { /* ... */ }
|
||||||
|
|
||||||
|
impl WasmStore {
|
||||||
|
pub async fn open(name: &str) -> Result<Self>;
|
||||||
|
// Implements KVStore trait
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum KVError {
|
||||||
|
Io(std::io::Error),
|
||||||
|
KeyNotFound(String),
|
||||||
|
StoreNotFound(String),
|
||||||
|
Serialization(String),
|
||||||
|
Deserialization(String),
|
||||||
|
Encryption(String),
|
||||||
|
Decryption(String),
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
- For architecture and design, see [`architecture.md`](architecture.md)
|
||||||
|
- For integration examples, see [`build_instructions.md`](build_instructions.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*For advanced usage, backend customization, and WASM integration, see the linked documents above.*
|
@ -39,7 +39,7 @@ sal/
|
|||||||
|
|
||||||
- **Each core component (`kvstore`, `vault`, `evm_client`, `rhai`) is a separate crate at the repo root.**
|
- **Each core component (`kvstore`, `vault`, `evm_client`, `rhai`) is a separate crate at the repo root.**
|
||||||
- **CLI binary** is in `cli_app` and depends on the core crates.
|
- **CLI binary** is in `cli_app` and depends on the core crates.
|
||||||
- **WebAssembly target** is in `web_app`.
|
- **WebAssembly target** is in `wasm_app`.
|
||||||
- **Rhai bindings** live in their own crate (`rhai/`), so both CLI and WASM can depend on them.
|
- **Rhai bindings** live in their own crate (`rhai/`), so both CLI and WASM can depend on them.
|
||||||
|
|
||||||
---
|
---
|
397
docs/rhai_architecture_plan.md
Normal file
@ -0,0 +1,397 @@
|
|||||||
|
# Rhai Scripting Architecture Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the architecture and integration plan for Rhai scripting within the modular Rust cryptographic system. The goal is to enable secure, extensible scripting for both browser and (future) CLI environments, with the browser extension as the main user interface.
|
||||||
|
|
||||||
|
## Interfaces
|
||||||
|
|
||||||
|
- **Browser Extension**: The primary and recommended user interface for all modules, scripting, and automation.
|
||||||
|
- **CLI**: Planned as a future feature; not a primary interface.
|
||||||
|
|
||||||
|
## Vault & Scripting Capabilities
|
||||||
|
- All cryptographic operations (sign, verify, encrypt, decrypt) are exposed to Rhai scripts via the extension.
|
||||||
|
- Symmetric encryption/decryption of arbitrary messages/files is supported using a key derived from the keyspace password (see `Vault::encrypt`/`Vault::decrypt`).
|
||||||
|
- User-provided Rhai scripts can access the current session's signer (with explicit approval).
|
||||||
|
|
||||||
|
## Extension UI/UX & Workflow
|
||||||
|
|
||||||
|
### Phase 1: Local Session & Script Execution
|
||||||
|
1. **Session Management**
|
||||||
|
- User is prompted to create/unlock a keyspace and select/create a keypair.
|
||||||
|
- The session (unlocked keyspace + selected keypair) is required for all cryptographic actions and script execution.
|
||||||
|
2. **Per-Keypair Actions**
|
||||||
|
- Sign, verify
|
||||||
|
- Asymmetric encrypt/decrypt
|
||||||
|
- Symmetric encrypt/decrypt (using password-derived key)
|
||||||
|
- Send transaction, check balance (with selected provider)
|
||||||
|
- Execute user-provided Rhai script (from input box)
|
||||||
|
- Scripts have access to the session manager's current signer and can send transactions on behalf of the user, but require explicit approval per script execution.
|
||||||
|
|
||||||
|
### Phase 2: WebSocket Server Integration
|
||||||
|
1. **Connection**
|
||||||
|
- User must have an active session to connect to the server (connects using selected keypair's public key).
|
||||||
|
- Connection is persistent while the extension is open; user may lock keyspace but remain connected.
|
||||||
|
2. **Script Delivery & Approval**
|
||||||
|
- Server can send Rhai scripts to the extension, each with a title, description, and tags (e.g., `local`, `remote`).
|
||||||
|
- Extension notifies user of incoming script, displays metadata, and allows user to view the script.
|
||||||
|
- User must unlock their keyspace and select the correct keypair to approve/execute the script.
|
||||||
|
- For `remote` scripts: user signs the script hash (consent/authorization) and sends the signature to the server. The server may then execute the script.
|
||||||
|
- For `local` scripts: script executes locally, and the extension logs and reports the result back to the server.
|
||||||
|
- For user-pasted scripts (from input box): logs only; server connection not required.
|
||||||
|
|
||||||
|
## Script Permissions & Security
|
||||||
|
- **Session Password Handling**: The session password (or a derived key) is kept in memory only for the duration of the unlocked session, never persisted, and is zeroized from memory on session lock/logout. This follows best practices for cryptographic applications and browser extensions.
|
||||||
|
- **Signer Access**: Scripts can access the session's signer only after explicit user approval per execution.
|
||||||
|
- **Approval Model**: Every script execution (local or remote) requires user approval.
|
||||||
|
- **No global permissions**: Permissions are not granted globally or permanently.
|
||||||
|
|
||||||
|
## UI Framework & UX
|
||||||
|
- Any robust, modern, and fast UI framework may be used (React, Svelte, etc.).
|
||||||
|
- Dark mode is recommended.
|
||||||
|
- UI should be responsive, intuitive, and secure.
|
||||||
|
|
||||||
|
## Developer Notes
|
||||||
|
- The extension is the canonical interface for scripting and secure automation.
|
||||||
|
- CLI support and additional server features are planned for future phases.
|
||||||
|
- See also: [EVM Client Plan](evm_client_architecture_plan.md) and [README.md] for architecture overview.
|
||||||
|
|
||||||
|
## 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*
|
48
docs/user_stories.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# User Stories: Modular Cryptographic Extension & Scripting
|
||||||
|
|
||||||
|
## As a User, I want to...
|
||||||
|
|
||||||
|
### Session & Key Management
|
||||||
|
- Create a new encrypted keyspace with a password so that only I can access my secrets.
|
||||||
|
- Unlock an existing keyspace by entering my password.
|
||||||
|
- Create, select, and manage multiple keypairs within a keyspace.
|
||||||
|
- Clearly see which keyspace and keypair are currently active in my session.
|
||||||
|
|
||||||
|
### Cryptographic Operations
|
||||||
|
- Sign and verify messages using my selected keypair.
|
||||||
|
- Encrypt and decrypt messages or files using asymmetric cryptography (public/private keypair).
|
||||||
|
- Encrypt and decrypt messages or files using symmetric encryption (derived from my keyspace password).
|
||||||
|
- Export or back up my keypairs securely.
|
||||||
|
|
||||||
|
### EVM Client Actions
|
||||||
|
- Connect to an Ethereum provider and check my account balance.
|
||||||
|
- Send transactions using my selected keypair.
|
||||||
|
|
||||||
|
### Scripting (Rhai)
|
||||||
|
- Paste or write a Rhai script into the extension UI and execute it securely.
|
||||||
|
- Approve or deny each script execution, with a clear understanding of what the script will access (e.g., signing, sending transactions).
|
||||||
|
- See script logs/output in the extension UI.
|
||||||
|
|
||||||
|
### Security & Permissions
|
||||||
|
- Be prompted for approval before any script can access my keypair or perform sensitive operations.
|
||||||
|
- See a clear audit trail/log of all cryptographic and scripting actions performed in my session.
|
||||||
|
|
||||||
|
### WebSocket Integration (Future)
|
||||||
|
- Connect to a server using my keypair's public key and receive Rhai scripts from the server.
|
||||||
|
- Review and approve/reject incoming scripts, with clear metadata (title, description, tags).
|
||||||
|
- For remote scripts, sign the script hash and send my signature to the server as consent.
|
||||||
|
- For local scripts, execute them in the extension and have the results reported back to the server.
|
||||||
|
|
||||||
|
### UI/UX
|
||||||
|
- Use a fast, modern, and intuitive extension interface, with dark mode support.
|
||||||
|
- Always know the current security state (locked/unlocked, connected/disconnected, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## As a Developer, I want to...
|
||||||
|
|
||||||
|
- Expose all vault and EVM client APIs to WASM so they are callable from JavaScript/TypeScript.
|
||||||
|
- Provide ergonomic Rust-to-Rhai bindings for all key cryptographic and EVM actions.
|
||||||
|
- Ensure clear error reporting and logging for all extension and scripting operations.
|
||||||
|
- Write tests for both WASM and native environments.
|
||||||
|
- Easily add new cryptographic algorithms, providers, or scripting APIs as the system evolves.
|
138
docs/vault.md
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
# Vault Crate Overview
|
||||||
|
|
||||||
|
Welcome to the Vault crate! This document provides a high-level overview, usage examples, and a guide to the main components of the Vault system. For deeper technical details, see [`architecture.md`](architecture.md) and [`vault_impl_plan.md`](vault_impl_plan.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Summary](#summary)
|
||||||
|
- [Main Components](#main-components)
|
||||||
|
- [Security Model](#security-model)
|
||||||
|
- [Quickstart & Usage Examples](#quickstart--usage-examples)
|
||||||
|
- [More Information](#more-information)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
The Vault crate is a modular, async, and WASM-compatible cryptographic keystore. It manages encrypted keyspaces (each with multiple keypairs), provides cryptographic APIs, and persists all data via a pluggable key-value store. All sensitive material is always encrypted at rest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Main Components
|
||||||
|
- **VaultStore**: Central manager for all keyspaces. Handles creation, loading, and metadata.
|
||||||
|
- **KeySpace**: Isolated environment containing multiple keypairs, encrypted with its own password.
|
||||||
|
- **KeyPair**: Represents an individual cryptographic keypair (e.g., secp256k1, Ed25519).
|
||||||
|
- **Symmetric Encryption Module**: Handles encryption/decryption using ChaCha20Poly1305 and PBKDF2.
|
||||||
|
- **SessionManager** (optional): Manages active context for ergonomic API usage (not required for stateless usage).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
- **Per-KeySpace Encryption:** Each keyspace is encrypted independently using a password-derived key.
|
||||||
|
- **VaultStore Metadata:** Stores non-sensitive metadata about keyspaces (names, creation dates, etc.).
|
||||||
|
- **Zero-Knowledge:** Passwords and keys are never stored in plaintext; all cryptographic operations are performed in memory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart & Usage Examples
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Create a new VaultStore
|
||||||
|
let mut vault = VaultStore::new();
|
||||||
|
|
||||||
|
// Create a new keyspace
|
||||||
|
vault.create_keyspace("personal", "password123")?;
|
||||||
|
|
||||||
|
// List available keyspaces
|
||||||
|
let keyspaces = vault.list_keyspaces();
|
||||||
|
|
||||||
|
// Unlock a keyspace
|
||||||
|
let keyspace = vault.load_keyspace("personal", "password123")?;
|
||||||
|
|
||||||
|
// Create a new keypair
|
||||||
|
let key_id = keyspace.create_key(KeyType::Secp256k1, "mykey")?;
|
||||||
|
|
||||||
|
// Sign a message
|
||||||
|
let signature = keyspace.sign(&key_id, b"hello world")?;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
- **Architecture & Design:** See [`architecture.md`](architecture.md)
|
||||||
|
- **Implementation Details:** See [`vault_impl_plan.md`](vault_impl_plan.md)
|
||||||
|
- **Build Instructions:** See [`build_instructions.md`](build_instructions.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*For advanced usage (stateless/session APIs, custom backends, WASM integration), see the linked documents above.*
|
||||||
|
}
|
||||||
|
|
||||||
|
### KeySpace API Example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl KeySpace {
|
||||||
|
pub fn new(name: &str, password: &str) -> Result<Self, VaultError>;
|
||||||
|
pub fn save(&self) -> Result<(), VaultError>;
|
||||||
|
pub fn list_keypairs(&self) -> Vec<String>;
|
||||||
|
pub fn create_keypair(&mut self, name: &str) -> Result<(), VaultError>;
|
||||||
|
pub fn delete_keypair(&mut self, name: &str) -> Result<(), VaultError>;
|
||||||
|
pub fn rename_keypair(&mut self, old_name: &str, new_name: &str) -> Result<(), VaultError>;
|
||||||
|
pub fn get_keypair(&self, name: &str) -> Result<KeyPair, VaultError>;
|
||||||
|
pub fn sign(&self, keypair_name: &str, message: &[u8]) -> Result<Vec<u8>, VaultError>;
|
||||||
|
pub fn verify(&self, keypair_name: &str, message: &[u8], signature: &[u8]) -> Result<bool, VaultError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### KeyPair API Example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct KeyPair {
|
||||||
|
// Internal fields
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyPair {
|
||||||
|
pub fn new() -> Self;
|
||||||
|
pub fn from_private_key(private_key: &[u8]) -> Result<Self, VaultError>;
|
||||||
|
pub fn public_key(&self) -> Vec<u8>;
|
||||||
|
pub fn private_key(&self) -> Vec<u8>;
|
||||||
|
pub fn sign(&self, message: &[u8]) -> Result<Vec<u8>, VaultError>;
|
||||||
|
pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<bool, VaultError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SessionManager API Example (Optional)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct SessionManager {
|
||||||
|
keyspace: KeySpace,
|
||||||
|
active_keypair: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionManager {
|
||||||
|
pub fn new(keyspace: KeySpace, keypair_name: &str) -> Result<Self, VaultError>;
|
||||||
|
pub fn sign(&self, message: &[u8]) -> Result<Vec<u8>, VaultError>;
|
||||||
|
pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<bool, VaultError>;
|
||||||
|
pub fn switch_keypair(&mut self, keypair_name: &str) -> Result<(), VaultError>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
vault_store/
|
||||||
|
├── metadata.json
|
||||||
|
└── keyspaces/
|
||||||
|
├── alice.ksp
|
||||||
|
├── bob.ksp
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
- `metadata.json`: Contains metadata about each keyspace, such as name and creation date.
|
||||||
|
|
||||||
|
|
||||||
|
## Supported Environments
|
||||||
|
|
||||||
|
- **Native:** Uses filesystem or a database (e.g., SQLite) for storage.
|
||||||
|
- **Browser (WASM):** Uses IndexedDB or localStorage via the kvstore abstraction.
|
||||||
|
|
||||||
|
For full build and integration instructions, see [build_instructions.md](build_instructions.md).
|
234
docs/vault_impl_plan.md
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
# Vault Implementation Plan (Technical Appendix)
|
||||||
|
|
||||||
|
This document is a technical reference for contributors and maintainers of the Vault crate. It covers advanced implementation details, design rationale, and data models. For a high-level overview and usage, see [`vault.md`](vault.md) and [`architecture.md`](architecture.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Design Principle: Stateless & Session APIs](#design-principle-stateless--session-apis)
|
||||||
|
- [Data Model](#data-model)
|
||||||
|
- [Module & File Structure](#module--file-structure)
|
||||||
|
- [Advanced Notes](#advanced-notes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
> **Design Principle:**
|
||||||
|
> **The vault crate will provide both a stateless (context-passing) API and an ergonomic session-based API.**
|
||||||
|
> This ensures maximum flexibility for both library developers and application builders, supporting both functional and stateful usage patterns.
|
||||||
|
|
||||||
|
## Design Principle: Stateless & Session APIs
|
||||||
|
|
||||||
|
The `vault` crate is a modular, async, and WASM-compatible cryptographic keystore. It manages an encrypted keyspace (multiple keypairs), provides cryptographic APIs, and persists all data via the `kvstore` trait. The design ensures all sensitive material is encrypted at rest and is portable across native and browser environments.
|
||||||
|
|
||||||
|
**Core Components:**
|
||||||
|
- **Vault:** Main manager for encrypted keyspace and cryptographic operations.
|
||||||
|
- **KeyPair:** Represents individual asymmetric keypairs (e.g., secp256k1, Ed25519).
|
||||||
|
- **Symmetric Encryption Module:** Handles encryption/decryption and key derivation.
|
||||||
|
- **SessionManager (Optional):** Maintains current context (e.g., selected keypair) for user sessions.
|
||||||
|
- **KVStore:** Async trait for backend-agnostic persistence (sled on native, IndexedDB on WASM).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
You can design the vault crate to support both stateless and session-based (stateful) usage patterns. This gives maximum flexibility to both library developers and application builders.
|
||||||
|
|
||||||
|
### Stateless API
|
||||||
|
- All operations require explicit context (unlocked keyspace, keypair, etc.) as arguments.
|
||||||
|
- No hidden or global state; maximally testable and concurrency-friendly.
|
||||||
|
- Example:
|
||||||
|
```rust
|
||||||
|
let keyspace = vault.unlock_keyspace("personal", b"password").await?;
|
||||||
|
let signature = keyspace.sign("key1", &msg).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Manager API
|
||||||
|
- Maintains in-memory state of unlocked keyspaces and current selections.
|
||||||
|
- Provides ergonomic methods for interactive apps (CLI, desktop, browser).
|
||||||
|
- Example:
|
||||||
|
```rust
|
||||||
|
let mut session = SessionManager::new();
|
||||||
|
session.unlock_keyspace("personal", b"password", &vault)?;
|
||||||
|
session.select_keypair("key1");
|
||||||
|
let signature = session.current_keypair().unwrap().sign(&msg)?;
|
||||||
|
session.logout(); // wipes all secrets from memory
|
||||||
|
```
|
||||||
|
|
||||||
|
### How They Work Together
|
||||||
|
- The **stateless API** is the core, always available and used internally by the session manager.
|
||||||
|
- The **session manager** is a thin, optional layer that wraps the stateless API for convenience.
|
||||||
|
- Applications can choose which pattern fits their needs, or even mix both (e.g., use stateless for background jobs, session manager for user sessions).
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
- **Flexibility:** Library users can pick the best model for their use case.
|
||||||
|
- **Security:** Session manager can enforce auto-lock, timeouts, and secure memory wiping.
|
||||||
|
- **Simplicity:** Stateless API is easy to test and reason about, while session manager improves UX for interactive flows.
|
||||||
|
|
||||||
|
### Commitment: Provide Both APIs
|
||||||
|
- **Both stateless and session-based APIs will be provided in the vault crate.**
|
||||||
|
- Stateless API: For backend, automation, or library contexts—explicit, functional, and concurrency-friendly.
|
||||||
|
- Session manager API: For UI/UX-focused applications—ergonomic, stateful, and user-friendly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### VaultMetadata & Keyspace Model
|
||||||
|
```rust
|
||||||
|
struct VaultMetadata {
|
||||||
|
name: String,
|
||||||
|
keyspaces: Vec<KeyspaceMetadata>,
|
||||||
|
// ... other vault-level metadata (optionally encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KeyspaceMetadata {
|
||||||
|
name: String,
|
||||||
|
salt: [u8; 16], // Unique salt for this keyspace
|
||||||
|
encrypted_blob: Vec<u8>, // All keypairs & secrets, encrypted with keyspace password
|
||||||
|
// ... other keyspace metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decrypted contents of a keyspace:
|
||||||
|
struct KeyspaceData {
|
||||||
|
keypairs: Vec<KeyEntry>,
|
||||||
|
// ... other keyspace-level metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
struct KeyEntry {
|
||||||
|
id: String,
|
||||||
|
key_type: KeyType,
|
||||||
|
private_key: Vec<u8>, // Only present in memory after decryption
|
||||||
|
public_key: Vec<u8>,
|
||||||
|
metadata: Option<KeyMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KeyType {
|
||||||
|
Secp256k1,
|
||||||
|
Ed25519,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- The vault contains a list of keyspaces, each with its own salt and encrypted blob.
|
||||||
|
- Each keyspace is unlocked independently using its password and salt.
|
||||||
|
- Key material is never stored unencrypted; only decrypted in memory after unlocking a keyspace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API Design (Keyspace Model)
|
||||||
|
|
||||||
|
### Vault
|
||||||
|
```rust
|
||||||
|
impl<S: KVStore + Send + Sync> Vault<S> {
|
||||||
|
async fn open(store: S) -> Result<Self, VaultError>;
|
||||||
|
async fn list_keyspaces(&self) -> Result<Vec<KeyspaceInfo>, VaultError>;
|
||||||
|
async fn create_keyspace(&mut self, name: &str, password: &[u8]) -> Result<(), VaultError>;
|
||||||
|
async fn delete_keyspace(&mut self, name: &str) -> Result<(), VaultError>;
|
||||||
|
async fn unlock_keyspace(&mut self, name: &str, password: &[u8]) -> Result<(), VaultError>;
|
||||||
|
async fn lock_keyspace(&mut self, name: &str);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyspace Management
|
||||||
|
```rust
|
||||||
|
impl Keyspace {
|
||||||
|
fn is_unlocked(&self) -> bool;
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
async fn create_key(&mut self, key_type: KeyType, name: &str) -> Result<String, VaultError>;
|
||||||
|
async fn list_keys(&self) -> Result<Vec<KeyInfo>, VaultError>;
|
||||||
|
async fn sign(&self, key_id: &str, msg: &[u8]) -> Result<Signature, VaultError>;
|
||||||
|
async fn encrypt(&self, key_id: &str, plaintext: &[u8]) -> Result<Ciphertext, VaultError>;
|
||||||
|
async fn decrypt(&self, key_id: &str, ciphertext: &[u8]) -> Result<Vec<u8>, VaultError>;
|
||||||
|
async fn change_password(&mut self, old: &[u8], new: &[u8]) -> Result<(), VaultError>;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SessionManager
|
||||||
|
```rust
|
||||||
|
impl SessionManager {
|
||||||
|
fn select_key(&mut self, key_id: &str);
|
||||||
|
fn current_key(&self) -> Option<&KeyPair>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
vault/
|
||||||
|
├── src/
|
||||||
|
│ ├── lib.rs # Vault API and main logic
|
||||||
|
│ ├── data.rs # Data models: VaultData, KeyEntry, etc.
|
||||||
|
│ ├── crypto.rs # Symmetric/asymmetric crypto, key derivation
|
||||||
|
│ ├── session.rs # SessionManager
|
||||||
|
│ ├── error.rs # VaultError and error handling
|
||||||
|
│ └── utils.rs # Helpers, serialization, etc.
|
||||||
|
├── tests/
|
||||||
|
│ ├── native.rs # Native (sled) tests
|
||||||
|
│ └── wasm.rs # WASM (IndexedDB) tests
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Notes
|
||||||
|
|
||||||
|
- For further context on cryptographic choices, async patterns, and WASM compatibility, see `architecture.md`.
|
||||||
|
- This appendix is intended for developers extending or maintaining the Vault implementation.
|
||||||
|
|
||||||
|
### Cryptography: Crates and Algorithms
|
||||||
|
|
||||||
|
**Crates:**
|
||||||
|
- [`aes-gcm`](https://crates.io/crates/aes-gcm): AES-GCM authenticated encryption (WASM-compatible)
|
||||||
|
- [`chacha20poly1305`](https://crates.io/crates/chacha20poly1305): ChaCha20Poly1305 authenticated encryption (WASM-compatible)
|
||||||
|
- [`pbkdf2`](https://crates.io/crates/pbkdf2): Password-based key derivation (WASM-compatible)
|
||||||
|
- [`scrypt`](https://crates.io/crates/scrypt): Alternative KDF, strong and WASM-compatible
|
||||||
|
- [`k256`](https://crates.io/crates/k256): secp256k1 ECDSA (Ethereum keys)
|
||||||
|
- [`ed25519-dalek`](https://crates.io/crates/ed25519-dalek): Ed25519 keypairs
|
||||||
|
- [`rand_core`](https://crates.io/crates/rand_core): Randomness, WASM-compatible
|
||||||
|
- [`getrandom`](https://crates.io/crates/getrandom): Platform-agnostic RNG
|
||||||
|
|
||||||
|
**Algorithm Choices:**
|
||||||
|
- **Vault Encryption:**
|
||||||
|
- AES-256-GCM (default, via `aes-gcm`)
|
||||||
|
- Optionally ChaCha20Poly1305 (via `chacha20poly1305`)
|
||||||
|
- **Password Key Derivation:**
|
||||||
|
- PBKDF2-HMAC-SHA256 (via `pbkdf2`)
|
||||||
|
- Optionally scrypt (via `scrypt`)
|
||||||
|
- **Asymmetric Keypairs:**
|
||||||
|
- secp256k1 (via `k256`) for Ethereum/EVM
|
||||||
|
- Ed25519 (via `ed25519-dalek`) for general-purpose signatures
|
||||||
|
- **Randomness:**
|
||||||
|
- Use `rand_core` and `getrandom` for secure RNG in both native and WASM
|
||||||
|
|
||||||
|
**Feature-to-Algorithm Mapping:**
|
||||||
|
| Feature | Crate(s) | Algorithm(s) |
|
||||||
|
|------------------------|-----------------------|---------------------------|
|
||||||
|
| Vault encryption | aes-gcm, chacha20poly1305 | AES-256-GCM, ChaCha20Poly1305 |
|
||||||
|
| Password KDF | pbkdf2, scrypt | PBKDF2-HMAC-SHA256, scrypt|
|
||||||
|
| Symmetric encryption | aes-gcm, chacha20poly1305 | AES-256-GCM, ChaCha20Poly1305 |
|
||||||
|
| secp256k1 keypairs | k256 | secp256k1 ECDSA |
|
||||||
|
| Ed25519 keypairs | ed25519-dalek | Ed25519 |
|
||||||
|
| Randomness | rand_core, getrandom | OS RNG |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. WASM & Native Considerations
|
||||||
|
- Use only WASM-compatible crypto crates (`aes-gcm`, `chacha20poly1305`, `k256`, `ed25519-dalek`, etc).
|
||||||
|
- Use `wasm-bindgen`/`wasm-bindgen-futures` for browser interop.
|
||||||
|
- Use `tokio::task::spawn_blocking` for blocking crypto on native.
|
||||||
|
- All APIs are async and runtime-agnostic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Future Extensions
|
||||||
|
- Multi-user vaults (multi-password, access control)
|
||||||
|
- Hardware-backed key storage (YubiKey, WebAuthn)
|
||||||
|
- Key rotation and auditing
|
||||||
|
- Pluggable crypto algorithms
|
||||||
|
- Advanced metadata and tagging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. References
|
||||||
|
- See `docs/Architecture.md` and `docs/kvstore-vault-architecture.md` for high-level design and rationale.
|
||||||
|
- Crypto patterns inspired by industry best practices (e.g., Wire, Signal, Bitwarden).
|
@ -7,7 +7,39 @@ edition = "2021"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
vault = { path = "../vault" }
|
instant = { version = "0.1", features = ["wasm-bindgen"] }
|
||||||
|
# Only universal/core dependencies here
|
||||||
|
|
||||||
|
tokio = { version = "1.37", features = ["rt", "macros"] }
|
||||||
|
rhai = "1.16"
|
||||||
|
ethers-core = "2.0"
|
||||||
|
rlp = "0.5"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
alloy = "0.6"
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
|
log = "0.4"
|
||||||
|
hex = "0.4"
|
||||||
|
k256 = { version = "0.13", features = ["ecdsa"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.10"
|
||||||
|
wasm-bindgen-test = "0.3"
|
||||||
|
web-sys = { version = "0.3", features = ["console"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
|
getrandom_02 = { package = "getrandom", version = "0.2.16", features = ["js"] }
|
||||||
|
wasm-bindgen = { version = "0.2.92", features = ["serde-serialize"] }
|
||||||
|
js-sys = "0.3"
|
||||||
|
# console_error_panic_hook = "0.1"
|
||||||
|
gloo-net = { version = "0.5", features = ["http"] }
|
||||||
|
console_log = "1"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
log = "0.4"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
env_logger = "0.11"
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
9
evm_client/src/error.rs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum EvmError {
|
||||||
|
#[error("RPC error: {0}")]
|
||||||
|
Rpc(String),
|
||||||
|
#[error("Signing error: {0}")]
|
||||||
|
Signing(String),
|
||||||
|
#[error("Other error: {0}")]
|
||||||
|
Other(String),
|
||||||
|
}
|
@ -2,22 +2,163 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//! evm_client: Minimal EVM JSON-RPC client abstraction
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
//! evm_client: Minimal EVM JSON-RPC client abstraction
|
||||||
pub enum EvmError {
|
|
||||||
#[error("RPC error: {0}")]
|
//! evm_client: Minimal EVM JSON-RPC client abstraction
|
||||||
Rpc(String),
|
|
||||||
#[error("Vault error: {0}")]
|
//! evm_client: Minimal EVM JSON-RPC client abstraction
|
||||||
Vault(String),
|
|
||||||
|
//! evm_client: Minimal EVM JSON-RPC client abstraction
|
||||||
|
|
||||||
|
pub use ethers_core::types::*;
|
||||||
|
pub mod provider;
|
||||||
|
pub mod signer;
|
||||||
|
pub mod rhai_bindings;
|
||||||
|
pub mod rhai_sync_helpers;
|
||||||
|
pub mod error;
|
||||||
|
pub use provider::send_rpc;
|
||||||
|
pub use error::EvmError;
|
||||||
|
|
||||||
|
/// Public EVM client struct for use in bindings and sync helpers
|
||||||
|
pub struct Provider {
|
||||||
|
pub rpc_url: String,
|
||||||
|
pub chain_id: u64,
|
||||||
|
pub explorer_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct EvmClient {
|
pub struct EvmClient {
|
||||||
// ... fields for RPC, vault, etc.
|
pub provider: Provider,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EvmClient {
|
impl EvmClient {
|
||||||
pub async fn connect(_rpc_url: &str) -> Result<Self, EvmError> {
|
pub fn new(provider: Provider) -> Self {
|
||||||
todo!("Implement connect")
|
Self { provider }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize logging for the current target (native: env_logger, WASM: console_log)
|
||||||
|
/// Call this before using any log macros.
|
||||||
|
pub fn init_logging() {
|
||||||
|
use std::sync::Once;
|
||||||
|
static INIT: Once = Once::new();
|
||||||
|
INIT.call_once(|| {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
use env_logger;
|
||||||
|
let _ = env_logger::builder().is_test(false).try_init();
|
||||||
|
}
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use console_log;
|
||||||
|
let _ = console_log::init_with_level(log::Level::Debug);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_balance(&self, address: Address) -> Result<U256, EvmError> {
|
||||||
|
// TODO: Use provider info
|
||||||
|
provider::get_balance(&self.provider.rpc_url, address)
|
||||||
|
.await
|
||||||
|
.map_err(|e| EvmError::Rpc(e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_transaction(
|
||||||
|
&self,
|
||||||
|
mut tx: provider::Transaction,
|
||||||
|
signer: &dyn crate::signer::Signer,
|
||||||
|
) -> Result<ethers_core::types::H256, EvmError> {
|
||||||
|
use ethers_core::types::{U256, H256, Bytes, Address};
|
||||||
|
use std::str::FromStr;
|
||||||
|
use serde_json::json;
|
||||||
|
use crate::provider::{send_rpc, parse_signature_rs_v};
|
||||||
|
|
||||||
|
// 1. Fill in missing fields via JSON-RPC if needed
|
||||||
|
// Parse signer address as H160
|
||||||
|
let signer_addr = ethers_core::types::Address::from_str(&signer.address())
|
||||||
|
.map_err(|e| EvmError::Rpc(format!("Invalid signer address: {}", e)))?;
|
||||||
|
// Nonce
|
||||||
|
if tx.nonce.is_none() {
|
||||||
|
let body = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "eth_getTransactionCount",
|
||||||
|
"params": [format!("0x{:x}", signer_addr), "pending"],
|
||||||
|
"id": 1
|
||||||
|
}).to_string();
|
||||||
|
let resp = send_rpc(&self.provider.rpc_url, &body).await.map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&resp).map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
let hex = v["result"].as_str().ok_or_else(|| EvmError::Rpc("No result field in eth_getTransactionCount".to_string()))?;
|
||||||
|
tx.nonce = Some(U256::from_str_radix(hex.trim_start_matches("0x"), 16).map_err(|e| EvmError::Rpc(e.to_string()))?);
|
||||||
|
}
|
||||||
|
// Gas Price
|
||||||
|
if tx.gas_price.is_none() {
|
||||||
|
let body = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "eth_gasPrice",
|
||||||
|
"params": [],
|
||||||
|
"id": 1
|
||||||
|
}).to_string();
|
||||||
|
let resp = send_rpc(&self.provider.rpc_url, &body).await.map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&resp).map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
let hex = v["result"].as_str().ok_or_else(|| EvmError::Rpc("No result field in eth_gasPrice".to_string()))?;
|
||||||
|
tx.gas_price = Some(U256::from_str_radix(hex.trim_start_matches("0x"), 16).map_err(|e| EvmError::Rpc(e.to_string()))?);
|
||||||
|
}
|
||||||
|
// Chain ID
|
||||||
|
if tx.chain_id.is_none() {
|
||||||
|
tx.chain_id = Some(self.provider.chain_id);
|
||||||
|
}
|
||||||
|
// Gas (optional: estimate if missing)
|
||||||
|
if tx.gas.is_none() {
|
||||||
|
let body = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "eth_estimateGas",
|
||||||
|
"params": [{
|
||||||
|
"to": format!("0x{:x}", tx.to),
|
||||||
|
"from": format!("0x{:x}", signer_addr),
|
||||||
|
"value": format!("0x{:x}", tx.value),
|
||||||
|
"data": format!("0x{}", hex::encode(&tx.data)),
|
||||||
|
}],
|
||||||
|
"id": 1
|
||||||
|
}).to_string();
|
||||||
|
let resp = send_rpc(&self.provider.rpc_url, &body).await.map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&resp).map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
let hex = v["result"].as_str().ok_or_else(|| EvmError::Rpc("No result field in eth_estimateGas".to_string()))?;
|
||||||
|
tx.gas = Some(U256::from_str_radix(hex.trim_start_matches("0x"), 16).map_err(|e| EvmError::Rpc(e.to_string()))?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. RLP encode unsigned transaction
|
||||||
|
let rlp_unsigned = tx.rlp_encode_unsigned();
|
||||||
|
|
||||||
|
// 3. Sign the RLP-encoded unsigned transaction
|
||||||
|
let sig = signer.sign(&rlp_unsigned).await?;
|
||||||
|
let (r, s, v) = parse_signature_rs_v(&sig, tx.chain_id.unwrap()).ok_or_else(|| EvmError::Signing("Invalid signature format".to_string()))?;
|
||||||
|
|
||||||
|
// 4. RLP encode signed transaction (EIP-155)
|
||||||
|
use rlp::RlpStream;
|
||||||
|
let mut rlp_stream = RlpStream::new_list(9);
|
||||||
|
rlp_stream.append(&tx.nonce.unwrap());
|
||||||
|
rlp_stream.append(&tx.gas_price.unwrap());
|
||||||
|
rlp_stream.append(&tx.gas.unwrap());
|
||||||
|
rlp_stream.append(&tx.to);
|
||||||
|
rlp_stream.append(&tx.value);
|
||||||
|
rlp_stream.append(&tx.data.to_vec());
|
||||||
|
rlp_stream.append(&tx.chain_id.unwrap());
|
||||||
|
rlp_stream.append(&r);
|
||||||
|
rlp_stream.append(&s);
|
||||||
|
let raw_tx = rlp_stream.out().to_vec();
|
||||||
|
|
||||||
|
// 5. Broadcast the raw transaction
|
||||||
|
let raw_hex = format!("0x{}", hex::encode(&raw_tx));
|
||||||
|
let body = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "eth_sendRawTransaction",
|
||||||
|
"params": [raw_hex],
|
||||||
|
"id": 1
|
||||||
|
}).to_string();
|
||||||
|
let resp = send_rpc(&self.provider.rpc_url, &body).await.map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&resp).map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
let tx_hash_hex = v["result"].as_str().ok_or_else(|| EvmError::Rpc("No result field in eth_sendRawTransaction".to_string()))?;
|
||||||
|
let tx_hash = H256::from_slice(&hex::decode(tx_hash_hex.trim_start_matches("0x")).map_err(|e| EvmError::Rpc(e.to_string()))?);
|
||||||
|
Ok(tx_hash)
|
||||||
}
|
}
|
||||||
// ... other API stubs
|
|
||||||
}
|
}
|
||||||
|
97
evm_client/src/provider.rs
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
// Minimal provider abstraction for EVM JSON-RPC
|
||||||
|
// Uses ethers-core for types and signing
|
||||||
|
// Uses gloo-net (WASM) or reqwest (native) for HTTP
|
||||||
|
use std::error::Error;
|
||||||
|
use ethers_core::types::{U256, Address, Bytes};
|
||||||
|
use rlp::RlpStream;
|
||||||
|
|
||||||
|
/// Send a JSON-RPC POST request to an EVM node.
|
||||||
|
pub async fn send_rpc(url: &str, body: &str) -> Result<String, Box<dyn Error>> {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
let resp = Request::post(url)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(body)?
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(resp.text().await?)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let resp = client
|
||||||
|
.post(url)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(body.to_string())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
Ok(resp.text().await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub struct Transaction {
|
||||||
|
pub to: Address,
|
||||||
|
pub value: U256,
|
||||||
|
pub data: Bytes,
|
||||||
|
pub gas: Option<U256>,
|
||||||
|
pub gas_price: Option<U256>,
|
||||||
|
pub nonce: Option<U256>,
|
||||||
|
pub chain_id: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Transaction {
|
||||||
|
pub fn rlp_encode_unsigned(&self) -> Vec<u8> {
|
||||||
|
let mut s = RlpStream::new_list(9);
|
||||||
|
s.append(&self.nonce);
|
||||||
|
s.append(&self.gas_price);
|
||||||
|
s.append(&self.gas);
|
||||||
|
s.append(&self.to);
|
||||||
|
s.append(&self.value);
|
||||||
|
s.append(&self.data.to_vec());
|
||||||
|
s.append(&self.chain_id);
|
||||||
|
s.append(&0u8);
|
||||||
|
s.append(&0u8);
|
||||||
|
s.out().to_vec()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to parse a 65-byte secp256k1 signature into (r, s, v) for EVM.
|
||||||
|
/// Assumes signature is [r (32 bytes) | s (32 bytes) | v (1 byte)]
|
||||||
|
pub fn parse_signature_rs_v(sig: &[u8], chain_id: u64) -> Option<(U256, U256, u64)> {
|
||||||
|
if sig.len() != 65 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut r_bytes = [0u8; 32];
|
||||||
|
r_bytes.copy_from_slice(&sig[0..32]);
|
||||||
|
let r = U256::from_big_endian(&r_bytes);
|
||||||
|
let mut s_bytes = [0u8; 32];
|
||||||
|
s_bytes.copy_from_slice(&sig[32..64]);
|
||||||
|
let s = U256::from_big_endian(&s_bytes);
|
||||||
|
let mut v = sig[64] as u64;
|
||||||
|
// EIP-155: v = recid + 35 + chain_id * 2
|
||||||
|
if v < 27 { v += 27; }
|
||||||
|
v = v + chain_id * 2 + 8;
|
||||||
|
Some((r, s, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage:
|
||||||
|
// let (r, s, v) = parse_signature_rs_v(&signature, tx.chain_id).unwrap();
|
||||||
|
// Use these for EVM transaction serialization.
|
||||||
|
|
||||||
|
/// Query the balance of an Ethereum address using eth_getBalance
|
||||||
|
pub async fn get_balance(url: &str, address: Address) -> Result<U256, Box<dyn std::error::Error>> {
|
||||||
|
use serde_json::json;
|
||||||
|
let body = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "eth_getBalance",
|
||||||
|
"params": [format!("0x{:x}", address), "latest"],
|
||||||
|
"id": 1
|
||||||
|
}).to_string();
|
||||||
|
let resp = send_rpc(url, &body).await?;
|
||||||
|
let v: serde_json::Value = serde_json::from_str(&resp)?;
|
||||||
|
let hex = v["result"].as_str().ok_or("No result field in RPC response")?;
|
||||||
|
let balance = U256::from_str_radix(hex.trim_start_matches("0x"), 16)?;
|
||||||
|
Ok(balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
45
evm_client/src/rhai_bindings.rs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
//! Rhai bindings for EVM Client module
|
||||||
|
//! Provides a single source of truth for scripting integration for EVM actions.
|
||||||
|
|
||||||
|
use rhai::{Engine, Map};
|
||||||
|
pub use crate::EvmClient; // Ensure EvmClient is public and defined in lib.rs
|
||||||
|
|
||||||
|
/// Register EVM Client APIs with the Rhai scripting engine.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn register_rhai_api(engine: &mut Engine, evm_client: std::sync::Arc<EvmClient>) {
|
||||||
|
/// Rhai-friendly wrapper for EvmClient, allowing method registration and instance sharing.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct RhaiEvmClient {
|
||||||
|
inner: std::sync::Arc<EvmClient>,
|
||||||
|
}
|
||||||
|
impl RhaiEvmClient {
|
||||||
|
/// Get balance using the EVM client.
|
||||||
|
pub fn get_balance(&self, address_hex: String) -> Result<String, String> {
|
||||||
|
use ethers_core::types::Address;
|
||||||
|
let address = Address::from_slice(&hex::decode(address_hex.trim_start_matches("0x")).map_err(|e| format!("hex decode error: {e}"))?);
|
||||||
|
crate::rhai_sync_helpers::get_balance_sync(&self.inner, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
engine.register_type::<RhaiEvmClient>();
|
||||||
|
engine.register_fn("get_balance", RhaiEvmClient::get_balance);
|
||||||
|
// Register instance for scripts
|
||||||
|
let rhai_ec = RhaiEvmClient { inner: evm_client.clone() };
|
||||||
|
// Rhai does not support register_global_constant; pass the client as a parameter or use module scope.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub fn register_rhai_api(engine: &mut Engine) {
|
||||||
|
// In WASM, register global functions that operate on the singleton/global EvmClient
|
||||||
|
engine.register_fn("get_balance", |provider_url: String, public_key: rhai::Blob| -> Result<String, String> {
|
||||||
|
// WASM: get_balance is async, so error if called from Rhai
|
||||||
|
Err("get_balance is async in WASM; use the WASM get_balance() API from JS instead".to_string())
|
||||||
|
});
|
||||||
|
engine.register_fn("send_transaction", |provider_url: String, key_id: String, password: rhai::Blob, tx_data: rhai::Map| -> Result<String, String> {
|
||||||
|
// WASM: send_transaction is async, so error if called from Rhai
|
||||||
|
Err("send_transaction is async in WASM; use the WASM send_transaction() API from JS instead".to_string())
|
||||||
|
});
|
||||||
|
// No global evm object in WASM; use JS/WASM API for EVM ops
|
||||||
|
}
|
||||||
|
|
37
evm_client/src/rhai_sync_helpers.rs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
//! Synchronous wrappers for async EVM client APIs for use in Rhai bindings.
|
||||||
|
//! These use block_on for native, and should be adapted for WASM as needed.
|
||||||
|
|
||||||
|
use crate::EvmClient;
|
||||||
|
use rhai::Map;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use tokio::runtime::Handle;
|
||||||
|
|
||||||
|
/// Synchronously get the balance using the EVM client.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn get_balance_sync(
|
||||||
|
evm_client: &EvmClient,
|
||||||
|
address: ethers_core::types::Address,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
Handle::current().block_on(async {
|
||||||
|
evm_client.get_balance(address)
|
||||||
|
.await
|
||||||
|
.map(|b| b.to_string())
|
||||||
|
.map_err(|e| format!("get_balance error: {e}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synchronously send a transaction using the EVM client.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn send_transaction_sync(
|
||||||
|
evm_client: &EvmClient,
|
||||||
|
tx: crate::provider::Transaction,
|
||||||
|
signer: &dyn crate::signer::Signer,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
Handle::current().block_on(async {
|
||||||
|
evm_client.send_transaction(tx, signer)
|
||||||
|
.await
|
||||||
|
.map(|tx| format!("0x{:x}", tx))
|
||||||
|
.map_err(|e| format!("send_transaction error: {e}"))
|
||||||
|
})
|
||||||
|
}
|
8
evm_client/src/signer.rs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
use super::error::EvmError;
|
||||||
|
|
||||||
|
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
|
||||||
|
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
|
||||||
|
pub trait Signer: Send + Sync {
|
||||||
|
async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, EvmError>;
|
||||||
|
fn address(&self) -> String;
|
||||||
|
}
|
25
evm_client/src/utils.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// No longer needed: use serde_json and ethers-core utilities directly.
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub async fn http_post(url: &str, body: &str) -> Result<serde_json::Value, EvmError> {
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post(url)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(body.to_owned())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
let json = resp.json().await.map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
Ok(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub async fn http_post(url: &str, body: &str) -> Result<serde_json::Value, EvmError> {
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
let resp = Request::post(url)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(body).map_err(|e| EvmError::Rpc(e.to_string()))?
|
||||||
|
.send().await.map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
let json = resp.json().await.map_err(|e| EvmError::Rpc(e.to_string()))?;
|
||||||
|
Ok(json)
|
||||||
|
}
|
49
evm_client/tests/balance.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// This file contains native-only integration tests for EVM client balance and signing logic.
|
||||||
|
// All code is strictly separated from WASM code using cfg attributes.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rlp_encode_unsigned() {
|
||||||
|
use ethers_core::types::{Address, U256, Bytes};
|
||||||
|
use evm_client::provider::Transaction;
|
||||||
|
|
||||||
|
let tx = Transaction {
|
||||||
|
to: Address::zero(),
|
||||||
|
value: U256::from(100),
|
||||||
|
data: Bytes::new(),
|
||||||
|
gas: Some(U256::from(21000)),
|
||||||
|
gas_price: Some(U256::from(1)),
|
||||||
|
nonce: Some(U256::from(1)),
|
||||||
|
chain_id: Some(1u64),
|
||||||
|
};
|
||||||
|
let rlp = tx.rlp_encode_unsigned();
|
||||||
|
assert!(!rlp.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_signature_rs_v() {
|
||||||
|
use ethers_core::types::U256;
|
||||||
|
use evm_client::provider::parse_signature_rs_v;
|
||||||
|
|
||||||
|
let mut sig = [0u8; 65];
|
||||||
|
sig[31] = 1; sig[63] = 2; sig[64] = 27;
|
||||||
|
let (r, s, v) = parse_signature_rs_v(&sig, 1).unwrap();
|
||||||
|
assert_eq!(r, U256::from(1));
|
||||||
|
assert_eq!(s, U256::from(2));
|
||||||
|
assert_eq!(v, 27 + 1 * 2 + 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_balance_real_address() {
|
||||||
|
use ethers_core::types::{Address, U256};
|
||||||
|
use evm_client::provider::get_balance;
|
||||||
|
|
||||||
|
// Vitalik's address
|
||||||
|
let address = "d8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
|
||||||
|
let address = ethers_core::types::Address::from_slice(&hex::decode(address).unwrap());
|
||||||
|
let url = "https://ethereum.blockpi.network/v1/rpc/public";
|
||||||
|
let balance = get_balance(url, address).await.expect("Failed to get balance"); // TODO: Update to use new EvmClient API
|
||||||
|
assert!(balance > ethers_core::types::U256::zero(), "Vitalik's balance should be greater than zero");
|
||||||
|
}
|
11
evm_client/tests/evm_client.rs
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
// tests/evm_client.rs
|
||||||
|
use evm_client::send_rpc;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_send_rpc_smoke() {
|
||||||
|
// This test just checks the function compiles and can be called.
|
||||||
|
let url = "http://localhost:8545";
|
||||||
|
let body = r#"{"jsonrpc":"2.0","method":"web3_clientVersion","params":[],"id":1}"#;
|
||||||
|
let _ = send_rpc(url, body).await;
|
||||||
|
}
|
47
evm_client/tests/wasm.rs
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// This file contains WASM-only integration tests for EVM client balance and signing logic.
|
||||||
|
// All code is strictly separated from native using cfg attributes.
|
||||||
|
#![cfg(target_arch = "wasm32")]
|
||||||
|
use wasm_bindgen_test::*;
|
||||||
|
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
|
||||||
|
|
||||||
|
use evm_client::provider::{Transaction, parse_signature_rs_v, get_balance};
|
||||||
|
use ethers_core::types::{U256, Address, Bytes};
|
||||||
|
use hex;
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
fn test_rlp_encode_unsigned() {
|
||||||
|
let tx = Transaction {
|
||||||
|
to: Address::zero(),
|
||||||
|
value: U256::from(100),
|
||||||
|
data: Bytes::new(),
|
||||||
|
gas: Some(U256::from(21000)),
|
||||||
|
gas_price: Some(U256::from(1)),
|
||||||
|
nonce: Some(U256::from(1)),
|
||||||
|
chain_id: Some(1),
|
||||||
|
};
|
||||||
|
let rlp = tx.rlp_encode_unsigned();
|
||||||
|
assert!(!rlp.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[wasm_bindgen_test(async)]
|
||||||
|
pub async fn test_get_balance_real_address_wasm_unique() {
|
||||||
|
web_sys::console::log_1(&"WASM balance test running!".into());
|
||||||
|
// Vitalik's address
|
||||||
|
let address = "d8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
|
||||||
|
let address = Address::from_slice(&hex::decode(address).unwrap());
|
||||||
|
let url = "https://ethereum.blockpi.network/v1/rpc/public";
|
||||||
|
let balance = get_balance(url, address).await.expect("Failed to get balance"); // TODO: Update to use new EvmClient API
|
||||||
|
web_sys::console::log_1(&format!("Balance: {balance:?}").into());
|
||||||
|
assert!(balance > U256::zero(), "Vitalik's balance should be greater than zero");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
fn test_parse_signature_rs_v() {
|
||||||
|
let mut sig = [0u8; 65];
|
||||||
|
sig[31] = 1; sig[63] = 2; sig[64] = 27;
|
||||||
|
let (r, s, v) = parse_signature_rs_v(&sig, 1).unwrap();
|
||||||
|
assert_eq!(r, U256::from(1));
|
||||||
|
assert_eq!(s, U256::from(2));
|
||||||
|
assert_eq!(v, 27 + 1 * 2 + 8);
|
||||||
|
}
|
88
hero_vault_extension/README.md
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# SAL Modular Cryptographic Browser Extension
|
||||||
|
|
||||||
|
A modern, secure browser extension for interacting with the SAL modular Rust cryptographic stack, enabling key management, cryptographic operations, and secure Rhai script execution.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Session & Key Management
|
||||||
|
- Create and unlock encrypted keyspaces with password protection
|
||||||
|
- Create, select, and manage multiple keypairs (Ed25519, Secp256k1)
|
||||||
|
- Clear session state visualization and management
|
||||||
|
|
||||||
|
### Cryptographic Operations
|
||||||
|
- Sign and verify messages using selected keypair
|
||||||
|
- Encrypt and decrypt messages using asymmetric cryptography
|
||||||
|
- Support for symmetric encryption using password-derived keys
|
||||||
|
|
||||||
|
### Scripting (Rhai)
|
||||||
|
- Execute Rhai scripts securely within the extension
|
||||||
|
- Explicit user approval for all script executions
|
||||||
|
- Script history and audit trail
|
||||||
|
|
||||||
|
### WebSocket Integration
|
||||||
|
- Connect to WebSocket servers using keypair's public key
|
||||||
|
- Receive, review, and approve/reject incoming scripts
|
||||||
|
- Support for both local and remote script execution
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Dark mode UI with modern, responsive design
|
||||||
|
- Session auto-lock after configurable inactivity period
|
||||||
|
- Explicit user approval for all sensitive operations
|
||||||
|
- No persistent storage of passwords or private keys in plaintext
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The extension is built with a modern tech stack:
|
||||||
|
|
||||||
|
- **Frontend**: React with TypeScript, Material-UI
|
||||||
|
- **State Management**: Zustand
|
||||||
|
- **Backend**: WebAssembly (WASM) modules compiled from Rust
|
||||||
|
- **Storage**: Chrome extension storage API with encryption
|
||||||
|
- **Networking**: WebSocket for server communication
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```
|
||||||
|
cd sal_extension
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Build the extension:
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Load the extension in Chrome/Edge:
|
||||||
|
- Navigate to `chrome://extensions/`
|
||||||
|
- Enable "Developer mode"
|
||||||
|
- Click "Load unpacked" and select the `dist` directory
|
||||||
|
|
||||||
|
4. For development with hot-reload:
|
||||||
|
```
|
||||||
|
npm run watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with WASM
|
||||||
|
|
||||||
|
The extension uses WebAssembly modules compiled from Rust to perform cryptographic operations securely. The WASM modules are loaded in the extension's background script and provide a secure API for the frontend.
|
||||||
|
|
||||||
|
Key WASM functions exposed:
|
||||||
|
- `init_session` - Unlock a keyspace with password
|
||||||
|
- `create_keyspace` - Create a new keyspace
|
||||||
|
- `add_keypair` - Create a new keypair
|
||||||
|
- `select_keypair` - Select a keypair for use
|
||||||
|
- `sign` - Sign a message with the selected keypair
|
||||||
|
- `run_rhai` - Execute a Rhai script securely
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- The extension follows the principle of least privilege
|
||||||
|
- All sensitive operations require explicit user approval
|
||||||
|
- Passwords are never stored persistently, only kept in memory during an active session
|
||||||
|
- Session state is automatically cleared when the extension is locked
|
||||||
|
- WebSocket connections are authenticated using the user's public key
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT License](LICENSE)
|
1
hero_vault_extension/dist/assets/index-11057528.css
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
:root{font-family:Roboto,system-ui,sans-serif;line-height:1.5;font-weight:400;color-scheme:dark}body{margin:0;min-width:360px;min-height:520px;overflow-x:hidden}#root{width:100%;height:100%}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:rgba(255,255,255,.05);border-radius:3px}::-webkit-scrollbar-thumb{background:rgba(255,255,255,.2);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.3)}
|
205
hero_vault_extension/dist/assets/index-b58c7e43.js
vendored
Normal file
1
hero_vault_extension/dist/assets/simple-background.ts-e63275e1.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
console.log("Background script initialized");let i=!1,e=null;chrome.runtime.onMessage.addListener((o,l,r)=>{if(console.log("Background received message:",o.type),o.type==="SESSION_STATUS")return r({active:i}),!0;if(o.type==="SESSION_UNLOCK")return i=!0,r({success:!0}),!0;if(o.type==="SESSION_LOCK")return i=!1,e&&(e.close(),e=null),r({success:!0}),!0;if(o.type==="CONNECT_WEBSOCKET"&&o.serverUrl&&o.publicKey){try{e&&e.close(),e=new WebSocket(o.serverUrl),e.onopen=()=>{console.log("WebSocket connection established"),e&&e.send(JSON.stringify({type:"IDENTIFY",publicKey:o.publicKey}))},e.onmessage=c=>{try{const t=JSON.parse(c.data);console.log("WebSocket message received:",t),chrome.runtime.sendMessage({type:"WEBSOCKET_MESSAGE",data:t}).catch(n=>{console.error("Failed to forward WebSocket message:",n)})}catch(t){console.error("Failed to parse WebSocket message:",t)}},e.onerror=c=>{console.error("WebSocket error:",c)},e.onclose=()=>{console.log("WebSocket connection closed"),e=null},r({success:!0})}catch(c){console.error("Failed to connect to WebSocket:",c),r({success:!1,error:c.message})}return!0}return o.type==="DISCONNECT_WEBSOCKET"?(e?(e.close(),e=null,r({success:!0})):r({success:!1,error:"No active WebSocket connection"}),!0):!1});chrome.notifications&&chrome.notifications.onClicked&&chrome.notifications.onClicked.addListener(o=>{chrome.action.openPopup()});
|
2
hero_vault_extension/dist/assets/wasm_app-bd9134aa.js
vendored
Normal file
61
hero_vault_extension/dist/background.js
vendored
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
|
||||||
|
// Background Service Worker for SAL Modular Cryptographic Extension
|
||||||
|
// This is a simplified version that only handles messaging
|
||||||
|
|
||||||
|
console.log('Background script initialized');
|
||||||
|
|
||||||
|
// Store active WebSocket connection
|
||||||
|
let activeWebSocket = null;
|
||||||
|
let sessionActive = false;
|
||||||
|
|
||||||
|
// Listen for messages from popup or content scripts
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('Background received message:', message.type);
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_STATUS') {
|
||||||
|
sendResponse({ active: sessionActive });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_UNLOCK') {
|
||||||
|
sessionActive = true;
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_LOCK') {
|
||||||
|
sessionActive = false;
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.close();
|
||||||
|
activeWebSocket = null;
|
||||||
|
}
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'CONNECT_WEBSOCKET') {
|
||||||
|
// Simplified WebSocket handling
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'DISCONNECT_WEBSOCKET') {
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.close();
|
||||||
|
activeWebSocket = null;
|
||||||
|
sendResponse({ success: true });
|
||||||
|
} else {
|
||||||
|
sendResponse({ success: false, error: 'No active WebSocket connection' });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize notification setup
|
||||||
|
chrome.notifications.onClicked.addListener((notificationId) => {
|
||||||
|
// Open the extension popup when a notification is clicked
|
||||||
|
chrome.action.openPopup();
|
||||||
|
});
|
||||||
|
|
BIN
hero_vault_extension/dist/icons/icon-128.png
vendored
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
hero_vault_extension/dist/icons/icon-16.png
vendored
Normal file
After Width: | Height: | Size: 454 B |
BIN
hero_vault_extension/dist/icons/icon-32.png
vendored
Normal file
After Width: | Height: | Size: 712 B |
BIN
hero_vault_extension/dist/icons/icon-48.png
vendored
Normal file
After Width: | Height: | Size: 1.1 KiB |
14
hero_vault_extension/dist/index.html
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Hero Vault</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-b58c7e43.js"></script>
|
||||||
|
<link rel="stylesheet" href="/assets/index-11057528.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
26
hero_vault_extension/dist/manifest.json
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Hero Vault",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A secure browser extension for cryptographic operations and Rhai script execution",
|
||||||
|
"action": {
|
||||||
|
"default_popup": "index.html",
|
||||||
|
"default_title": "Hero Vault"
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon-16.png",
|
||||||
|
"48": "icons/icon-48.png",
|
||||||
|
"128": "icons/icon-128.png"
|
||||||
|
},
|
||||||
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"unlimitedStorage"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "service-worker-loader.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
|
}
|
||||||
|
}
|
1
hero_vault_extension/dist/service-worker-loader.js
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
import './assets/simple-background.ts-e63275e1.js';
|
12
hero_vault_extension/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Hero Vault</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
4862
hero_vault_extension/package-lock.json
generated
Normal file
42
hero_vault_extension/package.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "hero-vault-extension",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Hero Vault - A secure browser extension for cryptographic operations",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node scripts/copy-wasm.js && vite",
|
||||||
|
"build": "node scripts/copy-wasm.js && ([ \"$NO_TYPECHECK\" = \"true\" ] || tsc) && vite build",
|
||||||
|
"watch": "node scripts/copy-wasm.js && tsc && vite build --watch",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
|
||||||
|
"copy-wasm": "node scripts/copy-wasm.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@mui/icons-material": "^5.14.3",
|
||||||
|
"@mui/material": "^5.14.3",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.14.2",
|
||||||
|
"zustand": "^4.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@crxjs/vite-plugin": "^2.0.0-beta.18",
|
||||||
|
"@types/chrome": "^0.0.243",
|
||||||
|
"@types/node": "^20.4.5",
|
||||||
|
"@types/react": "^18.2.15",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
|
"esbuild": "^0.25.4",
|
||||||
|
"eslint": "^8.45.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.3",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"sass": "^1.64.1",
|
||||||
|
"typescript": "^5.0.2",
|
||||||
|
"vite": "^4.4.5"
|
||||||
|
}
|
||||||
|
}
|
BIN
hero_vault_extension/public/icons/icon-128.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
hero_vault_extension/public/icons/icon-16.png
Normal file
After Width: | Height: | Size: 454 B |
BIN
hero_vault_extension/public/icons/icon-32.png
Normal file
After Width: | Height: | Size: 712 B |
BIN
hero_vault_extension/public/icons/icon-48.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
26
hero_vault_extension/public/manifest.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Hero Vault",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A secure browser extension for cryptographic operations and Rhai script execution",
|
||||||
|
"action": {
|
||||||
|
"default_popup": "index.html",
|
||||||
|
"default_title": "Hero Vault"
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon-16.png",
|
||||||
|
"48": "icons/icon-48.png",
|
||||||
|
"128": "icons/icon-128.png"
|
||||||
|
},
|
||||||
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"unlimitedStorage"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "src/background/simple-background.ts",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
|
}
|
||||||
|
}
|
85
hero_vault_extension/scripts/build-background.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Script to build the background script for the extension
|
||||||
|
*/
|
||||||
|
const { build } = require('esbuild');
|
||||||
|
const { resolve } = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
async function buildBackground() {
|
||||||
|
try {
|
||||||
|
console.log('Building background script...');
|
||||||
|
|
||||||
|
// First, create a simplified background script that doesn't import WASM
|
||||||
|
const backgroundContent = `
|
||||||
|
// Background Service Worker for SAL Modular Cryptographic Extension
|
||||||
|
// This is a simplified version that only handles messaging
|
||||||
|
|
||||||
|
console.log('Background script initialized');
|
||||||
|
|
||||||
|
// Store active WebSocket connection
|
||||||
|
let activeWebSocket = null;
|
||||||
|
let sessionActive = false;
|
||||||
|
|
||||||
|
// Listen for messages from popup or content scripts
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('Background received message:', message.type);
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_STATUS') {
|
||||||
|
sendResponse({ active: sessionActive });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_UNLOCK') {
|
||||||
|
sessionActive = true;
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_LOCK') {
|
||||||
|
sessionActive = false;
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.close();
|
||||||
|
activeWebSocket = null;
|
||||||
|
}
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'CONNECT_WEBSOCKET') {
|
||||||
|
// Simplified WebSocket handling
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'DISCONNECT_WEBSOCKET') {
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.close();
|
||||||
|
activeWebSocket = null;
|
||||||
|
sendResponse({ success: true });
|
||||||
|
} else {
|
||||||
|
sendResponse({ success: false, error: 'No active WebSocket connection' });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize notification setup
|
||||||
|
chrome.notifications.onClicked.addListener((notificationId) => {
|
||||||
|
// Open the extension popup when a notification is clicked
|
||||||
|
chrome.action.openPopup();
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Write the simplified background script to a temporary file
|
||||||
|
fs.writeFileSync(resolve(__dirname, '../dist/background.js'), backgroundContent);
|
||||||
|
|
||||||
|
console.log('Background script built successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error building background script:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildBackground();
|
33
hero_vault_extension/scripts/copy-wasm.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Script to copy WASM files from wasm_app/pkg to the extension build directory
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Source and destination paths
|
||||||
|
const sourceDir = path.resolve(__dirname, '../../wasm_app/pkg');
|
||||||
|
const destDir = path.resolve(__dirname, '../public/wasm');
|
||||||
|
|
||||||
|
// Create destination directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(destDir)) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true });
|
||||||
|
console.log(`Created directory: ${destDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy all files from source to destination
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(sourceDir);
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const sourcePath = path.join(sourceDir, file);
|
||||||
|
const destPath = path.join(destDir, file);
|
||||||
|
|
||||||
|
fs.copyFileSync(sourcePath, destPath);
|
||||||
|
console.log(`Copied: ${file}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('WASM files copied successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error copying WASM files:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
127
hero_vault_extension/src/App.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Box, Container, Paper } from '@mui/material';
|
||||||
|
import { Routes, Route, HashRouter } from 'react-router-dom';
|
||||||
|
|
||||||
|
// Import pages
|
||||||
|
import HomePage from './pages/HomePage';
|
||||||
|
import SessionPage from './pages/SessionPage';
|
||||||
|
import KeypairPage from './pages/KeypairPage';
|
||||||
|
import ScriptPage from './pages/ScriptPage';
|
||||||
|
import SettingsPage from './pages/SettingsPage';
|
||||||
|
import WebSocketPage from './pages/WebSocketPage';
|
||||||
|
import CryptoPage from './pages/CryptoPage';
|
||||||
|
|
||||||
|
// Import components
|
||||||
|
import Header from './components/Header';
|
||||||
|
import Navigation from './components/Navigation';
|
||||||
|
|
||||||
|
// Import session state management
|
||||||
|
import { useSessionStore } from './store/sessionStore';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { checkSessionStatus, initWasm } = useSessionStore();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [wasmError, setWasmError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Initialize WASM and check session status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeApp = async () => {
|
||||||
|
try {
|
||||||
|
// First initialize WASM module
|
||||||
|
const wasmInitialized = await initWasm();
|
||||||
|
|
||||||
|
if (!wasmInitialized) {
|
||||||
|
throw new Error('Failed to initialize WASM module');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check session status
|
||||||
|
await checkSessionStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Initialization error:', error);
|
||||||
|
setWasmError((error as Error).message || 'Failed to initialize the extension');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeApp();
|
||||||
|
}, [checkSessionStatus, initWasm]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasmError) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
p: 3,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper sx={{ p: 3, maxWidth: 400 }}>
|
||||||
|
<h6 style={{ color: 'red', marginBottom: '8px' }}>
|
||||||
|
WASM Module Failed to Initialize
|
||||||
|
</h6>
|
||||||
|
<p style={{ marginBottom: '16px' }}>
|
||||||
|
The WASM module could not be loaded. Please try reloading the extension.
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: 'gray' }}>
|
||||||
|
Error: {wasmError} Please contact support if the problem persists.
|
||||||
|
</p>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HashRouter>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<Container component="main" sx={{ flexGrow: 1, overflow: 'auto', py: 2 }}>
|
||||||
|
<Paper
|
||||||
|
elevation={3}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/session" element={<SessionPage />} />
|
||||||
|
<Route path="/keypair" element={<KeypairPage />} />
|
||||||
|
<Route path="/crypto" element={<CryptoPage />} />
|
||||||
|
<Route path="/script" element={<ScriptPage />} />
|
||||||
|
<Route path="/websocket" element={<WebSocketPage />} />
|
||||||
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Navigation />
|
||||||
|
</Box>
|
||||||
|
</HashRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
145
hero_vault_extension/src/background/index.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Background Service Worker for Hero Vault Extension
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Maintain WebSocket connections
|
||||||
|
* - Handle incoming script requests
|
||||||
|
* - Manage session state when popup is closed
|
||||||
|
* - Provide messaging interface for popup/content scripts
|
||||||
|
* - Initialize WASM module when extension starts
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Import WASM helper functions
|
||||||
|
import { initWasm } from '../wasm/wasmHelper';
|
||||||
|
|
||||||
|
// Initialize WASM module when service worker starts
|
||||||
|
initWasm().catch(error => {
|
||||||
|
console.error('Failed to initialize WASM module:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store active WebSocket connection
|
||||||
|
let activeWebSocket: WebSocket | null = null;
|
||||||
|
let sessionActive = false;
|
||||||
|
|
||||||
|
// Listen for messages from popup or content scripts
|
||||||
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message.type === 'SESSION_STATUS') {
|
||||||
|
sendResponse({ active: sessionActive });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_UNLOCK') {
|
||||||
|
sessionActive = true;
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_LOCK') {
|
||||||
|
sessionActive = false;
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.close();
|
||||||
|
activeWebSocket = null;
|
||||||
|
}
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'CONNECT_WEBSOCKET' && message.serverUrl && message.publicKey) {
|
||||||
|
connectToWebSocket(message.serverUrl, message.publicKey)
|
||||||
|
.then(success => sendResponse({ success }))
|
||||||
|
.catch(error => sendResponse({ success: false, error: error.message }));
|
||||||
|
return true; // Indicates we'll respond asynchronously
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'DISCONNECT_WEBSOCKET') {
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.close();
|
||||||
|
activeWebSocket = null;
|
||||||
|
sendResponse({ success: true });
|
||||||
|
} else {
|
||||||
|
sendResponse({ success: false, error: 'No active WebSocket connection' });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to a WebSocket server with the user's public key
|
||||||
|
*/
|
||||||
|
async function connectToWebSocket(serverUrl: string, publicKey: string): Promise<boolean> {
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(serverUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Send authentication message with public key
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'AUTH',
|
||||||
|
publicKey
|
||||||
|
}));
|
||||||
|
|
||||||
|
activeWebSocket = ws;
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
reject(new Error('Failed to connect to WebSocket server'));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
activeWebSocket = null;
|
||||||
|
console.log('WebSocket connection closed');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = async (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// Handle incoming script requests
|
||||||
|
if (data.type === 'SCRIPT_REQUEST') {
|
||||||
|
// Notify the user of the script request
|
||||||
|
chrome.notifications.create({
|
||||||
|
type: 'basic',
|
||||||
|
iconUrl: 'icons/icon128.png',
|
||||||
|
title: 'Script Request',
|
||||||
|
message: `Received script request: ${data.title || 'Untitled Script'}`,
|
||||||
|
priority: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store the script request for the popup to handle
|
||||||
|
await chrome.storage.local.set({
|
||||||
|
pendingScripts: [
|
||||||
|
...(await chrome.storage.local.get('pendingScripts')).pendingScripts || [],
|
||||||
|
{
|
||||||
|
id: data.id,
|
||||||
|
title: data.title || 'Untitled Script',
|
||||||
|
description: data.description || '',
|
||||||
|
script: data.script,
|
||||||
|
tags: data.tags || [],
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize notification setup
|
||||||
|
chrome.notifications.onClicked.addListener((_notificationId) => {
|
||||||
|
// Open the extension popup when a notification is clicked
|
||||||
|
chrome.action.openPopup();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Hero Vault Extension background service worker initialized');
|
115
hero_vault_extension/src/background/simple-background.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Simplified Background Service Worker for Hero Vault Extension
|
||||||
|
*
|
||||||
|
* This is a version that doesn't use WASM to avoid service worker limitations
|
||||||
|
* with dynamic imports. It only handles basic messaging between components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
console.log('Background script initialized');
|
||||||
|
|
||||||
|
// Store session state
|
||||||
|
let sessionActive = false;
|
||||||
|
let activeWebSocket: WebSocket | null = null;
|
||||||
|
|
||||||
|
// Listen for messages from popup or content scripts
|
||||||
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||||
|
console.log('Background received message:', message.type);
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_STATUS') {
|
||||||
|
sendResponse({ active: sessionActive });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_UNLOCK') {
|
||||||
|
sessionActive = true;
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'SESSION_LOCK') {
|
||||||
|
sessionActive = false;
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.close();
|
||||||
|
activeWebSocket = null;
|
||||||
|
}
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'CONNECT_WEBSOCKET' && message.serverUrl && message.publicKey) {
|
||||||
|
// Simplified WebSocket handling
|
||||||
|
try {
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeWebSocket = new WebSocket(message.serverUrl);
|
||||||
|
|
||||||
|
activeWebSocket.onopen = () => {
|
||||||
|
console.log('WebSocket connection established');
|
||||||
|
// Send public key to identify this client
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.send(JSON.stringify({
|
||||||
|
type: 'IDENTIFY',
|
||||||
|
publicKey: message.publicKey
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
activeWebSocket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log('WebSocket message received:', data);
|
||||||
|
|
||||||
|
// Forward message to popup
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'WEBSOCKET_MESSAGE',
|
||||||
|
data
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Failed to forward WebSocket message:', error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
activeWebSocket.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
activeWebSocket.onclose = () => {
|
||||||
|
console.log('WebSocket connection closed');
|
||||||
|
activeWebSocket = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
sendResponse({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to WebSocket:', error);
|
||||||
|
sendResponse({ success: false, error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'DISCONNECT_WEBSOCKET') {
|
||||||
|
if (activeWebSocket) {
|
||||||
|
activeWebSocket.close();
|
||||||
|
activeWebSocket = null;
|
||||||
|
sendResponse({ success: true });
|
||||||
|
} else {
|
||||||
|
sendResponse({ success: false, error: 'No active WebSocket connection' });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we don't handle the message, return false
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle notifications if available
|
||||||
|
if (chrome.notifications && chrome.notifications.onClicked) {
|
||||||
|
chrome.notifications.onClicked.addListener((notificationId) => {
|
||||||
|
// Open the extension popup when a notification is clicked
|
||||||
|
chrome.action.openPopup();
|
||||||
|
});
|
||||||
|
}
|
97
hero_vault_extension/src/components/Header.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { AppBar, Toolbar, Typography, IconButton, Box, Chip } from '@mui/material';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||||
|
import SignalWifiStatusbar4BarIcon from '@mui/icons-material/SignalWifiStatusbar4Bar';
|
||||||
|
import SignalWifiOffIcon from '@mui/icons-material/SignalWifiOff';
|
||||||
|
import { useSessionStore } from '../store/sessionStore';
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
const {
|
||||||
|
isSessionUnlocked,
|
||||||
|
currentKeyspace,
|
||||||
|
currentKeypair,
|
||||||
|
isWebSocketConnected,
|
||||||
|
lockSession
|
||||||
|
} = useSessionStore();
|
||||||
|
|
||||||
|
const handleLockClick = async () => {
|
||||||
|
if (isSessionUnlocked) {
|
||||||
|
await lockSession();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBar position="static" color="primary" elevation={0}>
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
Hero Vault
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
{/* WebSocket connection status */}
|
||||||
|
{isWebSocketConnected ? (
|
||||||
|
<Chip
|
||||||
|
icon={<SignalWifiStatusbar4BarIcon fontSize="small" />}
|
||||||
|
label="Connected"
|
||||||
|
size="small"
|
||||||
|
color="success"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
icon={<SignalWifiOffIcon fontSize="small" />}
|
||||||
|
label="Offline"
|
||||||
|
size="small"
|
||||||
|
color="default"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Session status */}
|
||||||
|
{isSessionUnlocked ? (
|
||||||
|
<Chip
|
||||||
|
icon={<LockOpenIcon fontSize="small" />}
|
||||||
|
label={currentKeyspace || 'Unlocked'}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Chip
|
||||||
|
icon={<LockIcon fontSize="small" />}
|
||||||
|
label="Locked"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current keypair */}
|
||||||
|
{isSessionUnlocked && currentKeypair && (
|
||||||
|
<Chip
|
||||||
|
label={currentKeypair.name || currentKeypair.id}
|
||||||
|
size="small"
|
||||||
|
color="secondary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lock button */}
|
||||||
|
{isSessionUnlocked && (
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
color="inherit"
|
||||||
|
onClick={handleLockClick}
|
||||||
|
size="small"
|
||||||
|
aria-label="lock session"
|
||||||
|
>
|
||||||
|
<LockIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
130
hero_vault_extension/src/components/Navigation.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { BottomNavigation, BottomNavigationAction, Paper, Box, IconButton, Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
|
||||||
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
|
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||||
|
import CodeIcon from '@mui/icons-material/Code';
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import WifiIcon from '@mui/icons-material/Wifi';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import { useSessionStore } from '../store/sessionStore';
|
||||||
|
|
||||||
|
const Navigation = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { isSessionUnlocked } = useSessionStore();
|
||||||
|
|
||||||
|
// Get current path without leading slash
|
||||||
|
const currentPath = location.pathname.substring(1) || 'home';
|
||||||
|
|
||||||
|
// State for the more menu
|
||||||
|
const [moreAnchorEl, setMoreAnchorEl] = useState<null | HTMLElement>(null);
|
||||||
|
const isMoreMenuOpen = Boolean(moreAnchorEl);
|
||||||
|
|
||||||
|
const handleMoreClick = (event: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||||
|
setMoreAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoreClose = () => {
|
||||||
|
setMoreAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigation = (path: string) => {
|
||||||
|
navigate(`/${path === 'home' ? '' : path}`);
|
||||||
|
handleMoreClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
sx={{ position: 'static', bottom: 0, left: 0, right: 0 }}
|
||||||
|
elevation={3}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', width: '100%' }}>
|
||||||
|
<BottomNavigation
|
||||||
|
showLabels
|
||||||
|
value={currentPath}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
navigate(`/${newValue === 'home' ? '' : newValue}`);
|
||||||
|
}}
|
||||||
|
sx={{ flexGrow: 1 }}
|
||||||
|
>
|
||||||
|
<BottomNavigationAction
|
||||||
|
label="Home"
|
||||||
|
value="home"
|
||||||
|
icon={<HomeIcon />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BottomNavigationAction
|
||||||
|
label="Keys"
|
||||||
|
value="keypair"
|
||||||
|
icon={<VpnKeyIcon />}
|
||||||
|
disabled={!isSessionUnlocked}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BottomNavigationAction
|
||||||
|
label="Crypto"
|
||||||
|
value="crypto"
|
||||||
|
icon={<LockIcon />}
|
||||||
|
disabled={!isSessionUnlocked}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BottomNavigationAction
|
||||||
|
label="More"
|
||||||
|
value="more"
|
||||||
|
icon={<MoreVertIcon />}
|
||||||
|
onClick={handleMoreClick}
|
||||||
|
/>
|
||||||
|
</BottomNavigation>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
anchorEl={moreAnchorEl}
|
||||||
|
open={isMoreMenuOpen}
|
||||||
|
onClose={handleMoreClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'right',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => handleNavigation('script')}
|
||||||
|
disabled={!isSessionUnlocked}
|
||||||
|
selected={currentPath === 'script'}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<CodeIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>Scripts</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => handleNavigation('websocket')}
|
||||||
|
disabled={!isSessionUnlocked}
|
||||||
|
selected={currentPath === 'websocket'}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<WifiIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>WebSocket</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => handleNavigation('settings')}
|
||||||
|
selected={currentPath === 'settings'}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<SettingsIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>Settings</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Navigation;
|
38
hero_vault_extension/src/index.css
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
:root {
|
||||||
|
font-family: 'Roboto', system-ui, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 360px;
|
||||||
|
min-height: 520px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
64
hero_vault_extension/src/main.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
// Create a dark theme for the extension
|
||||||
|
const darkTheme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
primary: {
|
||||||
|
main: '#6200ee',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#03dac6',
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#121212',
|
||||||
|
paper: '#1e1e1e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||||
|
h1: {
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: '1.125rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 8,
|
||||||
|
textTransform: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={darkTheme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
392
hero_vault_extension/src/pages/CryptoPage.tsx
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
/**
|
||||||
|
* Cryptographic Operations Page
|
||||||
|
*
|
||||||
|
* This page provides a UI for:
|
||||||
|
* - Encrypting/decrypting data using the keyspace's symmetric cipher
|
||||||
|
* - Signing/verifying messages using the selected keypair
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { SyntheticEvent } from '../types';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import { useSessionStore } from '../store/sessionStore';
|
||||||
|
import { useCryptoStore } from '../store/cryptoStore';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const CryptoPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isSessionUnlocked, currentKeypair } = useSessionStore();
|
||||||
|
const {
|
||||||
|
encryptData,
|
||||||
|
decryptData,
|
||||||
|
signMessage,
|
||||||
|
verifySignature,
|
||||||
|
isEncrypting,
|
||||||
|
isDecrypting,
|
||||||
|
isSigning,
|
||||||
|
isVerifying,
|
||||||
|
error,
|
||||||
|
clearError
|
||||||
|
} = useCryptoStore();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState(0);
|
||||||
|
const [copySuccess, setCopySuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Encryption state
|
||||||
|
const [plaintext, setPlaintext] = useState('');
|
||||||
|
const [encryptedData, setEncryptedData] = useState('');
|
||||||
|
|
||||||
|
// Decryption state
|
||||||
|
const [ciphertext, setCiphertext] = useState('');
|
||||||
|
const [decryptedData, setDecryptedData] = useState('');
|
||||||
|
|
||||||
|
// Signing state
|
||||||
|
const [messageToSign, setMessageToSign] = useState('');
|
||||||
|
const [signature, setSignature] = useState('');
|
||||||
|
|
||||||
|
// Verification state
|
||||||
|
const [messageToVerify, setMessageToVerify] = useState('');
|
||||||
|
const [signatureToVerify, setSignatureToVerify] = useState('');
|
||||||
|
const [isVerified, setIsVerified] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// Redirect if not unlocked
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSessionUnlocked) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [isSessionUnlocked, navigate]);
|
||||||
|
|
||||||
|
const handleTabChange = (_event: React.SyntheticEvent<Element, Event>, newValue: number) => {
|
||||||
|
setActiveTab(newValue);
|
||||||
|
clearError();
|
||||||
|
setCopySuccess(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEncrypt = async () => {
|
||||||
|
try {
|
||||||
|
const result = await encryptData(plaintext);
|
||||||
|
setEncryptedData(result);
|
||||||
|
} catch (err) {
|
||||||
|
// Error is already handled in the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecrypt = async () => {
|
||||||
|
try {
|
||||||
|
const result = await decryptData(ciphertext);
|
||||||
|
setDecryptedData(result);
|
||||||
|
} catch (err) {
|
||||||
|
// Error is already handled in the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSign = async () => {
|
||||||
|
try {
|
||||||
|
const result = await signMessage(messageToSign);
|
||||||
|
setSignature(result);
|
||||||
|
} catch (err) {
|
||||||
|
// Error is already handled in the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerify = async () => {
|
||||||
|
try {
|
||||||
|
const result = await verifySignature(messageToVerify, signatureToVerify);
|
||||||
|
setIsVerified(result);
|
||||||
|
} catch (err) {
|
||||||
|
setIsVerified(false);
|
||||||
|
// Error is already handled in the store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string, label: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => {
|
||||||
|
setCopySuccess(`${label} copied to clipboard!`);
|
||||||
|
setTimeout(() => setCopySuccess(null), 2000);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
setCopySuccess('Failed to copy!');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSessionUnlocked) {
|
||||||
|
return null; // Will redirect via useEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2 }}>Cryptographic Operations</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{copySuccess && (
|
||||||
|
<Alert severity="success" sx={{ mb: 2 }}>
|
||||||
|
{copySuccess}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
{/* Tabs with smaller width and scrollable */}
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
allowScrollButtonsMobile
|
||||||
|
sx={{ minHeight: '48px' }}
|
||||||
|
>
|
||||||
|
<Tab label="Encrypt" sx={{ minWidth: '80px', minHeight: '48px', py: 0 }} />
|
||||||
|
<Tab label="Decrypt" sx={{ minWidth: '80px', minHeight: '48px', py: 0 }} />
|
||||||
|
<Tab label="Sign" sx={{ minWidth: '80px', minHeight: '48px', py: 0 }} />
|
||||||
|
<Tab label="Verify" sx={{ minWidth: '80px', minHeight: '48px', py: 0 }} />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Content area with proper scrolling */}
|
||||||
|
<Box sx={{ p: 2, flexGrow: 1, overflow: 'auto', height: 'calc(100% - 48px)' }}>
|
||||||
|
{/* Encryption Tab */}
|
||||||
|
{activeTab === 0 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>Encrypt Data</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
Data will be encrypted using ChaCha20-Poly1305 with a key derived from your keyspace password.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Data to Encrypt"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
fullWidth
|
||||||
|
value={plaintext}
|
||||||
|
onChange={(e) => setPlaintext(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleEncrypt}
|
||||||
|
disabled={!plaintext || isEncrypting}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
{isEncrypting ? <CircularProgress size={24} /> : 'Encrypt'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{encryptedData && (
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="subtitle1">Encrypted Result</Typography>
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<TextField
|
||||||
|
label="Encrypted Data (Base64)"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
fullWidth
|
||||||
|
value={encryptedData}
|
||||||
|
InputProps={{ readOnly: true }}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
<Tooltip title="Copy to clipboard">
|
||||||
|
<IconButton
|
||||||
|
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||||||
|
onClick={() => copyToClipboard(encryptedData, 'Encrypted data')}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Decryption Tab */}
|
||||||
|
{activeTab === 1 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>Decrypt Data</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
Paste encrypted data (in Base64 format) to decrypt it using your keyspace password.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Encrypted Data (Base64)"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
fullWidth
|
||||||
|
value={ciphertext}
|
||||||
|
onChange={(e) => setCiphertext(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleDecrypt}
|
||||||
|
disabled={!ciphertext || isDecrypting}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
{isDecrypting ? <CircularProgress size={24} /> : 'Decrypt'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{decryptedData && (
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="subtitle1">Decrypted Result</Typography>
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<TextField
|
||||||
|
label="Decrypted Data"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
fullWidth
|
||||||
|
value={decryptedData}
|
||||||
|
InputProps={{ readOnly: true }}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
<Tooltip title="Copy to clipboard">
|
||||||
|
<IconButton
|
||||||
|
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||||||
|
onClick={() => copyToClipboard(decryptedData, 'Decrypted data')}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Signing Tab */}
|
||||||
|
{activeTab === 2 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>Sign Message</Typography>
|
||||||
|
|
||||||
|
{!currentKeypair ? (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
Please select a keypair from the Keypair page before signing messages.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
Signing with keypair: {currentKeypair.name || currentKeypair.id.substring(0, 8)}...
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Message to Sign"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
fullWidth
|
||||||
|
value={messageToSign}
|
||||||
|
onChange={(e) => setMessageToSign(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
disabled={!currentKeypair}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleSign}
|
||||||
|
disabled={!messageToSign || !currentKeypair || isSigning}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
{isSigning ? <CircularProgress size={24} /> : 'Sign Message'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{signature && (
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="subtitle1">Signature</Typography>
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<TextField
|
||||||
|
label="Signature (Hex)"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
fullWidth
|
||||||
|
value={signature}
|
||||||
|
InputProps={{ readOnly: true }}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
<Tooltip title="Copy to clipboard">
|
||||||
|
<IconButton
|
||||||
|
sx={{ position: 'absolute', top: 8, right: 8 }}
|
||||||
|
onClick={() => copyToClipboard(signature, 'Signature')}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verification Tab */}
|
||||||
|
{activeTab === 3 && (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>Verify Signature</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
Verify that a message was signed by the currently selected keypair.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Message"
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
fullWidth
|
||||||
|
value={messageToVerify}
|
||||||
|
onChange={(e) => setMessageToVerify(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Signature (Hex)"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
fullWidth
|
||||||
|
value={signatureToVerify}
|
||||||
|
onChange={(e) => setSignatureToVerify(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
onClick={handleVerify}
|
||||||
|
disabled={!messageToVerify || !signatureToVerify || isVerifying}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
{isVerifying ? <CircularProgress size={24} /> : 'Verify Signature'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isVerified !== null && (
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Alert severity={isVerified ? "success" : "error"}>
|
||||||
|
{isVerified
|
||||||
|
? "Signature is valid! The message was signed by the expected keypair."
|
||||||
|
: "Invalid signature. The message may have been tampered with or signed by a different keypair."}
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CryptoPage;
|
155
hero_vault_extension/src/pages/HomePage.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Stack,
|
||||||
|
Alert,
|
||||||
|
CircularProgress
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useSessionStore } from '../store/sessionStore';
|
||||||
|
|
||||||
|
const HomePage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isSessionUnlocked, unlockSession, createKeyspace } = useSessionStore();
|
||||||
|
|
||||||
|
const [keyspace, setKeyspace] = useState<string>('');
|
||||||
|
const [password, setPassword] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [mode, setMode] = useState<'unlock' | 'create'>('unlock');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
if (mode === 'unlock') {
|
||||||
|
success = await unlockSession(keyspace, password);
|
||||||
|
} else {
|
||||||
|
success = await createKeyspace(keyspace, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Navigate to keypair page on success
|
||||||
|
navigate('/keypair');
|
||||||
|
} else {
|
||||||
|
setError(mode === 'unlock'
|
||||||
|
? 'Failed to unlock keyspace. Check your password and try again.'
|
||||||
|
: 'Failed to create keyspace. Please try again.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || 'An unexpected error occurred');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSessionUnlocked) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Welcome to Hero Vault
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" paragraph>
|
||||||
|
Your session is unlocked. You can now use the extension features.
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={2} justifyContent="center" mt={3}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => navigate('/keypair')}
|
||||||
|
>
|
||||||
|
Manage Keys
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
onClick={() => navigate('/script')}
|
||||||
|
>
|
||||||
|
Run Scripts
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 400, mx: 'auto', py: 2 }}>
|
||||||
|
<Typography variant="h5" align="center" gutterBottom>
|
||||||
|
Hero Vault
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Card variant="outlined" sx={{ mt: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{mode === 'unlock' ? 'Unlock Keyspace' : 'Create New Keyspace'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
label="Keyspace Name"
|
||||||
|
value={keyspace}
|
||||||
|
onChange={(e) => setKeyspace(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
onClick={() => setMode(mode === 'unlock' ? 'create' : 'unlock')}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{mode === 'unlock' ? 'Create New Keyspace' : 'Unlock Existing'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
disabled={isLoading || !keyspace || !password}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<CircularProgress size={24} color="inherit" />
|
||||||
|
) : mode === 'unlock' ? (
|
||||||
|
'Unlock'
|
||||||
|
) : (
|
||||||
|
'Create'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
242
hero_vault_extension/src/pages/KeypairPage.tsx
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
IconButton,
|
||||||
|
Divider,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
CircularProgress,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
Chip
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
|
import { useSessionStore } from '../store/sessionStore';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const KeypairPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
isSessionUnlocked,
|
||||||
|
availableKeypairs,
|
||||||
|
currentKeypair,
|
||||||
|
listKeypairs,
|
||||||
|
selectKeypair,
|
||||||
|
createKeypair
|
||||||
|
} = useSessionStore();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [newKeypairName, setNewKeypairName] = useState('');
|
||||||
|
const [newKeypairType, setNewKeypairType] = useState('Secp256k1');
|
||||||
|
const [newKeypairDescription, setNewKeypairDescription] = useState('');
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
// Redirect if not unlocked
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSessionUnlocked) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [isSessionUnlocked, navigate]);
|
||||||
|
|
||||||
|
// Load keypairs on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadKeypairs = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await listKeypairs();
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || 'Failed to load keypairs');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSessionUnlocked) {
|
||||||
|
loadKeypairs();
|
||||||
|
}
|
||||||
|
}, [isSessionUnlocked, listKeypairs]);
|
||||||
|
|
||||||
|
const handleSelectKeypair = async (keypairId: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await selectKeypair(keypairId);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || 'Failed to select keypair');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateKeypair = async () => {
|
||||||
|
try {
|
||||||
|
setIsCreating(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
await createKeypair(newKeypairType, {
|
||||||
|
name: newKeypairName,
|
||||||
|
description: newKeypairDescription
|
||||||
|
});
|
||||||
|
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
setNewKeypairName('');
|
||||||
|
setNewKeypairDescription('');
|
||||||
|
|
||||||
|
// Refresh the list
|
||||||
|
await listKeypairs();
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || 'Failed to create keypair');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSessionUnlocked) {
|
||||||
|
return null; // Will redirect via useEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">Keypair Management</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setCreateDialogOpen(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Create New
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : availableKeypairs.length === 0 ? (
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
No keypairs found. Create your first keypair to get started.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
|
||||||
|
<List disablePadding>
|
||||||
|
{availableKeypairs.map((keypair: any, index: number) => (
|
||||||
|
<Box key={keypair.id}>
|
||||||
|
{index > 0 && <Divider />}
|
||||||
|
<ListItem
|
||||||
|
button
|
||||||
|
selected={currentKeypair?.id === keypair.id}
|
||||||
|
onClick={() => handleSelectKeypair(keypair.id)}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
{keypair.name || keypair.id}
|
||||||
|
<Chip
|
||||||
|
label={keypair.type}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{keypair.description || 'No description'}
|
||||||
|
<br />
|
||||||
|
Created: {new Date(keypair.createdAt).toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
{currentKeypair?.id === keypair.id && (
|
||||||
|
<IconButton edge="end" disabled>
|
||||||
|
<CheckIcon color="success" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Keypair Dialog */}
|
||||||
|
<Dialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Create New Keypair</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={newKeypairName}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewKeypairName(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={newKeypairType}
|
||||||
|
onChange={(e) => setNewKeypairType(e.target.value)}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
<MenuItem value="Ed25519">Ed25519</MenuItem>
|
||||||
|
<MenuItem value="Secp256k1">Secp256k1 (Ethereum)</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
value={newKeypairDescription}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewKeypairDescription(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateKeypair}
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
disabled={isCreating || !newKeypairName}
|
||||||
|
>
|
||||||
|
{isCreating ? <CircularProgress size={24} /> : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default KeypairPage;
|
557
hero_vault_extension/src/pages/ScriptPage.tsx
Normal file
@ -0,0 +1,557 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { getChromeApi } from '../utils/chromeApi';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
Divider,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Chip
|
||||||
|
} from '@mui/material';
|
||||||
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
// DeleteIcon removed as it's not used
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useSessionStore } from '../store/sessionStore';
|
||||||
|
|
||||||
|
interface ScriptResult {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
script: string;
|
||||||
|
result: string;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingScript {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
script: string;
|
||||||
|
tags: string[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScriptPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isSessionUnlocked, currentKeypair } = useSessionStore();
|
||||||
|
|
||||||
|
const [tabValue, setTabValue] = useState<number>(0);
|
||||||
|
const [scriptInput, setScriptInput] = useState<string>('');
|
||||||
|
const [isExecuting, setIsExecuting] = useState<boolean>(false);
|
||||||
|
const [executionResult, setExecutionResult] = useState<string | null>(null);
|
||||||
|
const [executionSuccess, setExecutionSuccess] = useState<boolean | null>(null);
|
||||||
|
const [scriptResults, setScriptResults] = useState<ScriptResult[]>([]);
|
||||||
|
const [pendingScripts, setPendingScripts] = useState<PendingScript[]>([]);
|
||||||
|
const [selectedPendingScript, setSelectedPendingScript] = useState<PendingScript | null>(null);
|
||||||
|
const [scriptDialogOpen, setScriptDialogOpen] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Redirect if not unlocked
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSessionUnlocked) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [isSessionUnlocked, navigate]);
|
||||||
|
|
||||||
|
// Load pending scripts from storage
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPendingScripts = async () => {
|
||||||
|
try {
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
const data = await chromeApi.storage.local.get('pendingScripts');
|
||||||
|
if (data.pendingScripts) {
|
||||||
|
setPendingScripts(data.pendingScripts);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load pending scripts:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSessionUnlocked) {
|
||||||
|
loadPendingScripts();
|
||||||
|
}
|
||||||
|
}, [isSessionUnlocked]);
|
||||||
|
|
||||||
|
// Load script history from storage
|
||||||
|
useEffect(() => {
|
||||||
|
const loadScriptResults = async () => {
|
||||||
|
try {
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
const data = await chromeApi.storage.local.get('scriptResults');
|
||||||
|
if (data.scriptResults) {
|
||||||
|
setScriptResults(data.scriptResults);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load script results:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSessionUnlocked) {
|
||||||
|
loadScriptResults();
|
||||||
|
}
|
||||||
|
}, [isSessionUnlocked]);
|
||||||
|
|
||||||
|
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
|
||||||
|
setTabValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecuteScript = async () => {
|
||||||
|
if (!scriptInput.trim()) return;
|
||||||
|
|
||||||
|
setIsExecuting(true);
|
||||||
|
setError(null);
|
||||||
|
setExecutionResult(null);
|
||||||
|
setExecutionSuccess(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call the WASM run_rhai function via our store
|
||||||
|
const result = await useSessionStore.getState().executeScript(scriptInput);
|
||||||
|
|
||||||
|
setExecutionResult(result);
|
||||||
|
setExecutionSuccess(true);
|
||||||
|
|
||||||
|
// Save to history
|
||||||
|
const newResult: ScriptResult = {
|
||||||
|
id: `script-${Date.now()}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
script: scriptInput,
|
||||||
|
result,
|
||||||
|
success: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedResults = [newResult, ...scriptResults].slice(0, 20); // Keep last 20
|
||||||
|
setScriptResults(updatedResults);
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
await chromeApi.storage.local.set({ scriptResults: updatedResults });
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || 'Failed to execute script');
|
||||||
|
setExecutionSuccess(false);
|
||||||
|
setExecutionResult('Execution failed');
|
||||||
|
} finally {
|
||||||
|
setIsExecuting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewPendingScript = (script: PendingScript) => {
|
||||||
|
setSelectedPendingScript(script);
|
||||||
|
setScriptDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApprovePendingScript = async () => {
|
||||||
|
if (!selectedPendingScript) return;
|
||||||
|
|
||||||
|
setScriptDialogOpen(false);
|
||||||
|
setScriptInput(selectedPendingScript.script);
|
||||||
|
setTabValue(0); // Switch to execute tab
|
||||||
|
|
||||||
|
// Remove from pending list
|
||||||
|
const updatedPendingScripts = pendingScripts.filter(
|
||||||
|
script => script.id !== selectedPendingScript.id
|
||||||
|
);
|
||||||
|
|
||||||
|
setPendingScripts(updatedPendingScripts);
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
await chromeApi.storage.local.set({ pendingScripts: updatedPendingScripts });
|
||||||
|
setSelectedPendingScript(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectPendingScript = async () => {
|
||||||
|
if (!selectedPendingScript) return;
|
||||||
|
|
||||||
|
// Remove from pending list
|
||||||
|
const updatedPendingScripts = pendingScripts.filter(
|
||||||
|
script => script.id !== selectedPendingScript.id
|
||||||
|
);
|
||||||
|
|
||||||
|
setPendingScripts(updatedPendingScripts);
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
await chromeApi.storage.local.set({ pendingScripts: updatedPendingScripts });
|
||||||
|
|
||||||
|
setScriptDialogOpen(false);
|
||||||
|
setSelectedPendingScript(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearHistory = async () => {
|
||||||
|
setScriptResults([]);
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
await chromeApi.storage.local.set({ scriptResults: [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSessionUnlocked) {
|
||||||
|
return null; // Will redirect via useEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||||
|
<Tabs
|
||||||
|
value={tabValue}
|
||||||
|
onChange={handleTabChange}
|
||||||
|
aria-label="script tabs"
|
||||||
|
variant="scrollable"
|
||||||
|
scrollButtons="auto"
|
||||||
|
allowScrollButtonsMobile
|
||||||
|
sx={{ minHeight: '48px' }}
|
||||||
|
>
|
||||||
|
<Tab label="Execute" sx={{ minHeight: '48px', py: 0 }} />
|
||||||
|
<Tab
|
||||||
|
label={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
Pending
|
||||||
|
{pendingScripts.length > 0 && (
|
||||||
|
<Chip
|
||||||
|
label={pendingScripts.length}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
sx={{ minHeight: '48px', py: 0 }}
|
||||||
|
/>
|
||||||
|
<Tab label="History" sx={{ minHeight: '48px', py: 0 }} />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Execute Tab */}
|
||||||
|
{tabValue === 0 && (
|
||||||
|
<Box sx={{
|
||||||
|
p: 2,
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: 'calc(100% - 48px)' // Subtract tab height
|
||||||
|
}}>
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'auto',
|
||||||
|
height: '100%',
|
||||||
|
pb: 2 // Add padding at bottom for scrolling
|
||||||
|
}}>
|
||||||
|
{!currentKeypair && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
No keypair selected. Select a keypair to enable script execution with signing capabilities.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Rhai Script"
|
||||||
|
multiline
|
||||||
|
rows={6} // Reduced from 8 to leave more space for results
|
||||||
|
value={scriptInput}
|
||||||
|
onChange={(e) => setScriptInput(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="Enter your Rhai script here..."
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
disabled={isExecuting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<PlayArrowIcon />}
|
||||||
|
onClick={handleExecuteScript}
|
||||||
|
disabled={isExecuting || !scriptInput.trim()}
|
||||||
|
>
|
||||||
|
{isExecuting ? <CircularProgress size={24} /> : 'Execute'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{executionResult && (
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: executionSuccess ? 'success.dark' : 'error.dark',
|
||||||
|
color: 'white',
|
||||||
|
overflowY: 'auto',
|
||||||
|
mb: 2, // Add margin at bottom
|
||||||
|
minHeight: '100px', // Ensure minimum height for visibility
|
||||||
|
maxHeight: '200px' // Limit maximum height
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Execution Result:
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{executionResult}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Scripts Tab */}
|
||||||
|
{tabValue === 1 && (
|
||||||
|
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{pendingScripts.length === 0 ? (
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
No pending scripts. Incoming scripts from connected WebSocket servers will appear here.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
|
||||||
|
<List disablePadding>
|
||||||
|
{pendingScripts.map((script, index) => (
|
||||||
|
<Box key={script.id}>
|
||||||
|
{index > 0 && <Divider />}
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary={script.title}
|
||||||
|
secondary={
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{script.description || 'No description'}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 0.5 }}>
|
||||||
|
{script.tags.map(tag => (
|
||||||
|
<Chip
|
||||||
|
key={tag}
|
||||||
|
label={tag}
|
||||||
|
size="small"
|
||||||
|
color={tag === 'remote' ? 'secondary' : 'primary'}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ mr: 0.5 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
onClick={() => handleViewPendingScript(script)}
|
||||||
|
aria-label="view script"
|
||||||
|
>
|
||||||
|
<VisibilityIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History Tab */}
|
||||||
|
{tabValue === 2 && (
|
||||||
|
<Box sx={{
|
||||||
|
p: 2,
|
||||||
|
flexGrow: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: 'calc(100% - 48px)' // Subtract tab height
|
||||||
|
}}>
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'auto',
|
||||||
|
height: '100%',
|
||||||
|
pb: 2 // Add padding at bottom for scrolling
|
||||||
|
}}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={handleClearHistory}
|
||||||
|
disabled={scriptResults.length === 0}
|
||||||
|
>
|
||||||
|
Clear History
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{scriptResults.length === 0 ? (
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
No script execution history yet.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
|
||||||
|
<List disablePadding>
|
||||||
|
{scriptResults.map((result, index) => (
|
||||||
|
<Box key={result.id}>
|
||||||
|
{index > 0 && <Divider />}
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="subtitle2">
|
||||||
|
{new Date(result.timestamp).toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={result.success ? 'Success' : 'Failed'}
|
||||||
|
size="small"
|
||||||
|
color={result.success ? 'success' : 'error'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
maxWidth: '280px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{result.script}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<IconButton
|
||||||
|
edge="end"
|
||||||
|
onClick={() => {
|
||||||
|
setScriptInput(result.script);
|
||||||
|
setTabValue(0);
|
||||||
|
}}
|
||||||
|
aria-label="reuse script"
|
||||||
|
>
|
||||||
|
<PlayArrowIcon />
|
||||||
|
</IconButton>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending Script Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={scriptDialogOpen}
|
||||||
|
onClose={() => setScriptDialogOpen(false)}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
{selectedPendingScript?.title || 'Script Details'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{selectedPendingScript && (
|
||||||
|
<>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Description:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" paragraph>
|
||||||
|
{selectedPendingScript.description || 'No description provided'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
{selectedPendingScript.tags.map(tag => (
|
||||||
|
<Chip
|
||||||
|
key={tag}
|
||||||
|
label={tag}
|
||||||
|
size="small"
|
||||||
|
color={tag === 'remote' ? 'secondary' : 'primary'}
|
||||||
|
sx={{ mr: 0.5 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Script Content:
|
||||||
|
</Typography>
|
||||||
|
<Paper
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
maxHeight: '300px',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="pre"
|
||||||
|
sx={{
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedPendingScript.script}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Alert severity="warning" sx={{ mt: 2 }}>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{selectedPendingScript.tags.includes('remote')
|
||||||
|
? 'This is a remote script. If approved, your signature will be sent to the server and the script may execute remotely.'
|
||||||
|
: 'This script will execute locally in your browser extension if approved.'}
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
onClick={handleRejectPendingScript}
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleApprovePendingScript}
|
||||||
|
color="primary"
|
||||||
|
variant="contained"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScriptPage;
|
191
hero_vault_extension/src/pages/SessionPage.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Divider,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Grid
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useSessionStore } from '../store/sessionStore';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import SecurityIcon from '@mui/icons-material/Security';
|
||||||
|
// HistoryIcon removed as it's not used
|
||||||
|
|
||||||
|
interface SessionActivity {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
timestamp: number;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
isSessionUnlocked,
|
||||||
|
currentKeyspace,
|
||||||
|
currentKeypair,
|
||||||
|
lockSession
|
||||||
|
} = useSessionStore();
|
||||||
|
|
||||||
|
const [sessionActivities, setSessionActivities] = useState<SessionActivity[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Redirect if not unlocked
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSessionUnlocked) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [isSessionUnlocked, navigate]);
|
||||||
|
|
||||||
|
// Load session activities from storage
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSessionActivities = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const data = await chrome.storage.local.get('sessionActivities');
|
||||||
|
if (data.sessionActivities) {
|
||||||
|
setSessionActivities(data.sessionActivities);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load session activities:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSessionUnlocked) {
|
||||||
|
loadSessionActivities();
|
||||||
|
}
|
||||||
|
}, [isSessionUnlocked]);
|
||||||
|
|
||||||
|
const handleLockSession = async () => {
|
||||||
|
try {
|
||||||
|
await lockSession();
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to lock session:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSessionUnlocked) {
|
||||||
|
return null; // Will redirect via useEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Session Management
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography color="text.secondary" gutterBottom>
|
||||||
|
Current Keyspace
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" component="div">
|
||||||
|
{currentKeyspace || 'None'}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography color="text.secondary" gutterBottom>
|
||||||
|
Selected Keypair
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" component="div">
|
||||||
|
{currentKeypair?.name || currentKeypair?.id || 'None'}
|
||||||
|
</Typography>
|
||||||
|
{currentKeypair && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Type: {currentKeypair.type}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Typography variant="subtitle1">
|
||||||
|
Session Activity
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
startIcon={<LockIcon />}
|
||||||
|
onClick={handleLockSession}
|
||||||
|
>
|
||||||
|
Lock Session
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : sessionActivities.length === 0 ? (
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
No session activity recorded yet.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
|
||||||
|
<List disablePadding>
|
||||||
|
{sessionActivities.map((activity, index) => (
|
||||||
|
<Box key={activity.id}>
|
||||||
|
{index > 0 && <Divider />}
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="subtitle2">
|
||||||
|
{activity.action}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{new Date(activity.timestamp).toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
{activity.details && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{activity.details}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Alert severity="info" icon={<SecurityIcon />}>
|
||||||
|
Your session is active. All cryptographic operations and script executions require explicit approval.
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SessionPage;
|
246
hero_vault_extension/src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Switch,
|
||||||
|
// FormControlLabel removed as it's not used
|
||||||
|
Divider,
|
||||||
|
Paper,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
TextField,
|
||||||
|
Alert,
|
||||||
|
Snackbar
|
||||||
|
} from '@mui/material';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import InfoIcon from '@mui/icons-material/Info';
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
darkMode: boolean;
|
||||||
|
autoLockTimeout: number; // minutes
|
||||||
|
confirmCryptoOperations: boolean;
|
||||||
|
showScriptNotifications: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsPage = () => {
|
||||||
|
const [settings, setSettings] = useState<Settings>({
|
||||||
|
darkMode: true,
|
||||||
|
autoLockTimeout: 15,
|
||||||
|
confirmCryptoOperations: true,
|
||||||
|
showScriptNotifications: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const [clearDataDialogOpen, setClearDataDialogOpen] = useState(false);
|
||||||
|
const [confirmText, setConfirmText] = useState('');
|
||||||
|
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||||
|
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||||
|
|
||||||
|
// Load settings from storage
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const data = await chrome.storage.local.get('settings');
|
||||||
|
if (data.settings) {
|
||||||
|
setSettings(data.settings);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load settings:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save settings when changed
|
||||||
|
const handleSettingChange = (key: keyof Settings, value: boolean | number) => {
|
||||||
|
const updatedSettings = { ...settings, [key]: value };
|
||||||
|
setSettings(updatedSettings);
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
chrome.storage.local.set({ settings: updatedSettings })
|
||||||
|
.then(() => {
|
||||||
|
setSnackbarMessage('Settings saved');
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to save settings:', err);
|
||||||
|
setSnackbarMessage('Failed to save settings');
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAllData = () => {
|
||||||
|
if (confirmText !== 'CLEAR ALL DATA') {
|
||||||
|
setSnackbarMessage('Please type the confirmation text exactly');
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all extension data
|
||||||
|
chrome.storage.local.clear()
|
||||||
|
.then(() => {
|
||||||
|
setSnackbarMessage('All data cleared successfully');
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
setClearDataDialogOpen(false);
|
||||||
|
setConfirmText('');
|
||||||
|
|
||||||
|
// Reset settings to defaults
|
||||||
|
setSettings({
|
||||||
|
darkMode: true,
|
||||||
|
autoLockTimeout: 15,
|
||||||
|
confirmCryptoOperations: true,
|
||||||
|
showScriptNotifications: true
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to clear data:', err);
|
||||||
|
setSnackbarMessage('Failed to clear data');
|
||||||
|
setSnackbarOpen(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Settings
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
|
||||||
|
<List disablePadding>
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Dark Mode"
|
||||||
|
secondary="Use dark theme for the extension"
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
edge="end"
|
||||||
|
checked={settings.darkMode}
|
||||||
|
onChange={(e) => handleSettingChange('darkMode', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Auto-Lock Timeout"
|
||||||
|
secondary={`Automatically lock session after ${settings.autoLockTimeout} minutes of inactivity`}
|
||||||
|
/>
|
||||||
|
<Box sx={{ width: 120 }}>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
size="small"
|
||||||
|
value={settings.autoLockTimeout}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseInt(e.target.value);
|
||||||
|
if (!isNaN(value) && value >= 1) {
|
||||||
|
handleSettingChange('autoLockTimeout', value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
InputProps={{ inputProps: { min: 1, max: 60 } }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Confirm Cryptographic Operations"
|
||||||
|
secondary="Always ask for confirmation before signing or encrypting"
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
edge="end"
|
||||||
|
checked={settings.confirmCryptoOperations}
|
||||||
|
onChange={(e) => handleSettingChange('confirmCryptoOperations', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="Script Notifications"
|
||||||
|
secondary="Show notifications when new scripts are received"
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
edge="end"
|
||||||
|
checked={settings.showScriptNotifications}
|
||||||
|
onChange={(e) => handleSettingChange('showScriptNotifications', e.target.checked)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<Alert
|
||||||
|
severity="info"
|
||||||
|
icon={<InfoIcon />}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2">
|
||||||
|
The extension stores all cryptographic keys in encrypted form. Your password is never stored and is only kept in memory while the session is unlocked.
|
||||||
|
</Typography>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
startIcon={<DeleteIcon />}
|
||||||
|
onClick={() => setClearDataDialogOpen(true)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Clear All Data
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Clear Data Confirmation Dialog */}
|
||||||
|
<Dialog open={clearDataDialogOpen} onClose={() => setClearDataDialogOpen(false)}>
|
||||||
|
<DialogTitle>Clear All Extension Data</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1" paragraph>
|
||||||
|
This will permanently delete all your keyspaces, keypairs, and settings. This action cannot be undone.
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="error" paragraph>
|
||||||
|
Type "CLEAR ALL DATA" to confirm:
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
variant="outlined"
|
||||||
|
placeholder="CLEAR ALL DATA"
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setClearDataDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleClearAllData}
|
||||||
|
color="error"
|
||||||
|
disabled={confirmText !== 'CLEAR ALL DATA'}
|
||||||
|
>
|
||||||
|
Clear All Data
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Snackbar for notifications */}
|
||||||
|
<Snackbar
|
||||||
|
open={snackbarOpen}
|
||||||
|
autoHideDuration={3000}
|
||||||
|
onClose={() => setSnackbarOpen(false)}
|
||||||
|
message={snackbarMessage}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPage;
|
248
hero_vault_extension/src/pages/WebSocketPage.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Paper,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
Divider,
|
||||||
|
Chip
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useSessionStore } from '../store/sessionStore';
|
||||||
|
|
||||||
|
interface ConnectionHistory {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
timestamp: number;
|
||||||
|
status: 'connected' | 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebSocketPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const {
|
||||||
|
isSessionUnlocked,
|
||||||
|
currentKeypair,
|
||||||
|
isWebSocketConnected,
|
||||||
|
webSocketUrl,
|
||||||
|
connectWebSocket,
|
||||||
|
disconnectWebSocket
|
||||||
|
} = useSessionStore();
|
||||||
|
|
||||||
|
const [serverUrl, setServerUrl] = useState('');
|
||||||
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [connectionHistory, setConnectionHistory] = useState<ConnectionHistory[]>([]);
|
||||||
|
|
||||||
|
// Redirect if not unlocked
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSessionUnlocked) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [isSessionUnlocked, navigate]);
|
||||||
|
|
||||||
|
// Load connection history from storage
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConnectionHistory = async () => {
|
||||||
|
try {
|
||||||
|
const data = await chrome.storage.local.get('connectionHistory');
|
||||||
|
if (data.connectionHistory) {
|
||||||
|
setConnectionHistory(data.connectionHistory);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load connection history:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSessionUnlocked) {
|
||||||
|
loadConnectionHistory();
|
||||||
|
}
|
||||||
|
}, [isSessionUnlocked]);
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
if (!serverUrl.trim() || !currentKeypair) return;
|
||||||
|
|
||||||
|
setIsConnecting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await connectWebSocket(serverUrl);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Add to connection history
|
||||||
|
const newConnection: ConnectionHistory = {
|
||||||
|
id: `conn-${Date.now()}`,
|
||||||
|
url: serverUrl,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
status: 'connected'
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedHistory = [newConnection, ...connectionHistory].slice(0, 10); // Keep last 10
|
||||||
|
setConnectionHistory(updatedHistory);
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
await chrome.storage.local.set({ connectionHistory: updatedHistory });
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to connect to WebSocket server');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || 'Failed to connect to WebSocket server');
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
const success = await disconnectWebSocket();
|
||||||
|
|
||||||
|
if (success && webSocketUrl) {
|
||||||
|
// Update connection history
|
||||||
|
const updatedHistory = connectionHistory.map(conn =>
|
||||||
|
conn.url === webSocketUrl && conn.status === 'connected'
|
||||||
|
? { ...conn, status: 'disconnected' }
|
||||||
|
: conn
|
||||||
|
);
|
||||||
|
|
||||||
|
setConnectionHistory(updatedHistory);
|
||||||
|
|
||||||
|
// Save to storage
|
||||||
|
await chrome.storage.local.set({ connectionHistory: updatedHistory });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || 'Failed to disconnect from WebSocket server');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickConnect = (url: string) => {
|
||||||
|
setServerUrl(url);
|
||||||
|
// Don't auto-connect to avoid unexpected connections
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isSessionUnlocked) {
|
||||||
|
return null; // Will redirect via useEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
WebSocket Connection
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!currentKeypair && (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
|
No keypair selected. Select a keypair before connecting to a WebSocket server.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
Connection Status:
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={isWebSocketConnected ? 'Connected' : 'Disconnected'}
|
||||||
|
color={isWebSocketConnected ? 'success' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
{isWebSocketConnected && webSocketUrl && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
Connected to: {webSocketUrl}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="WebSocket Server URL"
|
||||||
|
placeholder="wss://example.com/ws"
|
||||||
|
value={serverUrl}
|
||||||
|
onChange={(e) => setServerUrl(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
disabled={isConnecting || isWebSocketConnected || !currentKeypair}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isWebSocketConnected ? (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={isConnecting || !serverUrl.trim() || !currentKeypair}
|
||||||
|
>
|
||||||
|
{isConnecting ? <CircularProgress size={24} /> : 'Connect'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Connection History
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{connectionHistory.length === 0 ? (
|
||||||
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
No connection history yet.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
|
||||||
|
<List disablePadding>
|
||||||
|
{connectionHistory.map((conn, index) => (
|
||||||
|
<Box key={conn.id}>
|
||||||
|
{index > 0 && <Divider />}
|
||||||
|
<ListItem
|
||||||
|
button
|
||||||
|
onClick={() => handleQuickConnect(conn.url)}
|
||||||
|
disabled={isWebSocketConnected}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="subtitle2">
|
||||||
|
{conn.url}
|
||||||
|
</Typography>
|
||||||
|
<Chip
|
||||||
|
label={conn.status}
|
||||||
|
size="small"
|
||||||
|
color={conn.status === 'connected' ? 'success' : 'default'}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{new Date(conn.timestamp).toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WebSocketPage;
|
144
hero_vault_extension/src/store/cryptoStore.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Crypto Store for Hero Vault Extension
|
||||||
|
*
|
||||||
|
* This store manages cryptographic operations such as:
|
||||||
|
* - Encryption/decryption using the keyspace's symmetric cipher
|
||||||
|
* - Signing/verification using the selected keypair
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { getWasmModule, stringToUint8Array, uint8ArrayToString } from '../wasm/wasmHelper';
|
||||||
|
|
||||||
|
// Helper functions for Unicode-safe base64 encoding/decoding
|
||||||
|
function base64Encode(data: Uint8Array): string {
|
||||||
|
// Convert binary data to a string that only uses the low 8 bits of each character
|
||||||
|
const binaryString = Array.from(data)
|
||||||
|
.map(byte => String.fromCharCode(byte))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Use btoa on the binary string
|
||||||
|
return btoa(binaryString);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Decode(base64: string): Uint8Array {
|
||||||
|
// Decode base64 to binary string
|
||||||
|
const binaryString = atob(base64);
|
||||||
|
|
||||||
|
// Convert binary string to Uint8Array
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CryptoState {
|
||||||
|
// State
|
||||||
|
isEncrypting: boolean;
|
||||||
|
isDecrypting: boolean;
|
||||||
|
isSigning: boolean;
|
||||||
|
isVerifying: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
encryptData: (data: string) => Promise<string>;
|
||||||
|
decryptData: (encrypted: string) => Promise<string>;
|
||||||
|
signMessage: (message: string) => Promise<string>;
|
||||||
|
verifySignature: (message: string, signature: string) => Promise<boolean>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCryptoStore = create<CryptoState>()((set, get) => ({
|
||||||
|
isEncrypting: false,
|
||||||
|
isDecrypting: false,
|
||||||
|
isSigning: false,
|
||||||
|
isVerifying: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
encryptData: async (data: string) => {
|
||||||
|
try {
|
||||||
|
set({ isEncrypting: true, error: null });
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
|
||||||
|
// Convert input to Uint8Array
|
||||||
|
const dataBytes = stringToUint8Array(data);
|
||||||
|
|
||||||
|
// Encrypt the data
|
||||||
|
const encrypted = await wasmModule.encrypt_data(dataBytes);
|
||||||
|
|
||||||
|
// Convert result to base64 for storage/display using our Unicode-safe function
|
||||||
|
const encryptedBase64 = base64Encode(encrypted);
|
||||||
|
|
||||||
|
return encryptedBase64;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: (error as Error).message || 'Failed to encrypt data' });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
set({ isEncrypting: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
decryptData: async (encrypted: string) => {
|
||||||
|
try {
|
||||||
|
set({ isDecrypting: true, error: null });
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
|
||||||
|
// Convert input from base64 using our Unicode-safe function
|
||||||
|
const encryptedBytes = base64Decode(encrypted);
|
||||||
|
|
||||||
|
// Decrypt the data
|
||||||
|
const decrypted = await wasmModule.decrypt_data(encryptedBytes);
|
||||||
|
|
||||||
|
// Convert result to string
|
||||||
|
return uint8ArrayToString(decrypted);
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: (error as Error).message || 'Failed to decrypt data' });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
set({ isDecrypting: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
signMessage: async (message: string) => {
|
||||||
|
try {
|
||||||
|
set({ isSigning: true, error: null });
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
|
||||||
|
// Convert message to Uint8Array
|
||||||
|
const messageBytes = stringToUint8Array(message);
|
||||||
|
|
||||||
|
// Sign the message
|
||||||
|
const signature = await wasmModule.sign(messageBytes);
|
||||||
|
|
||||||
|
return signature;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: (error as Error).message || 'Failed to sign message' });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
set({ isSigning: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
verifySignature: async (message: string, signature: string) => {
|
||||||
|
try {
|
||||||
|
set({ isVerifying: true, error: null });
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
|
||||||
|
// Convert inputs
|
||||||
|
const messageBytes = stringToUint8Array(message);
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
const isValid = await wasmModule.verify(messageBytes, signature);
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
} catch (error) {
|
||||||
|
set({ error: (error as Error).message || 'Failed to verify signature' });
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
set({ isVerifying: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null })
|
||||||
|
}));
|
416
hero_vault_extension/src/store/sessionStore.ts
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { getWasmModule, stringToUint8Array } from '../wasm/wasmHelper';
|
||||||
|
import { getChromeApi } from '../utils/chromeApi';
|
||||||
|
|
||||||
|
// Import Chrome types
|
||||||
|
/// <reference types="chrome" />
|
||||||
|
|
||||||
|
interface KeypairMetadata {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
isSessionUnlocked: boolean;
|
||||||
|
currentKeyspace: string | null;
|
||||||
|
currentKeypair: KeypairMetadata | null;
|
||||||
|
availableKeypairs: KeypairMetadata[];
|
||||||
|
isWebSocketConnected: boolean;
|
||||||
|
webSocketUrl: string | null;
|
||||||
|
isWasmLoaded: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
initWasm: () => Promise<boolean>;
|
||||||
|
checkSessionStatus: () => Promise<boolean>;
|
||||||
|
unlockSession: (keyspace: string, password: string) => Promise<boolean>;
|
||||||
|
lockSession: () => Promise<boolean>;
|
||||||
|
createKeyspace: (keyspace: string, password: string) => Promise<boolean>;
|
||||||
|
listKeypairs: () => Promise<KeypairMetadata[]>;
|
||||||
|
selectKeypair: (keypairId: string) => Promise<boolean>;
|
||||||
|
createKeypair: (type: string, metadata?: Record<string, any>) => Promise<string>;
|
||||||
|
connectWebSocket: (url: string) => Promise<boolean>;
|
||||||
|
disconnectWebSocket: () => Promise<boolean>;
|
||||||
|
executeScript: (script: string) => Promise<string>;
|
||||||
|
signMessage: (message: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the store
|
||||||
|
export const useSessionStore = create<SessionState>((set: any, get: any) => ({
|
||||||
|
isSessionUnlocked: false,
|
||||||
|
currentKeyspace: null,
|
||||||
|
currentKeypair: null,
|
||||||
|
availableKeypairs: [],
|
||||||
|
isWebSocketConnected: false,
|
||||||
|
webSocketUrl: null,
|
||||||
|
isWasmLoaded: false,
|
||||||
|
|
||||||
|
// Initialize WASM module
|
||||||
|
initWasm: async () => {
|
||||||
|
try {
|
||||||
|
set({ isWasmLoaded: true });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize WASM module:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if a session is currently active
|
||||||
|
checkSessionStatus: async () => {
|
||||||
|
try {
|
||||||
|
// First check with the background service worker
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
const response = await chromeApi.runtime.sendMessage({ type: 'SESSION_STATUS' });
|
||||||
|
|
||||||
|
if (response && response.active) {
|
||||||
|
// If session is active in the background, check with WASM
|
||||||
|
try {
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
const isUnlocked = wasmModule.is_unlocked();
|
||||||
|
|
||||||
|
if (isUnlocked) {
|
||||||
|
// Get current keypair metadata if available
|
||||||
|
try {
|
||||||
|
const keypairMetadata = await wasmModule.current_keypair_metadata();
|
||||||
|
const parsedMetadata = JSON.parse(keypairMetadata);
|
||||||
|
|
||||||
|
set({
|
||||||
|
isSessionUnlocked: true,
|
||||||
|
currentKeypair: parsedMetadata
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load keypairs
|
||||||
|
await get().listKeypairs();
|
||||||
|
} catch (e) {
|
||||||
|
// No keypair selected, but session is unlocked
|
||||||
|
set({ isSessionUnlocked: true });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (wasmError) {
|
||||||
|
console.error('WASM error checking session status:', wasmError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isSessionUnlocked: false });
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check session status:', error);
|
||||||
|
set({ isSessionUnlocked: false });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unlock a session with keyspace and password
|
||||||
|
unlockSession: async (keyspace: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
|
||||||
|
// Call the WASM init_session function
|
||||||
|
await wasmModule.init_session(keyspace, password);
|
||||||
|
|
||||||
|
// Initialize Rhai environment
|
||||||
|
wasmModule.init_rhai_env();
|
||||||
|
|
||||||
|
// Notify background service worker
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
await chromeApi.runtime.sendMessage({ type: 'SESSION_UNLOCK' });
|
||||||
|
|
||||||
|
set({
|
||||||
|
isSessionUnlocked: true,
|
||||||
|
currentKeyspace: keyspace,
|
||||||
|
currentKeypair: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load keypairs after unlocking
|
||||||
|
const keypairs = await get().listKeypairs();
|
||||||
|
set({ availableKeypairs: keypairs });
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unlock session:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Lock the current session
|
||||||
|
lockSession: async () => {
|
||||||
|
try {
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
|
||||||
|
// Call the WASM lock_session function
|
||||||
|
wasmModule.lock_session();
|
||||||
|
|
||||||
|
// Notify background service worker
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
await chromeApi.runtime.sendMessage({ type: 'SESSION_LOCK' });
|
||||||
|
|
||||||
|
set({
|
||||||
|
isSessionUnlocked: false,
|
||||||
|
currentKeyspace: null,
|
||||||
|
currentKeypair: null,
|
||||||
|
availableKeypairs: [],
|
||||||
|
isWebSocketConnected: false,
|
||||||
|
webSocketUrl: null
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to lock session:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create a new keyspace
|
||||||
|
createKeyspace: async (keyspace: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
|
||||||
|
// Call the WASM create_keyspace function
|
||||||
|
await wasmModule.create_keyspace(keyspace, password);
|
||||||
|
|
||||||
|
// Initialize Rhai environment
|
||||||
|
wasmModule.init_rhai_env();
|
||||||
|
|
||||||
|
// Notify background service worker
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
await chromeApi.runtime.sendMessage({ type: 'SESSION_UNLOCK' });
|
||||||
|
|
||||||
|
set({
|
||||||
|
isSessionUnlocked: true,
|
||||||
|
currentKeyspace: keyspace,
|
||||||
|
currentKeypair: null,
|
||||||
|
availableKeypairs: []
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create keyspace:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// List all keypairs in the current keyspace
|
||||||
|
listKeypairs: async () => {
|
||||||
|
try {
|
||||||
|
console.log('Listing keypairs from WASM module');
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
console.log('WASM module loaded, calling list_keypairs');
|
||||||
|
|
||||||
|
// Call the WASM list_keypairs function
|
||||||
|
let keypairsJson;
|
||||||
|
try {
|
||||||
|
keypairsJson = await wasmModule.list_keypairs();
|
||||||
|
console.log('Raw keypairs JSON from WASM:', keypairsJson);
|
||||||
|
} catch (listError) {
|
||||||
|
console.error('Error calling list_keypairs:', listError);
|
||||||
|
throw new Error(`Failed to list keypairs: ${listError.message || listError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let keypairs;
|
||||||
|
try {
|
||||||
|
keypairs = JSON.parse(keypairsJson);
|
||||||
|
console.log('Parsed keypairs object:', keypairs);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Error parsing keypairs JSON:', parseError);
|
||||||
|
throw new Error(`Failed to parse keypairs JSON: ${parseError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform the keypairs to our expected format
|
||||||
|
const formattedKeypairs: KeypairMetadata[] = keypairs.map((keypair: any, index: number) => {
|
||||||
|
console.log(`Processing keypair at index ${index}:`, keypair);
|
||||||
|
return {
|
||||||
|
id: keypair.id, // Use the actual keypair ID from the WASM module
|
||||||
|
type: keypair.key_type || 'Unknown',
|
||||||
|
name: keypair.metadata?.name,
|
||||||
|
description: keypair.metadata?.description,
|
||||||
|
createdAt: keypair.metadata?.created_at || Date.now()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Formatted keypairs for UI:', formattedKeypairs);
|
||||||
|
set({ availableKeypairs: formattedKeypairs });
|
||||||
|
return formattedKeypairs;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to list keypairs:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Select a keypair for use
|
||||||
|
selectKeypair: async (keypairId: string) => {
|
||||||
|
try {
|
||||||
|
console.log('Selecting keypair with ID:', keypairId);
|
||||||
|
|
||||||
|
// First, let's log the available keypairs to see what we have
|
||||||
|
const { availableKeypairs } = get();
|
||||||
|
console.log('Available keypairs:', JSON.stringify(availableKeypairs));
|
||||||
|
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
console.log('WASM module loaded, attempting to select keypair');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call the WASM select_keypair function
|
||||||
|
await wasmModule.select_keypair(keypairId);
|
||||||
|
console.log('Successfully selected keypair in WASM');
|
||||||
|
} catch (selectError) {
|
||||||
|
console.error('Error in WASM select_keypair:', selectError);
|
||||||
|
throw new Error(`select_keypair error: ${selectError.message || selectError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the keypair in our availableKeypairs list
|
||||||
|
const selectedKeypair = availableKeypairs.find((kp: KeypairMetadata) => kp.id === keypairId);
|
||||||
|
|
||||||
|
if (selectedKeypair) {
|
||||||
|
console.log('Found keypair in available list, setting as current');
|
||||||
|
set({ currentKeypair: selectedKeypair });
|
||||||
|
} else {
|
||||||
|
console.log('Keypair not found in available list, creating new entry from available data');
|
||||||
|
// If not found in our list (rare case), create a new entry with what we know
|
||||||
|
// Since we can't get metadata from WASM, use what we have from the keypair list
|
||||||
|
const matchingKeypair = availableKeypairs.find(k => k.id === keypairId);
|
||||||
|
|
||||||
|
if (matchingKeypair) {
|
||||||
|
set({ currentKeypair: matchingKeypair });
|
||||||
|
} else {
|
||||||
|
// Last resort: create a minimal keypair entry
|
||||||
|
const newKeypair: KeypairMetadata = {
|
||||||
|
id: keypairId,
|
||||||
|
type: 'Unknown',
|
||||||
|
name: `Keypair ${keypairId.substring(0, 8)}...`,
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
set({ currentKeypair: newKeypair });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to select keypair:', error);
|
||||||
|
throw error; // Re-throw to show error in UI
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create a new keypair
|
||||||
|
createKeypair: async (type: string, metadata?: Record<string, any>) => {
|
||||||
|
try {
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
|
||||||
|
// Format metadata for WASM
|
||||||
|
const metadataJson = metadata ? JSON.stringify({
|
||||||
|
name: metadata.name,
|
||||||
|
description: metadata.description,
|
||||||
|
created_at: Date.now()
|
||||||
|
}) : undefined;
|
||||||
|
|
||||||
|
// Call the WASM add_keypair function
|
||||||
|
const keypairId = await wasmModule.add_keypair(type, metadataJson);
|
||||||
|
|
||||||
|
// Refresh the keypair list
|
||||||
|
await get().listKeypairs();
|
||||||
|
|
||||||
|
return keypairId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create keypair:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Connect to a WebSocket server
|
||||||
|
connectWebSocket: async (url: string) => {
|
||||||
|
try {
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
const { currentKeypair } = get();
|
||||||
|
|
||||||
|
if (!currentKeypair) {
|
||||||
|
throw new Error('No keypair selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the public key from WASM
|
||||||
|
const publicKeyArray = await wasmModule.current_keypair_public_key();
|
||||||
|
const publicKeyHex = Array.from(publicKeyArray)
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Connect to WebSocket via background service worker
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
const response = await chromeApi.runtime.sendMessage({
|
||||||
|
type: 'CONNECT_WEBSOCKET',
|
||||||
|
serverUrl: url,
|
||||||
|
publicKey: publicKeyHex
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response && response.success) {
|
||||||
|
set({
|
||||||
|
isWebSocketConnected: true,
|
||||||
|
webSocketUrl: url
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(response?.error || 'Failed to connect to WebSocket server');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect to WebSocket:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Disconnect from WebSocket server
|
||||||
|
disconnectWebSocket: async () => {
|
||||||
|
try {
|
||||||
|
// Disconnect via background service worker
|
||||||
|
const chromeApi = getChromeApi();
|
||||||
|
const response = await chromeApi.runtime.sendMessage({
|
||||||
|
type: 'DISCONNECT_WEBSOCKET'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response && response.success) {
|
||||||
|
set({
|
||||||
|
isWebSocketConnected: false,
|
||||||
|
webSocketUrl: null
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw new Error(response?.error || 'Failed to disconnect from WebSocket server');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to disconnect from WebSocket:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Execute a Rhai script
|
||||||
|
executeScript: async (script: string) => {
|
||||||
|
try {
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
|
||||||
|
// Call the WASM run_rhai function
|
||||||
|
const result = await wasmModule.run_rhai(script);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to execute script:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sign a message with the current keypair
|
||||||
|
signMessage: async (message: string) => {
|
||||||
|
try {
|
||||||
|
const wasmModule = await getWasmModule();
|
||||||
|
|
||||||
|
// Convert message to Uint8Array
|
||||||
|
const messageBytes = stringToUint8Array(message);
|
||||||
|
|
||||||
|
// Call the WASM sign function
|
||||||
|
const signature = await wasmModule.sign(messageBytes);
|
||||||
|
return signature;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sign message:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
45
hero_vault_extension/src/types.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Common TypeScript types for the Hero Vault Extension
|
||||||
|
*/
|
||||||
|
|
||||||
|
// React types
|
||||||
|
export type SyntheticEvent<T = Element, E = Event> = React.BaseSyntheticEvent<E, EventTarget & T, EventTarget>;
|
||||||
|
|
||||||
|
// Session types
|
||||||
|
export interface SessionActivity {
|
||||||
|
timestamp: number;
|
||||||
|
action: string;
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script types
|
||||||
|
export interface ScriptResult {
|
||||||
|
id: string;
|
||||||
|
script: string;
|
||||||
|
result: string;
|
||||||
|
timestamp: number;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingScript {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
script: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket types
|
||||||
|
export interface ConnectionHistory {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
timestamp: number;
|
||||||
|
status: 'connected' | 'disconnected' | 'error';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings types
|
||||||
|
export interface Settings {
|
||||||
|
darkMode: boolean;
|
||||||
|
autoLockTimeout: number;
|
||||||
|
defaultKeyType: string;
|
||||||
|
showScriptNotifications: boolean;
|
||||||
|
}
|
5
hero_vault_extension/src/types/chrome.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="chrome" />
|
||||||
|
|
||||||
|
// This file provides type declarations for Chrome extension APIs
|
||||||
|
// It's needed because we're using the Chrome extension API in a TypeScript project
|
||||||
|
// The actual implementation is provided by the browser at runtime
|
14
hero_vault_extension/src/types/declarations.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// Type declarations for modules without type definitions
|
||||||
|
|
||||||
|
// React and Material UI
|
||||||
|
declare module 'react';
|
||||||
|
declare module 'react-dom';
|
||||||
|
declare module 'react-router-dom';
|
||||||
|
declare module '@mui/material';
|
||||||
|
declare module '@mui/material/*';
|
||||||
|
declare module '@mui/icons-material/*';
|
||||||
|
|
||||||
|
// Project modules
|
||||||
|
declare module './pages/*';
|
||||||
|
declare module './components/*';
|
||||||
|
declare module './store/*';
|
16
hero_vault_extension/src/types/wasm.d.ts
vendored
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
declare module '*/wasm_app.js' {
|
||||||
|
export default function init(): Promise<void>;
|
||||||
|
export function init_session(keyspace: string, password: string): Promise<void>;
|
||||||
|
export function create_keyspace(keyspace: string, password: string): Promise<void>;
|
||||||
|
export function lock_session(): void;
|
||||||
|
export function is_unlocked(): boolean;
|
||||||
|
export function add_keypair(key_type: string | undefined, metadata: string | undefined): Promise<string>;
|
||||||
|
export function list_keypairs(): Promise<string>;
|
||||||
|
export function select_keypair(key_id: string): Promise<void>;
|
||||||
|
export function current_keypair_metadata(): Promise<any>;
|
||||||
|
export function current_keypair_public_key(): Promise<Uint8Array>;
|
||||||
|
export function sign(message: Uint8Array): Promise<string>;
|
||||||
|
export function verify(signature: string, message: Uint8Array): Promise<boolean>;
|
||||||
|
export function init_rhai_env(): void;
|
||||||
|
export function run_rhai(script: string): Promise<string>;
|
||||||
|
}
|
103
hero_vault_extension/src/utils/chromeApi.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Chrome API utilities for Hero Vault Extension
|
||||||
|
*
|
||||||
|
* This module provides Chrome API detection and mocks for development mode
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Check if we're running in a Chrome extension environment
|
||||||
|
export const isExtensionEnvironment = (): boolean => {
|
||||||
|
return typeof chrome !== 'undefined' && !!chrome.runtime && !!chrome.runtime.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock storage for development mode
|
||||||
|
const mockStorage: Record<string, any> = {
|
||||||
|
// Initialize with some default values for script storage
|
||||||
|
pendingScripts: [],
|
||||||
|
scriptResults: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock Chrome API for development mode
|
||||||
|
export const getChromeApi = () => {
|
||||||
|
// If we're in a Chrome extension environment, return the real Chrome API
|
||||||
|
if (isExtensionEnvironment()) {
|
||||||
|
return chrome;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, return a mock implementation
|
||||||
|
return {
|
||||||
|
runtime: {
|
||||||
|
sendMessage: (message: any): Promise<any> => {
|
||||||
|
console.log('Mock sendMessage called with:', message);
|
||||||
|
|
||||||
|
// Mock responses based on message type
|
||||||
|
if (message.type === 'SESSION_STATUS') {
|
||||||
|
return Promise.resolve({ active: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'CREATE_KEYSPACE') {
|
||||||
|
mockStorage['currentKeyspace'] = message.keyspace;
|
||||||
|
return Promise.resolve({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'UNLOCK_SESSION') {
|
||||||
|
mockStorage['currentKeyspace'] = message.keyspace;
|
||||||
|
return Promise.resolve({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'LOCK_SESSION') {
|
||||||
|
delete mockStorage['currentKeyspace'];
|
||||||
|
return Promise.resolve({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({ success: false });
|
||||||
|
},
|
||||||
|
getURL: (path: string): string => {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: (keys: string | string[] | object): Promise<Record<string, any>> => {
|
||||||
|
console.log('Mock storage.local.get called with:', keys);
|
||||||
|
|
||||||
|
if (typeof keys === 'string') {
|
||||||
|
// Handle specific script storage keys
|
||||||
|
if (keys === 'pendingScripts' && !mockStorage[keys]) {
|
||||||
|
mockStorage[keys] = [];
|
||||||
|
}
|
||||||
|
if (keys === 'scriptResults' && !mockStorage[keys]) {
|
||||||
|
mockStorage[keys] = [];
|
||||||
|
}
|
||||||
|
return Promise.resolve({ [keys]: mockStorage[keys] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(keys)) {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
keys.forEach(key => {
|
||||||
|
// Handle specific script storage keys
|
||||||
|
if (key === 'pendingScripts' && !mockStorage[key]) {
|
||||||
|
mockStorage[key] = [];
|
||||||
|
}
|
||||||
|
if (key === 'scriptResults' && !mockStorage[key]) {
|
||||||
|
mockStorage[key] = [];
|
||||||
|
}
|
||||||
|
result[key] = mockStorage[key];
|
||||||
|
});
|
||||||
|
return Promise.resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve(mockStorage);
|
||||||
|
},
|
||||||
|
set: (items: Record<string, any>): Promise<void> => {
|
||||||
|
console.log('Mock storage.local.set called with:', items);
|
||||||
|
|
||||||
|
Object.keys(items).forEach(key => {
|
||||||
|
mockStorage[key] = items[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as typeof chrome;
|
||||||
|
};
|
139
hero_vault_extension/src/wasm/wasmHelper.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* WASM Helper for Hero Vault Extension
|
||||||
|
*
|
||||||
|
* This module handles loading and initializing the WASM module,
|
||||||
|
* and provides a typed interface to the WASM functions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Import types for TypeScript
|
||||||
|
interface WasmModule {
|
||||||
|
// Session management
|
||||||
|
init_session: (keyspace: string, password: string) => Promise<void>;
|
||||||
|
create_keyspace: (keyspace: string, password: string) => Promise<void>;
|
||||||
|
lock_session: () => void;
|
||||||
|
is_unlocked: () => boolean;
|
||||||
|
|
||||||
|
// Keypair management
|
||||||
|
add_keypair: (key_type: string | undefined, metadata: string | undefined) => Promise<string>;
|
||||||
|
list_keypairs: () => Promise<string>;
|
||||||
|
select_keypair: (key_id: string) => Promise<void>;
|
||||||
|
current_keypair_metadata: () => Promise<any>;
|
||||||
|
current_keypair_public_key: () => Promise<Uint8Array>;
|
||||||
|
|
||||||
|
// Cryptographic operations
|
||||||
|
sign: (message: Uint8Array) => Promise<string>;
|
||||||
|
verify: (message: Uint8Array, signature: string) => Promise<boolean>;
|
||||||
|
encrypt_data: (data: Uint8Array) => Promise<Uint8Array>;
|
||||||
|
decrypt_data: (encrypted: Uint8Array) => Promise<Uint8Array>;
|
||||||
|
|
||||||
|
// Rhai scripting
|
||||||
|
init_rhai_env: () => void;
|
||||||
|
run_rhai: (script: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global reference to the WASM module
|
||||||
|
let wasmModule: WasmModule | null = null;
|
||||||
|
let isInitializing = false;
|
||||||
|
let initPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the WASM module
|
||||||
|
* This should be called before any other WASM functions
|
||||||
|
*/
|
||||||
|
export const initWasm = async (): Promise<void> => {
|
||||||
|
if (wasmModule) {
|
||||||
|
return Promise.resolve(); // Already initialized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitializing && initPromise) {
|
||||||
|
return initPromise; // Already initializing
|
||||||
|
}
|
||||||
|
|
||||||
|
isInitializing = true;
|
||||||
|
|
||||||
|
initPromise = new Promise<void>(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
// Import the WASM module
|
||||||
|
// Use a relative path that will be resolved by Vite during build
|
||||||
|
const wasmImport = await import('../../public/wasm/wasm_app.js');
|
||||||
|
|
||||||
|
// Initialize the WASM module
|
||||||
|
await wasmImport.default();
|
||||||
|
|
||||||
|
// Store the WASM module globally
|
||||||
|
wasmModule = wasmImport as unknown as WasmModule;
|
||||||
|
|
||||||
|
console.log('WASM module initialized successfully');
|
||||||
|
resolve();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize WASM module:', error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
isInitializing = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return initPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the WASM module
|
||||||
|
* This will initialize the module if it hasn't been initialized yet
|
||||||
|
*/
|
||||||
|
export const getWasmModule = async (): Promise<WasmModule> => {
|
||||||
|
if (!wasmModule) {
|
||||||
|
await initWasm();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wasmModule) {
|
||||||
|
throw new Error('WASM module failed to initialize');
|
||||||
|
}
|
||||||
|
|
||||||
|
return wasmModule;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the WASM module is initialized
|
||||||
|
*/
|
||||||
|
export const isWasmInitialized = (): boolean => {
|
||||||
|
return wasmModule !== null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to convert string to Uint8Array
|
||||||
|
*/
|
||||||
|
export const stringToUint8Array = (str: string): Uint8Array => {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return encoder.encode(str);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to convert Uint8Array to string
|
||||||
|
*/
|
||||||
|
export const uint8ArrayToString = (array: Uint8Array): string => {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(array);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to convert hex string to Uint8Array
|
||||||
|
*/
|
||||||
|
export const hexToUint8Array = (hex: string): Uint8Array => {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to convert Uint8Array to hex string
|
||||||
|
*/
|
||||||
|
export const uint8ArrayToHex = (array: Uint8Array): string => {
|
||||||
|
return Array.from(array)
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
};
|
30
hero_vault_extension/tsconfig.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"typeRoots": ["./node_modules/@types", "./src/types"],
|
||||||
|
"jsxImportSource": "react"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
10
hero_vault_extension/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
33
hero_vault_extension/vite.config.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { crx } from '@crxjs/vite-plugin';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const manifest = JSON.parse(
|
||||||
|
readFileSync('public/manifest.json', 'utf-8')
|
||||||
|
);
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
crx({ manifest }),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: resolve(__dirname, 'index.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Copy WASM files to the dist directory
|
||||||
|
publicDir: 'public',
|
||||||
|
});
|
@ -7,25 +7,29 @@ edition = "2021"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
instant = { version = "0.1", features = ["wasm-bindgen"] }
|
||||||
|
tokio = { version = "1.37", features = ["rt", "macros"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
sled = { version = "0.34", optional = true }
|
|
||||||
idb = { version = "0.4", optional = true }
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
js-sys = "0.3"
|
wasm-bindgen = { version = "0.2.92", features = ["serde-serialize"] }
|
||||||
wasm-bindgen = "0.2"
|
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
sled = { version = "0.34" }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
|
getrandom_02 = { package = "getrandom", version = "0.2.16", features = ["js"] }
|
||||||
|
idb = { version = "0.6" }
|
||||||
|
wasm-bindgen-test = "0.3"
|
||||||
|
wasm-bindgen = { version = "0.2.92", features = ["serde-serialize"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
native = ["sled", "tokio"]
|
native = []
|
||||||
web = ["idb"]
|
|
||||||
|
|
||||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
|
||||||
tokio = { version = "1.45", optional = true, default-features = false, features = ["rt-multi-thread", "macros"] }
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
|
||||||
idb = "0.4"
|
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
|
||||||
wasm-bindgen-test = "0.3"
|
|
||||||
|
@ -22,3 +22,12 @@ pub enum KVError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, KVError>;
|
pub type Result<T> = std::result::Result<T, KVError>;
|
||||||
|
|
||||||
|
// Allow automatic conversion from idb::Error to KVError
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
impl From<idb::Error> for KVError {
|
||||||
|
fn from(e: idb::Error) -> Self {
|
||||||
|
KVError::Other(format!("idb error: {e:?}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! # Runtime Requirement
|
//! # Runtime Requirement
|
||||||
//!
|
//!
|
||||||
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
//! **A Tokio runtime must be running to use this backend.**
|
//! **A Tokio runtime must be running to use this backend.**
|
||||||
//! This library does not start or manage a runtime; it assumes that all async methods are called from within an existing Tokio runtime context (e.g., via `#[tokio::main]` or `tokio::test`).
|
//! This library does not start or manage a runtime; it assumes that all async methods are called from within an existing Tokio runtime context (e.g., via `#[tokio::main]` or `tokio::test`).
|
||||||
//!
|
//!
|
||||||
@ -10,11 +11,18 @@
|
|||||||
//! # Example
|
//! # Example
|
||||||
//!
|
//!
|
||||||
|
|
||||||
use crate::traits::KVStore;
|
//! Native backend for kvstore using sled
|
||||||
use crate::error::{KVError, Result};
|
//! Only compiled for non-wasm32 targets
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::traits::KVStore;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::error::{KVError, Result};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use sled::Db;
|
use sled::Db;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
@ -16,6 +16,11 @@ use crate::error::Result;
|
|||||||
/// - contains_key (was exists)
|
/// - contains_key (was exists)
|
||||||
/// - keys
|
/// - keys
|
||||||
/// - clear
|
/// - clear
|
||||||
|
/// Async key-value store interface for both native and WASM backends.
|
||||||
|
///
|
||||||
|
/// For native (non-wasm32) backends, implementers should be `Send + Sync` to support async usage.
|
||||||
|
/// For WASM (wasm32) backends, `Send + Sync` is not required.
|
||||||
|
#[async_trait::async_trait]
|
||||||
pub trait KVStore {
|
pub trait KVStore {
|
||||||
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
|
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
|
||||||
async fn set(&self, key: &str, value: &[u8]) -> Result<()>;
|
async fn set(&self, key: &str, value: &[u8]) -> Result<()>;
|
||||||
|
@ -13,25 +13,30 @@
|
|||||||
//!
|
//!
|
||||||
|
|
||||||
|
|
||||||
|
//! WASM backend for kvstore using IndexedDB (idb crate)
|
||||||
|
//! Only compiled for wasm32 targets
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::traits::KVStore;
|
use crate::traits::KVStore;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::error::{KVError, Result};
|
use crate::error::{KVError, Result};
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use idb::{Database, TransactionMode, Factory};
|
use idb::{Database, TransactionMode, Factory};
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use wasm_bindgen::JsValue;
|
use wasm_bindgen::JsValue;
|
||||||
|
// use wasm-bindgen directly for Uint8Array if needed
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use js_sys::Uint8Array;
|
use std::rc::Rc;
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
const STORE_NAME: &str = "kv";
|
const STORE_NAME: &str = "kv";
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct WasmStore {
|
pub struct WasmStore {
|
||||||
db: Database,
|
db: Rc<Database>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@ -41,13 +46,14 @@ impl WasmStore {
|
|||||||
let mut open_req = factory.open(name, None)
|
let mut open_req = factory.open(name, None)
|
||||||
.map_err(|e| KVError::Other(format!("IndexedDB factory open error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("IndexedDB factory open error: {e:?}")))?;
|
||||||
open_req.on_upgrade_needed(|event| {
|
open_req.on_upgrade_needed(|event| {
|
||||||
|
use idb::DatabaseEvent;
|
||||||
let db = event.database().expect("Failed to get database in upgrade event");
|
let db = event.database().expect("Failed to get database in upgrade event");
|
||||||
if !db.store_names().iter().any(|n| n == STORE_NAME) {
|
if !db.store_names().iter().any(|n| n == STORE_NAME) {
|
||||||
db.create_object_store(STORE_NAME, Default::default()).unwrap();
|
db.create_object_store(STORE_NAME, Default::default()).unwrap();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let db = open_req.await.map_err(|e| KVError::Other(format!("IndexedDB open error: {e:?}")))?;
|
let db = open_req.await.map_err(|e| KVError::Other(format!("IndexedDB open error: {e:?}")))?;
|
||||||
Ok(Self { db })
|
Ok(Self { db: Rc::new(db) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,11 +66,13 @@ impl KVStore for WasmStore {
|
|||||||
let store = tx.object_store(STORE_NAME)
|
let store = tx.object_store(STORE_NAME)
|
||||||
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
||||||
use idb::Query;
|
use idb::Query;
|
||||||
let val = store.get(Query::from(JsValue::from_str(key))).await
|
let val = store.get(Query::from(JsValue::from_str(key)))?.await
|
||||||
.map_err(|e| KVError::Other(format!("idb get await error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb get error: {e:?}")))?;
|
||||||
if let Some(jsval) = val {
|
if let Some(jsval) = val {
|
||||||
let arr = Uint8Array::new(&jsval);
|
match jsval.into_serde::<Vec<u8>>() {
|
||||||
Ok(Some(arr.to_vec()))
|
Ok(bytes) => Ok(Some(bytes)),
|
||||||
|
Err(_) => Ok(None),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
@ -74,8 +82,9 @@ impl KVStore for WasmStore {
|
|||||||
.map_err(|e| KVError::Other(format!("idb transaction error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb transaction error: {e:?}")))?;
|
||||||
let store = tx.object_store(STORE_NAME)
|
let store = tx.object_store(STORE_NAME)
|
||||||
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
||||||
store.put(&Uint8Array::from(value).into(), Some(&JsValue::from_str(key))).await
|
let js_value = JsValue::from_serde(&value).map_err(|e| KVError::Other(format!("serde error: {e:?}")))?;
|
||||||
.map_err(|e| KVError::Other(format!("idb put await error: {e:?}")))?;
|
store.put(&js_value, Some(&JsValue::from_str(key)))?.await
|
||||||
|
.map_err(|e| KVError::Other(format!("idb put error: {e:?}")))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn remove(&self, key: &str) -> Result<()> {
|
async fn remove(&self, key: &str) -> Result<()> {
|
||||||
@ -84,8 +93,8 @@ impl KVStore for WasmStore {
|
|||||||
let store = tx.object_store(STORE_NAME)
|
let store = tx.object_store(STORE_NAME)
|
||||||
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
||||||
use idb::Query;
|
use idb::Query;
|
||||||
store.delete(Query::from(JsValue::from_str(key))).await
|
store.delete(Query::from(JsValue::from_str(key)))?.await
|
||||||
.map_err(|e| KVError::Other(format!("idb delete await error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb delete error: {e:?}")))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn contains_key(&self, key: &str) -> Result<bool> {
|
async fn contains_key(&self, key: &str) -> Result<bool> {
|
||||||
@ -97,12 +106,11 @@ impl KVStore for WasmStore {
|
|||||||
.map_err(|e| KVError::Other(format!("idb transaction error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb transaction error: {e:?}")))?;
|
||||||
let store = tx.object_store(STORE_NAME)
|
let store = tx.object_store(STORE_NAME)
|
||||||
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
||||||
let js_keys = store.get_all_keys(None, None).await
|
let js_keys = store.get_all_keys(None, None)?.await
|
||||||
.map_err(|e| KVError::Other(format!("idb get_all_keys error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb get_all_keys error: {e:?}")))?;
|
||||||
let arr = js_sys::Array::from(&JsValue::from(js_keys));
|
|
||||||
let mut keys = Vec::new();
|
let mut keys = Vec::new();
|
||||||
for i in 0..arr.length() {
|
for key in js_keys.iter() {
|
||||||
if let Some(s) = arr.get(i).as_string() {
|
if let Some(s) = key.as_string() {
|
||||||
keys.push(s);
|
keys.push(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -114,7 +122,7 @@ impl KVStore for WasmStore {
|
|||||||
.map_err(|e| KVError::Other(format!("idb transaction error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb transaction error: {e:?}")))?;
|
||||||
let store = tx.object_store(STORE_NAME)
|
let store = tx.object_store(STORE_NAME)
|
||||||
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
||||||
store.clear().await
|
store.clear()?.await
|
||||||
.map_err(|e| KVError::Other(format!("idb clear error: {e:?}")))?;
|
.map_err(|e| KVError::Other(format!("idb clear error: {e:?}")))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -31,3 +31,22 @@ async fn test_native_store_basic() {
|
|||||||
let keys = store.keys().await.unwrap();
|
let keys = store.keys().await.unwrap();
|
||||||
assert_eq!(keys.len(), 0);
|
assert_eq!(keys.len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_native_store_persistence() {
|
||||||
|
let tmp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = tmp_dir.path().join("persistdb");
|
||||||
|
let db_path = path.to_str().unwrap();
|
||||||
|
// First open, set value
|
||||||
|
{
|
||||||
|
let store = NativeStore::open(db_path).unwrap();
|
||||||
|
store.set("persist", b"value").await.unwrap();
|
||||||
|
}
|
||||||
|
// Reopen and check value
|
||||||
|
{
|
||||||
|
let store = NativeStore::open(db_path).unwrap();
|
||||||
|
let val = store.get("persist").await.unwrap();
|
||||||
|
assert_eq!(val, Some(b"value".to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ wasm_bindgen_test_configure!(run_in_browser);
|
|||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
async fn test_set_and_get() {
|
async fn test_set_and_get() {
|
||||||
let store = WasmStore::open("test-db").await.expect("open");
|
let store = WasmStore::open("vault").await.expect("open");
|
||||||
store.set("foo", b"bar").await.expect("set");
|
store.set("foo", b"bar").await.expect("set");
|
||||||
let val = store.get("foo").await.expect("get");
|
let val = store.get("foo").await.expect("get");
|
||||||
assert_eq!(val, Some(b"bar".to_vec()));
|
assert_eq!(val, Some(b"bar".to_vec()));
|
||||||
@ -16,7 +16,7 @@ async fn test_set_and_get() {
|
|||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
async fn test_delete_and_exists() {
|
async fn test_delete_and_exists() {
|
||||||
let store = WasmStore::open("test-db").await.expect("open");
|
let store = WasmStore::open("vault").await.expect("open");
|
||||||
store.set("foo", b"bar").await.expect("set");
|
store.set("foo", b"bar").await.expect("set");
|
||||||
assert_eq!(store.contains_key("foo").await.unwrap(), true);
|
assert_eq!(store.contains_key("foo").await.unwrap(), true);
|
||||||
assert_eq!(store.contains_key("bar").await.unwrap(), false);
|
assert_eq!(store.contains_key("bar").await.unwrap(), false);
|
||||||
@ -26,7 +26,7 @@ async fn test_delete_and_exists() {
|
|||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
async fn test_keys() {
|
async fn test_keys() {
|
||||||
let store = WasmStore::open("test-db").await.expect("open");
|
let store = WasmStore::open("vault").await.expect("open");
|
||||||
store.set("foo", b"bar").await.expect("set");
|
store.set("foo", b"bar").await.expect("set");
|
||||||
store.set("baz", b"qux").await.expect("set");
|
store.set("baz", b"qux").await.expect("set");
|
||||||
let keys = store.keys().await.unwrap();
|
let keys = store.keys().await.unwrap();
|
||||||
@ -35,9 +35,26 @@ async fn test_keys() {
|
|||||||
assert!(keys.contains(&"baz".to_string()));
|
assert!(keys.contains(&"baz".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
async fn test_wasm_store_persistence() {
|
||||||
|
// Use a unique store name to avoid collisions
|
||||||
|
let store_name = "persist_test_store";
|
||||||
|
// First open, set value
|
||||||
|
{
|
||||||
|
let store = WasmStore::open(store_name).await.expect("open");
|
||||||
|
store.set("persist", b"value").await.expect("set");
|
||||||
|
}
|
||||||
|
// Reopen and check value
|
||||||
|
{
|
||||||
|
let store = WasmStore::open(store_name).await.expect("open");
|
||||||
|
let val = store.get("persist").await.expect("get");
|
||||||
|
assert_eq!(val, Some(b"value".to_vec()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen_test]
|
#[wasm_bindgen_test]
|
||||||
async fn test_clear() {
|
async fn test_clear() {
|
||||||
let store = WasmStore::open("test-db").await.expect("open");
|
let store = WasmStore::open("vault").await.expect("open");
|
||||||
store.set("foo", b"bar").await.expect("set");
|
store.set("foo", b"bar").await.expect("set");
|
||||||
store.set("baz", b"qux").await.expect("set");
|
store.set("baz", b"qux").await.expect("set");
|
||||||
store.clear().await.unwrap();
|
store.clear().await.unwrap();
|
||||||
|
@ -7,9 +7,56 @@ edition = "2021"
|
|||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
instant = { version = "0.1", features = ["wasm-bindgen"] }
|
||||||
|
once_cell = "1.18"
|
||||||
|
tokio = { version = "1.37", features = ["rt", "macros"] }
|
||||||
kvstore = { path = "../kvstore" }
|
kvstore = { path = "../kvstore" }
|
||||||
|
scrypt = "0.11"
|
||||||
|
sha2 = "0.10"
|
||||||
|
# aes-gcm = "0.10"
|
||||||
|
pbkdf2 = "0.12"
|
||||||
|
signature = "2.2"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
chacha20poly1305 = "0.10"
|
chacha20poly1305 = "0.10"
|
||||||
k256 = "0.13"
|
k256 = { version = "0.13", features = ["ecdsa"] }
|
||||||
|
ed25519-dalek = "2.1"
|
||||||
rand_core = "0.6"
|
rand_core = "0.6"
|
||||||
|
log = "0.4"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
|
console_log = "1"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||||
|
env_logger = "0.11"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
hex = "0.4"
|
||||||
|
zeroize = "1.8.1"
|
||||||
|
rhai = "1.21.0"
|
||||||
|
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
wasm-bindgen = { version = "0.2.92", features = ["serde-serialize"] }
|
||||||
|
wasm-bindgen-test = "0.3"
|
||||||
|
# console_error_panic_hook = "0.1"
|
||||||
|
|
||||||
|
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||||
|
tempfile = "3.10"
|
||||||
|
tokio = { version = "1.0", features = ["rt", "macros"] }
|
||||||
|
async-std = { version = "1", features = ["attributes"] }
|
||||||
|
chrono = "0.4"
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
|
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||||
|
getrandom_02 = { package = "getrandom", version = "0.2.16", features = ["js"] }
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
js-sys = "0.3"
|
||||||
|
# console_error_panic_hook = "0.1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
hex = "0.4"
|
||||||
|
rhai = "1.21.0"
|
||||||
|
zeroize = "1.8.1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
native = []
|
68
vault/README.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# vault: Cryptographic Vault for Native and WASM
|
||||||
|
|
||||||
|
`vault` provides a secure, async, and cross-platform cryptographic key management system. It leverages the `kvstore` crate for persistent storage and supports both native (desktop/server) and WASM (browser) environments.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Keyspace management**: Create, unlock, and manage encrypted keyspaces.
|
||||||
|
- **Keypair operations**: Add, remove, list, export, and use keypairs for signing and verification.
|
||||||
|
- **End-to-end encryption**: All key material is encrypted at rest using modern ciphers (ChaCha20Poly1305, AES-GCM).
|
||||||
|
- **Async API**: All operations are async and runtime-agnostic.
|
||||||
|
- **Cross-platform**: Native uses `sled` via `kvstore::native::NativeStore`, WASM uses IndexedDB via `kvstore::wasm::WasmStore`.
|
||||||
|
- **Pluggable logging**: Uses the standard `log` crate for logging, with recommended backends for native (`env_logger`) and WASM (`console_log`).
|
||||||
|
|
||||||
|
## Logging Best Practices
|
||||||
|
|
||||||
|
This crate uses the [`log`](https://docs.rs/log) crate for logging. For native tests, use [`env_logger`](https://docs.rs/env_logger); for WASM tests, use [`console_log`](https://docs.rs/console_log).
|
||||||
|
|
||||||
|
- Native (in tests):
|
||||||
|
```rust
|
||||||
|
let _ = env_logger::builder().is_test(true).try_init();
|
||||||
|
log::info!("test started");
|
||||||
|
```
|
||||||
|
- WASM (in tests):
|
||||||
|
```rust
|
||||||
|
console_log::init_with_level(log::Level::Debug).expect("error initializing logger");
|
||||||
|
log::debug!("wasm test started");
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `log::debug!`, `log::info!`, `log::error!`, etc., throughout the codebase for consistent and idiomatic logging. Do not prefix messages with [DEBUG], [ERROR], etc. The log level is handled by the logger.
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use vault::{Vault, KeyType, KeyMetadata};
|
||||||
|
use kvstore::native::NativeStore;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let store = NativeStore::open("/tmp/vaultdb").unwrap();
|
||||||
|
let mut vault = Vault::new(store);
|
||||||
|
let keyspace = "myspace";
|
||||||
|
let password = b"secret";
|
||||||
|
vault.create_keyspace(keyspace, password, "pbkdf2", "chacha20poly1305", None).await.unwrap();
|
||||||
|
let key_id = vault.add_keypair(keyspace, password, KeyType::Ed25519, None).await.unwrap();
|
||||||
|
println!("Created keypair: {}", key_id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For WASM/browser, use `kvstore::wasm::WasmStore` and initialize logging with `console_log`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Native
|
||||||
|
```sh
|
||||||
|
cargo test -p vault --features native
|
||||||
|
```
|
||||||
|
|
||||||
|
### WASM
|
||||||
|
```sh
|
||||||
|
wasm-pack test --headless --firefox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
- All cryptographic operations use vetted RustCrypto crates.
|
||||||
|
- Password-based key derivation uses PBKDF2 by default (10,000 iterations).
|
||||||
|
- All sensitive data is encrypted before storage.
|
||||||
|
|
||||||
|
## License
|
||||||
|
MIT OR Apache-2.0
|
56
vault/src/crypto.rs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
//! Crypto utilities for the vault crate
|
||||||
|
|
||||||
|
//! Crypto utilities for the vault crate
|
||||||
|
use chacha20poly1305::{ChaCha20Poly1305, KeyInit as ChaChaKeyInit, aead::{Aead, generic_array::GenericArray}};
|
||||||
|
|
||||||
|
use pbkdf2::pbkdf2_hmac;
|
||||||
|
|
||||||
|
use sha2::Sha256;
|
||||||
|
use rand_core::{RngCore, OsRng as RandOsRng};
|
||||||
|
|
||||||
|
pub mod kdf {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Standard parameters for keyspace key derivation
|
||||||
|
pub const KEYSPACE_KEY_LENGTH: usize = 32;
|
||||||
|
pub const KEYSPACE_KEY_ITERATIONS: u32 = 10_000;
|
||||||
|
|
||||||
|
/// Derive a symmetric key for keyspace operations using standard parameters
|
||||||
|
/// Always uses PBKDF2 with SHA-256, 32 bytes output, and 10,000 iterations
|
||||||
|
pub fn keyspace_key(password: &[u8], salt: &[u8]) -> Vec<u8> {
|
||||||
|
derive_key_pbkdf2(password, salt, KEYSPACE_KEY_LENGTH, KEYSPACE_KEY_ITERATIONS)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn derive_key_pbkdf2(password: &[u8], salt: &[u8], key_len: usize, iterations: u32) -> Vec<u8> {
|
||||||
|
let mut key = vec![0u8; key_len];
|
||||||
|
pbkdf2_hmac::<Sha256>(password, salt, iterations, &mut key);
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub mod cipher {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
pub fn encrypt_chacha20(key: &[u8], plaintext: &[u8], nonce: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
let cipher = ChaCha20Poly1305::new(GenericArray::from_slice(key));
|
||||||
|
cipher.encrypt(GenericArray::from_slice(nonce), plaintext)
|
||||||
|
.map_err(|e| format!("encryption error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_chacha20(key: &[u8], ciphertext: &[u8], nonce: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
let cipher = ChaCha20Poly1305::new(GenericArray::from_slice(key));
|
||||||
|
cipher.decrypt(GenericArray::from_slice(nonce), ciphertext)
|
||||||
|
.map_err(|e| format!("decryption error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn random_salt(len: usize) -> Vec<u8> {
|
||||||
|
let mut salt = vec![0u8; len];
|
||||||
|
RandOsRng.fill_bytes(&mut salt);
|
||||||
|
salt
|
||||||
|
}
|