From 168d94370abdb47f540de2ec29f28019f25c3a76 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sat, 1 Aug 2026 20:25:56 +0200 Subject: [PATCH] feat(opaque): legacy login refuses migrated OPAQUE users --- src/application/ports/opaque_ports.rs | 14 ++++ .../services/auth_application_service.rs | 72 ++++++++++++++++++ src/common/di.rs | 22 ++++++ src/infrastructure/auth_factory.rs | 12 +++ .../repositories/pg/opaque_pg_repository.rs | 75 +++++++++++++++++++ src/interfaces/api/handlers/auth_handler.rs | 16 ++++ 6 files changed, 211 insertions(+) diff --git a/src/application/ports/opaque_ports.rs b/src/application/ports/opaque_ports.rs index 220b97db..ae506f9a 100644 --- a/src/application/ports/opaque_ports.rs +++ b/src/application/ports/opaque_ports.rs @@ -102,4 +102,18 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static { /// Idempotent (COALESCE preserves the first-migration timestamp /// so a later login doesn't rewrite the operational signal). async fn mark_migrated(&self, user_id: Uuid) -> Result<()>; + + /// True iff `user_id` has completed at least one successful + /// OPAQUE login (i.e. `opaque_migrated_at IS NOT NULL`). Read + /// by the legacy login gate in Phase 4 to refuse password + /// authentication for users who've already proven OPAQUE + /// capability — the admin-reset path re-opens legacy by + /// NULL-ing this column via [`clear_registration`], so the + /// state is coherent without a separate carve-out. + /// + /// Returns `false` for missing users (anti-enum: the legacy + /// gate must not distinguish "user gone" from "user not + /// migrated" — the wrong-password branch already covered the + /// user-lookup miss upstream). + async fn is_migrated(&self, user_id: Uuid) -> Result; } diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 3dfd4bb4..82acb055 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -180,6 +180,13 @@ pub struct AuthApplicationService { /// `email_verified_at IS NULL`. Mirrors /// `AuthConfig::require_verified_email`. require_verified_email: bool, + /// OPAQUE envelope repo — populated when the OPAQUE substrate is + /// wired (`OXICLOUD_OPAQUE_MODE != off`). `login()` consults it to + /// enforce the Phase 4 gate: once a user has completed at least + /// one successful OPAQUE handshake (`opaque_migrated_at IS NOT + /// NULL`), legacy `POST /api/auth/login` is refused for that + /// account. `None` = substrate off, no gate applies. + opaque_repo: Option>, } /// TTL for [`AuthApplicationService::user_flags_cache`]. Upper bound on how @@ -233,6 +240,7 @@ impl AuthApplicationService { allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink], auth_policies: Vec::new(), require_verified_email: false, + opaque_repo: None, } } @@ -357,6 +365,17 @@ impl AuthApplicationService { self.magic_link_repo.is_some() } + /// Wire the OPAQUE envelope repo. Called by the DI factory when the + /// OPAQUE substrate is configured (`OXICLOUD_OPAQUE_MODE != off`). + /// Enables the Phase 4 legacy-login gate — see the field docstring. + pub fn with_opaque_repo( + mut self, + repo: Arc, + ) -> Self { + self.opaque_repo = Some(repo); + self + } + /// Returns the default quota for the given role, capped to the available /// disk space on the filesystem that hosts the storage directory. fn capped_quota(&self, role: &UserRole) -> i64 { @@ -788,6 +807,59 @@ impl AuthApplicationService { )); } + // Phase 4 gate: legacy password login is refused for users who + // have completed at least one OPAQUE handshake + // (`opaque_migrated_at IS NOT NULL`). A stale client or a + // downgrade attacker with a stolen password blob is the only + // caller who lands here — the SPA already probes + // `POST /api/auth/opaque/login/lookup` and takes the OPAQUE + // branch when an envelope exists. Admin password reset + // atomically NULLs `opaque_migrated_at` (see + // `opaque_pg_repository.rs::clear_registration`), so the + // state is coherent — no `force_password_change` + // carve-out is needed here. + // + // Checked AFTER password verify so an attacker without the + // password learns nothing new about a user's OPAQUE status: + // only a caller who supplied the right password gets the + // distinguishing "use OPAQUE" signal, and that caller was + // going to be redirected anyway. + // + // Fails OPEN on repo error — a transient DB blip must not + // lock every migrated user out; the same login path will + // succeed on the next attempt when the repo recovers, and + // an operator reading the audit log sees the failure clearly. + if let Some(opaque) = self.opaque_repo.as_ref() { + match opaque.is_migrated(user.id()).await { + Ok(true) => { + tracing::info!( + target: "audit", + event = "auth.login_rejected", + reason = "opaque_migrated_use_opaque", + user_id = %user.id(), + username = %user.display_for_audit(), + "🔐 legacy login refused: user is OPAQUE-migrated ('{}')", + user.display_for_audit(), + ); + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Auth", + "Password login refused: this account has migrated to OPAQUE", + )); + } + Ok(false) => {} + Err(e) => { + tracing::warn!( + target: "audit", + event = "auth.opaque_migration_check_failed", + user_id = %user.id(), + error = %e, + "OPAQUE migration check failed — allowing legacy login as fallback" + ); + } + } + } + // Gate: `OXICLOUD_REQUIRE_VERIFIED_EMAIL`. Checked AFTER password // validation so an attacker with only a username cannot probe // account verification state (the response shape is diff --git a/src/common/di.rs b/src/common/di.rs index 68302613..aa5f63c4 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1878,10 +1878,32 @@ impl AppServiceFactory { // PersonalDriveLifecycleHook, which already holds an Arc to the // folder service via the user_lifecycle dispatcher. if self.config.features.enable_auth { + // OPAQUE repo built here (pre-`create_auth_services`) so + // the AuthApplicationService gets the Phase 4 gate wired + // at construction time — before Arc-wrapping locks the + // shape. Gate fires only when `effective_mode != Off`; + // an OPAQUE-off deployment gets `None` and legacy login + // stays open for every user (including anyone with a + // stale `opaque_migrated_at` from a previous rollout). + let opaque_repo_for_auth: Option< + Arc, + > = { + use crate::infrastructure::services::opaque_service::OpaqueMode; + if self.config.opaque.effective_mode(&self.config.auth) != OpaqueMode::Off { + Some(Arc::new( + crate::infrastructure::repositories::pg::OpaquePgRepository::new( + pool.clone(), + ), + )) + } else { + None + } + }; let services = crate::infrastructure::auth_factory::create_auth_services( &self.config, pool.clone(), user_lifecycle.clone(), + opaque_repo_for_auth, ) .await .map_err(|e| { diff --git a/src/infrastructure/auth_factory.rs b/src/infrastructure/auth_factory.rs index d8c64c42..67abdec9 100644 --- a/src/infrastructure/auth_factory.rs +++ b/src/infrastructure/auth_factory.rs @@ -18,6 +18,7 @@ pub async fn create_auth_services( config: &AppConfig, pool: Arc, user_lifecycle: Arc, + opaque_repo: Option>, ) -> Result { // Create JWT token service (TokenServicePort implementation) let token_service: Arc = Arc::new(JwtTokenService::new( @@ -71,6 +72,17 @@ pub async fn create_auth_services( Arc::new(MagicLinkTokenPgRepository::new(pool.clone())); auth_app_service = auth_app_service.with_magic_link_repo(magic_link_repo); + // Wire the OPAQUE repo when the substrate is active. Enables the + // Phase 4 legacy-login gate — `AuthApplicationService::login` + // refuses `POST /api/auth/login` for users with + // `opaque_migrated_at IS NOT NULL` (see the field doc for the + // safety analysis). When the OPAQUE mode is `off` at the config + // layer this is None and the gate never fires — legacy stays open + // for every user regardless of any historical migration state. + if let Some(repo) = opaque_repo { + auth_app_service = auth_app_service.with_opaque_repo(repo); + } + // Configure OIDC service if enabled if config.oidc.enabled { tracing::info!( diff --git a/src/infrastructure/repositories/pg/opaque_pg_repository.rs b/src/infrastructure/repositories/pg/opaque_pg_repository.rs index 7797a900..03847ca1 100644 --- a/src/infrastructure/repositories/pg/opaque_pg_repository.rs +++ b/src/infrastructure/repositories/pg/opaque_pg_repository.rs @@ -149,6 +149,27 @@ impl OpaqueRepositoryPort for OpaquePgRepository { Ok(()) } + async fn is_migrated(&self, user_id: Uuid) -> Result { + // Cheap presence check on the partial index + // `idx_users_opaque_migrated`. `fetch_optional` returning `None` + // covers both "no such user" and "user exists but not migrated"; + // Phase 4's gate collapses both to `false` (anti-enum — the + // wrong-password branch upstream has already covered the + // user-lookup miss). + let row: Option<(Option>,)> = sqlx::query_as( + r#" + SELECT opaque_migrated_at + FROM auth.users + WHERE id = $1 + "#, + ) + .bind(user_id) + .fetch_optional(self.pool()) + .await + .map_err(|e| DomainError::internal_error("OpaquePg", format!("is_migrated: {e}")))?; + Ok(row.and_then(|(t,)| t).is_some()) + } + async fn clear_registration(&self, user_id: Uuid) -> Result<()> { // One UPDATE writes both the envelope invalidation AND the // force-change flag — matches the atomicity we promise in the @@ -510,6 +531,60 @@ mod integration_tests { ); } + /// `is_migrated` returns false until `mark_migrated` stamps + /// `opaque_migrated_at`, then flips to true. `clear_registration` + /// re-opens the fallback by NULL-ing the column. This is the + /// exact state machine the Phase 4 legacy-login gate reads. + #[tokio::test] + async fn is_migrated_tracks_mark_and_clear_state_transitions() { + let repo = test_repo().await; + let user = seed_user( + &repo, + &format!("opaque-ism-{}@example.invalid", Uuid::new_v4()), + ) + .await; + + // Fresh user: no envelope, no migration mark. + assert!( + !repo.is_migrated(user).await.unwrap(), + "fresh user must not be marked migrated" + ); + + // Mark migrated — should flip the read to true. The service- + // level gate refuses legacy login from this point onward. + repo.mark_migrated(user).await.expect("mark migrated"); + assert!( + repo.is_migrated(user).await.unwrap(), + "user must be marked migrated after mark_migrated" + ); + + // Admin password reset (clear_registration) MUST re-open the + // legacy fallback by NULL-ing opaque_migrated_at — otherwise + // an admin-reset user would be locked out of their own account + // (no envelope, but Phase 4 gate still refuses legacy). + repo.clear_registration(user) + .await + .expect("clear registration"); + assert!( + !repo.is_migrated(user).await.unwrap(), + "clear_registration must NULL opaque_migrated_at to re-open the legacy fallback" + ); + } + + /// Missing user reads as `false` (anti-enum). The service-layer + /// gate must not distinguish "user gone" from "user not migrated" + /// — the upstream user lookup + password check already covered + /// the "unknown identifier" branch. + #[tokio::test] + async fn is_migrated_returns_false_for_missing_user() { + let repo = test_repo().await; + let ghost = Uuid::new_v4(); + assert!( + !repo.is_migrated(ghost).await.unwrap(), + "missing user must read as not-migrated (anti-enum)" + ); + } + #[tokio::test] async fn missing_user_surfaces_notfound_on_write_and_read_and_clear() { let repo = test_repo().await; diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 817f2073..5e5a66de 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -466,6 +466,22 @@ pub async fn login( // AFTER the password check specifically so an attacker // without the password can't discover an account's // verification state from the response shape. + // Phase 4: the service refuses legacy login for + // OPAQUE-migrated users with this exact message. Remap to a + // stable `error_type` the SPA can branch on — a legit + // caller reaching this branch would already have taken the + // OPAQUE path (the SPA's login form calls + // `/api/auth/opaque/login/lookup` first), so this response + // primarily serves legacy clients and downgrade-attack + // detection. 403 keeps the shape consistent with the other + // policy refusals (PasswordLoginDisabled, EmailNotVerified). + if err.message == "Password login refused: this account has migrated to OPAQUE" { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Password login is no longer available for this account. Please sign in via the OPAQUE flow.", + "OpaqueLoginRequired", + )); + } if err.message == "Email not verified" { // Best-effort auto-send. We swallow any error and still // return the same EmailNotVerified response — the