use alloy::signers::{SignerSync, local::PrivateKeySigner};
use thiserror::Error;
use crate::signing::encoding::utc_now_ms;
#[derive(Debug, Error)]
pub enum AuthError {
#[error("system clock is before UNIX epoch")]
ClockBeforeEpoch,
#[error("signing failed: {message}")]
SigningFailed {
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthHeaders {
pub wallet: String,
pub timestamp: String,
pub signature: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WsLogin {
pub wallet: String,
pub timestamp: String,
pub signature: String,
}
pub fn build_rest_auth_headers(
wallet: &str,
signer: &PrivateKeySigner,
) -> Result<AuthHeaders, AuthError> {
let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
build_rest_auth_headers_at(wallet, signer, now)
}
pub fn build_rest_auth_headers_at(
wallet: &str,
signer: &PrivateKeySigner,
now_ms: u64,
) -> Result<AuthHeaders, AuthError> {
let timestamp = now_ms.to_string();
let signature = sign_message(×tamp, signer)?;
Ok(AuthHeaders {
wallet: wallet.to_owned(),
timestamp,
signature,
})
}
pub fn build_ws_login(wallet: &str, signer: &PrivateKeySigner) -> Result<WsLogin, AuthError> {
let now = utc_now_ms().map_err(|_| AuthError::ClockBeforeEpoch)?;
build_ws_login_at(wallet, signer, now)
}
pub fn build_ws_login_at(
wallet: &str,
signer: &PrivateKeySigner,
now_ms: u64,
) -> Result<WsLogin, AuthError> {
let timestamp = now_ms.to_string();
let signature = sign_message(×tamp, signer)?;
Ok(WsLogin {
wallet: wallet.to_owned(),
timestamp,
signature,
})
}
fn sign_message(message: &str, signer: &PrivateKeySigner) -> Result<String, AuthError> {
let signature =
signer
.sign_message_sync(message.as_bytes())
.map_err(|e| AuthError::SigningFailed {
message: e.to_string(),
})?;
Ok(format!(
"0x{}",
alloy_primitives::hex::encode(signature.as_bytes())
))
}
#[cfg(test)]
mod tests {
use alloy_primitives::{Address, Signature, eip191_hash_message, hex};
use rstest::rstest;
use super::*;
const SESSION_KEY_HEX: &str =
"0x2ae8be44db8a590d20bffbe3b6872df9b569147d3bf6801a35a28281a4816bbd";
const TEST_WALLET: &str = "0x000000000000000000000000000000000000aaaa";
fn signer_address() -> Address {
let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
signer.address()
}
#[rstest]
fn test_rest_headers_contain_three_fields() {
let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
assert_eq!(headers.wallet, TEST_WALLET);
assert_eq!(headers.timestamp, "1700000000000");
assert!(headers.signature.starts_with("0x"));
assert_eq!(headers.signature.len(), 2 + 130);
}
#[rstest]
fn test_rest_signature_recovers_signer_address() {
let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
let raw = hex::decode(headers.signature.trim_start_matches("0x")).unwrap();
let signature = Signature::try_from(raw.as_slice()).unwrap();
let digest = eip191_hash_message(headers.timestamp.as_bytes());
let recovered = signature
.recover_address_from_prehash(&digest)
.expect("recover");
assert_eq!(recovered, signer_address());
}
#[rstest]
fn test_ws_login_matches_rest_signature_for_same_timestamp() {
let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
let now = 1_700_000_001_234;
let rest = build_rest_auth_headers_at(TEST_WALLET, &signer, now).unwrap();
let ws = build_ws_login_at(TEST_WALLET, &signer, now).unwrap();
assert_eq!(rest.timestamp, ws.timestamp);
assert_eq!(rest.signature, ws.signature);
assert_eq!(rest.wallet, ws.wallet);
}
#[rstest]
fn test_distinct_timestamps_produce_distinct_signatures() {
let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
let a = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
let b = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_001).unwrap();
assert_ne!(a.signature, b.signature);
}
#[rstest]
fn test_signature_format_is_lowercase_hex() {
let signer: PrivateKeySigner = SESSION_KEY_HEX.parse().unwrap();
let headers = build_rest_auth_headers_at(TEST_WALLET, &signer, 1_700_000_000_000).unwrap();
let sig = headers.signature.trim_start_matches("0x");
assert!(
sig.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
"expected lowercase hex, was {sig}",
);
}
}