Compare commits
36 Commits
main
...
main_brows
Author | SHA1 | Date | |
---|---|---|---|
|
1d3d0a4fa4 | ||
|
4f3f98a954 | ||
|
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
2
.cargo/config.toml
Normal file
@ -0,0 +1,2 @@
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ["--cfg", 'getrandom_backend="wasm_js"']
|
4
.gitignore
vendored
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
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
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
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"
|
670
crypto_vault_extension/background.js
Normal file
670
crypto_vault_extension/background.js
Normal file
@ -0,0 +1,670 @@
|
||||
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();
|
||||
|
||||
// Keep the session info for SigSocket connection but mark it as timed out
|
||||
const keyspace = currentSession.keyspace;
|
||||
await sessionManager.clear();
|
||||
|
||||
// Maintain SigSocket connection for the locked keyspace to receive pending requests
|
||||
if (sigSocketService && keyspace) {
|
||||
try {
|
||||
// Keep SigSocket connected to receive requests even when locked
|
||||
console.log(`🔒 Session timed out but maintaining SigSocket connection for: ${keyspace}`);
|
||||
} catch (error) {
|
||||
console.warn('Failed to maintain SigSocket connection after timeout:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Connect to SigSocket for the restored session
|
||||
if (sigSocketService) {
|
||||
try {
|
||||
const connected = await sigSocketService.connectToServer(session.keyspace);
|
||||
if (connected) {
|
||||
console.log(`🔗 SigSocket reconnected for restored workspace: ${session.keyspace}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't show as warning if it's just "no workspace" - this is expected on fresh start
|
||||
if (error.message && error.message.includes('Workspace not found')) {
|
||||
console.log(`ℹ️ SigSocket connection skipped for restored session: No workspace available yet`);
|
||||
} else {
|
||||
console.warn('Failed to reconnect SigSocket for restored session:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return session;
|
||||
} else {
|
||||
// Session exists but is locked - still try to connect SigSocket to receive pending requests
|
||||
if (sigSocketService && session.keyspace) {
|
||||
try {
|
||||
const connected = await sigSocketService.connectToServer(session.keyspace);
|
||||
if (connected) {
|
||||
console.log(`🔗 SigSocket connected for locked workspace: ${session.keyspace} (will queue requests)`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't show as warning if it's just "no workspace" - this is expected on fresh start
|
||||
if (error.message && error.message.includes('Workspace not found')) {
|
||||
console.log(`ℹ️ SigSocket connection skipped for locked session: No workspace available yet`);
|
||||
} else {
|
||||
console.warn('Failed to connect SigSocket for locked session:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't clear the session - keep it for SigSocket connection
|
||||
// await sessionManager.clear();
|
||||
}
|
||||
}
|
||||
return session; // Return session even if locked, so we know which keyspace to use
|
||||
}
|
||||
|
||||
// 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 {
|
||||
console.log(`🔗 Initializing SigSocket connection for workspace: ${request.keyspace}`);
|
||||
|
||||
// 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}`);
|
||||
} else {
|
||||
console.warn(`⚠️ SigSocket connection failed for workspace: ${request.keyspace}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to auto-connect to SigSocket:', error);
|
||||
|
||||
// If connection fails, try once more after a short delay
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
console.log(`🔄 Retrying SigSocket connection for workspace: ${request.keyspace}`);
|
||||
await sigSocketService.connectToServer(request.keyspace);
|
||||
} catch (retryError) {
|
||||
console.warn('SigSocket retry connection also failed:', retryError);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Notify SigSocket service that keyspace is now unlocked
|
||||
await sigSocketService.onKeypaceUnlocked();
|
||||
}
|
||||
|
||||
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 };
|
||||
},
|
||||
|
||||
updateSigSocketUrl: async (request) => {
|
||||
if (sigSocketService) {
|
||||
// Update the server URL in the SigSocket service
|
||||
sigSocketService.defaultServerUrl = request.serverUrl;
|
||||
|
||||
// Save to storage (already done in popup, but ensure consistency)
|
||||
await chrome.storage.local.set({ sigSocketUrl: request.serverUrl });
|
||||
|
||||
console.log(`🔗 SigSocket server URL updated to: ${request.serverUrl}`);
|
||||
}
|
||||
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 };
|
||||
},
|
||||
|
||||
getSigSocketStatusWithTest: async () => {
|
||||
if (!sigSocketService) {
|
||||
return { success: false, error: 'SigSocket service not initialized' };
|
||||
}
|
||||
// Use the enhanced connection testing method
|
||||
const status = await sigSocketService.getStatusWithConnectionTest();
|
||||
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();
|
||||
}
|
||||
|
||||
// Handle messages from popup
|
||||
port.onMessage.addListener(async (message) => {
|
||||
if (message.type === 'REQUEST_IMMEDIATE_STATUS') {
|
||||
// Immediately send current SigSocket status to popup
|
||||
if (sigSocketService) {
|
||||
try {
|
||||
const status = await sigSocketService.getStatus();
|
||||
port.postMessage({
|
||||
type: 'CONNECTION_STATUS_CHANGED',
|
||||
status: status
|
||||
});
|
||||
console.log('📡 Sent immediate status to popup:', status.isConnected ? 'Connected' : 'Disconnected');
|
||||
} catch (error) {
|
||||
console.warn('Failed to send immediate status:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
// Popup closed, clear reference and stop keep-alive
|
||||
popupPort = null;
|
||||
stopKeepAlive();
|
||||
|
||||
// Disconnect SigSocket service from popup
|
||||
if (sigSocketService) {
|
||||
sigSocketService.setPopupPort(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Handle notification clicks to open extension (notifications are now clickable without buttons)
|
||||
chrome.notifications.onClicked.addListener(async (notificationId) => {
|
||||
console.log(`🔔 Notification clicked: ${notificationId}`);
|
||||
|
||||
// Check if this is a SigSocket notification
|
||||
if (notificationId.startsWith('sigsocket-request-')) {
|
||||
console.log('🔔 SigSocket notification clicked, opening extension...');
|
||||
try {
|
||||
await openExtensionPopup();
|
||||
// Clear the notification after successfully opening
|
||||
chrome.notifications.clear(notificationId);
|
||||
console.log('✅ Notification cleared after opening extension');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to handle notification click:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('🔔 Non-SigSocket notification clicked, ignoring');
|
||||
}
|
||||
});
|
||||
|
||||
// Note: Notification button handler removed - notifications are now clickable without buttons
|
||||
|
||||
// Function to open extension popup with best UX
|
||||
async function openExtensionPopup() {
|
||||
try {
|
||||
console.log('🔔 Opening extension popup from notification...');
|
||||
|
||||
// First, check if there's already a popup window open
|
||||
const windows = await chrome.windows.getAll({ populate: true });
|
||||
const existingPopup = windows.find(window =>
|
||||
window.type === 'popup' &&
|
||||
window.tabs?.some(tab => tab.url?.includes('popup.html'))
|
||||
);
|
||||
|
||||
if (existingPopup) {
|
||||
// Focus existing popup and send focus message
|
||||
await chrome.windows.update(existingPopup.id, { focused: true });
|
||||
console.log('✅ Focused existing popup window');
|
||||
|
||||
// Send message to focus on SigSocket section
|
||||
if (popupPort) {
|
||||
popupPort.postMessage({
|
||||
type: 'FOCUS_SIGSOCKET',
|
||||
fromNotification: true
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Best UX: Try to use the normal popup experience
|
||||
// The action API gives the same popup as clicking the extension icon
|
||||
try {
|
||||
if (chrome.action && chrome.action.openPopup) {
|
||||
await chrome.action.openPopup();
|
||||
console.log('✅ Extension popup opened via action API (best UX - normal popup)');
|
||||
|
||||
// Send focus message after popup opens
|
||||
setTimeout(() => {
|
||||
if (popupPort) {
|
||||
popupPort.postMessage({
|
||||
type: 'FOCUS_SIGSOCKET',
|
||||
fromNotification: true
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (actionError) {
|
||||
// The action API fails when there's no active browser window
|
||||
// This is common when all browser windows are closed but extension is still running
|
||||
console.log('⚠️ Action API failed (likely no active window):', actionError.message);
|
||||
|
||||
// Check if we have any normal browser windows
|
||||
const allWindows = await chrome.windows.getAll();
|
||||
const normalWindows = allWindows.filter(w => w.type === 'normal');
|
||||
|
||||
if (normalWindows.length > 0) {
|
||||
// We have browser windows, try to focus one and retry action API
|
||||
try {
|
||||
const targetWindow = normalWindows.find(w => w.focused) || normalWindows[0];
|
||||
await chrome.windows.update(targetWindow.id, { focused: true });
|
||||
|
||||
// Small delay and retry
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
await chrome.action.openPopup();
|
||||
console.log('✅ Extension popup opened via action API after focusing window');
|
||||
|
||||
setTimeout(() => {
|
||||
if (popupPort) {
|
||||
popupPort.postMessage({
|
||||
type: 'FOCUS_SIGSOCKET',
|
||||
fromNotification: true
|
||||
});
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return;
|
||||
} catch (retryError) {
|
||||
console.log('⚠️ Action API retry also failed:', retryError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If action API fails completely, we need to create a window
|
||||
// But let's make it as close to the normal popup experience as possible
|
||||
console.log('⚠️ Creating popup window as fallback (action API unavailable)');
|
||||
|
||||
const popupUrl = chrome.runtime.getURL('popup.html?from=notification');
|
||||
|
||||
// Position the popup where the extension icon would normally show its popup
|
||||
// Try to position it in the top-right area like a normal extension popup
|
||||
let left = screen.width - 420; // 400px width + 20px margin
|
||||
let top = 80; // Below browser toolbar area
|
||||
|
||||
try {
|
||||
// If we have a browser window, position relative to it
|
||||
const allWindows = await chrome.windows.getAll();
|
||||
const normalWindows = allWindows.filter(w => w.type === 'normal');
|
||||
|
||||
if (normalWindows.length > 0) {
|
||||
const referenceWindow = normalWindows[0];
|
||||
left = (referenceWindow.left || 0) + (referenceWindow.width || 800) - 420;
|
||||
top = (referenceWindow.top || 0) + 80;
|
||||
}
|
||||
} catch (positionError) {
|
||||
console.log('⚠️ Could not get window position, using screen-based positioning');
|
||||
}
|
||||
|
||||
const newWindow = await chrome.windows.create({
|
||||
url: popupUrl,
|
||||
type: 'popup',
|
||||
width: 400,
|
||||
height: 600,
|
||||
left: Math.max(0, left),
|
||||
top: Math.max(0, top),
|
||||
focused: true
|
||||
});
|
||||
|
||||
console.log(`✅ Extension popup window created: ${newWindow.id}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to open extension popup:', error);
|
||||
|
||||
// Final fallback: open in new tab (least ideal but still functional)
|
||||
try {
|
||||
const popupUrl = chrome.runtime.getURL('popup.html?from=notification');
|
||||
await chrome.tabs.create({ url: popupUrl, active: true });
|
||||
console.log('✅ Opened extension in new tab as final fallback');
|
||||
} catch (tabError) {
|
||||
console.error('❌ All popup opening methods failed:', tabError);
|
||||
}
|
||||
}
|
||||
}
|
876
crypto_vault_extension/background/sigsocket.js
Normal file
876
crypto_vault_extension/background/sigsocket.js
Normal file
@ -0,0 +1,876 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Status monitoring
|
||||
this.statusMonitorInterval = null;
|
||||
this.lastKnownConnectionState = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
// Restore any persisted pending requests
|
||||
await this.restorePendingRequests();
|
||||
|
||||
console.log('🔌 SigSocket service initialized with WASM APIs');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore pending requests from persistent storage
|
||||
* Only restore requests that match the current workspace
|
||||
*/
|
||||
async restorePendingRequests() {
|
||||
try {
|
||||
const result = await chrome.storage.local.get(['sigSocketPendingRequests']);
|
||||
if (result.sigSocketPendingRequests && Array.isArray(result.sigSocketPendingRequests)) {
|
||||
console.log(`🔄 Found ${result.sigSocketPendingRequests.length} stored requests`);
|
||||
|
||||
// Filter requests for current workspace only
|
||||
const currentWorkspaceRequests = result.sigSocketPendingRequests.filter(request =>
|
||||
request.target_public_key === this.connectedPublicKey
|
||||
);
|
||||
|
||||
console.log(`🔄 Restoring ${currentWorkspaceRequests.length} requests for current workspace`);
|
||||
|
||||
// Add each workspace-specific request back to WASM storage
|
||||
for (const request of currentWorkspaceRequests) {
|
||||
try {
|
||||
await this.wasmModule.SigSocketManager.add_pending_request(JSON.stringify(request.request || request));
|
||||
console.log(`✅ Restored request: ${request.id || request.request?.id}`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to restore request ${request.id || request.request?.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update badge after restoration
|
||||
this.updateBadge();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore pending requests:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist pending requests to storage with workspace isolation
|
||||
*/
|
||||
async persistPendingRequests() {
|
||||
try {
|
||||
const requests = await this.getFilteredRequests();
|
||||
|
||||
// Get existing storage to merge with other workspaces
|
||||
const result = await chrome.storage.local.get(['sigSocketPendingRequests']);
|
||||
const existingRequests = result.sigSocketPendingRequests || [];
|
||||
|
||||
// Remove old requests for current workspace
|
||||
const otherWorkspaceRequests = existingRequests.filter(request =>
|
||||
request.target_public_key !== this.connectedPublicKey
|
||||
);
|
||||
|
||||
// Combine with current workspace requests
|
||||
const allRequests = [...otherWorkspaceRequests, ...requests];
|
||||
|
||||
await chrome.storage.local.set({ sigSocketPendingRequests: allRequests });
|
||||
console.log(`💾 Persisted ${requests.length} requests for current workspace (${allRequests.length} total)`);
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist pending requests:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to SigSocket server using WASM APIs
|
||||
* WASM handles all connection logic (reuse, switching, etc.)
|
||||
* @param {string} workspaceId - The workspace/keyspace identifier
|
||||
* @param {number} retryCount - Number of retry attempts (default: 3)
|
||||
* @returns {Promise<boolean>} - True if connected successfully
|
||||
*/
|
||||
async connectToServer(workspaceId, retryCount = 3) {
|
||||
for (let attempt = 1; attempt <= retryCount; attempt++) {
|
||||
try {
|
||||
if (!this.wasmModule?.SigSocketManager) {
|
||||
throw new Error('WASM SigSocketManager not available');
|
||||
}
|
||||
|
||||
console.log(`🔗 Requesting SigSocket connection for workspace: ${workspaceId} (attempt ${attempt}/${retryCount})`);
|
||||
|
||||
// Clean workspace switching
|
||||
if (this.currentWorkspace && this.currentWorkspace !== workspaceId) {
|
||||
console.log(`🔄 Clean workspace switch: ${this.currentWorkspace} -> ${workspaceId}`);
|
||||
await this.cleanWorkspaceSwitch(workspaceId);
|
||||
// Small delay to ensure clean state transition
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
// 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 = workspaceId; // Use the parameter we passed, not WASM response
|
||||
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,
|
||||
serverUrl: this.defaultServerUrl
|
||||
});
|
||||
|
||||
// Validate that we have a public key if connected
|
||||
if (this.isConnected && !this.connectedPublicKey) {
|
||||
console.warn('⚠️ Connected but no public key received - this may cause request issues');
|
||||
}
|
||||
|
||||
// Update badge to show current state
|
||||
this.updateBadge();
|
||||
|
||||
if (this.isConnected) {
|
||||
// Clean flow: Connect -> Restore workspace requests -> Update UI
|
||||
console.log(`🔗 Connected to workspace: ${workspaceId}, restoring pending requests...`);
|
||||
|
||||
// 1. Restore requests for this specific workspace
|
||||
await this.restorePendingRequests();
|
||||
|
||||
// 2. Update badge with current count
|
||||
this.updateBadge();
|
||||
|
||||
console.log(`✅ Workspace ${workspaceId} ready with restored requests`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// If not connected but no error, try again
|
||||
if (attempt < retryCount) {
|
||||
console.log(`⏳ Connection not established, retrying in 1 second...`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Check if this is an expected "no workspace" error during startup
|
||||
const isExpectedStartupError = error.message &&
|
||||
(error.message.includes('Workspace not found') ||
|
||||
error.message.includes('no keypairs available'));
|
||||
|
||||
if (isExpectedStartupError && attempt === 1) {
|
||||
console.log(`⏳ SigSocket connection attempt ${attempt}: No active workspace (expected after extension reload)`);
|
||||
}
|
||||
|
||||
// Check if this is a public key related error
|
||||
if (error.message && error.message.includes('public key')) {
|
||||
console.error(`🔑 Public key error detected: ${error.message}`);
|
||||
// For public key errors, don't retry immediately - might need workspace change
|
||||
if (attempt === 1) {
|
||||
console.log(`🔄 Public key error on first attempt, trying to disconnect and reconnect...`);
|
||||
await this.disconnect();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < retryCount) {
|
||||
if (!isExpectedStartupError) {
|
||||
console.log(`⏳ Retrying connection in 2 seconds...`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
} else {
|
||||
// Final attempt failed
|
||||
this.isConnected = false;
|
||||
this.currentWorkspace = null;
|
||||
this.connectedPublicKey = null;
|
||||
|
||||
if (isExpectedStartupError) {
|
||||
console.log(`ℹ️ SigSocket connection failed: No active workspace. Will connect when user logs in.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Handle events from the WASM SigSocket client
|
||||
* This is called automatically when requests arrive
|
||||
* @param {Object} event - Event from WASM layer
|
||||
*/
|
||||
async handleSigSocketEvent(event) {
|
||||
console.log('📨 Received SigSocket event:', event);
|
||||
|
||||
if (event.type === 'sign_request') {
|
||||
console.log(`🔐 New sign request: ${event.request_id} for workspace: ${this.currentWorkspace}`);
|
||||
|
||||
// Clean flow: Request arrives -> Store -> Persist -> Update UI
|
||||
try {
|
||||
// 1. Request is automatically stored in WASM (already done by WASM layer)
|
||||
|
||||
// 2. Persist to storage with workspace isolation
|
||||
await this.persistPendingRequests();
|
||||
|
||||
// 3. Update badge count
|
||||
this.updateBadge();
|
||||
|
||||
// 4. Show notification
|
||||
this.showSignRequestNotification();
|
||||
|
||||
// 5. Notify popup if connected
|
||||
this.notifyPopupOfNewRequest();
|
||||
|
||||
console.log(`✅ Request ${event.request_id} processed and stored for workspace: ${this.currentWorkspace}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to process request ${event.request_id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
// Check if we're connected before attempting approval
|
||||
if (!this.isConnected) {
|
||||
console.warn(`⚠️ Not connected to SigSocket server, cannot approve request: ${requestId}`);
|
||||
throw new Error('Not connected to SigSocket server');
|
||||
}
|
||||
|
||||
// Verify we can approve this request
|
||||
const canApprove = await this.canApproveRequest(requestId);
|
||||
if (!canApprove) {
|
||||
console.warn(`⚠️ Cannot approve request ${requestId} - keyspace may be locked or request not found`);
|
||||
throw new Error('Cannot approve request - keyspace may be locked or request not found');
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
// Clean flow: Approve -> Remove from storage -> Update UI
|
||||
// 1. Remove from persistent storage (WASM already removed it)
|
||||
await this.persistPendingRequests();
|
||||
|
||||
// 2. Update badge count
|
||||
this.updateBadge();
|
||||
|
||||
// 3. Notify popup of updated state
|
||||
this.notifyPopupOfRequestUpdate();
|
||||
|
||||
console.log(`✅ Request ${requestId} approved and cleaned up`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to approve request ${requestId}:`, error);
|
||||
|
||||
// Check if this is a connection-related error
|
||||
if (error.message && (error.message.includes('Connection not found') || error.message.includes('public key'))) {
|
||||
console.error(`🔑 Connection/public key error during approval. Current state:`, {
|
||||
connected: this.isConnected,
|
||||
workspace: this.currentWorkspace,
|
||||
publicKey: this.connectedPublicKey?.substring(0, 16) + '...'
|
||||
});
|
||||
|
||||
// Try to reconnect for next time
|
||||
if (this.currentWorkspace) {
|
||||
console.log(`🔄 Attempting to reconnect to workspace: ${this.currentWorkspace}`);
|
||||
setTimeout(() => this.connectToServer(this.currentWorkspace), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
// Clean flow: Reject -> Remove from storage -> Update UI
|
||||
// 1. Remove from persistent storage (WASM already removed it)
|
||||
await this.persistPendingRequests();
|
||||
|
||||
// 2. Update badge count
|
||||
this.updateBadge();
|
||||
|
||||
// 3. Notify popup of updated state
|
||||
this.notifyPopupOfRequestUpdate();
|
||||
|
||||
console.log(`✅ Request ${requestId} rejected and cleaned up`);
|
||||
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 clickable notification for new sign request
|
||||
* Call this AFTER the request has been stored and persisted
|
||||
*/
|
||||
async showSignRequestNotification() {
|
||||
try {
|
||||
if (chrome.notifications && chrome.notifications.create) {
|
||||
// Small delay to ensure request is fully stored
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log(`📢 Preparing notification for new signature request`);
|
||||
|
||||
// Check if keyspace is currently unlocked to customize message
|
||||
let message = 'New signature request received. Click to review and approve.';
|
||||
let title = 'SigSocket Sign Request';
|
||||
|
||||
// Try to determine if keyspace is locked
|
||||
try {
|
||||
const requests = await this.getPendingRequests();
|
||||
const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : false;
|
||||
if (!canApprove) {
|
||||
message = 'New signature request received. Click to unlock keyspace and approve.';
|
||||
title = 'SigSocket Request';
|
||||
}
|
||||
} catch (error) {
|
||||
// If we can't check, use generic message
|
||||
message = 'New signature request received. Click to open extension.';
|
||||
}
|
||||
|
||||
// Create clickable notification with unique ID
|
||||
const notificationId = `sigsocket-request-${Date.now()}`;
|
||||
|
||||
const notificationOptions = {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon48.png',
|
||||
title: title,
|
||||
message: message,
|
||||
requireInteraction: true // Keep notification visible until user interacts
|
||||
};
|
||||
|
||||
console.log(`📢 Creating notification: ${notificationId}`, notificationOptions);
|
||||
|
||||
chrome.notifications.create(notificationId, notificationOptions, (createdId) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error('❌ Failed to create notification:', chrome.runtime.lastError);
|
||||
} else {
|
||||
console.log(`✅ Notification created successfully: ${createdId}`);
|
||||
}
|
||||
});
|
||||
} 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.lastKnownConnectionState = false;
|
||||
|
||||
// Stop status monitoring
|
||||
this.stopStatusMonitoring();
|
||||
|
||||
this.updateBadge();
|
||||
|
||||
console.log('🔌 SigSocket disconnection requested');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear persisted pending requests from storage
|
||||
*/
|
||||
async clearPersistedRequests() {
|
||||
try {
|
||||
await chrome.storage.local.remove(['sigSocketPendingRequests']);
|
||||
console.log('🗑️ Cleared persisted pending requests from storage');
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear persisted requests:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean workspace switch - clear current workspace requests only
|
||||
*/
|
||||
async cleanWorkspaceSwitch(newWorkspace) {
|
||||
try {
|
||||
console.log(`🔄 Clean workspace switch: ${this.currentWorkspace} -> ${newWorkspace}`);
|
||||
|
||||
// 1. Persist current workspace requests before switching
|
||||
if (this.currentWorkspace && this.isConnected) {
|
||||
await this.persistPendingRequests();
|
||||
console.log(`💾 Saved requests for workspace: ${this.currentWorkspace}`);
|
||||
}
|
||||
|
||||
// 2. Clear WASM state (will be restored for new workspace)
|
||||
if (this.wasmModule?.SigSocketManager) {
|
||||
await this.wasmModule.SigSocketManager.clear_pending_requests();
|
||||
console.log('🧹 Cleared WASM request state');
|
||||
}
|
||||
|
||||
// 3. Reset local state
|
||||
this.currentWorkspace = null;
|
||||
this.connectedPublicKey = null;
|
||||
this.isConnected = false;
|
||||
|
||||
console.log('✅ Workspace switch cleanup completed');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to clean workspace switch:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status with real connection verification
|
||||
* @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
|
||||
};
|
||||
}
|
||||
|
||||
// Get WASM status first
|
||||
const statusJson = await this.wasmModule.SigSocketManager.get_connection_status();
|
||||
const status = JSON.parse(statusJson);
|
||||
|
||||
// Verify connection by trying to get requests (this will fail if not connected)
|
||||
let actuallyConnected = false;
|
||||
let requests = [];
|
||||
|
||||
try {
|
||||
requests = await this.getPendingRequests();
|
||||
// If we can get requests and WASM says connected, we're probably connected
|
||||
actuallyConnected = status.is_connected && Array.isArray(requests);
|
||||
} catch (error) {
|
||||
// If getting requests fails, we're definitely not connected
|
||||
console.warn('Connection verification failed:', error);
|
||||
actuallyConnected = false;
|
||||
}
|
||||
|
||||
// Update our internal state
|
||||
this.isConnected = actuallyConnected;
|
||||
|
||||
if (status.connected_public_key && actuallyConnected) {
|
||||
this.connectedPublicKey = status.connected_public_key;
|
||||
} else {
|
||||
this.connectedPublicKey = null;
|
||||
}
|
||||
|
||||
// If we're disconnected, clear our workspace
|
||||
if (!actuallyConnected) {
|
||||
this.currentWorkspace = null;
|
||||
}
|
||||
|
||||
const statusResult = {
|
||||
isConnected: actuallyConnected,
|
||||
workspace: this.currentWorkspace,
|
||||
publicKey: status.connected_public_key,
|
||||
pendingRequestCount: requests.length,
|
||||
serverUrl: this.defaultServerUrl,
|
||||
// Clean flow status indicators
|
||||
cleanFlowReady: actuallyConnected && this.currentWorkspace && status.connected_public_key
|
||||
};
|
||||
|
||||
console.log('📊 Clean flow status:', {
|
||||
connected: statusResult.isConnected,
|
||||
workspace: statusResult.workspace,
|
||||
requestCount: statusResult.pendingRequestCount,
|
||||
flowReady: statusResult.cleanFlowReady
|
||||
});
|
||||
|
||||
return statusResult;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to get status:', error);
|
||||
// Clear state on error
|
||||
this.isConnected = false;
|
||||
this.currentWorkspace = null;
|
||||
this.connectedPublicKey = null;
|
||||
return {
|
||||
isConnected: false,
|
||||
workspace: null,
|
||||
publicKey: null,
|
||||
pendingRequestCount: 0,
|
||||
serverUrl: this.defaultServerUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the popup port for communication
|
||||
* @param {chrome.runtime.Port|null} port - The popup port or null to disconnect
|
||||
*/
|
||||
setPopupPort(port) {
|
||||
this.popupPort = port;
|
||||
|
||||
if (port) {
|
||||
console.log('📱 Popup connected to SigSocket service');
|
||||
|
||||
// Immediately check connection status when popup opens
|
||||
this.checkConnectionStatusNow();
|
||||
|
||||
// Start monitoring connection status when popup connects
|
||||
this.startStatusMonitoring();
|
||||
} else {
|
||||
console.log('📱 Popup disconnected from SigSocket service');
|
||||
// Stop monitoring when popup disconnects
|
||||
this.stopStatusMonitoring();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately check and update connection status
|
||||
*/
|
||||
async checkConnectionStatusNow() {
|
||||
try {
|
||||
// Force a fresh connection check
|
||||
const currentStatus = await this.getStatusWithConnectionTest();
|
||||
this.lastKnownConnectionState = currentStatus.isConnected;
|
||||
|
||||
// Notify popup of current status
|
||||
this.notifyPopupOfStatusChange(currentStatus);
|
||||
|
||||
console.log(`🔍 Immediate status check: ${currentStatus.isConnected ? 'Connected' : 'Disconnected'}`);
|
||||
} catch (error) {
|
||||
console.warn('Failed to check connection status immediately:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status with additional connection testing
|
||||
*/
|
||||
async getStatusWithConnectionTest() {
|
||||
const status = await this.getStatus();
|
||||
|
||||
// If WASM claims we're connected, do an additional verification
|
||||
if (status.isConnected) {
|
||||
try {
|
||||
// Try to get connection status again - if this fails, we're not really connected
|
||||
const verifyJson = await this.wasmModule.SigSocketManager.get_connection_status();
|
||||
const verifyStatus = JSON.parse(verifyJson);
|
||||
|
||||
if (!verifyStatus.is_connected) {
|
||||
console.log('🔍 Connection verification failed - marking as disconnected');
|
||||
status.isConnected = false;
|
||||
this.isConnected = false;
|
||||
this.currentWorkspace = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('🔍 Connection test failed - marking as disconnected:', error.message);
|
||||
status.isConnected = false;
|
||||
this.isConnected = false;
|
||||
this.currentWorkspace = null;
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic status monitoring to detect connection changes
|
||||
*/
|
||||
startStatusMonitoring() {
|
||||
// Clear any existing monitoring
|
||||
if (this.statusMonitorInterval) {
|
||||
clearInterval(this.statusMonitorInterval);
|
||||
}
|
||||
|
||||
// Check status every 2 seconds when popup is open (more responsive)
|
||||
this.statusMonitorInterval = setInterval(async () => {
|
||||
if (this.popupPort) {
|
||||
try {
|
||||
const currentStatus = await this.getStatusWithConnectionTest();
|
||||
|
||||
// Check if connection status changed
|
||||
if (currentStatus.isConnected !== this.lastKnownConnectionState) {
|
||||
console.log(`🔄 Connection state changed: ${this.lastKnownConnectionState} -> ${currentStatus.isConnected}`);
|
||||
this.lastKnownConnectionState = currentStatus.isConnected;
|
||||
|
||||
// Notify popup of status change
|
||||
this.notifyPopupOfStatusChange(currentStatus);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Status monitoring error:', error);
|
||||
// On error, assume disconnected
|
||||
if (this.lastKnownConnectionState !== false) {
|
||||
console.log('🔄 Status monitoring error - marking as disconnected');
|
||||
this.lastKnownConnectionState = false;
|
||||
this.notifyPopupOfStatusChange({
|
||||
isConnected: false,
|
||||
workspace: null,
|
||||
publicKey: null,
|
||||
pendingRequestCount: 0,
|
||||
serverUrl: this.defaultServerUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Stop monitoring when popup is closed
|
||||
this.stopStatusMonitoring();
|
||||
}
|
||||
}, 2000); // 2 seconds for better responsiveness
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop status monitoring
|
||||
*/
|
||||
stopStatusMonitoring() {
|
||||
if (this.statusMonitorInterval) {
|
||||
clearInterval(this.statusMonitorInterval);
|
||||
this.statusMonitorInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify popup of connection status change
|
||||
* @param {Object} status - Current connection status
|
||||
*/
|
||||
notifyPopupOfStatusChange(status) {
|
||||
if (this.popupPort) {
|
||||
this.popupPort.postMessage({
|
||||
type: 'CONNECTION_STATUS_CHANGED',
|
||||
status: status
|
||||
});
|
||||
console.log(`📡 Notified popup of connection status change: ${status.isConnected ? 'Connected' : 'Disconnected'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when keyspace is unlocked - clean approach to show pending requests
|
||||
*/
|
||||
async onKeypaceUnlocked() {
|
||||
try {
|
||||
console.log('🔓 Keyspace unlocked - preparing to show pending requests');
|
||||
|
||||
// 1. Restore any persisted requests for this workspace
|
||||
await this.restorePendingRequests();
|
||||
|
||||
// 2. Get current requests (includes restored + any new ones)
|
||||
const requests = await this.getPendingRequests();
|
||||
|
||||
// 3. Check if we can approve requests (keyspace should be unlocked now)
|
||||
const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : true;
|
||||
|
||||
// 4. Update badge with current count
|
||||
this.updateBadge();
|
||||
|
||||
// 5. Notify popup if connected
|
||||
if (this.popupPort) {
|
||||
this.popupPort.postMessage({
|
||||
type: 'KEYSPACE_UNLOCKED',
|
||||
canApprove,
|
||||
pendingRequests: requests
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🔓 Keyspace unlocked: ${requests.length} requests ready, canApprove: ${canApprove}`);
|
||||
|
||||
return requests;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to handle keyspace unlock:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in background script
|
||||
export default SigSocketService;
|
75
crypto_vault_extension/demo/README.md
Normal file
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
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
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
BIN
crypto_vault_extension/icons/icon128.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.5 KiB |
BIN
crypto_vault_extension/icons/icon16.png
Normal file
BIN
crypto_vault_extension/icons/icon16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 676 B |
BIN
crypto_vault_extension/icons/icon32.png
Normal file
BIN
crypto_vault_extension/icons/icon32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
BIN
crypto_vault_extension/icons/icon36.png
Normal file
BIN
crypto_vault_extension/icons/icon36.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
BIN
crypto_vault_extension/icons/icon48.png
Normal file
BIN
crypto_vault_extension/icons/icon48.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
247
crypto_vault_extension/js/errorHandler.js
Normal file
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
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';"
|
||||
}
|
||||
}
|
269
crypto_vault_extension/popup.html
Normal file
269
crypto_vault_extension/popup.html
Normal file
@ -0,0 +1,269 @@
|
||||
<!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 clickable-header" id="headerTitle">
|
||||
<div class="logo-icon">🔐</div>
|
||||
<h1>CryptoVault</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="settingsBtn" 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>
|
||||
<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="loading-requests hidden" id="loadingRequestsMessage">
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading requests...</p>
|
||||
<small>Fetching pending signature requests</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<section class="section hidden" id="settingsSection">
|
||||
<div class="settings-header">
|
||||
<h2>Settings</h2>
|
||||
</div>
|
||||
|
||||
<!-- Session Settings -->
|
||||
<div class="card">
|
||||
<h3>Session Settings</h3>
|
||||
<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>
|
||||
<small class="settings-help">Automatically lock session after inactivity</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SigSocket Settings -->
|
||||
<div class="card">
|
||||
<h3>SigSocket Settings</h3>
|
||||
<div class="settings-item">
|
||||
<label for="serverUrlInput">Server URL</label>
|
||||
<div class="server-input-group">
|
||||
<input type="text" id="serverUrlInput" placeholder="ws://localhost:8080/ws" value="ws://localhost:8080/ws">
|
||||
<button id="saveServerUrlBtn" class="btn btn-small btn-primary">Save</button>
|
||||
</div>
|
||||
<small class="settings-help">WebSocket URL for SigSocket server (ws:// or wss://)</small>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Enhanced JavaScript modules -->
|
||||
<script src="js/errorHandler.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
1651
crypto_vault_extension/popup.js
Normal file
1651
crypto_vault_extension/popup.js
Normal file
File diff suppressed because it is too large
Load Diff
443
crypto_vault_extension/popup/components/SignRequestManager.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;
|
||||
}
|
1419
crypto_vault_extension/styles/popup.css
Normal file
1419
crypto_vault_extension/styles/popup.css
Normal file
File diff suppressed because it is too large
Load Diff
114
crypto_vault_extension/test_extension.md
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
1398
crypto_vault_extension/wasm/wasm_app.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
crypto_vault_extension/wasm/wasm_app_bg.wasm
Normal file
BIN
crypto_vault_extension/wasm/wasm_app_bg.wasm
Normal file
Binary file not shown.
@ -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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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);
|
||||
}
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
@ -31,3 +31,22 @@ async fn test_native_store_basic() {
|
||||
let keys = store.keys().await.unwrap();
|
||||
assert_eq!(keys.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_native_store_persistence() {
|
||||
let tmp_dir = tempfile::tempdir().unwrap();
|
||||
let path = tmp_dir.path().join("persistdb");
|
||||
let db_path = path.to_str().unwrap();
|
||||
// First open, set value
|
||||
{
|
||||
let store = NativeStore::open(db_path).unwrap();
|
||||
store.set("persist", b"value").await.unwrap();
|
||||
}
|
||||
// Reopen and check value
|
||||
{
|
||||
let store = NativeStore::open(db_path).unwrap();
|
||||
let val = store.get("persist").await.unwrap();
|
||||
assert_eq!(val, Some(b"value".to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -8,7 +8,7 @@ wasm_bindgen_test_configure!(run_in_browser);
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn test_set_and_get() {
|
||||
let store = WasmStore::open("test-db").await.expect("open");
|
||||
let store = WasmStore::open("vault").await.expect("open");
|
||||
store.set("foo", b"bar").await.expect("set");
|
||||
let val = store.get("foo").await.expect("get");
|
||||
assert_eq!(val, Some(b"bar".to_vec()));
|
||||
@ -16,7 +16,7 @@ async fn test_set_and_get() {
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn test_delete_and_exists() {
|
||||
let store = WasmStore::open("test-db").await.expect("open");
|
||||
let store = WasmStore::open("vault").await.expect("open");
|
||||
store.set("foo", b"bar").await.expect("set");
|
||||
assert_eq!(store.contains_key("foo").await.unwrap(), true);
|
||||
assert_eq!(store.contains_key("bar").await.unwrap(), false);
|
||||
@ -26,7 +26,7 @@ async fn test_delete_and_exists() {
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn test_keys() {
|
||||
let store = WasmStore::open("test-db").await.expect("open");
|
||||
let store = WasmStore::open("vault").await.expect("open");
|
||||
store.set("foo", b"bar").await.expect("set");
|
||||
store.set("baz", b"qux").await.expect("set");
|
||||
let keys = store.keys().await.unwrap();
|
||||
@ -35,9 +35,26 @@ async fn test_keys() {
|
||||
assert!(keys.contains(&"baz".to_string()));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn test_wasm_store_persistence() {
|
||||
// Use a unique store name to avoid collisions
|
||||
let store_name = "persist_test_store";
|
||||
// First open, set value
|
||||
{
|
||||
let store = WasmStore::open(store_name).await.expect("open");
|
||||
store.set("persist", b"value").await.expect("set");
|
||||
}
|
||||
// Reopen and check value
|
||||
{
|
||||
let store = WasmStore::open(store_name).await.expect("open");
|
||||
let val = store.get("persist").await.expect("get");
|
||||
assert_eq!(val, Some(b"value".to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
async fn test_clear() {
|
||||
let store = WasmStore::open("test-db").await.expect("open");
|
||||
let store = WasmStore::open("vault").await.expect("open");
|
||||
store.set("foo", b"bar").await.expect("set");
|
||||
store.set("baz", b"qux").await.expect("set");
|
||||
store.clear().await.unwrap();
|
||||
|
53
sigsocket_client/Cargo.toml
Normal file
53
sigsocket_client/Cargo.toml
Normal file
@ -0,0 +1,53 @@
|
||||
[package]
|
||||
name = "sigsocket_client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "WebSocket client for sigsocket server with WASM-first support"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://git.ourworld.tf/samehabouelsaad/sal-modular"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
# Core dependencies (both native and WASM)
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
log = "0.4"
|
||||
hex = "0.4"
|
||||
base64 = "0.21"
|
||||
url = "2.5"
|
||||
async-trait = "0.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Native-only dependencies
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio-tungstenite = "0.21"
|
||||
futures-util = "0.3"
|
||||
thiserror = "1.0"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# WASM-only dependencies
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = [
|
||||
"console",
|
||||
"WebSocket",
|
||||
"MessageEvent",
|
||||
"Event",
|
||||
"BinaryType",
|
||||
"CloseEvent",
|
||||
"ErrorEvent",
|
||||
"Window",
|
||||
] }
|
||||
js-sys = "0.3"
|
||||
|
||||
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
env_logger = "0.10"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
214
sigsocket_client/IMPLEMENTATION.md
Normal file
214
sigsocket_client/IMPLEMENTATION.md
Normal file
@ -0,0 +1,214 @@
|
||||
# SigSocket Client Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the implementation of the `sigsocket_client` crate, a WebSocket client library designed for connecting to sigsocket servers with **WASM-first support**.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Design Principles
|
||||
|
||||
1. **WASM-First**: Designed primarily for browser environments with native support as a secondary target
|
||||
2. **No Signing Logic**: The client delegates all signing operations to the application
|
||||
3. **User Approval Flow**: Applications are notified about incoming requests and handle user approval
|
||||
4. **Protocol Compatibility**: Fully compatible with the sigsocket server protocol
|
||||
5. **Async/Await**: Modern async Rust API throughout
|
||||
|
||||
### Module Structure
|
||||
|
||||
```
|
||||
sigsocket_client/
|
||||
├── src/
|
||||
│ ├── lib.rs # Main library entry point
|
||||
│ ├── error.rs # Error types (native + WASM versions)
|
||||
│ ├── protocol.rs # Protocol message definitions
|
||||
│ ├── client.rs # Main client interface
|
||||
│ ├── native.rs # Native (tokio) implementation
|
||||
│ └── wasm.rs # WASM (web-sys) implementation
|
||||
├── examples/
|
||||
│ ├── basic_usage.rs # Native usage example
|
||||
│ └── wasm_usage.rs # WASM usage example
|
||||
├── tests/
|
||||
│ └── integration_test.rs
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Protocol Implementation
|
||||
|
||||
The sigsocket protocol is simple and consists of three message types:
|
||||
|
||||
### 1. Introduction Message
|
||||
When connecting, the client sends its public key as a hex-encoded string:
|
||||
```
|
||||
02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9
|
||||
```
|
||||
|
||||
### 2. Sign Request (Server → Client)
|
||||
```json
|
||||
{
|
||||
"id": "req_123",
|
||||
"message": "dGVzdCBtZXNzYWdl" // base64-encoded message
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Sign Response (Client → Server)
|
||||
```json
|
||||
{
|
||||
"id": "req_123",
|
||||
"message": "dGVzdCBtZXNzYWdl", // original message
|
||||
"signature": "c2lnbmF0dXJl" // base64-encoded signature
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
### ✅ Dual Platform Support
|
||||
- **Native**: Uses `tokio` and `tokio-tungstenite` for async WebSocket communication
|
||||
- **WASM**: Uses `web-sys` and `wasm-bindgen` for browser WebSocket API
|
||||
|
||||
### ✅ Type-Safe Protocol
|
||||
- `SignRequest` and `SignResponse` structs with serde serialization
|
||||
- Helper methods for base64 encoding/decoding
|
||||
- Comprehensive error handling
|
||||
|
||||
### ✅ Flexible Sign Handler Interface
|
||||
```rust
|
||||
trait SignRequestHandler {
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>>;
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Connection Management
|
||||
- Automatic connection state tracking
|
||||
- Clean disconnect handling
|
||||
- Connection status queries
|
||||
|
||||
### ✅ Error Handling
|
||||
- Comprehensive error types for different failure modes
|
||||
- Platform-specific error conversions
|
||||
- WASM-compatible error handling (no `std::error::Error` dependency)
|
||||
|
||||
## Platform-Specific Implementations
|
||||
|
||||
### Native Implementation (`native.rs`)
|
||||
- Uses `tokio-tungstenite` for WebSocket communication
|
||||
- Spawns separate tasks for reading and writing
|
||||
- Thread-safe with `Arc<RwLock<T>>` for shared state
|
||||
- Supports `Send + Sync` trait bounds
|
||||
|
||||
### WASM Implementation (`wasm.rs`)
|
||||
- Uses `web-sys::WebSocket` for browser WebSocket API
|
||||
- Event-driven with JavaScript closures
|
||||
- Single-threaded (no `Send + Sync` requirements)
|
||||
- Browser console logging for debugging
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### Native Usage
|
||||
```rust
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let public_key = hex::decode("02f9308a...")?;
|
||||
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)?;
|
||||
|
||||
client.set_sign_handler(MySignHandler);
|
||||
client.connect().await?;
|
||||
|
||||
// Client handles requests automatically
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### WASM Usage
|
||||
```rust
|
||||
#[wasm_bindgen]
|
||||
pub async fn connect_to_sigsocket() -> Result<(), JsValue> {
|
||||
let public_key = get_user_public_key()?;
|
||||
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)?;
|
||||
|
||||
client.set_sign_handler(WasmSignHandler);
|
||||
client.connect().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
- Protocol message serialization/deserialization
|
||||
- Error handling and conversion
|
||||
- Client creation and configuration
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end usage patterns
|
||||
- Sign request/response cycles
|
||||
- Error scenarios
|
||||
|
||||
### Documentation Tests
|
||||
- Example code in documentation is verified to compile
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core Dependencies (Both Platforms)
|
||||
- `serde` + `serde_json` - JSON serialization
|
||||
- `hex` - Hex encoding/decoding
|
||||
- `base64` - Base64 encoding/decoding
|
||||
- `url` - URL parsing and validation
|
||||
|
||||
### Native-Only Dependencies
|
||||
- `tokio` - Async runtime
|
||||
- `tokio-tungstenite` - WebSocket client
|
||||
- `futures-util` - Stream utilities
|
||||
- `thiserror` - Error derive macros
|
||||
|
||||
### WASM-Only Dependencies
|
||||
- `wasm-bindgen` - Rust/JavaScript interop
|
||||
- `web-sys` - Browser API bindings
|
||||
- `js-sys` - JavaScript type bindings
|
||||
- `wasm-bindgen-futures` - Async support
|
||||
|
||||
## Build Targets
|
||||
|
||||
### Native Build
|
||||
```bash
|
||||
cargo build --features native
|
||||
cargo test --features native
|
||||
cargo run --example basic_usage --features native
|
||||
```
|
||||
|
||||
### WASM Build
|
||||
```bash
|
||||
cargo check --target wasm32-unknown-unknown --features wasm
|
||||
wasm-pack build --target web --features wasm
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements
|
||||
1. **Reconnection Logic**: Automatic reconnection with exponential backoff
|
||||
2. **Request Queuing**: Queue multiple concurrent sign requests
|
||||
3. **Timeout Handling**: Configurable timeouts for requests
|
||||
4. **Metrics**: Connection and request metrics
|
||||
5. **Logging**: Structured logging with configurable levels
|
||||
|
||||
### WASM Enhancements
|
||||
1. **Better Callback System**: More ergonomic callback handling in WASM
|
||||
2. **Browser Wallet Integration**: Direct integration with MetaMask, etc.
|
||||
3. **Service Worker Support**: Background request handling
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **No Private Key Storage**: The client never handles private keys
|
||||
2. **User Approval Required**: All signing requires explicit user approval
|
||||
3. **Message Validation**: All incoming messages are validated
|
||||
4. **Secure Transport**: Requires WebSocket Secure (WSS) in production
|
||||
|
||||
## Compatibility
|
||||
|
||||
- **Rust Version**: 1.70+
|
||||
- **WASM Target**: `wasm32-unknown-unknown`
|
||||
- **Browser Support**: Modern browsers with WebSocket support
|
||||
- **Server Compatibility**: Compatible with sigsocket server protocol
|
||||
|
||||
This implementation provides a solid foundation for applications that need to connect to sigsocket servers while maintaining security and user control over signing operations.
|
218
sigsocket_client/README.md
Normal file
218
sigsocket_client/README.md
Normal file
@ -0,0 +1,218 @@
|
||||
# SigSocket Client
|
||||
|
||||
A WebSocket client library for connecting to sigsocket servers with **WASM-first support**.
|
||||
|
||||
## Features
|
||||
|
||||
- 🌐 **WASM-first design**: Optimized for browser environments
|
||||
- 🖥️ **Native support**: Works in native Rust applications
|
||||
- 🔐 **No signing logic**: Delegates signing to the application
|
||||
- 👤 **User approval flow**: Notifies applications about incoming requests
|
||||
- 🔌 **sigsocket compatible**: Fully compatible with sigsocket server protocol
|
||||
- 🚀 **Async/await**: Modern async Rust API
|
||||
- 🔄 **Automatic reconnection**: Both platforms support reconnection with exponential backoff
|
||||
- ⏱️ **Connection timeouts**: Proper timeout handling and connection management
|
||||
- 🛡️ **Production ready**: Comprehensive error handling and reliability features
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Native Usage
|
||||
|
||||
```rust
|
||||
use sigsocket_client::{SigSocketClient, SignRequestHandler, SignRequest, Result};
|
||||
|
||||
struct MySignHandler;
|
||||
|
||||
impl SignRequestHandler for MySignHandler {
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
|
||||
// 1. Present request to user
|
||||
println!("Sign request: {}", request.message);
|
||||
|
||||
// 2. Get user approval
|
||||
// ... your UI logic here ...
|
||||
|
||||
// 3. Sign the message (using your signing logic)
|
||||
let signature = your_signing_function(&request.message_bytes()?)?;
|
||||
|
||||
Ok(signature)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Your public key bytes
|
||||
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9388")?;
|
||||
|
||||
// Create and configure client
|
||||
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)?;
|
||||
client.set_sign_handler(MySignHandler);
|
||||
|
||||
// Connect and handle requests
|
||||
client.connect().await?;
|
||||
|
||||
// Client will automatically handle incoming signature requests
|
||||
// Keep the connection alive...
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### WASM Usage
|
||||
|
||||
```rust
|
||||
use sigsocket_client::{SigSocketClient, SignRequestHandler, SignRequest, Result};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
struct WasmSignHandler;
|
||||
|
||||
impl SignRequestHandler for WasmSignHandler {
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
|
||||
// Show request to user in browser
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.alert_with_message(&format!("Sign request: {}", request.id))
|
||||
.unwrap();
|
||||
|
||||
// Your signing logic here...
|
||||
let signature = sign_with_browser_wallet(&request.message_bytes()?)?;
|
||||
Ok(signature)
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub async fn connect_to_sigsocket() -> Result<(), JsValue> {
|
||||
let public_key = get_user_public_key()?;
|
||||
|
||||
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
client.set_sign_handler(WasmSignHandler);
|
||||
|
||||
client.connect().await
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The sigsocket client implements a simple WebSocket protocol:
|
||||
|
||||
### 1. Introduction
|
||||
Upon connection, the client sends its public key as a hex-encoded string:
|
||||
```
|
||||
02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9388
|
||||
```
|
||||
|
||||
### 2. Sign Requests
|
||||
The server sends signature requests as JSON:
|
||||
```json
|
||||
{
|
||||
"id": "req_123",
|
||||
"message": "dGVzdCBtZXNzYWdl" // base64-encoded message
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Sign Responses
|
||||
The client responds with signatures as JSON:
|
||||
```json
|
||||
{
|
||||
"id": "req_123",
|
||||
"message": "dGVzdCBtZXNzYWdl", // original message
|
||||
"signature": "c2lnbmF0dXJl" // base64-encoded signature
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SigSocketClient`
|
||||
|
||||
Main client for connecting to sigsocket servers.
|
||||
|
||||
#### Methods
|
||||
|
||||
- `new(url, public_key)` - Create a new client
|
||||
- `set_sign_handler(handler)` - Set the signature request handler
|
||||
- `connect()` - Connect to the server with automatic reconnection
|
||||
- `disconnect()` - Disconnect from the server
|
||||
- `send_sign_response(response)` - Manually send a signature response
|
||||
- `state()` - Get current connection state
|
||||
- `is_connected()` - Check if connected
|
||||
|
||||
#### Reconnection Configuration (WASM only)
|
||||
|
||||
- `set_auto_reconnect(enabled)` - Enable/disable automatic reconnection
|
||||
- `set_reconnect_config(max_attempts, initial_delay_ms)` - Configure reconnection parameters
|
||||
|
||||
**Default settings:**
|
||||
- Max attempts: 5
|
||||
- Initial delay: 1000ms (with exponential backoff: 1s, 2s, 4s, 8s, 16s)
|
||||
- Auto-reconnect: enabled
|
||||
|
||||
### `SignRequestHandler` Trait
|
||||
|
||||
Implement this trait to handle incoming signature requests.
|
||||
|
||||
```rust
|
||||
trait SignRequestHandler {
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>>;
|
||||
}
|
||||
```
|
||||
|
||||
### `SignRequest`
|
||||
|
||||
Represents a signature request from the server.
|
||||
|
||||
#### Fields
|
||||
- `id: String` - Unique request identifier
|
||||
- `message: String` - Base64-encoded message to sign
|
||||
|
||||
#### Methods
|
||||
- `message_bytes()` - Decode message to bytes
|
||||
- `message_hex()` - Get message as hex string
|
||||
|
||||
### `SignResponse`
|
||||
|
||||
Represents a signature response to send to the server.
|
||||
|
||||
#### Methods
|
||||
- `new(id, message, signature)` - Create a new response
|
||||
- `from_request_and_signature(request, signature)` - Create from request and signature bytes
|
||||
|
||||
## Examples
|
||||
|
||||
Run the basic example:
|
||||
|
||||
```bash
|
||||
cargo run --example basic_usage
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### Native Build
|
||||
```bash
|
||||
cargo build
|
||||
cargo test
|
||||
cargo run --example basic_usage
|
||||
```
|
||||
|
||||
### WASM Build
|
||||
```bash
|
||||
wasm-pack build --target web
|
||||
wasm-pack test --headless --firefox # Run WASM tests
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
### Native
|
||||
- Rust 1.70+
|
||||
- tokio runtime
|
||||
|
||||
### WASM
|
||||
- wasm-pack
|
||||
- Modern browser with WebSocket support
|
||||
|
||||
## License
|
||||
|
||||
MIT OR Apache-2.0
|
133
sigsocket_client/examples/basic_usage.rs
Normal file
133
sigsocket_client/examples/basic_usage.rs
Normal file
@ -0,0 +1,133 @@
|
||||
//! Basic usage example for sigsocket_client
|
||||
//!
|
||||
//! This example demonstrates how to:
|
||||
//! 1. Create a sigsocket client
|
||||
//! 2. Set up a sign request handler
|
||||
//! 3. Connect to a sigsocket server
|
||||
//! 4. Handle incoming signature requests
|
||||
//!
|
||||
//! This example only runs on native (non-WASM) targets.
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use sigsocket_client::{SigSocketClient, SignRequest, SignResponse, SignRequestHandler, Result, SigSocketError};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// Example sign request handler
|
||||
///
|
||||
/// In a real application, this would:
|
||||
/// - Present the request to the user
|
||||
/// - Get user approval
|
||||
/// - Use a secure signing method (hardware wallet, etc.)
|
||||
/// - Return the signature
|
||||
struct ExampleSignHandler;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl SignRequestHandler for ExampleSignHandler {
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
|
||||
println!("📝 Received sign request:");
|
||||
println!(" ID: {}", request.id);
|
||||
println!(" Message (base64): {}", request.message);
|
||||
|
||||
// Decode the message to show what we're signing
|
||||
match request.message_bytes() {
|
||||
Ok(message_bytes) => {
|
||||
println!(" Message (hex): {}", hex::encode(&message_bytes));
|
||||
println!(" Message (text): {}", String::from_utf8_lossy(&message_bytes));
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" ⚠️ Failed to decode message: {}", e);
|
||||
return Err(SigSocketError::Base64(e.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// In a real implementation, you would:
|
||||
// 1. Show this to the user
|
||||
// 2. Get user approval
|
||||
// 3. Sign the message using a secure method
|
||||
|
||||
println!("🤔 Would you like to sign this message? (This is a simulation)");
|
||||
println!("✅ Auto-approving for demo purposes...");
|
||||
|
||||
// Simulate signing - in reality, this would be a real signature
|
||||
let fake_signature = format!("fake_signature_for_{}", request.id);
|
||||
Ok(fake_signature.into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Initialize logging
|
||||
env_logger::init();
|
||||
|
||||
println!("🚀 SigSocket Client Example");
|
||||
println!("============================");
|
||||
|
||||
// Example public key (in a real app, this would be your actual public key)
|
||||
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9388")
|
||||
.expect("Invalid public key hex");
|
||||
|
||||
println!("🔑 Public key: {}", hex::encode(&public_key));
|
||||
|
||||
// Create the client
|
||||
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)?;
|
||||
println!("📡 Created client for: {}", client.url());
|
||||
|
||||
// Set up the sign request handler
|
||||
client.set_sign_handler(ExampleSignHandler);
|
||||
println!("✅ Sign request handler configured");
|
||||
|
||||
// Connect to the server
|
||||
println!("🔌 Connecting to sigsocket server...");
|
||||
match client.connect().await {
|
||||
Ok(()) => {
|
||||
println!("✅ Connected successfully!");
|
||||
println!("📊 Connection state: {:?}", client.state());
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ Failed to connect: {}", e);
|
||||
println!("💡 Make sure the sigsocket server is running on localhost:8080");
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the connection alive and handle requests
|
||||
println!("👂 Listening for signature requests...");
|
||||
println!(" (Press Ctrl+C to exit)");
|
||||
|
||||
// In a real application, you might want to:
|
||||
// - Handle reconnection
|
||||
// - Provide a UI for user interaction
|
||||
// - Manage multiple concurrent requests
|
||||
// - Store and manage signatures
|
||||
|
||||
// For this example, we'll just wait
|
||||
tokio::signal::ctrl_c().await.expect("Failed to listen for ctrl-c");
|
||||
|
||||
println!("\n🛑 Shutting down...");
|
||||
client.disconnect().await?;
|
||||
println!("✅ Disconnected cleanly");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Example of how you might manually send a response (if needed)
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(dead_code)]
|
||||
async fn send_manual_response(client: &SigSocketClient) -> Result<()> {
|
||||
let response = SignResponse::new(
|
||||
"example-request-id",
|
||||
"dGVzdCBtZXNzYWdl", // "test message" in base64
|
||||
"ZmFrZV9zaWduYXR1cmU=", // "fake_signature" in base64
|
||||
);
|
||||
|
||||
client.send_sign_response(&response).await?;
|
||||
println!("📤 Sent manual response: {}", response.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// WASM main function (does nothing since this example is native-only)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
// This example is designed for native use only
|
||||
}
|
384
sigsocket_client/src/client.rs
Normal file
384
sigsocket_client/src/client.rs
Normal file
@ -0,0 +1,384 @@
|
||||
//! Main client interface for sigsocket communication
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use alloc::{string::String, vec::Vec, boxed::Box, string::ToString};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use alloc::collections::BTreeMap as HashMap;
|
||||
|
||||
use crate::{SignRequest, SignResponse, Result, SigSocketError};
|
||||
use crate::protocol::ManagedSignRequest;
|
||||
|
||||
|
||||
|
||||
/// Connection state of the sigsocket client
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnectionState {
|
||||
/// Client is disconnected
|
||||
Disconnected,
|
||||
/// Client is connecting
|
||||
Connecting,
|
||||
/// Client is connected and ready
|
||||
Connected,
|
||||
/// Client connection failed
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Trait for handling sign requests from the sigsocket server
|
||||
///
|
||||
/// Applications should implement this trait to handle incoming signature requests.
|
||||
/// The implementation should:
|
||||
/// 1. Present the request to the user
|
||||
/// 2. Get user approval
|
||||
/// 3. Sign the message (using external signing logic)
|
||||
/// 4. Return the signature
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub trait SignRequestHandler: Send + Sync {
|
||||
/// Handle a sign request from the server
|
||||
///
|
||||
/// This method is called when the server sends a signature request.
|
||||
/// The implementation should:
|
||||
/// - Decode and validate the message
|
||||
/// - Present it to the user for approval
|
||||
/// - If approved, sign the message and return the signature
|
||||
/// - If rejected, return an error
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - The sign request from the server
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(signature_bytes)` - The signature as raw bytes
|
||||
/// * `Err(error)` - If the request was rejected or signing failed
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// WASM version of SignRequestHandler (no Send + Sync requirements)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub trait SignRequestHandler {
|
||||
/// Handle a sign request from the server
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// Main sigsocket client
|
||||
///
|
||||
/// This is the primary interface for connecting to sigsocket servers.
|
||||
/// It handles the WebSocket connection, protocol communication, and
|
||||
/// delegates signing requests to the application.
|
||||
pub struct SigSocketClient {
|
||||
/// WebSocket server URL
|
||||
url: String,
|
||||
/// Client's public key (hex-encoded)
|
||||
public_key: Vec<u8>,
|
||||
/// Current connection state
|
||||
state: ConnectionState,
|
||||
/// Sign request handler
|
||||
sign_handler: Option<Box<dyn SignRequestHandler>>,
|
||||
/// Pending sign requests managed by the client
|
||||
pending_requests: HashMap<String, ManagedSignRequest>,
|
||||
/// Connected public key (hex-encoded) - set when connection is established
|
||||
connected_public_key: Option<String>,
|
||||
/// Platform-specific implementation
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
inner: Option<crate::native::NativeClient>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
inner: Option<crate::wasm::WasmClient>,
|
||||
}
|
||||
|
||||
impl SigSocketClient {
|
||||
/// Create a new sigsocket client
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `url` - WebSocket server URL (e.g., "ws://localhost:8080/ws")
|
||||
/// * `public_key` - Client's public key as bytes
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(client)` - New client instance
|
||||
/// * `Err(error)` - If the URL is invalid or public key is invalid
|
||||
pub fn new(url: impl Into<String>, public_key: Vec<u8>) -> Result<Self> {
|
||||
let url = url.into();
|
||||
|
||||
// Validate URL
|
||||
let _ = url::Url::parse(&url)?;
|
||||
|
||||
// Validate public key (should be 33 bytes for compressed secp256k1)
|
||||
if public_key.is_empty() {
|
||||
return Err(SigSocketError::InvalidPublicKey("Public key cannot be empty".into()));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
url,
|
||||
public_key,
|
||||
state: ConnectionState::Disconnected,
|
||||
sign_handler: None,
|
||||
pending_requests: HashMap::new(),
|
||||
connected_public_key: None,
|
||||
inner: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the sign request handler
|
||||
///
|
||||
/// This handler will be called whenever the server sends a signature request.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `handler` - Implementation of SignRequestHandler trait
|
||||
pub fn set_sign_handler<H>(&mut self, handler: H)
|
||||
where
|
||||
H: SignRequestHandler + 'static,
|
||||
{
|
||||
self.sign_handler = Some(Box::new(handler));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Get the current connection state
|
||||
pub fn state(&self) -> ConnectionState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Check if the client is connected
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.state == ConnectionState::Connected
|
||||
}
|
||||
|
||||
/// Get the client's public key as hex string
|
||||
pub fn public_key_hex(&self) -> String {
|
||||
hex::encode(&self.public_key)
|
||||
}
|
||||
|
||||
/// Get the WebSocket server URL
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Get the connected public key (if connected)
|
||||
pub fn connected_public_key(&self) -> Option<&str> {
|
||||
self.connected_public_key.as_deref()
|
||||
}
|
||||
|
||||
// === Request Management Methods ===
|
||||
|
||||
/// Add a pending sign request
|
||||
///
|
||||
/// This is typically called when a sign request is received from the server.
|
||||
/// The request will be stored and can be retrieved later for processing.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - The sign request to add
|
||||
/// * `target_public_key` - The public key this request is intended for
|
||||
pub fn add_pending_request(&mut self, request: SignRequest, target_public_key: String) {
|
||||
let managed_request = ManagedSignRequest::new(request, target_public_key);
|
||||
self.pending_requests.insert(managed_request.id().to_string(), managed_request);
|
||||
}
|
||||
|
||||
/// Remove a pending request by ID
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_id` - The ID of the request to remove
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(request)` - The removed request if it existed
|
||||
/// * `None` - If no request with that ID was found
|
||||
pub fn remove_pending_request(&mut self, request_id: &str) -> Option<ManagedSignRequest> {
|
||||
self.pending_requests.remove(request_id)
|
||||
}
|
||||
|
||||
/// Get a pending request by ID
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_id` - The ID of the request to retrieve
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(request)` - The request if it exists
|
||||
/// * `None` - If no request with that ID was found
|
||||
pub fn get_pending_request(&self, request_id: &str) -> Option<&ManagedSignRequest> {
|
||||
self.pending_requests.get(request_id)
|
||||
}
|
||||
|
||||
/// Get all pending requests
|
||||
///
|
||||
/// # Returns
|
||||
/// * A reference to the HashMap containing all pending requests
|
||||
pub fn get_pending_requests(&self) -> &HashMap<String, ManagedSignRequest> {
|
||||
&self.pending_requests
|
||||
}
|
||||
|
||||
/// Get pending requests filtered by public key
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `public_key` - The public key to filter by (hex-encoded)
|
||||
///
|
||||
/// # Returns
|
||||
/// * A vector of references to requests for the specified public key
|
||||
pub fn get_requests_for_public_key(&self, public_key: &str) -> Vec<&ManagedSignRequest> {
|
||||
self.pending_requests
|
||||
.values()
|
||||
.filter(|req| req.is_for_public_key(public_key))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if a request can be handled for the given public key
|
||||
///
|
||||
/// This performs protocol-level validation without cryptographic operations.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - The sign request to validate
|
||||
/// * `public_key` - The public key to check against (hex-encoded)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `true` - If the request can be handled for this public key
|
||||
/// * `false` - If the request cannot be handled
|
||||
pub fn can_handle_request_for_key(&self, request: &SignRequest, public_key: &str) -> bool {
|
||||
// Basic protocol validation
|
||||
if request.id.is_empty() || request.message.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we can decode the message
|
||||
if request.message_bytes().is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For now, we assume any valid request can be handled for any public key
|
||||
// More sophisticated validation can be added here
|
||||
!public_key.is_empty()
|
||||
}
|
||||
|
||||
/// Clear all pending requests
|
||||
pub fn clear_pending_requests(&mut self) {
|
||||
self.pending_requests.clear();
|
||||
}
|
||||
|
||||
/// Get the count of pending requests
|
||||
pub fn pending_request_count(&self) -> usize {
|
||||
self.pending_requests.len()
|
||||
}
|
||||
}
|
||||
|
||||
// Platform-specific implementations will be added in separate modules
|
||||
impl SigSocketClient {
|
||||
/// Connect to the sigsocket server
|
||||
///
|
||||
/// This establishes a WebSocket connection and sends the introduction message
|
||||
/// with the client's public key.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Successfully connected
|
||||
/// * `Err(error)` - Connection failed
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
if self.state == ConnectionState::Connected {
|
||||
return Err(SigSocketError::AlreadyConnected);
|
||||
}
|
||||
|
||||
self.state = ConnectionState::Connecting;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut client = crate::native::NativeClient::new(&self.url, &self.public_key)?;
|
||||
if let Some(handler) = self.sign_handler.take() {
|
||||
client.set_sign_handler_boxed(handler);
|
||||
}
|
||||
client.connect().await?;
|
||||
self.inner = Some(client);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let mut client = crate::wasm::WasmClient::new(&self.url, &self.public_key)?;
|
||||
if let Some(handler) = self.sign_handler.take() {
|
||||
client.set_sign_handler_boxed(handler);
|
||||
}
|
||||
client.connect().await?;
|
||||
self.inner = Some(client);
|
||||
}
|
||||
|
||||
self.state = ConnectionState::Connected;
|
||||
self.connected_public_key = Some(self.public_key_hex());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect from the sigsocket server
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Successfully disconnected
|
||||
/// * `Err(error)` - Disconnect failed
|
||||
pub async fn disconnect(&mut self) -> Result<()> {
|
||||
if let Some(inner) = &mut self.inner {
|
||||
inner.disconnect().await?;
|
||||
}
|
||||
self.inner = None;
|
||||
self.state = ConnectionState::Disconnected;
|
||||
self.connected_public_key = None;
|
||||
self.clear_pending_requests();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a sign response to the server
|
||||
///
|
||||
/// This is typically called after the user has approved a signature request
|
||||
/// and the application has generated the signature.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `response` - The sign response containing the signature
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Response sent successfully
|
||||
/// * `Err(error)` - Failed to send response
|
||||
pub async fn send_sign_response(&self, response: &SignResponse) -> Result<()> {
|
||||
if !self.is_connected() {
|
||||
return Err(SigSocketError::NotConnected);
|
||||
}
|
||||
|
||||
if let Some(inner) = &self.inner {
|
||||
inner.send_sign_response(response).await
|
||||
} else {
|
||||
Err(SigSocketError::NotConnected)
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a response for a specific request ID with signature
|
||||
///
|
||||
/// This is a convenience method that creates a SignResponse and sends it.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_id` - The ID of the request being responded to
|
||||
/// * `message` - The original message (base64-encoded)
|
||||
/// * `signature` - The signature (base64-encoded)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Response sent successfully
|
||||
/// * `Err(error)` - Failed to send response
|
||||
pub async fn send_response(&self, request_id: &str, message: &str, signature: &str) -> Result<()> {
|
||||
let response = SignResponse::new(request_id, message, signature);
|
||||
self.send_sign_response(&response).await
|
||||
}
|
||||
|
||||
/// Send a rejection for a specific request ID
|
||||
///
|
||||
/// This sends an error response to indicate the request was rejected.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_id` - The ID of the request being rejected
|
||||
/// * `reason` - The reason for rejection
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Rejection sent successfully
|
||||
/// * `Err(error)` - Failed to send rejection
|
||||
pub async fn send_rejection(&self, request_id: &str, _reason: &str) -> Result<()> {
|
||||
// For now, we'll send an empty signature to indicate rejection
|
||||
// This can be improved with a proper rejection protocol
|
||||
let response = SignResponse::new(request_id, "", "");
|
||||
self.send_sign_response(&response).await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SigSocketClient {
|
||||
fn drop(&mut self) {
|
||||
// Cleanup will be handled by the platform-specific implementations
|
||||
}
|
||||
}
|
||||
|
||||
|
168
sigsocket_client/src/error.rs
Normal file
168
sigsocket_client/src/error.rs
Normal file
@ -0,0 +1,168 @@
|
||||
//! Error types for the sigsocket client
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use alloc::{string::{String, ToString}, format};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use thiserror::Error;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use core::fmt;
|
||||
|
||||
/// Result type alias for sigsocket client operations
|
||||
pub type Result<T> = core::result::Result<T, SigSocketError>;
|
||||
|
||||
/// Error types that can occur when using the sigsocket client
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SigSocketError {
|
||||
/// WebSocket connection error
|
||||
#[error("Connection error: {0}")]
|
||||
Connection(String),
|
||||
|
||||
/// WebSocket protocol error
|
||||
#[error("Protocol error: {0}")]
|
||||
Protocol(String),
|
||||
|
||||
/// Message serialization/deserialization error
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
|
||||
/// Invalid public key format
|
||||
#[error("Invalid public key: {0}")]
|
||||
InvalidPublicKey(String),
|
||||
|
||||
/// Invalid URL format
|
||||
#[error("Invalid URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
|
||||
/// Client is not connected
|
||||
#[error("Client is not connected")]
|
||||
NotConnected,
|
||||
|
||||
/// Client is already connected
|
||||
#[error("Client is already connected")]
|
||||
AlreadyConnected,
|
||||
|
||||
/// Timeout error
|
||||
#[error("Operation timed out")]
|
||||
Timeout,
|
||||
|
||||
/// Send error
|
||||
#[error("Failed to send message: {0}")]
|
||||
Send(String),
|
||||
|
||||
/// Receive error
|
||||
#[error("Failed to receive message: {0}")]
|
||||
Receive(String),
|
||||
|
||||
/// Base64 encoding/decoding error
|
||||
#[error("Base64 error: {0}")]
|
||||
Base64(String),
|
||||
|
||||
/// Hex encoding/decoding error
|
||||
#[error("Hex error: {0}")]
|
||||
Hex(String),
|
||||
|
||||
/// Generic error
|
||||
#[error("Error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// WASM version of error types (no thiserror dependency)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(Debug)]
|
||||
pub enum SigSocketError {
|
||||
/// WebSocket connection error
|
||||
Connection(String),
|
||||
/// WebSocket protocol error
|
||||
Protocol(String),
|
||||
/// Message serialization/deserialization error
|
||||
Serialization(String),
|
||||
/// Invalid public key format
|
||||
InvalidPublicKey(String),
|
||||
/// Invalid URL format
|
||||
InvalidUrl(String),
|
||||
/// Client is not connected
|
||||
NotConnected,
|
||||
/// Client is already connected
|
||||
AlreadyConnected,
|
||||
/// Timeout error
|
||||
Timeout,
|
||||
/// Send error
|
||||
Send(String),
|
||||
/// Receive error
|
||||
Receive(String),
|
||||
/// Base64 encoding/decoding error
|
||||
Base64(String),
|
||||
/// Hex encoding/decoding error
|
||||
Hex(String),
|
||||
/// Generic error
|
||||
Other(String),
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl fmt::Display for SigSocketError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SigSocketError::Connection(msg) => write!(f, "Connection error: {}", msg),
|
||||
SigSocketError::Protocol(msg) => write!(f, "Protocol error: {}", msg),
|
||||
SigSocketError::Serialization(msg) => write!(f, "Serialization error: {}", msg),
|
||||
SigSocketError::InvalidPublicKey(msg) => write!(f, "Invalid public key: {}", msg),
|
||||
SigSocketError::InvalidUrl(msg) => write!(f, "Invalid URL: {}", msg),
|
||||
SigSocketError::NotConnected => write!(f, "Client is not connected"),
|
||||
SigSocketError::AlreadyConnected => write!(f, "Client is already connected"),
|
||||
SigSocketError::Timeout => write!(f, "Operation timed out"),
|
||||
SigSocketError::Send(msg) => write!(f, "Failed to send message: {}", msg),
|
||||
SigSocketError::Receive(msg) => write!(f, "Failed to receive message: {}", msg),
|
||||
SigSocketError::Base64(msg) => write!(f, "Base64 error: {}", msg),
|
||||
SigSocketError::Hex(msg) => write!(f, "Hex error: {}", msg),
|
||||
SigSocketError::Other(msg) => write!(f, "Error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Implement From traits for common error types
|
||||
impl From<serde_json::Error> for SigSocketError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
SigSocketError::Serialization(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for SigSocketError {
|
||||
fn from(err: base64::DecodeError) -> Self {
|
||||
SigSocketError::Base64(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<hex::FromHexError> for SigSocketError {
|
||||
fn from(err: hex::FromHexError) -> Self {
|
||||
SigSocketError::Hex(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<url::ParseError> for SigSocketError {
|
||||
fn from(err: url::ParseError) -> Self {
|
||||
SigSocketError::InvalidUrl(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Native-specific error conversions
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_errors {
|
||||
use super::SigSocketError;
|
||||
|
||||
impl From<tokio_tungstenite::tungstenite::Error> for SigSocketError {
|
||||
fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||
SigSocketError::Connection(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WASM-specific error conversions
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl From<wasm_bindgen::JsValue> for SigSocketError {
|
||||
fn from(err: wasm_bindgen::JsValue) -> Self {
|
||||
SigSocketError::Other(format!("{:?}", err))
|
||||
}
|
||||
}
|
72
sigsocket_client/src/lib.rs
Normal file
72
sigsocket_client/src/lib.rs
Normal file
@ -0,0 +1,72 @@
|
||||
//! # SigSocket Client
|
||||
//!
|
||||
//! A WebSocket client library for connecting to sigsocket servers with WASM-first support.
|
||||
//!
|
||||
//! This library provides a unified interface for both native and WASM environments,
|
||||
//! allowing applications to connect to sigsocket servers using a public key and handle
|
||||
//! incoming signature requests.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **WASM-first design**: Optimized for browser environments
|
||||
//! - **Native support**: Works in native Rust applications
|
||||
//! - **No signing logic**: Delegates signing to the application
|
||||
//! - **User approval flow**: Notifies applications about incoming requests
|
||||
//! - **sigsocket compatible**: Fully compatible with sigsocket server protocol
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use sigsocket_client::{SigSocketClient, SignRequest, SignRequestHandler, Result};
|
||||
//!
|
||||
//! struct MyHandler;
|
||||
//! impl SignRequestHandler for MyHandler {
|
||||
//! fn handle_sign_request(&self, _request: &SignRequest) -> Result<Vec<u8>> {
|
||||
//! Ok(b"fake_signature".to_vec())
|
||||
//! }
|
||||
//! }
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> Result<()> {
|
||||
//! // Create client with public key
|
||||
//! let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9").unwrap();
|
||||
//! let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)?;
|
||||
//!
|
||||
//! // Set up request handler
|
||||
//! client.set_sign_handler(MyHandler);
|
||||
//!
|
||||
//! // Connect to server
|
||||
//! client.connect().await?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
#![cfg_attr(target_arch = "wasm32", no_std)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
extern crate alloc;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use alloc::{string::String, vec::Vec};
|
||||
|
||||
mod error;
|
||||
mod protocol;
|
||||
mod client;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod wasm;
|
||||
|
||||
pub use error::{SigSocketError, Result};
|
||||
pub use protocol::{SignRequest, SignResponse, ManagedSignRequest, RequestStatus};
|
||||
pub use client::{SigSocketClient, SignRequestHandler, ConnectionState};
|
||||
|
||||
// Re-export for convenience
|
||||
pub mod prelude {
|
||||
pub use crate::{
|
||||
SigSocketClient, SignRequest, SignResponse, ManagedSignRequest, RequestStatus,
|
||||
SignRequestHandler, ConnectionState, SigSocketError, Result
|
||||
};
|
||||
}
|
232
sigsocket_client/src/native.rs
Normal file
232
sigsocket_client/src/native.rs
Normal file
@ -0,0 +1,232 @@
|
||||
//! Native (non-WASM) implementation of the sigsocket client
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, RwLock};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use url::Url;
|
||||
|
||||
use crate::{SignRequest, SignResponse, SignRequestHandler, Result, SigSocketError};
|
||||
|
||||
/// Native WebSocket client implementation
|
||||
pub struct NativeClient {
|
||||
url: String,
|
||||
public_key: Vec<u8>,
|
||||
sign_handler: Option<Arc<dyn SignRequestHandler>>,
|
||||
sender: Option<mpsc::UnboundedSender<Message>>,
|
||||
connected: Arc<RwLock<bool>>,
|
||||
reconnect_attempts: u32,
|
||||
max_reconnect_attempts: u32,
|
||||
reconnect_delay_ms: u64,
|
||||
}
|
||||
|
||||
impl NativeClient {
|
||||
/// Create a new native client
|
||||
pub fn new(url: &str, public_key: &[u8]) -> Result<Self> {
|
||||
Ok(Self {
|
||||
url: url.to_string(),
|
||||
public_key: public_key.to_vec(),
|
||||
sign_handler: None,
|
||||
sender: None,
|
||||
connected: Arc::new(RwLock::new(false)),
|
||||
reconnect_attempts: 0,
|
||||
max_reconnect_attempts: 5,
|
||||
reconnect_delay_ms: 1000, // Start with 1 second
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the sign request handler
|
||||
pub fn set_sign_handler<H>(&mut self, handler: H)
|
||||
where
|
||||
H: SignRequestHandler + 'static,
|
||||
{
|
||||
self.sign_handler = Some(Arc::new(handler));
|
||||
}
|
||||
|
||||
/// Set the sign request handler from a boxed trait object
|
||||
pub fn set_sign_handler_boxed(&mut self, handler: Box<dyn SignRequestHandler>) {
|
||||
self.sign_handler = Some(Arc::from(handler));
|
||||
}
|
||||
|
||||
/// Connect to the WebSocket server with automatic reconnection
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
self.reconnect_attempts = 0;
|
||||
self.connect_with_retry().await
|
||||
}
|
||||
|
||||
/// Connect with retry logic
|
||||
async fn connect_with_retry(&mut self) -> Result<()> {
|
||||
loop {
|
||||
match self.try_connect().await {
|
||||
Ok(()) => {
|
||||
self.reconnect_attempts = 0; // Reset on successful connection
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
self.reconnect_attempts += 1;
|
||||
|
||||
if self.reconnect_attempts > self.max_reconnect_attempts {
|
||||
log::error!("Max reconnection attempts ({}) exceeded", self.max_reconnect_attempts);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
let delay = self.reconnect_delay_ms * (2_u64.pow(self.reconnect_attempts - 1)); // Exponential backoff
|
||||
log::warn!("Connection failed (attempt {}/{}), retrying in {}ms: {}",
|
||||
self.reconnect_attempts, self.max_reconnect_attempts, delay, e);
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Single connection attempt
|
||||
async fn try_connect(&mut self) -> Result<()> {
|
||||
let url = Url::parse(&self.url)?;
|
||||
|
||||
// Connect to WebSocket
|
||||
let (ws_stream, _) = connect_async(url).await
|
||||
.map_err(|e| SigSocketError::Connection(e.to_string()))?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// Send introduction message (hex-encoded public key)
|
||||
let intro_message = hex::encode(&self.public_key);
|
||||
write.send(Message::Text(intro_message)).await
|
||||
.map_err(|e| SigSocketError::Send(e.to_string()))?;
|
||||
|
||||
// Set up message sender channel
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
self.sender = Some(tx);
|
||||
|
||||
// Set connected state
|
||||
*self.connected.write().await = true;
|
||||
|
||||
// Spawn write task
|
||||
let write_task = tokio::spawn(async move {
|
||||
while let Some(message) = rx.recv().await {
|
||||
if let Err(e) = write.send(message).await {
|
||||
log::error!("Failed to send message: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn read task
|
||||
let connected = self.connected.clone();
|
||||
let sign_handler = self.sign_handler.clone();
|
||||
let sender = self.sender.as_ref().unwrap().clone();
|
||||
|
||||
let read_task = tokio::spawn(async move {
|
||||
while let Some(message) = read.next().await {
|
||||
match message {
|
||||
Ok(Message::Text(text)) => {
|
||||
if let Err(e) = Self::handle_text_message(&text, &sign_handler, &sender).await {
|
||||
log::error!("Failed to handle message: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => {
|
||||
log::info!("WebSocket connection closed");
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
// Ignore other message types
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as disconnected
|
||||
*connected.write().await = false;
|
||||
});
|
||||
|
||||
// Store tasks (in a real implementation, you'd want to manage these properly)
|
||||
tokio::spawn(async move {
|
||||
let _ = tokio::try_join!(write_task, read_task);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle incoming text messages
|
||||
async fn handle_text_message(
|
||||
text: &str,
|
||||
sign_handler: &Option<Arc<dyn SignRequestHandler>>,
|
||||
sender: &mpsc::UnboundedSender<Message>,
|
||||
) -> Result<()> {
|
||||
log::debug!("Received message: {}", text);
|
||||
|
||||
// Handle simple acknowledgment messages
|
||||
if text == "Connected" {
|
||||
log::info!("Server acknowledged connection");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try to parse as sign request
|
||||
if let Ok(sign_request) = serde_json::from_str::<SignRequest>(text) {
|
||||
if let Some(handler) = sign_handler {
|
||||
// Handle the sign request
|
||||
match handler.handle_sign_request(&sign_request) {
|
||||
Ok(signature) => {
|
||||
// Create and send response
|
||||
let response = SignResponse::from_request_and_signature(&sign_request, &signature);
|
||||
let response_json = serde_json::to_string(&response)?;
|
||||
|
||||
sender.send(Message::Text(response_json))
|
||||
.map_err(|e| SigSocketError::Send(e.to_string()))?;
|
||||
|
||||
log::info!("Sent signature response for request {}", response.id);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Sign request rejected: {}", e);
|
||||
// Optionally send an error response to the server
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!("No sign request handler registered, ignoring request");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::warn!("Failed to parse message: {}", text);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect from the WebSocket server
|
||||
pub async fn disconnect(&mut self) -> Result<()> {
|
||||
*self.connected.write().await = false;
|
||||
|
||||
if let Some(sender) = &self.sender {
|
||||
// Send close message
|
||||
let _ = sender.send(Message::Close(None));
|
||||
}
|
||||
|
||||
self.sender = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a sign response to the server
|
||||
pub async fn send_sign_response(&self, response: &SignResponse) -> Result<()> {
|
||||
if let Some(sender) = &self.sender {
|
||||
let response_json = serde_json::to_string(response)?;
|
||||
sender.send(Message::Text(response_json))
|
||||
.map_err(|e| SigSocketError::Send(e.to_string()))?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SigSocketError::NotConnected)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if connected
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
*self.connected.read().await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NativeClient {
|
||||
fn drop(&mut self) {
|
||||
// Cleanup will be handled by the async tasks
|
||||
}
|
||||
}
|
256
sigsocket_client/src/protocol.rs
Normal file
256
sigsocket_client/src/protocol.rs
Normal file
@ -0,0 +1,256 @@
|
||||
//! Protocol definitions for sigsocket communication
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use alloc::{string::String, vec::Vec};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Sign request from the sigsocket server
|
||||
///
|
||||
/// This represents a request from the server for the client to sign a message.
|
||||
/// The client should present this to the user for approval before signing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SignRequest {
|
||||
/// Unique identifier for this request
|
||||
pub id: String,
|
||||
/// Message to be signed (base64-encoded)
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Sign response to send back to the sigsocket server
|
||||
///
|
||||
/// This represents the client's response after the user has approved and signed the message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SignResponse {
|
||||
/// Request identifier (must match the original request)
|
||||
pub id: String,
|
||||
/// Original message that was signed (base64-encoded)
|
||||
pub message: String,
|
||||
/// Signature of the message (base64-encoded)
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
impl SignRequest {
|
||||
/// Create a new sign request
|
||||
pub fn new(id: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the message as bytes (decoded from base64)
|
||||
pub fn message_bytes(&self) -> Result<Vec<u8>, base64::DecodeError> {
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &self.message)
|
||||
}
|
||||
|
||||
/// Get the message as a hex string (for display purposes)
|
||||
pub fn message_hex(&self) -> Result<String, base64::DecodeError> {
|
||||
self.message_bytes().map(|bytes| hex::encode(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl SignResponse {
|
||||
/// Create a new sign response
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
message: impl Into<String>,
|
||||
signature: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
message: message.into(),
|
||||
signature: signature.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a sign response from a request and signature bytes
|
||||
pub fn from_request_and_signature(
|
||||
request: &SignRequest,
|
||||
signature: &[u8],
|
||||
) -> Self {
|
||||
Self {
|
||||
id: request.id.clone(),
|
||||
message: request.message.clone(),
|
||||
signature: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, signature),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the signature as bytes (decoded from base64)
|
||||
pub fn signature_bytes(&self) -> Result<Vec<u8>, base64::DecodeError> {
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &self.signature)
|
||||
}
|
||||
}
|
||||
|
||||
/// Enhanced sign request with additional metadata for request management
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ManagedSignRequest {
|
||||
/// The original sign request
|
||||
#[serde(flatten)]
|
||||
pub request: SignRequest,
|
||||
/// Timestamp when the request was received (Unix timestamp in milliseconds)
|
||||
pub timestamp: u64,
|
||||
/// Target public key for this request (hex-encoded)
|
||||
pub target_public_key: String,
|
||||
/// Current status of the request
|
||||
pub status: RequestStatus,
|
||||
}
|
||||
|
||||
/// Status of a sign request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum RequestStatus {
|
||||
/// Request is pending user approval
|
||||
Pending,
|
||||
/// Request has been approved and signed
|
||||
Approved,
|
||||
/// Request has been rejected by user
|
||||
Rejected,
|
||||
/// Request has expired or been cancelled
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl ManagedSignRequest {
|
||||
/// Create a new managed sign request
|
||||
pub fn new(request: SignRequest, target_public_key: String) -> Self {
|
||||
Self {
|
||||
request,
|
||||
timestamp: current_timestamp_ms(),
|
||||
target_public_key,
|
||||
status: RequestStatus::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the request ID
|
||||
pub fn id(&self) -> &str {
|
||||
&self.request.id
|
||||
}
|
||||
|
||||
/// Get the message as bytes (decoded from base64)
|
||||
pub fn message_bytes(&self) -> Result<Vec<u8>, base64::DecodeError> {
|
||||
self.request.message_bytes()
|
||||
}
|
||||
|
||||
/// Check if this request is for the given public key
|
||||
pub fn is_for_public_key(&self, public_key: &str) -> bool {
|
||||
self.target_public_key == public_key
|
||||
}
|
||||
|
||||
/// Mark the request as approved
|
||||
pub fn mark_approved(&mut self) {
|
||||
self.status = RequestStatus::Approved;
|
||||
}
|
||||
|
||||
/// Mark the request as rejected
|
||||
pub fn mark_rejected(&mut self) {
|
||||
self.status = RequestStatus::Rejected;
|
||||
}
|
||||
|
||||
/// Check if the request is still pending
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self.status, RequestStatus::Pending)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current timestamp in milliseconds
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn current_timestamp_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
/// Get current timestamp in milliseconds (WASM version)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn current_timestamp_ms() -> u64 {
|
||||
// In WASM, we'll use a simple counter or Date.now() via JS
|
||||
// For now, return 0 - this can be improved later
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sign_request_creation() {
|
||||
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl"); // "test message" in base64
|
||||
assert_eq!(request.id, "test-id");
|
||||
assert_eq!(request.message, "dGVzdCBtZXNzYWdl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_request_message_bytes() {
|
||||
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl"); // "test message" in base64
|
||||
let bytes = request.message_bytes().unwrap();
|
||||
assert_eq!(bytes, b"test message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_request_message_hex() {
|
||||
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl"); // "test message" in base64
|
||||
let hex = request.message_hex().unwrap();
|
||||
assert_eq!(hex, hex::encode(b"test message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_response_creation() {
|
||||
let response = SignResponse::new("test-id", "dGVzdCBtZXNzYWdl", "c2lnbmF0dXJl"); // "signature" in base64
|
||||
assert_eq!(response.id, "test-id");
|
||||
assert_eq!(response.message, "dGVzdCBtZXNzYWdl");
|
||||
assert_eq!(response.signature, "c2lnbmF0dXJl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_response_from_request() {
|
||||
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl");
|
||||
let signature = b"signature";
|
||||
let response = SignResponse::from_request_and_signature(&request, signature);
|
||||
|
||||
assert_eq!(response.id, request.id);
|
||||
assert_eq!(response.message, request.message);
|
||||
assert_eq!(response.signature_bytes().unwrap(), signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialization() {
|
||||
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl");
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
let deserialized: SignRequest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(request, deserialized);
|
||||
|
||||
let response = SignResponse::new("test-id", "dGVzdCBtZXNzYWdl", "c2lnbmF0dXJl");
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
let deserialized: SignResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(response, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_managed_sign_request() {
|
||||
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl");
|
||||
let managed = ManagedSignRequest::new(request.clone(), "test-public-key".to_string());
|
||||
|
||||
assert_eq!(managed.id(), "test-id");
|
||||
assert_eq!(managed.request, request);
|
||||
assert_eq!(managed.target_public_key, "test-public-key");
|
||||
assert!(managed.is_pending());
|
||||
assert!(managed.is_for_public_key("test-public-key"));
|
||||
assert!(!managed.is_for_public_key("other-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_managed_request_status_changes() {
|
||||
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl");
|
||||
let mut managed = ManagedSignRequest::new(request, "test-public-key".to_string());
|
||||
|
||||
assert!(managed.is_pending());
|
||||
|
||||
managed.mark_approved();
|
||||
assert_eq!(managed.status, RequestStatus::Approved);
|
||||
assert!(!managed.is_pending());
|
||||
|
||||
managed.mark_rejected();
|
||||
assert_eq!(managed.status, RequestStatus::Rejected);
|
||||
assert!(!managed.is_pending());
|
||||
}
|
||||
}
|
549
sigsocket_client/src/wasm.rs
Normal file
549
sigsocket_client/src/wasm.rs
Normal file
@ -0,0 +1,549 @@
|
||||
//! WASM implementation of the sigsocket client
|
||||
|
||||
use alloc::{string::{String, ToString}, vec::Vec, boxed::Box, rc::Rc, format};
|
||||
use core::cell::RefCell;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{WebSocket, MessageEvent, Event, BinaryType};
|
||||
|
||||
use crate::{SignRequest, SignResponse, SignRequestHandler, Result, SigSocketError};
|
||||
|
||||
/// WASM WebSocket client implementation
|
||||
pub struct WasmClient {
|
||||
url: String,
|
||||
public_key: Vec<u8>,
|
||||
sign_handler: Option<Rc<RefCell<Box<dyn SignRequestHandler>>>>,
|
||||
websocket: Option<WebSocket>,
|
||||
connected: Rc<RefCell<bool>>,
|
||||
reconnect_attempts: Rc<RefCell<u32>>,
|
||||
max_reconnect_attempts: u32,
|
||||
reconnect_delay_ms: u64,
|
||||
auto_reconnect: bool,
|
||||
}
|
||||
|
||||
impl WasmClient {
|
||||
/// Create a new WASM client
|
||||
pub fn new(url: &str, public_key: &[u8]) -> Result<Self> {
|
||||
Ok(Self {
|
||||
url: url.to_string(),
|
||||
public_key: public_key.to_vec(),
|
||||
sign_handler: None,
|
||||
websocket: None,
|
||||
connected: Rc::new(RefCell::new(false)),
|
||||
reconnect_attempts: Rc::new(RefCell::new(0)),
|
||||
max_reconnect_attempts: 5,
|
||||
reconnect_delay_ms: 1000, // Start with 1 second
|
||||
auto_reconnect: false, // Disable auto-reconnect to avoid multiple connections
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the sign request handler from a boxed trait object
|
||||
pub fn set_sign_handler_boxed(&mut self, handler: Box<dyn SignRequestHandler>) {
|
||||
self.sign_handler = Some(Rc::new(RefCell::new(handler)));
|
||||
}
|
||||
|
||||
/// Enable or disable automatic reconnection
|
||||
pub fn set_auto_reconnect(&mut self, enabled: bool) {
|
||||
self.auto_reconnect = enabled;
|
||||
}
|
||||
|
||||
/// Set reconnection parameters
|
||||
pub fn set_reconnect_config(&mut self, max_attempts: u32, initial_delay_ms: u64) {
|
||||
self.max_reconnect_attempts = max_attempts;
|
||||
self.reconnect_delay_ms = initial_delay_ms;
|
||||
}
|
||||
|
||||
/// Connect to the WebSocket server with automatic reconnection
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
*self.reconnect_attempts.borrow_mut() = 0;
|
||||
self.connect_with_retry().await
|
||||
}
|
||||
|
||||
/// Connect with retry logic
|
||||
async fn connect_with_retry(&mut self) -> Result<()> {
|
||||
loop {
|
||||
match self.try_connect().await {
|
||||
Ok(()) => {
|
||||
*self.reconnect_attempts.borrow_mut() = 0; // Reset on successful connection
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
let mut attempts = self.reconnect_attempts.borrow_mut();
|
||||
*attempts += 1;
|
||||
|
||||
if *attempts > self.max_reconnect_attempts {
|
||||
web_sys::console::error_1(&format!("Max reconnection attempts ({}) exceeded", self.max_reconnect_attempts).into());
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
let delay = self.reconnect_delay_ms * (2_u64.pow(*attempts - 1)); // Exponential backoff
|
||||
web_sys::console::warn_1(&format!("Connection failed (attempt {}/{}), retrying in {}ms: {}",
|
||||
*attempts, self.max_reconnect_attempts, delay, e).into());
|
||||
|
||||
// Drop the borrow before the async sleep
|
||||
drop(attempts);
|
||||
|
||||
// Wait before retrying
|
||||
self.sleep_ms(delay).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sleep for the specified number of milliseconds (WASM-compatible)
|
||||
async fn sleep_ms(&self, ms: u64) -> () {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use js_sys::Promise;
|
||||
|
||||
let promise = Promise::new(&mut |resolve, _reject| {
|
||||
let timeout_callback = Closure::wrap(Box::new(move || {
|
||||
resolve.call0(&wasm_bindgen::JsValue::UNDEFINED).unwrap();
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
timeout_callback.as_ref().unchecked_ref(),
|
||||
ms as i32,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
timeout_callback.forget();
|
||||
});
|
||||
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
/// Single connection attempt
|
||||
async fn try_connect(&mut self) -> Result<()> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use js_sys::Promise;
|
||||
|
||||
web_sys::console::log_1(&format!("try_connect: Creating WebSocket to {}", self.url).into());
|
||||
|
||||
// Create WebSocket
|
||||
let ws = WebSocket::new(&self.url)
|
||||
.map_err(|e| {
|
||||
web_sys::console::error_1(&format!("Failed to create WebSocket: {:?}", e).into());
|
||||
SigSocketError::Connection(format!("{:?}", e))
|
||||
})?;
|
||||
|
||||
web_sys::console::log_1(&"try_connect: WebSocket created successfully".into());
|
||||
|
||||
// Set binary type
|
||||
ws.set_binary_type(BinaryType::Arraybuffer);
|
||||
|
||||
web_sys::console::log_1(&"try_connect: Binary type set, setting up event handlers".into());
|
||||
|
||||
let connected = self.connected.clone();
|
||||
let public_key = self.public_key.clone();
|
||||
|
||||
// Set up onopen handler
|
||||
{
|
||||
let ws_clone = ws.clone();
|
||||
let public_key_clone = public_key.clone();
|
||||
|
||||
let onopen_callback = Closure::<dyn FnMut(Event)>::new(move |_event| {
|
||||
web_sys::console::log_1(&"MAIN CONNECTION: WebSocket opened, sending public key introduction".into());
|
||||
|
||||
// Send introduction message (hex-encoded public key)
|
||||
let intro_message = hex::encode(&public_key_clone);
|
||||
web_sys::console::log_1(&format!("MAIN CONNECTION: Sending public key: {}", &intro_message[..16]).into());
|
||||
|
||||
if let Err(e) = ws_clone.send_with_str(&intro_message) {
|
||||
web_sys::console::error_1(&format!("MAIN CONNECTION: Failed to send introduction: {:?}", e).into());
|
||||
} else {
|
||||
web_sys::console::log_1(&"MAIN CONNECTION: Public key sent successfully".into());
|
||||
}
|
||||
});
|
||||
|
||||
ws.set_onopen(Some(onopen_callback.as_ref().unchecked_ref()));
|
||||
onopen_callback.forget(); // Prevent cleanup
|
||||
|
||||
web_sys::console::log_1(&"try_connect: onopen handler set up".into());
|
||||
}
|
||||
|
||||
// Set up onmessage handler
|
||||
{
|
||||
let ws_clone = ws.clone();
|
||||
let handler_clone = self.sign_handler.clone();
|
||||
let connected_clone = connected.clone();
|
||||
|
||||
let onmessage_callback = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
|
||||
if let Ok(text) = event.data().dyn_into::<js_sys::JsString>() {
|
||||
let message = text.as_string().unwrap_or_default();
|
||||
web_sys::console::log_1(&format!("MAIN CONNECTION: Received message: {}", message).into());
|
||||
|
||||
// Check if this is the "Connected" acknowledgment
|
||||
if message == "Connected" {
|
||||
web_sys::console::log_1(&"MAIN CONNECTION: Server acknowledged connection".into());
|
||||
*connected_clone.borrow_mut() = true;
|
||||
}
|
||||
|
||||
// Handle the message with proper sign request support
|
||||
Self::handle_message(&message, &ws_clone, &handler_clone, &connected_clone);
|
||||
}
|
||||
});
|
||||
|
||||
ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
|
||||
onmessage_callback.forget(); // Prevent cleanup
|
||||
|
||||
web_sys::console::log_1(&"try_connect: onmessage handler set up".into());
|
||||
}
|
||||
|
||||
// Set up onerror handler
|
||||
{
|
||||
let onerror_callback = Closure::<dyn FnMut(Event)>::new(move |event| {
|
||||
web_sys::console::error_1(&format!("MAIN CONNECTION: WebSocket error: {:?}", event).into());
|
||||
});
|
||||
|
||||
ws.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
|
||||
onerror_callback.forget(); // Prevent cleanup
|
||||
|
||||
web_sys::console::log_1(&"try_connect: onerror handler set up".into());
|
||||
}
|
||||
|
||||
// Set up onclose handler with auto-reconnection support
|
||||
{
|
||||
let connected = connected.clone();
|
||||
let auto_reconnect = self.auto_reconnect;
|
||||
let reconnect_attempts = self.reconnect_attempts.clone();
|
||||
let max_attempts = self.max_reconnect_attempts;
|
||||
let url = self.url.clone();
|
||||
let public_key = self.public_key.clone();
|
||||
let sign_handler = self.sign_handler.clone();
|
||||
let delay_ms = self.reconnect_delay_ms;
|
||||
|
||||
let onclose_callback = Closure::<dyn FnMut(Event)>::new(move |_event| {
|
||||
*connected.borrow_mut() = false;
|
||||
web_sys::console::log_1(&"WebSocket connection closed".into());
|
||||
|
||||
// Trigger auto-reconnection if enabled
|
||||
if auto_reconnect {
|
||||
let attempts = reconnect_attempts.clone();
|
||||
let current_attempts = *attempts.borrow();
|
||||
|
||||
if current_attempts < max_attempts {
|
||||
web_sys::console::log_1(&"Attempting automatic reconnection...".into());
|
||||
|
||||
// Schedule reconnection attempt
|
||||
Self::schedule_reconnection(
|
||||
url.clone(),
|
||||
public_key.clone(),
|
||||
sign_handler.clone(),
|
||||
attempts.clone(),
|
||||
max_attempts,
|
||||
delay_ms,
|
||||
connected.clone(),
|
||||
);
|
||||
} else {
|
||||
web_sys::console::error_1(&format!("Max reconnection attempts ({}) reached, giving up", max_attempts).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ws.set_onclose(Some(onclose_callback.as_ref().unchecked_ref()));
|
||||
onclose_callback.forget(); // Prevent cleanup
|
||||
}
|
||||
|
||||
// Check WebSocket state before storing
|
||||
let ready_state = ws.ready_state();
|
||||
web_sys::console::log_1(&format!("try_connect: WebSocket ready state: {}", ready_state).into());
|
||||
|
||||
self.websocket = Some(ws);
|
||||
|
||||
web_sys::console::log_1(&"try_connect: WebSocket stored, waiting for connection to be established".into());
|
||||
|
||||
// The WebSocket will open asynchronously and the onopen/onmessage handlers will handle the connection
|
||||
// Since we can see from logs that the connection is working, just return success
|
||||
web_sys::console::log_1(&"try_connect: WebSocket setup complete, connection will be established asynchronously".into());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for WebSocket connection to be established
|
||||
async fn wait_for_connection(&self) -> Result<()> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use js_sys::Promise;
|
||||
|
||||
web_sys::console::log_1(&"wait_for_connection: Starting to wait for connection".into());
|
||||
|
||||
// Simple approach: just wait a bit and check if we're connected
|
||||
// The onopen handler should have fired by now if the connection is working
|
||||
|
||||
let connected = self.connected.clone();
|
||||
|
||||
// Wait up to 30 seconds, checking every 500ms
|
||||
for attempt in 1..=60 {
|
||||
// Check if we're connected
|
||||
if *connected.borrow() {
|
||||
web_sys::console::log_1(&format!("wait_for_connection: Connected after {} attempts ({}ms)", attempt, attempt * 500).into());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Wait 500ms before next check
|
||||
let promise = Promise::new(&mut |resolve, _reject| {
|
||||
let timeout_callback = Closure::wrap(Box::new(move || {
|
||||
resolve.call0(&wasm_bindgen::JsValue::UNDEFINED).unwrap();
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
timeout_callback.as_ref().unchecked_ref(),
|
||||
500,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
timeout_callback.forget();
|
||||
});
|
||||
|
||||
let _ = JsFuture::from(promise).await;
|
||||
|
||||
if attempt % 10 == 0 {
|
||||
web_sys::console::log_1(&format!("wait_for_connection: Still waiting... attempt {}/60", attempt).into());
|
||||
}
|
||||
}
|
||||
|
||||
web_sys::console::error_1(&"wait_for_connection: Timeout after 30 seconds".into());
|
||||
Err(SigSocketError::Connection("Connection timeout".to_string()))
|
||||
}
|
||||
|
||||
/// Schedule a reconnection attempt (called from onclose handler)
|
||||
fn schedule_reconnection(
|
||||
url: String,
|
||||
public_key: Vec<u8>,
|
||||
sign_handler: Option<Rc<RefCell<Box<dyn SignRequestHandler>>>>,
|
||||
reconnect_attempts: Rc<RefCell<u32>>,
|
||||
_max_attempts: u32,
|
||||
delay_ms: u64,
|
||||
connected: Rc<RefCell<bool>>,
|
||||
) {
|
||||
let mut attempts = reconnect_attempts.borrow_mut();
|
||||
*attempts += 1;
|
||||
let current_attempt = *attempts;
|
||||
drop(attempts); // Release the borrow
|
||||
|
||||
let delay = delay_ms * (2_u64.pow(current_attempt - 1)); // Exponential backoff
|
||||
|
||||
web_sys::console::log_1(&format!("Scheduling reconnection attempt {} in {}ms", current_attempt, delay).into());
|
||||
|
||||
// Schedule the reconnection attempt
|
||||
let timeout_callback = Closure::wrap(Box::new(move || {
|
||||
// Create a new client instance for reconnection
|
||||
match Self::attempt_reconnection(url.clone(), public_key.clone(), sign_handler.clone(), connected.clone()) {
|
||||
Ok(_) => {
|
||||
web_sys::console::log_1(&"Reconnection attempt initiated".into());
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to initiate reconnection: {:?}", e).into());
|
||||
}
|
||||
}
|
||||
}) as Box<dyn FnMut()>);
|
||||
|
||||
web_sys::window()
|
||||
.unwrap()
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(
|
||||
timeout_callback.as_ref().unchecked_ref(),
|
||||
delay as i32,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
timeout_callback.forget();
|
||||
}
|
||||
|
||||
/// Attempt to reconnect (helper method)
|
||||
fn attempt_reconnection(
|
||||
url: String,
|
||||
public_key: Vec<u8>,
|
||||
sign_handler: Option<Rc<RefCell<Box<dyn SignRequestHandler>>>>,
|
||||
connected: Rc<RefCell<bool>>,
|
||||
) -> Result<()> {
|
||||
// Create WebSocket
|
||||
let ws = WebSocket::new(&url)
|
||||
.map_err(|e| SigSocketError::Connection(format!("{:?}", e)))?;
|
||||
|
||||
ws.set_binary_type(BinaryType::Arraybuffer);
|
||||
|
||||
// Send public key on open
|
||||
{
|
||||
let public_key_clone = public_key.clone();
|
||||
let connected_clone = connected.clone();
|
||||
let ws_clone = ws.clone();
|
||||
|
||||
let onopen_callback = Closure::<dyn FnMut(Event)>::new(move |_event| {
|
||||
web_sys::console::log_1(&"Reconnection WebSocket opened, sending public key introduction".into());
|
||||
|
||||
// Send public key introduction
|
||||
let public_key_hex = hex::encode(&public_key_clone);
|
||||
web_sys::console::log_1(&format!("Reconnection sending public key: {}", &public_key_hex[..16]).into());
|
||||
|
||||
if let Err(e) = ws_clone.send_with_str(&public_key_hex) {
|
||||
web_sys::console::error_1(&format!("Failed to send public key on reconnection: {:?}", e).into());
|
||||
} else {
|
||||
web_sys::console::log_1(&"Reconnection public key sent successfully, waiting for server acknowledgment".into());
|
||||
// Don't set connected=true here, wait for "Connected" message
|
||||
}
|
||||
});
|
||||
|
||||
ws.set_onopen(Some(onopen_callback.as_ref().unchecked_ref()));
|
||||
onopen_callback.forget();
|
||||
}
|
||||
|
||||
// Set up message handler for reconnected socket
|
||||
{
|
||||
let ws_clone = ws.clone();
|
||||
let handler_clone = sign_handler.clone();
|
||||
let connected_clone = connected.clone();
|
||||
|
||||
let onmessage_callback = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
|
||||
if let Ok(text) = event.data().dyn_into::<js_sys::JsString>() {
|
||||
let message = text.as_string().unwrap_or_default();
|
||||
Self::handle_message(&message, &ws_clone, &handler_clone, &connected_clone);
|
||||
}
|
||||
});
|
||||
|
||||
ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
|
||||
onmessage_callback.forget();
|
||||
}
|
||||
|
||||
// Set up error handler
|
||||
{
|
||||
let onerror_callback = Closure::<dyn FnMut(Event)>::new(move |event| {
|
||||
web_sys::console::error_1(&format!("Reconnection WebSocket error: {:?}", event).into());
|
||||
});
|
||||
|
||||
ws.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
|
||||
onerror_callback.forget();
|
||||
}
|
||||
|
||||
// Set up close handler (for potential future reconnections)
|
||||
{
|
||||
let connected_clone = connected.clone();
|
||||
let onclose_callback = Closure::<dyn FnMut(Event)>::new(move |_event| {
|
||||
*connected_clone.borrow_mut() = false;
|
||||
web_sys::console::log_1(&"Reconnected WebSocket closed".into());
|
||||
});
|
||||
|
||||
ws.set_onclose(Some(onclose_callback.as_ref().unchecked_ref()));
|
||||
onclose_callback.forget();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle incoming messages with full sign request support
|
||||
fn handle_message(
|
||||
text: &str,
|
||||
ws: &WebSocket,
|
||||
sign_handler: &Option<Rc<RefCell<Box<dyn SignRequestHandler>>>>,
|
||||
connected: &Rc<RefCell<bool>>
|
||||
) {
|
||||
web_sys::console::log_1(&format!("Received message: {}", text).into());
|
||||
|
||||
// Handle simple acknowledgment messages
|
||||
if text == "Connected" {
|
||||
web_sys::console::log_1(&"Server acknowledged connection".into());
|
||||
*connected.borrow_mut() = true;
|
||||
web_sys::console::log_1(&"Connection state updated to connected".into());
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse as sign request
|
||||
if let Ok(sign_request) = serde_json::from_str::<SignRequest>(text) {
|
||||
web_sys::console::log_1(&format!("Received sign request: {}", sign_request.id).into());
|
||||
|
||||
// Handle the sign request if we have a handler
|
||||
if let Some(handler_rc) = sign_handler {
|
||||
match handler_rc.try_borrow() {
|
||||
Ok(handler) => {
|
||||
match handler.handle_sign_request(&sign_request) {
|
||||
Ok(signature) => {
|
||||
// Create and send response
|
||||
let response = SignResponse::from_request_and_signature(&sign_request, &signature);
|
||||
match serde_json::to_string(&response) {
|
||||
Ok(response_json) => {
|
||||
if let Err(e) = ws.send_with_str(&response_json) {
|
||||
web_sys::console::error_1(&format!("Failed to send response: {:?}", e).into());
|
||||
} else {
|
||||
web_sys::console::log_1(&format!("Sent signature response for request {}", response.id).into());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to serialize response: {}", e).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::warn_1(&format!("Sign request rejected: {}", e).into());
|
||||
// Optionally send an error response to the server
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
web_sys::console::error_1(&"Failed to borrow sign handler".into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
web_sys::console::warn_1(&"No sign request handler registered, ignoring request".into());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
web_sys::console::warn_1(&format!("Failed to parse message: {}", text).into());
|
||||
}
|
||||
|
||||
/// Disconnect from the WebSocket server
|
||||
pub async fn disconnect(&mut self) -> Result<()> {
|
||||
if let Some(ws) = &self.websocket {
|
||||
ws.close()
|
||||
.map_err(|e| SigSocketError::Connection(format!("{:?}", e)))?;
|
||||
}
|
||||
|
||||
*self.connected.borrow_mut() = false;
|
||||
self.websocket = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a sign response to the server
|
||||
pub async fn send_sign_response(&self, response: &SignResponse) -> Result<()> {
|
||||
if let Some(ws) = &self.websocket {
|
||||
let response_json = serde_json::to_string(response)?;
|
||||
ws.send_with_str(&response_json)
|
||||
.map_err(|e| SigSocketError::Send(format!("{:?}", e)))?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SigSocketError::NotConnected)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if connected
|
||||
pub fn is_connected(&self) -> bool {
|
||||
*self.connected.borrow()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WasmClient {
|
||||
fn drop(&mut self) {
|
||||
// Close WebSocket connection if it exists
|
||||
if let Some(ws) = self.websocket.take() {
|
||||
ws.close().unwrap_or_else(|e| {
|
||||
web_sys::console::warn_1(&format!("Failed to close WebSocket: {:?}", e).into());
|
||||
});
|
||||
web_sys::console::log_1(&"🔌 WebSocket connection closed on drop".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WASM-specific utilities
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = console)]
|
||||
fn log(s: &str);
|
||||
}
|
||||
|
||||
// Helper macro for logging in WASM
|
||||
#[allow(unused_macros)]
|
||||
macro_rules! console_log {
|
||||
($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
|
||||
}
|
162
sigsocket_client/tests/integration_test.rs
Normal file
162
sigsocket_client/tests/integration_test.rs
Normal file
@ -0,0 +1,162 @@
|
||||
//! Integration tests for sigsocket_client
|
||||
|
||||
use sigsocket_client::{SigSocketClient, SignRequest, SignResponse, SignRequestHandler, Result, SigSocketError};
|
||||
|
||||
/// Test sign request handler
|
||||
struct TestSignHandler {
|
||||
should_approve: bool,
|
||||
}
|
||||
|
||||
impl TestSignHandler {
|
||||
fn new(should_approve: bool) -> Self {
|
||||
Self { should_approve }
|
||||
}
|
||||
}
|
||||
|
||||
impl SignRequestHandler for TestSignHandler {
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
|
||||
if self.should_approve {
|
||||
// Create a test signature
|
||||
let signature = format!("test_signature_for_{}", request.id);
|
||||
Ok(signature.into_bytes())
|
||||
} else {
|
||||
Err(SigSocketError::Other("User rejected request".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_request_creation() {
|
||||
let request = SignRequest::new("test-123", "dGVzdCBtZXNzYWdl");
|
||||
assert_eq!(request.id, "test-123");
|
||||
assert_eq!(request.message, "dGVzdCBtZXNzYWdl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_request_message_decoding() {
|
||||
let request = SignRequest::new("test-123", "dGVzdCBtZXNzYWdl"); // "test message" in base64
|
||||
|
||||
let bytes = request.message_bytes().unwrap();
|
||||
assert_eq!(bytes, b"test message");
|
||||
|
||||
let hex = request.message_hex().unwrap();
|
||||
assert_eq!(hex, hex::encode(b"test message"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_response_creation() {
|
||||
let response = SignResponse::new("test-123", "dGVzdCBtZXNzYWdl", "c2lnbmF0dXJl");
|
||||
assert_eq!(response.id, "test-123");
|
||||
assert_eq!(response.message, "dGVzdCBtZXNzYWdl");
|
||||
assert_eq!(response.signature, "c2lnbmF0dXJl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_response_from_request() {
|
||||
let request = SignRequest::new("test-123", "dGVzdCBtZXNzYWdl");
|
||||
let signature = b"test_signature";
|
||||
|
||||
let response = SignResponse::from_request_and_signature(&request, signature);
|
||||
assert_eq!(response.id, request.id);
|
||||
assert_eq!(response.message, request.message);
|
||||
assert_eq!(response.signature_bytes().unwrap(), signature);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_protocol_serialization() {
|
||||
// Test SignRequest serialization
|
||||
let request = SignRequest::new("req-456", "SGVsbG8gV29ybGQ="); // "Hello World" in base64
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
let deserialized: SignRequest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(request, deserialized);
|
||||
|
||||
// Test SignResponse serialization
|
||||
let response = SignResponse::new("req-456", "SGVsbG8gV29ybGQ=", "c2lnbmF0dXJlXzEyMw==");
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
let deserialized: SignResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(response, deserialized);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_creation() {
|
||||
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9")
|
||||
.unwrap();
|
||||
|
||||
let client = SigSocketClient::new("ws://localhost:8080/ws", public_key.clone()).unwrap();
|
||||
assert_eq!(client.url(), "ws://localhost:8080/ws");
|
||||
assert_eq!(client.public_key_hex(), hex::encode(&public_key));
|
||||
assert!(!client.is_connected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_invalid_url() {
|
||||
let public_key = vec![1, 2, 3];
|
||||
let result = SigSocketClient::new("invalid-url", public_key);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_empty_public_key() {
|
||||
let result = SigSocketClient::new("ws://localhost:8080/ws", vec![]);
|
||||
assert!(result.is_err());
|
||||
if let Err(error) = result {
|
||||
assert!(matches!(error, SigSocketError::InvalidPublicKey(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_handler_approval() {
|
||||
let handler = TestSignHandler::new(true);
|
||||
let request = SignRequest::new("test-789", "dGVzdA==");
|
||||
|
||||
let result = handler.handle_sign_request(&request);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let signature = result.unwrap();
|
||||
assert_eq!(signature, b"test_signature_for_test-789");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_handler_rejection() {
|
||||
let handler = TestSignHandler::new(false);
|
||||
let request = SignRequest::new("test-789", "dGVzdA==");
|
||||
|
||||
let result = handler.handle_sign_request(&request);
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), SigSocketError::Other(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_display() {
|
||||
let error = SigSocketError::NotConnected;
|
||||
assert_eq!(error.to_string(), "Client is not connected");
|
||||
|
||||
let error = SigSocketError::Connection("test error".to_string());
|
||||
assert_eq!(error.to_string(), "Connection error: test error");
|
||||
}
|
||||
|
||||
// Test that demonstrates the expected usage pattern
|
||||
#[test]
|
||||
fn test_usage_pattern() {
|
||||
// 1. Create client
|
||||
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9")
|
||||
.unwrap();
|
||||
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key).unwrap();
|
||||
|
||||
// 2. Set handler
|
||||
client.set_sign_handler(TestSignHandler::new(true));
|
||||
|
||||
// 3. Verify state
|
||||
assert!(!client.is_connected());
|
||||
|
||||
// 4. Create a test request/response cycle
|
||||
let request = SignRequest::new("test-request", "dGVzdCBtZXNzYWdl");
|
||||
let handler = TestSignHandler::new(true);
|
||||
let signature = handler.handle_sign_request(&request).unwrap();
|
||||
let response = SignResponse::from_request_and_signature(&request, &signature);
|
||||
|
||||
// 5. Verify the response
|
||||
assert_eq!(response.id, request.id);
|
||||
assert_eq!(response.message, request.message);
|
||||
assert_eq!(response.signature_bytes().unwrap(), signature);
|
||||
}
|
92
sigsocket_client/tests/request_management_test.rs
Normal file
92
sigsocket_client/tests/request_management_test.rs
Normal file
@ -0,0 +1,92 @@
|
||||
//! Tests for the enhanced request management functionality
|
||||
|
||||
use sigsocket_client::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn test_client_request_management() {
|
||||
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9").unwrap();
|
||||
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key).unwrap();
|
||||
|
||||
// Initially no requests
|
||||
assert_eq!(client.pending_request_count(), 0);
|
||||
assert!(client.get_pending_requests().is_empty());
|
||||
|
||||
// Add a request
|
||||
let request = SignRequest::new("test-1", "dGVzdCBtZXNzYWdl");
|
||||
let public_key_hex = "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9";
|
||||
client.add_pending_request(request.clone(), public_key_hex.to_string());
|
||||
|
||||
// Check request was added
|
||||
assert_eq!(client.pending_request_count(), 1);
|
||||
assert!(client.get_pending_request("test-1").is_some());
|
||||
|
||||
// Check filtering by public key
|
||||
let filtered = client.get_requests_for_public_key(public_key_hex);
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].id(), "test-1");
|
||||
|
||||
// Add another request for different public key
|
||||
let request2 = SignRequest::new("test-2", "dGVzdCBtZXNzYWdlMg==");
|
||||
let other_public_key = "03f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9";
|
||||
client.add_pending_request(request2, other_public_key.to_string());
|
||||
|
||||
// Check total count
|
||||
assert_eq!(client.pending_request_count(), 2);
|
||||
|
||||
// Check filtering still works
|
||||
let filtered = client.get_requests_for_public_key(public_key_hex);
|
||||
assert_eq!(filtered.len(), 1);
|
||||
|
||||
let filtered_other = client.get_requests_for_public_key(other_public_key);
|
||||
assert_eq!(filtered_other.len(), 1);
|
||||
|
||||
// Remove a request
|
||||
let removed = client.remove_pending_request("test-1");
|
||||
assert!(removed.is_some());
|
||||
assert_eq!(removed.unwrap().id(), "test-1");
|
||||
assert_eq!(client.pending_request_count(), 1);
|
||||
|
||||
// Clear all requests
|
||||
client.clear_pending_requests();
|
||||
assert_eq!(client.pending_request_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_request_validation() {
|
||||
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9").unwrap();
|
||||
let client = SigSocketClient::new("ws://localhost:8080/ws", public_key).unwrap();
|
||||
|
||||
// Valid request
|
||||
let valid_request = SignRequest::new("test-1", "dGVzdCBtZXNzYWdl");
|
||||
assert!(client.can_handle_request_for_key(&valid_request, "some-public-key"));
|
||||
|
||||
// Invalid request - empty ID
|
||||
let invalid_request = SignRequest::new("", "dGVzdCBtZXNzYWdl");
|
||||
assert!(!client.can_handle_request_for_key(&invalid_request, "some-public-key"));
|
||||
|
||||
// Invalid request - empty message
|
||||
let invalid_request2 = SignRequest::new("test-1", "");
|
||||
assert!(!client.can_handle_request_for_key(&invalid_request2, "some-public-key"));
|
||||
|
||||
// Invalid request - invalid base64
|
||||
let invalid_request3 = SignRequest::new("test-1", "invalid-base64!");
|
||||
assert!(!client.can_handle_request_for_key(&invalid_request3, "some-public-key"));
|
||||
|
||||
// Invalid public key
|
||||
assert!(!client.can_handle_request_for_key(&valid_request, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_connection_state() {
|
||||
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9").unwrap();
|
||||
let client = SigSocketClient::new("ws://localhost:8080/ws", public_key).unwrap();
|
||||
|
||||
// Initially disconnected
|
||||
assert_eq!(client.state(), ConnectionState::Disconnected);
|
||||
assert!(!client.is_connected());
|
||||
assert!(client.connected_public_key().is_none());
|
||||
|
||||
// Public key should be available
|
||||
assert_eq!(client.public_key_hex(), "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9");
|
||||
assert_eq!(client.url(), "ws://localhost:8080/ws");
|
||||
}
|
181
sigsocket_client/tests/wasm_tests.rs
Normal file
181
sigsocket_client/tests/wasm_tests.rs
Normal file
@ -0,0 +1,181 @@
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
//! WASM/browser tests for sigsocket_client using wasm-bindgen-test
|
||||
|
||||
use wasm_bindgen_test::*;
|
||||
use sigsocket_client::{SigSocketClient, SignRequest, SignResponse, SignRequestHandler, Result, SigSocketError};
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
|
||||
/// Test sign request handler for WASM tests
|
||||
struct TestWasmSignHandler {
|
||||
should_approve: bool,
|
||||
}
|
||||
|
||||
impl TestWasmSignHandler {
|
||||
fn new(should_approve: bool) -> Self {
|
||||
Self { should_approve }
|
||||
}
|
||||
}
|
||||
|
||||
impl SignRequestHandler for TestWasmSignHandler {
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
|
||||
if self.should_approve {
|
||||
// Create a test signature
|
||||
let signature = format!("wasm_test_signature_for_{}", request.id);
|
||||
Ok(signature.into_bytes())
|
||||
} else {
|
||||
Err(SigSocketError::Other("User rejected request in WASM test".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_sign_request_creation_wasm() {
|
||||
let request = SignRequest::new("wasm-test-123", "dGVzdCBtZXNzYWdl");
|
||||
assert_eq!(request.id, "wasm-test-123");
|
||||
assert_eq!(request.message, "dGVzdCBtZXNzYWdl");
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_sign_request_message_decoding_wasm() {
|
||||
let request = SignRequest::new("wasm-test-123", "dGVzdCBtZXNzYWdl"); // "test message" in base64
|
||||
|
||||
let bytes = request.message_bytes().unwrap();
|
||||
assert_eq!(bytes, b"test message");
|
||||
|
||||
let hex = request.message_hex().unwrap();
|
||||
assert_eq!(hex, hex::encode(b"test message"));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_sign_response_creation_wasm() {
|
||||
let response = SignResponse::new("wasm-test-123", "dGVzdCBtZXNzYWdl", "c2lnbmF0dXJl");
|
||||
assert_eq!(response.id, "wasm-test-123");
|
||||
assert_eq!(response.message, "dGVzdCBtZXNzYWdl");
|
||||
assert_eq!(response.signature, "c2lnbmF0dXJl");
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_sign_response_from_request_wasm() {
|
||||
let request = SignRequest::new("wasm-test-123", "dGVzdCBtZXNzYWdl");
|
||||
let signature = b"wasm_test_signature";
|
||||
|
||||
let response = SignResponse::from_request_and_signature(&request, signature);
|
||||
assert_eq!(response.id, request.id);
|
||||
assert_eq!(response.message, request.message);
|
||||
assert_eq!(response.signature_bytes().unwrap(), signature);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_protocol_serialization_wasm() {
|
||||
// Test SignRequest serialization
|
||||
let request = SignRequest::new("wasm-req-456", "SGVsbG8gV29ybGQ="); // "Hello World" in base64
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
let deserialized: SignRequest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(request, deserialized);
|
||||
|
||||
// Test SignResponse serialization
|
||||
let response = SignResponse::new("wasm-req-456", "SGVsbG8gV29ybGQ=", "c2lnbmF0dXJlXzEyMw==");
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
let deserialized: SignResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(response, deserialized);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_client_creation_wasm() {
|
||||
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9")
|
||||
.unwrap();
|
||||
|
||||
let client = SigSocketClient::new("ws://localhost:8080/ws", public_key.clone()).unwrap();
|
||||
assert_eq!(client.url(), "ws://localhost:8080/ws");
|
||||
assert_eq!(client.public_key_hex(), hex::encode(&public_key));
|
||||
assert!(!client.is_connected());
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_client_invalid_url_wasm() {
|
||||
let public_key = vec![1, 2, 3];
|
||||
let result = SigSocketClient::new("invalid-url", public_key);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_client_empty_public_key_wasm() {
|
||||
let result = SigSocketClient::new("ws://localhost:8080/ws", vec![]);
|
||||
assert!(result.is_err());
|
||||
if let Err(error) = result {
|
||||
assert!(matches!(error, SigSocketError::InvalidPublicKey(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_sign_handler_approval_wasm() {
|
||||
let handler = TestWasmSignHandler::new(true);
|
||||
let request = SignRequest::new("wasm-test-789", "dGVzdA==");
|
||||
|
||||
let result = handler.handle_sign_request(&request);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let signature = result.unwrap();
|
||||
assert_eq!(signature, b"wasm_test_signature_for_wasm-test-789");
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_sign_handler_rejection_wasm() {
|
||||
let handler = TestWasmSignHandler::new(false);
|
||||
let request = SignRequest::new("wasm-test-789", "dGVzdA==");
|
||||
|
||||
let result = handler.handle_sign_request(&request);
|
||||
assert!(result.is_err());
|
||||
assert!(matches!(result.unwrap_err(), SigSocketError::Other(_)));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_error_display_wasm() {
|
||||
let error = SigSocketError::NotConnected;
|
||||
assert_eq!(error.to_string(), "Client is not connected");
|
||||
|
||||
let error = SigSocketError::Connection("wasm test error".to_string());
|
||||
assert_eq!(error.to_string(), "Connection error: wasm test error");
|
||||
}
|
||||
|
||||
// Test that demonstrates the expected WASM usage pattern
|
||||
#[wasm_bindgen_test]
|
||||
fn test_wasm_usage_pattern() {
|
||||
// 1. Create client
|
||||
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9")
|
||||
.unwrap();
|
||||
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key).unwrap();
|
||||
|
||||
// 2. Set handler
|
||||
client.set_sign_handler(TestWasmSignHandler::new(true));
|
||||
|
||||
// 3. Verify state
|
||||
assert!(!client.is_connected());
|
||||
|
||||
// 4. Create a test request/response cycle
|
||||
let request = SignRequest::new("wasm-test-request", "dGVzdCBtZXNzYWdl");
|
||||
let handler = TestWasmSignHandler::new(true);
|
||||
let signature = handler.handle_sign_request(&request).unwrap();
|
||||
let response = SignResponse::from_request_and_signature(&request, &signature);
|
||||
|
||||
// 5. Verify the response
|
||||
assert_eq!(response.id, request.id);
|
||||
assert_eq!(response.message, request.message);
|
||||
assert_eq!(response.signature_bytes().unwrap(), signature);
|
||||
}
|
||||
|
||||
// Test WASM-specific console logging (if needed)
|
||||
#[wasm_bindgen_test]
|
||||
fn test_wasm_console_logging() {
|
||||
// This test verifies that WASM console logging works
|
||||
web_sys::console::log_1(&"SigSocket WASM test logging works!".into());
|
||||
|
||||
// Test that we can create and log protocol messages
|
||||
let request = SignRequest::new("log-test", "dGVzdA==");
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
web_sys::console::log_1(&format!("Sign request JSON: {}", json).into());
|
||||
|
||||
// This test always passes - it's just for verification that logging works
|
||||
assert!(true);
|
||||
}
|
@ -7,9 +7,56 @@ edition = "2021"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
instant = { version = "0.1", features = ["wasm-bindgen"] }
|
||||
once_cell = "1.18"
|
||||
tokio = { version = "1.37", features = ["rt", "macros"] }
|
||||
kvstore = { path = "../kvstore" }
|
||||
scrypt = "0.11"
|
||||
sha2 = "0.10"
|
||||
# aes-gcm = "0.10"
|
||||
pbkdf2 = "0.12"
|
||||
signature = "2.2"
|
||||
async-trait = "0.1"
|
||||
chacha20poly1305 = "0.10"
|
||||
k256 = "0.13"
|
||||
k256 = { version = "0.13", features = ["ecdsa"] }
|
||||
ed25519-dalek = "2.1"
|
||||
rand_core = "0.6"
|
||||
log = "0.4"
|
||||
thiserror = "1"
|
||||
console_log = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
env_logger = "0.11"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
hex = "0.4"
|
||||
zeroize = "1.8.1"
|
||||
rhai = "1.21.0"
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen = { version = "0.2.92", features = ["serde-serialize"] }
|
||||
wasm-bindgen-test = "0.3"
|
||||
# console_error_panic_hook = "0.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
tokio = { version = "1.0", features = ["rt", "macros"] }
|
||||
async-std = { version = "1", features = ["attributes"] }
|
||||
chrono = "0.4"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
getrandom_02 = { package = "getrandom", version = "0.2.16", features = ["js"] }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
# console_error_panic_hook = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
hex = "0.4"
|
||||
rhai = "1.21.0"
|
||||
zeroize = "1.8.1"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
native = []
|
68
vault/README.md
Normal file
68
vault/README.md
Normal file
@ -0,0 +1,68 @@
|
||||
# vault: Cryptographic Vault for Native and WASM
|
||||
|
||||
`vault` provides a secure, async, and cross-platform cryptographic key management system. It leverages the `kvstore` crate for persistent storage and supports both native (desktop/server) and WASM (browser) environments.
|
||||
|
||||
## Features
|
||||
- **Keyspace management**: Create, unlock, and manage encrypted keyspaces.
|
||||
- **Keypair operations**: Add, remove, list, export, and use keypairs for signing and verification.
|
||||
- **End-to-end encryption**: All key material is encrypted at rest using modern ciphers (ChaCha20Poly1305, AES-GCM).
|
||||
- **Async API**: All operations are async and runtime-agnostic.
|
||||
- **Cross-platform**: Native uses `sled` via `kvstore::native::NativeStore`, WASM uses IndexedDB via `kvstore::wasm::WasmStore`.
|
||||
- **Pluggable logging**: Uses the standard `log` crate for logging, with recommended backends for native (`env_logger`) and WASM (`console_log`).
|
||||
|
||||
## Logging Best Practices
|
||||
|
||||
This crate uses the [`log`](https://docs.rs/log) crate for logging. For native tests, use [`env_logger`](https://docs.rs/env_logger); for WASM tests, use [`console_log`](https://docs.rs/console_log).
|
||||
|
||||
- Native (in tests):
|
||||
```rust
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
log::info!("test started");
|
||||
```
|
||||
- WASM (in tests):
|
||||
```rust
|
||||
console_log::init_with_level(log::Level::Debug).expect("error initializing logger");
|
||||
log::debug!("wasm test started");
|
||||
```
|
||||
|
||||
Use `log::debug!`, `log::info!`, `log::error!`, etc., throughout the codebase for consistent and idiomatic logging. Do not prefix messages with [DEBUG], [ERROR], etc. The log level is handled by the logger.
|
||||
|
||||
## Usage Example
|
||||
|
||||
```rust
|
||||
use vault::{Vault, KeyType, KeyMetadata};
|
||||
use kvstore::native::NativeStore;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let store = NativeStore::open("/tmp/vaultdb").unwrap();
|
||||
let mut vault = Vault::new(store);
|
||||
let keyspace = "myspace";
|
||||
let password = b"secret";
|
||||
vault.create_keyspace(keyspace, password, "pbkdf2", "chacha20poly1305", None).await.unwrap();
|
||||
let key_id = vault.add_keypair(keyspace, password, KeyType::Ed25519, None).await.unwrap();
|
||||
println!("Created keypair: {}", key_id);
|
||||
}
|
||||
```
|
||||
|
||||
For WASM/browser, use `kvstore::wasm::WasmStore` and initialize logging with `console_log`.
|
||||
|
||||
## Testing
|
||||
|
||||
### Native
|
||||
```sh
|
||||
cargo test -p vault --features native
|
||||
```
|
||||
|
||||
### WASM
|
||||
```sh
|
||||
wasm-pack test --headless --firefox
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
- All cryptographic operations use vetted RustCrypto crates.
|
||||
- Password-based key derivation uses PBKDF2 by default (10,000 iterations).
|
||||
- All sensitive data is encrypted before storage.
|
||||
|
||||
## License
|
||||
MIT OR Apache-2.0
|
56
vault/src/crypto.rs
Normal file
56
vault/src/crypto.rs
Normal file
@ -0,0 +1,56 @@
|
||||
//! Crypto utilities for the vault crate
|
||||
|
||||
//! Crypto utilities for the vault crate
|
||||
use chacha20poly1305::{ChaCha20Poly1305, KeyInit as ChaChaKeyInit, aead::{Aead, generic_array::GenericArray}};
|
||||
|
||||
use pbkdf2::pbkdf2_hmac;
|
||||
|
||||
use sha2::Sha256;
|
||||
use rand_core::{RngCore, OsRng as RandOsRng};
|
||||
|
||||
pub mod kdf {
|
||||
use super::*;
|
||||
|
||||
/// Standard parameters for keyspace key derivation
|
||||
pub const KEYSPACE_KEY_LENGTH: usize = 32;
|
||||
pub const KEYSPACE_KEY_ITERATIONS: u32 = 10_000;
|
||||
|
||||
/// Derive a symmetric key for keyspace operations using standard parameters
|
||||
/// Always uses PBKDF2 with SHA-256, 32 bytes output, and 10,000 iterations
|
||||
pub fn keyspace_key(password: &[u8], salt: &[u8]) -> Vec<u8> {
|
||||
derive_key_pbkdf2(password, salt, KEYSPACE_KEY_LENGTH, KEYSPACE_KEY_ITERATIONS)
|
||||
}
|
||||
|
||||
pub fn derive_key_pbkdf2(password: &[u8], salt: &[u8], key_len: usize, iterations: u32) -> Vec<u8> {
|
||||
let mut key = vec![0u8; key_len];
|
||||
pbkdf2_hmac::<Sha256>(password, salt, iterations, &mut key);
|
||||
key
|
||||
}
|
||||
}
|
||||
|
||||
pub mod cipher {
|
||||
use super::*;
|
||||
|
||||
|
||||
pub fn encrypt_chacha20(key: &[u8], plaintext: &[u8], nonce: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let cipher = ChaCha20Poly1305::new(GenericArray::from_slice(key));
|
||||
cipher.encrypt(GenericArray::from_slice(nonce), plaintext)
|
||||
.map_err(|e| format!("encryption error: {e}"))
|
||||
}
|
||||
|
||||
pub fn decrypt_chacha20(key: &[u8], ciphertext: &[u8], nonce: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let cipher = ChaCha20Poly1305::new(GenericArray::from_slice(key));
|
||||
cipher.decrypt(GenericArray::from_slice(nonce), ciphertext)
|
||||
.map_err(|e| format!("decryption error: {e}"))
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
pub fn random_salt(len: usize) -> Vec<u8> {
|
||||
let mut salt = vec![0u8; len];
|
||||
RandOsRng.fill_bytes(&mut salt);
|
||||
salt
|
||||
}
|
66
vault/src/data.rs
Normal file
66
vault/src/data.rs
Normal file
@ -0,0 +1,66 @@
|
||||
//! Data models for the vault crate
|
||||
|
||||
// Only keep serde derives on structs, remove unused imports
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct VaultMetadata {
|
||||
pub name: String,
|
||||
pub keyspaces: Vec<KeyspaceMetadata>,
|
||||
// ... other vault-level metadata
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
pub struct KeyspaceMetadata {
|
||||
pub name: String,
|
||||
pub salt: [u8; 16], // Unique salt for this keyspace
|
||||
pub encrypted_blob: Vec<u8>,
|
||||
pub created_at: Option<u64>, // Unix timestamp
|
||||
pub tags: Option<Vec<String>>,
|
||||
// ... other keyspace metadata
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
|
||||
pub struct KeyspaceData {
|
||||
pub keypairs: Vec<KeyEntry>,
|
||||
// ... other keyspace-level metadata
|
||||
}
|
||||
|
||||
impl zeroize::Zeroize for KeyspaceData {
|
||||
fn zeroize(&mut self) {
|
||||
for key in &mut self.keypairs {
|
||||
key.zeroize();
|
||||
}
|
||||
self.keypairs.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
impl zeroize::Zeroize for KeyEntry {
|
||||
fn zeroize(&mut self) {
|
||||
self.private_key.zeroize();
|
||||
// Optionally, zeroize other fields if needed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KeyEntry {
|
||||
pub id: String,
|
||||
pub key_type: KeyType,
|
||||
pub private_key: Vec<u8>, // Only present in memory after decryption
|
||||
pub public_key: Vec<u8>,
|
||||
pub metadata: Option<KeyMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum KeyType {
|
||||
Secp256k1,
|
||||
Ed25519,
|
||||
// ...
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct KeyMetadata {
|
||||
pub name: Option<String>,
|
||||
pub created_at: Option<u64>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
// ...
|
||||
}
|
21
vault/src/error.rs
Normal file
21
vault/src/error.rs
Normal file
@ -0,0 +1,21 @@
|
||||
//! Error types for the vault crate
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VaultError {
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
#[error("Crypto error: {0}")]
|
||||
Crypto(String),
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("Keyspace not found: {0}")]
|
||||
KeyspaceNotFound(String),
|
||||
#[error("Key not found: {0}")]
|
||||
KeyNotFound(String),
|
||||
#[error("Invalid password")]
|
||||
InvalidPassword,
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(String),
|
||||
#[error("Other: {0}")]
|
||||
Other(String),
|
||||
}
|
632
vault/src/lib.rs
632
vault/src/lib.rs
@ -1,27 +1,623 @@
|
||||
//! vault: Cryptographic keyspace and operations
|
||||
|
||||
//! vault: Cryptographic keyspace and operations
|
||||
|
||||
use kvstore::KVStore;
|
||||
pub mod data;
|
||||
pub use crate::data::{KeyEntry, KeyMetadata, KeyType};
|
||||
pub use crate::session::SessionManager;
|
||||
mod crypto;
|
||||
mod error;
|
||||
pub mod rhai_bindings;
|
||||
mod rhai_sync_helpers;
|
||||
pub mod session;
|
||||
mod utils;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VaultError {
|
||||
#[error("Storage error: {0}")]
|
||||
Storage(String),
|
||||
#[error("Crypto error: {0}")]
|
||||
Crypto(String),
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod session_singleton;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod wasm_helpers;
|
||||
|
||||
pub struct Vault<S: KVStore + Send + Sync> {
|
||||
use crate::crypto::kdf;
|
||||
use crate::crypto::random_salt;
|
||||
use data::*;
|
||||
use error::VaultError;
|
||||
pub use kvstore::traits::KVStore;
|
||||
|
||||
use crate::crypto::cipher::{decrypt_chacha20, encrypt_chacha20};
|
||||
// TEMP: File-based debug logger for crypto troubleshooting
|
||||
use log::debug;
|
||||
|
||||
/// Vault: Cryptographic keyspace and operations
|
||||
pub struct Vault<S: KVStore> {
|
||||
storage: S,
|
||||
// ... other fields
|
||||
// Optionally: cache of unlocked keyspaces, etc.
|
||||
}
|
||||
|
||||
impl<S: KVStore + Send + Sync> Vault<S> {
|
||||
/// Creates a new keyspace. Implementation pending.
|
||||
pub async fn create_keyspace(_dummy: ()) -> Result<(), VaultError> {
|
||||
todo!("Implement create_keyspace")
|
||||
}
|
||||
// ... other API stubs
|
||||
/// Helper to encrypt and prepend nonce to ciphertext for keyspace storage
|
||||
/// Helper to encrypt and prepend nonce to ciphertext for keyspace storage
|
||||
/// Always uses ChaCha20Poly1305.
|
||||
fn encrypt_with_nonce_prepended(key: &[u8], plaintext: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
let nonce = random_salt(12);
|
||||
debug!("nonce: {}", hex::encode(&nonce));
|
||||
// Always use ChaCha20Poly1305 for encryption
|
||||
let ct = encrypt_chacha20(key, plaintext, &nonce).map_err(|e| VaultError::Crypto(e))?;
|
||||
debug!("ct: {}", hex::encode(&ct));
|
||||
debug!("key: {}", hex::encode(key));
|
||||
let mut blob = nonce.clone();
|
||||
blob.extend_from_slice(&ct);
|
||||
debug!("ENCRYPTED (nonce|ct): {}", hex::encode(&blob));
|
||||
Ok(blob)
|
||||
}
|
||||
|
||||
impl<S: KVStore> Vault<S> {
|
||||
pub fn new(storage: S) -> Self {
|
||||
Self { storage }
|
||||
}
|
||||
|
||||
/// Create a new keyspace with the given name, password, and options.
|
||||
/// Create a new keyspace with the given name and password. Always uses PBKDF2 and ChaCha20Poly1305.
|
||||
pub async fn create_keyspace(
|
||||
&mut self,
|
||||
name: &str,
|
||||
password: &[u8],
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<(), VaultError> {
|
||||
// Check if keyspace already exists
|
||||
if self
|
||||
.storage
|
||||
.get(name)
|
||||
.await
|
||||
.map_err(|e| VaultError::Storage(format!("{e:?}")))?
|
||||
.is_some()
|
||||
{
|
||||
debug!("keyspace '{}' already exists", name);
|
||||
return Err(VaultError::Crypto("Keyspace already exists".to_string()));
|
||||
}
|
||||
debug!("entry: name={}", name);
|
||||
use crate::crypto::{kdf, random_salt};
|
||||
use crate::data::{KeyspaceData, KeyspaceMetadata};
|
||||
use serde_json;
|
||||
|
||||
// 1. Generate salt
|
||||
let salt = random_salt(16);
|
||||
debug!("salt: {:?}", salt);
|
||||
// 2. Derive key
|
||||
// Always use PBKDF2 for key derivation
|
||||
let key = kdf::keyspace_key(password, &salt);
|
||||
debug!("derived key: {} bytes", key.len());
|
||||
// 3. Prepare initial keyspace data
|
||||
let keyspace_data = KeyspaceData { keypairs: vec![] };
|
||||
let plaintext = match serde_json::to_vec(&keyspace_data) {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
debug!("serde_json error: {}", e);
|
||||
return Err(VaultError::Serialization(e.to_string()));
|
||||
}
|
||||
};
|
||||
debug!("plaintext serialized: {} bytes", plaintext.len());
|
||||
// 4. Generate nonce (12 bytes for both ciphers)
|
||||
let nonce = random_salt(12);
|
||||
debug!("nonce: {}", hex::encode(&nonce));
|
||||
// 5. Encrypt
|
||||
// Always use ChaCha20Poly1305 for encryption
|
||||
let encrypted_blob = encrypt_with_nonce_prepended(&key, &plaintext)?;
|
||||
debug!("encrypted_blob: {} bytes", encrypted_blob.len());
|
||||
debug!("encrypted_blob (hex): {}", hex::encode(&encrypted_blob));
|
||||
// 6. Compose metadata
|
||||
let metadata = KeyspaceMetadata {
|
||||
name: name.to_string(),
|
||||
salt: salt.clone().try_into().unwrap_or([0u8; 16]),
|
||||
encrypted_blob,
|
||||
created_at: Some(crate::utils::now()),
|
||||
tags,
|
||||
};
|
||||
// 7. Store in kvstore (keyed by keyspace name)
|
||||
let meta_bytes = match serde_json::to_vec(&metadata) {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
debug!("serde_json metadata error: {}", e);
|
||||
return Err(VaultError::Serialization(e.to_string()));
|
||||
}
|
||||
};
|
||||
self.storage
|
||||
.set(name, &meta_bytes)
|
||||
.await
|
||||
.map_err(|e| VaultError::Storage(format!("{e:?}")))?;
|
||||
debug!("success");
|
||||
|
||||
// 8. Create default keypair, passing the salt we already have
|
||||
self.create_default_keypair(name, password, &salt).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all keyspaces (metadata only, not decrypted)
|
||||
pub async fn list_keyspaces(&self) -> Result<Vec<KeyspaceMetadata>, VaultError> {
|
||||
use serde_json;
|
||||
// 1. List all keys in kvstore
|
||||
let keys = self
|
||||
.storage
|
||||
.keys()
|
||||
.await
|
||||
.map_err(|e| VaultError::Storage(format!("{e:?}")))?;
|
||||
let mut keyspaces = Vec::new();
|
||||
for key in keys {
|
||||
if let Some(bytes) = self
|
||||
.storage
|
||||
.get(&key)
|
||||
.await
|
||||
.map_err(|e| VaultError::Storage(format!("{e:?}")))?
|
||||
{
|
||||
if let Ok(meta) = serde_json::from_slice::<KeyspaceMetadata>(&bytes) {
|
||||
keyspaces.push(meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(keyspaces)
|
||||
}
|
||||
|
||||
/// Unlock a keyspace by name and password, returning the decrypted data
|
||||
/// Unlock a keyspace by name and password, returning the decrypted data
|
||||
/// Always uses PBKDF2 and ChaCha20Poly1305.
|
||||
pub async fn unlock_keyspace(
|
||||
&self,
|
||||
name: &str,
|
||||
password: &[u8],
|
||||
) -> Result<KeyspaceData, VaultError> {
|
||||
debug!("unlock_keyspace entry: name={}", name);
|
||||
// use crate::crypto::kdf; // removed if not needed
|
||||
use serde_json;
|
||||
// 1. Fetch keyspace metadata
|
||||
let meta_bytes = self
|
||||
.storage
|
||||
.get(name)
|
||||
.await
|
||||
.map_err(|e| VaultError::Storage(format!("{e:?}")))?;
|
||||
let meta_bytes = meta_bytes.ok_or(VaultError::KeyspaceNotFound(name.to_string()))?;
|
||||
let metadata: KeyspaceMetadata = serde_json::from_slice(&meta_bytes)
|
||||
.map_err(|e| VaultError::Serialization(e.to_string()))?;
|
||||
if metadata.salt.len() != 16 {
|
||||
debug!("salt length {} != 16", metadata.salt.len());
|
||||
return Err(VaultError::Crypto(
|
||||
"Salt length must be 16 bytes".to_string(),
|
||||
));
|
||||
}
|
||||
// 2. Derive key
|
||||
let key = kdf::keyspace_key(password, &metadata.salt);
|
||||
debug!("derived key: {} bytes", key.len());
|
||||
|
||||
let ciphertext = &metadata.encrypted_blob;
|
||||
if ciphertext.len() < 12 {
|
||||
debug!("ciphertext too short: {}", ciphertext.len());
|
||||
return Err(VaultError::Crypto("Ciphertext too short".to_string()));
|
||||
}
|
||||
|
||||
let (nonce, ct) = ciphertext.split_at(12);
|
||||
debug!("nonce: {}", hex::encode(nonce));
|
||||
let plaintext = decrypt_chacha20(&key, ct, nonce).map_err(VaultError::Crypto)?;
|
||||
debug!("plaintext decrypted: {} bytes", plaintext.len());
|
||||
// 4. Deserialize keyspace data
|
||||
let keyspace_data: KeyspaceData = match serde_json::from_slice(&plaintext) {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
debug!("serde_json data error: {}", e);
|
||||
return Err(VaultError::Serialization(e.to_string()));
|
||||
}
|
||||
};
|
||||
debug!("success");
|
||||
Ok(keyspace_data)
|
||||
}
|
||||
|
||||
/// Lock a keyspace (remove from cache, if any)
|
||||
/// Lock a keyspace (remove from cache, if any)
|
||||
pub fn lock_keyspace(&mut self, _name: &str) {
|
||||
// Optional: clear from in-memory cache
|
||||
}
|
||||
|
||||
// --- Keypair Management APIs ---
|
||||
|
||||
/// Create a default Secp256k1 keypair for client identity
|
||||
/// This keypair is deterministically generated from the password and salt
|
||||
/// and will always be the first keypair in the keyspace
|
||||
async fn create_default_keypair(
|
||||
&mut self,
|
||||
keyspace: &str,
|
||||
password: &[u8],
|
||||
salt: &[u8],
|
||||
) -> Result<String, VaultError> {
|
||||
// 1. Derive a deterministic seed using standard PBKDF2
|
||||
let seed = kdf::keyspace_key(password, salt);
|
||||
|
||||
// 2. Generate Secp256k1 keypair from the seed
|
||||
use k256::ecdsa::{SigningKey, VerifyingKey};
|
||||
|
||||
// Use the seed as the private key directly (32 bytes)
|
||||
let mut secret_key_bytes = [0u8; 32];
|
||||
secret_key_bytes.copy_from_slice(&seed[..32]);
|
||||
|
||||
// Create signing key
|
||||
let signing_key = SigningKey::from_bytes(&secret_key_bytes.into())
|
||||
.map_err(|e| VaultError::Crypto(format!("Failed to create signing key: {}", e)))?;
|
||||
|
||||
// Get verifying key
|
||||
let verifying_key = VerifyingKey::from(&signing_key);
|
||||
|
||||
// Convert keys to bytes
|
||||
let priv_bytes = signing_key.to_bytes().to_vec();
|
||||
let pub_bytes = verifying_key.to_encoded_point(false).as_bytes().to_vec();
|
||||
let id = hex::encode(&pub_bytes);
|
||||
|
||||
// 3. Unlock keyspace to add the keypair
|
||||
let mut data = self.unlock_keyspace(keyspace, password).await?;
|
||||
|
||||
// 4. Create key entry
|
||||
let entry = KeyEntry {
|
||||
id: id.clone(),
|
||||
key_type: KeyType::Secp256k1,
|
||||
private_key: priv_bytes,
|
||||
public_key: pub_bytes,
|
||||
metadata: Some(KeyMetadata {
|
||||
name: Some("Default Identity".to_string()),
|
||||
created_at: Some(crate::utils::now()),
|
||||
tags: Some(vec!["default".to_string(), "identity".to_string()]),
|
||||
}),
|
||||
};
|
||||
|
||||
// Ensure it's the first keypair by inserting at index 0
|
||||
data.keypairs.insert(0, entry);
|
||||
|
||||
// 5. Re-encrypt and store
|
||||
self.save_keyspace(keyspace, password, &data).await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Add a new keypair to a keyspace (generates and stores a new keypair)
|
||||
/// Add a new keypair to a keyspace (generates and stores a new keypair)
|
||||
/// If key_type is None, defaults to Secp256k1.
|
||||
pub async fn add_keypair(
|
||||
&mut self,
|
||||
keyspace: &str,
|
||||
password: &[u8],
|
||||
key_type: Option<KeyType>,
|
||||
metadata: Option<KeyMetadata>,
|
||||
) -> Result<String, VaultError> {
|
||||
use crate::data::KeyEntry;
|
||||
use rand_core::OsRng;
|
||||
use rand_core::RngCore;
|
||||
|
||||
// 1. Unlock keyspace
|
||||
let mut data = self.unlock_keyspace(keyspace, password).await?;
|
||||
// 2. Generate keypair
|
||||
let key_type = key_type.unwrap_or(KeyType::Secp256k1);
|
||||
let (private_key, public_key, id) = match key_type {
|
||||
KeyType::Ed25519 => {
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let signing = SigningKey::from_bytes(&bytes);
|
||||
let verifying: VerifyingKey = (&signing).into();
|
||||
let priv_bytes = signing.to_bytes().to_vec();
|
||||
let pub_bytes = verifying.to_bytes().to_vec();
|
||||
let id = hex::encode(&pub_bytes);
|
||||
(priv_bytes, pub_bytes, id)
|
||||
}
|
||||
KeyType::Secp256k1 => {
|
||||
use k256::ecdsa::SigningKey;
|
||||
|
||||
let sk = SigningKey::random(&mut OsRng);
|
||||
let pk = sk.verifying_key();
|
||||
let priv_bytes = sk.to_bytes().to_vec();
|
||||
let pub_bytes = pk.to_encoded_point(false).as_bytes().to_vec();
|
||||
let id = hex::encode(&pub_bytes);
|
||||
(priv_bytes, pub_bytes, id)
|
||||
}
|
||||
};
|
||||
// 3. Add to keypairs
|
||||
let entry = KeyEntry {
|
||||
id: id.clone(),
|
||||
key_type,
|
||||
private_key,
|
||||
public_key,
|
||||
metadata,
|
||||
};
|
||||
data.keypairs.push(entry);
|
||||
// 4. Re-encrypt and store
|
||||
self.save_keyspace(keyspace, password, &data).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Remove a keypair by id from a keyspace
|
||||
pub async fn remove_keypair(
|
||||
&mut self,
|
||||
keyspace: &str,
|
||||
password: &[u8],
|
||||
key_id: &str,
|
||||
) -> Result<(), VaultError> {
|
||||
let mut data = self.unlock_keyspace(keyspace, password).await?;
|
||||
data.keypairs.retain(|k| k.id != key_id);
|
||||
self.save_keyspace(keyspace, password, &data).await
|
||||
}
|
||||
|
||||
/// List all keypairs in a keyspace (public info only)
|
||||
pub async fn list_keypairs(
|
||||
&self,
|
||||
keyspace: &str,
|
||||
password: &[u8],
|
||||
) -> Result<Vec<(String, KeyType)>, VaultError> {
|
||||
let data = self.unlock_keyspace(keyspace, password).await?;
|
||||
Ok(data
|
||||
.keypairs
|
||||
.iter()
|
||||
.map(|k| (k.id.clone(), k.key_type.clone()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Export a keypair's private and public key by id
|
||||
pub async fn export_keypair(
|
||||
&self,
|
||||
keyspace: &str,
|
||||
password: &[u8],
|
||||
key_id: &str,
|
||||
) -> Result<(Vec<u8>, Vec<u8>), VaultError> {
|
||||
let data = self.unlock_keyspace(keyspace, password).await?;
|
||||
let key = data
|
||||
.keypairs
|
||||
.iter()
|
||||
.find(|k| k.id == key_id)
|
||||
.ok_or(VaultError::KeyNotFound(key_id.to_string()))?;
|
||||
Ok((key.private_key.clone(), key.public_key.clone()))
|
||||
}
|
||||
|
||||
/// Save the updated keyspace data (helper)
|
||||
async fn save_keyspace(
|
||||
&mut self,
|
||||
keyspace: &str,
|
||||
password: &[u8],
|
||||
data: &KeyspaceData,
|
||||
) -> Result<(), VaultError> {
|
||||
debug!("save_keyspace entry: keyspace={}", keyspace);
|
||||
use crate::crypto::kdf;
|
||||
use serde_json;
|
||||
|
||||
let meta_bytes = self
|
||||
.storage
|
||||
.get(keyspace)
|
||||
.await
|
||||
.map_err(|e| VaultError::Storage(format!("{e:?}")))?;
|
||||
debug!(
|
||||
"got meta_bytes: {}",
|
||||
meta_bytes.as_ref().map(|v| v.len()).unwrap_or(0)
|
||||
);
|
||||
let meta_bytes = meta_bytes.ok_or(VaultError::KeyspaceNotFound(keyspace.to_string()))?;
|
||||
let mut metadata: KeyspaceMetadata = serde_json::from_slice(&meta_bytes)
|
||||
.map_err(|e| VaultError::Serialization(e.to_string()))?;
|
||||
debug!("metadata: salt={:?}", metadata.salt);
|
||||
if metadata.salt.len() != 16 {
|
||||
debug!("salt length {} != 16", metadata.salt.len());
|
||||
return Err(VaultError::Crypto(
|
||||
"Salt length must be 16 bytes".to_string(),
|
||||
));
|
||||
}
|
||||
// 2. Derive key
|
||||
let key = kdf::keyspace_key(password, &metadata.salt);
|
||||
debug!("derived key: {} bytes", key.len());
|
||||
// 3. Serialize plaintext
|
||||
let plaintext = match serde_json::to_vec(data) {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
debug!("serde_json data error: {}", e);
|
||||
return Err(VaultError::Serialization(e.to_string()));
|
||||
}
|
||||
};
|
||||
debug!("plaintext serialized: {} bytes", plaintext.len());
|
||||
// 4. Generate nonce
|
||||
let nonce = random_salt(12);
|
||||
debug!("nonce: {}", hex::encode(&nonce));
|
||||
// 5. Encrypt
|
||||
let encrypted_blob = encrypt_with_nonce_prepended(&key, &plaintext)?;
|
||||
debug!("encrypted_blob: {} bytes", encrypted_blob.len());
|
||||
// 6. Store new encrypted blob
|
||||
metadata.encrypted_blob = encrypted_blob;
|
||||
let meta_bytes = match serde_json::to_vec(&metadata) {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
debug!("serde_json metadata error: {}", e);
|
||||
return Err(VaultError::Serialization(e.to_string()));
|
||||
}
|
||||
};
|
||||
self.storage
|
||||
.set(keyspace, &meta_bytes)
|
||||
.await
|
||||
.map_err(|e| VaultError::Storage(format!("{e:?}")))?;
|
||||
debug!("success");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sign a message with a stored keypair in a keyspace
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `keyspace` - Keyspace name
|
||||
/// * `password` - Keyspace password
|
||||
/// * `key_id` - Keypair ID
|
||||
/// * `message` - Message to sign
|
||||
pub async fn sign(
|
||||
&self,
|
||||
keyspace: &str,
|
||||
password: &[u8],
|
||||
key_id: &str,
|
||||
message: &[u8],
|
||||
) -> Result<Vec<u8>, VaultError> {
|
||||
let data = self.unlock_keyspace(keyspace, password).await?;
|
||||
let key = data
|
||||
.keypairs
|
||||
.iter()
|
||||
.find(|k| k.id == key_id)
|
||||
.ok_or(VaultError::KeyNotFound(key_id.to_string()))?;
|
||||
match key.key_type {
|
||||
KeyType::Ed25519 => {
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
let signing =
|
||||
SigningKey::from_bytes(&key.private_key.clone().try_into().map_err(|_| {
|
||||
VaultError::Crypto("Invalid Ed25519 private key length".to_string())
|
||||
})?);
|
||||
let sig = signing.sign(message);
|
||||
Ok(sig.to_bytes().to_vec())
|
||||
}
|
||||
KeyType::Secp256k1 => {
|
||||
use k256::ecdsa::{signature::Signer, SigningKey, Signature};
|
||||
let arr: &[u8; 32] = key.private_key.as_slice().try_into().map_err(|_| {
|
||||
VaultError::Crypto("Invalid secp256k1 private key length".to_string())
|
||||
})?;
|
||||
let sk = SigningKey::from_bytes(arr.into())
|
||||
.map_err(|e| VaultError::Crypto(e.to_string()))?;
|
||||
let sig: Signature = sk.sign(message);
|
||||
// Return compact signature (64 bytes) instead of DER format
|
||||
Ok(sig.to_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a signature with a stored keypair in a keyspace
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `keyspace` - Keyspace name
|
||||
/// * `password` - Keyspace password
|
||||
/// * `key_id` - Keypair ID
|
||||
/// * `message` - Message that was signed
|
||||
/// * `signature` - Signature to verify
|
||||
pub async fn verify(
|
||||
&self,
|
||||
keyspace: &str,
|
||||
password: &[u8],
|
||||
key_id: &str,
|
||||
message: &[u8],
|
||||
signature: &[u8],
|
||||
) -> Result<bool, VaultError> {
|
||||
let data = self.unlock_keyspace(keyspace, password).await?;
|
||||
let key = data
|
||||
.keypairs
|
||||
.iter()
|
||||
.find(|k| k.id == key_id)
|
||||
.ok_or(VaultError::KeyNotFound(key_id.to_string()))?;
|
||||
match key.key_type {
|
||||
KeyType::Ed25519 => {
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
let verifying =
|
||||
VerifyingKey::from_bytes(&key.public_key.clone().try_into().map_err(|_| {
|
||||
VaultError::Crypto("Invalid Ed25519 public key length".to_string())
|
||||
})?)
|
||||
.map_err(|e| VaultError::Crypto(e.to_string()))?;
|
||||
let sig = Signature::from_bytes(&signature.try_into().map_err(|_| {
|
||||
VaultError::Crypto("Invalid Ed25519 signature length".to_string())
|
||||
})?);
|
||||
Ok(verifying.verify(message, &sig).is_ok())
|
||||
}
|
||||
KeyType::Secp256k1 => {
|
||||
use k256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
|
||||
let pk = VerifyingKey::from_sec1_bytes(&key.public_key)
|
||||
.map_err(|e| VaultError::Crypto(e.to_string()))?;
|
||||
// Use compact format (64 bytes) instead of DER
|
||||
let sig_array: &[u8; 64] = signature.try_into().map_err(|_| {
|
||||
VaultError::Crypto("Invalid secp256k1 signature length".to_string())
|
||||
})?;
|
||||
let sig = Signature::from_bytes(sig_array.into())
|
||||
.map_err(|e| VaultError::Crypto(e.to_string()))?;
|
||||
Ok(pk.verify(message, &sig).is_ok())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt a message using the keyspace symmetric cipher
|
||||
/// (for simplicity, uses keyspace password-derived key)
|
||||
pub async fn encrypt(
|
||||
&self,
|
||||
keyspace: &str,
|
||||
password: &[u8],
|
||||
plaintext: &[u8],
|
||||
) -> Result<Vec<u8>, VaultError> {
|
||||
debug!("encrypt");
|
||||
|
||||
// 1. Load keyspace metadata
|
||||
let meta_bytes = self
|
||||
.storage
|
||||
.get(keyspace)
|
||||
.await
|
||||
.map_err(|e| VaultError::Storage(format!("{e:?}")))?;
|
||||
let meta_bytes = match meta_bytes {
|
||||
Some(val) => val,
|
||||
None => {
|
||||
debug!("keyspace not found");
|
||||
return Err(VaultError::Other("Keyspace not found".to_string()));
|
||||
}
|
||||
};
|
||||
let meta: KeyspaceMetadata = match serde_json::from_slice(&meta_bytes) {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
debug!("serialization error: {}", e);
|
||||
return Err(VaultError::Serialization(e.to_string()));
|
||||
}
|
||||
};
|
||||
debug!(
|
||||
"salt={:?} (hex salt: {})",
|
||||
meta.salt,
|
||||
hex::encode(&meta.salt)
|
||||
);
|
||||
// 2. Derive key
|
||||
let key = kdf::keyspace_key(password, &meta.salt);
|
||||
// 3. Generate nonce
|
||||
let nonce = random_salt(12);
|
||||
debug!("nonce={:?} (hex nonce: {})", nonce, hex::encode(&nonce));
|
||||
// 4. Encrypt
|
||||
let ciphertext = encrypt_chacha20(&key, plaintext, &nonce).map_err(VaultError::Crypto)?;
|
||||
let mut out = nonce;
|
||||
out.extend_from_slice(&ciphertext);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decrypt a message using the keyspace symmetric cipher
|
||||
/// (for simplicity, uses keyspace password-derived key)
|
||||
pub async fn decrypt(
|
||||
&self,
|
||||
keyspace: &str,
|
||||
password: &[u8],
|
||||
ciphertext: &[u8],
|
||||
) -> Result<Vec<u8>, VaultError> {
|
||||
debug!("decrypt");
|
||||
|
||||
// 1. Load keyspace metadata
|
||||
let meta_bytes = self
|
||||
.storage
|
||||
.get(keyspace)
|
||||
.await
|
||||
.map_err(|e| VaultError::Storage(format!("{e:?}")))?;
|
||||
let meta_bytes = match meta_bytes {
|
||||
Some(val) => val,
|
||||
None => {
|
||||
debug!("keyspace not found");
|
||||
return Err(VaultError::Other("Keyspace not found".to_string()));
|
||||
}
|
||||
};
|
||||
let meta: KeyspaceMetadata = match serde_json::from_slice(&meta_bytes) {
|
||||
Ok(val) => val,
|
||||
Err(e) => {
|
||||
debug!("serialization error: {}", e);
|
||||
return Err(VaultError::Serialization(e.to_string()));
|
||||
}
|
||||
};
|
||||
debug!(
|
||||
"salt={:?} (hex salt: {})",
|
||||
meta.salt,
|
||||
hex::encode(&meta.salt)
|
||||
);
|
||||
// 2. Derive key
|
||||
let key = kdf::keyspace_key(password, &meta.salt);
|
||||
// 3. Extract nonce
|
||||
let nonce = &ciphertext[..12];
|
||||
debug!("nonce={:?} (hex nonce: {})", nonce, hex::encode(nonce));
|
||||
// 4. Decrypt
|
||||
let plaintext =
|
||||
decrypt_chacha20(&key, &ciphertext[12..], nonce).map_err(VaultError::Crypto)?;
|
||||
Ok(plaintext)
|
||||
}
|
||||
}
|
||||
|
115
vault/src/rhai_bindings.rs
Normal file
115
vault/src/rhai_bindings.rs
Normal file
@ -0,0 +1,115 @@
|
||||
//! Rhai bindings for Vault and EVM Client modules
|
||||
//! Provides a single source of truth for scripting integration.
|
||||
|
||||
use rhai::Engine;
|
||||
use crate::session::SessionManager;
|
||||
|
||||
|
||||
/// Register core Vault and EVM Client APIs with the Rhai scripting engine.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn register_rhai_api<S: kvstore::traits::KVStore + Send + Sync + Clone + 'static>(
|
||||
engine: &mut Engine,
|
||||
_session_manager: std::sync::Arc<std::sync::Mutex<SessionManager<S>>>,
|
||||
) {
|
||||
engine.register_type::<RhaiSessionManager<S>>();
|
||||
engine.register_fn("select_keypair", RhaiSessionManager::<S>::select_keypair);
|
||||
engine.register_fn("select_default_keypair", RhaiSessionManager::<S>::select_default_keypair);
|
||||
engine.register_fn("sign", RhaiSessionManager::<S>::sign);
|
||||
// No global constant registration: Rhai does not support this directly.
|
||||
// Scripts should receive the session manager as a parameter or via module scope.
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Clone)]
|
||||
struct RhaiSessionManager<S: kvstore::traits::KVStore + Send + Sync + Clone + 'static> {
|
||||
inner: std::sync::Arc<std::sync::Mutex<SessionManager<S>>>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[derive(Clone)]
|
||||
struct RhaiSessionManager<S: kvstore::traits::KVStore + Clone + 'static> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl<S: kvstore::traits::KVStore + Send + Sync + Clone + 'static> RhaiSessionManager<S> {
|
||||
pub fn select_keypair(&self, key_id: String) -> Result<(), String> {
|
||||
// Use Mutex for interior mutability, &self is sufficient
|
||||
self.inner.lock().unwrap().select_keypair(&key_id).map_err(|e| format!("select_keypair error: {e}"))
|
||||
}
|
||||
|
||||
pub fn select_default_keypair(&self) -> Result<(), String> {
|
||||
self.inner.lock().unwrap().select_default_keypair()
|
||||
.map_err(|e| format!("select_default_keypair error: {e}"))
|
||||
}
|
||||
pub fn sign(&self, message: rhai::Blob) -> Result<rhai::Blob, String> {
|
||||
let sm = self.inner.lock().unwrap();
|
||||
// Try to get the current keyspace name from session state if possible
|
||||
let _keypair = sm.current_keypair().ok_or("No keypair selected")?;
|
||||
// Sign using the session manager; password and keyspace are not needed (already unlocked)
|
||||
crate::rhai_sync_helpers::sign_sync::<S>(
|
||||
&sm,
|
||||
&message,
|
||||
).map_err(|e| format!("sign error: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl<S: kvstore::traits::KVStore + Clone + 'static> RhaiSessionManager<S> {
|
||||
pub fn select_keypair(&self, key_id: String) -> Result<(), String> {
|
||||
// Use the global singleton for session management
|
||||
crate::session_singleton::SESSION_MANAGER.with(|cell| {
|
||||
let mut opt = cell.borrow_mut();
|
||||
if let Some(session) = opt.as_mut() {
|
||||
session.select_keypair(&key_id).map_err(|e| format!("select_keypair error: {e}"))
|
||||
} else {
|
||||
Err("Session not initialized".to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn current_keypair(&self) -> Option<String> {
|
||||
crate::session_singleton::SESSION_MANAGER.with(|cell| {
|
||||
let opt = cell.borrow();
|
||||
opt.as_ref()
|
||||
.and_then(|session| session.current_keypair().map(|k| k.id.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn logout(&self) {
|
||||
crate::session_singleton::SESSION_MANAGER.with(|cell| {
|
||||
let mut opt = cell.borrow_mut();
|
||||
if let Some(session) = opt.as_mut() {
|
||||
session.logout();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn sign(&self, _message: rhai::Blob) -> Result<rhai::Blob, String> {
|
||||
// Signing is async in WASM; must be called from JS/wasm-bindgen, not Rhai
|
||||
Err("sign is async in WASM; use the WASM sign() API from JS instead".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// WASM-specific API: no Arc/Mutex, just a reference
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn register_rhai_api<S: kvstore::traits::KVStore + Clone + 'static>(
|
||||
engine: &mut Engine,
|
||||
// session_manager: &SessionManager<S>,
|
||||
) {
|
||||
// WASM registration logic (adapt as needed)
|
||||
// Example: engine.register_type::<RhaiSessionManager<S>>();
|
||||
// engine.register_fn(...);
|
||||
// In WASM, register global functions that operate on the singleton
|
||||
engine.register_fn("select_keypair", |key_id: String| {
|
||||
crate::wasm_helpers::select_keypair_global(&key_id)
|
||||
}); // Calls the shared WASM session singleton
|
||||
engine.register_fn("sign", |_message: rhai::Blob| -> Result<rhai::Blob, String> {
|
||||
Err("sign is async in WASM; use the WASM sign() API from JS instead".to_string())
|
||||
});
|
||||
// No global session object in WASM; use JS/WASM API for session ops
|
||||
}
|
||||
|
||||
// --- Sync wrappers for async Rust APIs (to be implemented with block_on or similar) ---
|
||||
// These should be implemented in a separate module (rhai_sync_helpers.rs)
|
||||
// and use block_on or spawn_local for WASM compatibility.
|
22
vault/src/rhai_sync_helpers.rs
Normal file
22
vault/src/rhai_sync_helpers.rs
Normal file
@ -0,0 +1,22 @@
|
||||
use crate::session::SessionManager;
|
||||
|
||||
// Synchronous wrappers for async Vault and EVM client APIs for use in Rhai bindings.
|
||||
// These use block_on for native, and spawn_local for WASM if needed.
|
||||
|
||||
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tokio::runtime::Handle;
|
||||
|
||||
// Synchronous sign wrapper for Rhai: only supports signing the currently selected keypair in the unlocked keyspace
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn sign_sync<S: kvstore::traits::KVStore + Send + Sync + 'static>(
|
||||
session_manager: &SessionManager<S>,
|
||||
message: &[u8],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
Handle::current().block_on(async {
|
||||
session_manager.sign(message).await.map_err(|e| format!("sign error: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
|
394
vault/src/session.rs
Normal file
394
vault/src/session.rs
Normal file
@ -0,0 +1,394 @@
|
||||
//! Session manager for the vault crate (optional)
|
||||
//! Provides ergonomic, stateful access to unlocked keyspaces and keypairs for interactive applications.
|
||||
//! All state is local to the SessionManager instance. No global state.
|
||||
|
||||
use crate::{KVStore, KeyEntry, KeyspaceData, Vault, VaultError};
|
||||
use zeroize::Zeroize;
|
||||
|
||||
/// SessionManager: Ergonomic, stateful wrapper over the Vault stateless API.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct SessionManager<S: KVStore + Send + Sync> {
|
||||
// ... existing fields
|
||||
|
||||
vault: Vault<S>,
|
||||
unlocked_keyspace: Option<(String, Vec<u8>, KeyspaceData)>, // (name, password, data)
|
||||
current_keypair: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub struct SessionManager<S: KVStore> {
|
||||
vault: Vault<S>,
|
||||
unlocked_keyspace: Option<(String, Vec<u8>, KeyspaceData)>, // (name, password, data)
|
||||
current_keypair: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl<S: KVStore> SessionManager<S> {
|
||||
pub fn get_vault_mut(&mut self) -> &mut Vault<S> {
|
||||
&mut self.vault
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl<S: KVStore + Send + Sync> SessionManager<S> {
|
||||
pub fn new(vault: Vault<S>) -> Self {
|
||||
Self {
|
||||
vault,
|
||||
unlocked_keyspace: None,
|
||||
current_keypair: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_keyspace(
|
||||
&mut self,
|
||||
name: &str,
|
||||
password: &[u8],
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<(), VaultError> {
|
||||
self.vault.create_keyspace(name, password, tags).await?;
|
||||
self.unlock_keyspace(name, password).await
|
||||
}
|
||||
|
||||
pub async fn unlock_keyspace(&mut self, name: &str, password: &[u8]) -> Result<(), VaultError> {
|
||||
let data = self.vault.unlock_keyspace(name, password).await?;
|
||||
self.unlocked_keyspace = Some((name.to_string(), password.to_vec(), data));
|
||||
self.current_keypair = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn select_keypair(&mut self, key_id: &str) -> Result<(), VaultError> {
|
||||
let data = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.map(|(_, _, d)| d)
|
||||
.ok_or_else(|| VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
if data.keypairs.iter().any(|k| k.id == key_id) {
|
||||
self.current_keypair = Some(key_id.to_string());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(VaultError::Crypto("Keypair not found".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_keypair(
|
||||
&mut self,
|
||||
key_type: Option<crate::KeyType>,
|
||||
metadata: Option<crate::KeyMetadata>,
|
||||
) -> Result<String, VaultError> {
|
||||
let (name, password, _) = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.ok_or_else(|| VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
let id = self
|
||||
.vault
|
||||
.add_keypair(name, password, key_type, metadata.clone())
|
||||
.await?;
|
||||
let data = self.vault.unlock_keyspace(name, password).await?;
|
||||
self.unlocked_keyspace = Some((name.clone(), password.clone(), data));
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn list_keypairs(&self) -> Option<&[KeyEntry]> {
|
||||
self.current_keyspace().map(|ks| ks.keypairs.as_slice())
|
||||
}
|
||||
|
||||
pub fn current_keyspace(&self) -> Option<&KeyspaceData> {
|
||||
self.unlocked_keyspace.as_ref().map(|(_, _, data)| data)
|
||||
}
|
||||
|
||||
/// Returns the name of the currently unlocked keyspace, if any.
|
||||
pub fn current_keyspace_name(&self) -> Option<&str> {
|
||||
self.unlocked_keyspace
|
||||
.as_ref()
|
||||
.map(|(name, _, _)| name.as_str())
|
||||
}
|
||||
|
||||
pub fn current_keypair(&self) -> Option<&KeyEntry> {
|
||||
let keyspace = self.current_keyspace()?;
|
||||
let key_id = self.current_keypair.as_ref()?;
|
||||
keyspace.keypairs.iter().find(|k| &k.id == key_id)
|
||||
}
|
||||
|
||||
/// Returns the metadata of the current selected keypair, if any.
|
||||
pub fn current_keypair_metadata(&self) -> Option<crate::KeyMetadata> {
|
||||
self.current_keypair().and_then(|k| k.metadata.clone())
|
||||
}
|
||||
|
||||
/// Returns the public key of the current selected keypair, if any.
|
||||
pub fn current_keypair_public_key(&self) -> Option<Vec<u8>> {
|
||||
self.current_keypair().map(|k| k.public_key.clone())
|
||||
}
|
||||
|
||||
/// Returns true if a keyspace is currently unlocked.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
self.unlocked_keyspace.is_some()
|
||||
}
|
||||
|
||||
/// Returns the default keypair (first keypair) for client identity, if any.
|
||||
pub fn default_keypair(&self) -> Option<&KeyEntry> {
|
||||
self.current_keyspace()
|
||||
.and_then(|ks| ks.keypairs.first())
|
||||
}
|
||||
|
||||
/// Selects the default keypair (first keypair) as the current keypair.
|
||||
pub fn select_default_keypair(&mut self) -> Result<(), VaultError> {
|
||||
let default_id = self
|
||||
.default_keypair()
|
||||
.map(|k| k.id.clone())
|
||||
.ok_or_else(|| VaultError::Crypto("No default keypair found".to_string()))?;
|
||||
|
||||
self.select_keypair(&default_id)
|
||||
}
|
||||
|
||||
/// Returns true if the current keypair is the default keypair (first keypair).
|
||||
pub fn is_default_keypair_selected(&self) -> bool {
|
||||
match (self.current_keypair(), self.default_keypair()) {
|
||||
(Some(current), Some(default)) => current.id == default.id,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
let (name, password, _) = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.ok_or(VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
let keypair = self
|
||||
.current_keypair()
|
||||
.ok_or(VaultError::Crypto("No keypair selected".to_string()))?;
|
||||
self.vault.sign(name, password, &keypair.id, message).await
|
||||
}
|
||||
|
||||
/// Verify a signature using the currently selected keypair
|
||||
pub async fn verify(&self, message: &[u8], signature: &[u8]) -> Result<bool, VaultError> {
|
||||
let (name, password, _) = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.ok_or(VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
let keypair = self
|
||||
.current_keypair()
|
||||
.ok_or(VaultError::Crypto("No keypair selected".to_string()))?;
|
||||
self.vault.verify(name, password, &keypair.id, message, signature).await
|
||||
}
|
||||
|
||||
/// Encrypt data using the keyspace symmetric cipher
|
||||
/// Returns the encrypted data with the nonce prepended
|
||||
pub async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
let (name, password, _) = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.ok_or(VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
self.vault.encrypt(name, password, plaintext).await
|
||||
}
|
||||
|
||||
/// Decrypt data using the keyspace symmetric cipher
|
||||
/// Expects the nonce to be prepended to the ciphertext (as returned by encrypt)
|
||||
pub async fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
let (name, password, _) = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.ok_or(VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
self.vault.decrypt(name, password, ciphertext).await
|
||||
}
|
||||
|
||||
pub fn get_vault(&self) -> &Vault<S> {
|
||||
&self.vault
|
||||
}
|
||||
|
||||
pub fn logout(&mut self) {
|
||||
if let Some((_, mut password, mut data)) = self.unlocked_keyspace.take() {
|
||||
password.zeroize();
|
||||
data.zeroize();
|
||||
}
|
||||
self.current_keypair = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl<S: KVStore + Send + Sync> Drop for SessionManager<S> {
|
||||
fn drop(&mut self) {
|
||||
self.logout();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl<S: KVStore> SessionManager<S> {
|
||||
pub fn new(vault: Vault<S>) -> Self {
|
||||
Self {
|
||||
vault,
|
||||
unlocked_keyspace: None,
|
||||
current_keypair: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_keyspace(
|
||||
&mut self,
|
||||
name: &str,
|
||||
password: &[u8],
|
||||
tags: Option<Vec<String>>,
|
||||
) -> Result<(), VaultError> {
|
||||
self.vault.create_keyspace(name, password, tags).await?;
|
||||
self.unlock_keyspace(name, password).await
|
||||
}
|
||||
|
||||
pub async fn unlock_keyspace(&mut self, name: &str, password: &[u8]) -> Result<(), VaultError> {
|
||||
let data = self.vault.unlock_keyspace(name, password).await?;
|
||||
self.unlocked_keyspace = Some((name.to_string(), password.to_vec(), data));
|
||||
self.current_keypair = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn select_keypair(&mut self, key_id: &str) -> Result<(), VaultError> {
|
||||
let data = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.map(|(_, _, d)| d)
|
||||
.ok_or_else(|| VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
if data.keypairs.iter().any(|k| k.id == key_id) {
|
||||
self.current_keypair = Some(key_id.to_string());
|
||||
Ok(())
|
||||
} else {
|
||||
Err(VaultError::Crypto("Keypair not found".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_keypair(
|
||||
&mut self,
|
||||
key_type: Option<crate::KeyType>,
|
||||
metadata: Option<crate::KeyMetadata>,
|
||||
) -> Result<String, VaultError> {
|
||||
let (name, password, _) = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.ok_or_else(|| VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
let id = self
|
||||
.vault
|
||||
.add_keypair(name, password, key_type, metadata.clone())
|
||||
.await?;
|
||||
let data = self.vault.unlock_keyspace(name, password).await?;
|
||||
self.unlocked_keyspace = Some((name.clone(), password.clone(), data));
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn list_keypairs(&self) -> Option<&[KeyEntry]> {
|
||||
self.current_keyspace().map(|ks| ks.keypairs.as_slice())
|
||||
}
|
||||
|
||||
pub fn current_keyspace(&self) -> Option<&KeyspaceData> {
|
||||
self.unlocked_keyspace.as_ref().map(|(_, _, data)| data)
|
||||
}
|
||||
|
||||
/// Returns the name of the currently unlocked keyspace, if any.
|
||||
pub fn current_keyspace_name(&self) -> Option<&str> {
|
||||
self.unlocked_keyspace
|
||||
.as_ref()
|
||||
.map(|(name, _, _)| name.as_str())
|
||||
}
|
||||
|
||||
pub fn current_keypair(&self) -> Option<&KeyEntry> {
|
||||
let keyspace = self.current_keyspace()?;
|
||||
let key_id = self.current_keypair.as_ref()?;
|
||||
keyspace.keypairs.iter().find(|k| &k.id == key_id)
|
||||
}
|
||||
|
||||
/// Returns the metadata of the current selected keypair, if any.
|
||||
pub fn current_keypair_metadata(&self) -> Option<crate::KeyMetadata> {
|
||||
self.current_keypair().and_then(|k| k.metadata.clone())
|
||||
}
|
||||
|
||||
/// Returns the public key of the current selected keypair, if any.
|
||||
pub fn current_keypair_public_key(&self) -> Option<Vec<u8>> {
|
||||
self.current_keypair().map(|k| k.public_key.clone())
|
||||
}
|
||||
|
||||
/// Returns true if a keyspace is currently unlocked.
|
||||
pub fn is_unlocked(&self) -> bool {
|
||||
self.unlocked_keyspace.is_some()
|
||||
}
|
||||
|
||||
/// Returns the default keypair (first keypair) for client identity, if any.
|
||||
pub fn default_keypair(&self) -> Option<&KeyEntry> {
|
||||
self.current_keyspace()
|
||||
.and_then(|ks| ks.keypairs.first())
|
||||
}
|
||||
|
||||
/// Selects the default keypair (first keypair) as the current keypair.
|
||||
pub fn select_default_keypair(&mut self) -> Result<(), VaultError> {
|
||||
let default_id = self
|
||||
.default_keypair()
|
||||
.map(|k| k.id.clone())
|
||||
.ok_or_else(|| VaultError::Crypto("No default keypair found".to_string()))?;
|
||||
|
||||
self.select_keypair(&default_id)
|
||||
}
|
||||
|
||||
/// Returns true if the current keypair is the default keypair (first keypair).
|
||||
pub fn is_default_keypair_selected(&self) -> bool {
|
||||
match (self.current_keypair(), self.default_keypair()) {
|
||||
(Some(current), Some(default)) => current.id == default.id,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sign(&self, message: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
let (name, password, _) = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.ok_or(VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
let keypair = self
|
||||
.current_keypair()
|
||||
.ok_or(VaultError::Crypto("No keypair selected".to_string()))?;
|
||||
self.vault.sign(name, password, &keypair.id, message).await
|
||||
}
|
||||
|
||||
/// Verify a signature using the currently selected keypair
|
||||
pub async fn verify(&self, message: &[u8], signature: &[u8]) -> Result<bool, VaultError> {
|
||||
let (name, password, _) = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.ok_or(VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
let keypair = self
|
||||
.current_keypair()
|
||||
.ok_or(VaultError::Crypto("No keypair selected".to_string()))?;
|
||||
self.vault.verify(name, password, &keypair.id, message, signature).await
|
||||
}
|
||||
|
||||
/// Encrypt data using the keyspace symmetric cipher
|
||||
/// Returns the encrypted data with the nonce prepended
|
||||
pub async fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
let (name, password, _) = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.ok_or(VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
self.vault.encrypt(name, password, plaintext).await
|
||||
}
|
||||
|
||||
/// Decrypt data using the keyspace symmetric cipher
|
||||
/// Expects the nonce to be prepended to the ciphertext (as returned by encrypt)
|
||||
pub async fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
let (name, password, _) = self
|
||||
.unlocked_keyspace
|
||||
.as_ref()
|
||||
.ok_or(VaultError::Crypto("No keyspace unlocked".to_string()))?;
|
||||
self.vault.decrypt(name, password, ciphertext).await
|
||||
}
|
||||
|
||||
pub fn get_vault(&self) -> &Vault<S> {
|
||||
&self.vault
|
||||
}
|
||||
|
||||
pub fn logout(&mut self) {
|
||||
if let Some((_, mut password, mut data)) = self.unlocked_keyspace.take() {
|
||||
password.zeroize();
|
||||
data.zeroize();
|
||||
}
|
||||
self.current_keypair = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl<S: KVStore> Drop for SessionManager<S> {
|
||||
fn drop(&mut self) {
|
||||
self.logout();
|
||||
}
|
||||
}
|
12
vault/src/session_singleton.rs
Normal file
12
vault/src/session_singleton.rs
Normal file
@ -0,0 +1,12 @@
|
||||
//! WASM session singleton for the vault crate
|
||||
//! This file defines the global SessionManager singleton for WASM builds.
|
||||
|
||||
use once_cell::unsync::Lazy;
|
||||
use std::cell::RefCell;
|
||||
use crate::session::SessionManager;
|
||||
use kvstore::wasm::WasmStore;
|
||||
|
||||
// Thread-local singleton for WASM session management
|
||||
thread_local! {
|
||||
pub static SESSION_MANAGER: Lazy<RefCell<Option<SessionManager<WasmStore>>>> = Lazy::new(|| RefCell::new(None));
|
||||
}
|
19
vault/src/utils.rs
Normal file
19
vault/src/utils.rs
Normal file
@ -0,0 +1,19 @@
|
||||
//! Utility functions for the vault crate
|
||||
|
||||
// Add serialization helpers, random salt generation, etc.
|
||||
|
||||
/// Returns the current unix timestamp as u64 (seconds since epoch)
|
||||
pub fn now() -> u64 {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// Use JS Date.now() in milliseconds, convert to seconds
|
||||
|
||||
let date = js_sys::Date::new_0();
|
||||
(date.get_time() / 1000.0) as u64
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()
|
||||
}
|
||||
}
|
15
vault/src/wasm_helpers.rs
Normal file
15
vault/src/wasm_helpers.rs
Normal file
@ -0,0 +1,15 @@
|
||||
//! WASM-specific helpers for Rhai bindings and session management
|
||||
//! Provides global functions for Rhai integration in WASM builds.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn select_keypair_global(key_id: &str) -> Result<(), String> {
|
||||
use crate::session_singleton::SESSION_MANAGER;
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
let mut opt = cell.borrow_mut();
|
||||
if let Some(session) = opt.as_mut() {
|
||||
session.select_keypair(key_id).map_err(|e| format!("select_keypair error: {e}"))
|
||||
} else {
|
||||
Err("Session not initialized".to_string())
|
||||
}
|
||||
})
|
||||
}
|
71
vault/tests/keypair_management.rs
Normal file
71
vault/tests/keypair_management.rs
Normal file
@ -0,0 +1,71 @@
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
//! Tests for vault keypair management and crypto operations
|
||||
use vault::{Vault, KeyType, KeyMetadata};
|
||||
use kvstore::native::NativeStore;
|
||||
|
||||
use log::debug;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_keypair_management_and_crypto() {
|
||||
let _ = env_logger::builder().is_test(true).try_init();
|
||||
debug!("test_keypair_management_and_crypto started");
|
||||
// Use NativeStore for native tests
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use tempfile::TempDir;
|
||||
let tmp_dir = TempDir::new().expect("create temp dir");
|
||||
let store = NativeStore::open(tmp_dir.path().to_str().unwrap()).expect("Failed to open native store");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let mut vault = Vault::new(store);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
compile_error!("This test is not intended for wasm32 targets");
|
||||
let keyspace = &format!("testspace_{}", chrono::Utc::now().timestamp_nanos_opt().unwrap());
|
||||
let password = b"supersecret";
|
||||
|
||||
debug!("keyspace: {} password: {}", keyspace, hex::encode(password));
|
||||
debug!("before create_keyspace");
|
||||
vault.create_keyspace(keyspace, password, None).await.unwrap();
|
||||
|
||||
debug!("after create_keyspace: keyspace={} password={}", keyspace, hex::encode(password));
|
||||
let keys = vault.list_keypairs(keyspace, password).await.unwrap();
|
||||
assert_eq!(keys.len(), 1); // should be 1 because we added a default keypair on create_keyspace
|
||||
debug!("before add Ed25519 keypair");
|
||||
let key_id = vault.add_keypair(keyspace, password, Some(KeyType::Ed25519), Some(KeyMetadata { name: Some("edkey".into()), created_at: None, tags: None })).await;
|
||||
match &key_id {
|
||||
Ok(_) => debug!("after add Ed25519 keypair (Ok)"),
|
||||
Err(e) => debug!("after add Ed25519 keypair (Err): {:?}", e),
|
||||
}
|
||||
let key_id = key_id.unwrap();
|
||||
debug!("before add secp256k1 keypair");
|
||||
let secp_id = vault.add_keypair(keyspace, password, None, Some(KeyMetadata { name: Some("secpkey".into()), created_at: None, tags: None })).await.unwrap();
|
||||
|
||||
debug!("before list_keypairs");
|
||||
let keys = vault.list_keypairs(keyspace, password).await.unwrap();
|
||||
assert_eq!(keys.len(), 3);
|
||||
|
||||
debug!("before export Ed25519 keypair");
|
||||
let (priv_bytes, pub_bytes) = vault.export_keypair(keyspace, password, &key_id).await.unwrap();
|
||||
assert!(!priv_bytes.is_empty() && !pub_bytes.is_empty());
|
||||
|
||||
debug!("before sign Ed25519");
|
||||
let msg = b"hello world";
|
||||
let sig = vault.sign(keyspace, password, &key_id, msg).await.unwrap();
|
||||
debug!("before verify Ed25519");
|
||||
let ok = vault.verify(keyspace, password, &key_id, msg, &sig).await.unwrap();
|
||||
assert!(ok);
|
||||
|
||||
debug!("before sign secp256k1");
|
||||
let sig2 = vault.sign(keyspace, password, &secp_id, msg).await.unwrap();
|
||||
debug!("before verify secp256k1");
|
||||
let ok2 = vault.verify(keyspace, password, &secp_id, msg, &sig2).await.unwrap();
|
||||
assert!(ok2);
|
||||
|
||||
// Encrypt and decrypt
|
||||
let ciphertext = vault.encrypt(keyspace, password, msg).await.unwrap();
|
||||
let plaintext = vault.decrypt(keyspace, password, &ciphertext).await.unwrap();
|
||||
assert_eq!(plaintext, msg);
|
||||
|
||||
// Remove a keypair
|
||||
vault.remove_keypair(keyspace, password, &key_id).await.unwrap();
|
||||
let keys = vault.list_keypairs(keyspace, password).await.unwrap();
|
||||
assert_eq!(keys.len(), 2);
|
||||
}
|
35
vault/tests/mock_store.rs
Normal file
35
vault/tests/mock_store.rs
Normal file
@ -0,0 +1,35 @@
|
||||
//! In-memory mock key-value store for testing vault logic (native only)
|
||||
use kvstore::KVStore;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MockStore {
|
||||
inner: Arc<Mutex<HashMap<String, Vec<u8>>>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
|
||||
impl KVStore for MockStore {
|
||||
async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, kvstore::KVError> {
|
||||
Ok(self.inner.lock().unwrap().get(key).cloned())
|
||||
}
|
||||
async fn set(&self, key: &str, value: &[u8]) -> Result<(), kvstore::KVError> {
|
||||
self.inner.lock().unwrap().insert(key.to_string(), value.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn remove(&self, key: &str) -> Result<(), kvstore::KVError> {
|
||||
self.inner.lock().unwrap().remove(key);
|
||||
Ok(())
|
||||
}
|
||||
async fn contains_key(&self, key: &str) -> Result<bool, kvstore::KVError> {
|
||||
Ok(self.inner.lock().unwrap().contains_key(key))
|
||||
}
|
||||
async fn keys(&self) -> Result<Vec<String>, kvstore::KVError> {
|
||||
Ok(self.inner.lock().unwrap().keys().cloned().collect())
|
||||
}
|
||||
async fn clear(&self) -> Result<(), kvstore::KVError> {
|
||||
self.inner.lock().unwrap().clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
66
vault/tests/session_manager.rs
Normal file
66
vault/tests/session_manager.rs
Normal file
@ -0,0 +1,66 @@
|
||||
//! Integration tests for SessionManager (stateful API) in the vault crate
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use vault::{Vault, KeyType, KeyMetadata, SessionManager};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use kvstore::NativeStore;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[tokio::test]
|
||||
async fn session_manager_end_to_end() {
|
||||
use tempfile::TempDir;
|
||||
let tmp_dir = TempDir::new().expect("create temp dir");
|
||||
let store = NativeStore::open(tmp_dir.path().to_str().unwrap()).expect("open NativeStore");
|
||||
let vault = Vault::new(store);
|
||||
let keyspace = "personal";
|
||||
let password = b"testpass";
|
||||
|
||||
// Create session manager
|
||||
let mut session = SessionManager::new(vault);
|
||||
// Create and unlock keyspace in one step
|
||||
session.create_keyspace(keyspace, password, None).await.expect("create_keyspace via session");
|
||||
// Add keypair using session API
|
||||
let key_id = session.add_keypair(Some(KeyType::Secp256k1), Some(KeyMetadata { name: Some("main".to_string()), created_at: None, tags: None })).await.expect("add_keypair via session");
|
||||
session.select_keypair(&key_id).expect("select_keypair");
|
||||
|
||||
// Test add_keypair with metadata via SessionManager
|
||||
let meta = KeyMetadata { name: Some("user1-key".to_string()), created_at: None, tags: Some(vec!["tag1".to_string()]) };
|
||||
let key_id2 = session.add_keypair(Some(KeyType::Ed25519), Some(meta.clone())).await.expect("add_keypair via session");
|
||||
// List keypairs and check metadata
|
||||
let keypairs = session.list_keypairs().expect("list_keypairs");
|
||||
assert!(keypairs.iter().any(|k| k.id == key_id2 && k.metadata.as_ref().unwrap().name.as_deref() == Some("user1-key")), "metadata name should be present");
|
||||
|
||||
// Sign and verify
|
||||
let msg = b"hello world";
|
||||
let sig = session.sign(msg).await.expect("sign");
|
||||
let _keypair = session.current_keypair().expect("current_keypair");
|
||||
// Use stateless API for verify: get password from test context, not from private fields
|
||||
let password = b"testpass";
|
||||
let verified = session
|
||||
.get_vault()
|
||||
.verify(keyspace, password, &key_id, msg, &sig)
|
||||
.await
|
||||
.expect("verify");
|
||||
assert!(verified, "signature should verify");
|
||||
|
||||
// Logout wipes secrets
|
||||
session.logout();
|
||||
assert!(session.current_keyspace().is_none());
|
||||
assert!(session.current_keypair().is_none());
|
||||
// No public API for unlocked_keyspaces, but behavior is covered by above asserts
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[tokio::test]
|
||||
async fn session_manager_errors() {
|
||||
use tempfile::TempDir;
|
||||
let tmp_dir = TempDir::new().expect("create temp dir");
|
||||
let store = NativeStore::open(tmp_dir.path().to_str().unwrap()).expect("open NativeStore");
|
||||
let vault = Vault::new(store);
|
||||
let mut session = SessionManager::new(vault);
|
||||
// No keyspace unlocked
|
||||
// select_keyspace removed; test unlocking a non-existent keyspace or selecting a keypair from an empty keyspace instead.
|
||||
assert!(session.select_keypair("none").is_err());
|
||||
assert!(session.select_keypair("none").is_err());
|
||||
assert!(session.sign(b"fail").await.is_err());
|
||||
}
|
24
vault/tests/wasm_keypair_management.rs
Normal file
24
vault/tests/wasm_keypair_management.rs
Normal file
@ -0,0 +1,24 @@
|
||||
// This file contains WASM-only tests for keypair management in the vault crate.
|
||||
// All code is strictly separated from native using cfg attributes.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
//! WASM test for keypair management in the vault crate.
|
||||
|
||||
use wasm_bindgen_test::*;
|
||||
use vault::Vault;
|
||||
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
|
||||
#[wasm_bindgen_test(async)]
|
||||
async fn test_keypair_management_and_crypto() {
|
||||
// Example: test keypair creation, selection, signing, etc.
|
||||
// This is a placeholder for your real test logic.
|
||||
// All imports are WASM-specific and local to the test function
|
||||
use kvstore::wasm::WasmStore;
|
||||
use vault::Vault;
|
||||
let store = WasmStore::open("vault").await.unwrap();
|
||||
let mut vault = Vault::new(store);
|
||||
vault.create_keyspace("testspace", b"pw", None).await.unwrap();
|
||||
let key_id = vault.add_keypair("testspace", b"pw", None, None).await.unwrap();
|
||||
assert!(!key_id.is_empty(), "Keypair ID should not be empty");
|
||||
}
|
74
vault/tests/wasm_session_manager.rs
Normal file
74
vault/tests/wasm_session_manager.rs
Normal file
@ -0,0 +1,74 @@
|
||||
// This file contains WASM-only tests for session manager logic in the vault crate.
|
||||
// All code is strictly separated from native using cfg attributes.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
//! WASM test for session manager logic in the vault crate.
|
||||
|
||||
use wasm_bindgen_test::*;
|
||||
use vault::session::SessionManager;
|
||||
use vault::Vault;
|
||||
|
||||
|
||||
wasm_bindgen_test_configure!(run_in_browser);
|
||||
|
||||
#[wasm_bindgen_test(async)]
|
||||
async fn test_session_manager_lock_unlock_keypairs_persistence() {
|
||||
use kvstore::wasm::WasmStore;
|
||||
use vault::{Vault, KeyType, KeyMetadata};
|
||||
use vault::session::SessionManager;
|
||||
let store = WasmStore::open("test-session-manager-lock-unlock").await.unwrap();
|
||||
let mut vault = Vault::new(store);
|
||||
let keyspace = "testspace2";
|
||||
let password = b"testpass2";
|
||||
|
||||
// 1. Create session manager
|
||||
let mut session = SessionManager::new(vault);
|
||||
// Create and unlock keyspace in one step
|
||||
session.create_keyspace(keyspace, password, None).await.expect("create_keyspace via session");
|
||||
// 2. Add two keypairs with names using session API
|
||||
let meta1 = KeyMetadata { name: Some("keypair-one".to_string()), created_at: None, tags: None };
|
||||
let meta2 = KeyMetadata { name: Some("keypair-two".to_string()), created_at: None, tags: None };
|
||||
let id1 = session.add_keypair(Some(KeyType::Secp256k1), Some(meta1.clone())).await.expect("add_keypair1 via session");
|
||||
let id2 = session.add_keypair(Some(KeyType::Ed25519), Some(meta2.clone())).await.expect("add_keypair2 via session");
|
||||
|
||||
// 3. List, store keys and names
|
||||
let keypairs_before = session.list_keypairs().expect("list_keypairs before").iter().map(|k| (k.id.clone(), k.public_key.clone(), k.private_key.clone(), k.metadata.clone())).collect::<Vec<_>>();
|
||||
assert_eq!(keypairs_before.len(), 3);
|
||||
assert!(keypairs_before.iter().any(|k| k.0 == id1 && k.3.as_ref().unwrap().name.as_deref() == Some("keypair-one")));
|
||||
assert!(keypairs_before.iter().any(|k| k.0 == id2 && k.3.as_ref().unwrap().name.as_deref() == Some("keypair-two")));
|
||||
|
||||
// 4. Lock (logout)
|
||||
session.logout();
|
||||
assert!(session.current_keyspace().is_none());
|
||||
|
||||
// 5. Unlock again
|
||||
session.unlock_keyspace(keyspace, password).await.expect("unlock_keyspace again");
|
||||
// select_keyspace removed; unlocking a keyspace is sufficient after refactor.
|
||||
|
||||
// 6. List and check keys/names match
|
||||
let keypairs_after = session.list_keypairs().expect("list_keypairs after").iter().map(|k| (k.id.clone(), k.public_key.clone(), k.private_key.clone(), k.metadata.clone())).collect::<Vec<_>>();
|
||||
assert_eq!(keypairs_before, keypairs_after, "Keypairs before and after lock/unlock should match");
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test(async)]
|
||||
async fn test_session_manager_end_to_end() {
|
||||
use kvstore::wasm::WasmStore;
|
||||
use vault::{Vault, KeyType, KeyMetadata};
|
||||
use vault::session::SessionManager;
|
||||
let store = WasmStore::open("test-session-manager").await.unwrap();
|
||||
let keyspace = "testspace";
|
||||
let password = b"testpass";
|
||||
|
||||
// Create session manager
|
||||
let mut session = SessionManager::new(Vault::new(store));
|
||||
// Create and unlock keyspace in one step
|
||||
session.create_keyspace(keyspace, password, None).await.expect("create_keyspace via session");
|
||||
// Add keypair using session API
|
||||
let key_id = session.add_keypair(Some(KeyType::Secp256k1), Some(KeyMetadata { name: Some("main".to_string()), created_at: None, tags: None })).await.expect("add_keypair via session");
|
||||
|
||||
// Test add_keypair with metadata via SessionManager
|
||||
let meta = KeyMetadata { name: Some("user1-key".to_string()), created_at: None, tags: Some(vec!["tag1".to_string()]) };
|
||||
let key_id2 = session.add_keypair(Some(KeyType::Ed25519), Some(meta.clone())).await.expect("add_keypair via session");
|
||||
// List keypairs and check metadata
|
||||
let keypairs = session.list_keypairs().expect("list_keypairs");
|
||||
assert!(keypairs.iter().any(|k| k.id == key_id2 && k.metadata.as_ref().unwrap().name.as_deref() == Some("user1-key")), "metadata name should be present");
|
||||
}
|
34
wasm_app/Cargo.toml
Normal file
34
wasm_app/Cargo.toml
Normal file
@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "wasm_app"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
instant = { version = "0.1", features = ["wasm-bindgen"] }
|
||||
web-sys = { version = "0.3", features = ["console"] }
|
||||
js-sys = "0.3"
|
||||
kvstore = { path = "../kvstore" }
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
wasm-bindgen = { version = "0.2", features = ["serde-serialize"] }
|
||||
gloo-utils = "0.1"
|
||||
|
||||
#
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
rhai = { version = "1.16", features = ["serde"] }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
once_cell = "1.21"
|
||||
vault = { path = "../vault" }
|
||||
evm_client = { path = "../evm_client" }
|
||||
sigsocket_client = { path = "../sigsocket_client" }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.3", features = ["wasm_js"] }
|
||||
getrandom_02 = { package = "getrandom", version = "0.2.16", features = ["js"] }
|
172
wasm_app/src/debug_bindings.rs
Normal file
172
wasm_app/src/debug_bindings.rs
Normal file
@ -0,0 +1,172 @@
|
||||
//! WASM-only debug bindings for the vault extension
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use crate::{SESSION_MANAGER, SESSION_PASSWORD};
|
||||
|
||||
/// Debugging function to check if keypairs can be listed
|
||||
#[wasm_bindgen]
|
||||
pub async fn list_keypairs_debug() -> Result<JsValue, JsValue> {
|
||||
use js_sys::{Array, Object};
|
||||
use web_sys::console;
|
||||
console::log_1(&"Debug listing keypairs...".into());
|
||||
let session_ptr = SESSION_MANAGER.with(|cell| {
|
||||
let has_session = cell.borrow().is_some();
|
||||
console::log_1(&format!("Has session: {}", has_session).into());
|
||||
cell.borrow().as_ref().map(|s| s as *const _)
|
||||
});
|
||||
let password_opt = SESSION_PASSWORD.with(|pw| {
|
||||
let has_pw = pw.borrow().is_some();
|
||||
console::log_1(&format!("Has password: {}", has_pw).into());
|
||||
pw.borrow().clone()
|
||||
});
|
||||
if session_ptr.is_none() {
|
||||
return Err(JsValue::from_str("Session not initialized in debug function"));
|
||||
}
|
||||
if password_opt.is_none() {
|
||||
return Err(JsValue::from_str("Session password not set in debug function"));
|
||||
}
|
||||
let session: &vault::session::SessionManager<kvstore::wasm::WasmStore> = unsafe { &*session_ptr.unwrap() };
|
||||
let password = password_opt.unwrap();
|
||||
match session.current_keyspace_name() {
|
||||
Some(ks) => {
|
||||
let vault = session.get_vault();
|
||||
match vault.list_keypairs(ks, &password).await {
|
||||
Ok(keypairs) => {
|
||||
console::log_1(&format!("Found {} keypairs", keypairs.len()).into());
|
||||
let array = Array::new();
|
||||
for (id, key_type) in keypairs {
|
||||
let obj = Object::new();
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("id"), &JsValue::from_str(&id)).unwrap();
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("type"), &JsValue::from_str(&format!("{:?}", key_type))).unwrap();
|
||||
array.push(&obj);
|
||||
}
|
||||
return Ok(array.into());
|
||||
}
|
||||
Err(e) => {
|
||||
console::error_1(&format!("Error listing keypairs in debug function: {}", e).into());
|
||||
return Err(JsValue::from_str(&format!("Error listing keypairs: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
console::error_1(&"No keyspace selected in debug function".into());
|
||||
return Err(JsValue::from_str("No keyspace selected"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub async fn check_indexeddb() -> Result<JsValue, JsValue> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
return Err(JsValue::from_str(
|
||||
"IndexedDB check only available in browser context",
|
||||
));
|
||||
}
|
||||
|
||||
{
|
||||
use js_sys::Object;
|
||||
use kvstore::traits::KVStore;
|
||||
use kvstore::wasm::WasmStore;
|
||||
use web_sys::console; // Import the trait so we can use its methods
|
||||
|
||||
console::log_1(&"Checking IndexedDB availability...".into());
|
||||
|
||||
// Check if window.indexedDB is available
|
||||
if js_sys::eval("typeof window.indexedDB")
|
||||
.map_err(|e| {
|
||||
console::error_1(&format!("Error checking IndexedDB: {:?}", e).into());
|
||||
JsValue::from_str(&format!("Error checking IndexedDB: {:?}", e))
|
||||
})?
|
||||
.as_string()
|
||||
.unwrap_or_default()
|
||||
== "undefined"
|
||||
{
|
||||
console::error_1(&"IndexedDB is not available in this browser".into());
|
||||
return Err(JsValue::from_str(
|
||||
"IndexedDB is not available in this browser",
|
||||
));
|
||||
}
|
||||
|
||||
// Try to create a test database
|
||||
match WasmStore::open("db_test").await {
|
||||
Ok(store) => {
|
||||
console::log_1(&"Successfully opened test database".into());
|
||||
|
||||
// Try to write and read a value to ensure it works
|
||||
let test_key = "test_key";
|
||||
let test_value = "test_value";
|
||||
|
||||
// Use the KVStore trait methods
|
||||
if let Err(e) = store.set(test_key, test_value.as_bytes()).await {
|
||||
console::error_1(&format!("Failed to write to IndexedDB: {}", e).into());
|
||||
return Err(JsValue::from_str(&format!(
|
||||
"Failed to write to IndexedDB: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
|
||||
// Get the value and handle the Option<Vec<u8>> properly
|
||||
match store.get(test_key).await {
|
||||
Ok(maybe_value) => match maybe_value {
|
||||
Some(value) => {
|
||||
let value_str = String::from_utf8_lossy(&value);
|
||||
if value_str == test_value {
|
||||
console::log_1(
|
||||
&"Successfully read test value from IndexedDB".into(),
|
||||
);
|
||||
} else {
|
||||
console::error_1(
|
||||
&format!(
|
||||
"IndexedDB test value mismatch: expected {}, got {}",
|
||||
test_value, value_str
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
return Err(JsValue::from_str("IndexedDB test value mismatch"));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
console::error_1(&"IndexedDB test key not found after writing".into());
|
||||
return Err(JsValue::from_str(
|
||||
"IndexedDB test key not found after writing",
|
||||
));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
console::error_1(&format!("Failed to read from IndexedDB: {}", e).into());
|
||||
return Err(JsValue::from_str(&format!(
|
||||
"Failed to read from IndexedDB: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Return success with the available database names
|
||||
let result = Object::new();
|
||||
js_sys::Reflect::set(
|
||||
&result,
|
||||
&JsValue::from_str("status"),
|
||||
&JsValue::from_str("success"),
|
||||
)
|
||||
.unwrap();
|
||||
js_sys::Reflect::set(
|
||||
&result,
|
||||
&JsValue::from_str("message"),
|
||||
&JsValue::from_str("IndexedDB is working properly"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
return Ok(result.into());
|
||||
}
|
||||
Err(e) => {
|
||||
console::error_1(&format!("Failed to open IndexedDB test database: {}", e).into());
|
||||
return Err(JsValue::from_str(&format!(
|
||||
"Failed to open test database: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
82
wasm_app/src/lib.rs
Normal file
82
wasm_app/src/lib.rs
Normal file
@ -0,0 +1,82 @@
|
||||
//! WASM entrypoint for Rhai scripting integration for the extension.
|
||||
//! Composes vault and evm_client Rhai bindings and exposes a secure run_rhai API.
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use once_cell::unsync::Lazy;
|
||||
use std::cell::RefCell;
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
use rhai::Engine;
|
||||
use vault::rhai_bindings as vault_rhai_bindings;
|
||||
use vault::session::SessionManager;
|
||||
|
||||
use kvstore::wasm::WasmStore;
|
||||
|
||||
// Global singleton engine/session/client (for demonstration; production should scope per user/session)
|
||||
thread_local! {
|
||||
static ENGINE: Lazy<RefCell<Engine>> = Lazy::new(|| RefCell::new(Engine::new()));
|
||||
static SESSION_PASSWORD: RefCell<Option<Vec<u8>>> = RefCell::new(None);
|
||||
}
|
||||
|
||||
pub use vault::session_singleton::SESSION_MANAGER;
|
||||
|
||||
// Include the keypair bindings module
|
||||
mod vault_bindings;
|
||||
mod sigsocket_bindings;
|
||||
pub use vault_bindings::*;
|
||||
|
||||
// Include the sigsocket module
|
||||
mod sigsocket;
|
||||
pub use sigsocket::*;
|
||||
|
||||
/// Initialize the scripting environment (must be called before run_rhai)
|
||||
#[wasm_bindgen]
|
||||
pub fn init_rhai_env() {
|
||||
ENGINE.with(|engine_cell| {
|
||||
let mut engine = engine_cell.borrow_mut();
|
||||
// Register APIs with dummy session; will be replaced by real session after init
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
if let Some(ref session) = cell.borrow().as_ref() {
|
||||
vault_rhai_bindings::register_rhai_api::<WasmStore>(&mut engine);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Securely run a Rhai script in the extension context (must be called only after user approval)
|
||||
#[wasm_bindgen]
|
||||
pub fn run_rhai(script: &str) -> Result<JsValue, JsValue> {
|
||||
ENGINE.with(|engine_cell| {
|
||||
let mut engine = engine_cell.borrow_mut();
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
if let Some(ref mut session) = cell.borrow_mut().as_mut() {
|
||||
let mut scope = rhai::Scope::new();
|
||||
engine
|
||||
.eval_with_scope::<rhai::Dynamic>(&mut scope, script)
|
||||
.map(|res| JsValue::from_str(&format!("{:?}", res)))
|
||||
.map_err(|e| JsValue::from_str(&format!("{}", e)))
|
||||
} else {
|
||||
Err(JsValue::from_str("Session not initialized"))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub mod wasm_helpers {
|
||||
use super::*;
|
||||
|
||||
/// Global function to select keypair (used in Rhai)
|
||||
pub fn select_keypair_global(key_id: &str) -> Result<(), String> {
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
if let Some(session) = cell.borrow_mut().as_mut() {
|
||||
session
|
||||
.select_keypair(key_id)
|
||||
.map_err(|e| format!("select_keypair error: {e}"))
|
||||
} else {
|
||||
Err("Session not initialized".to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
168
wasm_app/src/sigsocket/connection.rs
Normal file
168
wasm_app/src/sigsocket/connection.rs
Normal file
@ -0,0 +1,168 @@
|
||||
//! SigSocket connection wrapper for WASM
|
||||
//!
|
||||
//! This module provides a WASM-bindgen compatible wrapper around the
|
||||
//! SigSocket client that can be used from JavaScript in the browser extension.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use sigsocket_client::{SigSocketClient, SignResponse};
|
||||
use crate::sigsocket::handler::JavaScriptSignHandler;
|
||||
|
||||
/// WASM-bindgen wrapper for SigSocket client
|
||||
///
|
||||
/// This provides a clean JavaScript API for the browser extension to:
|
||||
/// - Connect to SigSocket servers
|
||||
/// - Send responses to sign requests
|
||||
/// - Manage connection state
|
||||
#[wasm_bindgen]
|
||||
pub struct SigSocketConnection {
|
||||
client: Option<SigSocketClient>,
|
||||
connected: bool,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl SigSocketConnection {
|
||||
/// Create a new SigSocket connection
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: None,
|
||||
connected: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a SigSocket server
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `server_url` - WebSocket server URL (e.g., "ws://localhost:8080/ws")
|
||||
/// * `public_key_hex` - Client's public key as hex string
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Successfully connected
|
||||
/// * `Err(error)` - Connection failed
|
||||
#[wasm_bindgen]
|
||||
pub async fn connect(&mut self, server_url: &str, public_key_hex: &str) -> Result<(), JsValue> {
|
||||
web_sys::console::log_1(&format!("SigSocketConnection::connect called with URL: {}", server_url).into());
|
||||
web_sys::console::log_1(&format!("Public key (first 16 chars): {}", &public_key_hex[..16]).into());
|
||||
|
||||
// Decode public key from hex
|
||||
let public_key = hex::decode(public_key_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid public key hex: {}", e)))?;
|
||||
|
||||
web_sys::console::log_1(&"Creating SigSocketClient...".into());
|
||||
|
||||
// Create client
|
||||
let mut client = SigSocketClient::new(server_url, public_key)
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to create client: {}", e)))?;
|
||||
|
||||
web_sys::console::log_1(&"SigSocketClient created, attempting connection...".into());
|
||||
|
||||
// Set up JavaScript handler
|
||||
client.set_sign_handler(JavaScriptSignHandler);
|
||||
|
||||
// Connect to server
|
||||
web_sys::console::log_1(&"Calling client.connect()...".into());
|
||||
client.connect().await
|
||||
.map_err(|e| {
|
||||
web_sys::console::error_1(&format!("Client connection failed: {}", e).into());
|
||||
JsValue::from_str(&format!("Failed to connect: {}", e))
|
||||
})?;
|
||||
|
||||
web_sys::console::log_1(&"Client connection successful!".into());
|
||||
|
||||
self.client = Some(client);
|
||||
self.connected = true;
|
||||
|
||||
web_sys::console::log_1(&"SigSocketConnection state updated to connected".into());
|
||||
|
||||
// Notify JavaScript of connection state change
|
||||
super::handler::on_connection_state_changed(true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a response to a sign request
|
||||
///
|
||||
/// This should be called by the extension after the user has approved
|
||||
/// a sign request and the message has been signed.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_id` - ID of the original request
|
||||
/// * `message_base64` - Original message (base64-encoded)
|
||||
/// * `signature_hex` - Signature as hex string
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Response sent successfully
|
||||
/// * `Err(error)` - Failed to send response
|
||||
#[wasm_bindgen]
|
||||
pub async fn send_response(&self, request_id: &str, message_base64: &str, signature_hex: &str) -> Result<(), JsValue> {
|
||||
let client = self.client.as_ref()
|
||||
.ok_or_else(|| JsValue::from_str("Not connected"))?;
|
||||
|
||||
// Decode signature from hex
|
||||
let signature = hex::decode(signature_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid signature hex: {}", e)))?;
|
||||
|
||||
// Create response
|
||||
let response = SignResponse::new(request_id, message_base64,
|
||||
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &signature));
|
||||
|
||||
// Send response
|
||||
client.send_sign_response(&response).await
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to send response: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a rejection for a sign request
|
||||
///
|
||||
/// This should be called when the user rejects a sign request.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_id` - ID of the request to reject
|
||||
/// * `reason` - Reason for rejection (optional)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Rejection sent successfully
|
||||
/// * `Err(error)` - Failed to send rejection
|
||||
#[wasm_bindgen]
|
||||
pub async fn send_rejection(&self, request_id: &str, reason: &str) -> Result<(), JsValue> {
|
||||
// For now, we'll just log the rejection
|
||||
// In a full implementation, the server might support rejection messages
|
||||
web_sys::console::log_1(&format!("Sign request {} rejected: {}", request_id, reason).into());
|
||||
|
||||
// TODO: If the server supports rejection messages, send them here
|
||||
// For now, we just ignore the request (timeout on server side)
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect from the SigSocket server
|
||||
#[wasm_bindgen]
|
||||
pub fn disconnect(&mut self) {
|
||||
if let Some(_client) = self.client.take() {
|
||||
// Note: We can't await in a non-async function, so we'll just drop the client
|
||||
// The Drop implementation should handle cleanup
|
||||
self.connected = false;
|
||||
|
||||
// Notify JavaScript of connection state change
|
||||
super::handler::on_connection_state_changed(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if connected to the server
|
||||
#[wasm_bindgen]
|
||||
pub fn is_connected(&self) -> bool {
|
||||
// Check if we have a client and if it reports as connected
|
||||
if let Some(ref client) = self.client {
|
||||
client.is_connected()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SigSocketConnection {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
51
wasm_app/src/sigsocket/handler.rs
Normal file
51
wasm_app/src/sigsocket/handler.rs
Normal file
@ -0,0 +1,51 @@
|
||||
//! JavaScript bridge handler for SigSocket sign requests
|
||||
//!
|
||||
//! This module provides a sign request handler that delegates to JavaScript
|
||||
//! callbacks, allowing the browser extension to handle the actual signing
|
||||
//! and user approval flow.
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use sigsocket_client::{SignRequest, SignRequestHandler, Result, SigSocketError};
|
||||
|
||||
/// JavaScript sign handler that delegates to extension
|
||||
///
|
||||
/// This handler receives sign requests from the SigSocket server and
|
||||
/// calls JavaScript callbacks to notify the extension. The extension
|
||||
/// handles the user approval flow and signing, then responds via
|
||||
/// the SigSocketConnection.send_response() method.
|
||||
pub struct JavaScriptSignHandler;
|
||||
|
||||
impl SignRequestHandler for JavaScriptSignHandler {
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
|
||||
// Call JavaScript callback to notify extension of incoming request
|
||||
on_sign_request_received(&request.id, &request.message);
|
||||
|
||||
// Return error - JavaScript handles response via send_response()
|
||||
// This is intentional as the signing happens asynchronously in the extension
|
||||
Err(SigSocketError::Other("Handled by JavaScript extension".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// External JavaScript functions that the extension must implement
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
/// Called when a sign request is received from the server
|
||||
///
|
||||
/// The extension should:
|
||||
/// 1. Store the request details
|
||||
/// 2. Show notification/badge to user
|
||||
/// 3. Handle user approval flow when popup is opened
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_id` - Unique identifier for the request
|
||||
/// * `message_base64` - Message to be signed (base64-encoded)
|
||||
#[wasm_bindgen(js_name = "onSignRequestReceived")]
|
||||
pub fn on_sign_request_received(request_id: &str, message_base64: &str);
|
||||
|
||||
/// Called when connection state changes
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `connected` - True if connected, false if disconnected
|
||||
#[wasm_bindgen(js_name = "onConnectionStateChanged")]
|
||||
pub fn on_connection_state_changed(connected: bool);
|
||||
}
|
11
wasm_app/src/sigsocket/mod.rs
Normal file
11
wasm_app/src/sigsocket/mod.rs
Normal file
@ -0,0 +1,11 @@
|
||||
//! SigSocket integration module for WASM app
|
||||
//!
|
||||
//! This module provides a clean transport API for SigSocket communication
|
||||
//! that can be used by the browser extension. It handles connection management
|
||||
//! and delegates signing to the extension through JavaScript callbacks.
|
||||
|
||||
pub mod connection;
|
||||
pub mod handler;
|
||||
|
||||
pub use connection::SigSocketConnection;
|
||||
pub use handler::JavaScriptSignHandler;
|
528
wasm_app/src/sigsocket_bindings.rs
Normal file
528
wasm_app/src/sigsocket_bindings.rs
Normal file
@ -0,0 +1,528 @@
|
||||
//! SigSocket bindings for WASM - integrates sigsocket_client with vault system
|
||||
|
||||
use std::cell::RefCell;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sigsocket_client::{SigSocketClient, SignRequest, SignRequestHandler, Result as SigSocketResult, SigSocketError};
|
||||
use web_sys::console;
|
||||
use base64::prelude::*;
|
||||
|
||||
use crate::vault_bindings::{get_workspace_default_public_key, get_current_keyspace_name, is_unlocked, sign_with_default_keypair};
|
||||
|
||||
// Global SigSocket client instance
|
||||
thread_local! {
|
||||
static SIGSOCKET_CLIENT: RefCell<Option<SigSocketClient>> = RefCell::new(None);
|
||||
}
|
||||
|
||||
// Helper macro for console logging
|
||||
macro_rules! console_log {
|
||||
($($t:tt)*) => (console::log_1(&format!($($t)*).into()))
|
||||
}
|
||||
|
||||
/// Extension notification handler that forwards requests to JavaScript
|
||||
pub struct ExtensionNotificationHandler {
|
||||
callback: js_sys::Function,
|
||||
}
|
||||
|
||||
impl ExtensionNotificationHandler {
|
||||
pub fn new(callback: js_sys::Function) -> Self {
|
||||
Self { callback }
|
||||
}
|
||||
}
|
||||
|
||||
impl SignRequestHandler for ExtensionNotificationHandler {
|
||||
fn handle_sign_request(&self, request: &SignRequest) -> SigSocketResult<Vec<u8>> {
|
||||
console_log!("📨 WASM: Handling sign request: {}", request.id);
|
||||
|
||||
// First, store the request in the WASM client
|
||||
let store_result = SIGSOCKET_CLIENT.with(|c| {
|
||||
let mut client_opt = c.borrow_mut();
|
||||
if let Some(client) = client_opt.as_mut() {
|
||||
// Get the connected public key as the target
|
||||
if let Some(target_public_key) = client.connected_public_key() {
|
||||
client.add_pending_request(request.clone(), target_public_key.to_string());
|
||||
console_log!("✅ WASM: Stored sign request: {}", request.id);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SigSocketError::Other("No connected public key".to_string()))
|
||||
}
|
||||
} else {
|
||||
Err(SigSocketError::Other("No SigSocket client available".to_string()))
|
||||
}
|
||||
});
|
||||
|
||||
// If storage failed, return error
|
||||
if let Err(e) = store_result {
|
||||
console_log!("❌ WASM: Failed to store request: {:?}", e);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Create event object for JavaScript notification
|
||||
let event = js_sys::Object::new();
|
||||
js_sys::Reflect::set(&event, &"type".into(), &"sign_request".into())
|
||||
.map_err(|_| SigSocketError::Other("Failed to set event type".to_string()))?;
|
||||
js_sys::Reflect::set(&event, &"request_id".into(), &request.id.clone().into())
|
||||
.map_err(|_| SigSocketError::Other("Failed to set request_id".to_string()))?;
|
||||
js_sys::Reflect::set(&event, &"message".into(), &request.message.clone().into())
|
||||
.map_err(|_| SigSocketError::Other("Failed to set message".to_string()))?;
|
||||
|
||||
// Notify the extension
|
||||
match self.callback.call1(&wasm_bindgen::JsValue::NULL, &event) {
|
||||
Ok(_) => {
|
||||
console_log!("✅ WASM: Notified extension about sign request: {}", request.id);
|
||||
// Return an error to indicate this request should not be auto-signed
|
||||
// The extension will handle the approval flow
|
||||
Err(SigSocketError::Other("Request forwarded to extension for approval".to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
console_log!("❌ WASM: Failed to notify extension: {:?}", e);
|
||||
Err(SigSocketError::Other("Extension notification failed".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connection information for SigSocket
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SigSocketConnectionInfo {
|
||||
pub workspace: String,
|
||||
pub public_key: String,
|
||||
pub is_connected: bool,
|
||||
pub server_url: String,
|
||||
}
|
||||
|
||||
/// SigSocket manager for high-level operations
|
||||
#[wasm_bindgen]
|
||||
pub struct SigSocketManager;
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl SigSocketManager {
|
||||
/// Connect to SigSocket server with smart connection management
|
||||
///
|
||||
/// This handles all connection logic:
|
||||
/// - Reuses existing connection if same workspace
|
||||
/// - Switches connection if different workspace
|
||||
/// - Creates new connection if none exists
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `workspace` - The workspace name to connect with
|
||||
/// * `server_url` - The SigSocket server URL (e.g., "ws://localhost:8080/ws")
|
||||
/// * `event_callback` - JavaScript function to call when events occur
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(connection_info)` - JSON string with connection details
|
||||
/// * `Err(error)` - If connection failed or workspace is invalid
|
||||
#[wasm_bindgen]
|
||||
pub async fn connect_workspace_with_events(workspace: &str, server_url: &str, event_callback: &js_sys::Function) -> Result<String, JsValue> {
|
||||
// 1. Validate workspace exists and get default public key from vault
|
||||
let public_key_js = get_workspace_default_public_key(workspace).await
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to get workspace public key: {:?}", e)))?;
|
||||
|
||||
let public_key_hex = public_key_js.as_string()
|
||||
.ok_or_else(|| JsValue::from_str("Public key is not a string"))?;
|
||||
|
||||
// 2. Decode public key
|
||||
let public_key_bytes = hex::decode(&public_key_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid public key format: {}", e)))?;
|
||||
|
||||
// 3. Check if already connected to same workspace and handle disconnection
|
||||
let should_connect = SIGSOCKET_CLIENT.with(|c| {
|
||||
let mut client_opt = c.borrow_mut();
|
||||
|
||||
// Check if we already have a client for this workspace
|
||||
if let Some(existing_client) = client_opt.as_ref() {
|
||||
if let Some(existing_key) = existing_client.connected_public_key() {
|
||||
if existing_key == hex::encode(&public_key_bytes) && existing_client.is_connected() {
|
||||
console_log!("🔄 WASM: Already connected to workspace: {}", workspace);
|
||||
return false; // Reuse existing connection
|
||||
} else {
|
||||
console_log!("🔄 WASM: Switching workspace from {} to {}",
|
||||
existing_key, hex::encode(&public_key_bytes));
|
||||
|
||||
// Disconnect the old client
|
||||
*client_opt = None; // This will drop the old client and close WebSocket
|
||||
console_log!("🔌 WASM: Disconnected from old workspace");
|
||||
|
||||
return true; // Need new connection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true // Need new connection, no old one to disconnect
|
||||
});
|
||||
|
||||
// 4. Create and connect if needed
|
||||
if should_connect {
|
||||
console_log!("🔗 WASM: Creating new connection for workspace: {}", workspace);
|
||||
|
||||
// Create new client
|
||||
let mut client = SigSocketClient::new(server_url, public_key_bytes.clone())
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to create client: {:?}", e)))?;
|
||||
|
||||
// Set up extension notification handler
|
||||
let handler = ExtensionNotificationHandler::new(event_callback.clone());
|
||||
client.set_sign_handler(handler);
|
||||
|
||||
// Connect to the WebSocket server
|
||||
client.connect().await
|
||||
.map_err(|e| JsValue::from_str(&format!("Connection failed: {:?}", e)))?;
|
||||
|
||||
console_log!("✅ WASM: Connected to SigSocket server for workspace: {}", workspace);
|
||||
|
||||
// Store the connected client
|
||||
SIGSOCKET_CLIENT.with(|c| {
|
||||
*c.borrow_mut() = Some(client);
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Return connection info
|
||||
let connection_info = SigSocketConnectionInfo {
|
||||
workspace: workspace.to_string(),
|
||||
public_key: public_key_hex.clone(),
|
||||
is_connected: true,
|
||||
server_url: server_url.to_string(),
|
||||
};
|
||||
|
||||
// 7. Serialize and return connection info
|
||||
serde_json::to_string(&connection_info)
|
||||
.map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
|
||||
}
|
||||
|
||||
/// Connect to SigSocket server with a specific workspace (backward compatibility)
|
||||
///
|
||||
/// This is a simpler version that doesn't set up event callbacks.
|
||||
/// Use connect_workspace_with_events for full functionality.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `workspace` - The workspace name to connect with
|
||||
/// * `server_url` - The SigSocket server URL (e.g., "ws://localhost:8080/ws")
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(connection_info)` - JSON string with connection details
|
||||
/// * `Err(error)` - If connection failed or workspace is invalid
|
||||
#[wasm_bindgen]
|
||||
pub async fn connect_workspace(workspace: &str, server_url: &str) -> Result<String, JsValue> {
|
||||
// Create a dummy callback that just logs
|
||||
let dummy_callback = js_sys::Function::new_no_args("console.log('SigSocket event:', arguments[0]);");
|
||||
Self::connect_workspace_with_events(workspace, server_url, &dummy_callback).await
|
||||
}
|
||||
|
||||
/// Disconnect from SigSocket server
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Successfully disconnected
|
||||
/// * `Err(error)` - If disconnect failed
|
||||
#[wasm_bindgen]
|
||||
pub async fn disconnect() -> Result<(), JsValue> {
|
||||
SIGSOCKET_CLIENT.with(|c| {
|
||||
let mut client_opt = c.borrow_mut();
|
||||
if let Some(client) = client_opt.take() {
|
||||
let workspace_info = client.connected_public_key()
|
||||
.map(|key| key[..16].to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
// Dropping the client will close the WebSocket connection
|
||||
drop(client);
|
||||
console_log!("🔌 WASM: Disconnected SigSocket client (was: {}...)", workspace_info);
|
||||
} else {
|
||||
console_log!("🔌 WASM: No SigSocket client to disconnect");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if we can approve a specific sign request
|
||||
///
|
||||
/// This validates that:
|
||||
/// 1. The request exists
|
||||
/// 2. The vault session is unlocked
|
||||
/// 3. The current workspace matches the request's target
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_id` - The ID of the request to validate
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(true)` - Request can be approved
|
||||
/// * `Ok(false)` - Request cannot be approved
|
||||
/// * `Err(error)` - Validation error
|
||||
#[wasm_bindgen]
|
||||
pub async fn can_approve_request(request_id: &str) -> Result<bool, JsValue> {
|
||||
// 1. Check if vault session is unlocked
|
||||
if !is_unlocked() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 2. Get current workspace and its public key
|
||||
let current_workspace = get_current_keyspace_name()
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to get current workspace: {:?}", e)))?;
|
||||
|
||||
let current_public_key_js = get_workspace_default_public_key(¤t_workspace).await
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to get current public key: {:?}", e)))?;
|
||||
|
||||
let current_public_key = current_public_key_js.as_string()
|
||||
.ok_or_else(|| JsValue::from_str("Current public key is not a string"))?;
|
||||
|
||||
// 3. Check the request
|
||||
SIGSOCKET_CLIENT.with(|c| {
|
||||
let client = c.borrow();
|
||||
let client = client.as_ref().ok_or_else(|| JsValue::from_str("Not connected to SigSocket"))?;
|
||||
|
||||
// Get the request
|
||||
let request = client.get_pending_request(request_id)
|
||||
.ok_or_else(|| JsValue::from_str("Request not found"))?;
|
||||
|
||||
// Check if request matches current session
|
||||
let can_approve = request.target_public_key == current_public_key;
|
||||
|
||||
console_log!("Can approve request {}: {} (current: {}, target: {})",
|
||||
request_id, can_approve, current_public_key, request.target_public_key);
|
||||
|
||||
Ok(can_approve)
|
||||
})
|
||||
}
|
||||
|
||||
/// Approve a sign request and send the signature to the server
|
||||
///
|
||||
/// This performs the complete approval flow:
|
||||
/// 1. Validates the request can be approved
|
||||
/// 2. Signs the message using the vault
|
||||
/// 3. Sends the signature to the SigSocket server
|
||||
/// 4. Removes the request from pending list
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_id` - The ID of the request to approve
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(signature)` - Base64-encoded signature that was sent
|
||||
/// * `Err(error)` - If approval failed
|
||||
#[wasm_bindgen]
|
||||
pub async fn approve_request(request_id: &str) -> Result<String, JsValue> {
|
||||
// 1. Validate we can approve this request
|
||||
if !Self::can_approve_request(request_id).await? {
|
||||
return Err(JsValue::from_str("Cannot approve this request"));
|
||||
}
|
||||
|
||||
// 2. Get request details and sign the message
|
||||
let (message_bytes, original_request) = SIGSOCKET_CLIENT.with(|c| {
|
||||
let client = c.borrow();
|
||||
let client = client.as_ref().ok_or_else(|| JsValue::from_str("Not connected"))?;
|
||||
|
||||
let request = client.get_pending_request(request_id)
|
||||
.ok_or_else(|| JsValue::from_str("Request not found"))?;
|
||||
|
||||
// Decode the message
|
||||
let message_bytes = request.message_bytes()
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid message format: {}", e)))?;
|
||||
|
||||
Ok::<(Vec<u8>, SignRequest), JsValue>((message_bytes, request.request.clone()))
|
||||
})?;
|
||||
|
||||
// 3. Sign with vault
|
||||
let signature_result = sign_with_default_keypair(&message_bytes).await?;
|
||||
let signature_hex = signature_result.as_string()
|
||||
.ok_or_else(|| JsValue::from_str("Signature result is not a string"))?;
|
||||
|
||||
// Convert hex signature to base64 for SigSocket protocol
|
||||
let signature_bytes = hex::decode(&signature_hex)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid hex signature: {}", e)))?;
|
||||
let signature_base64 = base64::prelude::BASE64_STANDARD.encode(&signature_bytes);
|
||||
|
||||
// 4. Get original message for response
|
||||
let original_message = SIGSOCKET_CLIENT.with(|c| {
|
||||
let client = c.borrow();
|
||||
let client = client.as_ref().ok_or_else(|| JsValue::from_str("Not connected"))?;
|
||||
|
||||
let request = client.get_pending_request(request_id)
|
||||
.ok_or_else(|| JsValue::from_str("Request not found"))?;
|
||||
|
||||
Ok::<String, JsValue>(request.request.message.clone())
|
||||
})?;
|
||||
|
||||
// 5. Send response to server (create a new scope to avoid borrowing issues)
|
||||
{
|
||||
let client_ref = SIGSOCKET_CLIENT.with(|c| {
|
||||
c.borrow().as_ref().map(|client| client as *const SigSocketClient)
|
||||
}).ok_or_else(|| JsValue::from_str("Not connected"))?;
|
||||
|
||||
// SAFETY: We know the client exists and we're using it synchronously
|
||||
let client = unsafe { &*client_ref };
|
||||
|
||||
client.send_response(request_id, &original_message, &signature_base64).await
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to send response: {:?}", e)))?;
|
||||
|
||||
console_log!("✅ WASM: Sent signature response to server for request: {}", request_id);
|
||||
}
|
||||
|
||||
// 6. Remove the request after successful send
|
||||
SIGSOCKET_CLIENT.with(|c| {
|
||||
let mut client = c.borrow_mut();
|
||||
if let Some(client) = client.as_mut() {
|
||||
client.remove_pending_request(request_id);
|
||||
console_log!("✅ WASM: Removed request from pending list: {}", request_id);
|
||||
}
|
||||
});
|
||||
|
||||
console_log!("🎉 WASM: Successfully approved and sent signature for request: {}", request_id);
|
||||
Ok(signature_base64)
|
||||
}
|
||||
|
||||
/// Reject a sign request
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_id` - The ID of the request to reject
|
||||
/// * `reason` - The reason for rejection
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Request rejected successfully
|
||||
/// * `Err(error)` - If rejection failed
|
||||
#[wasm_bindgen]
|
||||
pub async fn reject_request(request_id: &str, reason: &str) -> Result<(), JsValue> {
|
||||
// Send rejection to server first
|
||||
{
|
||||
let client_ref = SIGSOCKET_CLIENT.with(|c| {
|
||||
c.borrow().as_ref().map(|client| client as *const SigSocketClient)
|
||||
}).ok_or_else(|| JsValue::from_str("Not connected"))?;
|
||||
|
||||
// SAFETY: We know the client exists and we're using it synchronously
|
||||
let client = unsafe { &*client_ref };
|
||||
|
||||
client.send_rejection(request_id, reason).await
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to send rejection: {:?}", e)))?;
|
||||
|
||||
console_log!("✅ WASM: Sent rejection to server for request: {}", request_id);
|
||||
}
|
||||
|
||||
// Remove the request after successful send
|
||||
SIGSOCKET_CLIENT.with(|c| {
|
||||
let mut client = c.borrow_mut();
|
||||
if let Some(client) = client.as_mut() {
|
||||
client.remove_pending_request(request_id);
|
||||
console_log!("✅ WASM: Removed rejected request from pending list: {}", request_id);
|
||||
}
|
||||
});
|
||||
|
||||
console_log!("🚫 WASM: Successfully rejected request: {} (reason: {})", request_id, reason);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get pending requests filtered by current workspace
|
||||
///
|
||||
/// This returns only the requests that the current vault session can handle,
|
||||
/// based on the unlocked workspace and its public key.
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(requests_json)` - JSON array of filtered requests
|
||||
/// * `Err(error)` - If filtering failed
|
||||
#[wasm_bindgen]
|
||||
pub async fn get_filtered_requests() -> Result<String, JsValue> {
|
||||
// If vault is locked, return empty array
|
||||
if !is_unlocked() {
|
||||
return Ok("[]".to_string());
|
||||
}
|
||||
|
||||
// Get current workspace public key
|
||||
let current_workspace = get_current_keyspace_name()
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to get current workspace: {:?}", e)))?;
|
||||
|
||||
let current_public_key_js = get_workspace_default_public_key(¤t_workspace).await
|
||||
.map_err(|e| JsValue::from_str(&format!("Failed to get current public key: {:?}", e)))?;
|
||||
|
||||
let current_public_key = current_public_key_js.as_string()
|
||||
.ok_or_else(|| JsValue::from_str("Current public key is not a string"))?;
|
||||
|
||||
// Filter requests for current workspace
|
||||
SIGSOCKET_CLIENT.with(|c| {
|
||||
let client = c.borrow();
|
||||
let client = client.as_ref().ok_or_else(|| JsValue::from_str("Not connected to SigSocket"))?;
|
||||
|
||||
let filtered_requests: Vec<_> = client.get_requests_for_public_key(¤t_public_key);
|
||||
|
||||
console_log!("Filtered requests: {} total, {} for current workspace",
|
||||
client.pending_request_count(), filtered_requests.len());
|
||||
|
||||
// Serialize and return
|
||||
serde_json::to_string(&filtered_requests)
|
||||
.map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a pending sign request (called when request arrives from server)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request_json` - JSON string containing the sign request
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Request added successfully
|
||||
/// * `Err(error)` - If adding failed
|
||||
#[wasm_bindgen]
|
||||
pub fn add_pending_request(request_json: &str) -> Result<(), JsValue> {
|
||||
// Parse the request
|
||||
let request: SignRequest = serde_json::from_str(request_json)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid request JSON: {}", e)))?;
|
||||
|
||||
SIGSOCKET_CLIENT.with(|c| {
|
||||
let mut client = c.borrow_mut();
|
||||
let client = client.as_mut().ok_or_else(|| JsValue::from_str("Not connected to SigSocket"))?;
|
||||
|
||||
// Get the connected public key as the target
|
||||
let target_public_key = client.connected_public_key()
|
||||
.ok_or_else(|| JsValue::from_str("No connected public key"))?
|
||||
.to_string();
|
||||
|
||||
// Add the request
|
||||
client.add_pending_request(request, target_public_key);
|
||||
|
||||
console_log!("Added pending request: {}", request_json);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get connection status
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(status_json)` - JSON object with connection status
|
||||
/// * `Err(error)` - If getting status failed
|
||||
#[wasm_bindgen]
|
||||
pub fn get_connection_status() -> Result<String, JsValue> {
|
||||
SIGSOCKET_CLIENT.with(|c| {
|
||||
let client = c.borrow();
|
||||
|
||||
if let Some(client) = client.as_ref() {
|
||||
let status = serde_json::json!({
|
||||
"is_connected": client.is_connected(),
|
||||
"connected_public_key": client.connected_public_key(),
|
||||
"pending_request_count": client.pending_request_count(),
|
||||
"server_url": client.url()
|
||||
});
|
||||
|
||||
Ok(status.to_string())
|
||||
} else {
|
||||
let status = serde_json::json!({
|
||||
"is_connected": false,
|
||||
"connected_public_key": null,
|
||||
"pending_request_count": 0,
|
||||
"server_url": null
|
||||
});
|
||||
|
||||
Ok(status.to_string())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Clear all pending requests
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(())` - Requests cleared successfully
|
||||
#[wasm_bindgen]
|
||||
pub fn clear_pending_requests() -> Result<(), JsValue> {
|
||||
SIGSOCKET_CLIENT.with(|c| {
|
||||
let mut client = c.borrow_mut();
|
||||
if let Some(client) = client.as_mut() {
|
||||
client.clear_pending_requests();
|
||||
console_log!("Cleared all pending requests");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
413
wasm_app/src/vault_bindings.rs
Normal file
413
wasm_app/src/vault_bindings.rs
Normal file
@ -0,0 +1,413 @@
|
||||
//! WebAssembly bindings for accessing vault operations (session, keypairs, signing, scripting, etc)
|
||||
#![cfg(target_arch = "wasm32")]
|
||||
|
||||
use kvstore::wasm::WasmStore;
|
||||
use once_cell::unsync::Lazy;
|
||||
use rhai::Engine;
|
||||
use std::cell::RefCell;
|
||||
use vault::rhai_bindings as vault_rhai_bindings;
|
||||
use vault::session::SessionManager;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsValue;
|
||||
use js_sys::Uint8Array;
|
||||
|
||||
thread_local! {
|
||||
static ENGINE: Lazy<RefCell<Engine>> = Lazy::new(|| RefCell::new(Engine::new()));
|
||||
static SESSION_PASSWORD: RefCell<Option<Vec<u8>>> = RefCell::new(None);
|
||||
}
|
||||
|
||||
pub use vault::session_singleton::SESSION_MANAGER;
|
||||
|
||||
// =====================
|
||||
// Session Lifecycle
|
||||
// =====================
|
||||
|
||||
/// Create and unlock a new keyspace with the given name and password
|
||||
#[wasm_bindgen]
|
||||
pub async fn create_keyspace(keyspace: &str, password: &str) -> Result<(), JsValue> {
|
||||
let keyspace = keyspace.to_string();
|
||||
let password_vec = password.as_bytes().to_vec();
|
||||
match WasmStore::open("vault").await {
|
||||
Ok(store) => {
|
||||
let vault = vault::Vault::new(store);
|
||||
let mut manager = SessionManager::new(vault);
|
||||
match manager.create_keyspace(&keyspace, &password_vec, None).await {
|
||||
Ok(_) => {
|
||||
SESSION_MANAGER.with(|cell| cell.replace(Some(manager)));
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to create keyspace: {e}").into());
|
||||
return Err(JsValue::from_str(&format!("Failed to create keyspace: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to open WasmStore: {e}").into());
|
||||
return Err(JsValue::from_str(&format!("Failed to open WasmStore: {e}")));
|
||||
}
|
||||
}
|
||||
SESSION_PASSWORD.with(|cell| cell.replace(Some(password.as_bytes().to_vec())));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Initialize session with keyspace and password
|
||||
#[wasm_bindgen]
|
||||
pub async fn init_session(keyspace: &str, password: &str) -> Result<(), JsValue> {
|
||||
let keyspace = keyspace.to_string();
|
||||
let password_vec = password.as_bytes().to_vec();
|
||||
match WasmStore::open("vault").await {
|
||||
Ok(store) => {
|
||||
let vault = vault::Vault::new(store);
|
||||
let mut manager = SessionManager::new(vault);
|
||||
match manager.unlock_keyspace(&keyspace, &password_vec).await {
|
||||
Ok(_) => {
|
||||
SESSION_MANAGER.with(|cell| cell.replace(Some(manager)));
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to unlock keyspace: {e}").into());
|
||||
return Err(JsValue::from_str(&format!("Failed to unlock keyspace: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
web_sys::console::error_1(&format!("Failed to open WasmStore: {e}").into());
|
||||
return Err(JsValue::from_str(&format!("Failed to open WasmStore: {e}")));
|
||||
}
|
||||
}
|
||||
SESSION_PASSWORD.with(|cell| cell.replace(Some(password.as_bytes().to_vec())));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Lock the session (zeroize password and session)
|
||||
#[wasm_bindgen]
|
||||
pub fn lock_session() {
|
||||
SESSION_MANAGER.with(|cell| *cell.borrow_mut() = None);
|
||||
SESSION_PASSWORD.with(|cell| *cell.borrow_mut() = None);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Keypair Management
|
||||
// =====================
|
||||
|
||||
/// Get metadata of the currently selected keypair
|
||||
#[wasm_bindgen]
|
||||
pub fn current_keypair_metadata() -> Result<JsValue, JsValue> {
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
cell.borrow().as_ref()
|
||||
.and_then(|session| session.current_keypair_metadata())
|
||||
.map(|meta| wasm_bindgen::JsValue::from_serde(&meta).unwrap())
|
||||
.ok_or_else(|| JsValue::from_str("No keypair selected or no keyspace unlocked"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Get public key of the currently selected keypair as Uint8Array
|
||||
#[wasm_bindgen]
|
||||
pub fn current_keypair_public_key() -> Result<JsValue, JsValue> {
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
cell.borrow().as_ref()
|
||||
.and_then(|session| session.current_keypair_public_key())
|
||||
.map(|pk| js_sys::Uint8Array::from(pk.as_slice()).into())
|
||||
.ok_or_else(|| JsValue::from_str("No keypair selected or no keyspace unlocked"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns true if a keyspace is currently unlocked
|
||||
#[wasm_bindgen]
|
||||
pub fn is_unlocked() -> bool {
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
cell.borrow().as_ref().map(|session| session.is_unlocked()).unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the default public key for a workspace (keyspace)
|
||||
/// This returns the public key of the first keypair in the keyspace
|
||||
#[wasm_bindgen]
|
||||
pub async fn get_workspace_default_public_key(workspace_id: &str) -> Result<JsValue, JsValue> {
|
||||
// For now, workspace_id is the same as keyspace name
|
||||
// In a full implementation, you might have a mapping from workspace to keyspace
|
||||
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
if let Some(session) = cell.borrow().as_ref() {
|
||||
if let Some(keyspace_name) = session.current_keyspace_name() {
|
||||
if keyspace_name == workspace_id {
|
||||
// Use the default_keypair method to get the first keypair
|
||||
if let Some(default_keypair) = session.default_keypair() {
|
||||
// Return the actual public key as hex
|
||||
let public_key_hex = hex::encode(&default_keypair.public_key);
|
||||
return Ok(JsValue::from_str(&public_key_hex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(JsValue::from_str("Workspace not found or no keypairs available"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the current unlocked public key as hex string
|
||||
#[wasm_bindgen]
|
||||
pub fn get_current_unlocked_public_key() -> Result<String, JsValue> {
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
cell.borrow().as_ref()
|
||||
.and_then(|session| session.current_keypair_public_key())
|
||||
.map(|pk| hex::encode(pk.as_slice()))
|
||||
.ok_or_else(|| JsValue::from_str("No keypair selected or no keyspace unlocked"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Get all keypairs from the current session
|
||||
/// Returns an array of keypair objects with id, type, and metadata
|
||||
// #[wasm_bindgen]
|
||||
// pub async fn list_keypairs() -> Result<JsValue, JsValue> {
|
||||
// // [Function body commented out to resolve duplicate symbol error]
|
||||
// // (Original implementation moved to keypair_bindings.rs)
|
||||
// unreachable!("This function is disabled. Use the export from keypair_bindings.rs.");
|
||||
// }
|
||||
|
||||
// [Function body commented out to resolve duplicate symbol error]
|
||||
// }
|
||||
|
||||
|
||||
/// Select keypair for the session
|
||||
#[wasm_bindgen]
|
||||
pub fn select_keypair(key_id: &str) -> Result<(), JsValue> {
|
||||
let mut result = Err(JsValue::from_str("Session not initialized"));
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
if let Some(session) = cell.borrow_mut().as_mut() {
|
||||
result = session
|
||||
.select_keypair(key_id)
|
||||
.map_err(|e| JsValue::from_str(&format!("select_keypair error: {e}")));
|
||||
}
|
||||
});
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
/// List keypairs in the current session's keyspace
|
||||
#[wasm_bindgen]
|
||||
pub async fn list_keypairs() -> Result<JsValue, JsValue> {
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
if let Some(session) = cell.borrow().as_ref() {
|
||||
if let Some(keyspace) = session.current_keyspace() {
|
||||
let keypairs = &keyspace.keypairs;
|
||||
serde_json::to_string(keypairs)
|
||||
.map(|s| JsValue::from_str(&s))
|
||||
.map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
|
||||
} else {
|
||||
Err(JsValue::from_str("No keyspace unlocked"))
|
||||
}
|
||||
} else {
|
||||
Err(JsValue::from_str("Session not initialized"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a keypair to the current keyspace
|
||||
#[wasm_bindgen]
|
||||
pub async fn add_keypair(
|
||||
key_type: Option<String>,
|
||||
metadata: Option<String>,
|
||||
) -> Result<JsValue, JsValue> {
|
||||
use vault::{KeyMetadata, KeyType};
|
||||
let password = SESSION_PASSWORD
|
||||
.with(|pw| pw.borrow().clone())
|
||||
.ok_or_else(|| JsValue::from_str("Session password not set"))?;
|
||||
let keyspace_name = SESSION_MANAGER.with(|cell| {
|
||||
cell.borrow().as_ref().and_then(|session| {
|
||||
session.current_keyspace_name().map(|name| name.to_string())
|
||||
})
|
||||
});
|
||||
let keyspace_name = keyspace_name.ok_or_else(|| JsValue::from_str("No keyspace selected"))?;
|
||||
let key_type = key_type
|
||||
.as_deref()
|
||||
.map(|s| match s {
|
||||
"Ed25519" => KeyType::Ed25519,
|
||||
"Secp256k1" => KeyType::Secp256k1,
|
||||
_ => KeyType::Secp256k1,
|
||||
})
|
||||
.unwrap_or(KeyType::Secp256k1);
|
||||
let metadata = match metadata {
|
||||
Some(ref meta_str) => Some(
|
||||
serde_json::from_str::<KeyMetadata>(meta_str)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid metadata: {e}")))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// Take session out, do async work, then put it back
|
||||
let mut session_opt = SESSION_MANAGER.with(|cell| cell.borrow_mut().take());
|
||||
let session = session_opt.as_mut().ok_or_else(|| JsValue::from_str("Session not initialized"))?;
|
||||
let key_id = session
|
||||
.get_vault_mut()
|
||||
.add_keypair(&keyspace_name, &password, Some(key_type), metadata)
|
||||
.await
|
||||
.map_err(|e| JsValue::from_str(&format!("add_keypair error: {e}")))?;
|
||||
// Refresh in-memory keyspace data so list_keypairs reflects the new keypair immediately
|
||||
session.unlock_keyspace(&keyspace_name, &password).await
|
||||
.map_err(|e| JsValue::from_str(&format!("refresh keyspace after add_keypair error: {e}")))?;
|
||||
// Put session back
|
||||
SESSION_MANAGER.with(|cell| *cell.borrow_mut() = Some(session_opt.take().unwrap()));
|
||||
Ok(JsValue::from_str(&key_id))
|
||||
}
|
||||
|
||||
/// Sign message with current session (requires selected keypair)
|
||||
#[wasm_bindgen]
|
||||
pub async fn sign(message: &[u8]) -> Result<JsValue, JsValue> {
|
||||
{
|
||||
// SAFETY: We only use this pointer synchronously within this function, and SESSION_MANAGER outlives this scope.
|
||||
let session_ptr =
|
||||
SESSION_MANAGER.with(|cell| cell.borrow().as_ref().map(|s| s as *const _));
|
||||
let session: &vault::session::SessionManager<kvstore::wasm::WasmStore> = match session_ptr {
|
||||
Some(ptr) => unsafe { &*ptr },
|
||||
None => return Err(JsValue::from_str("Session not initialized")),
|
||||
};
|
||||
match session.sign(message).await {
|
||||
Ok(sig_bytes) => {
|
||||
let hex_sig = hex::encode(&sig_bytes);
|
||||
Ok(JsValue::from_str(&hex_sig))
|
||||
}
|
||||
Err(e) => Err(JsValue::from_str(&format!("Sign error: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current keyspace name
|
||||
#[wasm_bindgen]
|
||||
pub fn get_current_keyspace_name() -> Result<String, JsValue> {
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
if let Some(session) = cell.borrow().as_ref() {
|
||||
if let Some(keyspace_name) = session.current_keyspace_name() {
|
||||
Ok(keyspace_name.to_string())
|
||||
} else {
|
||||
Err(JsValue::from_str("No keyspace unlocked"))
|
||||
}
|
||||
} else {
|
||||
Err(JsValue::from_str("Session not initialized"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Sign message with default keypair (first keypair in keyspace) without changing session state
|
||||
#[wasm_bindgen]
|
||||
pub async fn sign_with_default_keypair(message: &[u8]) -> Result<JsValue, JsValue> {
|
||||
// Temporarily select the default keypair, sign, then restore the original selection
|
||||
let original_keypair = SESSION_MANAGER.with(|cell| {
|
||||
cell.borrow().as_ref()
|
||||
.and_then(|session| session.current_keypair())
|
||||
.map(|kp| kp.id.clone())
|
||||
});
|
||||
|
||||
// Select default keypair
|
||||
let select_result = SESSION_MANAGER.with(|cell| {
|
||||
let mut session_opt = cell.borrow_mut().take();
|
||||
if let Some(ref mut session) = session_opt {
|
||||
let result = session.select_default_keypair();
|
||||
*cell.borrow_mut() = Some(session_opt.take().unwrap());
|
||||
result.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err("Session not initialized".to_string())
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(e) = select_result {
|
||||
return Err(JsValue::from_str(&format!("Failed to select default keypair: {e}")));
|
||||
}
|
||||
|
||||
// Sign with the default keypair
|
||||
let sign_result = {
|
||||
let session_ptr = SESSION_MANAGER.with(|cell| cell.borrow().as_ref().map(|s| s as *const _));
|
||||
let session: &vault::session::SessionManager<kvstore::wasm::WasmStore> = match session_ptr {
|
||||
Some(ptr) => unsafe { &*ptr },
|
||||
None => return Err(JsValue::from_str("Session not initialized")),
|
||||
};
|
||||
session.sign(message).await
|
||||
};
|
||||
|
||||
// Restore original keypair selection if there was one
|
||||
if let Some(original_id) = original_keypair {
|
||||
SESSION_MANAGER.with(|cell| {
|
||||
let mut session_opt = cell.borrow_mut().take();
|
||||
if let Some(ref mut session) = session_opt {
|
||||
let _ = session.select_keypair(&original_id); // Ignore errors here
|
||||
*cell.borrow_mut() = Some(session_opt.take().unwrap());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Return the signature result
|
||||
match sign_result {
|
||||
Ok(sig_bytes) => {
|
||||
let hex_sig = hex::encode(&sig_bytes);
|
||||
Ok(JsValue::from_str(&hex_sig))
|
||||
}
|
||||
Err(e) => Err(JsValue::from_str(&format!("Sign error: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a signature with the current session's selected keypair
|
||||
#[wasm_bindgen]
|
||||
pub async fn verify(message: &[u8], signature: &str) -> Result<JsValue, JsValue> {
|
||||
{
|
||||
// SAFETY: We only use this pointer synchronously within this function, and SESSION_MANAGER outlives this scope.
|
||||
let session_ptr =
|
||||
SESSION_MANAGER.with(|cell| cell.borrow().as_ref().map(|s| s as *const _));
|
||||
let session: &vault::session::SessionManager<kvstore::wasm::WasmStore> = match session_ptr {
|
||||
Some(ptr) => unsafe { &*ptr },
|
||||
None => return Err(JsValue::from_str("Session not initialized")),
|
||||
};
|
||||
|
||||
// Convert hex signature to bytes
|
||||
let sig_bytes = match hex::decode(signature) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => return Err(JsValue::from_str(&format!("Invalid signature format: {e}"))),
|
||||
};
|
||||
|
||||
match session.verify(message, &sig_bytes).await {
|
||||
Ok(is_valid) => Ok(JsValue::from_bool(is_valid)),
|
||||
Err(e) => Err(JsValue::from_str(&format!("Verify error: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt data using the current session's keyspace symmetric cipher
|
||||
#[wasm_bindgen]
|
||||
pub async fn encrypt_data(data: &[u8]) -> Result<JsValue, JsValue> {
|
||||
{
|
||||
// SAFETY: We only use this pointer synchronously within this function, and SESSION_MANAGER outlives this scope.
|
||||
let session_ptr =
|
||||
SESSION_MANAGER.with(|cell| cell.borrow().as_ref().map(|s| s as *const _));
|
||||
let session: &vault::session::SessionManager<kvstore::wasm::WasmStore> = match session_ptr {
|
||||
Some(ptr) => unsafe { &*ptr },
|
||||
None => return Err(JsValue::from_str("Session not initialized")),
|
||||
};
|
||||
|
||||
match session.encrypt(data).await {
|
||||
Ok(encrypted) => {
|
||||
// Return as Uint8Array for JavaScript
|
||||
Ok(Uint8Array::from(&encrypted[..]).into())
|
||||
}
|
||||
Err(e) => Err(JsValue::from_str(&format!("Encryption error: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt data using the current session's keyspace symmetric cipher
|
||||
#[wasm_bindgen]
|
||||
pub async fn decrypt_data(encrypted: &[u8]) -> Result<JsValue, JsValue> {
|
||||
{
|
||||
// SAFETY: We only use this pointer synchronously within this function, and SESSION_MANAGER outlives this scope.
|
||||
let session_ptr =
|
||||
SESSION_MANAGER.with(|cell| cell.borrow().as_ref().map(|s| s as *const _));
|
||||
let session: &vault::session::SessionManager<kvstore::wasm::WasmStore> = match session_ptr {
|
||||
Some(ptr) => unsafe { &*ptr },
|
||||
None => return Err(JsValue::from_str("Session not initialized")),
|
||||
};
|
||||
|
||||
match session.decrypt(encrypted).await {
|
||||
Ok(decrypted) => {
|
||||
// Return as Uint8Array for JavaScript
|
||||
Ok(Uint8Array::from(&decrypted[..]).into())
|
||||
}
|
||||
Err(e) => Err(JsValue::from_str(&format!("Decryption error: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
17
wasm_console_demo/index.html
Normal file
17
wasm_console_demo/index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>WASM App Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>WASM App Demo</h1>
|
||||
<script type="module">
|
||||
import init, * as wasm from './wasm_app.js';
|
||||
window.wasm = wasm;
|
||||
init().then(() => {
|
||||
console.log("WASM module loaded! Try window.wasm in the console.");
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
13
wasm_console_demo/main.js
Normal file
13
wasm_console_demo/main.js
Normal file
@ -0,0 +1,13 @@
|
||||
// Minimal loader for the vault WASM module for console interaction
|
||||
// Adjust the module path if needed (this assumes the default wasm-pack output in the parent dir)
|
||||
import init, * as vault from './wasm_app.js';
|
||||
|
||||
window.vault = null;
|
||||
|
||||
init().then(() => {
|
||||
window.vault = vault;
|
||||
console.log('Vault WASM module loaded. Use window.vault.<function>() in the console.');
|
||||
});
|
||||
|
||||
// Optional: Helper to convert Uint8Array to hex
|
||||
window.toHex = arr => Array.from(new Uint8Array(arr)).map(b => b.toString(16).padStart(2, '0')).join('');
|
15
wasm_console_demo/package.json
Normal file
15
wasm_console_demo/package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "wasm_app",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"wasm_app_bg.wasm",
|
||||
"wasm_app.js",
|
||||
"wasm_app.d.ts"
|
||||
],
|
||||
"main": "wasm_app.js",
|
||||
"types": "wasm_app.d.ts",
|
||||
"sideEffects": [
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
103
wasm_console_demo/wasm_app.d.ts
vendored
Normal file
103
wasm_console_demo/wasm_app.d.ts
vendored
Normal file
@ -0,0 +1,103 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Initialize the scripting environment (must be called before run_rhai)
|
||||
*/
|
||||
export function init_rhai_env(): void;
|
||||
/**
|
||||
* Securely run a Rhai script in the extension context (must be called only after user approval)
|
||||
*/
|
||||
export function run_rhai(script: string): any;
|
||||
/**
|
||||
* Create and unlock a new keyspace with the given name and password
|
||||
*/
|
||||
export function create_keyspace(keyspace: string, password: string): Promise<void>;
|
||||
/**
|
||||
* Initialize session with keyspace and password
|
||||
*/
|
||||
export function init_session(keyspace: string, password: string): Promise<void>;
|
||||
/**
|
||||
* Lock the session (zeroize password and session)
|
||||
*/
|
||||
export function lock_session(): void;
|
||||
/**
|
||||
* Get metadata of the currently selected keypair
|
||||
*/
|
||||
export function current_keypair_metadata(): any;
|
||||
/**
|
||||
* Get public key of the currently selected keypair as Uint8Array
|
||||
*/
|
||||
export function current_keypair_public_key(): any;
|
||||
/**
|
||||
* Returns true if a keyspace is currently unlocked
|
||||
*/
|
||||
export function is_unlocked(): boolean;
|
||||
/**
|
||||
* Get all keypairs from the current session
|
||||
* Returns an array of keypair objects with id, type, and metadata
|
||||
* Select keypair for the session
|
||||
*/
|
||||
export function select_keypair(key_id: string): void;
|
||||
/**
|
||||
* List keypairs in the current session's keyspace
|
||||
*/
|
||||
export function list_keypairs(): Promise<any>;
|
||||
/**
|
||||
* Add a keypair to the current keyspace
|
||||
*/
|
||||
export function add_keypair(key_type?: string | null, metadata?: string | null): Promise<any>;
|
||||
/**
|
||||
* Sign message with current session
|
||||
*/
|
||||
export function sign(message: Uint8Array): Promise<any>;
|
||||
|
||||
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
export interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly init_rhai_env: () => void;
|
||||
readonly run_rhai: (a: number, b: number) => [number, number, number];
|
||||
readonly create_keyspace: (a: number, b: number, c: number, d: number) => any;
|
||||
readonly init_session: (a: number, b: number, c: number, d: number) => any;
|
||||
readonly lock_session: () => void;
|
||||
readonly current_keypair_metadata: () => [number, number, number];
|
||||
readonly current_keypair_public_key: () => [number, number, number];
|
||||
readonly is_unlocked: () => number;
|
||||
readonly select_keypair: (a: number, b: number) => [number, number];
|
||||
readonly list_keypairs: () => any;
|
||||
readonly add_keypair: (a: number, b: number, c: number, d: number) => any;
|
||||
readonly sign: (a: number, b: number) => any;
|
||||
readonly __wbindgen_exn_store: (a: number) => void;
|
||||
readonly __externref_table_alloc: () => number;
|
||||
readonly __wbindgen_export_2: WebAssembly.Table;
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
readonly __wbindgen_export_5: WebAssembly.Table;
|
||||
readonly __externref_table_dealloc: (a: number) => void;
|
||||
readonly closure89_externref_shim: (a: number, b: number, c: any) => void;
|
||||
readonly closure133_externref_shim: (a: number, b: number, c: any) => void;
|
||||
readonly closure188_externref_shim: (a: number, b: number, c: any) => void;
|
||||
readonly closure1847_externref_shim: (a: number, b: number, c: any, d: any) => void;
|
||||
readonly __wbindgen_start: () => void;
|
||||
}
|
||||
|
||||
export type SyncInitInput = BufferSource | WebAssembly.Module;
|
||||
/**
|
||||
* Instantiates the given `module`, which can either be bytes or
|
||||
* a precompiled `WebAssembly.Module`.
|
||||
*
|
||||
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {InitOutput}
|
||||
*/
|
||||
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
|
||||
|
||||
/**
|
||||
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||
*
|
||||
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {Promise<InitOutput>}
|
||||
*/
|
||||
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
822
wasm_console_demo/wasm_app.js
Normal file
822
wasm_console_demo/wasm_app.js
Normal file
@ -0,0 +1,822 @@
|
||||
let wasm;
|
||||
|
||||
function addToExternrefTable0(obj) {
|
||||
const idx = wasm.__externref_table_alloc();
|
||||
wasm.__wbindgen_export_2.set(idx, obj);
|
||||
return idx;
|
||||
}
|
||||
|
||||
function handleError(f, args) {
|
||||
try {
|
||||
return f.apply(this, args);
|
||||
} catch (e) {
|
||||
const idx = addToExternrefTable0(e);
|
||||
wasm.__wbindgen_exn_store(idx);
|
||||
}
|
||||
}
|
||||
|
||||
const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
|
||||
|
||||
if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); };
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
}
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
function isLikeNone(x) {
|
||||
return x === undefined || x === null;
|
||||
}
|
||||
|
||||
function getArrayU8FromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
const cachedTextEncoder = (typeof TextEncoder !== 'undefined' ? new TextEncoder('utf-8') : { encode: () => { throw Error('TextEncoder not available') } } );
|
||||
|
||||
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
|
||||
? function (arg, view) {
|
||||
return cachedTextEncoder.encodeInto(arg, view);
|
||||
}
|
||||
: function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
});
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
||||
WASM_VECTOR_LEN = buf.length;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let len = arg.length;
|
||||
let ptr = malloc(len, 1) >>> 0;
|
||||
|
||||
const mem = getUint8ArrayMemory0();
|
||||
|
||||
let offset = 0;
|
||||
|
||||
for (; offset < len; offset++) {
|
||||
const code = arg.charCodeAt(offset);
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = encodeString(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
}
|
||||
|
||||
WASM_VECTOR_LEN = offset;
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined')
|
||||
? { register: () => {}, unregister: () => {} }
|
||||
: new FinalizationRegistry(state => {
|
||||
wasm.__wbindgen_export_5.get(state.dtor)(state.a, state.b)
|
||||
});
|
||||
|
||||
function makeMutClosure(arg0, arg1, dtor, f) {
|
||||
const state = { a: arg0, b: arg1, cnt: 1, dtor };
|
||||
const real = (...args) => {
|
||||
// First up with a closure we increment the internal reference
|
||||
// count. This ensures that the Rust closure environment won't
|
||||
// be deallocated while we're invoking it.
|
||||
state.cnt++;
|
||||
const a = state.a;
|
||||
state.a = 0;
|
||||
try {
|
||||
return f(a, state.b, ...args);
|
||||
} finally {
|
||||
if (--state.cnt === 0) {
|
||||
wasm.__wbindgen_export_5.get(state.dtor)(a, state.b);
|
||||
CLOSURE_DTORS.unregister(state);
|
||||
} else {
|
||||
state.a = a;
|
||||
}
|
||||
}
|
||||
};
|
||||
real.original = state;
|
||||
CLOSURE_DTORS.register(real, state, state);
|
||||
return real;
|
||||
}
|
||||
|
||||
function debugString(val) {
|
||||
// primitive types
|
||||
const type = typeof val;
|
||||
if (type == 'number' || type == 'boolean' || val == null) {
|
||||
return `${val}`;
|
||||
}
|
||||
if (type == 'string') {
|
||||
return `"${val}"`;
|
||||
}
|
||||
if (type == 'symbol') {
|
||||
const description = val.description;
|
||||
if (description == null) {
|
||||
return 'Symbol';
|
||||
} else {
|
||||
return `Symbol(${description})`;
|
||||
}
|
||||
}
|
||||
if (type == 'function') {
|
||||
const name = val.name;
|
||||
if (typeof name == 'string' && name.length > 0) {
|
||||
return `Function(${name})`;
|
||||
} else {
|
||||
return 'Function';
|
||||
}
|
||||
}
|
||||
// objects
|
||||
if (Array.isArray(val)) {
|
||||
const length = val.length;
|
||||
let debug = '[';
|
||||
if (length > 0) {
|
||||
debug += debugString(val[0]);
|
||||
}
|
||||
for(let i = 1; i < length; i++) {
|
||||
debug += ', ' + debugString(val[i]);
|
||||
}
|
||||
debug += ']';
|
||||
return debug;
|
||||
}
|
||||
// Test for built-in
|
||||
const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
|
||||
let className;
|
||||
if (builtInMatches && builtInMatches.length > 1) {
|
||||
className = builtInMatches[1];
|
||||
} else {
|
||||
// Failed to match the standard '[object ClassName]'
|
||||
return toString.call(val);
|
||||
}
|
||||
if (className == 'Object') {
|
||||
// we're a user defined class or Object
|
||||
// JSON.stringify avoids problems with cycles, and is generally much
|
||||
// easier than looping through ownProperties of `val`.
|
||||
try {
|
||||
return 'Object(' + JSON.stringify(val) + ')';
|
||||
} catch (_) {
|
||||
return 'Object';
|
||||
}
|
||||
}
|
||||
// errors
|
||||
if (val instanceof Error) {
|
||||
return `${val.name}: ${val.message}\n${val.stack}`;
|
||||
}
|
||||
// TODO we could test for more things here, like `Set`s and `Map`s.
|
||||
return className;
|
||||
}
|
||||
/**
|
||||
* Initialize the scripting environment (must be called before run_rhai)
|
||||
*/
|
||||
export function init_rhai_env() {
|
||||
wasm.init_rhai_env();
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_export_2.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* Securely run a Rhai script in the extension context (must be called only after user approval)
|
||||
* @param {string} script
|
||||
* @returns {any}
|
||||
*/
|
||||
export function run_rhai(script) {
|
||||
const ptr0 = passStringToWasm0(script, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.run_rhai(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and unlock a new keyspace with the given name and password
|
||||
* @param {string} keyspace
|
||||
* @param {string} password
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function create_keyspace(keyspace, password) {
|
||||
const ptr0 = passStringToWasm0(keyspace, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(password, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.create_keyspace(ptr0, len0, ptr1, len1);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize session with keyspace and password
|
||||
* @param {string} keyspace
|
||||
* @param {string} password
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function init_session(keyspace, password) {
|
||||
const ptr0 = passStringToWasm0(keyspace, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ptr1 = passStringToWasm0(password, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.init_session(ptr0, len0, ptr1, len1);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the session (zeroize password and session)
|
||||
*/
|
||||
export function lock_session() {
|
||||
wasm.lock_session();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata of the currently selected keypair
|
||||
* @returns {any}
|
||||
*/
|
||||
export function current_keypair_metadata() {
|
||||
const ret = wasm.current_keypair_metadata();
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public key of the currently selected keypair as Uint8Array
|
||||
* @returns {any}
|
||||
*/
|
||||
export function current_keypair_public_key() {
|
||||
const ret = wasm.current_keypair_public_key();
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a keyspace is currently unlocked
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function is_unlocked() {
|
||||
const ret = wasm.is_unlocked();
|
||||
return ret !== 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keypairs from the current session
|
||||
* Returns an array of keypair objects with id, type, and metadata
|
||||
* Select keypair for the session
|
||||
* @param {string} key_id
|
||||
*/
|
||||
export function select_keypair(key_id) {
|
||||
const ptr0 = passStringToWasm0(key_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.select_keypair(ptr0, len0);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List keypairs in the current session's keyspace
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export function list_keypairs() {
|
||||
const ret = wasm.list_keypairs();
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a keypair to the current keyspace
|
||||
* @param {string | null} [key_type]
|
||||
* @param {string | null} [metadata]
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export function add_keypair(key_type, metadata) {
|
||||
var ptr0 = isLikeNone(key_type) ? 0 : passStringToWasm0(key_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
var len0 = WASM_VECTOR_LEN;
|
||||
var ptr1 = isLikeNone(metadata) ? 0 : passStringToWasm0(metadata, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
var len1 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.add_keypair(ptr0, len0, ptr1, len1);
|
||||
return ret;
|
||||
}
|
||||
|
||||
function passArray8ToWasm0(arg, malloc) {
|
||||
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
||||
getUint8ArrayMemory0().set(arg, ptr / 1);
|
||||
WASM_VECTOR_LEN = arg.length;
|
||||
return ptr;
|
||||
}
|
||||
/**
|
||||
* Sign message with current session
|
||||
* @param {Uint8Array} message
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export function sign(message) {
|
||||
const ptr0 = passArray8ToWasm0(message, wasm.__wbindgen_malloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.sign(ptr0, len0);
|
||||
return ret;
|
||||
}
|
||||
|
||||
function __wbg_adapter_32(arg0, arg1, arg2) {
|
||||
wasm.closure89_externref_shim(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function __wbg_adapter_35(arg0, arg1, arg2) {
|
||||
wasm.closure133_externref_shim(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function __wbg_adapter_38(arg0, arg1, arg2) {
|
||||
wasm.closure188_externref_shim(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function __wbg_adapter_135(arg0, arg1, arg2, arg3) {
|
||||
wasm.closure1847_externref_shim(arg0, arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
const __wbindgen_enum_IdbTransactionMode = ["readonly", "readwrite", "versionchange", "readwriteflush", "cleanup"];
|
||||
|
||||
async function __wbg_load(module, imports) {
|
||||
if (typeof Response === 'function' && module instanceof Response) {
|
||||
if (typeof WebAssembly.instantiateStreaming === 'function') {
|
||||
try {
|
||||
return await WebAssembly.instantiateStreaming(module, imports);
|
||||
|
||||
} catch (e) {
|
||||
if (module.headers.get('Content-Type') != 'application/wasm') {
|
||||
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
|
||||
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await module.arrayBuffer();
|
||||
return await WebAssembly.instantiate(bytes, imports);
|
||||
|
||||
} else {
|
||||
const instance = await WebAssembly.instantiate(module, imports);
|
||||
|
||||
if (instance instanceof WebAssembly.Instance) {
|
||||
return { instance, module };
|
||||
|
||||
} else {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function __wbg_get_imports() {
|
||||
const imports = {};
|
||||
imports.wbg = {};
|
||||
imports.wbg.__wbg_buffer_609cc3eee51ed158 = function(arg0) {
|
||||
const ret = arg0.buffer;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_call_672a4d21634d4a24 = function() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.call(arg1);
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_call_7cccdd69e0791ae2 = function() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.call(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_createObjectStore_d2f9e1016f4d81b9 = function() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
const ret = arg0.createObjectStore(getStringFromWasm0(arg1, arg2), arg3);
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_crypto_574e78ad8b13b65f = function(arg0) {
|
||||
const ret = arg0.crypto;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_error_524f506f44df1645 = function(arg0) {
|
||||
console.error(arg0);
|
||||
};
|
||||
imports.wbg.__wbg_error_ff4ddaabdfc5dbb3 = function() { return handleError(function (arg0) {
|
||||
const ret = arg0.error;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_getRandomValues_3c9c0d586e575a16 = function() { return handleError(function (arg0, arg1) {
|
||||
globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1));
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_getRandomValues_b8f5dbd5f3995a9e = function() { return handleError(function (arg0, arg1) {
|
||||
arg0.getRandomValues(arg1);
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_getTime_46267b1c24877e30 = function(arg0) {
|
||||
const ret = arg0.getTime();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_get_4f73335ab78445db = function(arg0, arg1, arg2) {
|
||||
const ret = arg1[arg2 >>> 0];
|
||||
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
var len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbg_get_67b2ba62fc30de12 = function() { return handleError(function (arg0, arg1) {
|
||||
const ret = Reflect.get(arg0, arg1);
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_get_8da03f81f6a1111e = function() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.get(arg1);
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_instanceof_IdbDatabase_a3ef009ca00059f9 = function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof IDBDatabase;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_instanceof_IdbFactory_12eaba3366f4302f = function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof IDBFactory;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_instanceof_IdbOpenDbRequest_a3416e156c9db893 = function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof IDBOpenDBRequest;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_instanceof_IdbRequest_4813c3f207666aa4 = function(arg0) {
|
||||
let result;
|
||||
try {
|
||||
result = arg0 instanceof IDBRequest;
|
||||
} catch (_) {
|
||||
result = false;
|
||||
}
|
||||
const ret = result;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_length_52b6c4580c5ec934 = function(arg0) {
|
||||
const ret = arg0.length;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_msCrypto_a61aeb35a24c1329 = function(arg0) {
|
||||
const ret = arg0.msCrypto;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new0_f788a2397c7ca929 = function() {
|
||||
const ret = new Date();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new_23a2665fac83c611 = function(arg0, arg1) {
|
||||
try {
|
||||
var state0 = {a: arg0, b: arg1};
|
||||
var cb0 = (arg0, arg1) => {
|
||||
const a = state0.a;
|
||||
state0.a = 0;
|
||||
try {
|
||||
return __wbg_adapter_135(a, state0.b, arg0, arg1);
|
||||
} finally {
|
||||
state0.a = a;
|
||||
}
|
||||
};
|
||||
const ret = new Promise(cb0);
|
||||
return ret;
|
||||
} finally {
|
||||
state0.a = state0.b = 0;
|
||||
}
|
||||
};
|
||||
imports.wbg.__wbg_new_405e22f390576ce2 = function() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new_78feb108b6472713 = function() {
|
||||
const ret = new Array();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_new_a12002a7f91c75be = function(arg0) {
|
||||
const ret = new Uint8Array(arg0);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_newnoargs_105ed471475aaf50 = function(arg0, arg1) {
|
||||
const ret = new Function(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_newwithbyteoffsetandlength_d97e637ebe145a9a = function(arg0, arg1, arg2) {
|
||||
const ret = new Uint8Array(arg0, arg1 >>> 0, arg2 >>> 0);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_newwithlength_a381634e90c276d4 = function(arg0) {
|
||||
const ret = new Uint8Array(arg0 >>> 0);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_node_905d3e251edff8a2 = function(arg0) {
|
||||
const ret = arg0.node;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_now_d18023d54d4e5500 = function(arg0) {
|
||||
const ret = arg0.now();
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_objectStoreNames_9bb1ab04a7012aaf = function(arg0) {
|
||||
const ret = arg0.objectStoreNames;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_objectStore_21878d46d25b64b6 = function() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.objectStore(getStringFromWasm0(arg1, arg2));
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_open_88b1390d99a7c691 = function() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.open(getStringFromWasm0(arg1, arg2));
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_open_e0c0b2993eb596e1 = function() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
const ret = arg0.open(getStringFromWasm0(arg1, arg2), arg3 >>> 0);
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_process_dc0fbacc7c1c06f7 = function(arg0) {
|
||||
const ret = arg0.process;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_push_737cfc8c1432c2c6 = function(arg0, arg1) {
|
||||
const ret = arg0.push(arg1);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_put_066faa31a6a88f5b = function() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.put(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_put_9ef5363941008835 = function() { return handleError(function (arg0, arg1) {
|
||||
const ret = arg0.put(arg1);
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_queueMicrotask_97d92b4fcc8a61c5 = function(arg0) {
|
||||
queueMicrotask(arg0);
|
||||
};
|
||||
imports.wbg.__wbg_queueMicrotask_d3219def82552485 = function(arg0) {
|
||||
const ret = arg0.queueMicrotask;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_randomFillSync_ac0988aba3254290 = function() { return handleError(function (arg0, arg1) {
|
||||
arg0.randomFillSync(arg1);
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_require_60cc747a6bc5215a = function() { return handleError(function () {
|
||||
const ret = module.require;
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_resolve_4851785c9c5f573d = function(arg0) {
|
||||
const ret = Promise.resolve(arg0);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_result_f29afabdf2c05826 = function() { return handleError(function (arg0) {
|
||||
const ret = arg0.result;
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_set_65595bdd868b3009 = function(arg0, arg1, arg2) {
|
||||
arg0.set(arg1, arg2 >>> 0);
|
||||
};
|
||||
imports.wbg.__wbg_setonerror_d7e3056cc6e56085 = function(arg0, arg1) {
|
||||
arg0.onerror = arg1;
|
||||
};
|
||||
imports.wbg.__wbg_setonsuccess_afa464ee777a396d = function(arg0, arg1) {
|
||||
arg0.onsuccess = arg1;
|
||||
};
|
||||
imports.wbg.__wbg_setonupgradeneeded_fcf7ce4f2eb0cb5f = function(arg0, arg1) {
|
||||
arg0.onupgradeneeded = arg1;
|
||||
};
|
||||
imports.wbg.__wbg_static_accessor_GLOBAL_88a902d13a557d07 = function() {
|
||||
const ret = typeof global === 'undefined' ? null : global;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
};
|
||||
imports.wbg.__wbg_static_accessor_GLOBAL_THIS_56578be7e9f832b0 = function() {
|
||||
const ret = typeof globalThis === 'undefined' ? null : globalThis;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
};
|
||||
imports.wbg.__wbg_static_accessor_SELF_37c5d418e4bf5819 = function() {
|
||||
const ret = typeof self === 'undefined' ? null : self;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
};
|
||||
imports.wbg.__wbg_static_accessor_WINDOW_5de37043a91a9c40 = function() {
|
||||
const ret = typeof window === 'undefined' ? null : window;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
};
|
||||
imports.wbg.__wbg_subarray_aa9065fa9dc5df96 = function(arg0, arg1, arg2) {
|
||||
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_target_0a62d9d79a2a1ede = function(arg0) {
|
||||
const ret = arg0.target;
|
||||
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
|
||||
};
|
||||
imports.wbg.__wbg_then_44b73946d2fb3e7d = function(arg0, arg1) {
|
||||
const ret = arg0.then(arg1);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbg_transaction_d6d07c3c9963c49e = function() { return handleError(function (arg0, arg1, arg2) {
|
||||
const ret = arg0.transaction(arg1, __wbindgen_enum_IdbTransactionMode[arg2]);
|
||||
return ret;
|
||||
}, arguments) };
|
||||
imports.wbg.__wbg_versions_c01dfd4722a88165 = function(arg0) {
|
||||
const ret = arg0.versions;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_cb_drop = function(arg0) {
|
||||
const obj = arg0.original;
|
||||
if (obj.cnt-- == 1) {
|
||||
obj.a = 0;
|
||||
return true;
|
||||
}
|
||||
const ret = false;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_closure_wrapper288 = function(arg0, arg1, arg2) {
|
||||
const ret = makeMutClosure(arg0, arg1, 90, __wbg_adapter_32);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_closure_wrapper518 = function(arg0, arg1, arg2) {
|
||||
const ret = makeMutClosure(arg0, arg1, 134, __wbg_adapter_35);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_closure_wrapper776 = function(arg0, arg1, arg2) {
|
||||
const ret = makeMutClosure(arg0, arg1, 189, __wbg_adapter_38);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_debug_string = function(arg0, arg1) {
|
||||
const ret = debugString(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbindgen_init_externref_table = function() {
|
||||
const table = wasm.__wbindgen_export_2;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
;
|
||||
};
|
||||
imports.wbg.__wbindgen_is_function = function(arg0) {
|
||||
const ret = typeof(arg0) === 'function';
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_is_null = function(arg0) {
|
||||
const ret = arg0 === null;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_is_object = function(arg0) {
|
||||
const val = arg0;
|
||||
const ret = typeof(val) === 'object' && val !== null;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_is_string = function(arg0) {
|
||||
const ret = typeof(arg0) === 'string';
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_is_undefined = function(arg0) {
|
||||
const ret = arg0 === undefined;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_json_parse = function(arg0, arg1) {
|
||||
const ret = JSON.parse(getStringFromWasm0(arg0, arg1));
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_json_serialize = function(arg0, arg1) {
|
||||
const obj = arg1;
|
||||
const ret = JSON.stringify(obj === undefined ? null : obj);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
imports.wbg.__wbindgen_memory = function() {
|
||||
const ret = wasm.memory;
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_string_new = function(arg0, arg1) {
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
};
|
||||
imports.wbg.__wbindgen_throw = function(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
};
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
function __wbg_init_memory(imports, memory) {
|
||||
|
||||
}
|
||||
|
||||
function __wbg_finalize_init(instance, module) {
|
||||
wasm = instance.exports;
|
||||
__wbg_init.__wbindgen_wasm_module = module;
|
||||
cachedDataViewMemory0 = null;
|
||||
cachedUint8ArrayMemory0 = null;
|
||||
|
||||
|
||||
wasm.__wbindgen_start();
|
||||
return wasm;
|
||||
}
|
||||
|
||||
function initSync(module) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (typeof module !== 'undefined') {
|
||||
if (Object.getPrototypeOf(module) === Object.prototype) {
|
||||
({module} = module)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
__wbg_init_memory(imports);
|
||||
|
||||
if (!(module instanceof WebAssembly.Module)) {
|
||||
module = new WebAssembly.Module(module);
|
||||
}
|
||||
|
||||
const instance = new WebAssembly.Instance(module, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
async function __wbg_init(module_or_path) {
|
||||
if (wasm !== undefined) return wasm;
|
||||
|
||||
|
||||
if (typeof module_or_path !== 'undefined') {
|
||||
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
|
||||
({module_or_path} = module_or_path)
|
||||
} else {
|
||||
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module_or_path === 'undefined') {
|
||||
module_or_path = new URL('wasm_app_bg.wasm', import.meta.url);
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
|
||||
module_or_path = fetch(module_or_path);
|
||||
}
|
||||
|
||||
__wbg_init_memory(imports);
|
||||
|
||||
const { instance, module } = await __wbg_load(await module_or_path, imports);
|
||||
|
||||
return __wbg_finalize_init(instance, module);
|
||||
}
|
||||
|
||||
export { initSync };
|
||||
export default __wbg_init;
|
BIN
wasm_console_demo/wasm_app_bg.wasm
Normal file
BIN
wasm_console_demo/wasm_app_bg.wasm
Normal file
Binary file not shown.
27
wasm_console_demo/wasm_app_bg.wasm.d.ts
vendored
Normal file
27
wasm_console_demo/wasm_app_bg.wasm.d.ts
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export const memory: WebAssembly.Memory;
|
||||
export const init_rhai_env: () => void;
|
||||
export const run_rhai: (a: number, b: number) => [number, number, number];
|
||||
export const create_keyspace: (a: number, b: number, c: number, d: number) => any;
|
||||
export const init_session: (a: number, b: number, c: number, d: number) => any;
|
||||
export const lock_session: () => void;
|
||||
export const current_keypair_metadata: () => [number, number, number];
|
||||
export const current_keypair_public_key: () => [number, number, number];
|
||||
export const is_unlocked: () => number;
|
||||
export const select_keypair: (a: number, b: number) => [number, number];
|
||||
export const list_keypairs: () => any;
|
||||
export const add_keypair: (a: number, b: number, c: number, d: number) => any;
|
||||
export const sign: (a: number, b: number) => any;
|
||||
export const __wbindgen_exn_store: (a: number) => void;
|
||||
export const __externref_table_alloc: () => number;
|
||||
export const __wbindgen_export_2: WebAssembly.Table;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __wbindgen_export_5: WebAssembly.Table;
|
||||
export const __externref_table_dealloc: (a: number) => void;
|
||||
export const closure89_externref_shim: (a: number, b: number, c: any) => void;
|
||||
export const closure133_externref_shim: (a: number, b: number, c: any) => void;
|
||||
export const closure188_externref_shim: (a: number, b: number, c: any) => void;
|
||||
export const closure1847_externref_shim: (a: number, b: number, c: any, d: any) => void;
|
||||
export const __wbindgen_start: () => void;
|
Loading…
Reference in New Issue
Block a user