From 8b5a488c80aff25d07e9dbfd960cc3f73177054d Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Sat, 7 Mar 2026 00:31:10 +0530 Subject: [PATCH] feat: add support for bridge approval mechanism and related configurations --- Cargo.lock | 35 ++ Cargo.toml | 1 + crates/cli/Cargo.toml | 2 + crates/cli/src/main.rs | 370 +++++++++++++++++- crates/server/Cargo.toml | 1 + crates/server/src/auth.rs | 14 +- crates/server/src/bin/fishnet_sign.rs | 2 +- crates/server/src/constants.rs | 6 + crates/server/src/lib.rs | 26 ++ crates/server/src/onchain.rs | 52 ++- crates/server/src/signer.rs | 517 +++++++++++++++++++++++--- crates/server/src/state.rs | 14 +- crates/server/src/webhook.rs | 22 +- crates/types/src/config.rs | 17 + 14 files changed, 994 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 576dec6..978bc44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -763,6 +763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -897,6 +898,7 @@ dependencies = [ "ff", "generic-array", "group", + "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -1028,8 +1030,10 @@ dependencies = [ "fishnet-types", "hex", "libc", + "p256", "reqwest", "security-framework", + "security-framework-sys", "serde", "serde_json", "tokio", @@ -1055,6 +1059,7 @@ dependencies = [ "libsodium-sys", "mime_guess", "notify", + "p256", "rand 0.9.2", "reqwest", "rusqlite", @@ -2033,6 +2038,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -2101,6 +2118,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2173,6 +2199,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "primitive-types" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index c0d4b97..26c83d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ sha3 = "0.10" reqwest = { version = "0.12", features = ["json", "stream"] } rusqlite = { version = "0.32", features = ["bundled"] } k256 = { version = "0.13", features = ["ecdsa"] } +p256 = { version = "0.13", features = ["ecdsa"] } alloy-primitives = "0.8" hex = "0.4" async-trait = "0.1" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 4a2ee06..557dff1 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,6 +25,8 @@ serde_json.workspace = true clap = { version = "4.5", features = ["derive"] } libc.workspace = true dirs.workspace = true +p256.workspace = true [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3" +security-framework-sys = "2" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b060daf..b86ad70 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -5,8 +5,12 @@ use std::sync::Arc; use clap::{Args, Parser, Subcommand}; use fishnet_server::config::load_config; +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +use fishnet_server::signer::BridgeApprovalSigner; #[cfg(not(feature = "dev-seed"))] -use fishnet_server::signer::StubSigner; +use fishnet_server::signer::{ + BridgeSigner, StubSigner, random_secp256k1_secret, secp256k1_secret_is_valid, +}; use fishnet_server::{ alert::{AlertSeverity, AlertStore}, anomaly::AnomalyTracker, @@ -24,8 +28,22 @@ use fishnet_server::{ vault::{CredentialMetadata, CredentialStore}, watch::spawn_config_watcher, }; +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +use p256::ecdsa::Signature as P256Signature; +#[cfg(target_os = "macos")] +use security_framework::access_control::{ProtectionMode, SecAccessControl}; +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +use security_framework::item::{ + ItemClass, ItemSearchOptions, KeyClass, Location, Reference, SearchResult, +}; +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +use security_framework::key::{Algorithm, GenerateKeyOptions, KeyType, SecKey, Token}; #[cfg(target_os = "macos")] use security_framework::passwords::{get_generic_password, set_generic_password}; +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +use security_framework_sys::access_control::{ + kSecAccessControlPrivateKeyUsage, kSecAccessControlUserPresence, +}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; @@ -1087,7 +1105,7 @@ async fn run_server(explicit_config: Option) -> Result<(), String> { let load_baselines = !config.llm.prompt_drift.reset_baseline_on_restart; - let (config_tx, config_rx) = config_channel(config); + let (config_tx, config_rx) = config_channel(config.clone()); let config_path_for_state = config_path .clone() @@ -1127,12 +1145,9 @@ async fn run_server(explicit_config: Option) -> Result<(), String> { }; #[cfg(not(feature = "dev-seed"))] let signer: Arc = { - let s = StubSigner::new(); - eprintln!( - "[fishnet] signer initialized (mode: stub-secp256k1, address: {})", - s.status().address - ); - Arc::new(s) + let signer = build_runtime_signer_for_config(&config)?; + log_signer_summary("[fishnet] signer initialized", signer.as_ref()); + signer }; let state = AppState::new( @@ -1162,6 +1177,8 @@ async fn run_server(explicit_config: Option) -> Result<(), String> { ); spawn_baseline_config_watcher(state.config_rx.clone(), baseline_store); + #[cfg(not(feature = "dev-seed"))] + spawn_signer_config_watcher(state.clone()); { let retention_days = state.config().alerts.retention_days; @@ -1850,6 +1867,86 @@ fn spawn_baseline_config_watcher( }); } +#[cfg(not(feature = "dev-seed"))] +fn log_signer_summary(prefix: &str, signer: &dyn SignerTrait) { + let status = signer.status(); + eprintln!( + "{prefix} (mode: {}, address: {}, approval_mode: {}, approval_ttl_seconds: {})", + status.mode, + status.address, + status.approval_mode.as_deref().unwrap_or("none"), + status.approval_ttl_seconds.unwrap_or(0) + ); +} + +#[cfg(not(feature = "dev-seed"))] +fn build_runtime_signer_for_config( + config: &fishnet_types::config::FishnetConfig, +) -> Result, String> { + let base_signer = load_or_create_runtime_signer()?; + let base_signer: Arc = Arc::new(base_signer); + if !config.onchain.approval.enabled { + return Ok(base_signer); + } + + let bridge_signer = create_bridge_signer(base_signer, config.onchain.approval.ttl_seconds)?; + Ok(Arc::new(bridge_signer)) +} + +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +fn create_bridge_signer( + base_signer: Arc, + approval_ttl_seconds: u64, +) -> Result { + let approval_signer = load_or_create_bridge_approval_signer()?; + BridgeSigner::with_approval_signer(base_signer, approval_signer, approval_ttl_seconds) + .map_err(|e| format!("failed to initialize bridge signer: {e}")) +} + +#[cfg(all(not(target_os = "macos"), not(feature = "dev-seed")))] +fn create_bridge_signer( + _base_signer: Arc, + _approval_ttl_seconds: u64, +) -> Result { + Err("onchain.approval requires macOS Secure Enclave support on this build".to_string()) +} + +#[cfg(not(feature = "dev-seed"))] +fn spawn_signer_config_watcher(state: AppState) { + let mut config_rx = state.config_rx.clone(); + let initial = config_rx.borrow().clone(); + let mut prev_approval_enabled = initial.onchain.approval.enabled; + let mut prev_approval_ttl = initial.onchain.approval.ttl_seconds; + + tokio::spawn(async move { + while config_rx.changed().await.is_ok() { + let cfg = config_rx.borrow().clone(); + let approval_enabled = cfg.onchain.approval.enabled; + let approval_ttl = cfg.onchain.approval.ttl_seconds; + if approval_enabled == prev_approval_enabled && approval_ttl == prev_approval_ttl { + continue; + } + + match build_runtime_signer_for_config(cfg.as_ref()) { + Ok(new_signer) => { + log_signer_summary( + "[fishnet] signer reloaded after onchain.approval config update", + new_signer.as_ref(), + ); + state.replace_signer(new_signer).await; + prev_approval_enabled = approval_enabled; + prev_approval_ttl = approval_ttl; + } + Err(e) => { + eprintln!( + "[fishnet] warning: failed to reload signer after onchain.approval config update: {e}" + ); + } + } + } + }); +} + fn open_credential_store( path: std::path::PathBuf, explicit_password: Option<&str>, @@ -1907,6 +2004,252 @@ fn open_credential_store( )) } +#[cfg(not(feature = "dev-seed"))] +fn load_or_create_runtime_signer() -> Result { + let path = signer_key_path()?; + + if let Some(secret) = load_hex_key32_from_file( + &path, + "runtime secp256k1 signer key", + secp256k1_secret_is_valid, + )? { + return StubSigner::try_from_bytes(secret) + .map_err(|e| format!("failed to initialize runtime signer from key file: {e}")); + } + + let secret = random_secp256k1_secret(); + write_hex_key32_to_file(&path, &secret)?; + StubSigner::try_from_bytes(secret) + .map_err(|e| format!("failed to initialize runtime signer: {e}")) +} + +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +struct SecureEnclaveBridgeApprovalSigner { + private_key: SecKey, + public_key_hex: String, + mode: String, +} + +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +impl SecureEnclaveBridgeApprovalSigner { + fn from_private_key(private_key: SecKey, mode: String) -> Result { + let public_key = private_key + .public_key() + .ok_or_else(|| "failed to derive public key from Secure Enclave key".to_string())?; + let public_bytes = public_key + .external_representation() + .ok_or_else(|| "failed to export Secure Enclave public key".to_string())? + .to_vec(); + if public_bytes.len() != 65 || public_bytes.first().copied() != Some(0x04) { + return Err(format!( + "unexpected Secure Enclave public key format (expected 65-byte uncompressed sec1, got {} bytes)", + public_bytes.len() + )); + } + Ok(Self { + private_key, + public_key_hex: format!("0x{}", hex::encode(public_bytes)), + mode, + }) + } +} + +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +impl BridgeApprovalSigner for SecureEnclaveBridgeApprovalSigner { + fn mode(&self) -> &str { + &self.mode + } + + fn public_key_hex(&self) -> &str { + &self.public_key_hex + } + + fn sign_prehash( + &self, + prehash: &[u8; 32], + ) -> Result { + let der = self + .private_key + .create_signature(Algorithm::ECDSASignatureDigestX962, prehash) + .map_err(|e| { + fishnet_server::signer::SignerError::ApprovalFailed(format!( + "Secure Enclave signing failed: {e}" + )) + })?; + P256Signature::from_der(&der).map_err(|e| { + fishnet_server::signer::SignerError::ApprovalFailed(format!( + "invalid DER signature from Secure Enclave: {e}" + )) + }) + } +} + +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +fn bridge_approval_secure_enclave_label() -> String { + let (service, account) = bridge_approval_keychain_service_account(); + format!("fishnet.bridge.approval.se.v1:{service}:{account}") +} + +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +fn find_bridge_approval_secure_enclave_key(label: &str) -> Result, String> { + let mut search = ItemSearchOptions::new(); + search + .class(ItemClass::key()) + .key_class(KeyClass::private()) + .label(label) + .load_refs(true) + .limit(1); + + let results = match search.search() { + Ok(results) => results, + Err(e) => { + // errSecItemNotFound + if e.code() == -25300 { + return Ok(None); + } + return Err(format!( + "failed to query Secure Enclave bridge key from keychain: {e}" + )); + } + }; + + for result in results { + if let SearchResult::Ref(Reference::Key(key)) = result { + return Ok(Some(key)); + } + } + + Ok(None) +} + +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +fn create_bridge_approval_secure_enclave_key(label: &str) -> Result { + let access_flags = kSecAccessControlPrivateKeyUsage | kSecAccessControlUserPresence; + let build_access_control = || { + SecAccessControl::create_with_protection( + Some(ProtectionMode::AccessibleWhenUnlockedThisDeviceOnly), + access_flags, + ) + .map_err(|e| format!("failed to configure Secure Enclave access control: {e}")) + }; + + // Try persistent key first (stored in login keychain). + let mut options = GenerateKeyOptions::default(); + options + .set_key_type(KeyType::ec_sec_prime_random()) + .set_size_in_bits(256) + .set_label(label) + .set_token(Token::SecureEnclave) + .set_location(Location::DefaultFileKeychain) + .set_access_control(build_access_control()?); + + match SecKey::new(&options) { + Ok(key) => Ok(key), + Err(primary_err) => { + // Fallback for unsigned/dev contexts where persistent Secure Enclave storage is denied. + let mut session_options = GenerateKeyOptions::default(); + session_options + .set_key_type(KeyType::ec_sec_prime_random()) + .set_size_in_bits(256) + .set_label(label) + .set_token(Token::SecureEnclave) + .set_access_control(build_access_control()?); + SecKey::new(&session_options).map_err(|fallback_err| { + format!( + "failed to create Secure Enclave bridge approval key (persistent attempt failed: {primary_err}; session-only fallback failed: {fallback_err})" + ) + }) + } + } +} + +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +fn secure_enclave_mode_for_key(label: &str) -> String { + match find_bridge_approval_secure_enclave_key(label) { + Ok(Some(_)) => "p256-secure-enclave-bridge".to_string(), + _ => "p256-secure-enclave-bridge-session".to_string(), + } +} + +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +fn load_or_create_bridge_approval_signer() -> Result, String> { + let label = bridge_approval_secure_enclave_label(); + let (private_key, mode) = match find_bridge_approval_secure_enclave_key(&label)? { + Some(existing) => (existing, "p256-secure-enclave-bridge".to_string()), + None => { + let key = create_bridge_approval_secure_enclave_key(&label)?; + // If key lookup still fails, we're using a session-only key. + let mode = secure_enclave_mode_for_key(&label); + (key, mode) + } + }; + + if mode == "p256-secure-enclave-bridge-session" { + eprintln!( + "[fishnet] warning: Secure Enclave key is session-only (not persisted in keychain; restart rotates approval key)" + ); + } + + let signer = SecureEnclaveBridgeApprovalSigner::from_private_key(private_key, mode)?; + Ok(Arc::new(signer)) +} + +#[cfg(not(feature = "dev-seed"))] +fn signer_key_path() -> Result { + fishnet_server::constants::default_data_file(fishnet_server::constants::SIGNER_KEY_FILE) + .ok_or_else(|| "could not determine signer key path".to_string()) +} + +#[cfg(not(feature = "dev-seed"))] +fn load_hex_key32_from_file( + path: &Path, + label: &str, + validator: fn(&[u8; 32]) -> bool, +) -> Result, String> { + if !path.exists() { + return Ok(None); + } + + let raw = std::fs::read_to_string(path) + .map_err(|e| format!("failed to read {label} file '{}': {e}", path.display()))?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(format!("{label} file '{}' is empty", path.display())); + } + + let key = parse_hex_key32(trimmed, label, validator)?; + Ok(Some(key)) +} + +#[cfg(not(feature = "dev-seed"))] +fn write_hex_key32_to_file(path: &Path, key: &[u8; 32]) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create key directory '{}': {e}", parent.display()))?; + } + std::fs::write(path, format!("{}\n", hex::encode(key))) + .map_err(|e| format!("failed to write key file '{}': {e}", path.display()))?; + set_owner_only_file_permissions(path)?; + Ok(()) +} + +#[cfg(not(feature = "dev-seed"))] +fn parse_hex_key32( + raw: &str, + label: &str, + validator: fn(&[u8; 32]) -> bool, +) -> Result<[u8; 32], String> { + let stripped = raw.strip_prefix("0x").unwrap_or(raw); + let bytes = hex::decode(stripped).map_err(|e| format!("{label} is not valid hex: {e}"))?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| format!("{label} must be exactly 32 bytes"))?; + if !validator(&arr) { + return Err(format!("{label} is not valid for expected curve")); + } + Ok(arr) +} + #[cfg(target_os = "macos")] const KEYCHAIN_DERIVED_KEY_PREFIX: &str = "derived_hex:v1:"; @@ -2000,6 +2343,17 @@ fn store_keychain_value(value: &str) -> Result<(), String> { .map_err(|e| format!("failed to write macOS keychain item: {e}")) } +#[cfg(all(target_os = "macos", not(feature = "dev-seed")))] +fn bridge_approval_keychain_service_account() -> (String, String) { + let service = + std::env::var(fishnet_server::constants::ENV_FISHNET_BRIDGE_APPROVAL_KEYCHAIN_SERVICE) + .unwrap_or_else(|_| "fishnet".to_string()); + let account = + std::env::var(fishnet_server::constants::ENV_FISHNET_BRIDGE_APPROVAL_KEYCHAIN_ACCOUNT) + .unwrap_or_else(|_| "bridge_approval_p256".to_string()); + (service, account) +} + #[cfg(all(test, target_os = "macos"))] mod tests { use super::*; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 2696191..283b4a5 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -28,6 +28,7 @@ sha3.workspace = true reqwest.workspace = true rusqlite.workspace = true k256.workspace = true +p256.workspace = true alloy-primitives.workspace = true hex.workspace = true async-trait.workspace = true diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index c9805cf..1234751 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -5,6 +5,7 @@ use axum::{ response::{IntoResponse, Response}, }; use fishnet_types::auth::*; +use zeroize::Zeroizing; use crate::state::AppState; @@ -29,6 +30,9 @@ pub async fn status(State(state): State, headers: HeaderMap) -> impl I } pub async fn setup(State(state): State, Json(req): Json) -> Response { + let password = Zeroizing::new(req.password); + let confirm = Zeroizing::new(req.confirm); + match state.password_store.is_initialized() { Ok(true) => { return ( @@ -53,7 +57,7 @@ pub async fn setup(State(state): State, Json(req): Json) _ => {} } - if req.password != req.confirm { + if password.as_str() != confirm.as_str() { return ( StatusCode::BAD_REQUEST, Json(ErrorResponse { @@ -64,7 +68,7 @@ pub async fn setup(State(state): State, Json(req): Json) .into_response(); } - if req.password.len() < 8 { + if password.len() < 8 { return ( StatusCode::BAD_REQUEST, Json(ErrorResponse { @@ -75,7 +79,7 @@ pub async fn setup(State(state): State, Json(req): Json) .into_response(); } - match state.password_store.setup(&req.password) { + match state.password_store.setup(password.as_str()) { Ok(()) => Json(SetupResponse { success: true, message: "password configured successfully".to_string(), @@ -93,6 +97,8 @@ pub async fn setup(State(state): State, Json(req): Json) } pub async fn login(State(state): State, Json(req): Json) -> Response { + let password = Zeroizing::new(req.password); + if let Err(retry_after) = state.rate_limiter.check_rate_limit().await { return ( StatusCode::TOO_MANY_REQUESTS, @@ -106,7 +112,7 @@ pub async fn login(State(state): State, Json(req): Json) state.rate_limiter.progressive_delay().await; - match state.password_store.verify(&req.password) { + match state.password_store.verify(password.as_str()) { Ok(true) => { state.rate_limiter.reset().await; let session = state.session_store.create().await; diff --git a/crates/server/src/bin/fishnet_sign.rs b/crates/server/src/bin/fishnet_sign.rs index ae9c890..bd62eaf 100644 --- a/crates/server/src/bin/fishnet_sign.rs +++ b/crates/server/src/bin/fishnet_sign.rs @@ -10,7 +10,7 @@ /// Output (two lines): /// 0x /// 0x<65_byte_signature_hex> -use fishnet_server::signer::{FishnetPermit, StubSigner, SignerTrait}; +use fishnet_server::signer::{FishnetPermit, SignerTrait, StubSigner}; #[tokio::main] async fn main() { diff --git a/crates/server/src/constants.rs b/crates/server/src/constants.rs index f992ad7..d5da909 100644 --- a/crates/server/src/constants.rs +++ b/crates/server/src/constants.rs @@ -13,6 +13,8 @@ pub const BASELINES_FILE: &str = "baselines.json"; pub const VAULT_DB_FILE: &str = "vault.db"; pub const CONFIG_FILE: &str = "fishnet.toml"; pub const CONFIG_TEMP_EXT: &str = "toml.tmp"; +pub const SIGNER_KEY_FILE: &str = "signer_secp256k1.hex"; +pub const BRIDGE_APPROVAL_KEY_FILE: &str = "bridge_approval_p256.hex"; pub const SESSION_TTL_HOURS: i64 = 4; pub const MAX_SESSIONS: usize = 5; @@ -46,6 +48,10 @@ pub const ENV_FISHNET_STORE_DERIVED_KEY_IN_KEYCHAIN: &str = "FISHNET_STORE_DERIV pub const ENV_FISHNET_KEYCHAIN_SERVICE: &str = "FISHNET_KEYCHAIN_SERVICE"; pub const ENV_FISHNET_KEYCHAIN_ACCOUNT: &str = "FISHNET_KEYCHAIN_ACCOUNT"; pub const ENV_FISHNET_VAULT_REQUIRE_MLOCK: &str = "FISHNET_VAULT_REQUIRE_MLOCK"; +pub const ENV_FISHNET_BRIDGE_APPROVAL_KEYCHAIN_SERVICE: &str = + "FISHNET_BRIDGE_APPROVAL_KEYCHAIN_SERVICE"; +pub const ENV_FISHNET_BRIDGE_APPROVAL_KEYCHAIN_ACCOUNT: &str = + "FISHNET_BRIDGE_APPROVAL_KEYCHAIN_ACCOUNT"; #[cfg(target_os = "macos")] const DEFAULT_SYSTEM_DATA_DIR: &str = "/Library/Application Support/Fishnet"; diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index a360cfe..24ef31b 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -2341,6 +2341,32 @@ mod tests { assert!(body["signature"].as_str().unwrap().starts_with("0x")); } + #[tokio::test] + async fn test_onchain_approval_enabled_fails_closed_without_approval_proof() { + let dir = tempfile::tempdir().unwrap(); + let mut config = onchain_config_enabled(); + config.onchain.approval.enabled = true; + let (state, _tx) = test_state_with_config(dir.path(), config); + let app = create_router(state); + let token = setup_and_login(&app).await; + + let resp = app + .clone() + .oneshot(onchain_submit_request( + "0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", + &valid_calldata(), + "0", + 8453, + &token, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = body_json(resp.into_body()).await; + assert_eq!(body["status"], "error"); + assert_eq!(body["error"], "approval_unavailable"); + } + #[tokio::test] async fn test_onchain_unknown_contract_denied() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/server/src/onchain.rs b/crates/server/src/onchain.rs index cae4797..acb2f3d 100644 --- a/crates/server/src/onchain.rs +++ b/crates/server/src/onchain.rs @@ -329,7 +329,8 @@ pub async fn submit_handler( .into_response(); } - let signer_info = state.signer.status(); + let signer = state.current_signer().await; + let signer_info = signer.status(); let wallet_hex = signer_info .address .strip_prefix("0x") @@ -428,8 +429,8 @@ pub async fn submit_handler( verifying_contract: config.onchain.permits.verifying_contract.clone(), }; - let signature = match state.signer.sign_permit(&permit).await { - Ok(sig) => format!("0x{}", hex::encode(&sig)), + let signed_permit = match signer.sign_permit_with_proof(&permit).await { + Ok(signed) => signed, Err(e) => { eprintln!("[fishnet] signing failed: {e}"); log_onchain_audit_decision( @@ -452,6 +453,31 @@ pub async fn submit_handler( } }; + if config.onchain.approval.enabled && signed_permit.approval.is_none() { + let reason = "approval is enabled but signer did not return approval proof".to_string(); + eprintln!("[fishnet] signing failed: {reason}"); + log_onchain_audit_decision( + &state, + &audit_ctx, + "denied", + Some(reason.clone()), + None, + None, + ) + .await; + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "status": "error", + "error": "approval_unavailable", + "reason": reason + })), + ) + .into_response(); + } + + let signature = format!("0x{}", hex::encode(&signed_permit.signature)); + let permit_hash_str = format!("0x{}", hex::encode(Keccak256::digest(signature.as_bytes()))); let tx_value: f64 = req.value.parse().unwrap_or(0.0); let _ = state @@ -500,6 +526,7 @@ pub async fn submit_handler( "verifyingContract": permit.verifying_contract, }, "signature": signature, + "approval": signed_permit.approval, })) .into_response() } @@ -589,6 +616,10 @@ pub async fn get_config(State(state): State) -> impl IntoResponse { "require_policy_hash": config.onchain.permits.require_policy_hash, "verifying_contract": config.onchain.permits.verifying_contract, }, + "approval": { + "enabled": config.onchain.approval.enabled, + "ttl_seconds": config.onchain.approval.ttl_seconds, + }, "whitelist": config.onchain.whitelist, })) } @@ -605,6 +636,8 @@ pub struct UpdateOnchainConfigRequest { pub expiry_seconds: Option, pub require_policy_hash: Option, pub verifying_contract: Option, + pub approval_enabled: Option, + pub approval_ttl_seconds: Option, pub whitelist: Option>>, } @@ -632,6 +665,9 @@ pub async fn update_config( if matches!(req.expiry_seconds, Some(v) if !(60..=3600).contains(&v)) { errors.push("expiry_seconds must be between 60 and 3600"); } + if matches!(req.approval_ttl_seconds, Some(v) if !(10..=900).contains(&v)) { + errors.push("approval_ttl_seconds must be between 10 and 900"); + } if let Some(ref v) = req.verifying_contract { let hex = v.strip_prefix("0x").unwrap_or(v); if !v.is_empty() && (hex.len() != 40 || !hex.chars().all(|c| c.is_ascii_hexdigit())) { @@ -682,6 +718,12 @@ pub async fn update_config( if let Some(v) = req.verifying_contract { updated.onchain.permits.verifying_contract = v; } + if let Some(v) = req.approval_enabled { + updated.onchain.approval.enabled = v; + } + if let Some(v) = req.approval_ttl_seconds { + updated.onchain.approval.ttl_seconds = v; + } if let Some(v) = req.whitelist { updated.onchain.whitelist = v; } @@ -703,6 +745,10 @@ pub async fn update_config( "require_policy_hash": updated.onchain.permits.require_policy_hash, "verifying_contract": updated.onchain.permits.verifying_contract, }, + "approval": { + "enabled": updated.onchain.approval.enabled, + "ttl_seconds": updated.onchain.approval.ttl_seconds, + }, "whitelist": updated.onchain.whitelist, })) .into_response(), diff --git a/crates/server/src/signer.rs b/crates/server/src/signer.rs index e61764c..e098c5a 100644 --- a/crates/server/src/signer.rs +++ b/crates/server/src/signer.rs @@ -1,10 +1,18 @@ +use std::collections::HashMap; +use std::sync::Arc; + use async_trait::async_trait; use axum::Json; use axum::extract::State; use axum::response::IntoResponse; use k256::ecdsa::{RecoveryId, SigningKey, signature::hazmat::PrehashSigner}; +use p256::ecdsa::signature::hazmat::PrehashVerifier; +use p256::ecdsa::{ + Signature as P256Signature, SigningKey as P256SigningKey, VerifyingKey as P256VerifyingKey, +}; use serde::Serialize; use sha3::{Digest, Keccak256}; +use tokio::sync::Mutex; use crate::state::AppState; @@ -12,6 +20,12 @@ use crate::state::AppState; pub struct SignerInfo { pub mode: String, pub address: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub approval_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub approval_public_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub approval_ttl_seconds: Option, } #[derive(Debug, Clone, Serialize)] @@ -33,10 +47,28 @@ pub enum SignerError { SigningFailed(String), #[error("invalid permit: {0}")] InvalidPermit(String), + #[error("approval failed: {0}")] + ApprovalFailed(String), } const UINT48_MAX: u64 = (1u64 << 48) - 1; +#[derive(Debug, Clone, Serialize)] +pub struct ApprovalProof { + pub mode: String, + pub issued_at: u64, + pub expires_at: u64, + pub payload_hash: String, + pub public_key: String, + pub signature: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SignedPermit { + pub signature: Vec, + pub approval: Option, +} + impl FishnetPermit { pub fn validate(&self) -> Result<(), SignerError> { Self::validate_address(&self.wallet, "wallet")?; @@ -46,43 +78,42 @@ impl FishnetPermit { if let Some(ref ph) = self.policy_hash { Self::validate_bytes32(ph, "policy_hash")?; } - alloy_primitives::U256::from_str_radix(&self.value, 10) - .map_err(|_| SignerError::InvalidPermit( - format!("value '{}' is not a valid uint256", self.value) - ))?; + alloy_primitives::U256::from_str_radix(&self.value, 10).map_err(|_| { + SignerError::InvalidPermit(format!("value '{}' is not a valid uint256", self.value)) + })?; if self.expiry > UINT48_MAX { - return Err(SignerError::InvalidPermit( - format!( - "expiry {} exceeds uint48 max ({}), would be truncated by Solidity", - self.expiry, UINT48_MAX - ) - )); + return Err(SignerError::InvalidPermit(format!( + "expiry {} exceeds uint48 max ({}), would be truncated by Solidity", + self.expiry, UINT48_MAX + ))); } Ok(()) } fn validate_address(field: &str, name: &str) -> Result<(), SignerError> { let stripped = field.strip_prefix("0x").unwrap_or(field); - let bytes = hex::decode(stripped).map_err(|_| + let bytes = hex::decode(stripped).map_err(|_| { SignerError::InvalidPermit(format!("{name} '{}' is not valid hex", field)) - )?; + })?; if bytes.len() != 20 { - return Err(SignerError::InvalidPermit( - format!("{name} must be 20 bytes, got {}", bytes.len()) - )); + return Err(SignerError::InvalidPermit(format!( + "{name} must be 20 bytes, got {}", + bytes.len() + ))); } Ok(()) } fn validate_bytes32(field: &str, name: &str) -> Result<(), SignerError> { let stripped = field.strip_prefix("0x").unwrap_or(field); - let bytes = hex::decode(stripped).map_err(|_| + let bytes = hex::decode(stripped).map_err(|_| { SignerError::InvalidPermit(format!("{name} '{}' is not valid hex", field)) - )?; + })?; if bytes.len() != 32 { - return Err(SignerError::InvalidPermit( - format!("{name} must be 32 bytes, got {}", bytes.len()) - )); + return Err(SignerError::InvalidPermit(format!( + "{name} must be 32 bytes, got {}", + bytes.len() + ))); } Ok(()) } @@ -91,6 +122,16 @@ impl FishnetPermit { #[async_trait] pub trait SignerTrait: Send + Sync { async fn sign_permit(&self, permit: &FishnetPermit) -> Result, SignerError>; + async fn sign_permit_with_proof( + &self, + permit: &FishnetPermit, + ) -> Result { + let signature = self.sign_permit(permit).await?; + Ok(SignedPermit { + signature, + approval: None, + }) + } fn status(&self) -> SignerInfo; } @@ -107,22 +148,31 @@ impl Default for StubSigner { impl StubSigner { pub fn new() -> Self { - let secret_bytes: [u8; 32] = rand::random(); - Self::from_bytes(secret_bytes) + loop { + let secret_bytes: [u8; 32] = rand::random(); + if let Ok(signer) = Self::try_from_bytes(secret_bytes) { + return signer; + } + } } pub fn from_bytes(secret_bytes: [u8; 32]) -> Self { - let signing_key = - SigningKey::from_bytes((&secret_bytes).into()).expect("valid 32-byte key"); + Self::try_from_bytes(secret_bytes).expect("valid 32-byte key") + } + + pub fn try_from_bytes(secret_bytes: [u8; 32]) -> Result { + let signing_key = SigningKey::from_bytes((&secret_bytes).into()).map_err(|e| { + SignerError::SigningFailed(format!("invalid secp256k1 signing key bytes: {e}")) + })?; let verifying_key = signing_key.verifying_key(); let public_key_bytes = verifying_key.to_encoded_point(false); let hash = Keccak256::digest(&public_key_bytes.as_bytes()[1..]); let mut address = [0u8; 20]; address.copy_from_slice(&hash[12..]); - Self { + Ok(Self { signing_key, address, - } + }) } fn eip712_hash(&self, permit: &FishnetPermit) -> [u8; 32] { @@ -142,7 +192,10 @@ impl StubSigner { domain_data.extend_from_slice(&chain_id_bytes); let vc_bytes = hex::decode( - permit.verifying_contract.strip_prefix("0x").unwrap_or(&permit.verifying_contract), + permit + .verifying_contract + .strip_prefix("0x") + .unwrap_or(&permit.verifying_contract), ) .unwrap_or_default(); let mut vc_padded = [0u8; 32]; @@ -159,7 +212,8 @@ impl StubSigner { let mut struct_data = Vec::new(); struct_data.extend_from_slice(&permit_type_hash); - let wallet_bytes = hex::decode(permit.wallet.strip_prefix("0x").unwrap_or(&permit.wallet)).unwrap_or_default(); + let wallet_bytes = hex::decode(permit.wallet.strip_prefix("0x").unwrap_or(&permit.wallet)) + .unwrap_or_default(); let mut wallet_padded = [0u8; 32]; if wallet_bytes.len() <= 32 { wallet_padded[32 - wallet_bytes.len()..].copy_from_slice(&wallet_bytes); @@ -176,7 +230,8 @@ impl StubSigner { expiry_bytes[24..].copy_from_slice(&permit.expiry.to_be_bytes()); struct_data.extend_from_slice(&expiry_bytes); - let target_bytes = hex::decode(permit.target.strip_prefix("0x").unwrap_or(&permit.target)).unwrap_or_default(); + let target_bytes = hex::decode(permit.target.strip_prefix("0x").unwrap_or(&permit.target)) + .unwrap_or_default(); let mut target_padded = [0u8; 32]; if target_bytes.len() <= 32 { target_padded[32 - target_bytes.len()..].copy_from_slice(&target_bytes); @@ -187,7 +242,13 @@ impl StubSigner { .unwrap_or(alloy_primitives::U256::ZERO); struct_data.extend_from_slice(&value_u256.to_be_bytes::<32>()); - let calldata_hash_bytes = hex::decode(permit.calldata_hash.strip_prefix("0x").unwrap_or(&permit.calldata_hash)).unwrap_or_default(); + let calldata_hash_bytes = hex::decode( + permit + .calldata_hash + .strip_prefix("0x") + .unwrap_or(&permit.calldata_hash), + ) + .unwrap_or_default(); let mut calldata_padded = [0u8; 32]; if calldata_hash_bytes.len() == 32 { calldata_padded.copy_from_slice(&calldata_hash_bytes); @@ -242,6 +303,267 @@ impl SignerTrait for StubSigner { SignerInfo { mode: "stub-secp256k1".to_string(), address: format!("0x{}", hex::encode(self.address)), + approval_mode: None, + approval_public_key: None, + approval_ttl_seconds: None, + } + } +} + +pub fn secp256k1_secret_is_valid(secret: &[u8; 32]) -> bool { + SigningKey::from_bytes(secret.into()).is_ok() +} + +pub fn random_secp256k1_secret() -> [u8; 32] { + loop { + let secret: [u8; 32] = rand::random(); + if secp256k1_secret_is_valid(&secret) { + return secret; + } + } +} + +pub fn bridge_approval_secret_is_valid(secret: &[u8; 32]) -> bool { + P256SigningKey::from_bytes(secret.into()).is_ok() +} + +pub fn random_bridge_approval_secret() -> [u8; 32] { + loop { + let secret: [u8; 32] = rand::random(); + if bridge_approval_secret_is_valid(&secret) { + return secret; + } + } +} + +pub trait BridgeApprovalSigner: Send + Sync { + fn mode(&self) -> &str; + fn public_key_hex(&self) -> &str; + fn sign_prehash(&self, prehash: &[u8; 32]) -> Result; +} + +struct LocalBridgeApprovalSigner { + signing_key: P256SigningKey, + public_key_hex: String, +} + +impl LocalBridgeApprovalSigner { + fn try_new(approval_secret: [u8; 32]) -> Result { + let signing_key = P256SigningKey::from_bytes((&approval_secret).into()) + .map_err(|e| SignerError::ApprovalFailed(format!("invalid approval key bytes: {e}")))?; + let verifying_key = P256VerifyingKey::from(&signing_key); + let public_key_hex = format!( + "0x{}", + hex::encode(verifying_key.to_encoded_point(false).as_bytes()) + ); + + Ok(Self { + signing_key, + public_key_hex, + }) + } +} + +impl BridgeApprovalSigner for LocalBridgeApprovalSigner { + fn mode(&self) -> &str { + "p256-local-bridge" + } + + fn public_key_hex(&self) -> &str { + &self.public_key_hex + } + + fn sign_prehash(&self, prehash: &[u8; 32]) -> Result { + self.signing_key + .sign_prehash(prehash) + .map_err(|e| SignerError::ApprovalFailed(format!("approval signing failed: {e}"))) + } +} + +pub struct BridgeSigner { + inner: Arc, + approval_signer: Arc, + approval_verifying_key: P256VerifyingKey, + approval_public_key_hex: String, + approval_mode: String, + approval_ttl_seconds: u64, + replay_cache: Mutex>, +} + +impl BridgeSigner { + fn parse_public_key_hex(public_key_hex: &str) -> Result { + let stripped = public_key_hex + .strip_prefix("0x") + .unwrap_or(public_key_hex) + .trim(); + let bytes = hex::decode(stripped).map_err(|e| { + SignerError::ApprovalFailed(format!("approval public key is not valid hex: {e}")) + })?; + P256VerifyingKey::from_sec1_bytes(&bytes).map_err(|e| { + SignerError::ApprovalFailed(format!("approval public key is not valid sec1 bytes: {e}")) + }) + } + + pub fn with_approval_signer( + inner: Arc, + approval_signer: Arc, + approval_ttl_seconds: u64, + ) -> Result { + if approval_ttl_seconds == 0 { + return Err(SignerError::ApprovalFailed( + "approval_ttl_seconds must be greater than zero".to_string(), + )); + } + + let approval_mode = approval_signer.mode().to_string(); + let approval_public_key_hex = approval_signer.public_key_hex().to_string(); + let approval_verifying_key = Self::parse_public_key_hex(&approval_public_key_hex)?; + + Ok(Self { + inner, + approval_signer, + approval_verifying_key, + approval_public_key_hex, + approval_mode, + approval_ttl_seconds, + replay_cache: Mutex::new(HashMap::new()), + }) + } + + pub fn new( + inner: Arc, + approval_secret: [u8; 32], + approval_ttl_seconds: u64, + ) -> Result { + let local = LocalBridgeApprovalSigner::try_new(approval_secret)?; + Self::with_approval_signer(inner, Arc::new(local), approval_ttl_seconds) + } + + fn stable_permit_payload(&self, permit: &FishnetPermit) -> Vec { + let mut payload = Vec::with_capacity(512); + payload.extend_from_slice(permit.wallet.to_ascii_lowercase().as_bytes()); + payload.extend_from_slice(b"|"); + payload.extend_from_slice(permit.chain_id.to_string().as_bytes()); + payload.extend_from_slice(b"|"); + payload.extend_from_slice(permit.nonce.to_string().as_bytes()); + payload.extend_from_slice(b"|"); + payload.extend_from_slice(permit.expiry.to_string().as_bytes()); + payload.extend_from_slice(b"|"); + payload.extend_from_slice(permit.target.to_ascii_lowercase().as_bytes()); + payload.extend_from_slice(b"|"); + payload.extend_from_slice(permit.value.as_bytes()); + payload.extend_from_slice(b"|"); + payload.extend_from_slice(permit.calldata_hash.to_ascii_lowercase().as_bytes()); + payload.extend_from_slice(b"|"); + payload.extend_from_slice( + permit + .policy_hash + .as_deref() + .unwrap_or("0x0000000000000000000000000000000000000000000000000000000000000000") + .to_ascii_lowercase() + .as_bytes(), + ); + payload.extend_from_slice(b"|"); + payload.extend_from_slice(permit.verifying_contract.to_ascii_lowercase().as_bytes()); + payload + } + + fn intent_hash(&self, permit: &FishnetPermit, issued_at: u64, expires_at: u64) -> [u8; 32] { + let stable_payload = self.stable_permit_payload(permit); + let mut payload = Vec::with_capacity(stable_payload.len() + 96); + payload.extend_from_slice(b"fishnet-bridge-approval-v1|"); + payload.extend_from_slice(&stable_payload); + payload.extend_from_slice(b"|"); + payload.extend_from_slice(issued_at.to_string().as_bytes()); + payload.extend_from_slice(b"|"); + payload.extend_from_slice(expires_at.to_string().as_bytes()); + + let digest = Keccak256::digest(&payload); + let mut hash = [0u8; 32]; + hash.copy_from_slice(&digest); + hash + } + + fn replay_key(&self, permit: &FishnetPermit) -> [u8; 32] { + let stable_payload = self.stable_permit_payload(permit); + let mut payload = Vec::with_capacity(stable_payload.len() + 40); + payload.extend_from_slice(b"fishnet-bridge-replay-v1|"); + payload.extend_from_slice(&stable_payload); + let digest = Keccak256::digest(&payload); + let mut hash = [0u8; 32]; + hash.copy_from_slice(&digest); + hash + } +} + +#[async_trait] +impl SignerTrait for BridgeSigner { + async fn sign_permit(&self, permit: &FishnetPermit) -> Result, SignerError> { + let signed = self.sign_permit_with_proof(permit).await?; + Ok(signed.signature) + } + + async fn sign_permit_with_proof( + &self, + permit: &FishnetPermit, + ) -> Result { + permit.validate()?; + let issued_at = chrono::Utc::now().timestamp() as u64; + let expires_at = issued_at + self.approval_ttl_seconds; + let replay_key = self.replay_key(permit); + let intent_hash = self.intent_hash(permit, issued_at, expires_at); + + { + let mut replay_cache = self.replay_cache.lock().await; + replay_cache.retain(|_, expiry| *expiry >= issued_at); + if replay_cache.contains_key(&replay_key) { + return Err(SignerError::ApprovalFailed( + "approval replay blocked for identical permit payload".to_string(), + )); + } + replay_cache.insert(replay_key, expires_at); + } + + let approval_sig = self.approval_signer.sign_prehash(&intent_hash)?; + + self.approval_verifying_key + .verify_prehash(&intent_hash, &approval_sig) + .map_err(|e| { + SignerError::ApprovalFailed(format!("approval verification failed: {e}")) + })?; + + let signature = match self.inner.sign_permit(permit).await { + Ok(sig) => sig, + Err(e) => { + let mut replay_cache = self.replay_cache.lock().await; + replay_cache.remove(&replay_key); + return Err(e); + } + }; + + let proof = ApprovalProof { + mode: self.approval_mode.clone(), + issued_at, + expires_at, + payload_hash: format!("0x{}", hex::encode(intent_hash)), + public_key: self.approval_public_key_hex.clone(), + signature: format!("0x{}", hex::encode(approval_sig.to_bytes())), + }; + + Ok(SignedPermit { + signature, + approval: Some(proof), + }) + } + + fn status(&self) -> SignerInfo { + let inner = self.inner.status(); + SignerInfo { + mode: format!("bridge+{}", inner.mode), + address: inner.address, + approval_mode: Some(self.approval_mode.clone()), + approval_public_key: Some(self.approval_public_key_hex.clone()), + approval_ttl_seconds: Some(self.approval_ttl_seconds), } } } @@ -250,13 +572,16 @@ impl SignerTrait for StubSigner { mod tests { use super::*; use k256::ecdsa::VerifyingKey; + use std::sync::Arc; /// Deterministic signer from a known private key for reproducible tests. fn test_signer() -> StubSigner { // Anvil account #1: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d - let key_bytes: [u8; 32] = hex::decode( - "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" - ).unwrap().try_into().unwrap(); + let key_bytes: [u8; 32] = + hex::decode("59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d") + .unwrap() + .try_into() + .unwrap(); StubSigner::from_bytes(key_bytes) } @@ -269,9 +594,12 @@ mod tests { target: "0x2222222222222222222222222222222222222222".to_string(), value: "0".to_string(), // keccak256(0xdeadbeef) - calldata_hash: "0xd4fd4e189132273036449fc9e11198c739161b4c0116a9a2dccdfa1c492006f1".to_string(), + calldata_hash: "0xd4fd4e189132273036449fc9e11198c739161b4c0116a9a2dccdfa1c492006f1" + .to_string(), // keccak256("policy-v1") - policy_hash: Some("0xb2590ce26adfc7f2814ca4b72880660e2369b23d16ffb446362696d8186d6348".to_string()), + policy_hash: Some( + "0xb2590ce26adfc7f2814ca4b72880660e2369b23d16ffb446362696d8186d6348".to_string(), + ), verifying_contract: "0x3333333333333333333333333333333333333333".to_string(), } } @@ -292,9 +620,9 @@ mod tests { fn test_eip712_hash_matches_solidity_reference() { // Reference digest computed by `cast` and verified against the deployed contract. // See scripts/sc3-integration-test.sh for the full derivation. - let expected_digest = hex::decode( - "fab98461d60ccf4decb708d9176202165010b11b742597e64641146072ad2145" - ).unwrap(); + let expected_digest = + hex::decode("fab98461d60ccf4decb708d9176202165010b11b742597e64641146072ad2145") + .unwrap(); let signer = test_signer(); let permit = test_permit(); @@ -310,9 +638,9 @@ mod tests { #[test] fn test_eip712_hash_none_policy_is_zero_bytes() { // When policy_hash is None, Rust should encode bytes32(0). - let expected_digest = hex::decode( - "d61a0eb9e892785d7b2d77d28389cbad95c7d077cfedf6e9fba0d45b1267ef05" - ).unwrap(); + let expected_digest = + hex::decode("d61a0eb9e892785d7b2d77d28389cbad95c7d077cfedf6e9fba0d45b1267ef05") + .unwrap(); let signer = test_signer(); let mut permit = test_permit(); @@ -334,7 +662,11 @@ mod tests { let sig = signer.sign_permit(&permit).await.unwrap(); - assert_eq!(sig.len(), 65, "Signature must be exactly 65 bytes (r:32 + s:32 + v:1)"); + assert_eq!( + sig.len(), + 65, + "Signature must be exactly 65 bytes (r:32 + s:32 + v:1)" + ); let v = sig[64]; assert!(v == 27 || v == 28, "v byte must be 27 or 28, got {}", v); } @@ -355,10 +687,8 @@ mod tests { let mut rs_bytes = [0u8; 64]; rs_bytes[..32].copy_from_slice(r); rs_bytes[32..].copy_from_slice(s); - let signature = k256::ecdsa::Signature::from_slice(&rs_bytes) - .expect("valid 64-byte r||s"); - let recovery_id = RecoveryId::from_byte(v - 27) - .expect("valid recovery id"); + let signature = k256::ecdsa::Signature::from_slice(&rs_bytes).expect("valid 64-byte r||s"); + let recovery_id = RecoveryId::from_byte(v - 27).expect("valid recovery id"); let recovered_key = VerifyingKey::recover_from_prehash(&digest, &signature, recovery_id) .expect("recovery should succeed"); @@ -369,7 +699,8 @@ mod tests { recovered_address.copy_from_slice(&hash[12..]); assert_eq!( - recovered_address, expected_address, + recovered_address, + expected_address, "Recovered address {} must match signer address {}", hex::encode(recovered_address), hex::encode(expected_address) @@ -379,7 +710,7 @@ mod tests { #[test] fn test_domain_separator_components() { let domain_type_hash = Keccak256::digest( - b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)", ); assert_eq!( hex::encode(domain_type_hash), @@ -423,7 +754,10 @@ mod tests { fn test_expiry_at_uint48_max_passes_validation() { let mut permit = test_permit(); permit.expiry = 281474976710655; // type(uint48).max - assert!(permit.validate().is_ok(), "uint48 max should pass validation"); + assert!( + permit.validate().is_ok(), + "uint48 max should pass validation" + ); } #[test] @@ -444,7 +778,10 @@ mod tests { permit.expiry = 281474976710656; let result = signer.sign_permit(&permit).await; - assert!(result.is_err(), "sign_permit must reject overflowing expiry"); + assert!( + result.is_err(), + "sign_permit must reject overflowing expiry" + ); assert!(result.unwrap_err().to_string().contains("uint48")); } @@ -464,7 +801,10 @@ mod tests { let mut permit = test_permit(); permit.wallet = "".to_string(); let err = permit.validate().unwrap_err(); - assert!(err.to_string().contains("wallet"), "Should mention wallet: {err}"); + assert!( + err.to_string().contains("wallet"), + "Should mention wallet: {err}" + ); } #[test] @@ -472,7 +812,10 @@ mod tests { let mut permit = test_permit(); permit.wallet = "0xZZZZ".to_string(); let err = permit.validate().unwrap_err(); - assert!(err.to_string().contains("wallet"), "Should mention wallet: {err}"); + assert!( + err.to_string().contains("wallet"), + "Should mention wallet: {err}" + ); } #[test] @@ -538,7 +881,10 @@ mod tests { fn test_none_policy_hash_passes_validation() { let mut permit = test_permit(); permit.policy_hash = None; - assert!(permit.validate().is_ok(), "None policy hash should be valid"); + assert!( + permit.validate().is_ok(), + "None policy hash should be valid" + ); } #[tokio::test] @@ -574,6 +920,63 @@ mod tests { assert!(result.is_ok(), "Valid permit must sign successfully"); assert_eq!(result.unwrap().len(), 65); } + + #[tokio::test] + async fn test_bridge_signer_returns_approval_proof() { + let inner: Arc = Arc::new(test_signer()); + let approval_secret = random_bridge_approval_secret(); + let bridge = BridgeSigner::new(inner, approval_secret, 60).unwrap(); + + let signed = bridge.sign_permit_with_proof(&test_permit()).await.unwrap(); + assert_eq!(signed.signature.len(), 65); + let proof = signed + .approval + .expect("bridge signer must include approval proof"); + assert_eq!(proof.mode, "p256-local-bridge"); + assert!(proof.payload_hash.starts_with("0x")); + assert!(proof.public_key.starts_with("0x04")); + assert!(proof.signature.starts_with("0x")); + assert!(proof.expires_at > proof.issued_at); + } + + #[tokio::test] + async fn test_bridge_signer_replay_protection_blocks_duplicate_payload() { + let inner: Arc = Arc::new(test_signer()); + let approval_secret = random_bridge_approval_secret(); + let bridge = BridgeSigner::new(inner, approval_secret, 120).unwrap(); + let permit = test_permit(); + + let first = bridge.sign_permit_with_proof(&permit).await; + assert!(first.is_ok()); + + let second = bridge.sign_permit_with_proof(&permit).await; + assert!( + matches!(second, Err(SignerError::ApprovalFailed(_))), + "duplicate payload should be blocked" + ); + } + + #[tokio::test] + async fn test_bridge_signer_replay_protection_blocks_duplicate_across_second_boundary() { + let inner: Arc = Arc::new(test_signer()); + let approval_secret = random_bridge_approval_secret(); + let bridge = BridgeSigner::new(inner, approval_secret, 120).unwrap(); + let permit = test_permit(); + + let first = bridge.sign_permit_with_proof(&permit).await; + assert!(first.is_ok()); + + let initial_second = chrono::Utc::now().timestamp(); + while chrono::Utc::now().timestamp() == initial_second { + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + let second = bridge.sign_permit_with_proof(&permit).await; + assert!( + matches!(second, Err(SignerError::ApprovalFailed(_))), + "duplicate payload should remain blocked after a second boundary" + ); + } } pub async fn status_handler(State(state): State) -> impl IntoResponse { @@ -590,7 +993,8 @@ pub async fn status_handler(State(state): State) -> impl IntoResponse })); } - let signer_info = state.signer.status(); + let signer = state.current_signer().await; + let signer_info = signer.status(); let stats = state .spend_store .get_onchain_stats() @@ -601,6 +1005,9 @@ pub async fn status_handler(State(state): State) -> impl IntoResponse "enabled": true, "mode": signer_info.mode, "address": signer_info.address, + "approval_mode": signer_info.approval_mode, + "approval_public_key": signer_info.approval_public_key, + "approval_ttl_seconds": signer_info.approval_ttl_seconds, "chain_ids": config.onchain.chain_ids, "config": { "max_tx_value_usd": config.onchain.limits.max_tx_value_usd, @@ -608,6 +1015,8 @@ pub async fn status_handler(State(state): State) -> impl IntoResponse "cooldown_seconds": config.onchain.limits.cooldown_seconds, "max_slippage_bps": config.onchain.limits.max_slippage_bps, "permit_expiry_seconds": config.onchain.permits.expiry_seconds, + "bridge_approval_enabled": config.onchain.approval.enabled, + "bridge_approval_ttl_seconds": config.onchain.approval.ttl_seconds, }, "stats": { "total_permits_signed": stats.total_signed, diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index d6d7f66..a5fb6b2 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; use fishnet_types::config::FishnetConfig; -use tokio::sync::watch; +use tokio::sync::{RwLock, watch}; use crate::alert::AlertStore; use crate::anomaly::AnomalyTracker; @@ -36,7 +36,7 @@ pub struct AppState { pub http_clients_by_service: Arc>, pub anomaly_tracker: Arc>, pub onchain_store: Arc, - pub signer: Arc, + signer: Arc>>, pub started_at: std::time::Instant, } @@ -81,7 +81,7 @@ impl AppState { http_clients_by_service: Arc::new(http_clients_by_service), anomaly_tracker, onchain_store, - signer, + signer: Arc::new(RwLock::new(signer)), started_at, } } @@ -97,6 +97,14 @@ impl AppState { self.config_tx.send(new_config) } + pub async fn current_signer(&self) -> Arc { + self.signer.read().await.clone() + } + + pub async fn replace_signer(&self, signer: Arc) { + *self.signer.write().await = signer; + } + pub fn http_client_for_service(&self, service: &str) -> &reqwest::Client { if let Some(client) = self.http_clients_by_service.get(service) { return client; diff --git a/crates/server/src/webhook.rs b/crates/server/src/webhook.rs index 20bda75..5632763 100644 --- a/crates/server/src/webhook.rs +++ b/crates/server/src/webhook.rs @@ -246,7 +246,12 @@ pub async fn set_webhook_url( .credential_store .decrypt_for_service_and_name(WEBHOOK_VAULT_SERVICE, provider.credential_name()) .await - .map_err(|e| format!("failed to read current {} webhook URL: {e}", provider.as_str()))? + .map_err(|e| { + format!( + "failed to read current {} webhook URL: {e}", + provider.as_str() + ) + })? .map(|c| c.key.to_string()); clear_webhook_url(state, provider).await?; @@ -305,8 +310,8 @@ fn validate_webhook_url(raw: &str) -> Result { } let parsed = url::Url::parse(trimmed).map_err(|e| format!("invalid URL: {e}"))?; - let allow_http = cfg!(test) - || std::env::var("FISHNET_DEV").map_or(false, |v| v == "1" || v == "true"); + let allow_http = + cfg!(test) || std::env::var("FISHNET_DEV").map_or(false, |v| v == "1" || v == "true"); if allow_http { if !matches!(parsed.scheme(), "https" | "http") { return Err("URL scheme must be http or https".to_string()); @@ -332,10 +337,7 @@ fn reject_internal_host(host: &str) -> Result<(), String> { } let lower = host.to_ascii_lowercase(); - if lower.ends_with(".local") - || lower.ends_with(".internal") - || lower.ends_with(".localhost") - { + if lower.ends_with(".local") || lower.ends_with(".internal") || lower.ends_with(".localhost") { return Err(format!( "webhook URL must not point to an internal host ({host})" )); @@ -635,11 +637,7 @@ pub async fn create_alert_and_dispatch( } } -pub async fn dispatch_alert_webhooks_with_logging( - state: &AppState, - alert: &Alert, - context: &str, -) { +pub async fn dispatch_alert_webhooks_with_logging(state: &AppState, alert: &Alert, context: &str) { for result in dispatch_alert_webhooks(state, alert).await { if result.configured && !result.sent { eprintln!( diff --git a/crates/types/src/config.rs b/crates/types/src/config.rs index 43405d0..cdb406b 100644 --- a/crates/types/src/config.rs +++ b/crates/types/src/config.rs @@ -290,6 +290,7 @@ pub struct OnchainConfig { pub chain_ids: Vec, pub limits: OnchainLimits, pub permits: OnchainPermits, + pub approval: OnchainApprovalConfig, pub whitelist: HashMap>, } @@ -333,6 +334,22 @@ impl Default for OnchainPermits { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct OnchainApprovalConfig { + pub enabled: bool, + pub ttl_seconds: u64, +} + +impl Default for OnchainApprovalConfig { + fn default() -> Self { + Self { + enabled: false, + ttl_seconds: 60, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct BinanceConfig {