Files
Oxicloud/src/application/ports/opaque_ports.rs
T
Edouard Vanbelle 7d7621e387 feat(opaque): wire API
- POST /api/auth/opaque/login/ke1 (public) — takes {userIdentifier, startLoginRequest}, resolves the identifier via the same @-dispatch as legacy login (AuthApplicationService::lookup_user_for_login, factored out), fetches envelope, runs ServerLogin::start (real branch for known users, dummy branch for anti-enum on unknown/unregistered), stashes state under a random exchange_id in the moka cache, returns {exchangeId, loginResponse}.
- POST /api/auth/opaque/login/ke3 (public) — atomic take from the cache FIRST (anti-enum + anti-replay), then decodes the payload, runs ServerLogin::finish, stamps opaque_migrated_at (Phase 3 signal), and mints a session via the new AuthApplicationService::mint_session_for_authenticated_user helper — returns the same AuthResponseDto shape as legacy login so the SPA has one downstream handler.
- Session mint factored: mint_session_for_authenticated_user(User) extracted from login() so both the legacy password path and OPAQUE KE3 converge through one implementation.
- OpaqueRepositoryPort::mark_migrated with COALESCE-preserving idempotent stamp of opaque_migrated_at.
- opaque-setup CLI + Dockerfile wiring already shipped (Step 0 hygiene).
- Routing fix: sub-prefix split (/api/auth/opaque/register vs /api/auth/opaque/login) — axum composes middleware between sibling nests at the same prefix, which was cross-applying auth+CSRF to my public login routes. Distinct prefixes side-step that cleanly. Documented in both main.rs and the router builder doc.
- Rate-limit sharing: login KE1/KE3 layered with the SAME login_limiter instance as legacy POST /api/auth/login, so an attacker can't halve the per-IP budget by spraying both endpoints.

Anti-enum + anti-replay hardening in KE3: take runs BEFORE payload parse so:
- Unknown / expired / already-consumed exchange_id → 401 InvalidCredentials (same shape as wrong-passphrase, no payload-shape leak)
- Consumed handle can't be re-used to spam parse attempts
2026-08-04 07:03:08 +02:00

106 lines
4.8 KiB
Rust

//! Outbound port for OPAQUE aPAKE envelope persistence.
//!
//! The registration record (encrypted "envelope" blob) and its
//! metadata live in three columns on `auth.users` — introduced by the
//! Phase 0 migration (`20260926000000_auth_opaque.sql`). This trait
//! wraps the row-level access so:
//!
//! * the OPAQUE handlers (Phase 1+) depend on a small, mockable
//! interface rather than a `PgPool`,
//! * unit tests can drive envelope reads/writes without a live DB,
//! * a future E2EE-phase migration can slot in per-device bridges
//! alongside this trait without disturbing the OPAQUE auth path.
//!
//! The trait is deliberately narrow — only what the OPAQUE
//! registration + login flows need. Anything else that touches
//! `auth.users` still goes through [`UserStoragePort`].
//!
//! ## `clear_registration` and `force_password_change_at_next_login`
//!
//! When an operator resets a user's password (Phase 4+ admin flow), we
//! need to invalidate the existing OPAQUE envelope AND force the user
//! to pick a new passphrase on their next login — otherwise the
//! admin-set password becomes a durable credential. [`clear_registration`]
//! does both in one round-trip: NULLs the four OPAQUE columns AND
//! sets `force_password_change_at_next_login = TRUE`. Individual
//! callers should NOT set that flag independently to avoid drift
//! between the two writes.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use crate::common::errors::Result;
/// Server-stored OPAQUE registration record for one user.
///
/// Rebuilt on every read from three columns: the envelope blob, the
/// ciphersuite version it was minted under, and the first-registration
/// timestamp. `opaque_migrated_at` is intentionally NOT here — it's a
/// login-time signal, not registration state.
#[derive(Debug, Clone)]
pub struct StoredEnvelope {
/// Serialised `opaque_ke::RegistrationUpload` payload. Server-opaque;
/// only the client with the correct passphrase can use it to complete
/// the login handshake.
pub envelope: Vec<u8>,
/// The ciphersuite version this envelope was minted under. Handlers
/// compare against
/// [`OpaqueService::ciphersuite_version`](crate::infrastructure::services::opaque_service::OpaqueService::ciphersuite_version)
/// and refuse login with a specific `error_type` when they diverge
/// — the client must re-register under the current suite.
pub ciphersuite_version: i16,
/// When this account first minted an OPAQUE envelope. Preserved
/// across re-registrations (password changes) via a NULL check in
/// [`OpaqueRepositoryPort::write_registration`].
pub registered_at: DateTime<Utc>,
}
/// Secondary (outbound) port for OPAQUE envelope persistence.
///
/// Concrete impl lives in
/// [`crate::infrastructure::repositories::pg::opaque_pg_repository`].
#[cfg_attr(feature = "test_utils", mockall::automock)]
#[async_trait]
pub trait OpaqueRepositoryPort: Send + Sync + 'static {
/// Write (or overwrite) the OPAQUE registration for `user_id`.
///
/// Idempotent w.r.t. `opaque_registered_at`: the first-registration
/// timestamp is preserved across re-registrations. Only the
/// envelope + ciphersuite_version rotate on password change.
///
/// Does NOT touch `opaque_migrated_at` — that's flipped by the
/// login endpoint after the first successful OPAQUE handshake.
async fn write_registration(
&self,
user_id: Uuid,
envelope: &[u8],
ciphersuite_version: i16,
) -> Result<()>;
/// Read the current envelope for `user_id`. Returns `None` when
/// the user has no OPAQUE registration (Phase 0 default, or
/// account was cleared by the admin reset flow).
async fn read_registration(&self, user_id: Uuid) -> Result<Option<StoredEnvelope>>;
/// Invalidate the OPAQUE registration for `user_id` and stamp the
/// force-change-at-next-login flag in one transaction. Used by
/// admin-side password reset (Phase 4+) — see the module-level
/// note above for why the flag is co-located with the clear.
///
/// Idempotent: clearing an already-empty registration is a no-op
/// on the envelope columns but STILL sets the force-change flag
/// (that's the point of the admin call).
async fn clear_registration(&self, user_id: Uuid) -> Result<()>;
/// Stamp `opaque_migrated_at` on `user_id` if it isn't set yet.
/// Called by the login-KE3 handler after a successful OPAQUE
/// handshake — the presence of this timestamp is the Phase 3+
/// signal that legacy `POST /api/auth/login` should refuse for
/// this user.
///
/// 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<()>;
}