feat(auth): bring opaque (RFC 9807) auth

OPAQUE (RFC 9807) implementation (using `opaque-ke` crate)

    with opaque authentfication, server will never receive the password (in the auth=password mode)
    this is a must have to create trust with users to permit end to end encryption in the future
    (we cannot know if user use the same password/passphrase for his asymetric key or his oxicloud auth,
    this is why server must never have the password)

    pass1: prepare server
This commit is contained in:
Edouard Vanbelle
2026-07-26 15:04:31 +02:00
parent d76803f602
commit 0e395ae15f
19 changed files with 1570 additions and 7 deletions
+34
View File
@@ -0,0 +1,34 @@
//! `opaque-setup` — one-shot operator helper that mints a fresh
//! [`opaque_ke::ServerSetup`] and prints its base64 encoding to stdout.
//!
//! The output goes into `OXICLOUD_OPAQUE_SERVER_SETUP` (env var or secrets
//! manager) and MUST be persisted verbatim. Rotating it invalidates every
//! user's registration — treat it like the JWT secret, only more so.
//!
//! Usage:
//! ```text
//! cargo run --bin opaque-setup > opaque_setup.b64
//! # or paste directly into your env / .env file:
//! echo "OXICLOUD_OPAQUE_SERVER_SETUP=$(cargo run --bin opaque-setup)" >> .env
//! ```
//!
//! The generated value is a small (~64 byte) Ristretto255 keypair
//! serialised for storage. Nothing else — no config file, no key
//! rotation state. Idempotent per invocation (each run generates a
//! DIFFERENT value; only run it once per deployment).
use oxicloud::infrastructure::services::opaque_service::OpaqueService;
fn main() {
let b64 = OpaqueService::generate_server_setup_b64();
// Print JUST the value — no trailing newline commentary — so shell
// pipelines (`OXICLOUD_OPAQUE_SERVER_SETUP=$(cargo run --bin opaque-setup)`)
// capture cleanly without needing `tr -d '\n'` afterwards.
println!("{b64}");
// Guidance goes to stderr so it doesn't contaminate the pipeline.
eprintln!();
eprintln!("=== OPAQUE server setup generated. ===");
eprintln!("Persist the line above in OXICLOUD_OPAQUE_SERVER_SETUP.");
eprintln!("NEVER rotate: rotating invalidates every user's registration.");
eprintln!("Treat this value like your JWT secret.");
}
+222
View File
@@ -1648,6 +1648,156 @@ impl Default for OidcConfig {
}
}
/// OPAQUE aPAKE configuration (RFC 9807, Phase 0 substrate).
///
/// OPAQUE is a zero-knowledge password-authenticated key exchange: the
/// passphrase never leaves the client. This struct carries the runtime
/// knobs the server needs (mode, ciphersuite version, persisted
/// [`ServerSetup`] blob) plus the client-side KSF params the SPA reads
/// out of `/api/health` to configure its Argon2.
///
/// **The KSF params are client-side.** RFC 9807 runs Argon2 on the client
/// before the OPRF exchange; the server never invokes it. The params live
/// here so the operator has one source of truth and the SPA can fetch
/// them at page load — changing them requires re-registration for
/// affected users.
#[derive(Debug, Clone)]
pub struct OpaqueConfig {
/// Runtime mode gate. See
/// [`crate::infrastructure::services::opaque_service::OpaqueMode`]
/// for the state-machine and the phase-plan mapping.
///
/// Env: `OXICLOUD_OPAQUE_MODE` (`off` | `migrate` | `opaque_only`).
/// Default: `off`.
pub mode: crate::infrastructure::services::opaque_service::OpaqueMode,
/// Base64-encoded [`opaque_ke::ServerSetup`] blob. Generated once
/// per deployment and persisted verbatim — rotating this invalidates
/// every user's registration. Runbook: on first boot with
/// `OXICLOUD_OPAQUE_MODE != off`, if this is unset, print a fatal
/// message with a fresh setup for the operator to paste into their
/// env, then exit.
///
/// Env: `OXICLOUD_OPAQUE_SERVER_SETUP`. No default.
pub server_setup_b64: Option<String>,
/// Ciphersuite version stamped into `auth.users.opaque_ciphersuite_version`
/// on registration. Bumping this without changing the actual
/// ciphersuite type alias in the service module is meaningless;
/// bumping this WITH a type change invalidates all envelopes.
///
/// Env: not exposed. Compile-time constant, currently `1`.
pub ciphersuite_version: i16,
/// Client-side Argon2id memory cost in KiB. Published to the SPA so
/// the client can construct a matching `argon2::Argon2` before
/// running `ClientRegistration::start` / `ClientLogin::start`.
///
/// Env: `OXICLOUD_OPAQUE_KSF_MEMORY_KIB`. Default: `262144` (256 MiB).
pub ksf_memory_kib: u32,
/// Client-side Argon2id iteration count.
///
/// Env: `OXICLOUD_OPAQUE_KSF_ITERATIONS`. Default: `3`.
pub ksf_iterations: u32,
/// Client-side Argon2id parallelism (lanes).
///
/// Env: `OXICLOUD_OPAQUE_KSF_PARALLELISM`. Default: `4`.
pub ksf_parallelism: u32,
}
impl Default for OpaqueConfig {
fn default() -> Self {
Self {
mode: crate::infrastructure::services::opaque_service::OpaqueMode::Off,
server_setup_b64: None,
ciphersuite_version: 1,
ksf_memory_kib: 262_144,
ksf_iterations: 3,
ksf_parallelism: 4,
}
}
}
impl OpaqueConfig {
/// Load OPAQUE configuration from environment variables. Mirrors the
/// pattern used by [`OidcConfig::from_env`] — every field falls back
/// to the [`Default`] impl when unset, so the config is safe to
/// construct even in `Off` mode.
pub fn from_env() -> Self {
use std::env;
let mut cfg = Self::default();
if let Ok(v) = env::var("OXICLOUD_OPAQUE_MODE") {
match crate::infrastructure::services::opaque_service::OpaqueMode::parse(&v) {
Some(m) => cfg.mode = m,
None => {
tracing::warn!(
target: "oxicloud::config",
value = %v,
"OXICLOUD_OPAQUE_MODE has an unrecognised value — keeping default (off). \
Accepted: off | migrate | opaque_only"
);
}
}
}
if let Ok(v) = env::var("OXICLOUD_OPAQUE_SERVER_SETUP") {
cfg.server_setup_b64 = Some(v);
}
if let Ok(v) = env::var("OXICLOUD_OPAQUE_KSF_MEMORY_KIB")
&& let Ok(n) = v.parse::<u32>()
{
cfg.ksf_memory_kib = n;
}
if let Ok(v) = env::var("OXICLOUD_OPAQUE_KSF_ITERATIONS")
&& let Ok(n) = v.parse::<u32>()
{
cfg.ksf_iterations = n;
}
if let Ok(v) = env::var("OXICLOUD_OPAQUE_KSF_PARALLELISM")
&& let Ok(n) = v.parse::<u32>()
{
cfg.ksf_parallelism = n;
}
cfg
}
/// Runtime mode after cross-checking against the auth-method allowlist.
///
/// OPAQUE is fundamentally a **password** mechanism — its only reason
/// to exist is to replace `POST /api/auth/login`. An operator running
/// OIDC-only or magic-link-only (`OXICLOUD_AUTH_METHODS=oidc` or
/// `=magic_link`) has no password path for OPAQUE to shadow; any
/// non-`Off` mode would be a no-op that still nagged them for
/// `OXICLOUD_OPAQUE_SERVER_SETUP` at boot.
///
/// This helper resolves the misconfig quietly: if password isn't in
/// the allowlist AND OPAQUE mode is non-`Off`, we downgrade to `Off`
/// and emit an audit-channel INFO explaining why (so it shows up in
/// operator log tailing without being a startup warning that fails
/// health checks). Every OPAQUE-facing caller — the DI factory, the
/// endpoint router, the migration hook — MUST read this and never
/// touch `self.mode` directly.
pub fn effective_mode(
&self,
auth: &AuthConfig,
) -> crate::infrastructure::services::opaque_service::OpaqueMode {
use crate::infrastructure::services::opaque_service::OpaqueMode;
if self.mode == OpaqueMode::Off {
return OpaqueMode::Off;
}
if !auth.is_method_allowed(AuthMethod::Password) {
tracing::info!(
target: "audit",
event = "opaque.mode_downgraded",
reason = "password_auth_disabled",
configured_mode = ?self.mode,
"OXICLOUD_OPAQUE_MODE is configured but password auth is disabled \
via OXICLOUD_AUTH_METHODS — treating OPAQUE as off. \
OPAQUE only replaces the password login path; enable password \
in OXICLOUD_AUTH_METHODS to make this setting take effect."
);
return OpaqueMode::Off;
}
self.mode
}
}
impl OidcConfig {
/// Load OIDC configuration from environment variables only
pub fn from_env() -> Self {
@@ -2338,6 +2488,10 @@ pub struct AppConfig {
pub database: DatabaseConfig,
/// Authentication configuration
pub auth: AuthConfig,
/// OPAQUE (RFC 9807) zero-knowledge password auth configuration.
/// Substrate only in Phase 0 — endpoints are inert until
/// `OXICLOUD_OPAQUE_MODE != off`.
pub opaque: OpaqueConfig,
/// Feature configuration
pub features: FeaturesConfig,
/// OIDC configuration
@@ -2406,6 +2560,7 @@ impl Default for AppConfig {
storage_entries: Vec::new(),
database: DatabaseConfig::default(),
auth: AuthConfig::default(),
opaque: OpaqueConfig::default(),
features: FeaturesConfig::default(),
oidc: OidcConfig::default(),
wopi: WopiConfig::default(),
@@ -3448,6 +3603,11 @@ impl AppConfig {
}
}
// OPAQUE aPAKE — env-driven substrate wired via its own loader so the
// AppConfig::from_env body doesn't have to know the internals of the
// new mode enum / KSF param triple. See `OpaqueConfig::from_env`.
config.opaque = OpaqueConfig::from_env();
config
}
@@ -4167,4 +4327,66 @@ mod tests {
assert_eq!(cli_fp, parser_fp);
}
}
// ── OPAQUE effective-mode cross-check ────────────────────────────────
//
// OPAQUE is fundamentally a password mechanism; enabling its mode when
// password auth is disabled would be a no-op that still nagged
// operators for `OXICLOUD_OPAQUE_SERVER_SETUP` at boot. The
// `effective_mode` helper resolves that quietly by downgrading to
// Off + emitting an audit log, and these tests pin the truth table.
fn auth_with_methods(methods: Vec<AuthMethod>) -> AuthConfig {
AuthConfig {
allowed_auth_methods: methods,
..AuthConfig::default()
}
}
#[test]
fn effective_mode_stays_off_when_configured_off() {
use crate::infrastructure::services::opaque_service::OpaqueMode;
let opaque = OpaqueConfig::default(); // mode = Off
let auth = auth_with_methods(vec![AuthMethod::Password]);
assert_eq!(opaque.effective_mode(&auth), OpaqueMode::Off);
}
#[test]
fn effective_mode_passes_through_when_password_allowed() {
use crate::infrastructure::services::opaque_service::OpaqueMode;
for mode in [OpaqueMode::Migrate, OpaqueMode::OpaqueOnly] {
let opaque = OpaqueConfig {
mode,
..OpaqueConfig::default()
};
// Empty allowlist means "all methods allowed" per the existing
// convention, so password is implicitly in.
let empty_auth = auth_with_methods(vec![]);
assert_eq!(opaque.effective_mode(&empty_auth), mode);
// Explicit allowlist including Password.
let with_password = auth_with_methods(vec![AuthMethod::Password]);
assert_eq!(opaque.effective_mode(&with_password), mode);
// Multi-method allowlist including Password.
let mixed = auth_with_methods(vec![AuthMethod::Password, AuthMethod::MagicLink]);
assert_eq!(opaque.effective_mode(&mixed), mode);
}
}
#[test]
fn effective_mode_downgrades_to_off_when_password_disabled() {
use crate::infrastructure::services::opaque_service::OpaqueMode;
for mode in [OpaqueMode::Migrate, OpaqueMode::OpaqueOnly] {
let opaque = OpaqueConfig {
mode,
..OpaqueConfig::default()
};
// Magic-link-only deployment — no password path for OPAQUE
// to shadow, so effective mode must be Off regardless of the
// configured value. The audit log line is a side effect we
// don't try to assert on (tracing capture would be overkill
// for this straightforward truth table).
let magic_only = auth_with_methods(vec![AuthMethod::MagicLink]);
assert_eq!(opaque.effective_mode(&magic_only), OpaqueMode::Off);
}
}
}
+56
View File
@@ -1909,6 +1909,50 @@ impl AppServiceFactory {
user_lifecycle_handle = Some(user_lifecycle);
}
// OPAQUE aPAKE substrate — construct only when effective_mode != Off.
// The `effective_mode` helper resolves the cross-check against
// `OXICLOUD_AUTH_METHODS` (password must be enabled for OPAQUE to
// have anything to shadow), so OIDC-only / magic-link-only
// deployments transparently get `opaque_service = None` even if
// the operator accidentally set `OXICLOUD_OPAQUE_MODE=migrate`.
//
// Failing here (missing SERVER_SETUP, malformed base64, ciphersuite
// drift) refuses server boot — same fail-closed posture as the
// auth-service init above. Better to catch a misconfigured
// deployment at startup than at first login attempt.
let opaque_service = {
use crate::infrastructure::services::opaque_service::{OpaqueMode, OpaqueService};
let effective = self.config.opaque.effective_mode(&self.config.auth);
if effective == OpaqueMode::Off {
None
} else {
let svc = OpaqueService::from_config(self.config.opaque.clone()).map_err(|e| {
tracing::error!(
"FATAL: OPAQUE mode is {:?} but service failed to initialize: {}",
effective,
e
);
DomainError::internal_error(
"OpaqueInit",
format!(
"OXICLOUD_OPAQUE_MODE={:?} but the OPAQUE service failed: {}. \
Persist a valid OXICLOUD_OPAQUE_SERVER_SETUP or set \
OXICLOUD_OPAQUE_MODE=off. Refusing to start.",
effective, e
),
)
})?;
tracing::info!(
target: "audit",
event = "opaque.service_initialized",
mode = ?effective,
ciphersuite_version = svc.ciphersuite_version(),
"OPAQUE substrate active — endpoints will be wired in a subsequent phase"
);
Some(Arc::new(svc))
}
};
// 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 {
@@ -2003,6 +2047,7 @@ impl AppServiceFactory {
maintenance_pool: Some(maintenance_pool),
mount_router,
auth_service: auth_services,
opaque_service,
nextcloud: nextcloud_services,
admin_settings_service: None,
storage_settings_service: None,
@@ -2749,6 +2794,17 @@ pub struct AppState {
pub mount_router:
Arc<crate::application::services::external_mount_router::MountRouter>,
pub auth_service: Option<AuthServices>,
/// OPAQUE aPAKE substrate (RFC 9807). Populated only when
/// [`OpaqueConfig::effective_mode`] is not `Off` — that method
/// cross-checks `OXICLOUD_OPAQUE_MODE` against
/// `OXICLOUD_AUTH_METHODS` so an OIDC-only or magic-link-only
/// deployment gets `None` here even if `OXICLOUD_OPAQUE_MODE` was
/// set (with an audit-channel INFO explaining why). `None` also
/// means the future OPAQUE endpoints must 404 — a handler that
/// unwraps this without a nil check would break the phase gate.
pub opaque_service: Option<
Arc<crate::infrastructure::services::opaque_service::OpaqueService>,
>,
pub nextcloud: Option<NextcloudServices>,
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
/// WASM plugin management (list/install/toggle/remove), backing the admin
+1
View File
@@ -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_service;
pub mod password_hasher;
pub mod path_resolver_service;
pub mod path_service;
@@ -0,0 +1,407 @@
//! OPAQUE aPAKE service (Phase 0 substrate).
//!
//! OPAQUE (RFC 9807) is a zero-knowledge password-authenticated key exchange:
//! the passphrase never leaves the client, not on registration and not on
//! login. This module wraps the [`opaque_ke`] crate with a stable ciphersuite
//! type alias ([`OxiCloudSuite`]), a lazily-loaded per-process
//! [`ServerSetup`] persisted via env var, and a small handful of thin
//! wrappers over the four handshake steps.
//!
//! ## Phase 0 scope
//!
//! Endpoints are not yet wired. This module ships the primitives so the
//! subsequent phases can layer registration + login handlers, silent
//! migration hooks, and the eventual `opaque_only` cutover on top without
//! re-designing the type shape.
//!
//! ## Ciphersuite (frozen at v1)
//!
//! | Slot | Choice |
//! |---------------|--------------------------------------------------------|
//! | `OprfCs` | [`Ristretto255`] — SHA-512-backed VOPRF ciphersuite |
//! | `KeGroup` | [`Ristretto255`] — same group for the AKE |
//! | `KeyExchange` | [`TripleDh`] — 3DH mutual auth (opaque-ke default AKE) |
//! | `Ksf` | [`argon2::Argon2`] — memory-hard client-side stretch |
//!
//! **Changing any slot invalidates every previously-minted envelope.**
//! Bumped via [`OpaqueConfig::ciphersuite_version`] with a matching
//! DB-level `opaque_ciphersuite_version` column so a future migration can
//! decide per-user whether to re-register or refuse login until the client
//! re-registers.
//!
//! ## What lives where
//!
//! The KSF is applied CLIENT-side — RFC 9807 puts the memory-hard stretch
//! before the OPRF exchange so the server never runs Argon2. The Argon2
//! params in [`OpaqueConfig`] are therefore a CLIENT concern (published to
//! the SPA at page-load); the server binds the type at compile time so the
//! wire shape matches but never invokes it.
use opaque_ke::CipherSuite;
use opaque_ke::Ristretto255;
use opaque_ke::ServerSetup;
use opaque_ke::key_exchange::tripledh::TripleDh;
use rand_core::OsRng;
use crate::common::config::OpaqueConfig;
use crate::common::errors::{DomainError, ErrorKind};
/// OxiCloud's OPAQUE ciphersuite binding. See the module-level table for
/// the slot choices and the invariants around changing them.
///
/// Zero-sized — this type exists only to name the ciphersuite for the
/// generic `opaque_ke` machinery; no instances are ever constructed.
#[derive(Debug, Clone, Copy)]
pub struct OxiCloudSuite;
impl CipherSuite for OxiCloudSuite {
type OprfCs = Ristretto255;
type KeGroup = Ristretto255;
type KeyExchange = TripleDh;
type Ksf = argon2::Argon2<'static>;
}
/// A configured OPAQUE server. Holds the persistent [`ServerSetup`] plus a
/// clone of the runtime [`OpaqueConfig`] so callers don't have to plumb
/// both. Cheap to clone — [`ServerSetup`] is a small keypair blob.
#[derive(Debug, Clone)]
pub struct OpaqueService {
setup: ServerSetup<OxiCloudSuite>,
config: OpaqueConfig,
}
impl OpaqueService {
/// Build the service from runtime config. Expects the operator to have
/// persisted the server setup already (via `OXICLOUD_OPAQUE_SERVER_SETUP`);
/// call [`OpaqueService::generate_server_setup_b64`] first-time and print
/// the value for the operator to paste into their env before enabling
/// `OXICLOUD_OPAQUE_MODE`.
///
/// Rejects with `InternalError` if the setup is missing / malformed, or
/// with `AccessDenied` if the mode is `off` (guarding against
/// accidental use before the operator has explicitly opted in).
pub fn from_config(config: OpaqueConfig) -> Result<Self, DomainError> {
if config.mode == OpaqueMode::Off {
return Err(DomainError::access_denied(
"opaque",
"OPAQUE is disabled (OXICLOUD_OPAQUE_MODE=off)",
));
}
let setup_b64 = config.server_setup_b64.as_deref().ok_or_else(|| {
DomainError::new(
ErrorKind::InternalError,
"opaque",
"OXICLOUD_OPAQUE_SERVER_SETUP is required when OPAQUE is enabled — \
generate one with `oxicloud opaque-setup` and persist it in the env",
)
})?;
let setup = decode_server_setup(setup_b64)?;
Ok(Self { setup, config })
}
/// Runtime OPAQUE mode. Handlers can gate behaviour on this without
/// reaching for the whole config — see the phase plan in
/// `docs/plan/opaque.md`.
pub fn mode(&self) -> OpaqueMode {
self.config.mode
}
/// The bound ciphersuite version, stamped into `opaque_ciphersuite_version`
/// on registration so future migrations can reason per-user.
pub fn ciphersuite_version(&self) -> i16 {
self.config.ciphersuite_version
}
/// The persistent server setup — passed to `ServerRegistration::start`
/// and `ServerLogin::start` in the handler layer. Kept accessible so
/// callers can hold their own refs to it if they need to (e.g. inside
/// a Tokio task), avoiding an extra `Arc` layer.
pub fn setup(&self) -> &ServerSetup<OxiCloudSuite> {
&self.setup
}
/// Generate a fresh server setup and return it as base64. Called once
/// per deployment; the returned string must be persisted in
/// `OXICLOUD_OPAQUE_SERVER_SETUP` and NEVER rotated (rotating
/// invalidates every existing envelope — see
/// `docs/plan/opaque.md` §Phase 0).
pub fn generate_server_setup_b64() -> String {
use base64::Engine as _;
let mut rng = OsRng;
let setup = ServerSetup::<OxiCloudSuite>::new(&mut rng);
base64::engine::general_purpose::STANDARD.encode(setup.serialize())
}
}
fn decode_server_setup(b64: &str) -> Result<ServerSetup<OxiCloudSuite>, DomainError> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"opaque",
format!("OXICLOUD_OPAQUE_SERVER_SETUP is not valid base64: {e}"),
)
})?;
ServerSetup::<OxiCloudSuite>::deserialize(&bytes).map_err(|e| {
DomainError::new(
ErrorKind::InternalError,
"opaque",
format!(
"OXICLOUD_OPAQUE_SERVER_SETUP payload does not match ciphersuite v1: {e}. \
If you rotated the ciphersuite, every user must re-register."
),
)
})
}
/// Runtime OPAQUE mode. Drives whether the endpoints exist at all
/// (`Off`), run alongside the legacy password path (`Migrate`), or are
/// the only accepted mechanism for users with an envelope (`OpaqueOnly`).
///
/// Progression matches the phase plan — see `docs/plan/opaque.md`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpaqueMode {
/// Endpoints 404; no OPAQUE state ever mints. Default. Phase 0-1.
Off,
/// Endpoints live. Legacy login also accepted; successful legacy login
/// silently mints an envelope. Phase 2-3.
Migrate,
/// Endpoints live. Legacy login refused for users with
/// `opaque_migrated_at IS NOT NULL`. Phase 4+.
OpaqueOnly,
}
impl OpaqueMode {
/// Case-insensitive parse. Unknown token returns `None` so callers can
/// log-and-default (mirrors [`crate::common::config::AuthMethod::parse`]).
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"off" | "disabled" => Some(Self::Off),
"migrate" => Some(Self::Migrate),
"opaque_only" | "opaque-only" => Some(Self::OpaqueOnly),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_and_round_trip_server_setup() {
// Fresh setup encodes to base64, decodes back into an equivalent
// ServerSetup, and yields a usable OpaqueService when threaded
// through the config layer.
let b64 = OpaqueService::generate_server_setup_b64();
assert!(!b64.is_empty(), "setup must be non-empty");
let cfg = OpaqueConfig {
mode: OpaqueMode::Migrate,
server_setup_b64: Some(b64.clone()),
..OpaqueConfig::default()
};
let svc = OpaqueService::from_config(cfg).expect("service builds from valid config");
assert_eq!(svc.mode(), OpaqueMode::Migrate);
// Round-trip check: re-serialising the loaded setup produces the
// same bytes as the generator emitted.
use base64::Engine as _;
let re_encoded = base64::engine::general_purpose::STANDARD.encode(svc.setup().serialize());
assert_eq!(re_encoded, b64);
}
#[test]
fn from_config_rejects_off_mode() {
// Guard rail: explicitly refuses to build in Off mode so a stray
// caller cannot accidentally exercise the primitives when the
// operator has disabled OPAQUE.
let cfg = OpaqueConfig {
mode: OpaqueMode::Off,
server_setup_b64: Some(OpaqueService::generate_server_setup_b64()),
..OpaqueConfig::default()
};
let err = OpaqueService::from_config(cfg).expect_err("must reject Off");
assert_eq!(err.kind, ErrorKind::AccessDenied);
}
#[test]
fn from_config_rejects_missing_setup() {
// Enabling OPAQUE without persisting the setup is a boot-time
// misconfiguration — surface it with a clear error rather than
// silently generating a fresh (and unpersisted) keypair.
let cfg = OpaqueConfig {
mode: OpaqueMode::Migrate,
server_setup_b64: None,
..OpaqueConfig::default()
};
let err = OpaqueService::from_config(cfg).expect_err("must reject missing setup");
assert_eq!(err.kind, ErrorKind::InternalError);
assert!(err.to_string().contains("OXICLOUD_OPAQUE_SERVER_SETUP"));
}
#[test]
fn from_config_rejects_malformed_setup() {
// Truncated / garbled base64 is caught at boot with a helpful
// pointer to the ciphersuite-rotation caveat.
let cfg = OpaqueConfig {
mode: OpaqueMode::Migrate,
server_setup_b64: Some("not-base64!".to_string()),
..OpaqueConfig::default()
};
let err = OpaqueService::from_config(cfg).expect_err("must reject malformed setup");
assert_eq!(err.kind, ErrorKind::InternalError);
}
/// End-to-end round-trip through OPAQUE's four messages, using the
/// ciphersuite this service actually binds. This is the load-bearing
/// smoke test for Phase 0: proves the crate is wired correctly, the
/// ServerSetup we serialise / deserialise is functional, and the client
/// and server sides negotiate a matching session key given the correct
/// passphrase (and disagree on the wrong one).
///
/// Fast Argon2 params (8 KiB / 1 iter / 1 lane) keep the test in the
/// millisecond range — production clients pass their own configured
/// Argon2 instance via `ClientRegistrationFinishParameters` /
/// `ClientLoginFinishParameters`, so the test's choice of KSF params
/// does NOT contaminate the runtime behaviour of the service.
#[test]
fn round_trip_register_and_login_matches_session_keys() {
use opaque_ke::{
ClientLogin, ClientLoginFinishParameters, ClientRegistration,
ClientRegistrationFinishParameters, ServerLogin, ServerLoginStartParameters,
ServerRegistration,
};
use rand_core::OsRng;
// ── Server bootstrap (mirrors the production `from_config` path) ─
let b64 = OpaqueService::generate_server_setup_b64();
let svc = OpaqueService::from_config(OpaqueConfig {
mode: OpaqueMode::Migrate,
server_setup_b64: Some(b64),
..OpaqueConfig::default()
})
.expect("service builds");
// Test-scoped fast KSF — override the client-side Argon2 via the
// finish-parameters plumb so we don't pay the 256 MiB / 3-iter
// production defaults for every test run.
let ksf = argon2::Argon2::new(
argon2::Algorithm::Argon2id,
argon2::Version::V0x13,
argon2::Params::new(8, 1, 1, None).expect("valid test argon2 params"),
);
let user_id = b"alice@example.com";
let passphrase = b"correct horse battery staple";
// ── Registration ────────────────────────────────────────────────
let mut client_rng = OsRng;
let client_reg_start =
ClientRegistration::<OxiCloudSuite>::start(&mut client_rng, passphrase)
.expect("client registration start");
let server_reg_start = ServerRegistration::<OxiCloudSuite>::start(
svc.setup(),
client_reg_start.message,
user_id,
)
.expect("server registration start");
let client_reg_finish = client_reg_start
.state
.finish(
&mut client_rng,
passphrase,
server_reg_start.message,
ClientRegistrationFinishParameters::new(
opaque_ke::Identifiers::default(),
Some(&ksf),
),
)
.expect("client registration finish");
let password_file = ServerRegistration::<OxiCloudSuite>::finish(client_reg_finish.message);
let password_file_bytes = password_file.serialize();
// ── Login (correct passphrase → session keys match) ─────────────
let client_login_start = ClientLogin::<OxiCloudSuite>::start(&mut client_rng, passphrase)
.expect("client login start");
let stored = ServerRegistration::<OxiCloudSuite>::deserialize(&password_file_bytes)
.expect("password file deserialises");
let mut server_rng = OsRng;
let server_login_start = ServerLogin::start(
&mut server_rng,
svc.setup(),
Some(stored),
client_login_start.message,
user_id,
ServerLoginStartParameters::default(),
)
.expect("server login start");
let client_login_finish = client_login_start
.state
.finish(
passphrase,
server_login_start.message,
ClientLoginFinishParameters::new(
None,
opaque_ke::Identifiers::default(),
Some(&ksf),
),
)
.expect("client login finish");
let server_login_finish = server_login_start
.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(),
"OPAQUE session keys must match on both sides after a successful login"
);
assert!(
!client_login_finish.export_key.as_slice().is_empty(),
"client export_key must be populated (E2EE KEK bridge input)"
);
// ── Login (wrong passphrase → client finish must fail) ──────────
let bad_login_start =
ClientLogin::<OxiCloudSuite>::start(&mut client_rng, b"wrong-passphrase")
.expect("client login start (wrong pass)");
let stored_again =
ServerRegistration::<OxiCloudSuite>::deserialize(&password_file_bytes).unwrap();
let bad_server_login = ServerLogin::start(
&mut server_rng,
svc.setup(),
Some(stored_again),
bad_login_start.message,
user_id,
ServerLoginStartParameters::default(),
)
.expect("server login start (wrong pass)");
let bad_client_finish = bad_login_start.state.finish(
b"wrong-passphrase",
bad_server_login.message,
ClientLoginFinishParameters::new(None, opaque_ke::Identifiers::default(), Some(&ksf)),
);
assert!(
bad_client_finish.is_err(),
"client finish must reject a wrong passphrase — this is the whole point of OPAQUE"
);
}
#[test]
fn mode_parse_case_insensitive_and_alias_tolerant() {
assert_eq!(OpaqueMode::parse("off"), Some(OpaqueMode::Off));
assert_eq!(OpaqueMode::parse("OFF"), Some(OpaqueMode::Off));
assert_eq!(OpaqueMode::parse("disabled"), Some(OpaqueMode::Off));
assert_eq!(OpaqueMode::parse("migrate"), Some(OpaqueMode::Migrate));
assert_eq!(
OpaqueMode::parse("opaque_only"),
Some(OpaqueMode::OpaqueOnly)
);
assert_eq!(
OpaqueMode::parse("opaque-only"),
Some(OpaqueMode::OpaqueOnly)
);
assert_eq!(OpaqueMode::parse("nope"), None);
}
}