Skip to content
Open
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
1 change: 1 addition & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 5 additions & 2 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
51 changes: 47 additions & 4 deletions backend/src/db.rs
Original file line number Diff line number Diff line change
@@ -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<Mutex<HashMap<String, Instant>>>);

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<JudgeService>,
pub stellar: std::sync::Arc<StellarService>,
pub judge: Arc<JudgeService>,
pub stellar: Arc<StellarService>,
pub nonces: NonceStore,
pub jwt_secret: Arc<String>,
}

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),
}
}
}
3 changes: 3 additions & 0 deletions backend/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")]
Expand All @@ -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()),
};
Expand Down
87 changes: 60 additions & 27 deletions backend/src/routes/auth.rs
Original file line number Diff line number Diff line change
@@ -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<AppState> {
Expand All @@ -12,49 +12,82 @@ pub fn router() -> Router<AppState> {
.route("/verify", post(verify_signature))
}

// ── GET /nonce ────────────────────────────────────────────────────────────────

#[derive(Serialize)]
struct NonceResponse {
nonce: String,
}

async fn get_nonce() -> Result<Json<NonceResponse>> {
async fn get_nonce(State(state): State<AppState>) -> Result<Json<NonceResponse>> {
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:<nonce>"
#[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:<nonce>".
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<VerifyRequest>) -> Result<Json<VerifyResponse>> {
// 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<AppState>,
Json(req): Json<VerifyRequest>,
) -> Result<Json<VerifyResponse>> {
// 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 }))
}
Loading