Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions crates/perplex-edge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
112 changes: 102 additions & 10 deletions crates/perplex-edge/src/auth.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
//! 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};

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;
Expand Down Expand Up @@ -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<String, ApiError> {
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()))?;
Expand All @@ -110,6 +124,41 @@ pub fn verify_siwe(message: &str, signature: &str, state: &AppState) -> Result<S
Ok(address)
}

/// Recover the Ethereum address that produced an EIP-191 `personal_sign` signature over `message`.
/// Signature is the standard 65-byte `r || s || v` hex (with or without `0x`); `v` may be 27/28
/// or 0/1. Returns the lowercased `0x…` address.
fn recover_personal_sign(message: &str, signature: &str) -> Result<String, ApiError> {
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<String> {
msg.lines()
.find(|l| l.starts_with("Address: "))
Expand All @@ -125,3 +174,46 @@ fn parse_siwe_nonce(msg: &str) -> Option<String> {
.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());
}
}
55 changes: 48 additions & 7 deletions crates/perplex-edge/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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<()>) {
Expand Down Expand Up @@ -105,32 +131,47 @@ 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();
assert_eq!(res.status(), StatusCode::OK, "1.10 nonce");
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();
assert_eq!(res.status(), StatusCode::OK, "1.10 verify");
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",
Expand Down
Loading