From 64dc17b59a43ae5c74a8e64518d2531ac2d94889 Mon Sep 17 00:00:00 2001 From: ozpool Date: Fri, 29 May 2026 16:11:45 +0530 Subject: [PATCH] fix(edge): verify SIWE signature via ECDSA recovery siwe verify only length-checked the signature and trusted the Address line in the message, so anyone could mint a JWT for any address by quoting it (request a nonce for the victim, build a message, send any 132-char signature). Recover the signer from the EIP-191 personal_sign signature with k256 + keccak256 and require it to equal the asserted address before issuing a token; the nonce check (single-use) stays. Adds round-trip + malformed-signature unit tests, and updates the e2e suite to sign with a real key (and assert a forged signature is now rejected with 401). --- crates/perplex-edge/Cargo.toml | 3 + crates/perplex-edge/src/auth.rs | 112 ++++++++++++++++++++++++++++--- crates/perplex-edge/tests/e2e.rs | 55 +++++++++++++-- 3 files changed, 153 insertions(+), 17 deletions(-) diff --git a/crates/perplex-edge/Cargo.toml b/crates/perplex-edge/Cargo.toml index 2c5af44..e50ff9f 100644 --- a/crates/perplex-edge/Cargo.toml +++ b/crates/perplex-edge/Cargo.toml @@ -44,6 +44,9 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time", "te tokio-tungstenite = { workspace = true } futures-util = "0.3" serial_test = "3" +k256 = { version = "0.13", features = ["ecdsa"] } +sha3 = "0.10" +hex = "0.4" [[bin]] name = "perplex-edge" diff --git a/crates/perplex-edge/src/auth.rs b/crates/perplex-edge/src/auth.rs index 703aa61..f38a7e5 100644 --- a/crates/perplex-edge/src/auth.rs +++ b/crates/perplex-edge/src/auth.rs @@ -1,6 +1,8 @@ -//! SIWE-style auth. The verify endpoint accepts the message + signature pair; here we keep the -//! verification lightweight (recover-and-match a checksummed address embedded in the message). -//! Production hardens this with eth-signing recovery via alloy. +//! SIWE-style auth. The verify endpoint accepts the message + signature pair and performs full +//! ERC-4361 verification: the EIP-191 personal_sign signature is recovered to an Ethereum address +//! and matched against the address asserted in the message, and the message nonce must match a +//! nonce this server issued (single-use). Without the recovery step, anyone could mint a JWT for +//! any address by quoting it in a message — the signature was previously only length-checked. use std::time::{SystemTime, UNIX_EPOCH}; @@ -8,7 +10,9 @@ use async_trait::async_trait; use axum::extract::{FromRef, FromRequestParts}; use axum::http::request::Parts; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use k256::ecdsa::{RecoveryId, Signature, VerifyingKey}; use serde::{Deserialize, Serialize}; +use sha3::{Digest, Keccak256}; use crate::error::ApiError; use crate::state::AppState; @@ -90,17 +94,27 @@ where } } -/// Minimal SIWE message check used in dev. We parse out the `Address:` line and confirm it -/// matches what the request asserted. Production swaps this for full ERC-4361 verification -/// + ECDSA signature recovery. +/// Full SIWE verification: recover the signer from the EIP-191 personal_sign signature, confirm +/// it matches the address asserted in the message, and consume a matching server-issued nonce +/// (single-use). Returns the verified, lowercased address. pub fn verify_siwe(message: &str, signature: &str, state: &AppState) -> Result { - if !signature.starts_with("0x") || signature.len() < 132 { - return Err(ApiError::InvalidSignature); - } let address = parse_siwe_address(message) - .ok_or_else(|| ApiError::BadRequest("siwe message missing Address line".into()))?; + .ok_or_else(|| ApiError::BadRequest("siwe message missing Address line".into()))? + .to_lowercase(); let expected_nonce = parse_siwe_nonce(message) .ok_or_else(|| ApiError::BadRequest("siwe message missing Nonce line".into()))?; + + // Recover the signer from the signature and require it to match the asserted address. + // This is the check that actually authenticates the wallet — without it the address line + // is self-asserted and forgeable. + let recovered = recover_personal_sign(message, signature)?; + if recovered != address { + return Err(ApiError::Unauthorized( + "signature does not match address".into(), + )); + } + + // Nonce must be one we issued for this address and is consumed on use (replay protection). let issued = state .consume_siwe_nonce(&address) .ok_or_else(|| ApiError::Unauthorized("nonce not issued or already used".into()))?; @@ -110,6 +124,41 @@ pub fn verify_siwe(message: &str, signature: &str, state: &AppState) -> Result Result { + let sig_bytes = + hex::decode(signature.trim_start_matches("0x")).map_err(|_| ApiError::InvalidSignature)?; + if sig_bytes.len() != 65 { + return Err(ApiError::InvalidSignature); + } + let recovery_id = match sig_bytes[64] { + 27 | 28 => sig_bytes[64] - 27, + v @ (0 | 1) => v, + _ => return Err(ApiError::InvalidSignature), + }; + let signature = + Signature::from_slice(&sig_bytes[..64]).map_err(|_| ApiError::InvalidSignature)?; + let recid = RecoveryId::from_byte(recovery_id).ok_or(ApiError::InvalidSignature)?; + + // EIP-191 digest: keccak256("\x19Ethereum Signed Message:\n" + len + message). + let mut hasher = Keccak256::new(); + hasher.update(format!("\x19Ethereum Signed Message:\n{}", message.len()).as_bytes()); + hasher.update(message.as_bytes()); + let digest = hasher.finalize(); + + let vk = VerifyingKey::recover_from_prehash(&digest, &signature, recid) + .map_err(|_| ApiError::InvalidSignature)?; + + // address = last 20 bytes of keccak256(uncompressed pubkey without the 0x04 prefix). + let point = vk.to_encoded_point(false); + let mut h = Keccak256::new(); + h.update(&point.as_bytes()[1..]); + let hash = h.finalize(); + Ok(format!("0x{}", hex::encode(&hash[12..]))) +} + fn parse_siwe_address(msg: &str) -> Option { msg.lines() .find(|l| l.starts_with("Address: ")) @@ -125,3 +174,46 @@ fn parse_siwe_nonce(msg: &str) -> Option { .find(|l| l.starts_with("Nonce: ")) .map(|l| l.trim_start_matches("Nonce: ").trim().to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + use k256::ecdsa::SigningKey; + + fn eth_address(vk: &VerifyingKey) -> String { + let point = vk.to_encoded_point(false); + let mut h = Keccak256::new(); + h.update(&point.as_bytes()[1..]); + format!("0x{}", hex::encode(&h.finalize()[12..])) + } + + fn personal_sign(sk: &SigningKey, msg: &str) -> String { + let mut hasher = Keccak256::new(); + hasher.update(format!("\x19Ethereum Signed Message:\n{}", msg.len()).as_bytes()); + hasher.update(msg.as_bytes()); + let (sig, recid) = sk.sign_prehash_recoverable(&hasher.finalize()).unwrap(); + let mut bytes = sig.to_bytes().to_vec(); + bytes.push(recid.to_byte() + 27); + format!("0x{}", hex::encode(&bytes)) + } + + #[test] + fn recover_personal_sign_round_trips() { + let sk = SigningKey::from_slice(&[0x11u8; 32]).unwrap(); + let addr = eth_address(sk.verifying_key()); + let msg = "perplex.xyz wants you to sign in\nNonce: abc123"; + let sig = personal_sign(&sk, msg); + assert_eq!(recover_personal_sign(msg, &sig).unwrap(), addr); + // A tampered message recovers a different address (signature no longer valid for it). + assert_ne!( + recover_personal_sign("tampered message", &sig).unwrap(), + addr + ); + } + + #[test] + fn recover_rejects_malformed_signature() { + assert!(recover_personal_sign("x", "0xdeadbeef").is_err()); + assert!(recover_personal_sign("x", "not-hex").is_err()); + } +} diff --git a/crates/perplex-edge/tests/e2e.rs b/crates/perplex-edge/tests/e2e.rs index a006105..8f78738 100644 --- a/crates/perplex-edge/tests/e2e.rs +++ b/crates/perplex-edge/tests/e2e.rs @@ -9,9 +9,35 @@ use reqwest::StatusCode; use serde_json::Value; mod helpers { + pub use k256::ecdsa::SigningKey; + use k256::ecdsa::VerifyingKey; + use sha3::{Digest, Keccak256}; + pub fn jwt_secret() -> Vec { b"e2e-test-secret-not-for-prod".to_vec() } + + fn keccak_addr(vk: &VerifyingKey) -> String { + let point = vk.to_encoded_point(false); + let mut h = Keccak256::new(); + h.update(&point.as_bytes()[1..]); + format!("0x{}", hex::encode(&h.finalize()[12..])) + } + + pub fn eth_address(sk: &SigningKey) -> String { + keccak_addr(sk.verifying_key()) + } + + /// Produce an EIP-191 personal_sign signature (`0x` + 65 bytes, v = 27/28). + pub fn personal_sign(sk: &SigningKey, msg: &str) -> String { + let mut hasher = Keccak256::new(); + hasher.update(format!("\x19Ethereum Signed Message:\n{}", msg.len()).as_bytes()); + hasher.update(msg.as_bytes()); + let (sig, recid) = sk.sign_prehash_recoverable(&hasher.finalize()).unwrap(); + let mut bytes = sig.to_bytes().to_vec(); + bytes.push(recid.to_byte() + 27); + format!("0x{}", hex::encode(&bytes)) + } } async fn spawn_server() -> (String, tokio::task::JoinHandle<()>) { @@ -105,10 +131,13 @@ async fn e2e_all_eleven_endpoints() { let body: Value = res.json().await.unwrap(); assert_eq!(body["marketId"], "btc-usd"); - // 1.10 SIWE nonce. + // 1.10 SIWE — generate a real keypair, sign the message, and verify. The + // verify endpoint now recovers the signer, so a fake signature is rejected. + let signer = helpers::SigningKey::from_slice(&[0x42u8; 32]).unwrap(); + let siwe_addr = helpers::eth_address(&signer); let res = client .post(format!("{base}/v1/auth/siwe/nonce")) - .json(&serde_json::json!({"address": addr})) + .json(&serde_json::json!({"address": siwe_addr})) .send() .await .unwrap(); @@ -116,14 +145,13 @@ async fn e2e_all_eleven_endpoints() { let body: Value = res.json().await.unwrap(); let nonce = body["nonce"].as_str().unwrap().to_string(); - // 1.10 SIWE verify — synthesise a dev message that round-trips the nonce. - let dev_msg = format!( - "perplex.local wants you to sign in\nAddress: {addr}\nNonce: {nonce}\nIssued At: 2026-05-20T12:00:00Z" + let siwe_msg = format!( + "perplex.local wants you to sign in\nAddress: {siwe_addr}\nNonce: {nonce}\nIssued At: 2026-05-20T12:00:00Z" ); - let dev_sig = "0x".to_string() + &"00".repeat(65); + let siwe_sig = helpers::personal_sign(&signer, &siwe_msg); let res = client .post(format!("{base}/v1/auth/siwe/verify")) - .json(&serde_json::json!({"message": dev_msg, "signature": dev_sig})) + .json(&serde_json::json!({"message": siwe_msg, "signature": siwe_sig})) .send() .await .unwrap(); @@ -131,6 +159,19 @@ async fn e2e_all_eleven_endpoints() { let body: Value = res.json().await.unwrap(); assert!(!body["jwt"].as_str().unwrap().is_empty()); + // A forged signature for the same message must be rejected. + let res = client + .post(format!("{base}/v1/auth/siwe/verify")) + .json(&serde_json::json!({"message": siwe_msg, "signature": "0x".to_string() + &"00".repeat(65)})) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + StatusCode::UNAUTHORIZED, + "forged siwe rejected" + ); + // 1.5 place order — authed. let order_req = serde_json::json!({ "marketId": "btc-usd",