From 033146a6c9eb17e1addab7ec939b5673e9c907db Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 26 Jul 2026 23:01:07 +0200 Subject: [PATCH] feat(opaque): add login opaque exchange --- src/application/ports/mod.rs | 1 + src/application/ports/opaque_ports.rs | 95 ++++ src/common/di.rs | 47 ++ src/infrastructure/repositories/pg/mod.rs | 2 + .../repositories/pg/opaque_pg_repository.rs | 460 ++++++++++++++++++ src/infrastructure/services/mod.rs | 1 + .../services/opaque_login_exchange.rs | 252 ++++++++++ src/interfaces/api/handlers/mod.rs | 1 + .../api/handlers/opaque_auth_handler.rs | 302 ++++++++++++ src/main.rs | 19 + 10 files changed, 1180 insertions(+) create mode 100644 src/application/ports/opaque_ports.rs create mode 100644 src/infrastructure/repositories/pg/opaque_pg_repository.rs create mode 100644 src/infrastructure/services/opaque_login_exchange.rs create mode 100644 src/interfaces/api/handlers/opaque_auth_handler.rs diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 8d668d7c..3ee7c4dc 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -18,6 +18,7 @@ pub mod file_ports; pub mod folder_ports; pub mod inbound; pub mod music_ports; +pub mod opaque_ports; pub mod outbound; pub mod plugin_ports; pub mod recent_ports; diff --git a/src/application/ports/opaque_ports.rs b/src/application/ports/opaque_ports.rs new file mode 100644 index 00000000..a6daaab1 --- /dev/null +++ b/src/application/ports/opaque_ports.rs @@ -0,0 +1,95 @@ +//! 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, + /// 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, +} + +/// 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>; + + /// 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<()>; +} diff --git a/src/common/di.rs b/src/common/di.rs index f2d38daa..68302613 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1953,6 +1953,36 @@ impl AppServiceFactory { } }; + // OPAQUE persistence repo — mirrors the service's mode gate so + // both are `Some`/`None` in lock-step. Kept as + // `Arc` on `AppState` so future + // handlers can inject the trait instead of the concrete PG + // type — matches the trait-first convention used by + // FavoritesRepositoryPort / RecentItemsRepositoryPort. + let opaque_repo: Option< + Arc, + > = if opaque_service.is_some() { + Some(Arc::new( + crate::infrastructure::repositories::pg::OpaquePgRepository::new(pool.clone()), + )) + } else { + None + }; + + // OPAQUE login-exchange cache — holds ServerLogin state between + // KE1 and KE3 (~60s TTL). Same lock-step gate as the repo and + // service. Process-local; single-instance deployments only — + // multi-instance would need Redis or LB session affinity, but + // the swap is local to this cache since callers use + // `store`/`take` opaque handles. + let opaque_login_exchange = if opaque_service.is_some() { + Some(Arc::new( + crate::infrastructure::services::opaque_login_exchange::OpaqueLoginExchange::new(), + )) + } else { + None + }; + // Shared App Password service — created once, used by both NC routes and native API let shared_app_pw_svc: Option> = if self.config.nextcloud.enabled || self.config.features.enable_auth { @@ -2048,6 +2078,8 @@ impl AppServiceFactory { mount_router, auth_service: auth_services, opaque_service, + opaque_repo, + opaque_login_exchange, nextcloud: nextcloud_services, admin_settings_service: None, storage_settings_service: None, @@ -2805,6 +2837,21 @@ pub struct AppState { pub opaque_service: Option< Arc, >, + /// OPAQUE envelope persistence. Populated in lock-step with + /// [`Self::opaque_service`] — both `Some` or both `None`, gated + /// on the same `effective_mode` cross-check. Handlers should + /// consume both together so a partial-`Some` never occurs. + pub opaque_repo: Option< + Arc, + >, + /// OPAQUE login-exchange state cache (KE1 → KE3). Populated in + /// lock-step with [`Self::opaque_service`] and [`Self::opaque_repo`] + /// — all three `Some` or all three `None`. Process-local moka; + /// 60s TTL; atomic single-use `take` prevents replay of an + /// exchange_id. + pub opaque_login_exchange: Option< + Arc, + >, pub nextcloud: Option, pub admin_settings_service: Option>, /// WASM plugin management (list/install/toggle/remove), backing the admin diff --git a/src/infrastructure/repositories/pg/mod.rs b/src/infrastructure/repositories/pg/mod.rs index 979beba5..313e183c 100644 --- a/src/infrastructure/repositories/pg/mod.rs +++ b/src/infrastructure/repositories/pg/mod.rs @@ -13,6 +13,7 @@ mod favorites_pg_repository; pub mod file_metadata_repository; mod magic_link_token_pg_repository; mod nextcloud_object_id_repository; +mod opaque_pg_repository; pub mod playlist_pg_repository; mod recent_items_pg_repository; mod session_pg_repository; @@ -46,6 +47,7 @@ pub use file_metadata_repository::FileMetadataRepository; pub use folder_db_repository::FolderDbRepository; pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository; pub use nextcloud_object_id_repository::NextcloudObjectIdRepository; +pub use opaque_pg_repository::OpaquePgRepository; pub use playlist_pg_repository::{ AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository, }; diff --git a/src/infrastructure/repositories/pg/opaque_pg_repository.rs b/src/infrastructure/repositories/pg/opaque_pg_repository.rs new file mode 100644 index 00000000..0aeb76c6 --- /dev/null +++ b/src/infrastructure/repositories/pg/opaque_pg_repository.rs @@ -0,0 +1,460 @@ +//! PostgreSQL repository for OPAQUE aPAKE envelopes. +//! +//! Backs [`OpaqueRepositoryPort`] against the three OPAQUE columns on +//! `auth.users` introduced by migration `20260926000000_auth_opaque.sql`: +//! +//! * `opaque_envelope BYTEA` — the serialised registration blob, +//! * `opaque_ciphersuite_version SMALLINT` — the bound suite version, +//! * `opaque_registered_at TIMESTAMPTZ` — first-registration timestamp, +//! +//! plus the co-located `force_password_change_at_next_login BOOLEAN` +//! toggled by [`clear_registration`]. +//! +//! No caching. OPAQUE reads happen at most once per login (the +//! server hands the envelope to `ServerLogin::start` and that's it), +//! so the added cache-invalidation complexity would earn nothing. If +//! that ever changes (batch-endpoint use), the moka pattern in +//! `login_lockout_service` is the shape to reach for. + +use std::sync::Arc; + +use async_trait::async_trait; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::application::ports::opaque_ports::{OpaqueRepositoryPort, StoredEnvelope}; +use crate::common::errors::{DomainError, Result}; + +pub struct OpaquePgRepository { + pool: Arc, +} + +impl OpaquePgRepository { + pub fn new(pool: Arc) -> Self { + Self { pool } + } + + fn pool(&self) -> &PgPool { + &self.pool + } +} + +#[async_trait] +impl OpaqueRepositoryPort for OpaquePgRepository { + async fn write_registration( + &self, + user_id: Uuid, + envelope: &[u8], + ciphersuite_version: i16, + ) -> Result<()> { + // COALESCE preserves the first-registration timestamp across + // re-registrations (password change → new envelope, same + // registered_at). The alternative — always stamping `NOW()` + // — would erase the operational signal "when did this user + // first join OPAQUE," which the migration dashboard reads. + let res = sqlx::query( + r#" + UPDATE auth.users + SET opaque_envelope = $2, + opaque_ciphersuite_version = $3, + opaque_registered_at = COALESCE(opaque_registered_at, NOW()) + WHERE id = $1 + "#, + ) + .bind(user_id) + .bind(envelope) + .bind(ciphersuite_version) + .execute(self.pool()) + .await + .map_err(|e| DomainError::internal_error("OpaquePg", format!("write_registration: {e}")))?; + + if res.rows_affected() == 0 { + // No matching user id — caller expected the user to exist. + // We surface this as NotFound rather than swallowing so the + // handler layer can 404 anti-enum consistently. + return Err(DomainError::not_found("User", user_id.to_string())); + } + Ok(()) + } + + async fn read_registration(&self, user_id: Uuid) -> Result> { + // `try_get` on the envelope column returns None when the row + // exists but the column is NULL (the Phase 0 default for every + // pre-migration account). A missing row propagates as NotFound + // via the same anti-enum path as `write_registration`. + let row = sqlx::query_as::<_, EnvelopeRow>( + r#" + SELECT opaque_envelope, + opaque_ciphersuite_version, + opaque_registered_at + FROM auth.users + WHERE id = $1 + "#, + ) + .bind(user_id) + .fetch_optional(self.pool()) + .await + .map_err(|e| DomainError::internal_error("OpaquePg", format!("read_registration: {e}")))?; + + let Some(row) = row else { + return Err(DomainError::not_found("User", user_id.to_string())); + }; + + // All three columns are NULL together (they're set atomically by + // `write_registration`). Any partial-NULL is a schema-drift + // symptom — return None with a warn so ops can catch it. + match (row.envelope, row.ciphersuite_version, row.registered_at) { + (Some(env), Some(ver), Some(at)) => Ok(Some(StoredEnvelope { + envelope: env, + ciphersuite_version: ver, + registered_at: at, + })), + (None, None, None) => Ok(None), + (env, ver, at) => { + tracing::warn!( + target: "oxicloud::opaque", + user_id = %user_id, + envelope_set = env.is_some(), + version_set = ver.is_some(), + registered_at_set = at.is_some(), + "OPAQUE columns partial-NULL — treating as unregistered. \ + This shouldn't happen; check for a broken migration." + ); + Ok(None) + } + } + } + + 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 + // port doc, avoids drift between two separate writes. + let res = sqlx::query( + r#" + UPDATE auth.users + SET opaque_envelope = NULL, + opaque_ciphersuite_version = NULL, + opaque_registered_at = NULL, + opaque_migrated_at = NULL, + force_password_change_at_next_login = TRUE + WHERE id = $1 + "#, + ) + .bind(user_id) + .execute(self.pool()) + .await + .map_err(|e| DomainError::internal_error("OpaquePg", format!("clear_registration: {e}")))?; + + if res.rows_affected() == 0 { + return Err(DomainError::not_found("User", user_id.to_string())); + } + Ok(()) + } +} + +#[derive(sqlx::FromRow)] +struct EnvelopeRow { + #[sqlx(rename = "opaque_envelope")] + envelope: Option>, + #[sqlx(rename = "opaque_ciphersuite_version")] + ciphersuite_version: Option, + #[sqlx(rename = "opaque_registered_at")] + registered_at: Option>, +} + +#[cfg(integration_tests)] +#[allow(dead_code)] +mod integration_tests { + use super::*; + use crate::integration_test_support::{ensure_clean_test_db, test_db_url}; + use sqlx::postgres::PgPoolOptions; + + async fn test_repo() -> OpaquePgRepository { + let pool = PgPoolOptions::new() + .max_connections(2) + .connect(&test_db_url()) + .await + .expect("connect to integration-test PostgreSQL"); + ensure_clean_test_db(&pool).await; + OpaquePgRepository::new(Arc::new(pool)) + } + + /// Hermetic per-test user seed. `project_test_fixture_self_seeding` + /// memo: don't couple to `init-test-schema.sh`'s implicit admin — + /// mint a fresh user with a unique email so parallel tests don't + /// stomp each other. Returns the new user's id. + async fn seed_user(repo: &OpaquePgRepository, email: &str) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO auth.users ( + id, username, email, password_hash, role, + storage_quota_bytes, storage_used_bytes, + created_at, updated_at, active + ) VALUES ( + $1, NULL, $2, NULL, 'user'::auth.userrole, + 0, 0, NOW(), NOW(), TRUE + ) + "#, + ) + .bind(id) + .bind(email) + .execute(repo.pool()) + .await + .expect("seed test user"); + id + } + + #[tokio::test] + async fn write_then_read_round_trips_envelope_and_version() { + let repo = test_repo().await; + let user = seed_user( + &repo, + &format!("opaque-rt-{}@example.invalid", Uuid::new_v4()), + ) + .await; + + assert!( + repo.read_registration(user) + .await + .expect("read pre") + .is_none(), + "seed user must start with no envelope" + ); + + let payload = b"envelope-v1-bytes".to_vec(); + repo.write_registration(user, &payload, 1) + .await + .expect("write"); + + let stored = repo + .read_registration(user) + .await + .expect("read post") + .expect("envelope now present"); + assert_eq!(stored.envelope, payload); + assert_eq!(stored.ciphersuite_version, 1); + } + + #[tokio::test] + async fn re_registration_preserves_registered_at_but_swaps_envelope() { + // Password change / silent-migration re-mint: fresh envelope + // bytes + potentially a new suite version, but the + // first-registration timestamp must stay stable (ops dashboard + // reads it to know when the user joined OPAQUE). + let repo = test_repo().await; + let user = seed_user( + &repo, + &format!("opaque-rereg-{}@example.invalid", Uuid::new_v4()), + ) + .await; + + repo.write_registration(user, b"first-envelope", 1) + .await + .expect("first write"); + let first = repo.read_registration(user).await.unwrap().unwrap(); + + // Tiny sleep so a bug that overwrites registered_at with NOW() + // would produce a measurably different timestamp. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + repo.write_registration(user, b"second-envelope", 1) + .await + .expect("second write"); + let second = repo.read_registration(user).await.unwrap().unwrap(); + + assert_eq!(second.envelope, b"second-envelope"); + assert_eq!( + second.registered_at, first.registered_at, + "registered_at must be preserved across re-registration (COALESCE guard)" + ); + } + + #[tokio::test] + async fn clear_registration_nulls_columns_and_sets_force_change_flag() { + let repo = test_repo().await; + let user = seed_user( + &repo, + &format!("opaque-clr-{}@example.invalid", Uuid::new_v4()), + ) + .await; + + repo.write_registration(user, b"envelope", 1) + .await + .expect("prime with envelope"); + repo.clear_registration(user) + .await + .expect("clear registration"); + + // Envelope columns are back to NULL. + assert!( + repo.read_registration(user).await.unwrap().is_none(), + "clear must NULL the envelope" + ); + + // Force-change flag is TRUE — this is the load-bearing behaviour + // that keeps admin-set passwords temporary. + let flag: (bool,) = sqlx::query_as( + "SELECT force_password_change_at_next_login FROM auth.users WHERE id = $1", + ) + .bind(user) + .fetch_one(repo.pool()) + .await + .expect("read force-change flag"); + assert!(flag.0, "clear must set force_password_change_at_next_login"); + } + + /// End-to-end proof: run the FULL OPAQUE register handshake + /// client-side against the real ciphersuite, persist the resulting + /// envelope through this repository, read it back on a fresh + /// connection, and use those bytes to complete a real login + /// handshake. This is the load-bearing test for Phase 1 Step 3 — + /// it proves the shape the register endpoints will land on: + /// + /// client_register.start + /// → ServerRegistration::start (server-side, produces response) + /// client_register.finish + /// → ServerRegistration::finish → serialize → repo.write + /// repo.read + /// → ServerRegistration::deserialize + /// → ServerLogin::start (with the stored password_file) + /// client_login.finish → ServerLogin::finish + /// → session_key matches + /// + /// If any of these steps drift (envelope shape change, ciphersuite + /// mismatch, serialisation format regression), this test catches + /// it — without needing to spin up an HTTP server. + #[tokio::test] + async fn envelope_persists_across_register_and_serves_a_matching_login() { + use crate::infrastructure::services::opaque_service::OxiCloudSuite; + use opaque_ke::{ + ClientLogin, ClientLoginFinishParameters, ClientRegistration, + ClientRegistrationFinishParameters, ServerLogin, ServerLoginStartParameters, + ServerRegistration, ServerSetup, + }; + use rand_core::OsRng; + + let repo = test_repo().await; + let user = seed_user( + &repo, + &format!("opaque-e2e-{}@example.invalid", Uuid::new_v4()), + ) + .await; + + // Fresh server setup for this test only — mirrors what the + // DI factory would load from OXICLOUD_OPAQUE_SERVER_SETUP. + let mut server_rng = OsRng; + let server_setup = ServerSetup::::new(&mut server_rng); + + // Fast KSF so the test finishes in ms rather than seconds. + let ksf = argon2::Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + argon2::Params::new(8, 1, 1, None).unwrap(), + ); + let mut client_rng = OsRng; + let user_bytes = user.as_bytes(); + let passphrase = b"correct horse battery staple"; + + // ── REGISTER — same shape the /register/{start,finish} handlers run ─ + let client_reg = ClientRegistration::::start(&mut client_rng, passphrase) + .expect("client_register.start"); + let server_reg = ServerRegistration::::start( + &server_setup, + client_reg.message, + user_bytes, + ) + .expect("server_register.start"); + let client_reg_finish = client_reg + .state + .finish( + &mut client_rng, + passphrase, + server_reg.message, + ClientRegistrationFinishParameters::new( + opaque_ke::Identifiers::default(), + Some(&ksf), + ), + ) + .expect("client_register.finish"); + let password_file = ServerRegistration::::finish(client_reg_finish.message); + + // Persist through the repo — this is exactly what `register/finish` + // will do at the handler layer. `.serialize()` returns a + // `GenericArray`; convert to `Vec` at the boundary so + // downstream comparisons stay simple. + let envelope_bytes: Vec = password_file.serialize().to_vec(); + repo.write_registration(user, &envelope_bytes, 1) + .await + .expect("persist envelope"); + + // ── LOGIN — reads the envelope back the way `login/ke1` will ───── + let stored = repo + .read_registration(user) + .await + .expect("read") + .expect("envelope present after write"); + assert_eq!(stored.ciphersuite_version, 1); + assert_eq!( + stored.envelope, envelope_bytes, + "stored bytes must round-trip verbatim" + ); + + let password_file_back = ServerRegistration::::deserialize(&stored.envelope) + .expect("deserialize stored envelope"); + + let client_login = ClientLogin::::start(&mut client_rng, passphrase) + .expect("client_login.start"); + let server_login = ServerLogin::start( + &mut server_rng, + &server_setup, + Some(password_file_back), + client_login.message, + user_bytes, + ServerLoginStartParameters::default(), + ) + .expect("server_login.start (with stored envelope)"); + let client_login_finish = client_login + .state + .finish( + passphrase, + server_login.message, + ClientLoginFinishParameters::new( + None, + opaque_ke::Identifiers::default(), + Some(&ksf), + ), + ) + .expect("client_login.finish"); + let server_login_finish = server_login + .state + .finish(client_login_finish.message) + .expect("server_login.finish"); + + assert_eq!( + client_login_finish.session_key.as_slice(), + server_login_finish.session_key.as_slice(), + "session keys must match after a full register → persist → login round trip" + ); + } + + #[tokio::test] + async fn missing_user_surfaces_notfound_on_write_and_read_and_clear() { + let repo = test_repo().await; + let ghost = Uuid::new_v4(); + + for err in [ + repo.write_registration(ghost, b"whatever", 1) + .await + .unwrap_err(), + repo.read_registration(ghost).await.unwrap_err(), + repo.clear_registration(ghost).await.unwrap_err(), + ] { + assert_eq!( + err.kind, + crate::common::errors::ErrorKind::NotFound, + "missing-user path must surface as NotFound (anti-enum)" + ); + } + } +} diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 52779992..e912d60f 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -35,6 +35,7 @@ pub mod noop_face_analyzer; pub mod oidc_service; #[cfg(feature = "faces-onnx")] pub mod onnx_face_analyzer; +pub mod opaque_login_exchange; pub mod opaque_service; pub mod password_hasher; pub mod path_resolver_service; diff --git a/src/infrastructure/services/opaque_login_exchange.rs b/src/infrastructure/services/opaque_login_exchange.rs new file mode 100644 index 00000000..deb29656 --- /dev/null +++ b/src/infrastructure/services/opaque_login_exchange.rs @@ -0,0 +1,252 @@ +//! In-memory login-exchange cache for OPAQUE aPAKE (Phase 1). +//! +//! OPAQUE login is a two-round exchange. KE1 arrives from the client +//! and produces a [`ServerLogin`] state that must survive until the +//! matching KE3 arrives (usually within a few hundred milliseconds). +//! This module holds that state between the two round-trips, keyed by +//! a random `exchange_id: Uuid` handed back to the client on KE1. +//! +//! ## Why in-memory +//! +//! Single-instance deployments (the current OxiCloud shape) can use a +//! process-local cache without correctness issues — KE1 and KE3 always +//! hit the same server. Multi-instance deployments (Phase 5+ if we go +//! there) would need to swap the backing to Redis or session-affinity +//! at the load balancer; the shape of this module (`store`/`take`) +//! stays identical, so the swap would be local. +//! +//! ## Why NOT put the state in a cookie +//! +//! The naive alternative — "cookie the ServerLogin state client-side" — +//! would leak the server's ephemeral private key material (the KE1 +//! response bakes it in). Even if AEAD-wrapped with a server secret, +//! that AEAD key becomes another crown jewel to rotate. Server-side +//! storage keyed by a random opaque handle is simpler and correct. +//! +//! ## Single-use semantics +//! +//! An `exchange_id` MUST be consumed at most once. Two concurrent KE3s +//! with the same id would be either an accidental double-submit or a +//! replay attempt; either way, the second must fail. We use moka's +//! atomic [`Cache::remove`] (get-and-invalidate in one call — verified +//! in moka 0.12+ source) so there's no race window between "state +//! exists" and "state consumed". +//! +//! ## TTL +//! +//! 60s is the ceiling for a normal OPAQUE login round-trip (KE1 +//! response → user typed nothing new → client computes KE3 → sends). +//! Beyond that, the state is stale — the client would have to start +//! over anyway. Bounded capacity (default 10k concurrent exchanges, +//! LRU-evicted) caps memory even under a burst / abuse pattern. + +use std::sync::Arc; +use std::time::Duration; + +use moka::sync::Cache; +use opaque_ke::ServerLogin; +use uuid::Uuid; + +use crate::infrastructure::services::opaque_service::OxiCloudSuite; + +/// Default lifetime of a login exchange between KE1 and KE3. +/// +/// Chosen empirically to cover slow client CPUs (Argon2id on mobile +/// can take ~1s with production KSF params) plus a network buffer. +/// Extending beyond 60s is a security taste question; shortening +/// under ~15s starts failing legitimate slow clients. +pub const DEFAULT_TTL_SECS: u64 = 60; + +/// Default maximum concurrent in-flight exchanges. Bounds memory in +/// case of an attack or bug that spams KE1 without ever sending KE3. +/// Each entry is roughly the size of a `ServerLogin` +/// (~200 bytes with the Ristretto255 keypair + AKE state), so 10k +/// entries is a couple of MB total — well below "worry" territory. +pub const DEFAULT_MAX_INFLIGHT: u64 = 10_000; + +/// Handle passed to the client on KE1 that the client must echo back +/// on KE3. Opaque random UUID — nothing about the server state is +/// derivable from it, so leaking it only enables a race that STILL +/// requires a valid KE3 payload to succeed (which requires the +/// correct passphrase). +pub type ExchangeId = Uuid; + +/// In-memory cache holding server-side login state between KE1 and KE3. +#[derive(Clone)] +pub struct OpaqueLoginExchange { + inner: Arc>>, +} + +impl OpaqueLoginExchange { + /// Build a cache with production defaults ([`DEFAULT_TTL_SECS`], + /// [`DEFAULT_MAX_INFLIGHT`]). Callers wanting tighter TTL for + /// tests should use [`Self::with_params`]. + pub fn new() -> Self { + Self::with_params(Duration::from_secs(DEFAULT_TTL_SECS), DEFAULT_MAX_INFLIGHT) + } + + /// Build with explicit TTL + capacity. Used by tests to shrink the + /// TTL so expiry paths can be exercised in milliseconds. + pub fn with_params(ttl: Duration, max_capacity: u64) -> Self { + let inner = Cache::builder() + .time_to_live(ttl) + .max_capacity(max_capacity) + .build(); + Self { + inner: Arc::new(inner), + } + } + + /// Stash a fresh `ServerLogin` state and return the handle to + /// hand back to the client. The exchange_id is generated here so + /// callers can't accidentally reuse one — every KE1 gets its own. + pub fn store(&self, state: ServerLogin) -> ExchangeId { + let id = Uuid::new_v4(); + self.inner.insert(id, state); + id + } + + /// Atomically consume the state for `exchange_id`. Returns `None` + /// if the id is unknown, already consumed, or expired. Callers + /// must treat those three cases identically (anti-enum): a KE3 + /// with a bad id, a replay, and a timeout should all surface as + /// the same `InvalidCredentials` shape to the client. + /// + /// Uses moka's atomic `remove` (verified single get-and-invalidate + /// in moka 0.12+, no race window between the two operations). + pub fn take(&self, exchange_id: ExchangeId) -> Option> { + self.inner.remove(&exchange_id) + } + + /// Force runtime maintenance (LRU eviction + TTL sweep). Moka runs + /// these opportunistically on `insert`/`get` too; test code calls + /// this after fast-forwarding time so expiry assertions are + /// deterministic without waiting on background threads. + #[cfg(test)] + fn run_pending_tasks(&self) { + self.inner.run_pending_tasks(); + } +} + +impl Default for OpaqueLoginExchange { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::config::OpaqueConfig; + use crate::infrastructure::services::opaque_service::{ + OpaqueMode, OpaqueService, OxiCloudSuite, + }; + use opaque_ke::{ + ClientLogin, ClientRegistration, ClientRegistrationFinishParameters, + ServerLoginStartParameters, ServerRegistration, + }; + use rand_core::OsRng; + + /// Build a real `ServerLogin` to stash — the type + /// is generic and can't be `Default`ed, so we drive a mini + /// registration + KE1 to produce one. Slower than a fake, but + /// this exercises the full crate type-plumb. + fn build_server_login_state() -> ServerLogin { + let svc = OpaqueService::from_config(OpaqueConfig { + mode: OpaqueMode::Migrate, + server_setup_b64: Some(OpaqueService::generate_server_setup_b64()), + ..OpaqueConfig::default() + }) + .expect("build service"); + + let ksf = argon2::Argon2::new( + argon2::Algorithm::Argon2id, + argon2::Version::V0x13, + argon2::Params::new(8, 1, 1, None).unwrap(), + ); + let mut rng = OsRng; + let user = b"alice@example.com"; + let pass = b"pw"; + + // Registration to prime the password file. + let client_reg = ClientRegistration::::start(&mut rng, pass).unwrap(); + let server_reg = + ServerRegistration::::start(svc.setup(), client_reg.message, user) + .unwrap(); + let client_reg_finish = client_reg + .state + .finish( + &mut rng, + pass, + server_reg.message, + ClientRegistrationFinishParameters::new( + opaque_ke::Identifiers::default(), + Some(&ksf), + ), + ) + .unwrap(); + let password_file = ServerRegistration::::finish(client_reg_finish.message); + + // KE1 to produce the ServerLogin state we want to stash. + let client_login = ClientLogin::::start(&mut rng, pass).unwrap(); + opaque_ke::ServerLogin::start( + &mut rng, + svc.setup(), + Some(password_file), + client_login.message, + user, + ServerLoginStartParameters::default(), + ) + .unwrap() + .state + } + + #[test] + fn store_and_take_round_trip_returns_the_same_state_once() { + let cache = OpaqueLoginExchange::with_params(Duration::from_secs(60), 100); + let state = build_server_login_state(); + + let id = cache.store(state); + // Second call after take must miss — single-use semantic. + assert!(cache.take(id).is_some(), "first take retrieves the state"); + assert!( + cache.take(id).is_none(), + "second take must miss — exchange_id is single-use" + ); + } + + #[test] + fn unknown_id_returns_none() { + let cache = OpaqueLoginExchange::new(); + assert!(cache.take(Uuid::new_v4()).is_none()); + } + + #[test] + fn expired_state_is_evicted_and_take_returns_none() { + // 100ms TTL so the test finishes fast without wall-clock sleep + // beyond that. Moka's TTL is not perfectly wall-clock precise + // (it runs pending tasks lazily), so `run_pending_tasks` + // forces a deterministic sweep. + let cache = OpaqueLoginExchange::with_params(Duration::from_millis(100), 100); + let id = cache.store(build_server_login_state()); + + std::thread::sleep(Duration::from_millis(150)); + cache.run_pending_tasks(); + + assert!( + cache.take(id).is_none(), + "state must be evicted after TTL — replay attempts past 60s must fail" + ); + } + + #[test] + fn store_yields_distinct_exchange_ids_per_call() { + // Two KE1s from the same user MUST get different exchange_ids + // — reusing one would enable a KE3 to consume the wrong + // exchange's state. + let cache = OpaqueLoginExchange::new(); + let a = cache.store(build_server_login_state()); + let b = cache.store(build_server_login_state()); + assert_ne!(a, b, "each store() must mint a fresh UUID"); + } +} diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index a0885aba..a6a945cf 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -19,6 +19,7 @@ pub mod grant_handler; pub mod i18n_handler; pub mod magic_link_handler; pub mod music_handler; +pub mod opaque_auth_handler; pub mod people_handler; pub mod photos_handler; pub mod recent_handler; diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs new file mode 100644 index 00000000..cb6fa5bf --- /dev/null +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -0,0 +1,302 @@ +//! OPAQUE aPAKE (RFC 9807) HTTP handlers — Phase 1. +//! +//! Two round-trips per operation: +//! +//! ```text +//! Registration (session-authenticated): +//! POST /api/auth/opaque/register/start +//! { registrationRequest: base64 } +//! → { registrationResponse: base64 } +//! POST /api/auth/opaque/register/finish +//! { registrationRecord: base64, ciphersuiteVersion: i16 } +//! → 204 No Content +//! +//! Login (unauth) — LANDS IN A LATER STEP OF PHASE 1 +//! POST /api/auth/opaque/login/ke1 +//! POST /api/auth/opaque/login/ke3 +//! ``` +//! +//! ## Why session-authenticated for registration +//! +//! Registration binds an envelope to a user_id. That id has to come +//! from somewhere the server trusts — not from the request body +//! (attacker could bind an envelope to someone else's account) and +//! not from the OPAQUE handshake itself (the OPAQUE handshake IS +//! what we're bootstrapping; chicken-and-egg). The pragmatic answer, +//! matching Bitwarden / Proton / 1Password: the user proves identity +//! via the existing legacy password login (or magic-link, or OIDC) +//! ONCE, then registers their OPAQUE envelope from that session. +//! Phase 2 wires this as a silent hook after every legacy login. +//! +//! ## Payload encoding +//! +//! All OPAQUE messages are opaque byte blobs. We serialise them as +//! **standard base64** (not URL-safe, no padding-strip) because the +//! WASM client (`@serenity-kit/opaque`) emits the same shape and both +//! ends need to agree on one flavour. Round-tripped through +//! `serde_json` as a `String` field. +//! +//! ## Ciphersuite version handshake +//! +//! `register/finish` requires the client to echo back the +//! `ciphersuiteVersion` it minted the envelope under. That must match +//! the server's currently-configured version — a mismatch means the +//! client cached stale params (or the server rotated the suite). We +//! reject with `OpaqueCiphersuiteMismatch` so the SPA can prompt the +//! user to refresh and try again. +//! +//! ## What this handler does NOT do +//! +//! - Login endpoints (KE1 / KE3) — separate step in Phase 1. +//! - Silent-migration integration into legacy `/api/auth/login` — +//! Phase 2 concern; this handler ships the ENDPOINT, migration +//! plumbs the CALL SITE. +//! - Rate limiting — the register endpoints are session-authenticated, +//! so an attacker would need a stolen session to reach them; the +//! session's issuance path already carries its own rate limit. +//! Login endpoints (once shipped) share a budget with legacy login. + +use std::sync::Arc; + +use axum::Router; +use axum::extract::{Json, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::post; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as B64; +use opaque_ke::{RegistrationRequest, RegistrationUpload, ServerRegistration}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::common::di::AppState; +use crate::infrastructure::services::opaque_service::{OpaqueService, OxiCloudSuite}; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::CurrentUserId; + +/// Session-required OPAQUE routes. Callers layer the auth + CSRF +/// middlewares in `main.rs` (mirrors [`auth_protected_routes`]). +pub fn opaque_register_routes() -> Router> { + Router::new() + .route("/opaque/register/start", post(register_start)) + .route("/opaque/register/finish", post(register_finish)) +} + +/// Client → server on register KE1. `registrationRequest` is the +/// base64-encoded output of the client's +/// `ClientRegistration::start(...).message`. +#[derive(Debug, Deserialize, ToSchema)] +pub struct OpaqueRegisterStartDto { + #[serde(rename = "registrationRequest")] + pub registration_request: String, +} + +/// Server → client on register KE1. `registrationResponse` is the +/// base64-encoded output of `ServerRegistration::start(...).message`. +/// Also echoes `ciphersuiteVersion` so the client can reject a +/// mid-flight suite change and abort before uploading. +#[derive(Debug, Serialize, ToSchema)] +pub struct OpaqueRegisterStartResponse { + #[serde(rename = "registrationResponse")] + pub registration_response: String, + #[serde(rename = "ciphersuiteVersion")] + pub ciphersuite_version: i16, +} + +/// Client → server on register KE2. `registrationRecord` is the +/// base64-encoded output of `ClientRegistration::finish(...).message`; +/// `ciphersuiteVersion` is what the client believed it was minting +/// under (compared to the current server value — mismatch → 400). +#[derive(Debug, Deserialize, ToSchema)] +pub struct OpaqueRegisterFinishDto { + #[serde(rename = "registrationRecord")] + pub registration_record: String, + #[serde(rename = "ciphersuiteVersion")] + pub ciphersuite_version: i16, +} + +/// KE1 (register/start): parse client `RegistrationRequest`, run +/// `ServerRegistration::start`, return the server response. +/// +/// No state is persisted server-side by this call — registration is +/// stateless on the server between KE1 and KE2 (the `RegistrationRequest` +/// carries the client's OPRF blinding; the response includes the +/// server's static pubkey; the client alone knows the seed). +#[utoipa::path( + post, + path = "/api/auth/opaque/register/start", + request_body = OpaqueRegisterStartDto, + responses( + (status = 200, description = "Server registration response", body = OpaqueRegisterStartResponse), + (status = 400, description = "Malformed registration request"), + (status = 401, description = "Not authenticated"), + (status = 503, description = "OPAQUE service not configured"), + ), + security(("bearerAuth" = [])), + tag = "auth" +)] +pub async fn register_start( + State(state): State>, + CurrentUserId(user_id): CurrentUserId, + Json(dto): Json, +) -> Result { + let svc = require_opaque_service(&state)?; + + let req_bytes = B64 + .decode(dto.registration_request.trim()) + .map_err(|_| malformed("registrationRequest is not valid base64"))?; + let req = RegistrationRequest::::deserialize(&req_bytes) + .map_err(|_| malformed("registrationRequest failed to deserialize"))?; + + // `user_id` (a UUID) is the OPAQUE server-side user identifier. + // Encoded as the UUID's raw bytes so the same identifier bytes + // appear on both sides regardless of string representation — the + // client passes the same encoding via KE1's login step (Phase 1 + // login endpoints will mirror this decision). + let user_bytes = user_id.as_bytes(); + + let result = + ServerRegistration::::start(svc.setup(), req, user_bytes).map_err(|e| { + tracing::warn!( + target: "audit", + event = "opaque.register_start_failed", + reason = "server_start_error", + user_id = %user_id, + error = %e, + "OPAQUE server registration start failed" + ); + AppError::bad_request("OPAQUE server registration failed") + })?; + + let response_b64 = B64.encode(result.message.serialize()); + Ok(Json(OpaqueRegisterStartResponse { + registration_response: response_b64, + ciphersuite_version: svc.ciphersuite_version(), + })) +} + +/// KE2 (register/finish): parse client `RegistrationUpload`, finalise +/// via `ServerRegistration::finish`, persist the resulting envelope +/// bytes. Idempotent-per-user: re-running finish overwrites the +/// existing envelope (COALESCEing `opaque_registered_at`). +#[utoipa::path( + post, + path = "/api/auth/opaque/register/finish", + request_body = OpaqueRegisterFinishDto, + responses( + (status = 204, description = "Registration persisted"), + (status = 400, description = "Malformed record or ciphersuite mismatch"), + (status = 401, description = "Not authenticated"), + (status = 503, description = "OPAQUE service not configured"), + ), + security(("bearerAuth" = [])), + tag = "auth" +)] +pub async fn register_finish( + State(state): State>, + CurrentUserId(user_id): CurrentUserId, + Json(dto): Json, +) -> Result { + let svc = require_opaque_service(&state)?; + let repo = require_opaque_repo(&state)?; + + // Ciphersuite mismatch is a hard fail — the envelope the client + // is about to upload would be unusable under the server's current + // suite, so persisting it would just delay the failure to login + // time. Reject at KE2 with a machine-readable error_type so the + // SPA can prompt a page refresh + retry. + if dto.ciphersuite_version != svc.ciphersuite_version() { + tracing::info!( + target: "audit", + event = "opaque.register_rejected", + reason = "ciphersuite_mismatch", + user_id = %user_id, + client_version = dto.ciphersuite_version, + server_version = svc.ciphersuite_version(), + "👮🏻‍♂️ OPAQUE ciphersuite mismatch — client must refresh params and retry" + ); + return Err(AppError::new( + StatusCode::BAD_REQUEST, + format!( + "ciphersuiteVersion mismatch: client={}, server={}", + dto.ciphersuite_version, + svc.ciphersuite_version() + ), + "OpaqueCiphersuiteMismatch", + )); + } + + let record_bytes = B64 + .decode(dto.registration_record.trim()) + .map_err(|_| malformed("registrationRecord is not valid base64"))?; + let record = RegistrationUpload::::deserialize(&record_bytes) + .map_err(|_| malformed("registrationRecord failed to deserialize"))?; + + // `ServerRegistration::finish` in opaque-ke 3.x is pure — it + // packages the client's upload into a persistable form. No + // server-side state, no side effect. We write those bytes as the + // envelope; login-time `ServerLogin::start` will read them back. + let stored = ServerRegistration::::finish(record); + let envelope_bytes = stored.serialize(); + + repo.write_registration(user_id, &envelope_bytes, svc.ciphersuite_version()) + .await + .map_err(|e| { + tracing::error!( + target: "audit", + event = "opaque.register_persist_failed", + reason = "repo_write_error", + user_id = %user_id, + error = %e, + "OPAQUE envelope persistence failed" + ); + AppError::internal_error("OPAQUE envelope persistence failed") + })?; + + tracing::info!( + target: "audit", + event = "opaque.register_ok", + user_id = %user_id, + ciphersuite_version = svc.ciphersuite_version(), + envelope_bytes = envelope_bytes.len(), + "OPAQUE registration persisted" + ); + + Ok(StatusCode::NO_CONTENT) +} + +// ── Small helpers ──────────────────────────────────────────────────── + +fn require_opaque_service(state: &Arc) -> Result, AppError> { + state.opaque_service.clone().ok_or_else(|| { + AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "OPAQUE is not enabled on this server", + "OpaqueDisabled", + ) + }) +} + +fn require_opaque_repo( + state: &Arc, +) -> Result, AppError> { + state.opaque_repo.clone().ok_or_else(|| { + AppError::new( + StatusCode::SERVICE_UNAVAILABLE, + "OPAQUE persistence is not wired", + "OpaqueDisabled", + ) + }) +} + +fn malformed(msg: &'static str) -> AppError { + AppError::new(StatusCode::BAD_REQUEST, msg, "OpaqueMalformedRequest") +} + +// Discourage silent conversions of the `Uuid` extractor into `_` — +// forces future callers to acknowledge it. +#[allow(dead_code)] +fn _uuid_extractor_marker(u: Uuid) -> Uuid { + u +} diff --git a/src/main.rs b/src/main.rs index 11acf93e..5f53c5f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -790,6 +790,19 @@ async fn run() -> Result<(), Box> { auth_middleware, )) .with_state(app_state.clone()); + // OPAQUE register routes — require auth + CSRF. The handlers + // return 503 `OpaqueDisabled` when the substrate isn't wired + // (mode=off or password auth disabled), so mounting them + // unconditionally is safe: the mode gate lives in the DI + // factory, not the router. + let opaque_register_protected = + oxicloud::interfaces::api::handlers::opaque_auth_handler::opaque_register_routes() + .layer(axum::middleware::from_fn(csrf_middleware)) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + auth_middleware, + )) + .with_state(app_state.clone()); // One-time setup route — public, rate-limited like register let setup_router = setup_route() .layer(axum::middleware::from_fn_with_state( @@ -896,6 +909,12 @@ async fn run() -> Result<(), Box> { "/api/auth", app_pw_protected.layer(access_log!("http::api::auth")), ) + // OPAQUE aPAKE — session-required register endpoints. Login + // endpoints (public) are mounted in a later Phase 1 step. + .nest( + "/api/auth", + opaque_register_protected.layer(access_log!("http::api::auth")), + ) // One-time setup endpoint — public, rate-limited .nest("/api", setup_router.layer(access_log!("http::api"))) // Device Auth Grant public endpoints (authorize + token polling)