Compare commits
34 Commits
main
...
c641d0ae2e
Author | SHA1 | Date | |
---|---|---|---|
|
c641d0ae2e | ||
|
6f42e5ab8d | ||
|
203cde1cba | ||
|
6b037537bf | ||
|
580fd72dce | ||
|
a0622629ae | ||
|
4e1e707f85 | ||
|
9f143ded9d | ||
|
b0d0aaa53d | ||
|
e00c140396 | ||
|
4ba1e43f4e | ||
|
b82d457873 | ||
|
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
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Ignore test databases
|
||||
/vault/vault_native_test/
|
||||
node_modules/
|
@@ -3,5 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"kvstore",
|
||||
"vault",
|
||||
"evm_client"
|
||||
"evm_client",
|
||||
"wasm_app",
|
||||
"sigsocket_client",
|
||||
]
|
||||
|
32
Makefile
Normal file
@@ -0,0 +1,32 @@
|
||||
# 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-crypto-vault-extension: build-wasm-app
|
||||
cp wasm_app/pkg/wasm_app* crypto_vault_extension/wasm/
|
||||
cp wasm_app/pkg/*.d.ts crypto_vault_extension/wasm/
|
||||
cp wasm_app/pkg/*.js crypto_vault_extension/wasm/
|
223
README.md
@@ -1,57 +1,234 @@
|
||||
# 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
|
||||
|
||||
- **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.
|
||||
- **evm_client/**: EVM RPC client, integrates with `vault` for signing and secure key management.
|
||||
- **cli_app/**: (Planned) Command-line interface for scripting and automation.
|
||||
- **web_app/**: (Planned) WASM web app exposing the same APIs to JavaScript or browser scripting.
|
||||
- **kvstore/**: Async key-value store trait and implementations (native: `sled`, WASM: IndexedDB)
|
||||
- **vault/**: Cryptographic vault for encrypted keyspaces, key management, and signing; uses `kvstore` for persistence
|
||||
- **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, automation, and Rhai 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
|
||||
- **Async everywhere:** All APIs are async and runtime-agnostic.
|
||||
- **Conditional backends:** Uses Cargo features and `cfg` to select the appropriate backend for each environment.
|
||||
- **Secure by design:** Vault encrypts all key material at rest and leverages modern cryptography.
|
||||
- **Tested natively and in browser:** WASM and native backends are both covered by tests.
|
||||
- **Modular and async:** All APIs are async and runtime-agnostic (works with both native and WASM targets)
|
||||
- **Conditional backends:** Uses Cargo features and `cfg` for platform-specific storage/networking
|
||||
- **Secure by design:** Vault encrypts all key material at rest using modern cryptography
|
||||
- **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
|
||||
|
||||
### 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)
|
||||
- For WASM: `wasm-pack`, Firefox or Chrome (for browser tests)
|
||||
|
||||
### Native
|
||||
```sh
|
||||
cargo check --workspace --features kvstore/native
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
```
|
||||
|
||||
### WASM (kvstore only)
|
||||
### WASM
|
||||
```sh
|
||||
cd kvstore
|
||||
wasm-pack test --headless --firefox --features web
|
||||
make test-browser-all
|
||||
```
|
||||
|
||||
# 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
|
||||
```
|
||||
.
|
||||
├── kvstore/ # Key-value store trait and backends
|
||||
├── vault/ # Cryptographic vault
|
||||
├── evm_client/ # EVM RPC client
|
||||
├── cli_app/ # CLI (planned)
|
||||
├── web_app/ # Web app (planned)
|
||||
├── docs/ # Architecture docs
|
||||
├── vault/ # Cryptographic vault (shared core)
|
||||
├── evm_client/ # EVM RPC client (shared core)
|
||||
├── cli/ # Command-line tool for Rhai scripts
|
||||
├── wasm/ # WebAssembly module for browser/extension
|
||||
├── browser_extension/ # Extension source
|
||||
├── docs/ # Architecture & usage docs
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
- [x] Unified async trait for key-value storage
|
||||
- [x] Native and WASM backends for kvstore
|
||||
- [ ] Cryptographic vault with password-protected keyspace
|
||||
- [ ] EVM client with vault integration
|
||||
- [ ] CLI and web app targets
|
||||
- [ ] Full end-to-end integration
|
||||
- [x] Shared Rust core for vault and evm_client
|
||||
- [ ] WASM module exposing `run_rhai`
|
||||
- [ ] CLI tool for local Rhai script execution
|
||||
- [ ] Browser extension for secure script execution
|
||||
- [ ] Web app integration (postMessage/WebSocket)
|
||||
- [ ] Full end-to-end integration and security review
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
MIT OR Apache-2.0
|
||||
|
||||
|
38
build.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/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: Prepare the frontend extension
|
||||
echo -e "${BLUE}Preparing frontend extension...${RESET}"
|
||||
cd ../crypto_vault_extension || exit 1
|
||||
|
||||
# Copy WASM files to the extension's public directory
|
||||
echo "Copying WASM files..."
|
||||
cp ../wasm_app/pkg/wasm_app* wasm/
|
||||
cp ../wasm_app/pkg/*.d.ts wasm/
|
||||
cp ../wasm_app/pkg/*.js wasm/
|
||||
|
||||
|
||||
echo -e "${GREEN}=== Build Complete ===${RESET}"
|
||||
echo "Extension is ready in: $(pwd)"
|
||||
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 $(pwd) directory"
|
407
crypto_vault_extension/background.js
Normal file
@@ -0,0 +1,407 @@
|
||||
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
|
||||
|
||||
// SigSocket service instance
|
||||
let sigSocketService = null;
|
||||
|
||||
// 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 and SigSocket service
|
||||
import init, * as wasmFunctions from './wasm/wasm_app.js';
|
||||
import SigSocketService from './background/sigsocket.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;
|
||||
|
||||
// Initialize SigSocket service
|
||||
if (!sigSocketService) {
|
||||
sigSocketService = new SigSocketService();
|
||||
await sigSocketService.initialize(vault);
|
||||
console.log('🔌 SigSocket service initialized');
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Smart auto-connect to SigSocket when session is initialized
|
||||
if (sigSocketService) {
|
||||
try {
|
||||
// This will reuse existing connection if same workspace, or switch if different
|
||||
const connected = await sigSocketService.connectToServer(request.keyspace);
|
||||
if (connected) {
|
||||
console.log(`🔗 SigSocket ready for workspace: ${request.keyspace}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to auto-connect to SigSocket:', error);
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
},
|
||||
|
||||
// SigSocket handlers
|
||||
connectSigSocket: async (request) => {
|
||||
if (!sigSocketService) {
|
||||
return { success: false, error: 'SigSocket service not initialized' };
|
||||
}
|
||||
const connected = await sigSocketService.connectToServer(request.workspace);
|
||||
return { success: connected };
|
||||
},
|
||||
|
||||
disconnectSigSocket: async () => {
|
||||
if (!sigSocketService) {
|
||||
return { success: false, error: 'SigSocket service not initialized' };
|
||||
}
|
||||
await sigSocketService.disconnect();
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
getSigSocketStatus: async () => {
|
||||
if (!sigSocketService) {
|
||||
return { success: false, error: 'SigSocket service not initialized' };
|
||||
}
|
||||
const status = await sigSocketService.getStatus();
|
||||
return { success: true, status };
|
||||
},
|
||||
|
||||
getPendingSignRequests: async () => {
|
||||
if (!sigSocketService) {
|
||||
return { success: false, error: 'SigSocket service not initialized' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Use WASM filtered requests which handles workspace filtering
|
||||
const requests = await sigSocketService.getFilteredRequests();
|
||||
return { success: true, requests };
|
||||
} catch (error) {
|
||||
console.error('Failed to get pending requests:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
approveSignRequest: async (request) => {
|
||||
if (!sigSocketService) {
|
||||
return { success: false, error: 'SigSocket service not initialized' };
|
||||
}
|
||||
const approved = await sigSocketService.approveSignRequest(request.requestId);
|
||||
return { success: approved };
|
||||
},
|
||||
|
||||
rejectSignRequest: async (request) => {
|
||||
if (!sigSocketService) {
|
||||
return { success: false, error: 'SigSocket service not initialized' };
|
||||
}
|
||||
const rejected = await sigSocketService.rejectSignRequest(request.requestId, request.reason);
|
||||
return { success: rejected };
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
// Connect SigSocket service to popup
|
||||
if (sigSocketService) {
|
||||
sigSocketService.setPopupPort(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();
|
||||
|
||||
// Disconnect SigSocket service from popup
|
||||
if (sigSocketService) {
|
||||
sigSocketService.setPopupPort(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
410
crypto_vault_extension/background/sigsocket.js
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* SigSocket Service - Clean Implementation with New WASM APIs
|
||||
*
|
||||
* This service provides a clean interface for SigSocket functionality using
|
||||
* the new WASM-based APIs that handle all WebSocket management, request storage,
|
||||
* and security validation internally.
|
||||
*
|
||||
* Architecture:
|
||||
* - WASM handles: WebSocket connection, message parsing, request storage, security
|
||||
* - Extension handles: UI notifications, badge updates, user interactions
|
||||
*/
|
||||
|
||||
class SigSocketService {
|
||||
constructor() {
|
||||
// Connection state
|
||||
this.isConnected = false;
|
||||
this.currentWorkspace = null;
|
||||
this.connectedPublicKey = null;
|
||||
|
||||
// Configuration
|
||||
this.defaultServerUrl = "ws://localhost:8080/ws";
|
||||
|
||||
// WASM module reference
|
||||
this.wasmModule = null;
|
||||
|
||||
// UI communication
|
||||
this.popupPort = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the service with WASM module
|
||||
* @param {Object} wasmModule - The loaded WASM module with SigSocketManager
|
||||
*/
|
||||
async initialize(wasmModule) {
|
||||
this.wasmModule = wasmModule;
|
||||
|
||||
// Load server URL from storage
|
||||
try {
|
||||
const result = await chrome.storage.local.get(['sigSocketUrl']);
|
||||
if (result.sigSocketUrl) {
|
||||
this.defaultServerUrl = result.sigSocketUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load SigSocket URL from storage:', error);
|
||||
}
|
||||
|
||||
console.log('🔌 SigSocket service initialized with WASM APIs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to SigSocket server using WASM APIs
|
||||
* WASM handles all connection logic (reuse, switching, etc.)
|
||||
* @param {string} workspaceId - The workspace/keyspace identifier
|
||||
* @returns {Promise<boolean>} - True if connected successfully
|
||||
*/
|
||||
async connectToServer(workspaceId) {
|
||||
try {
|
||||
if (!this.wasmModule?.SigSocketManager) {
|
||||
throw new Error('WASM SigSocketManager not available');
|
||||
}
|
||||
|
||||
console.log(`🔗 Requesting SigSocket connection for workspace: ${workspaceId}`);
|
||||
|
||||
// Let WASM handle all connection logic (reuse, switching, etc.)
|
||||
const connectionInfo = await this.wasmModule.SigSocketManager.connect_workspace_with_events(
|
||||
workspaceId,
|
||||
this.defaultServerUrl,
|
||||
(event) => this.handleSigSocketEvent(event)
|
||||
);
|
||||
|
||||
// Parse connection info
|
||||
const info = JSON.parse(connectionInfo);
|
||||
this.currentWorkspace = info.workspace;
|
||||
this.connectedPublicKey = info.public_key;
|
||||
this.isConnected = info.is_connected;
|
||||
|
||||
console.log(`✅ SigSocket connection result:`, {
|
||||
workspace: this.currentWorkspace,
|
||||
publicKey: this.connectedPublicKey?.substring(0, 16) + '...',
|
||||
connected: this.isConnected
|
||||
});
|
||||
|
||||
// Update badge to show current state
|
||||
this.updateBadge();
|
||||
|
||||
return this.isConnected;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ SigSocket connection failed:', error);
|
||||
this.isConnected = false;
|
||||
this.currentWorkspace = null;
|
||||
this.connectedPublicKey = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle events from the WASM SigSocket client
|
||||
* This is called automatically when requests arrive
|
||||
* @param {Object} event - Event from WASM layer
|
||||
*/
|
||||
handleSigSocketEvent(event) {
|
||||
console.log('📨 Received SigSocket event:', event);
|
||||
|
||||
if (event.type === 'sign_request') {
|
||||
console.log(`🔐 New sign request: ${event.request_id}`);
|
||||
|
||||
// The request is automatically stored by WASM
|
||||
// We just handle UI updates
|
||||
this.showSignRequestNotification();
|
||||
this.updateBadge();
|
||||
this.notifyPopupOfNewRequest();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a sign request using WASM APIs
|
||||
* @param {string} requestId - Request to approve
|
||||
* @returns {Promise<boolean>} - True if approved successfully
|
||||
*/
|
||||
async approveSignRequest(requestId) {
|
||||
try {
|
||||
if (!this.wasmModule?.SigSocketManager) {
|
||||
throw new Error('WASM SigSocketManager not available');
|
||||
}
|
||||
|
||||
console.log(`✅ Approving request: ${requestId}`);
|
||||
|
||||
// WASM handles all validation, signing, and server communication
|
||||
await this.wasmModule.SigSocketManager.approve_request(requestId);
|
||||
|
||||
console.log(`🎉 Request approved successfully: ${requestId}`);
|
||||
|
||||
// Update UI
|
||||
this.updateBadge();
|
||||
this.notifyPopupOfRequestUpdate();
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to approve request ${requestId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a sign request using WASM APIs
|
||||
* @param {string} requestId - Request to reject
|
||||
* @param {string} reason - Reason for rejection
|
||||
* @returns {Promise<boolean>} - True if rejected successfully
|
||||
*/
|
||||
async rejectSignRequest(requestId, reason = 'User rejected') {
|
||||
try {
|
||||
if (!this.wasmModule?.SigSocketManager) {
|
||||
throw new Error('WASM SigSocketManager not available');
|
||||
}
|
||||
|
||||
console.log(`❌ Rejecting request: ${requestId}, reason: ${reason}`);
|
||||
|
||||
// WASM handles rejection and server communication
|
||||
await this.wasmModule.SigSocketManager.reject_request(requestId, reason);
|
||||
|
||||
console.log(`✅ Request rejected successfully: ${requestId}`);
|
||||
|
||||
// Update UI
|
||||
this.updateBadge();
|
||||
this.notifyPopupOfRequestUpdate();
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to reject request ${requestId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending requests from WASM (filtered by current workspace)
|
||||
* @returns {Promise<Array>} - Array of pending requests for current workspace
|
||||
*/
|
||||
async getPendingRequests() {
|
||||
return this.getFilteredRequests();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered requests from WASM (workspace-aware)
|
||||
* @returns {Promise<Array>} - Array of filtered requests
|
||||
*/
|
||||
async getFilteredRequests() {
|
||||
try {
|
||||
if (!this.wasmModule?.SigSocketManager) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const requestsJson = await this.wasmModule.SigSocketManager.get_filtered_requests();
|
||||
const requests = JSON.parse(requestsJson);
|
||||
|
||||
console.log(`📋 Retrieved ${requests.length} filtered requests for current workspace`);
|
||||
return requests;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to get filtered requests:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request can be approved (keyspace validation)
|
||||
* @param {string} requestId - Request ID to check
|
||||
* @returns {Promise<boolean>} - True if can be approved
|
||||
*/
|
||||
async canApproveRequest(requestId) {
|
||||
try {
|
||||
if (!this.wasmModule?.SigSocketManager) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.wasmModule.SigSocketManager.can_approve_request(requestId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to check request approval status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show notification for new sign request
|
||||
*/
|
||||
showSignRequestNotification() {
|
||||
try {
|
||||
if (chrome.notifications && chrome.notifications.create) {
|
||||
chrome.notifications.create({
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon48.png',
|
||||
title: 'SigSocket Sign Request',
|
||||
message: 'New signature request received. Click to review.'
|
||||
});
|
||||
console.log('📢 Notification shown for sign request');
|
||||
} else {
|
||||
console.log('📢 Notifications not available, skipping notification');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to show notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update extension badge with pending request count
|
||||
*/
|
||||
async updateBadge() {
|
||||
try {
|
||||
const requests = await this.getPendingRequests();
|
||||
const count = requests.length;
|
||||
const badgeText = count > 0 ? count.toString() : '';
|
||||
|
||||
console.log(`🔢 Updating badge: ${count} pending requests`);
|
||||
|
||||
chrome.action.setBadgeText({ text: badgeText });
|
||||
chrome.action.setBadgeBackgroundColor({ color: '#ff6b6b' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update badge:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify popup about new request
|
||||
*/
|
||||
async notifyPopupOfNewRequest() {
|
||||
if (!this.popupPort) {
|
||||
console.log('No popup connected, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const requests = await this.getPendingRequests();
|
||||
const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : false;
|
||||
|
||||
this.popupPort.postMessage({
|
||||
type: 'NEW_SIGN_REQUEST',
|
||||
canApprove,
|
||||
pendingRequests: requests
|
||||
});
|
||||
|
||||
console.log(`📤 Notified popup: ${requests.length} requests, canApprove: ${canApprove}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to notify popup:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify popup about request updates
|
||||
*/
|
||||
async notifyPopupOfRequestUpdate() {
|
||||
if (!this.popupPort) return;
|
||||
|
||||
try {
|
||||
const requests = await this.getPendingRequests();
|
||||
|
||||
this.popupPort.postMessage({
|
||||
type: 'REQUESTS_UPDATED',
|
||||
pendingRequests: requests
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to notify popup of update:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from SigSocket server
|
||||
* WASM handles all disconnection logic
|
||||
*/
|
||||
async disconnect() {
|
||||
try {
|
||||
if (this.wasmModule?.SigSocketManager) {
|
||||
await this.wasmModule.SigSocketManager.disconnect();
|
||||
}
|
||||
|
||||
// Clear local state
|
||||
this.isConnected = false;
|
||||
this.currentWorkspace = null;
|
||||
this.connectedPublicKey = null;
|
||||
|
||||
this.updateBadge();
|
||||
|
||||
console.log('🔌 SigSocket disconnection requested');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status from WASM
|
||||
* @returns {Promise<Object>} - Connection status information
|
||||
*/
|
||||
async getStatus() {
|
||||
try {
|
||||
if (!this.wasmModule?.SigSocketManager) {
|
||||
return {
|
||||
isConnected: false,
|
||||
workspace: null,
|
||||
publicKey: null,
|
||||
pendingRequestCount: 0,
|
||||
serverUrl: this.defaultServerUrl
|
||||
};
|
||||
}
|
||||
|
||||
// Let WASM provide the authoritative status
|
||||
const statusJson = await this.wasmModule.SigSocketManager.get_connection_status();
|
||||
const status = JSON.parse(statusJson);
|
||||
const requests = await this.getPendingRequests();
|
||||
|
||||
return {
|
||||
isConnected: status.is_connected,
|
||||
workspace: status.workspace,
|
||||
publicKey: status.public_key,
|
||||
pendingRequestCount: requests.length,
|
||||
serverUrl: this.defaultServerUrl
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to get status:', error);
|
||||
return {
|
||||
isConnected: false,
|
||||
workspace: null,
|
||||
publicKey: null,
|
||||
pendingRequestCount: 0,
|
||||
serverUrl: this.defaultServerUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the popup port for communication
|
||||
* @param {chrome.runtime.Port} port - The popup port
|
||||
*/
|
||||
setPopupPort(port) {
|
||||
this.popupPort = port;
|
||||
console.log('📱 Popup connected to SigSocket service');
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when keyspace is unlocked - notify popup of current state
|
||||
*/
|
||||
async onKeypaceUnlocked() {
|
||||
if (!this.popupPort) return;
|
||||
|
||||
try {
|
||||
const requests = await this.getPendingRequests();
|
||||
const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : false;
|
||||
|
||||
this.popupPort.postMessage({
|
||||
type: 'KEYSPACE_UNLOCKED',
|
||||
canApprove,
|
||||
pendingRequests: requests
|
||||
});
|
||||
|
||||
console.log(`🔓 Keyspace unlocked notification sent: ${requests.length} requests, canApprove: ${canApprove}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle keyspace unlock:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in background script
|
||||
export default SigSocketService;
|
75
crypto_vault_extension/demo/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Mock SigSocket Server Demo
|
||||
|
||||
This directory contains a mock SigSocket server for testing the browser extension functionality.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the mock server:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
The server will listen on `ws://localhost:8080/ws`
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Commands
|
||||
|
||||
Once the server is running, you can use these commands:
|
||||
|
||||
- `test` - Send a test sign request to all connected clients
|
||||
- `status` - Show server status and connected clients
|
||||
- `quit` - Shutdown the server
|
||||
|
||||
### Testing Flow
|
||||
|
||||
1. Start the mock server
|
||||
2. Load the browser extension in Chrome
|
||||
3. Create a keyspace and keypair in the extension
|
||||
4. The extension should automatically connect to the server
|
||||
5. The server will send a test sign request after 3 seconds
|
||||
6. Use the extension popup to approve or reject the request
|
||||
7. The server will log the response and send another request after 10 seconds
|
||||
|
||||
### Expected Output
|
||||
|
||||
When a client connects:
|
||||
```
|
||||
New WebSocket connection from: ::1
|
||||
Received message: 04a8b2c3d4e5f6...
|
||||
Client registered: client_1234567890_abc123 with public key: 04a8b2c3d4e5f6...
|
||||
📝 Sending sign request to client_1234567890_abc123: req_1_1234567890
|
||||
Message: "Test message 1 - 2024-01-01T12:00:00.000Z"
|
||||
```
|
||||
|
||||
When a sign response is received:
|
||||
```
|
||||
Received sign response from client_1234567890_abc123: {
|
||||
id: 'req_1_1234567890',
|
||||
message: 'VGVzdCBtZXNzYWdlIDEgLSAyMDI0LTAxLTAxVDEyOjAwOjAwLjAwMFo=',
|
||||
signature: '3045022100...'
|
||||
}
|
||||
✅ Sign request req_1_1234567890 completed successfully
|
||||
Signature: 3045022100...
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The mock server implements a simplified version of the SigSocket protocol:
|
||||
|
||||
1. **Client Introduction**: Client sends hex-encoded public key
|
||||
2. **Welcome Message**: Server responds with welcome JSON
|
||||
3. **Sign Requests**: Server sends JSON with `id` and `message` (base64)
|
||||
4. **Sign Responses**: Client sends JSON with `id`, `message`, and `signature`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Connection refused**: Make sure the server is running on port 8080
|
||||
- **No sign requests**: Check that the extension is properly connected
|
||||
- **Extension errors**: Check the browser console for JavaScript errors
|
||||
- **WASM errors**: Ensure the WASM files are properly built and loaded
|
232
crypto_vault_extension/demo/mock_sigsocket_server.js
Normal file
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Mock SigSocket Server for Testing Browser Extension
|
||||
*
|
||||
* This is a simple WebSocket server that simulates the SigSocket protocol
|
||||
* for testing the browser extension functionality.
|
||||
*
|
||||
* Usage:
|
||||
* node mock_sigsocket_server.js
|
||||
*
|
||||
* The server will listen on ws://localhost:8080/ws
|
||||
*/
|
||||
|
||||
const WebSocket = require('ws');
|
||||
const http = require('http');
|
||||
|
||||
class MockSigSocketServer {
|
||||
constructor(port = 8080) {
|
||||
this.port = port;
|
||||
this.clients = new Map(); // clientId -> { ws, publicKey }
|
||||
this.requestCounter = 0;
|
||||
|
||||
this.setupServer();
|
||||
}
|
||||
|
||||
setupServer() {
|
||||
// Create HTTP server
|
||||
this.httpServer = http.createServer();
|
||||
|
||||
// Create WebSocket server
|
||||
this.wss = new WebSocket.Server({
|
||||
server: this.httpServer,
|
||||
path: '/ws'
|
||||
});
|
||||
|
||||
this.wss.on('connection', (ws, req) => {
|
||||
console.log('New WebSocket connection from:', req.socket.remoteAddress);
|
||||
this.handleConnection(ws);
|
||||
});
|
||||
|
||||
this.httpServer.listen(this.port, () => {
|
||||
console.log(`Mock SigSocket Server listening on ws://localhost:${this.port}/ws`);
|
||||
console.log('Waiting for browser extension connections...');
|
||||
});
|
||||
}
|
||||
|
||||
handleConnection(ws) {
|
||||
let clientId = null;
|
||||
let publicKey = null;
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = data.toString();
|
||||
console.log('Received message:', message);
|
||||
|
||||
// Check if this is a client introduction (hex-encoded public key)
|
||||
if (!clientId && this.isHexString(message)) {
|
||||
publicKey = message;
|
||||
clientId = this.generateClientId();
|
||||
|
||||
this.clients.set(clientId, { ws, publicKey });
|
||||
|
||||
console.log(`Client registered: ${clientId} with public key: ${publicKey.substring(0, 16)}...`);
|
||||
|
||||
// Send welcome message
|
||||
ws.send(JSON.stringify({
|
||||
type: 'welcome',
|
||||
clientId: clientId,
|
||||
message: 'Connected to Mock SigSocket Server'
|
||||
}));
|
||||
|
||||
// Schedule a test sign request after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.sendTestSignRequest(clientId);
|
||||
}, 3000);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse as JSON (sign response)
|
||||
try {
|
||||
const jsonMessage = JSON.parse(message);
|
||||
this.handleSignResponse(clientId, jsonMessage);
|
||||
} catch (e) {
|
||||
console.log('Received non-JSON message:', message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error handling message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (clientId) {
|
||||
this.clients.delete(clientId);
|
||||
console.log(`Client disconnected: ${clientId}`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
handleSignResponse(clientId, response) {
|
||||
console.log(`Received sign response from ${clientId}:`, response);
|
||||
|
||||
if (response.id && response.signature) {
|
||||
console.log(`✅ Sign request ${response.id} completed successfully`);
|
||||
console.log(` Signature: ${response.signature.substring(0, 32)}...`);
|
||||
|
||||
// Send another test request after 10 seconds
|
||||
setTimeout(() => {
|
||||
this.sendTestSignRequest(clientId);
|
||||
}, 10000);
|
||||
} else {
|
||||
console.log('❌ Invalid sign response format');
|
||||
}
|
||||
}
|
||||
|
||||
sendTestSignRequest(clientId) {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) {
|
||||
console.log(`Client ${clientId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.requestCounter++;
|
||||
const requestId = `req_${this.requestCounter}_${Date.now()}`;
|
||||
const testMessage = `Test message ${this.requestCounter} - ${new Date().toISOString()}`;
|
||||
const messageBase64 = Buffer.from(testMessage).toString('base64');
|
||||
|
||||
const signRequest = {
|
||||
id: requestId,
|
||||
message: messageBase64
|
||||
};
|
||||
|
||||
console.log(`📝 Sending sign request to ${clientId}:`, requestId);
|
||||
console.log(` Message: "${testMessage}"`);
|
||||
|
||||
try {
|
||||
client.ws.send(JSON.stringify(signRequest));
|
||||
} catch (error) {
|
||||
console.error(`Failed to send sign request to ${clientId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
isHexString(str) {
|
||||
return /^[0-9a-fA-F]+$/.test(str) && str.length >= 32; // At least 16 bytes
|
||||
}
|
||||
|
||||
generateClientId() {
|
||||
return `client_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
||||
}
|
||||
|
||||
// Send a test request to all connected clients
|
||||
broadcastTestRequest() {
|
||||
console.log('\n📢 Broadcasting test sign request to all clients...');
|
||||
for (const [clientId] of this.clients) {
|
||||
this.sendTestSignRequest(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
// Get server status
|
||||
getStatus() {
|
||||
return {
|
||||
port: this.port,
|
||||
connectedClients: this.clients.size,
|
||||
clients: Array.from(this.clients.keys())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create and start the server
|
||||
const server = new MockSigSocketServer();
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n🛑 Shutting down Mock SigSocket Server...');
|
||||
server.httpServer.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Add some interactive commands
|
||||
process.stdin.setEncoding('utf8');
|
||||
console.log('\n📋 Available commands:');
|
||||
console.log(' "test" - Send test sign request to all clients');
|
||||
console.log(' "status" - Show server status');
|
||||
console.log(' "quit" - Shutdown server');
|
||||
console.log(' Type a command and press Enter\n');
|
||||
|
||||
process.stdin.on('readable', () => {
|
||||
const chunk = process.stdin.read();
|
||||
if (chunk !== null) {
|
||||
const command = chunk.trim().toLowerCase();
|
||||
|
||||
switch (command) {
|
||||
case 'test':
|
||||
server.broadcastTestRequest();
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
const status = server.getStatus();
|
||||
console.log('\n📊 Server Status:');
|
||||
console.log(` Port: ${status.port}`);
|
||||
console.log(` Connected clients: ${status.connectedClients}`);
|
||||
if (status.clients.length > 0) {
|
||||
console.log(` Client IDs: ${status.clients.join(', ')}`);
|
||||
}
|
||||
console.log('');
|
||||
break;
|
||||
|
||||
case 'quit':
|
||||
case 'exit':
|
||||
process.emit('SIGINT');
|
||||
break;
|
||||
|
||||
case '':
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`Unknown command: ${command}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
module.exports = MockSigSocketServer;
|
21
crypto_vault_extension/demo/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "mock-sigsocket-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Mock SigSocket server for testing browser extension",
|
||||
"main": "mock_sigsocket_server.js",
|
||||
"scripts": {
|
||||
"start": "node mock_sigsocket_server.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.14.0"
|
||||
},
|
||||
"keywords": [
|
||||
"websocket",
|
||||
"sigsocket",
|
||||
"testing",
|
||||
"mock"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
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;
|
48
crypto_vault_extension/manifest.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "CryptoVault",
|
||||
"version": "1.0.0",
|
||||
"description": "Secure cryptographic key management and signing in your browser",
|
||||
|
||||
"permissions": [
|
||||
"storage",
|
||||
"activeTab",
|
||||
"notifications"
|
||||
],
|
||||
|
||||
"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';"
|
||||
}
|
||||
}
|
247
crypto_vault_extension/popup.html
Normal file
@@ -0,0 +1,247 @@
|
||||
<!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>
|
||||
|
||||
<!-- SigSocket Requests Section -->
|
||||
<div class="card sigsocket-section" id="sigSocketSection">
|
||||
<div class="section-header">
|
||||
<h3>🔌 SigSocket Requests</h3>
|
||||
<div class="connection-status" id="connectionStatus">
|
||||
<span class="status-dot" id="connectionDot"></span>
|
||||
<span id="connectionText">Disconnected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="requests-container" id="requestsContainer">
|
||||
<div class="no-requests" id="noRequestsMessage">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📝</div>
|
||||
<p>No pending sign requests</p>
|
||||
<small>Requests will appear here when received from SigSocket server</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="requests-list hidden" id="requestsList">
|
||||
<!-- Requests will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sigsocket-actions">
|
||||
<button id="refreshRequestsBtn" class="btn btn-ghost btn-small">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
<polyline points="1 20 1 14 7 14"></polyline>
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
|
||||
</svg>
|
||||
Refresh
|
||||
</button>
|
||||
<button id="sigSocketStatusBtn" class="btn btn-ghost btn-small">
|
||||
<svg width="16" height="16" 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="12" y1="6" x2="12" y2="12"></line>
|
||||
<line x1="16" y1="16" x2="12" y2="12"></line>
|
||||
</svg>
|
||||
Status
|
||||
</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>
|
1223
crypto_vault_extension/popup.js
Normal file
443
crypto_vault_extension/popup/components/SignRequestManager.js
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Sign Request Manager Component
|
||||
*
|
||||
* Handles the display and management of SigSocket sign requests in the popup.
|
||||
* Manages different UI states:
|
||||
* 1. Keyspace locked: Show unlock form
|
||||
* 2. Wrong keyspace: Show mismatch message
|
||||
* 3. Correct keyspace: Show approval UI
|
||||
*/
|
||||
|
||||
class SignRequestManager {
|
||||
constructor() {
|
||||
this.pendingRequests = [];
|
||||
this.isKeypaceUnlocked = false;
|
||||
this.keypaceMatch = false;
|
||||
this.connectionStatus = { isConnected: false };
|
||||
|
||||
this.container = null;
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component
|
||||
* @param {HTMLElement} container - Container element to render into
|
||||
*/
|
||||
async initialize(container) {
|
||||
this.container = container;
|
||||
this.initialized = true;
|
||||
|
||||
// Load initial state
|
||||
await this.loadState();
|
||||
|
||||
// Render initial UI
|
||||
this.render();
|
||||
|
||||
// Set up event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Listen for background messages
|
||||
this.setupBackgroundListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current state from background script
|
||||
*/
|
||||
async loadState() {
|
||||
try {
|
||||
// Check if keyspace is unlocked
|
||||
const unlockedResponse = await this.sendMessage('isUnlocked');
|
||||
this.isKeypaceUnlocked = unlockedResponse?.unlocked || false;
|
||||
|
||||
// Get pending requests
|
||||
const requestsResponse = await this.sendMessage('getPendingRequests');
|
||||
this.pendingRequests = requestsResponse?.requests || [];
|
||||
|
||||
// Get SigSocket status
|
||||
const statusResponse = await this.sendMessage('getSigSocketStatus');
|
||||
this.connectionStatus = statusResponse?.status || { isConnected: false };
|
||||
|
||||
// If keyspace is unlocked, notify background to check keyspace match
|
||||
if (this.isKeypaceUnlocked) {
|
||||
await this.sendMessage('keypaceUnlocked');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load sign request state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component UI
|
||||
*/
|
||||
render() {
|
||||
if (!this.container) return;
|
||||
|
||||
const hasRequests = this.pendingRequests.length > 0;
|
||||
|
||||
if (!hasRequests) {
|
||||
this.renderNoRequests();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isKeypaceUnlocked) {
|
||||
this.renderUnlockPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.keypaceMatch) {
|
||||
this.renderKeypaceMismatch();
|
||||
return;
|
||||
}
|
||||
|
||||
this.renderApprovalUI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render no requests state
|
||||
*/
|
||||
renderNoRequests() {
|
||||
this.container.innerHTML = `
|
||||
<div class="sign-request-manager">
|
||||
<div class="connection-status ${this.connectionStatus.isConnected ? 'connected' : 'disconnected'}">
|
||||
<span class="status-indicator"></span>
|
||||
SigSocket: ${this.connectionStatus.isConnected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
<div class="no-requests">
|
||||
<p>No pending sign requests</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render unlock prompt
|
||||
*/
|
||||
renderUnlockPrompt() {
|
||||
const requestCount = this.pendingRequests.length;
|
||||
this.container.innerHTML = `
|
||||
<div class="sign-request-manager">
|
||||
<div class="connection-status ${this.connectionStatus.isConnected ? 'connected' : 'disconnected'}">
|
||||
<span class="status-indicator"></span>
|
||||
SigSocket: ${this.connectionStatus.isConnected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
<div class="unlock-prompt">
|
||||
<h3>🔒 Unlock Keyspace</h3>
|
||||
<p>Unlock your keyspace to see ${requestCount} pending sign request${requestCount !== 1 ? 's' : ''}.</p>
|
||||
<p class="hint">Use the login form above to unlock your keyspace.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render keyspace mismatch message
|
||||
*/
|
||||
renderKeypaceMismatch() {
|
||||
this.container.innerHTML = `
|
||||
<div class="sign-request-manager">
|
||||
<div class="connection-status ${this.connectionStatus.isConnected ? 'connected' : 'disconnected'}">
|
||||
<span class="status-indicator"></span>
|
||||
SigSocket: ${this.connectionStatus.isConnected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
<div class="keyspace-mismatch">
|
||||
<h3>⚠️ Wrong Keyspace</h3>
|
||||
<p>The unlocked keyspace doesn't match the connected SigSocket session.</p>
|
||||
<p class="hint">Please unlock the correct keyspace to approve sign requests.</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render approval UI with pending requests
|
||||
*/
|
||||
renderApprovalUI() {
|
||||
const requestsHtml = this.pendingRequests.map(request => this.renderSignRequestCard(request)).join('');
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="sign-request-manager">
|
||||
<div class="connection-status connected">
|
||||
<span class="status-indicator"></span>
|
||||
SigSocket: Connected
|
||||
</div>
|
||||
<div class="requests-header">
|
||||
<h3>📝 Sign Requests (${this.pendingRequests.length})</h3>
|
||||
</div>
|
||||
<div class="requests-list">
|
||||
${requestsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render individual sign request card
|
||||
* @param {Object} request - Sign request data
|
||||
* @returns {string} - HTML string for the request card
|
||||
*/
|
||||
renderSignRequestCard(request) {
|
||||
const timestamp = new Date(request.timestamp).toLocaleTimeString();
|
||||
const messagePreview = this.getMessagePreview(request.message);
|
||||
|
||||
return `
|
||||
<div class="sign-request-card" data-request-id="${request.id}">
|
||||
<div class="request-header">
|
||||
<div class="request-id">Request: ${request.id.substring(0, 8)}...</div>
|
||||
<div class="request-time">${timestamp}</div>
|
||||
</div>
|
||||
<div class="request-message">
|
||||
<label>Message:</label>
|
||||
<div class="message-content">
|
||||
<div class="message-preview">${messagePreview}</div>
|
||||
<button class="expand-message" data-request-id="${request.id}">
|
||||
<span class="expand-text">Show Full</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="request-actions">
|
||||
<button class="btn-reject" data-request-id="${request.id}">
|
||||
❌ Reject
|
||||
</button>
|
||||
<button class="btn-approve" data-request-id="${request.id}">
|
||||
✅ Approve & Sign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a preview of the message content
|
||||
* @param {string} messageBase64 - Base64 encoded message
|
||||
* @returns {string} - Preview text
|
||||
*/
|
||||
getMessagePreview(messageBase64) {
|
||||
try {
|
||||
const decoded = atob(messageBase64);
|
||||
const preview = decoded.length > 50 ? decoded.substring(0, 50) + '...' : decoded;
|
||||
return preview;
|
||||
} catch (error) {
|
||||
return `Base64: ${messageBase64.substring(0, 20)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
if (!this.container) return;
|
||||
|
||||
// Use event delegation for dynamic content
|
||||
this.container.addEventListener('click', (e) => {
|
||||
const target = e.target;
|
||||
|
||||
if (target.classList.contains('btn-approve')) {
|
||||
const requestId = target.getAttribute('data-request-id');
|
||||
this.approveRequest(requestId);
|
||||
} else if (target.classList.contains('btn-reject')) {
|
||||
const requestId = target.getAttribute('data-request-id');
|
||||
this.rejectRequest(requestId);
|
||||
} else if (target.classList.contains('expand-message')) {
|
||||
const requestId = target.getAttribute('data-request-id');
|
||||
this.toggleMessageExpansion(requestId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up listener for background script messages
|
||||
*/
|
||||
setupBackgroundListener() {
|
||||
// Listen for keyspace unlock events
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'KEYSPACE_UNLOCKED') {
|
||||
this.isKeypaceUnlocked = true;
|
||||
this.keypaceMatch = message.keypaceMatches;
|
||||
this.pendingRequests = message.pendingRequests || [];
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a sign request
|
||||
* @param {string} requestId - Request ID to approve
|
||||
*/
|
||||
async approveRequest(requestId) {
|
||||
try {
|
||||
const button = this.container.querySelector(`[data-request-id="${requestId}"].btn-approve`);
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Signing...';
|
||||
}
|
||||
|
||||
const response = await this.sendMessage('approveSignRequest', { requestId });
|
||||
|
||||
if (response?.success) {
|
||||
// Remove the request from UI
|
||||
this.pendingRequests = this.pendingRequests.filter(r => r.id !== requestId);
|
||||
this.render();
|
||||
|
||||
// Show success message
|
||||
this.showToast('Sign request approved successfully!', 'success');
|
||||
} else {
|
||||
throw new Error(response?.error || 'Failed to approve request');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to approve request:', error);
|
||||
this.showToast('Failed to approve request: ' + error.message, 'error');
|
||||
|
||||
// Re-enable button
|
||||
const button = this.container.querySelector(`[data-request-id="${requestId}"].btn-approve`);
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = '✅ Approve & Sign';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a sign request
|
||||
* @param {string} requestId - Request ID to reject
|
||||
*/
|
||||
async rejectRequest(requestId) {
|
||||
try {
|
||||
const button = this.container.querySelector(`[data-request-id="${requestId}"].btn-reject`);
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Rejecting...';
|
||||
}
|
||||
|
||||
const response = await this.sendMessage('rejectSignRequest', {
|
||||
requestId,
|
||||
reason: 'User rejected'
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
// Remove the request from UI
|
||||
this.pendingRequests = this.pendingRequests.filter(r => r.id !== requestId);
|
||||
this.render();
|
||||
|
||||
// Show success message
|
||||
this.showToast('Sign request rejected', 'info');
|
||||
} else {
|
||||
throw new Error(response?.error || 'Failed to reject request');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to reject request:', error);
|
||||
this.showToast('Failed to reject request: ' + error.message, 'error');
|
||||
|
||||
// Re-enable button
|
||||
const button = this.container.querySelector(`[data-request-id="${requestId}"].btn-reject`);
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = '❌ Reject';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle message expansion
|
||||
* @param {string} requestId - Request ID
|
||||
*/
|
||||
toggleMessageExpansion(requestId) {
|
||||
const request = this.pendingRequests.find(r => r.id === requestId);
|
||||
if (!request) return;
|
||||
|
||||
const card = this.container.querySelector(`[data-request-id="${requestId}"]`);
|
||||
const messageContent = card.querySelector('.message-content');
|
||||
const expandButton = card.querySelector('.expand-message');
|
||||
|
||||
const isExpanded = messageContent.classList.contains('expanded');
|
||||
|
||||
if (isExpanded) {
|
||||
messageContent.classList.remove('expanded');
|
||||
messageContent.querySelector('.message-preview').textContent = this.getMessagePreview(request.message);
|
||||
expandButton.querySelector('.expand-text').textContent = 'Show Full';
|
||||
} else {
|
||||
messageContent.classList.add('expanded');
|
||||
try {
|
||||
const fullMessage = atob(request.message);
|
||||
messageContent.querySelector('.message-preview').textContent = fullMessage;
|
||||
} catch (error) {
|
||||
messageContent.querySelector('.message-preview').textContent = `Base64: ${request.message}`;
|
||||
}
|
||||
expandButton.querySelector('.expand-text').textContent = 'Show Less';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to background script
|
||||
* @param {string} action - Action to perform
|
||||
* @param {Object} data - Additional data
|
||||
* @returns {Promise<Object>} - Response from background script
|
||||
*/
|
||||
async sendMessage(action, data = {}) {
|
||||
return new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage({ action, ...data }, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show toast notification
|
||||
* @param {string} message - Message to show
|
||||
* @param {string} type - Toast type (success, error, info)
|
||||
*/
|
||||
showToast(message, type = 'info') {
|
||||
// Use the existing toast system from popup.js
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(message, type);
|
||||
} else {
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update component state
|
||||
* @param {Object} newState - New state data
|
||||
*/
|
||||
updateState(newState) {
|
||||
console.log('SignRequestManager.updateState called with:', newState);
|
||||
console.log('Current state before update:', {
|
||||
isKeypaceUnlocked: this.isKeypaceUnlocked,
|
||||
keypaceMatch: this.keypaceMatch,
|
||||
pendingRequests: this.pendingRequests.length
|
||||
});
|
||||
|
||||
Object.assign(this, newState);
|
||||
|
||||
// Fix the property name mismatch
|
||||
if (newState.keypaceMatches !== undefined) {
|
||||
this.keypaceMatch = newState.keypaceMatches;
|
||||
}
|
||||
|
||||
console.log('State after update:', {
|
||||
isKeypaceUnlocked: this.isKeypaceUnlocked,
|
||||
keypaceMatch: this.keypaceMatch,
|
||||
pendingRequests: this.pendingRequests.length
|
||||
});
|
||||
|
||||
if (this.initialized) {
|
||||
console.log('Rendering SignRequestManager with new state');
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh component data
|
||||
*/
|
||||
async refresh() {
|
||||
await this.loadState();
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in popup
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = SignRequestManager;
|
||||
} else {
|
||||
window.SignRequestManager = SignRequestManager;
|
||||
}
|
1251
crypto_vault_extension/styles/popup.css
Normal file
114
crypto_vault_extension/test_extension.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Testing the SigSocket Browser Extension
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **SigSocket Server**: You need a running SigSocket server at `ws://localhost:8080/ws`
|
||||
2. **Browser**: Chrome or Chromium-based browser with developer mode enabled
|
||||
|
||||
## Test Steps
|
||||
|
||||
### 1. Load the Extension
|
||||
|
||||
1. Open Chrome and go to `chrome://extensions/`
|
||||
2. Enable "Developer mode" in the top right
|
||||
3. Click "Load unpacked" and select the `crypto_vault_extension` directory
|
||||
4. The CryptoVault extension should appear in your extensions list
|
||||
|
||||
### 2. Basic Functionality Test
|
||||
|
||||
1. Click the CryptoVault extension icon in the toolbar
|
||||
2. Create a new keyspace:
|
||||
- Enter a keyspace name (e.g., "test-workspace")
|
||||
- Enter a password
|
||||
- Click "Create New"
|
||||
3. The extension should automatically connect to the SigSocket server
|
||||
4. Add a keypair:
|
||||
- Click "Add Keypair"
|
||||
- Enter a name for the keypair
|
||||
- Click "Create Keypair"
|
||||
|
||||
### 3. SigSocket Integration Test
|
||||
|
||||
1. **Check Connection Status**:
|
||||
- Look for the SigSocket connection status at the bottom of the popup
|
||||
- It should show "SigSocket: Connected" with a green indicator
|
||||
|
||||
2. **Test Sign Request Flow**:
|
||||
- Send a sign request to the SigSocket server (you'll need to implement this on the server side)
|
||||
- The extension should show a notification
|
||||
- The extension badge should show the number of pending requests
|
||||
- Open the extension popup to see the sign request
|
||||
|
||||
3. **Test Approval Flow**:
|
||||
- If keyspace is locked, you should see "Unlock keyspace to see X pending requests"
|
||||
- Unlock the keyspace using the login form
|
||||
- You should see the sign request details
|
||||
- Click "Approve & Sign" to approve the request
|
||||
- The request should be signed and sent back to the server
|
||||
|
||||
### 4. Settings Test
|
||||
|
||||
1. Click the settings gear icon in the extension popup
|
||||
2. Change the SigSocket server URL if needed
|
||||
3. Adjust the session timeout if desired
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
- ✅ Extension loads without errors
|
||||
- ✅ Can create keyspaces and keypairs
|
||||
- ✅ SigSocket connection is established automatically
|
||||
- ✅ Sign requests are received and displayed
|
||||
- ✅ Approval flow works correctly
|
||||
- ✅ Settings can be configured
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Extension won't load**: Check the console for JavaScript errors
|
||||
2. **SigSocket won't connect**: Verify the server is running and the URL is correct
|
||||
3. **WASM errors**: Check that the WASM files are properly built and copied
|
||||
4. **Sign requests not appearing**: Check the browser console for callback errors
|
||||
|
||||
### Debug Steps
|
||||
|
||||
1. Open Chrome DevTools
|
||||
2. Go to the Extensions tab
|
||||
3. Find CryptoVault and click "Inspect views: background page"
|
||||
4. Check the console for any errors
|
||||
5. Also inspect the popup by right-clicking the extension icon and selecting "Inspect popup"
|
||||
|
||||
## Server-Side Testing
|
||||
|
||||
To fully test the extension, you'll need a SigSocket server that can:
|
||||
|
||||
1. Accept WebSocket connections at `/ws`
|
||||
2. Handle client introduction messages (hex-encoded public keys)
|
||||
3. Send sign requests in the format:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "unique-request-id",
|
||||
"message": "base64-encoded-message"
|
||||
}
|
||||
```
|
||||
|
||||
4. Receive sign responses in the format:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "request-id",
|
||||
"message": "base64-encoded-message",
|
||||
"signature": "base64-encoded-signature"
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
If basic functionality works:
|
||||
|
||||
1. Test with multiple concurrent sign requests
|
||||
2. Test connection recovery after network issues
|
||||
3. Test with different keyspace configurations
|
||||
4. Test the rejection flow
|
||||
5. Test session timeout behavior
|
1398
crypto_vault_extension/wasm/wasm_app.js
Normal file
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.**
|
||||
- **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.
|
||||
|
||||
---
|
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"
|
||||
|
||||
[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"
|
||||
alloy = "0.6"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "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)]
|
||||
pub enum EvmError {
|
||||
#[error("RPC error: {0}")]
|
||||
Rpc(String),
|
||||
#[error("Vault error: {0}")]
|
||||
Vault(String),
|
||||
//! evm_client: Minimal EVM JSON-RPC client abstraction
|
||||
|
||||
//! evm_client: Minimal EVM JSON-RPC client abstraction
|
||||
|
||||
//! evm_client: Minimal EVM JSON-RPC client abstraction
|
||||
|
||||
//! 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 {
|
||||
// ... fields for RPC, vault, etc.
|
||||
pub provider: Provider,
|
||||
}
|
||||
|
||||
impl EvmClient {
|
||||
pub async fn connect(_rpc_url: &str) -> Result<Self, EvmError> {
|
||||
todo!("Implement connect")
|
||||
pub fn new(provider: Provider) -> Self {
|
||||
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};
|
||||
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;
|
||||
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
|
||||
}
|
||||
|
36
evm_client/src/rhai_sync_helpers.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
//! 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;
|
||||
|
||||
#[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)
|
||||
}
|
48
evm_client/tests/balance.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
// 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 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);
|
||||
}
|
1
hero_vault_extension/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist
|
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)}
|
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()});
|
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"
|
||||
|
||||
[dependencies]
|
||||
instant = { version = "0.1", features = ["wasm-bindgen"] }
|
||||
tokio = { version = "1.37", features = ["rt", "macros"] }
|
||||
async-trait = "0.1"
|
||||
sled = { version = "0.34", optional = true }
|
||||
idb = { version = "0.4", optional = true }
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
wasm-bindgen = { version = "0.2.92", features = ["serde-serialize"] }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
thiserror = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
sled = { version = "0.34" }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
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]
|
||||
default = []
|
||||
native = ["sled", "tokio"]
|
||||
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"
|
||||
native = []
|
||||
|
@@ -22,3 +22,12 @@ pub enum 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
|
||||
//!
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
//! **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`).
|
||||
//!
|
||||
@@ -10,11 +11,18 @@
|
||||
//! # Example
|
||||
//!
|
||||
|
||||
use crate::traits::KVStore;
|
||||
use crate::error::{KVError, Result};
|
||||
//! Native backend for kvstore using sled
|
||||
//! 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;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use sled::Db;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
|
@@ -16,6 +16,11 @@ use crate::error::Result;
|
||||
/// - contains_key (was exists)
|
||||
/// - keys
|
||||
/// - 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 {
|
||||
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>>;
|
||||
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;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::error::{KVError, Result};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use async_trait::async_trait;
|
||||
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use idb::{Database, TransactionMode, Factory};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use wasm_bindgen::JsValue;
|
||||
// use wasm-bindgen directly for Uint8Array if needed
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use js_sys::Uint8Array;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const STORE_NAME: &str = "kv";
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(Clone)]
|
||||
pub struct WasmStore {
|
||||
db: Database,
|
||||
db: Rc<Database>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -41,13 +46,14 @@ impl WasmStore {
|
||||
let mut open_req = factory.open(name, None)
|
||||
.map_err(|e| KVError::Other(format!("IndexedDB factory open error: {e:?}")))?;
|
||||
open_req.on_upgrade_needed(|event| {
|
||||
use idb::DatabaseEvent;
|
||||
let db = event.database().expect("Failed to get database in upgrade event");
|
||||
if !db.store_names().iter().any(|n| n == STORE_NAME) {
|
||||
db.create_object_store(STORE_NAME, Default::default()).unwrap();
|
||||
}
|
||||
});
|
||||
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)
|
||||
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
||||
use idb::Query;
|
||||
let val = store.get(Query::from(JsValue::from_str(key))).await
|
||||
.map_err(|e| KVError::Other(format!("idb get await error: {e:?}")))?;
|
||||
let val = store.get(Query::from(JsValue::from_str(key)))?.await
|
||||
.map_err(|e| KVError::Other(format!("idb get error: {e:?}")))?;
|
||||
if let Some(jsval) = val {
|
||||
let arr = Uint8Array::new(&jsval);
|
||||
Ok(Some(arr.to_vec()))
|
||||
match jsval.into_serde::<Vec<u8>>() {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -74,8 +82,9 @@ impl KVStore for WasmStore {
|
||||
.map_err(|e| KVError::Other(format!("idb transaction error: {e:?}")))?;
|
||||
let store = tx.object_store(STORE_NAME)
|
||||
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
||||
store.put(&Uint8Array::from(value).into(), Some(&JsValue::from_str(key))).await
|
||||
.map_err(|e| KVError::Other(format!("idb put await error: {e:?}")))?;
|
||||
let js_value = JsValue::from_serde(&value).map_err(|e| KVError::Other(format!("serde error: {e:?}")))?;
|
||||
store.put(&js_value, Some(&JsValue::from_str(key)))?.await
|
||||
.map_err(|e| KVError::Other(format!("idb put error: {e:?}")))?;
|
||||
Ok(())
|
||||
}
|
||||
async fn remove(&self, key: &str) -> Result<()> {
|
||||
@@ -84,8 +93,8 @@ impl KVStore for WasmStore {
|
||||
let store = tx.object_store(STORE_NAME)
|
||||
.map_err(|e| KVError::Other(format!("idb object_store error: {e:?}")))?;
|
||||
use idb::Query;
|
||||
store.delete(Query::from(JsValue::from_str(key))).await
|
||||
.map_err(|e| KVError::Other(format!("idb delete await error: {e:?}")))?;
|
||||
store.delete(Query::from(JsValue::from_str(key)))?.await
|
||||
.map_err(|e| KVError::Other(format!("idb delete error: {e:?}")))?;
|
||||
Ok(())
|
||||
}
|
||||
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:?}")))?;
|
||||
let store = tx.object_store(STORE_NAME)
|
||||
.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:?}")))?;
|
||||
let arr = js_sys::Array::from(&JsValue::from(js_keys));
|
||||
let mut keys = Vec::new();
|
||||
for i in 0..arr.length() {
|
||||
if let Some(s) = arr.get(i).as_string() {
|
||||
for key in js_keys.iter() {
|
||||
if let Some(s) = key.as_string() {
|
||||
keys.push(s);
|
||||
}
|
||||
}
|
||||
@@ -114,7 +122,7 @@ impl KVStore for WasmStore {
|
||||
.map_err(|e| KVError::Other(format!("idb transaction error: {e:?}")))?;
|
||||
let store = tx.object_store(STORE_NAME)
|
||||
.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:?}")))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -122,6 +130,7 @@ impl KVStore for WasmStore {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct WasmStore;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[async_trait]
|
||||
impl KVStore for WasmStore {
|
||||
@@ -131,10 +140,16 @@ impl KVStore for WasmStore {
|
||||
async fn set(&self, _key: &str, _value: &[u8]) -> Result<()> {
|
||||
Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string()))
|
||||
}
|
||||
async fn delete(&self, _key: &str) -> Result<()> {
|
||||
async fn remove(&self, _key: &str) -> Result<()> {
|
||||
Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string()))
|
||||
}
|
||||
async fn exists(&self, _key: &str) -> Result<bool> {
|
||||
async fn contains_key(&self, _key: &str) -> Result<bool> {
|
||||
Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string()))
|
||||
}
|
||||
async fn keys(&self) -> Result<Vec<String>> {
|
||||
Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string()))
|
||||
}
|
||||
async fn clear(&self) -> Result<()> {
|
||||
Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string()))
|
||||
}
|
||||
}
|
||||
|