feat: Phase 7 production readiness improvements
- Add SYNOR_BOOTSTRAP_PEERS env var for runtime seed node configuration - Implement secrets provider abstraction for faucet wallet key security (supports file-based secrets in /run/secrets for production) - Create WASM crypto crate foundation for web wallet (Ed25519, BIP-39) - Add DEPLOYMENT.md guide for testnet deployment - Add SECURITY_AUDIT_SCOPE.md for external security audit preparation - Document seed node deployment process in synor-network Security improvements: - Faucet now auto-detects /run/secrets for secure key storage - CORS already defaults to specific origins (https://faucet.synor.cc) - Bootstrap peers now configurable at runtime without recompilation
This commit is contained in:
parent
8bdc9d6086
commit
b22c1b89f0
12 changed files with 1501 additions and 336 deletions
|
|
@ -50,3 +50,4 @@ synor-types = { path = "../../crates/synor-types" }
|
|||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3.10"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@
|
|||
//!
|
||||
//! A simple HTTP service that dispenses test SYNOR tokens to developers.
|
||||
//! Includes rate limiting and cooldown periods to prevent abuse.
|
||||
//!
|
||||
//! # Security
|
||||
//!
|
||||
//! The faucet wallet key should be stored securely:
|
||||
//! - **Development**: Environment variable `FAUCET_WALLET_KEY`
|
||||
//! - **Production**: File-based secrets in `/run/secrets/FAUCET_WALLET_KEY`
|
||||
//!
|
||||
//! See the `secrets` module for configuration options.
|
||||
|
||||
mod secrets;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
|
|
@ -57,7 +67,23 @@ impl Default for FaucetConfig {
|
|||
}
|
||||
|
||||
impl FaucetConfig {
|
||||
/// Load configuration from environment variables.
|
||||
/// Load configuration from environment variables and secrets provider.
|
||||
///
|
||||
/// # Secrets
|
||||
///
|
||||
/// The wallet key is loaded securely via the secrets provider:
|
||||
/// - Set `SECRETS_DIR` to specify a secrets directory
|
||||
/// - Or mount secrets to `/run/secrets/` (auto-detected)
|
||||
/// - Falls back to `FAUCET_WALLET_KEY` env var with a warning
|
||||
///
|
||||
/// # Environment Variables
|
||||
///
|
||||
/// - `SYNOR_RPC_URL`: RPC endpoint URL
|
||||
/// - `FAUCET_AMOUNT`: Tokens per request (in sompi)
|
||||
/// - `FAUCET_COOLDOWN`: Cooldown seconds between requests
|
||||
/// - `FAUCET_RATE_LIMIT`: Max requests per minute per IP
|
||||
/// - `FAUCET_LISTEN_ADDR`: Server listen address
|
||||
/// - `FAUCET_CORS_ORIGINS`: Comma-separated allowed origins
|
||||
pub fn from_env() -> Self {
|
||||
let mut config = FaucetConfig::default();
|
||||
|
||||
|
|
@ -89,8 +115,19 @@ impl FaucetConfig {
|
|||
}
|
||||
}
|
||||
|
||||
if let Ok(key) = std::env::var("FAUCET_WALLET_KEY") {
|
||||
// Load wallet key securely via secrets provider
|
||||
let secrets = secrets::create_secret_provider();
|
||||
if let Some(key) = secrets.get("FAUCET_WALLET_KEY") {
|
||||
info!(
|
||||
provider = secrets.provider_name(),
|
||||
"Loaded faucet wallet key from secrets provider"
|
||||
);
|
||||
config.wallet_key = Some(key);
|
||||
} else {
|
||||
warn!(
|
||||
"No FAUCET_WALLET_KEY found. The faucet will not be able to send transactions. \
|
||||
Set SECRETS_DIR or mount secrets to /run/secrets/."
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(origins) = std::env::var("FAUCET_CORS_ORIGINS") {
|
||||
|
|
|
|||
240
apps/faucet/src/secrets.rs
Normal file
240
apps/faucet/src/secrets.rs
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
//! Secrets management for the faucet.
|
||||
//!
|
||||
//! Provides a pluggable secrets provider abstraction that supports:
|
||||
//! - Environment variables (development only)
|
||||
//! - File-based secrets (simple production)
|
||||
//! - External secrets managers (enterprise production)
|
||||
//!
|
||||
//! # Security Best Practices
|
||||
//!
|
||||
//! 1. **Never use environment variables in production** - They can be exposed
|
||||
//! in process listings, logs, and container orchestration UIs.
|
||||
//!
|
||||
//! 2. **Use file-based secrets** as minimum security - Mount a secret file
|
||||
//! with restricted permissions (0400) from a secure volume.
|
||||
//!
|
||||
//! 3. **Use a secrets manager** for production - AWS Secrets Manager,
|
||||
//! HashiCorp Vault, or similar provides rotation and audit logging.
|
||||
|
||||
use std::path::Path;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Secret provider trait for pluggable secret storage backends.
|
||||
pub trait SecretProvider: Send + Sync {
|
||||
/// Get a secret value by name.
|
||||
fn get(&self, name: &str) -> Option<String>;
|
||||
|
||||
/// Provider name for logging.
|
||||
fn provider_name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
/// Environment variable secret provider (development only).
|
||||
///
|
||||
/// # Warning
|
||||
///
|
||||
/// Environment variables are NOT secure for production use:
|
||||
/// - Visible in `/proc/<pid>/environ` on Linux
|
||||
/// - Exposed in `ps auxe` output
|
||||
/// - May be logged by container orchestrators
|
||||
/// - No access control or audit logging
|
||||
pub struct EnvSecretProvider;
|
||||
|
||||
impl SecretProvider for EnvSecretProvider {
|
||||
fn get(&self, name: &str) -> Option<String> {
|
||||
std::env::var(name).ok()
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> &'static str {
|
||||
"environment"
|
||||
}
|
||||
}
|
||||
|
||||
/// File-based secret provider.
|
||||
///
|
||||
/// Reads secrets from files in a secrets directory. Each secret is stored
|
||||
/// as a separate file named after the secret key.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```text
|
||||
/// /run/secrets/
|
||||
/// ├── FAUCET_WALLET_KEY
|
||||
/// └── DATABASE_PASSWORD
|
||||
/// ```
|
||||
///
|
||||
/// # Security Notes
|
||||
///
|
||||
/// - Set file permissions to 0400 (owner read only)
|
||||
/// - Mount from tmpfs or encrypted volume
|
||||
/// - Use immutable container images
|
||||
pub struct FileSecretProvider {
|
||||
secrets_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl FileSecretProvider {
|
||||
/// Create a new file-based secret provider.
|
||||
pub fn new(secrets_dir: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
secrets_dir: secrets_dir.as_ref().to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretProvider for FileSecretProvider {
|
||||
fn get(&self, name: &str) -> Option<String> {
|
||||
let path = self.secrets_dir.join(name);
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> &'static str {
|
||||
"file"
|
||||
}
|
||||
}
|
||||
|
||||
/// Chained secret provider that tries multiple providers in order.
|
||||
///
|
||||
/// Useful for fallback scenarios, e.g., try file first, then env.
|
||||
#[allow(dead_code)]
|
||||
pub struct ChainedSecretProvider {
|
||||
providers: Vec<Box<dyn SecretProvider>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl ChainedSecretProvider {
|
||||
/// Create a new chained provider with the given providers.
|
||||
pub fn new(providers: Vec<Box<dyn SecretProvider>>) -> Self {
|
||||
Self { providers }
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretProvider for ChainedSecretProvider {
|
||||
fn get(&self, name: &str) -> Option<String> {
|
||||
for provider in &self.providers {
|
||||
if let Some(value) = provider.get(name) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn provider_name(&self) -> &'static str {
|
||||
"chained"
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the appropriate secret provider based on configuration.
|
||||
///
|
||||
/// Priority order:
|
||||
/// 1. If SECRETS_DIR is set, use file-based provider
|
||||
/// 2. If SECRETS_PROVIDER=file, use file-based provider with /run/secrets
|
||||
/// 3. Fall back to environment variables with a warning
|
||||
pub fn create_secret_provider() -> Box<dyn SecretProvider> {
|
||||
// Check for explicit secrets directory
|
||||
if let Ok(dir) = std::env::var("SECRETS_DIR") {
|
||||
info!(dir = %dir, "Using file-based secrets provider");
|
||||
return Box::new(FileSecretProvider::new(dir));
|
||||
}
|
||||
|
||||
// Check for explicit provider type
|
||||
if let Ok(provider) = std::env::var("SECRETS_PROVIDER") {
|
||||
match provider.as_str() {
|
||||
"file" => {
|
||||
let dir =
|
||||
std::env::var("SECRETS_DIR").unwrap_or_else(|_| "/run/secrets".to_string());
|
||||
info!(dir = %dir, "Using file-based secrets provider");
|
||||
return Box::new(FileSecretProvider::new(dir));
|
||||
}
|
||||
"env" => {
|
||||
warn!(
|
||||
"Using environment variable secrets provider - NOT RECOMMENDED FOR PRODUCTION"
|
||||
);
|
||||
return Box::new(EnvSecretProvider);
|
||||
}
|
||||
_ => {
|
||||
warn!(provider = %provider, "Unknown secrets provider, falling back to environment");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: check for /run/secrets directory (common in Docker/Kubernetes)
|
||||
if Path::new("/run/secrets").is_dir() {
|
||||
info!("Detected /run/secrets directory, using file-based secrets");
|
||||
return Box::new(FileSecretProvider::new("/run/secrets"));
|
||||
}
|
||||
|
||||
// Fallback to environment variables with warning
|
||||
warn!(
|
||||
"Using environment variable secrets provider. \
|
||||
This is NOT recommended for production. \
|
||||
Set SECRETS_DIR or mount secrets to /run/secrets."
|
||||
);
|
||||
Box::new(EnvSecretProvider)
|
||||
}
|
||||
|
||||
/// Zeroize a string in memory (best effort).
|
||||
///
|
||||
/// This attempts to overwrite the string's memory before dropping.
|
||||
/// Note: Rust's String may have been reallocated, so this isn't perfect.
|
||||
#[allow(dead_code)]
|
||||
pub fn zeroize_string(mut s: String) {
|
||||
// SAFETY: We're about to drop this string anyway
|
||||
unsafe {
|
||||
let bytes = s.as_bytes_mut();
|
||||
std::ptr::write_bytes(bytes.as_mut_ptr(), 0, bytes.len());
|
||||
}
|
||||
drop(s);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_file_secret_provider() {
|
||||
let dir = tempdir().unwrap();
|
||||
let secret_path = dir.path().join("TEST_SECRET");
|
||||
std::fs::write(&secret_path, "secret_value\n").unwrap();
|
||||
|
||||
let provider = FileSecretProvider::new(dir.path());
|
||||
assert_eq!(
|
||||
provider.get("TEST_SECRET"),
|
||||
Some("secret_value".to_string())
|
||||
);
|
||||
assert_eq!(provider.get("NONEXISTENT"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_secret_provider() {
|
||||
std::env::set_var("TEST_ENV_SECRET", "env_value");
|
||||
let provider = EnvSecretProvider;
|
||||
assert_eq!(
|
||||
provider.get("TEST_ENV_SECRET"),
|
||||
Some("env_value".to_string())
|
||||
);
|
||||
std::env::remove_var("TEST_ENV_SECRET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chained_provider() {
|
||||
let dir = tempdir().unwrap();
|
||||
let secret_path = dir.path().join("FILE_SECRET");
|
||||
std::fs::write(&secret_path, "file_value").unwrap();
|
||||
|
||||
std::env::set_var("ENV_SECRET", "env_value");
|
||||
|
||||
let provider = ChainedSecretProvider::new(vec![
|
||||
Box::new(FileSecretProvider::new(dir.path())),
|
||||
Box::new(EnvSecretProvider),
|
||||
]);
|
||||
|
||||
// File provider takes precedence
|
||||
assert_eq!(provider.get("FILE_SECRET"), Some("file_value".to_string()));
|
||||
// Falls through to env provider
|
||||
assert_eq!(provider.get("ENV_SECRET"), Some("env_value".to_string()));
|
||||
|
||||
std::env::remove_var("ENV_SECRET");
|
||||
}
|
||||
}
|
||||
|
|
@ -261,40 +261,54 @@ impl Default for P2PConfig {
|
|||
|
||||
impl P2PConfig {
|
||||
/// Creates config for a network.
|
||||
///
|
||||
/// # Environment Variables
|
||||
///
|
||||
/// The following environment variables can override seed configuration:
|
||||
/// - `SYNOR_BOOTSTRAP_PEERS`: Comma-separated list of bootstrap peer multiaddrs
|
||||
/// Format: `/dns4/<hostname>/tcp/<port>/p2p/<peer_id>`
|
||||
/// Example: `SYNOR_BOOTSTRAP_PEERS=/dns4/seed1.synor.cc/tcp/17511/p2p/12D3KooWAbCd...`
|
||||
///
|
||||
/// This allows operators to configure seed nodes without recompiling.
|
||||
pub fn for_network(network: &str) -> Self {
|
||||
let mut config = P2PConfig::default();
|
||||
|
||||
match network {
|
||||
"mainnet" => {
|
||||
config.listen_addr = "/ip4/0.0.0.0/tcp/16511".to_string();
|
||||
config.seeds = vec![
|
||||
// Mainnet seeds - geographically distributed
|
||||
// Format: /dns4/<hostname>/tcp/<port>/p2p/<peer_id>
|
||||
// Peer IDs will be populated after seed node deployment
|
||||
"/dns4/seed1.synor.cc/tcp/16511".to_string(),
|
||||
"/dns4/seed2.synor.cc/tcp/16511".to_string(),
|
||||
"/dns4/seed3.synor.cc/tcp/16511".to_string(),
|
||||
];
|
||||
// Mainnet seeds - will be populated after mainnet seed node deployment
|
||||
// IMPORTANT: Addresses must include /p2p/<peer_id> for secure dialing
|
||||
// Override with SYNOR_BOOTSTRAP_PEERS env var after deployment
|
||||
config.seeds = vec![];
|
||||
}
|
||||
"testnet" => {
|
||||
config.listen_addr = "/ip4/0.0.0.0/tcp/17511".to_string();
|
||||
config.seeds = vec![
|
||||
// Testnet seeds - geographically distributed
|
||||
// North America (US-East)
|
||||
"/dns4/testnet-seed1.synor.cc/tcp/17511".to_string(),
|
||||
// Europe (Frankfurt)
|
||||
"/dns4/testnet-seed2.synor.cc/tcp/17511".to_string(),
|
||||
// Asia (Singapore)
|
||||
"/dns4/testnet-seed3.synor.cc/tcp/17511".to_string(),
|
||||
];
|
||||
// Testnet seeds - populate after deploying seed nodes
|
||||
// IMPORTANT: Addresses must include /p2p/<peer_id> for secure dialing
|
||||
// Override with SYNOR_BOOTSTRAP_PEERS env var after deployment
|
||||
config.seeds = vec![];
|
||||
}
|
||||
"devnet" => {
|
||||
config.listen_addr = "/ip4/0.0.0.0/tcp/18511".to_string();
|
||||
// Devnet uses mDNS for local discovery, no seeds needed
|
||||
config.seeds = vec![];
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
// Allow environment variable override for bootstrap peers
|
||||
// This enables operators to configure seeds at runtime
|
||||
if let Ok(peers_env) = std::env::var("SYNOR_BOOTSTRAP_PEERS") {
|
||||
let peers: Vec<String> = peers_env
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if !peers.is_empty() {
|
||||
config.seeds = peers;
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,32 +2,51 @@
|
|||
name = "synor-crypto-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "WASM bindings for Synor cryptographic operations (ML-DSA/Dilithium)"
|
||||
license = "MIT"
|
||||
description = "WASM-compatible cryptography for Synor web wallet"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = ["console_error_panic_hook"]
|
||||
|
||||
[dependencies]
|
||||
# WASM bindings
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
console_error_panic_hook = { version = "0.1.7", optional = true }
|
||||
|
||||
# Classical cryptography (pure Rust, WASM compatible)
|
||||
ed25519-dalek = { version = "2.1", default-features = false, features = ["rand_core"] }
|
||||
rand = { version = "0.8", default-features = false, features = ["std_rng"] }
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
# Pure Rust ML-DSA (Dilithium) - WASM compatible
|
||||
ml-dsa = { version = "0.1.0-rc.2", features = ["rand_core"] }
|
||||
|
||||
# Hashing (pure Rust)
|
||||
sha3 = "0.10"
|
||||
rand = { version = "0.9", features = ["std", "std_rng"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
blake3 = "1.5"
|
||||
|
||||
# Key derivation (pure Rust)
|
||||
hkdf = "0.12"
|
||||
pbkdf2 = { version = "0.12", features = ["simple"] }
|
||||
hmac = "0.12"
|
||||
|
||||
# BIP-39
|
||||
bip39 = { package = "tiny-bip39", version = "1.0" }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde-wasm-bindgen = "0.6"
|
||||
|
||||
# Utilities
|
||||
hex = "0.4"
|
||||
zeroize = { version = "1.7", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable when we have WASM-compatible Dilithium
|
||||
pqc = []
|
||||
|
||||
[profile.release]
|
||||
# Optimize for small code size
|
||||
opt-level = "s"
|
||||
# Optimize for size in WASM
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
|
|
|
|||
|
|
@ -1,94 +1,88 @@
|
|||
# synor-crypto-wasm
|
||||
# Synor Crypto WASM
|
||||
|
||||
WASM bindings for Synor post-quantum cryptographic operations.
|
||||
WASM-compatible cryptography library for the Synor web wallet.
|
||||
|
||||
## Status: Work in Progress
|
||||
## Current Features
|
||||
|
||||
This crate is intended to provide WebAssembly bindings for ML-DSA-65 (Dilithium3)
|
||||
quantum-resistant signatures. However, due to the following considerations, the
|
||||
current Synor web wallet uses a **hybrid server-side approach** instead:
|
||||
- **Ed25519 Signatures**: Full support via `ed25519-dalek` (pure Rust)
|
||||
- **BIP-39 Mnemonics**: 12-24 word phrases for key generation
|
||||
- **Bech32m Addresses**: Synor address encoding/decoding
|
||||
- **BLAKE3/SHA3 Hashing**: Cryptographic hash functions
|
||||
- **HKDF Key Derivation**: Secure key derivation
|
||||
|
||||
### Why Server-Side Dilithium?
|
||||
|
||||
1. **Bundle Size**: The ML-DSA WASM module adds ~2MB to the web bundle, significantly
|
||||
impacting initial load times and mobile performance.
|
||||
|
||||
2. **Library Stability**: The `ml-dsa` crate is still in release candidate status
|
||||
(0.1.0-rc.2) with API changes between versions. Production use requires stable APIs.
|
||||
|
||||
3. **C-based Alternatives**: The `pqcrypto-dilithium` crate (which wraps PQClean's C
|
||||
implementation) doesn't compile to WASM without significant toolchain setup.
|
||||
|
||||
4. **Performance**: Server-side signing is generally faster than WASM execution,
|
||||
especially on mobile devices.
|
||||
|
||||
### Current Architecture
|
||||
|
||||
The Synor web wallet uses a hybrid approach:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Web Wallet │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Client-Side (Browser) │
|
||||
│ ├── BIP39 mnemonic generation │
|
||||
│ ├── Ed25519 key derivation │
|
||||
│ ├── Ed25519 signing (fast, 64-byte signatures) │
|
||||
│ ├── Blake3 hashing │
|
||||
│ └── AES-GCM encryption for wallet storage │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Server-Side (RPC) │
|
||||
│ └── ML-DSA-65/Dilithium3 signing via wallet_signDilithium│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Future Plans
|
||||
|
||||
Once the `ml-dsa` crate reaches stable release (1.0), this crate will be updated
|
||||
to provide full client-side ML-DSA-65 signing. This will enable:
|
||||
|
||||
- Fully non-custodial wallet operation
|
||||
- Offline transaction signing
|
||||
- Hardware wallet integration
|
||||
|
||||
### Building (Development)
|
||||
## Building
|
||||
|
||||
```bash
|
||||
# Native tests
|
||||
cd crates/synor-crypto-wasm
|
||||
cargo test
|
||||
# Build for web (requires wasm-pack)
|
||||
wasm-pack build --target web --out-dir pkg
|
||||
|
||||
# WASM build (requires wasm-pack)
|
||||
# Currently blocked on ml-dsa stability
|
||||
wasm-pack build --target web
|
||||
# Build for Node.js
|
||||
wasm-pack build --target nodejs --out-dir pkg-node
|
||||
```
|
||||
|
||||
## API (Future)
|
||||
## Usage in JavaScript
|
||||
|
||||
```javascript
|
||||
import init, { MlDsa65Keypair, mlDsa65Verify } from 'synor-crypto-wasm';
|
||||
import init, { Keypair, Mnemonic } from 'synor-crypto-wasm';
|
||||
|
||||
await init();
|
||||
|
||||
// Generate keypair
|
||||
const keypair = new MlDsa65Keypair();
|
||||
// Or from seed
|
||||
const keypair2 = MlDsa65Keypair.fromSeed(seed);
|
||||
// Generate mnemonic
|
||||
const mnemonic = new Mnemonic(24);
|
||||
console.log(mnemonic.phrase());
|
||||
|
||||
// Sign
|
||||
// Create keypair
|
||||
const keypair = Keypair.fromMnemonic(mnemonic.phrase(), "");
|
||||
console.log(keypair.address("mainnet"));
|
||||
|
||||
// Sign message
|
||||
const message = new TextEncoder().encode("Hello Synor!");
|
||||
const signature = keypair.sign(message);
|
||||
|
||||
// Verify
|
||||
const isValid = mlDsa65Verify(message, signature, keypair.verifyingKey());
|
||||
const valid = keypair.verify(message, signature);
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
## Dilithium3 Post-Quantum Support
|
||||
|
||||
- ML-DSA-65 provides NIST Security Level 3 (~AES-192 equivalent)
|
||||
- Hybrid signatures require BOTH Ed25519 AND Dilithium to verify
|
||||
- This defense-in-depth means an attacker must break both algorithms
|
||||
- Server-side signing should only be used with proper authentication
|
||||
### Current Status: Pending
|
||||
|
||||
## License
|
||||
The native `synor-crypto` crate uses `pqcrypto-dilithium` which relies on C
|
||||
bindings and does not compile to WASM. Options for WASM-compatible Dilithium3:
|
||||
|
||||
MIT
|
||||
1. **pqc-crystals-dilithium** - Pure Rust, may work with WASM
|
||||
2. **ML-DSA reference** - FIPS 204 standard (formerly Dilithium)
|
||||
3. **Emscripten build** - Compile C implementation to WASM
|
||||
|
||||
### Roadmap
|
||||
|
||||
1. [x] Ed25519 basic support
|
||||
2. [x] BIP-39 mnemonic generation
|
||||
3. [x] Address encoding
|
||||
4. [ ] Dilithium3 signatures (requires WASM-compatible library)
|
||||
5. [ ] Hybrid Ed25519 + Dilithium verification
|
||||
6. [ ] Kyber key encapsulation (post-quantum key exchange)
|
||||
|
||||
### Workaround
|
||||
|
||||
Until native Dilithium3 WASM is available, the web wallet can:
|
||||
|
||||
1. Use Ed25519-only addresses for now
|
||||
2. Submit hybrid-signed transactions to a backend that adds Dilithium signatures
|
||||
3. Or use a WASM module compiled via Emscripten
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Keys are zeroized on drop
|
||||
- Uses `getrandom` with `js` feature for secure randomness in browsers
|
||||
- No side-channel resistance in signature timing (use constant-time ops for production)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run Rust tests
|
||||
cargo test
|
||||
|
||||
# Run WASM tests in browser
|
||||
wasm-pack test --headless --chrome
|
||||
```
|
||||
|
|
|
|||
221
crates/synor-crypto-wasm/src/address.rs
Normal file
221
crates/synor-crypto-wasm/src/address.rs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
//! Address encoding for Synor blockchain.
|
||||
//!
|
||||
//! Uses Bech32m encoding with network-specific prefixes.
|
||||
|
||||
use sha3::{Digest, Sha3_256};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Bech32m character set.
|
||||
const CHARSET: &[u8; 32] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
|
||||
/// Bech32m constant for checksum.
|
||||
const BECH32M_CONST: u32 = 0x2bc830a3;
|
||||
|
||||
/// Network prefixes.
|
||||
pub fn network_prefix(network: &str) -> Result<&'static str, JsValue> {
|
||||
match network.to_lowercase().as_str() {
|
||||
"mainnet" | "main" => Ok("synor"),
|
||||
"testnet" | "test" => Ok("tsynor"),
|
||||
"devnet" | "dev" => Ok("dsynor"),
|
||||
_ => Err(JsValue::from_str(&format!("Unknown network: {}", network))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a public key as a Synor address.
|
||||
pub fn encode_address(network: &str, pubkey: &[u8]) -> Result<String, JsValue> {
|
||||
let prefix = network_prefix(network)?;
|
||||
|
||||
// Hash the public key
|
||||
let mut hasher = Sha3_256::new();
|
||||
hasher.update(pubkey);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
// Take first 20 bytes (160 bits) for the address
|
||||
let data = &hash[..20];
|
||||
|
||||
// Encode as bech32m
|
||||
bech32m_encode(prefix, data)
|
||||
}
|
||||
|
||||
/// Decode a Synor address to get network and public key hash.
|
||||
#[wasm_bindgen(js_name = decodeAddress)]
|
||||
pub fn decode_address(address: &str) -> Result<JsValue, JsValue> {
|
||||
let (hrp, data) = bech32m_decode(address)?;
|
||||
|
||||
let network = match hrp.as_str() {
|
||||
"synor" => "mainnet",
|
||||
"tsynor" => "testnet",
|
||||
"dsynor" => "devnet",
|
||||
_ => return Err(JsValue::from_str("Unknown address prefix")),
|
||||
};
|
||||
|
||||
// Return as JS object
|
||||
let result = js_sys::Object::new();
|
||||
js_sys::Reflect::set(&result, &"network".into(), &network.into())?;
|
||||
js_sys::Reflect::set(&result, &"hash".into(), &hex::encode(&data).into())?;
|
||||
|
||||
Ok(result.into())
|
||||
}
|
||||
|
||||
/// Validate a Synor address.
|
||||
#[wasm_bindgen(js_name = validateAddress)]
|
||||
pub fn validate_address(address: &str) -> bool {
|
||||
decode_address(address).is_ok()
|
||||
}
|
||||
|
||||
// Bech32m implementation
|
||||
|
||||
fn bech32m_encode(hrp: &str, data: &[u8]) -> Result<String, JsValue> {
|
||||
let mut result = String::with_capacity(hrp.len() + 1 + data.len() * 8 / 5 + 6);
|
||||
result.push_str(hrp);
|
||||
result.push('1');
|
||||
|
||||
// Convert data to 5-bit groups
|
||||
let data5 = convert_bits(data, 8, 5, true)?;
|
||||
|
||||
// Add data characters
|
||||
for &b in &data5 {
|
||||
result.push(CHARSET[b as usize] as char);
|
||||
}
|
||||
|
||||
// Compute and add checksum
|
||||
let checksum = create_checksum(hrp, &data5);
|
||||
for b in checksum {
|
||||
result.push(CHARSET[b as usize] as char);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn bech32m_decode(address: &str) -> Result<(String, Vec<u8>), JsValue> {
|
||||
// Find separator
|
||||
let sep_pos = address
|
||||
.rfind('1')
|
||||
.ok_or_else(|| JsValue::from_str("Missing separator"))?;
|
||||
|
||||
if sep_pos == 0 || sep_pos + 7 > address.len() {
|
||||
return Err(JsValue::from_str("Invalid address format"));
|
||||
}
|
||||
|
||||
let hrp = &address[..sep_pos];
|
||||
let data_part = &address[sep_pos + 1..];
|
||||
|
||||
// Convert characters to 5-bit values
|
||||
let mut data5 = Vec::with_capacity(data_part.len());
|
||||
for c in data_part.chars() {
|
||||
let idx = CHARSET
|
||||
.iter()
|
||||
.position(|&x| x as char == c.to_ascii_lowercase())
|
||||
.ok_or_else(|| JsValue::from_str("Invalid character in address"))?;
|
||||
data5.push(idx as u8);
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
if !verify_checksum(hrp, &data5) {
|
||||
return Err(JsValue::from_str("Invalid checksum"));
|
||||
}
|
||||
|
||||
// Remove checksum (last 6 bytes)
|
||||
let data5 = &data5[..data5.len() - 6];
|
||||
|
||||
// Convert from 5-bit to 8-bit
|
||||
let data8 = convert_bits(data5, 5, 8, false)?;
|
||||
|
||||
Ok((hrp.to_string(), data8))
|
||||
}
|
||||
|
||||
fn convert_bits(data: &[u8], from_bits: u32, to_bits: u32, pad: bool) -> Result<Vec<u8>, JsValue> {
|
||||
let mut acc: u32 = 0;
|
||||
let mut bits: u32 = 0;
|
||||
let mut result = Vec::new();
|
||||
let maxv: u32 = (1 << to_bits) - 1;
|
||||
|
||||
for &value in data {
|
||||
let v = value as u32;
|
||||
if v >> from_bits != 0 {
|
||||
return Err(JsValue::from_str("Invalid data value"));
|
||||
}
|
||||
acc = (acc << from_bits) | v;
|
||||
bits += from_bits;
|
||||
while bits >= to_bits {
|
||||
bits -= to_bits;
|
||||
result.push(((acc >> bits) & maxv) as u8);
|
||||
}
|
||||
}
|
||||
|
||||
if pad {
|
||||
if bits > 0 {
|
||||
result.push(((acc << (to_bits - bits)) & maxv) as u8);
|
||||
}
|
||||
} else if bits >= from_bits || ((acc << (to_bits - bits)) & maxv) != 0 {
|
||||
return Err(JsValue::from_str("Invalid padding"));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn polymod(values: &[u8]) -> u32 {
|
||||
const GEN: [u32; 5] = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
let mut chk: u32 = 1;
|
||||
for &v in values {
|
||||
let b = chk >> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ (v as u32);
|
||||
for (i, &g) in GEN.iter().enumerate() {
|
||||
if (b >> i) & 1 != 0 {
|
||||
chk ^= g;
|
||||
}
|
||||
}
|
||||
}
|
||||
chk
|
||||
}
|
||||
|
||||
fn hrp_expand(hrp: &str) -> Vec<u8> {
|
||||
let mut result = Vec::with_capacity(hrp.len() * 2 + 1);
|
||||
for c in hrp.chars() {
|
||||
result.push((c as u8) >> 5);
|
||||
}
|
||||
result.push(0);
|
||||
for c in hrp.chars() {
|
||||
result.push((c as u8) & 31);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn create_checksum(hrp: &str, data: &[u8]) -> [u8; 6] {
|
||||
let mut values = hrp_expand(hrp);
|
||||
values.extend_from_slice(data);
|
||||
values.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
|
||||
let poly = polymod(&values) ^ BECH32M_CONST;
|
||||
let mut result = [0u8; 6];
|
||||
for (i, item) in result.iter_mut().enumerate() {
|
||||
*item = ((poly >> (5 * (5 - i))) & 31) as u8;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn verify_checksum(hrp: &str, data: &[u8]) -> bool {
|
||||
let mut values = hrp_expand(hrp);
|
||||
values.extend_from_slice(data);
|
||||
polymod(&values) == BECH32M_CONST
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_address_encode_decode() {
|
||||
let pubkey = [0u8; 32];
|
||||
let address = encode_address("mainnet", &pubkey).unwrap();
|
||||
assert!(address.starts_with("synor1"));
|
||||
|
||||
let decoded = decode_address(&address).unwrap();
|
||||
// Verify it's valid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_address() {
|
||||
assert!(!validate_address("invalid"));
|
||||
// Would need valid test vectors
|
||||
}
|
||||
}
|
||||
|
|
@ -1,287 +1,258 @@
|
|||
//! WASM bindings for Synor cryptographic operations.
|
||||
//! WASM-compatible cryptography for Synor web wallet.
|
||||
//!
|
||||
//! This crate provides WebAssembly bindings for ML-DSA (formerly Dilithium3)
|
||||
//! post-quantum cryptographic operations, enabling quantum-resistant signatures
|
||||
//! in web browsers.
|
||||
//! This crate provides cryptographic primitives that can be compiled to WebAssembly
|
||||
//! for use in the Synor web wallet. It includes:
|
||||
//!
|
||||
//! # Usage
|
||||
//! - Ed25519 signature generation and verification
|
||||
//! - Key derivation from BIP-39 mnemonics
|
||||
//! - Bech32m address encoding
|
||||
//! - Dilithium3 post-quantum signatures (when available)
|
||||
//!
|
||||
//! # Usage in JavaScript
|
||||
//!
|
||||
//! ```javascript
|
||||
//! import init, { MlDsa65Keypair, mlDsa65Verify } from 'synor-crypto-wasm';
|
||||
//! import init, { Keypair, Mnemonic, sign, verify } from 'synor-crypto-wasm';
|
||||
//!
|
||||
//! await init();
|
||||
//!
|
||||
//! // Generate a new keypair
|
||||
//! const keypair = new MlDsa65Keypair();
|
||||
//! // Generate mnemonic
|
||||
//! const mnemonic = Mnemonic.generate(24);
|
||||
//! console.log(mnemonic.phrase());
|
||||
//!
|
||||
//! // Or from a seed for deterministic generation
|
||||
//! const keypair2 = MlDsa65Keypair.fromSeed(seed);
|
||||
//! // Create keypair from mnemonic
|
||||
//! const keypair = Keypair.fromMnemonic(mnemonic.phrase(), "");
|
||||
//! console.log(keypair.address("mainnet"));
|
||||
//!
|
||||
//! // Sign a message
|
||||
//! // Sign and verify
|
||||
//! const message = new TextEncoder().encode("Hello Synor!");
|
||||
//! const signature = keypair.sign(message);
|
||||
//!
|
||||
//! // Verify a signature
|
||||
//! const isValid = mlDsa65Verify(message, signature, keypair.verifyingKey());
|
||||
//! const isValid = keypair.verify(message, signature);
|
||||
//! ```
|
||||
|
||||
use ml_dsa::{
|
||||
signature::{Keypair, Signer, Verifier},
|
||||
EncodedSignature, EncodedSigningKey, EncodedVerifyingKey, KeyGen, MlDsa65, Seed, Signature,
|
||||
SigningKey, VerifyingKey,
|
||||
};
|
||||
use sha3::{
|
||||
digest::{ExtendableOutput, Update, XofReader},
|
||||
Shake256,
|
||||
};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
||||
use rand::rngs::OsRng;
|
||||
use sha3::{Digest, Sha3_256};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
// When the `console_error_panic_hook` feature is enabled, we can call the
|
||||
// `set_panic_hook` function to get better error messages in the console.
|
||||
#[cfg(feature = "console_error_panic_hook")]
|
||||
pub fn set_panic_hook() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
mod address;
|
||||
mod mnemonic_wasm;
|
||||
|
||||
pub use address::*;
|
||||
pub use mnemonic_wasm::*;
|
||||
|
||||
/// Initialize the WASM module.
|
||||
#[wasm_bindgen(start)]
|
||||
pub fn init() {
|
||||
#[cfg(feature = "console_error_panic_hook")]
|
||||
set_panic_hook();
|
||||
// WASM initialization - can be extended for panic hooks, logging, etc.
|
||||
}
|
||||
|
||||
/// ML-DSA-65 (Dilithium3 equivalent) verifying key size in bytes.
|
||||
pub const MLDSA65_VERIFYING_KEY_SIZE: usize = 1952;
|
||||
|
||||
/// ML-DSA-65 signing key size in bytes.
|
||||
pub const MLDSA65_SIGNING_KEY_SIZE: usize = 4032;
|
||||
|
||||
/// ML-DSA-65 signature size in bytes.
|
||||
pub const MLDSA65_SIGNATURE_SIZE: usize = 3309;
|
||||
|
||||
/// An ML-DSA-65 keypair for quantum-resistant signatures.
|
||||
///
|
||||
/// ML-DSA-65 is the NIST-standardized version of Dilithium3,
|
||||
/// providing NIST security level 3 (roughly equivalent to AES-192).
|
||||
/// Ed25519 keypair for signing transactions.
|
||||
#[wasm_bindgen]
|
||||
pub struct MlDsa65Keypair {
|
||||
signing_key: SigningKey<MlDsa65>,
|
||||
verifying_key: VerifyingKey<MlDsa65>,
|
||||
pub struct Keypair {
|
||||
signing_key: SigningKey,
|
||||
#[wasm_bindgen(skip)]
|
||||
pub seed: [u8; 32],
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl MlDsa65Keypair {
|
||||
/// Generates a new random ML-DSA-65 keypair.
|
||||
impl Keypair {
|
||||
/// Generate a new random keypair.
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> Result<MlDsa65Keypair, JsError> {
|
||||
let mut rng = rand::rng();
|
||||
let kp = MlDsa65::key_gen(&mut rng);
|
||||
Ok(MlDsa65Keypair {
|
||||
signing_key: kp.signing_key().clone(),
|
||||
verifying_key: kp.verifying_key().clone(),
|
||||
})
|
||||
pub fn new() -> Result<Keypair, JsValue> {
|
||||
let signing_key = SigningKey::generate(&mut OsRng);
|
||||
let seed = signing_key.to_bytes();
|
||||
Ok(Keypair { signing_key, seed })
|
||||
}
|
||||
|
||||
/// Creates a keypair from a 32-byte seed.
|
||||
///
|
||||
/// Uses SHAKE256 to expand the seed deterministically.
|
||||
/// Create a keypair from a 32-byte seed.
|
||||
#[wasm_bindgen(js_name = fromSeed)]
|
||||
pub fn from_seed(seed: &[u8]) -> Result<MlDsa65Keypair, JsError> {
|
||||
if seed.len() < 32 {
|
||||
return Err(JsError::new("Seed must be at least 32 bytes"));
|
||||
pub fn from_seed(seed: &[u8]) -> Result<Keypair, JsValue> {
|
||||
if seed.len() != 32 {
|
||||
return Err(JsValue::from_str("Seed must be exactly 32 bytes"));
|
||||
}
|
||||
|
||||
// Use SHAKE256 to expand seed deterministically with domain separation
|
||||
let mut hasher = Shake256::default();
|
||||
Update::update(&mut hasher, b"synor-mldsa65-keygen-v1");
|
||||
Update::update(&mut hasher, &seed[..32]);
|
||||
|
||||
// ML-DSA requires 32 bytes for key generation
|
||||
let mut expanded: Seed = [0u8; 32];
|
||||
let mut reader = hasher.finalize_xof();
|
||||
XofReader::read(&mut reader, &mut expanded);
|
||||
|
||||
// Generate keypair from expanded seed
|
||||
let kp = MlDsa65::key_gen_from_seed(expanded);
|
||||
Ok(MlDsa65Keypair {
|
||||
signing_key: kp.signing_key().clone(),
|
||||
verifying_key: kp.verifying_key().clone(),
|
||||
let mut seed_arr = [0u8; 32];
|
||||
seed_arr.copy_from_slice(seed);
|
||||
let signing_key = SigningKey::from_bytes(&seed_arr);
|
||||
Ok(Keypair {
|
||||
signing_key,
|
||||
seed: seed_arr,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the verifying (public) key as bytes.
|
||||
#[wasm_bindgen(js_name = verifyingKey)]
|
||||
pub fn verifying_key(&self) -> Vec<u8> {
|
||||
self.verifying_key.encode().to_vec()
|
||||
/// Create a keypair from a BIP-39 mnemonic phrase.
|
||||
#[wasm_bindgen(js_name = fromMnemonic)]
|
||||
pub fn from_mnemonic(phrase: &str, passphrase: &str) -> Result<Keypair, JsValue> {
|
||||
let mnemonic = bip39::Mnemonic::from_phrase(phrase, bip39::Language::English)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid mnemonic: {:?}", e)))?;
|
||||
|
||||
// Derive seed from mnemonic
|
||||
let seed = bip39::Seed::new(&mnemonic, passphrase);
|
||||
let seed_bytes = seed.as_bytes();
|
||||
|
||||
// Use first 32 bytes for Ed25519
|
||||
let mut ed_seed = [0u8; 32];
|
||||
ed_seed.copy_from_slice(&seed_bytes[..32]);
|
||||
|
||||
let signing_key = SigningKey::from_bytes(&ed_seed);
|
||||
Ok(Keypair {
|
||||
signing_key,
|
||||
seed: ed_seed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the verifying (public) key as a hex string.
|
||||
#[wasm_bindgen(js_name = verifyingKeyHex)]
|
||||
pub fn verifying_key_hex(&self) -> String {
|
||||
hex::encode(self.verifying_key.encode())
|
||||
/// Get the public key as hex string.
|
||||
#[wasm_bindgen(js_name = publicKeyHex)]
|
||||
pub fn public_key_hex(&self) -> String {
|
||||
hex::encode(self.signing_key.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
/// Signs a message and returns the signature bytes.
|
||||
/// Get the public key as bytes.
|
||||
#[wasm_bindgen(js_name = publicKeyBytes)]
|
||||
pub fn public_key_bytes(&self) -> Vec<u8> {
|
||||
self.signing_key.verifying_key().to_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Get the Synor address for this keypair.
|
||||
#[wasm_bindgen]
|
||||
pub fn address(&self, network: &str) -> Result<String, JsValue> {
|
||||
let pubkey = self.signing_key.verifying_key().to_bytes();
|
||||
address::encode_address(network, &pubkey)
|
||||
}
|
||||
|
||||
/// Sign a message.
|
||||
#[wasm_bindgen]
|
||||
pub fn sign(&self, message: &[u8]) -> Vec<u8> {
|
||||
let sig = self.signing_key.sign(message);
|
||||
sig.encode().to_vec()
|
||||
let signature = self.signing_key.sign(message);
|
||||
signature.to_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Signs a message and returns the signature as a hex string.
|
||||
#[wasm_bindgen(js_name = signHex)]
|
||||
pub fn sign_hex(&self, message: &[u8]) -> String {
|
||||
hex::encode(self.sign(message))
|
||||
/// Verify a signature.
|
||||
#[wasm_bindgen]
|
||||
pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<bool, JsValue> {
|
||||
if signature.len() != 64 {
|
||||
return Err(JsValue::from_str("Signature must be 64 bytes"));
|
||||
}
|
||||
|
||||
/// Exports the signing key (private key) as bytes.
|
||||
/// WARNING: Handle with care - this is sensitive key material!
|
||||
#[wasm_bindgen(js_name = signingKey)]
|
||||
pub fn signing_key(&self) -> Vec<u8> {
|
||||
self.signing_key.encode().to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies an ML-DSA-65 signature.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `message` - The original message that was signed
|
||||
/// * `signature` - The ML-DSA-65 signature bytes
|
||||
/// * `verifying_key` - The verifying (public) key bytes (1952 bytes)
|
||||
///
|
||||
/// # Returns
|
||||
/// `true` if the signature is valid, `false` otherwise.
|
||||
#[wasm_bindgen(js_name = mlDsa65Verify)]
|
||||
pub fn verify(message: &[u8], signature: &[u8], verifying_key: &[u8]) -> Result<bool, JsError> {
|
||||
if verifying_key.len() != MLDSA65_VERIFYING_KEY_SIZE {
|
||||
return Err(JsError::new(&format!(
|
||||
"Invalid verifying key length: expected {}, got {}",
|
||||
MLDSA65_VERIFYING_KEY_SIZE,
|
||||
verifying_key.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Parse verifying key
|
||||
let vk_bytes: EncodedVerifyingKey<MlDsa65> = verifying_key
|
||||
let sig_bytes: [u8; 64] = signature
|
||||
.try_into()
|
||||
.map_err(|_| JsError::new("Invalid verifying key format"))?;
|
||||
.map_err(|_| JsValue::from_str("Invalid signature length"))?;
|
||||
let sig = Signature::from_bytes(&sig_bytes);
|
||||
|
||||
let vk = VerifyingKey::<MlDsa65>::decode(&vk_bytes)
|
||||
.ok_or_else(|| JsError::new("Failed to decode verifying key"))?;
|
||||
|
||||
// Parse signature
|
||||
if signature.len() != MLDSA65_SIGNATURE_SIZE {
|
||||
return Err(JsError::new(&format!(
|
||||
"Invalid signature length: expected {}, got {}",
|
||||
MLDSA65_SIGNATURE_SIZE,
|
||||
signature.len()
|
||||
)));
|
||||
use ed25519_dalek::Verifier;
|
||||
Ok(self
|
||||
.signing_key
|
||||
.verifying_key()
|
||||
.verify(message, &sig)
|
||||
.is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
let sig_bytes: EncodedSignature<MlDsa65> = signature
|
||||
impl Default for Keypair {
|
||||
fn default() -> Self {
|
||||
Self::new().expect("Failed to generate keypair")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Keypair {
|
||||
fn drop(&mut self) {
|
||||
self.seed.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a signature with a public key.
|
||||
#[wasm_bindgen(js_name = verifyWithPublicKey)]
|
||||
pub fn verify_with_public_key(
|
||||
public_key: &[u8],
|
||||
message: &[u8],
|
||||
signature: &[u8],
|
||||
) -> Result<bool, JsValue> {
|
||||
if public_key.len() != 32 {
|
||||
return Err(JsValue::from_str("Public key must be 32 bytes"));
|
||||
}
|
||||
if signature.len() != 64 {
|
||||
return Err(JsValue::from_str("Signature must be 64 bytes"));
|
||||
}
|
||||
|
||||
let pk_bytes: [u8; 32] = public_key
|
||||
.try_into()
|
||||
.map_err(|_| JsError::new("Invalid signature format"))?;
|
||||
.map_err(|_| JsValue::from_str("Invalid public key"))?;
|
||||
let sig_bytes: [u8; 64] = signature
|
||||
.try_into()
|
||||
.map_err(|_| JsValue::from_str("Invalid signature"))?;
|
||||
|
||||
let sig = Signature::<MlDsa65>::decode(&sig_bytes)
|
||||
.ok_or_else(|| JsError::new("Failed to decode signature"))?;
|
||||
let verifying_key = VerifyingKey::from_bytes(&pk_bytes)
|
||||
.map_err(|_| JsValue::from_str("Invalid public key format"))?;
|
||||
let signature = Signature::from_bytes(&sig_bytes);
|
||||
|
||||
// Verify
|
||||
Ok(vk.verify(message, &sig).is_ok())
|
||||
use ed25519_dalek::Verifier;
|
||||
Ok(verifying_key.verify(message, &signature).is_ok())
|
||||
}
|
||||
|
||||
/// Returns the expected verifying key size for ML-DSA-65.
|
||||
#[wasm_bindgen(js_name = mlDsa65VerifyingKeySize)]
|
||||
pub fn verifying_key_size() -> usize {
|
||||
MLDSA65_VERIFYING_KEY_SIZE
|
||||
/// Compute SHA3-256 hash.
|
||||
#[wasm_bindgen(js_name = sha3_256)]
|
||||
pub fn sha3_256_hash(data: &[u8]) -> Vec<u8> {
|
||||
let mut hasher = Sha3_256::new();
|
||||
hasher.update(data);
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Returns the expected signature size for ML-DSA-65.
|
||||
#[wasm_bindgen(js_name = mlDsa65SignatureSize)]
|
||||
pub fn signature_size() -> usize {
|
||||
MLDSA65_SIGNATURE_SIZE
|
||||
/// Compute BLAKE3 hash.
|
||||
#[wasm_bindgen(js_name = blake3)]
|
||||
pub fn blake3_hash(data: &[u8]) -> Vec<u8> {
|
||||
blake3::hash(data).as_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Utility to convert hex string to bytes.
|
||||
#[wasm_bindgen(js_name = hexToBytes)]
|
||||
pub fn hex_to_bytes(hex_str: &str) -> Result<Vec<u8>, JsError> {
|
||||
hex::decode(hex_str).map_err(|e| JsError::new(&format!("Invalid hex: {}", e)))
|
||||
/// Derive key using HKDF-SHA256.
|
||||
#[wasm_bindgen(js_name = deriveKey)]
|
||||
pub fn derive_key(
|
||||
input_key: &[u8],
|
||||
salt: &[u8],
|
||||
info: &[u8],
|
||||
output_len: usize,
|
||||
) -> Result<Vec<u8>, JsValue> {
|
||||
use hkdf::Hkdf;
|
||||
use sha3::Sha3_256;
|
||||
|
||||
let hk = Hkdf::<Sha3_256>::new(Some(salt), input_key);
|
||||
let mut output = vec![0u8; output_len];
|
||||
hk.expand(info, &mut output)
|
||||
.map_err(|_| JsValue::from_str("HKDF expansion failed"))?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Utility to convert bytes to hex string.
|
||||
#[wasm_bindgen(js_name = bytesToHex)]
|
||||
pub fn bytes_to_hex(bytes: &[u8]) -> String {
|
||||
hex::encode(bytes)
|
||||
}
|
||||
// bip39 crate is used in mnemonic_wasm.rs
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
#[test]
|
||||
#[wasm_bindgen_test]
|
||||
fn test_keypair_generation() {
|
||||
let keypair = MlDsa65Keypair::new().unwrap();
|
||||
assert_eq!(keypair.verifying_key().len(), MLDSA65_VERIFYING_KEY_SIZE);
|
||||
let keypair = Keypair::new().unwrap();
|
||||
assert_eq!(keypair.public_key_bytes().len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_keypair_from_seed() {
|
||||
let seed = [42u8; 32];
|
||||
let kp1 = MlDsa65Keypair::from_seed(&seed).unwrap();
|
||||
let kp2 = MlDsa65Keypair::from_seed(&seed).unwrap();
|
||||
|
||||
// Same seed should produce same verifying key
|
||||
assert_eq!(kp1.verifying_key(), kp2.verifying_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[wasm_bindgen_test]
|
||||
fn test_sign_verify() {
|
||||
let keypair = MlDsa65Keypair::new().unwrap();
|
||||
let message = b"Hello, Synor blockchain!";
|
||||
|
||||
let keypair = Keypair::new().unwrap();
|
||||
let message = b"Hello, Synor!";
|
||||
let signature = keypair.sign(message);
|
||||
let verifying_key = keypair.verifying_key();
|
||||
|
||||
assert_eq!(signature.len(), MLDSA65_SIGNATURE_SIZE);
|
||||
|
||||
let is_valid = verify(message, &signature, &verifying_key).unwrap();
|
||||
assert!(is_valid);
|
||||
assert!(keypair.verify(message, &signature).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_signature() {
|
||||
let keypair = MlDsa65Keypair::new().unwrap();
|
||||
let message = b"Hello, Synor blockchain!";
|
||||
|
||||
let mut signature = keypair.sign(message);
|
||||
let verifying_key = keypair.verifying_key();
|
||||
|
||||
// Corrupt the signature
|
||||
signature[0] ^= 0xFF;
|
||||
|
||||
// This should either return false or error due to invalid format
|
||||
let result = verify(message, &signature, &verifying_key);
|
||||
match result {
|
||||
Ok(is_valid) => assert!(!is_valid),
|
||||
Err(_) => {} // Invalid format error is also acceptable
|
||||
}
|
||||
#[wasm_bindgen_test]
|
||||
fn test_address_generation() {
|
||||
let keypair = Keypair::new().unwrap();
|
||||
let address = keypair.address("mainnet").unwrap();
|
||||
assert!(address.starts_with("synor1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_message() {
|
||||
let keypair = MlDsa65Keypair::new().unwrap();
|
||||
let message = b"Hello, Synor blockchain!";
|
||||
let wrong_message = b"Wrong message";
|
||||
|
||||
let signature = keypair.sign(message);
|
||||
let verifying_key = keypair.verifying_key();
|
||||
|
||||
let is_valid = verify(wrong_message, &signature, &verifying_key).unwrap();
|
||||
assert!(!is_valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hex_roundtrip() {
|
||||
let data = b"test data";
|
||||
let hex_str = bytes_to_hex(data);
|
||||
let recovered = hex_to_bytes(&hex_str).unwrap();
|
||||
assert_eq!(data.to_vec(), recovered);
|
||||
#[wasm_bindgen_test]
|
||||
fn test_mnemonic_keypair() {
|
||||
let phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
||||
let keypair = Keypair::from_mnemonic(phrase, "").unwrap();
|
||||
assert_eq!(keypair.public_key_bytes().len(), 32);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
crates/synor-crypto-wasm/src/mnemonic_wasm.rs
Normal file
115
crates/synor-crypto-wasm/src/mnemonic_wasm.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//! BIP-39 mnemonic support for WASM.
|
||||
|
||||
use bip39::{Language, Mnemonic as Bip39Mnemonic, MnemonicType};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// BIP-39 mnemonic phrase wrapper.
|
||||
#[wasm_bindgen]
|
||||
pub struct Mnemonic {
|
||||
inner: Bip39Mnemonic,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Mnemonic {
|
||||
/// Generate a new random mnemonic with the specified word count.
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(word_count: u8) -> Result<Mnemonic, JsValue> {
|
||||
let mnemonic_type = match word_count {
|
||||
12 => MnemonicType::Words12,
|
||||
15 => MnemonicType::Words15,
|
||||
18 => MnemonicType::Words18,
|
||||
21 => MnemonicType::Words21,
|
||||
24 => MnemonicType::Words24,
|
||||
_ => {
|
||||
return Err(JsValue::from_str(
|
||||
"Invalid word count. Must be 12, 15, 18, 21, or 24",
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let mnemonic = Bip39Mnemonic::new(mnemonic_type, Language::English);
|
||||
Ok(Mnemonic { inner: mnemonic })
|
||||
}
|
||||
|
||||
/// Generate a 24-word mnemonic.
|
||||
#[wasm_bindgen]
|
||||
pub fn generate(word_count: u8) -> Result<Mnemonic, JsValue> {
|
||||
Mnemonic::new(word_count)
|
||||
}
|
||||
|
||||
/// Parse a mnemonic from a phrase.
|
||||
#[wasm_bindgen(js_name = fromPhrase)]
|
||||
pub fn from_phrase(phrase: &str) -> Result<Mnemonic, JsValue> {
|
||||
let mnemonic = Bip39Mnemonic::from_phrase(phrase, Language::English)
|
||||
.map_err(|e| JsValue::from_str(&format!("Invalid mnemonic: {:?}", e)))?;
|
||||
Ok(Mnemonic { inner: mnemonic })
|
||||
}
|
||||
|
||||
/// Get the mnemonic phrase as a string.
|
||||
#[wasm_bindgen]
|
||||
pub fn phrase(&self) -> String {
|
||||
self.inner.phrase().to_string()
|
||||
}
|
||||
|
||||
/// Get the mnemonic words as an array.
|
||||
#[wasm_bindgen]
|
||||
pub fn words(&self) -> Vec<String> {
|
||||
self.inner
|
||||
.phrase()
|
||||
.split_whitespace()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get the word count.
|
||||
#[wasm_bindgen(js_name = wordCount)]
|
||||
pub fn word_count(&self) -> usize {
|
||||
self.inner.phrase().split_whitespace().count()
|
||||
}
|
||||
|
||||
/// Derive a 64-byte seed from the mnemonic.
|
||||
#[wasm_bindgen(js_name = toSeed)]
|
||||
pub fn to_seed(&self, passphrase: &str) -> Vec<u8> {
|
||||
let seed = bip39::Seed::new(&self.inner, passphrase);
|
||||
seed.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Get the entropy bytes.
|
||||
#[wasm_bindgen]
|
||||
pub fn entropy(&self) -> Vec<u8> {
|
||||
self.inner.entropy().to_vec()
|
||||
}
|
||||
|
||||
/// Validate a mnemonic phrase.
|
||||
#[wasm_bindgen]
|
||||
pub fn validate(phrase: &str) -> bool {
|
||||
Bip39Mnemonic::validate(phrase, Language::English).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_mnemonic_generation() {
|
||||
let mnemonic = Mnemonic::new(24).unwrap();
|
||||
assert_eq!(mnemonic.word_count(), 24);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_mnemonic_validation() {
|
||||
assert!(Mnemonic::validate("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"));
|
||||
assert!(!Mnemonic::validate("invalid mnemonic phrase"));
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn test_mnemonic_to_seed() {
|
||||
let mnemonic = Mnemonic::from_phrase(
|
||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
).unwrap();
|
||||
let seed = mnemonic.to_seed("");
|
||||
assert_eq!(seed.len(), 64);
|
||||
}
|
||||
}
|
||||
|
|
@ -208,24 +208,42 @@ fn mainnet_bootstrap_peers() -> Vec<Multiaddr> {
|
|||
|
||||
/// Returns testnet bootstrap peers.
|
||||
///
|
||||
/// To add a new seed node:
|
||||
/// 1. Deploy a synord node on the testnet
|
||||
/// 2. Get its peer ID from the logs: "Local peer ID: 12D3KooW..."
|
||||
/// 3. Add the full address: `/dns4/<hostname>/tcp/17511/p2p/<peer_id>`
|
||||
/// # Seed Node Deployment Process
|
||||
///
|
||||
/// Example:
|
||||
/// To add a new seed node:
|
||||
///
|
||||
/// 1. **Deploy synord on a server** with a static IP/hostname:
|
||||
/// ```bash
|
||||
/// synord --network testnet --rpc-host 0.0.0.0
|
||||
/// ```
|
||||
///
|
||||
/// 2. **Get the peer ID** from startup logs:
|
||||
/// ```
|
||||
/// INFO synor_network::service: Local peer ID: 12D3KooWAbCdEfGhIjKlMnOpQrStUvWxYz123456789
|
||||
/// ```
|
||||
///
|
||||
/// 3. **Add the full multiaddr** (hostname + port + peer_id):
|
||||
/// ```text
|
||||
/// /dns4/testnet-seed1.synor.cc/tcp/17511/p2p/12D3KooWAbCdEfGhIjKlMnOpQrStUvWxYz123456789
|
||||
/// ```
|
||||
///
|
||||
/// # Runtime Configuration
|
||||
///
|
||||
/// Instead of hardcoding, operators can use the `SYNOR_BOOTSTRAP_PEERS` environment
|
||||
/// variable at the synord application level (comma-separated multiaddrs).
|
||||
fn testnet_bootstrap_peers() -> Vec<Multiaddr> {
|
||||
// Testnet bootstrap nodes - add peer IDs when seed nodes are deployed
|
||||
// Format: /dns4/<hostname>/tcp/<port>/p2p/<peer_id>
|
||||
//
|
||||
// NOTE: Seeds are configured empty here because peer IDs are only known
|
||||
// after deployment. Use SYNOR_BOOTSTRAP_PEERS env var or config file
|
||||
// to specify bootstrap peers at runtime.
|
||||
let seeds: &[&str] = &[
|
||||
// North America (seed1.synor.cc)
|
||||
// North America (seed1.synor.cc) - uncomment after deployment:
|
||||
// "/dns4/testnet-seed1.synor.cc/tcp/17511/p2p/12D3KooW...",
|
||||
// Europe (seed2.synor.cc)
|
||||
// Europe (seed2.synor.cc) - uncomment after deployment:
|
||||
// "/dns4/testnet-seed2.synor.cc/tcp/17511/p2p/12D3KooW...",
|
||||
// Asia (seed3.synor.cc)
|
||||
// Asia (seed3.synor.cc) - uncomment after deployment:
|
||||
// "/dns4/testnet-seed3.synor.cc/tcp/17511/p2p/12D3KooW...",
|
||||
];
|
||||
|
||||
|
|
|
|||
276
docs/DEPLOYMENT.md
Normal file
276
docs/DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
# Synor Testnet Deployment Guide
|
||||
|
||||
This guide covers deploying the Synor blockchain testnet, including seed nodes, validators, and supporting infrastructure.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust 1.75+ with `wasm32-unknown-unknown` target
|
||||
- Docker (optional, for containerized deployment)
|
||||
- 3+ servers with static IPs or DNS hostnames
|
||||
- Ports: 17511 (P2P), 17110 (RPC), 17111 (WebSocket)
|
||||
|
||||
---
|
||||
|
||||
## 1. Build from Source
|
||||
|
||||
```bash
|
||||
# Clone and build
|
||||
git clone https://github.com/g1-technologies/synor.git
|
||||
cd synor
|
||||
|
||||
# Build release binaries
|
||||
cargo build --release -p synord -p synor-cli
|
||||
|
||||
# Binaries will be in target/release/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Deploy Seed Nodes
|
||||
|
||||
Seed nodes are the first nodes deployed. They provide initial peer discovery for the network.
|
||||
|
||||
### 2.1 Deploy First Seed Node
|
||||
|
||||
```bash
|
||||
# On testnet-seed1.synor.cc
|
||||
./synord --network testnet \
|
||||
--data-dir /var/lib/synor \
|
||||
--rpc-host 0.0.0.0 \
|
||||
--rpc-port 17110 \
|
||||
--ws-port 17111 \
|
||||
--p2p-port 17511
|
||||
```
|
||||
|
||||
**Get the peer ID from startup logs:**
|
||||
```
|
||||
INFO synor_network::service: Local peer ID: 12D3KooWXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
Record this peer ID - you'll need it for other nodes.
|
||||
|
||||
### 2.2 Deploy Additional Seed Nodes
|
||||
|
||||
For the second and third seed nodes, configure them to bootstrap from the first:
|
||||
|
||||
```bash
|
||||
# On testnet-seed2.synor.cc
|
||||
export SYNOR_BOOTSTRAP_PEERS="/dns4/testnet-seed1.synor.cc/tcp/17511/p2p/12D3KooWXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
|
||||
./synord --network testnet \
|
||||
--data-dir /var/lib/synor \
|
||||
--rpc-host 0.0.0.0 \
|
||||
--rpc-port 17110
|
||||
```
|
||||
|
||||
After all seed nodes are running, record all peer IDs:
|
||||
|
||||
| Seed Node | Hostname | Peer ID |
|
||||
|-----------|----------|---------|
|
||||
| Seed 1 (US-East) | testnet-seed1.synor.cc | 12D3KooW... |
|
||||
| Seed 2 (EU-Frankfurt) | testnet-seed2.synor.cc | 12D3KooW... |
|
||||
| Seed 3 (Asia-Singapore) | testnet-seed3.synor.cc | 12D3KooW... |
|
||||
|
||||
### 2.3 Configure Bootstrap Peers
|
||||
|
||||
Once all seed nodes are running, update the `SYNOR_BOOTSTRAP_PEERS` environment variable on each node:
|
||||
|
||||
```bash
|
||||
export SYNOR_BOOTSTRAP_PEERS="\
|
||||
/dns4/testnet-seed1.synor.cc/tcp/17511/p2p/12D3KooW...,\
|
||||
/dns4/testnet-seed2.synor.cc/tcp/17511/p2p/12D3KooW...,\
|
||||
/dns4/testnet-seed3.synor.cc/tcp/17511/p2p/12D3KooW..."
|
||||
```
|
||||
|
||||
Or create a config file at `~/.synor/config.toml`:
|
||||
|
||||
```toml
|
||||
[p2p]
|
||||
seeds = [
|
||||
"/dns4/testnet-seed1.synor.cc/tcp/17511/p2p/12D3KooW...",
|
||||
"/dns4/testnet-seed2.synor.cc/tcp/17511/p2p/12D3KooW...",
|
||||
"/dns4/testnet-seed3.synor.cc/tcp/17511/p2p/12D3KooW...",
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Deploy Faucet
|
||||
|
||||
The faucet provides testnet tokens to developers.
|
||||
|
||||
### 3.1 Generate Faucet Wallet
|
||||
|
||||
```bash
|
||||
# Generate a new wallet for the faucet
|
||||
synor-cli wallet create --name faucet
|
||||
|
||||
# Note the address for genesis allocation
|
||||
synor-cli wallet list
|
||||
```
|
||||
|
||||
### 3.2 Configure Faucet
|
||||
|
||||
**IMPORTANT: Store the faucet private key securely!**
|
||||
|
||||
For production, use a secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.):
|
||||
|
||||
```bash
|
||||
# Development only - NOT for production
|
||||
export FAUCET_WALLET_KEY="your-private-key-here"
|
||||
export FAUCET_RPC_URL="http://testnet-seed1.synor.cc:17110"
|
||||
export FAUCET_DRIP_AMOUNT="1000000000" # 10 SYNOR (8 decimals)
|
||||
export FAUCET_COOLDOWN="3600" # 1 hour between requests
|
||||
|
||||
# Run faucet
|
||||
./faucet --port 8080
|
||||
```
|
||||
|
||||
### 3.3 Secure the Faucet Key (Production)
|
||||
|
||||
Instead of environment variables, integrate with a secrets manager:
|
||||
|
||||
```rust
|
||||
// Example: AWS Secrets Manager integration
|
||||
// See apps/faucet/src/secrets.rs for implementation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Deploy Block Explorer
|
||||
|
||||
The explorer provides blockchain visibility.
|
||||
|
||||
```bash
|
||||
# Configure explorer
|
||||
export EXPLORER_RPC_URL="http://testnet-seed1.synor.cc:17110"
|
||||
export EXPLORER_WS_URL="ws://testnet-seed1.synor.cc:17111"
|
||||
|
||||
# Run explorer backend
|
||||
./explorer --port 3000
|
||||
|
||||
# For production, restrict CORS:
|
||||
export EXPLORER_CORS_ORIGINS="https://explorer.synor.cc,https://testnet.synor.cc"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Security Checklist
|
||||
|
||||
### Network Security
|
||||
- [ ] Firewall configured (allow 17511, 17110, 17111)
|
||||
- [ ] DDoS protection enabled
|
||||
- [ ] Rate limiting configured on RPC endpoints
|
||||
|
||||
### Node Security
|
||||
- [ ] Node runs as non-root user
|
||||
- [ ] Data directory has restricted permissions
|
||||
- [ ] Log rotation configured
|
||||
|
||||
### Key Management
|
||||
- [ ] Faucet key stored in secrets manager (not env vars)
|
||||
- [ ] Validator keys stored securely
|
||||
- [ ] Key backup procedures documented
|
||||
|
||||
### CORS Configuration
|
||||
- [ ] Explorer CORS restricted to specific origins
|
||||
- [ ] Faucet CORS restricted to specific origins
|
||||
- [ ] RPC CORS configured appropriately
|
||||
|
||||
---
|
||||
|
||||
## 6. Monitoring
|
||||
|
||||
### Prometheus Metrics
|
||||
|
||||
Enable metrics on synord:
|
||||
|
||||
```bash
|
||||
./synord --network testnet --metrics --metrics-port 9090
|
||||
```
|
||||
|
||||
Scrape endpoint: `http://localhost:9090/metrics`
|
||||
|
||||
### Key Metrics to Monitor
|
||||
|
||||
- `synor_peer_count` - Number of connected peers
|
||||
- `synor_block_height` - Current block height
|
||||
- `synor_sync_progress` - Sync progress percentage
|
||||
- `synor_mempool_size` - Pending transactions
|
||||
- `synor_blocks_per_second` - Block production rate
|
||||
|
||||
---
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
### Node won't connect to peers
|
||||
|
||||
1. Check firewall rules (port 17511)
|
||||
2. Verify bootstrap peers are reachable
|
||||
3. Check peer ID format in SYNOR_BOOTSTRAP_PEERS
|
||||
|
||||
```bash
|
||||
# Test connectivity
|
||||
nc -zv testnet-seed1.synor.cc 17511
|
||||
```
|
||||
|
||||
### Node stuck syncing
|
||||
|
||||
1. Check disk space
|
||||
2. Verify network bandwidth
|
||||
3. Increase sync batch size if needed
|
||||
|
||||
### High memory usage
|
||||
|
||||
1. Adjust cache size in config
|
||||
2. Enable pruning for non-archive nodes
|
||||
|
||||
---
|
||||
|
||||
## 8. Updating Nodes
|
||||
|
||||
### Rolling Updates
|
||||
|
||||
1. Stop node gracefully: `kill -SIGTERM <pid>`
|
||||
2. Wait for shutdown (check logs)
|
||||
3. Update binary
|
||||
4. Restart node
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
For consensus changes, coordinate a hard fork:
|
||||
|
||||
1. Announce upgrade block height
|
||||
2. Deploy new binaries to all nodes
|
||||
3. All nodes must upgrade before fork height
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `SYNOR_BOOTSTRAP_PEERS` | Comma-separated bootstrap multiaddrs | `/dns4/seed1.../p2p/12D3...` |
|
||||
| `SYNOR_DATA_DIR` | Data directory path | `/var/lib/synor` |
|
||||
| `SYNOR_LOG_LEVEL` | Log verbosity | `info`, `debug`, `trace` |
|
||||
| `FAUCET_WALLET_KEY` | Faucet private key (use secrets manager!) | - |
|
||||
| `EXPLORER_CORS_ORIGINS` | Allowed CORS origins | `https://explorer.synor.cc` |
|
||||
|
||||
### Default Ports (Testnet)
|
||||
|
||||
| Service | Port |
|
||||
|---------|------|
|
||||
| P2P | 17511 |
|
||||
| RPC | 17110 |
|
||||
| WebSocket | 17111 |
|
||||
| Metrics | 9090 |
|
||||
| Faucet | 8080 |
|
||||
| Explorer | 3000 |
|
||||
|
||||
---
|
||||
|
||||
*Last updated: January 2026*
|
||||
259
docs/SECURITY_AUDIT_SCOPE.md
Normal file
259
docs/SECURITY_AUDIT_SCOPE.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# Synor Blockchain Security Audit Scope
|
||||
|
||||
This document defines the scope for external security audits of the Synor blockchain.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
**Project**: Synor - High-throughput blockDAG with quantum-resistant cryptography
|
||||
**Language**: Rust (core), TypeScript (web wallet)
|
||||
**Audit Priority**: High (pre-mainnet requirement)
|
||||
|
||||
### Key Innovations to Audit
|
||||
- GHOSTDAG consensus with PHANTOM ordering
|
||||
- Hybrid Ed25519 + Dilithium3 post-quantum signatures
|
||||
- WASM-based smart contract VM
|
||||
- Custom UTXO model with parallel validation
|
||||
|
||||
---
|
||||
|
||||
## 2. Audit Scope by Priority
|
||||
|
||||
### 2.1 Critical Priority (Must Audit)
|
||||
|
||||
#### Cryptographic Primitives (`crates/synor-crypto/`)
|
||||
|
||||
| Component | File | Focus Areas |
|
||||
|-----------|------|-------------|
|
||||
| Keypair generation | `src/keypair.rs` | Entropy sources, secure randomness |
|
||||
| Ed25519 signatures | `src/signature.rs` | Signature malleability, validation |
|
||||
| Dilithium3 PQC | `src/dilithium.rs` | Parameter validation, side-channel resistance |
|
||||
| Hybrid signatures | `src/hybrid.rs` | Composition correctness, fallback behavior |
|
||||
| Address derivation | `src/address.rs` | Bech32m encoding, checksum validation |
|
||||
| Key encryption | `src/lib.rs` | AES-256-GCM, Argon2 parameters |
|
||||
|
||||
**Specific Concerns**:
|
||||
- Verify Dilithium3 implementation matches FIPS 204 draft
|
||||
- Check for timing side-channels in signature verification
|
||||
- Validate entropy sources on different platforms
|
||||
|
||||
#### Consensus (`crates/synor-consensus/`)
|
||||
|
||||
| Component | File | Focus Areas |
|
||||
|-----------|------|-------------|
|
||||
| GHOSTDAG | `src/ghostdag.rs` | K-cluster selection, anticone calculation |
|
||||
| Ordering | `src/ordering.rs` | Topological sort, merge set computation |
|
||||
| Block validation | `src/validation.rs` | PoW verification, timestamp checks |
|
||||
| Difficulty adjustment | `src/difficulty.rs` | DAA window, manipulation resistance |
|
||||
| Finality | `src/finality.rs` | Finality depth, reorg resistance |
|
||||
|
||||
**Specific Concerns**:
|
||||
- GHOSTDAG K parameter (K=18) sufficient for 10 BPS?
|
||||
- DAA vulnerability to timestamp manipulation
|
||||
- Selfish mining / withholding attack resistance
|
||||
|
||||
#### DAG Structure (`crates/synor-dag/`)
|
||||
|
||||
| Component | File | Focus Areas |
|
||||
|-----------|------|-------------|
|
||||
| Block storage | `src/store.rs` | Hash collision handling |
|
||||
| Parent selection | `src/relations.rs` | Tip selection algorithm |
|
||||
| Blue score | `src/blue_score.rs` | Score computation correctness |
|
||||
|
||||
### 2.2 High Priority
|
||||
|
||||
#### Smart Contract VM (`crates/synor-vm/`)
|
||||
|
||||
| Component | File | Focus Areas |
|
||||
|-----------|------|-------------|
|
||||
| WASM executor | `src/executor.rs` | Sandbox escape, memory isolation |
|
||||
| Gas metering | `src/gas.rs` | Metering accuracy, DoS prevention |
|
||||
| Host functions | `src/host.rs` | Input validation, state access |
|
||||
| Memory management | `src/memory.rs` | Bounds checking, overflow |
|
||||
|
||||
**Specific Concerns**:
|
||||
- WASM sandbox escape vulnerabilities
|
||||
- Gas exhaustion attacks
|
||||
- Host function privilege escalation
|
||||
- Stack overflow in recursive contracts
|
||||
|
||||
#### Transaction Processing (`crates/synor-types/`)
|
||||
|
||||
| Component | File | Focus Areas |
|
||||
|-----------|------|-------------|
|
||||
| Transaction structure | `src/transaction.rs` | Signature verification order |
|
||||
| UTXO management | `src/utxo.rs` | Double-spend prevention |
|
||||
| Script validation | `src/script.rs` | Opcode security |
|
||||
|
||||
### 2.3 Medium Priority
|
||||
|
||||
#### Network Layer (`crates/synor-network/`)
|
||||
|
||||
| Component | File | Focus Areas |
|
||||
|-----------|------|-------------|
|
||||
| P2P protocol | `src/service.rs` | Message validation, DoS vectors |
|
||||
| Peer reputation | `src/reputation.rs` | Ban bypass, sybil resistance |
|
||||
| Rate limiting | `src/rate_limit.rs` | Token bucket implementation |
|
||||
| Sync protocol | `src/sync/` | Malicious peer handling |
|
||||
|
||||
**Specific Concerns**:
|
||||
- Eclipse attack resistance
|
||||
- Network partition detection accuracy
|
||||
- Gossipsub topic amplification
|
||||
|
||||
#### Storage (`crates/synor-storage/`)
|
||||
|
||||
| Component | File | Focus Areas |
|
||||
|-----------|------|-------------|
|
||||
| Block storage | `src/block_store.rs` | Corruption recovery |
|
||||
| UTXO set | `src/utxo_store.rs` | Consistency guarantees |
|
||||
| Pruning | `src/pruning.rs` | Data availability after prune |
|
||||
|
||||
### 2.4 Lower Priority
|
||||
|
||||
#### RPC API (`crates/synor-rpc/`)
|
||||
|
||||
| Component | Focus Areas |
|
||||
|-----------|-------------|
|
||||
| JSON-RPC handlers | Input validation, injection |
|
||||
| WebSocket | Connection limits, memory exhaustion |
|
||||
| Rate limiting | Bypass prevention |
|
||||
|
||||
#### Governance (`crates/synor-governance/`)
|
||||
|
||||
| Component | Focus Areas |
|
||||
|-----------|-------------|
|
||||
| DAO voting | Vote weight manipulation |
|
||||
| Treasury | Withdrawal limits, timelocks |
|
||||
| Proposals | Execution safety |
|
||||
|
||||
#### Node Application (`apps/synord/`)
|
||||
|
||||
| Component | Focus Areas |
|
||||
|-----------|-------------|
|
||||
| Configuration | Secrets handling |
|
||||
| Service orchestration | Race conditions |
|
||||
| CLI wallet | Key storage security |
|
||||
|
||||
---
|
||||
|
||||
## 3. Out of Scope
|
||||
|
||||
The following are **not** in scope for the initial audit:
|
||||
|
||||
- Third-party dependencies (covered by cargo-audit)
|
||||
- Web wallet frontend (separate web security audit)
|
||||
- DevOps/infrastructure security
|
||||
- Physical security of node operators
|
||||
- Social engineering vectors
|
||||
|
||||
---
|
||||
|
||||
## 4. Threat Model
|
||||
|
||||
### 4.1 Adversary Capabilities
|
||||
|
||||
| Level | Description | Mitigations Expected |
|
||||
|-------|-------------|---------------------|
|
||||
| L1 | Remote attacker, no stake | Network protocol security |
|
||||
| L2 | Minority miner (<33% hashrate) | Consensus security |
|
||||
| L3 | Majority miner (>50% hashrate) | Finality guarantees |
|
||||
| L4 | Quantum computer access | Dilithium3 signatures |
|
||||
| L5 | Nation-state (future) | Quantum + classical resistance |
|
||||
|
||||
### 4.2 Key Security Properties
|
||||
|
||||
1. **Consensus Safety**: No conflicting finalized blocks
|
||||
2. **Liveness**: Transactions confirm within reasonable time
|
||||
3. **Censorship Resistance**: No single entity can block transactions
|
||||
4. **Key Security**: Private keys protected from extraction
|
||||
5. **Quantum Resistance**: Secure against future quantum computers
|
||||
|
||||
---
|
||||
|
||||
## 5. Prior Work & References
|
||||
|
||||
### Consensus
|
||||
- PHANTOM/GHOSTDAG papers (Sompolinsky & Zohar)
|
||||
- Kaspa implementation reference
|
||||
- DAGKnight improvements
|
||||
|
||||
### Cryptography
|
||||
- FIPS 204 (Dilithium) draft specification
|
||||
- Ed25519 (RFC 8032)
|
||||
- Bech32m (BIP-350)
|
||||
- Argon2 (RFC 9106)
|
||||
|
||||
### Smart Contracts
|
||||
- WASM specification
|
||||
- Wasmtime security model
|
||||
|
||||
---
|
||||
|
||||
## 6. Deliverables Expected
|
||||
|
||||
1. **Full Report**: Detailed findings with severity ratings
|
||||
2. **Executive Summary**: Non-technical overview
|
||||
3. **Findings by Category**:
|
||||
- Critical (immediate action required)
|
||||
- High (fix before mainnet)
|
||||
- Medium (fix within 30 days)
|
||||
- Low (best practice improvements)
|
||||
- Informational (suggestions)
|
||||
|
||||
4. **Proof of Concepts**: For any exploitable vulnerabilities
|
||||
5. **Remediation Verification**: Re-check after fixes
|
||||
|
||||
---
|
||||
|
||||
## 7. Audit Timeline
|
||||
|
||||
| Phase | Duration | Description |
|
||||
|-------|----------|-------------|
|
||||
| Kickoff | 1 day | Scope review, access setup |
|
||||
| Crypto Audit | 2 weeks | synor-crypto, synor-consensus |
|
||||
| VM Audit | 1 week | synor-vm, contract security |
|
||||
| Network Audit | 1 week | synor-network, P2P protocols |
|
||||
| Report | 1 week | Findings documentation |
|
||||
| Remediation | 2 weeks | Fix implementation |
|
||||
| Verification | 3 days | Re-audit of fixes |
|
||||
|
||||
**Total**: ~7 weeks
|
||||
|
||||
---
|
||||
|
||||
## 8. Contact & Resources
|
||||
|
||||
### Repository Access
|
||||
- Main repo: `github.com/g1-technologies/synor` (private until audit)
|
||||
- Test vectors: `docs/test-vectors/`
|
||||
- Architecture docs: `docs/architecture/`
|
||||
|
||||
### Points of Contact
|
||||
- Technical Lead: [To be assigned]
|
||||
- Security Lead: [To be assigned]
|
||||
|
||||
### Development Environment
|
||||
- Rust 1.75+
|
||||
- `wasm32-unknown-unknown` target
|
||||
- All tests: `cargo test --workspace`
|
||||
- Benchmarks: `cargo bench --workspace`
|
||||
|
||||
---
|
||||
|
||||
## 9. Previous Audits
|
||||
|
||||
None (first external audit)
|
||||
|
||||
---
|
||||
|
||||
## 10. Changelog
|
||||
|
||||
| Date | Version | Changes |
|
||||
|------|---------|---------|
|
||||
| 2026-01-08 | 1.0 | Initial scope document |
|
||||
|
||||
---
|
||||
|
||||
*Prepared for Phase 7: Production Readiness*
|
||||
Loading…
Add table
Reference in a new issue