diff --git a/backend/.env.example b/backend/.env.example index 24ee6133..74a64c67 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,5 +7,6 @@ JUDGE_AUTHORITY_SECRET=TODO_fill_in_soroban_key ESCROW_CONTRACT_ID=TODO_after_deploy REPUTATION_CONTRACT_ID=TODO_after_deploy JOB_REGISTRY_CONTRACT_ID=TODO_after_deploy +JWT_SECRET=change-me-in-production PORT=3001 RUST_LOG=backend=debug,tower_http=debug diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 91a00219..4b21b503 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -24,9 +24,12 @@ dotenvy = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } bytes = { workspace = true } -base64 = "0.22" -sha2 = "0.10" +base64 = "0.22" +sha2 = "0.10" ed25519-dalek = { version = "2", features = ["rand_core"] } +stellar-strkey = "0.0.8" +jsonwebtoken = "9" +hex = "0.4" [dev-dependencies] axum-test = "16.0" diff --git a/backend/src/db.rs b/backend/src/db.rs index 09602aad..b0eaa298 100644 --- a/backend/src/db.rs +++ b/backend/src/db.rs @@ -1,20 +1,63 @@ use crate::services::judge::JudgeService; use crate::services::stellar::StellarService; use sqlx::PgPool; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +const NONCE_TTL: Duration = Duration::from_secs(300); // 5 minutes + +#[derive(Clone)] +pub struct NonceStore(Arc>>); + +impl NonceStore { + pub fn new() -> Self { + Self(Arc::new(Mutex::new(HashMap::new()))) + } + + /// Insert a nonce; returns false if it already exists (replay). + pub fn insert(&self, nonce: &str) -> bool { + let mut map = self.0.lock().unwrap(); + // Evict expired entries opportunistically. + map.retain(|_, ts| ts.elapsed() < NONCE_TTL); + if map.contains_key(nonce) { + return false; + } + map.insert(nonce.to_owned(), Instant::now()); + true + } + + /// Consume a nonce (one-time use). Returns true if valid and not expired. + pub fn consume(&self, nonce: &str) -> bool { + let mut map = self.0.lock().unwrap(); + match map.remove(nonce) { + Some(ts) => ts.elapsed() < NONCE_TTL, + None => false, + } + } +} #[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, + pub jwt_secret: Arc, } impl AppState { pub fn new(pool: PgPool) -> Self { + let jwt_secret = std::env::var("JWT_SECRET") + .unwrap_or_else(|_| "change-me-in-production".to_owned()); 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: NonceStore::new(), + jwt_secret: Arc::new(jwt_secret), } } } diff --git a/backend/src/error.rs b/backend/src/error.rs index 50c4b706..c47bbf53 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -11,6 +11,8 @@ pub enum AppError { NotFound(String), #[error("Bad request: {0}")] BadRequest(String), + #[error("Unauthorized: {0}")] + Unauthorized(String), #[error("Internal error: {0}")] Internal(#[from] anyhow::Error), #[error("Database error: {0}")] @@ -22,6 +24,7 @@ impl IntoResponse for AppError { let (status, message) = match &self { AppError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()), AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()), + AppError::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m.clone()), AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), }; diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 033375ff..25fea771 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -1,9 +1,9 @@ -use crate::{db::AppState, error::Result}; -use axum::{ - routing::{get, post}, - Json, Router, -}; +use crate::{db::AppState, error::{AppError, Result}}; +use axum::{extract::State, routing::{get, post}, Json, Router}; +use ed25519_dalek::{Signature, VerifyingKey}; +use jsonwebtoken::{encode, EncodingKey, Header}; use serde::{Deserialize, Serialize}; +use stellar_strkey::ed25519::PublicKey as StrKey; use uuid::Uuid; pub fn router() -> Router { @@ -12,49 +12,82 @@ pub fn router() -> Router { .route("/verify", post(verify_signature)) } +// ── GET /nonce ──────────────────────────────────────────────────────────────── + #[derive(Serialize)] struct NonceResponse { nonce: String, } -async fn get_nonce() -> Result> { +async fn get_nonce(State(state): State) -> Result> { let nonce = Uuid::new_v4().to_string(); - // In a real app, you might store this nonce in Redis with a TTL + state.nonces.insert(&nonce); Ok(Json(NonceResponse { nonce })) } +// ── POST /verify ────────────────────────────────────────────────────────────── + +/// The frontend must sign exactly this message (UTF-8 bytes): +/// "lance:auth:" #[derive(Deserialize)] -#[allow(dead_code)] struct VerifyRequest { + /// Stellar G… address of the signer. address: String, - message: String, - signature: String, // hex encoded + /// The nonce obtained from GET /nonce. + nonce: String, + /// Hex-encoded 64-byte ed25519 signature over "lance:auth:". + signature: String, +} + +#[derive(Serialize, Deserialize)] +struct Claims { + sub: String, // Stellar address + exp: usize, } #[derive(Serialize)] struct VerifyResponse { token: String, - success: bool, } -async fn verify_signature(Json(_req): Json) -> Result> { - // 1. Decode address (Stellar G... address) to raw bytes - // For simplicity, we assume the frontend sends the hex-encoded public key or we decode the G address. - // In Stellar, the public key is encoded in the G address (StrKey). +async fn verify_signature( + State(state): State, + Json(req): Json, +) -> Result> { + // 1. Consume nonce (one-time, TTL-checked). + if !state.nonces.consume(&req.nonce) { + return Err(AppError::Unauthorized("invalid or expired nonce".into())); + } + + // 2. Decode Stellar G… address → raw 32-byte public key. + let strkey = StrKey::from_string(&req.address) + .map_err(|_| AppError::BadRequest("invalid Stellar address".into()))?; + let verifying_key = VerifyingKey::from_bytes(&strkey.0) + .map_err(|_| AppError::BadRequest("invalid public key bytes".into()))?; - // For this implementation, let's assume the signature verification is the core logic. - // We'll need a way to decode Stellar addresses. - // Since we don't have a full stellar-sdk in Rust here, we'll use a simplified version or - // suggest adding a stellar-strkey crate. + // 3. Decode hex signature → 64 bytes. + let sig_bytes = hex::decode(&req.signature) + .map_err(|_| AppError::BadRequest("signature must be hex-encoded".into()))?; + let sig_array: [u8; 64] = sig_bytes + .try_into() + .map_err(|_| AppError::BadRequest("signature must be 64 bytes".into()))?; + let signature = Signature::from_bytes(&sig_array); - // Placeholder for actual Stellar StrKey decoding - // let public_key_bytes = decode_stellar_address(&req.address)?; + // 4. Verify ed25519 signature over canonical message. + let message = format!("lance:auth:{}", req.nonce); + verifying_key + .verify_strict(message.as_bytes(), &signature) + .map_err(|_| AppError::Unauthorized("signature verification failed".into()))?; - // For now, we'll return success if the logic is implemented. - // In a real scenario, we'd use ed25519-dalek to verify. + // 5. Issue JWT (24-hour expiry). + let exp = (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp() as usize; + let claims = Claims { sub: req.address, exp }; + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(state.jwt_secret.as_bytes()), + ) + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - Ok(Json(VerifyResponse { - token: "mock-jwt-token".into(), - success: true, - })) + Ok(Json(VerifyResponse { token })) }