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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user