feat(opaque): add login opaque exchange
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<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<()>;
|
||||
}
|
||||
@@ -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<dyn OpaqueRepositoryPort>` 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<dyn crate::application::ports::opaque_ports::OpaqueRepositoryPort>,
|
||||
> = 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<Arc<AppPasswordService>> =
|
||||
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<crate::infrastructure::services::opaque_service::OpaqueService>,
|
||||
>,
|
||||
/// 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<dyn crate::application::ports::opaque_ports::OpaqueRepositoryPort>,
|
||||
>,
|
||||
/// 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<crate::infrastructure::services::opaque_login_exchange::OpaqueLoginExchange>,
|
||||
>,
|
||||
pub nextcloud: Option<NextcloudServices>,
|
||||
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
||||
/// WASM plugin management (list/install/toggle/remove), backing the admin
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<PgPool>,
|
||||
}
|
||||
|
||||
impl OpaquePgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> 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<Option<StoredEnvelope>> {
|
||||
// `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<Vec<u8>>,
|
||||
#[sqlx(rename = "opaque_ciphersuite_version")]
|
||||
ciphersuite_version: Option<i16>,
|
||||
#[sqlx(rename = "opaque_registered_at")]
|
||||
registered_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[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::<OxiCloudSuite>::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::<OxiCloudSuite>::start(&mut client_rng, passphrase)
|
||||
.expect("client_register.start");
|
||||
let server_reg = ServerRegistration::<OxiCloudSuite>::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::<OxiCloudSuite>::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<u8>` at the boundary so
|
||||
// downstream comparisons stay simple.
|
||||
let envelope_bytes: Vec<u8> = 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::<OxiCloudSuite>::deserialize(&stored.envelope)
|
||||
.expect("deserialize stored envelope");
|
||||
|
||||
let client_login = ClientLogin::<OxiCloudSuite>::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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<OxiCloudSuite>`
|
||||
/// (~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<Cache<ExchangeId, ServerLogin<OxiCloudSuite>>>,
|
||||
}
|
||||
|
||||
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<OxiCloudSuite>) -> 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<ServerLogin<OxiCloudSuite>> {
|
||||
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<OxiCloudSuite>` 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<OxiCloudSuite> {
|
||||
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::<OxiCloudSuite>::start(&mut rng, pass).unwrap();
|
||||
let server_reg =
|
||||
ServerRegistration::<OxiCloudSuite>::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::<OxiCloudSuite>::finish(client_reg_finish.message);
|
||||
|
||||
// KE1 to produce the ServerLogin state we want to stash.
|
||||
let client_login = ClientLogin::<OxiCloudSuite>::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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Arc<AppState>> {
|
||||
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<Arc<AppState>>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Json(dto): Json<OpaqueRegisterStartDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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::<OxiCloudSuite>::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::<OxiCloudSuite>::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<Arc<AppState>>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Json(dto): Json<OpaqueRegisterFinishDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
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::<OxiCloudSuite>::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::<OxiCloudSuite>::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<AppState>) -> Result<Arc<OpaqueService>, 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<AppState>,
|
||||
) -> Result<Arc<dyn crate::application::ports::opaque_ports::OpaqueRepositoryPort>, 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
|
||||
}
|
||||
+19
@@ -790,6 +790,19 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
"/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)
|
||||
|
||||
Reference in New Issue
Block a user