diff --git a/backend/.env.example b/backend/.env.example index 24ee6133..48386f43 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,3 +9,6 @@ REPUTATION_CONTRACT_ID=TODO_after_deploy JOB_REGISTRY_CONTRACT_ID=TODO_after_deploy PORT=3001 RUST_LOG=backend=debug,tower_http=debug +# SIWS auth +APP_DOMAIN=localhost:3001 +SESSION_SECRET=TODO_replace_with_32_random_bytes_hex diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 91a00219..5f7cd8db 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -27,6 +27,9 @@ bytes = { workspace = true } base64 = "0.22" sha2 = "0.10" ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = "0.8" +hex = "0.4" +base32 = "0.4" [dev-dependencies] axum-test = "16.0" diff --git a/backend/src/db.rs b/backend/src/db.rs index 09602aad..8c2d08dc 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,20 +1,28 @@ use crate::services::judge::JudgeService; use crate::services::stellar::StellarService; use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +/// In-memory nonce store: address → (nonce, issued_at). +/// Nonces are consumed on first use (one-time). +pub type NonceStore = Arc>>; #[derive(Clone)] pub struct AppState { pub pool: PgPool, - pub judge: std::sync::Arc, - pub stellar: std::sync::Arc, + pub judge: Arc, + pub stellar: Arc, + pub nonces: NonceStore, } impl AppState { pub fn new(pool: PgPool) -> Self { Self { pool, - judge: std::sync::Arc::new(JudgeService::from_env()), - stellar: std::sync::Arc::new(StellarService::from_env()), + judge: Arc::new(JudgeService::from_env()), + stellar: Arc::new(StellarService::from_env()), + nonces: Arc::new(Mutex::new(HashMap::new())), } } } diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs new file mode 100644 index 00000000..3444ce38 --- /dev/null +++ b/backend/src/routes/auth.rs @@ -0,0 +1,100 @@ +//! SIWS authentication routes. +//! +//! POST /api/v1/auth/nonce — issue a one-time nonce for an address +//! POST /api/v1/auth/verify — verify a signed SIWS message, return a session token + +use axum::{extract::State, Json}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::{ + db::AppState, + error::{AppError, Result}, + services::siws, +}; + +// ── Request / Response types ───────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct NonceRequest { + pub address: String, +} + +#[derive(Serialize)] +pub struct NonceResponse { + pub nonce: String, + pub issued_at: String, +} + +#[derive(Deserialize)] +pub struct VerifyRequest { + pub address: String, + pub signature: String, // hex-encoded 64-byte ed25519 signature +} + +#[derive(Serialize)] +pub struct VerifyResponse { + pub token: String, // opaque session token (hex-encoded random bytes) +} + +// ── Handlers ───────────────────────────────────────────────────────────────── + +/// Issue a fresh nonce for the given Stellar address. +/// Replaces any previously stored nonce for that address. +pub async fn nonce( + State(state): State, + Json(req): Json, +) -> Result> { + if req.address.is_empty() { + return Err(AppError::BadRequest("address is required".into())); + } + + let nonce = siws::generate_nonce(); + let issued_at = Utc::now().to_rfc3339(); + + state + .nonces + .lock() + .unwrap() + .insert(req.address.clone(), (nonce.clone(), issued_at.clone())); + + Ok(Json(NonceResponse { nonce, issued_at })) +} + +/// Verify a SIWS signature. +/// Consumes the stored nonce (one-time use) and returns a session token on success. +pub async fn verify( + State(state): State, + Json(req): Json, +) -> Result> { + // Consume the nonce — remove it regardless of outcome to prevent replay. + let entry = state + .nonces + .lock() + .unwrap() + .remove(&req.address); + + let (nonce, issued_at) = entry + .ok_or_else(|| AppError::BadRequest("no pending nonce for this address".into()))?; + + let domain = std::env::var("APP_DOMAIN").unwrap_or_else(|_| "lance.app".into()); + let message = siws::build_message(&domain, &req.address, &nonce, &issued_at); + + siws::verify(&req.address, &message, &req.signature) + .map_err(|e| AppError::BadRequest(e.to_string()))?; + + // Issue a simple random session token. + // In production, replace with a signed JWT or encrypted cookie. + let token = siws::generate_nonce(); // reuse the 32-byte random hex generator + + Ok(Json(VerifyResponse { token })) +} + +// ── Router ─────────────────────────────────────────────────────────────────── + +pub fn router() -> axum::Router { + use axum::routing::post; + axum::Router::new() + .route("/nonce", post(nonce)) + .route("/verify", post(verify)) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 233b820c..c5ba9a84 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,4 +1,5 @@ pub mod appeals; +pub mod auth; pub mod bids; pub mod deliverables; pub mod disputes; @@ -15,12 +16,11 @@ use axum::{routing::get, Router}; pub fn api_router() -> Router { Router::new() - // health check — outside versioned prefix so load balancers can reach it .route("/health", get(health::health)) - // v1 API routes .nest( "/v1", Router::new() + .nest("/auth", auth::router()) .nest("/jobs", jobs::router()) .nest("/disputes", disputes::router()) .nest("/appeals", appeals::router()) diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index f99224cc..19d2beaf 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,3 +1,4 @@ pub mod ipfs; pub mod judge; +pub mod siws; pub mod stellar; diff --git a/backend/src/services/siws.rs b/backend/src/services/siws.rs new file mode 100644 index 00000000..93ffe4f5 --- /dev/null +++ b/backend/src/services/siws.rs @@ -0,0 +1,112 @@ +//! Sign-In With Stellar (SIWS) — nonce generation and signature verification. +//! +//! Message format (plain-text, signed by the wallet): +//! ``` +//! wants you to sign in with your Stellar account: +//!
+//! +//! Nonce: +//! Issued At: +//! ``` +//! +//! The wallet signs the UTF-8 bytes of this message with its ed25519 key. +//! The backend verifies the signature against the public key encoded in the +//! Stellar G-address (strkey, version byte 6 << 3 = 0x30). + +use ed25519_dalek::{Signature, VerifyingKey}; +use rand::RngCore; + +const STRKEY_VERSION_ACCOUNT: u8 = 6 << 3; // 0x30 + +/// Generate a cryptographically random 32-byte hex nonce. +pub fn generate_nonce() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Build the canonical SIWS message that the wallet must sign. +pub fn build_message(domain: &str, address: &str, nonce: &str, issued_at: &str) -> String { + format!( + "{domain} wants you to sign in with your Stellar account:\n\ + {address}\n\ + \n\ + Nonce: {nonce}\n\ + Issued At: {issued_at}" + ) +} + +/// Decode a Stellar G-address into its raw 32-byte ed25519 public key. +fn decode_stellar_address(address: &str) -> anyhow::Result<[u8; 32]> { + // Stellar strkey: base32(version_byte || payload || crc16) + let decoded = base32::decode(base32::Alphabet::RFC4648 { padding: false }, address) + .ok_or_else(|| anyhow::anyhow!("invalid base32 in address"))?; + + // minimum: 1 version + 32 payload + 2 crc = 35 bytes + if decoded.len() < 35 { + anyhow::bail!("address too short"); + } + if decoded[0] != STRKEY_VERSION_ACCOUNT { + anyhow::bail!("not an account address (wrong version byte)"); + } + + let payload = &decoded[1..decoded.len() - 2]; + if payload.len() != 32 { + anyhow::bail!("unexpected payload length"); + } + + let mut key = [0u8; 32]; + key.copy_from_slice(payload); + Ok(key) +} + +/// Verify a SIWS signature. +/// +/// * `address` — Stellar G-address of the signer +/// * `message` — the canonical SIWS message (as produced by [`build_message`]) +/// * `signature` — hex-encoded 64-byte ed25519 signature +pub fn verify(address: &str, message: &str, signature_hex: &str) -> anyhow::Result<()> { + let key_bytes = decode_stellar_address(address)?; + let verifying_key = VerifyingKey::from_bytes(&key_bytes) + .map_err(|e| anyhow::anyhow!("invalid public key: {e}"))?; + + let sig_bytes = hex::decode(signature_hex) + .map_err(|_| anyhow::anyhow!("signature is not valid hex"))?; + if sig_bytes.len() != 64 { + anyhow::bail!("signature must be 64 bytes"); + } + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + let signature = Signature::from_bytes(&sig_arr); + + verifying_key + .verify_strict(message.as_bytes(), &signature) + .map_err(|_| anyhow::anyhow!("signature verification failed")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nonce_is_64_hex_chars() { + let n = generate_nonce(); + assert_eq!(n.len(), 64); + assert!(n.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn message_contains_expected_fields() { + let msg = build_message("example.com", "GABC", "deadbeef", "2026-01-01T00:00:00Z"); + assert!(msg.contains("example.com wants you to sign in")); + assert!(msg.contains("GABC")); + assert!(msg.contains("Nonce: deadbeef")); + assert!(msg.contains("Issued At: 2026-01-01T00:00:00Z")); + } + + #[test] + fn rejects_bad_signature_hex() { + let result = verify("GABC", "msg", "not-hex"); + assert!(result.is_err()); + } +}