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
3 changes: 3 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
16 changes: 12 additions & 4 deletions backend/src/db.rs
Original file line number Diff line number Diff line change
@@ -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<Mutex<HashMap<String, (String, String)>>>;

#[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,
}

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())),
}
}
}
100 changes: 100 additions & 0 deletions backend/src/routes/auth.rs
Original file line number Diff line number Diff line change
@@ -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<AppState>,
Json(req): Json<NonceRequest>,
) -> Result<Json<NonceResponse>> {
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<AppState>,
Json(req): Json<VerifyRequest>,
) -> Result<Json<VerifyResponse>> {
// 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<AppState> {
use axum::routing::post;
axum::Router::new()
.route("/nonce", post(nonce))
.route("/verify", post(verify))
}
4 changes: 2 additions & 2 deletions backend/src/routes/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod appeals;
pub mod auth;
pub mod bids;
pub mod deliverables;
pub mod disputes;
Expand All @@ -15,12 +16,11 @@ use axum::{routing::get, Router};

pub fn api_router() -> Router<AppState> {
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())
Expand Down
1 change: 1 addition & 0 deletions backend/src/services/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod ipfs;
pub mod judge;
pub mod siws;
pub mod stellar;
112 changes: 112 additions & 0 deletions backend/src/services/siws.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! Sign-In With Stellar (SIWS) — nonce generation and signature verification.
//!
//! Message format (plain-text, signed by the wallet):
//! ```
//! <domain> wants you to sign in with your Stellar account:
//! <address>
//!
//! Nonce: <hex-nonce>
//! Issued At: <iso8601>
//! ```
//!
//! 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());
}
}