diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index 35a0270..8d51f68 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -95,7 +95,7 @@ mod tests { #[test] fn generate_and_validate_token() { - let svc = JwtAuthService::new("secret".into(), 3600); + let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600); let id = UserId::new(); let tok = svc.generate_token(&id).unwrap(); let parsed = svc.validate_token(&tok.token).unwrap(); @@ -104,7 +104,7 @@ mod tests { #[test] fn invalid_token_returns_unauthorized() { - let svc = JwtAuthService::new("secret".into(), 3600); + let svc = JwtAuthService::new("a-secret-that-is-at-least-32-bytes!!".into(), 3600); let err = svc.validate_token("not.a.token").unwrap_err(); assert!(matches!(err, DomainError::Unauthorized)); } diff --git a/crates/application/src/use_cases/auth.rs b/crates/application/src/use_cases/auth.rs index 5697a3a..5ec22c2 100644 --- a/crates/application/src/use_cases/auth.rs +++ b/crates/application/src/use_cases/auth.rs @@ -64,10 +64,15 @@ pub async fn login( input: LoginInput, ) -> Result { let email = Email::new(input.email)?; - let user = users - .find_by_email(&email) - .await? - .ok_or(DomainError::Unauthorized)?; + let user = users.find_by_email(&email).await?; + if user.is_none() { + // Timing equalization — prevents email enumeration via response-time oracle. + // Running the hasher on a miss makes "no such user" take the same time as + // "wrong password", so attackers cannot distinguish the two cases. + let _ = hasher.hash(&input.password).await; + return Err(DomainError::Unauthorized); + } + let user = user.unwrap(); if !hasher.verify(&input.password, &user.password_hash).await? { return Err(DomainError::Unauthorized); } diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs index 452f29f..7207695 100644 --- a/crates/bootstrap/src/factory.rs +++ b/crates/bootstrap/src/factory.rs @@ -1,4 +1,5 @@ -const JWT_TTL_SECS: i64 = 86_400 * 30; +const JWT_TTL_SECS: i64 = 86_400; // 24 hours (was 30 days) +const JWT_SECRET_MIN_BYTES: usize = 32; // 256 bits minimum for HS256 use async_trait::async_trait; use sqlx::PgPool; @@ -107,10 +108,16 @@ pub async fn build(cfg: &Config) -> Infrastructure { )), feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())), search: Arc::new(postgres_search::PgSearchRepository::new(pool.clone())), - auth: Arc::new(auth::JwtAuthService::new( - cfg.jwt_secret.clone(), - JWT_TTL_SECS, - )), + auth: Arc::new({ + if cfg.jwt_secret.len() < JWT_SECRET_MIN_BYTES { + panic!( + "JWT_SECRET is {} bytes — minimum is {} bytes for HS256 security", + cfg.jwt_secret.len(), + JWT_SECRET_MIN_BYTES, + ); + } + auth::JwtAuthService::new(cfg.jwt_secret.clone(), JWT_TTL_SECS) + }), hasher: Arc::new(auth::Argon2PasswordHasher), events: event_publisher, federation: ap_service.clone() as Arc,