diff --git a/api-server/Cargo.lock b/api-server/Cargo.lock index 459fb656..e76b4eff 100644 --- a/api-server/Cargo.lock +++ b/api-server/Cargo.lock @@ -100,6 +100,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha1", "sha2", "stellar-strkey", "testcontainers", diff --git a/api-server/Cargo.toml b/api-server/Cargo.toml index c068cee4..6edf0c9c 100644 --- a/api-server/Cargo.toml +++ b/api-server/Cargo.toml @@ -40,7 +40,9 @@ hex = "0.4" async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } ed25519-dalek = "2" +base32 = "0.4" hmac = "0.12" +sha1 = "0.10" jsonwebtoken = "9" stellar-strkey = "0.0.8" rand = "0.8.6" diff --git a/api-server/src/auth_2fa.rs b/api-server/src/auth_2fa.rs index 57b132a2..d41b5768 100644 --- a/api-server/src/auth_2fa.rs +++ b/api-server/src/auth_2fa.rs @@ -1,7 +1,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use chrono::Utc; +use hmac::{Hmac, Mac}; +use sha1::Sha1; /// #984: Two-Factor Authentication (2FA) module for Time-based One-Time Passwords (TOTP) /// and backup codes for account recovery. @@ -15,6 +16,9 @@ pub struct TotpSecret { pub enabled_at: i64, /// Whether 2FA is currently active/verified pub verified: bool, + /// Last successfully used TOTP time step, preventing code replay. + #[serde(default)] + pub last_totp_step: Option, } /// Backup code for account recovery @@ -85,11 +89,14 @@ impl TwoFactorStore { Self::default() } - /// Store user's 2FA configuration - pub fn store_config(&self, config: TwoFactorConfig) -> Result<(), String> { + /// Create a user's 2FA configuration without replacing an existing one. + pub fn create_config(&self, config: TwoFactorConfig) -> Result { let mut store = self.configs.lock().map_err(|e| e.to_string())?; + if store.contains_key(&config.user_id) { + return Ok(false); + } store.insert(config.user_id.clone(), config); - Ok(()) + Ok(true) } /// Retrieve user's 2FA configuration @@ -111,6 +118,33 @@ impl TwoFactorStore { .map(|c| c.totp.map(|t| t.verified).unwrap_or(false)) .unwrap_or(false)) } + + /// Verify a code and record its time step under the same lock. + pub fn verify_totp_code( + &self, + user_id: &str, + code: &str, + timestamp: i64, + ) -> Result, String> { + let mut configs = self.configs.lock().map_err(|e| e.to_string())?; + let Some(config) = configs.get_mut(user_id) else { + return Ok(None); + }; + let Some(totp) = config.totp.as_mut() else { + return Ok(None); + }; + let Some(step) = matching_totp_step(&totp.secret, code, timestamp)? else { + return Ok(Some(false)); + }; + if totp.last_totp_step.is_some_and(|last| step <= last) { + return Ok(Some(false)); + } + + totp.last_totp_step = Some(step); + totp.verified = true; + config.last_verified = Some(timestamp); + Ok(Some(true)) + } } /// Generate a TOTP secret (base32-encoded random bytes) @@ -150,23 +184,55 @@ pub fn generate_qr_code_uri(user_id: &str, secret: &str, issuer: &str) -> String ) } -/// Verify a TOTP code against a secret (simplified without time-window checking) -/// In production, use a proper TOTP library like `totp-lite` or `totp-rs` -pub fn verify_totp_code(secret: &str, code: &str) -> Result { - // This is a simplified implementation - // In production, use a proper TOTP validation library that: - // 1. Handles time window (±30 seconds) - // 2. Prevents code reuse - // 3. Validates base32 decoding - // 4. Uses HMAC-SHA1 for code generation +/// Find the matching TOTP counter in the current 30-second window or either adjacent step. +pub fn matching_totp_step( + secret: &str, + code: &str, + timestamp: i64, +) -> Result, String> { + if code.len() != 6 || !code.bytes().all(|byte| byte.is_ascii_digit()) { + return Ok(None); + } + + let key = base32::decode(base32::Alphabet::RFC4648 { padding: false }, secret) + .ok_or_else(|| "Invalid base32 TOTP secret".to_string())?; + if key.is_empty() { + return Err("TOTP secret must not be empty".to_string()); + } - if code.len() != 6 || !code.chars().all(|c| c.is_numeric()) { - return Ok(false); + let current_step = timestamp.max(0) as u64 / 30; + for step in [ + current_step.saturating_sub(1), + current_step, + current_step.saturating_add(1), + ] { + let expected = generate_totp_code(&key, step)?; + if constant_time_eq(expected.as_bytes(), code.as_bytes()) { + return Ok(Some(step)); + } } + Ok(None) +} + +fn generate_totp_code(key: &[u8], step: u64) -> Result { + let mut mac = Hmac::::new_from_slice(key).map_err(|err| err.to_string())?; + mac.update(&step.to_be_bytes()); + let digest = mac.finalize().into_bytes(); + let offset = (digest[19] & 0x0f) as usize; + let binary = (u32::from(digest[offset] & 0x7f) << 24) + | (u32::from(digest[offset + 1]) << 16) + | (u32::from(digest[offset + 2]) << 8) + | u32::from(digest[offset + 3]); + Ok(format!("{:06}", binary % 1_000_000)) +} - // TODO: Implement proper TOTP verification with totp-rs crate - // For now, accept any valid 6-digit code as a placeholder - Ok(true) +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + left.len() == right.len() + && left + .iter() + .zip(right) + .fold(0u8, |difference, (a, b)| difference | (a ^ b)) + == 0 } /// Mark a backup code as used @@ -186,3 +252,95 @@ pub fn mark_backup_code_used( } Ok(false) } + +#[cfg(test)] +mod tests { + use super::*; + + fn config(user_id: &str, secret: &str) -> TwoFactorConfig { + TwoFactorConfig { + user_id: user_id.to_string(), + totp: Some(TotpSecret { + secret: secret.to_string(), + enabled_at: 0, + verified: true, + last_totp_step: None, + }), + backup_codes: Vec::new(), + created_at: 0, + last_verified: None, + } + } + + #[test] + fn create_config_does_not_replace_existing_secret() { + let store = TwoFactorStore::new(); + assert!(store.create_config(config("user", "original")).unwrap()); + assert!(!store.create_config(config("user", "replacement")).unwrap()); + + assert_eq!( + store.get_config("user").unwrap().unwrap().totp.unwrap().secret, + "original" + ); + } + + #[test] + fn matches_rfc_6238_sha1_test_vector() { + assert_eq!( + matching_totp_step( + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + "287082", + 59 + ) + .unwrap(), + Some(1) + ); + } + + #[test] + fn accepts_adjacent_totp_steps_only() { + let secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + let key = base32::decode(base32::Alphabet::RFC4648 { padding: false }, secret).unwrap(); + let adjacent_code = generate_totp_code(&key, 2).unwrap(); + let outside_window_code = generate_totp_code(&key, 3).unwrap(); + + assert_eq!( + matching_totp_step(secret, &adjacent_code, 30).unwrap(), + Some(2) + ); + assert_eq!( + matching_totp_step(secret, &outside_window_code, 30).unwrap(), + None + ); + } + + #[test] + fn rejects_invalid_totp_codes_and_secrets() { + assert_eq!( + matching_totp_step("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", "123456", 59).unwrap(), + None + ); + assert_eq!( + matching_totp_step("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", "123456", 59).unwrap(), + None + ); + assert!(matching_totp_step("not-base32!", "287082", 59).is_err()); + } + + #[test] + fn rejects_reuse_of_a_totp_step() { + let store = TwoFactorStore::new(); + store + .create_config(config("user", "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ")) + .unwrap(); + + assert_eq!( + store.verify_totp_code("user", "287082", 59).unwrap(), + Some(true) + ); + assert_eq!( + store.verify_totp_code("user", "287082", 59).unwrap(), + Some(false) + ); + } +} diff --git a/api-server/src/handlers.rs b/api-server/src/handlers.rs index 2b1535ae..82ec42fc 100644 --- a/api-server/src/handlers.rs +++ b/api-server/src/handlers.rs @@ -2350,12 +2350,14 @@ pub(crate) static TWO_FACTOR_STORE: once_cell::sync::Lazy, ) -> Result, (StatusCode, Json)> { if body.user_id.is_empty() { @@ -2367,6 +2369,15 @@ pub async fn enable_2fa( )); } + if claims.sub != body.user_id { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Cannot manage 2FA for another user".to_string(), + }), + )); + } + // Generate TOTP secret let secret = crate::auth_2fa::generate_totp_secret(); let backup_codes = crate::auth_2fa::generate_backup_codes(8); @@ -2379,6 +2390,7 @@ pub async fn enable_2fa( secret: secret.clone(), enabled_at: chrono::Utc::now().timestamp(), verified: false, + last_totp_step: None, }), backup_codes: backup_codes .iter() @@ -2392,12 +2404,22 @@ pub async fn enable_2fa( last_verified: None, }; - TWO_FACTOR_STORE.store_config(config).map_err(|err| { + match TWO_FACTOR_STORE.create_config(config).map_err(|err| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: err }), ) - })?; + })? { + true => {} + false => { + return Err(( + StatusCode::CONFLICT, + Json(ErrorResponse { + error: "2FA is already configured for this user".to_string(), + }), + )); + } + } Ok(Json(Enable2faResponse { secret, @@ -2420,8 +2442,9 @@ pub async fn enable_2fa( (status = 404, description = "2FA not configured for user", body = ErrorResponse), ) )] -#[instrument(skip(body))] +#[instrument(skip(body, claims))] pub async fn verify_2fa( + crate::auth::AuthExtension(claims): crate::auth::AuthExtension, Json(body): Json, ) -> Result, (StatusCode, Json)> { if body.user_id.is_empty() { @@ -2433,6 +2456,15 @@ pub async fn verify_2fa( )); } + if claims.sub != body.user_id { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Cannot manage 2FA for another user".to_string(), + }), + )); + } + if body.totp_code.len() != 6 || !body.totp_code.chars().all(|c| c.is_numeric()) { return Err(( StatusCode::BAD_REQUEST, @@ -2442,70 +2474,31 @@ pub async fn verify_2fa( )); } - // Get user's 2FA config - let mut config = match TWO_FACTOR_STORE - .get_config(&body.user_id) + match TWO_FACTOR_STORE + .verify_totp_code(&body.user_id, &body.totp_code, chrono::Utc::now().timestamp()) .map_err(|err| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: err }), ) - })? - { - Some(cfg) => cfg, - None => { - return Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: "2FA not configured for this user".to_string(), - }), - )) - } - }; - - // Get TOTP secret - let secret = match &config.totp { - Some(totp) => &totp.secret, - None => { - return Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: "2FA not configured for this user".to_string(), - }), - )) - } - }; - - // Verify TOTP code - match crate::auth_2fa::verify_totp_code(secret, &body.totp_code) { - Ok(true) => { - // Mark 2FA as verified - if let Some(ref mut totp) = config.totp { - totp.verified = true; - } - config.last_verified = Some(chrono::Utc::now().timestamp()); - - TWO_FACTOR_STORE.store_config(config).map_err(|err| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { error: err }), - ) - })?; - + })? { + Some(true) => { Ok(Json(Verify2faResponse { success: true, message: "2FA verified successfully".to_string(), })) } - Ok(false) => Err(( + Some(false) => Err(( StatusCode::UNAUTHORIZED, Json(ErrorResponse { error: "Invalid TOTP code".to_string(), }), )), - Err(err) => Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { error: err }), + None => Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: "2FA not configured for this user".to_string(), + }), )), } } @@ -2523,8 +2516,9 @@ pub async fn verify_2fa( (status = 404, description = "User not found", body = ErrorResponse), ) )] -#[instrument(skip(body))] +#[instrument(skip(body, claims))] pub async fn use_backup_code( + crate::auth::AuthExtension(claims): crate::auth::AuthExtension, Json(body): Json, ) -> Result, (StatusCode, Json)> { if body.user_id.is_empty() || body.backup_code.is_empty() { @@ -2536,6 +2530,15 @@ pub async fn use_backup_code( )); } + if claims.sub != body.user_id { + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse { + error: "Cannot manage 2FA for another user".to_string(), + }), + )); + } + // Check if backup code exists and is valid let used = crate::auth_2fa::mark_backup_code_used(&TWO_FACTOR_STORE, &body.user_id, &body.backup_code) .map_err(|err| { diff --git a/contracts/atomic_swap/src/lib.rs b/contracts/atomic_swap/src/lib.rs index fa80e67b..e787de52 100644 --- a/contracts/atomic_swap/src/lib.rs +++ b/contracts/atomic_swap/src/lib.rs @@ -260,6 +260,8 @@ pub enum DataKey { EscrowDeposit(u64), /// Maps address → reputation score (0–100). UserReputation(Address), + /// Reputation points deducted from sellers when an accepted swap expires unrevealed. + ExpiredSwapReputationPenalty, /// Maps ip_id → minimum buyer reputation required (set by seller per swap). ReputationMultiplier(u64), /// Maps swap_id → timestamp when arbitration was requested. @@ -2568,8 +2570,13 @@ impl AtomicSwap { // #253: Log history entry Self::append_history(&env, swap_id, SwapStatus::Cancelled); - // Seller defaulted (expired without revealing key): seller loses 10 points - Self::update_reputation(&env, &swap.seller, -10); + // Seller defaulted (expired without revealing key). + let penalty: u32 = env + .storage() + .persistent() + .get(&DataKey::ExpiredSwapReputationPenalty) + .unwrap_or(10); + Self::update_reputation(&env, &swap.seller, -(penalty as i32)); env.events().publish( (soroban_sdk::symbol_short!("s_cancel"),), @@ -6030,6 +6037,28 @@ impl AtomicSwap { // ── Reputation ──────────────────────────────────────────────────────────── + /// Returns the configured seller reputation penalty for expired swaps. Defaults to 10. + pub fn get_expired_swap_reputation_penalty(env: Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::ExpiredSwapReputationPenalty) + .unwrap_or(10) + } + + /// Set the seller reputation penalty for expired swaps. Only the admin may call this. + pub fn set_expired_swap_reputation_penalty(env: Env, caller: Address, penalty: u32) { + caller.require_auth(); + require_admin(&env, &caller); + if penalty > 100 { + env.panic_with_error(Error::from_contract_error( + ContractError::InvalidReputationPenalty as u32, + )); + } + env.storage() + .persistent() + .set(&DataKey::ExpiredSwapReputationPenalty, &penalty); + } + /// Returns the reputation score (0–100) for an address. Defaults to 50. pub fn get_reputation(env: Env, address: Address) -> u32 { env.storage() diff --git a/contracts/atomic_swap/src/reputation_tests.rs b/contracts/atomic_swap/src/reputation_tests.rs index 98a2bc68..97ae797a 100644 --- a/contracts/atomic_swap/src/reputation_tests.rs +++ b/contracts/atomic_swap/src/reputation_tests.rs @@ -128,6 +128,31 @@ mod reputation_tests { assert_eq!(client.get_reputation(&seller), 40); } + #[test] + fn test_admin_can_configure_expired_swap_reputation_penalty() { + let env = Env::default(); + env.mock_all_auths(); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let (registry_id, ip_id, _, _) = setup_registry(&env, &seller); + let token_id = setup_token(&env, &seller, &buyer, 1_000_000); + let contract_id = setup_swap_contract(&env, ®istry_id); + let client = AtomicSwapClient::new(&env, &contract_id); + + let swap_id = client.initiate_swap( + &token_id, &ip_id, &seller, &1000i128, &buyer, + &0u32, &None, &0i128, &false, + ); + client.set_expired_swap_reputation_penalty(&seller, &7); + client.accept_swap(&swap_id); + env.ledger().with_mut(|l| l.timestamp += 604801); + client.cancel_expired_swap(&swap_id, &buyer); + + assert_eq!(client.get_expired_swap_reputation_penalty(), 7); + assert_eq!(client.get_reputation(&seller), 43); + } + #[test] fn test_reputation_clamped_at_zero() { let env = Env::default(); diff --git a/contracts/atomic_swap/src/upgrade.rs b/contracts/atomic_swap/src/upgrade.rs index 3257c502..22b7329d 100644 --- a/contracts/atomic_swap/src/upgrade.rs +++ b/contracts/atomic_swap/src/upgrade.rs @@ -246,6 +246,8 @@ pub fn build_v1_schema(env: &Env) -> ContractSchema { f!("initialize", "initialize(ip_registry:Address)->()"); f!("set_admin", "set_admin(new_admin:Address)->()"); + f!("get_expired_swap_reputation_penalty", "get_expired_swap_reputation_penalty()->u32"); + f!("set_expired_swap_reputation_penalty", "set_expired_swap_reputation_penalty(caller:Address,penalty:u32)->()"); f!("pause", "pause(caller:Address)->()"); f!("unpause", "unpause(caller:Address)->()"); f!("upgrade", "upgrade(new_wasm_hash:BytesN<32>)->()"); @@ -332,6 +334,7 @@ pub fn build_v1_schema(env: &Env) -> ContractSchema { e!("InsuranceNotClaimable", 60); e!("HashlockNotMet", 69); e!("TimelockExpired", 70); + e!("InvalidReputationPenalty", 71); let mut storage_keys: Vec = Vec::new(env); @@ -363,6 +366,7 @@ pub fn build_v1_schema(env: &Env) -> ContractSchema { k!("InsuranceReserved"); k!("InsuranceReservedTotal"); k!("SwapContingencies"); + k!("ExpiredSwapReputationPenalty"); ContractSchema { version: 1,