feat(opaque): permits ksf values change

KSF values are stored per user, if admin change value, client will detect it and regenerate the envelop
This pervent users being stuck
This commit is contained in:
Edouard Vanbelle
2026-08-05 22:16:03 +02:00
parent 84a1b0e005
commit 94e5b9e355
9 changed files with 547 additions and 62 deletions
+30 -1
View File
@@ -54,6 +54,26 @@ pub struct StoredEnvelope {
/// across re-registrations (password changes) via a NULL check in
/// [`OpaqueRepositoryPort::write_registration`].
pub registered_at: DateTime<Utc>,
/// Client-side Argon2id KSF parameters the CLIENT declared at
/// register time. `None` when the envelope predates the
/// per-envelope-KSF migration (`20261005000000`) — callers fall
/// back to the server's current `OpaqueConfig::ksf_*` in that
/// case. See the migration file for the "why per-envelope"
/// rationale.
pub ksf: Option<StoredKsf>,
}
/// Client-declared Argon2id parameters carried alongside an OPAQUE
/// envelope. All three move as an atomic set (populated together at
/// `register_finish`, nulled together at `clear_registration`).
#[derive(Debug, Clone, Copy)]
pub struct StoredKsf {
/// Argon2id memory cost in KiB.
pub memory_kib: u32,
/// Argon2id iteration count.
pub iterations: u32,
/// Argon2id parallelism (lanes).
pub parallelism: u32,
}
/// Secondary (outbound) port for OPAQUE envelope persistence.
@@ -67,7 +87,15 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static {
///
/// 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.
/// envelope + ciphersuite_version + KSF params rotate on password
/// change.
///
/// `ksf` carries the Argon2id parameters the CLIENT used at
/// register time (declared in the register/finish request). Stored
/// per-envelope so future changes to the server's
/// `OpaqueConfig::ksf_*` do not invalidate this envelope — the
/// lookup endpoint returns these values and the client uses them
/// on the login handshake.
///
/// Does NOT touch `opaque_migrated_at` — that's flipped by the
/// login endpoint after the first successful OPAQUE handshake.
@@ -76,6 +104,7 @@ pub trait OpaqueRepositoryPort: Send + Sync + 'static {
user_id: Uuid,
envelope: &[u8],
ciphersuite_version: i16,
ksf: StoredKsf,
) -> Result<()>;
/// Read the current envelope for `user_id`. Returns `None` when
+1 -5
View File
@@ -119,11 +119,7 @@ mod opaque {
pub async fn run(action: Action) -> ExitCode {
match action {
Action::Setup => run_setup(),
Action::Reset {
user,
all,
dry_run,
} => run_reset(user, all, dry_run).await,
Action::Reset { user, all, dry_run } => run_reset(user, all, dry_run).await,
}
}
@@ -22,7 +22,7 @@ use async_trait::async_trait;
use sqlx::PgPool;
use uuid::Uuid;
use crate::application::ports::opaque_ports::{OpaqueRepositoryPort, StoredEnvelope};
use crate::application::ports::opaque_ports::{OpaqueRepositoryPort, StoredEnvelope, StoredKsf};
use crate::common::errors::{DomainError, Result};
pub struct OpaquePgRepository {
@@ -46,24 +46,38 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
user_id: Uuid,
envelope: &[u8],
ciphersuite_version: i16,
ksf: StoredKsf,
) -> 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.
//
// KSF params ARE rewritten on every re-registration (unlike
// registered_at) — the point of storing them is to reflect
// what the CURRENT envelope was minted under, not the
// historical first. INTEGER column type in PG is i32; cast
// from u32 is lossless for realistic Argon2 values (max
// memory_kib would need ~2 TiB to overflow i32).
let res = sqlx::query(
r#"
UPDATE auth.users
SET opaque_envelope = $2,
opaque_ciphersuite_version = $3,
opaque_registered_at = COALESCE(opaque_registered_at, NOW())
opaque_registered_at = COALESCE(opaque_registered_at, NOW()),
opaque_ksf_memory_kib = $4,
opaque_ksf_iterations = $5,
opaque_ksf_parallelism = $6
WHERE id = $1
"#,
)
.bind(user_id)
.bind(envelope)
.bind(ciphersuite_version)
.bind(ksf.memory_kib as i32)
.bind(ksf.iterations as i32)
.bind(ksf.parallelism as i32)
.execute(self.pool())
.await
.map_err(|e| DomainError::internal_error("OpaquePg", format!("write_registration: {e}")))?;
@@ -86,7 +100,10 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
r#"
SELECT opaque_envelope,
opaque_ciphersuite_version,
opaque_registered_at
opaque_registered_at,
opaque_ksf_memory_kib,
opaque_ksf_iterations,
opaque_ksf_parallelism
FROM auth.users
WHERE id = $1
"#,
@@ -100,15 +117,46 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
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.
// Envelope + ciphersuite_version + registered_at move as one
// atomic set (all three set together by write_registration).
// Partial-NULL there is schema drift → warn + treat as
// unregistered.
//
// KSF columns are independently nullable: existing envelopes
// minted before migration 20261005000000 predate per-envelope
// storage and carry NULL. The service layer falls back to the
// server's current `OpaqueConfig` KSF in that case. Partial-
// NULL of the KSF triple IS caught below because they are also
// set atomically at register time.
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,
})),
(Some(env), Some(ver), Some(at)) => {
let ksf = match (row.ksf_memory_kib, row.ksf_iterations, row.ksf_parallelism) {
(Some(m), Some(i), Some(p)) => Some(StoredKsf {
memory_kib: m as u32,
iterations: i as u32,
parallelism: p as u32,
}),
(None, None, None) => None,
(m, i, p) => {
tracing::warn!(
target: "oxicloud::opaque",
user_id = %user_id,
memory_kib_set = m.is_some(),
iterations_set = i.is_some(),
parallelism_set = p.is_some(),
"OPAQUE KSF columns partial-NULL — treating as absent \
(falls back to server current defaults). Check for a broken migration."
);
None
}
};
Ok(Some(StoredEnvelope {
envelope: env,
ciphersuite_version: ver,
registered_at: at,
ksf,
}))
}
(None, None, None) => Ok(None),
(env, ver, at) => {
tracing::warn!(
@@ -171,9 +219,13 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
}
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.
// One UPDATE nulls the whole OPAQUE column set AND flips the
// force-change flag — matches the atomicity we promise in
// the port doc, avoids drift between separate writes. KSF
// columns move with the envelope (they're bound to it) so
// they're nulled here too; a subsequent silent-migration
// re-registration will re-populate them with the client's
// current declared values.
let res = sqlx::query(
r#"
UPDATE auth.users
@@ -181,6 +233,9 @@ impl OpaqueRepositoryPort for OpaquePgRepository {
opaque_ciphersuite_version = NULL,
opaque_registered_at = NULL,
opaque_migrated_at = NULL,
opaque_ksf_memory_kib = NULL,
opaque_ksf_iterations = NULL,
opaque_ksf_parallelism = NULL,
force_password_change_at_next_login = TRUE
WHERE id = $1
"#,
@@ -205,6 +260,15 @@ struct EnvelopeRow {
ciphersuite_version: Option<i16>,
#[sqlx(rename = "opaque_registered_at")]
registered_at: Option<chrono::DateTime<chrono::Utc>>,
// KSF columns are per-envelope (see migration 20261005000000). PG
// stores them as INTEGER (i32); the domain layer widens to u32.
// NULL for envelopes minted before per-envelope storage landed.
#[sqlx(rename = "opaque_ksf_memory_kib")]
ksf_memory_kib: Option<i32>,
#[sqlx(rename = "opaque_ksf_iterations")]
ksf_iterations: Option<i32>,
#[sqlx(rename = "opaque_ksf_parallelism")]
ksf_parallelism: Option<i32>,
}
#[cfg(integration_tests)]
@@ -268,9 +332,18 @@ mod integration_tests {
);
let payload = b"envelope-v1-bytes".to_vec();
repo.write_registration(user, &payload, 1)
.await
.expect("write");
repo.write_registration(
user,
&payload,
1,
StoredKsf {
memory_kib: 47_104,
iterations: 1,
parallelism: 1,
},
)
.await
.expect("write");
let stored = repo
.read_registration(user)
@@ -294,18 +367,36 @@ mod integration_tests {
)
.await;
repo.write_registration(user, b"first-envelope", 1)
.await
.expect("first write");
repo.write_registration(
user,
b"first-envelope",
1,
StoredKsf {
memory_kib: 47_104,
iterations: 1,
parallelism: 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");
repo.write_registration(
user,
b"second-envelope",
1,
StoredKsf {
memory_kib: 47_104,
iterations: 1,
parallelism: 1,
},
)
.await
.expect("second write");
let second = repo.read_registration(user).await.unwrap().unwrap();
assert_eq!(second.envelope, b"second-envelope");
@@ -324,9 +415,18 @@ mod integration_tests {
)
.await;
repo.write_registration(user, b"envelope", 1)
.await
.expect("prime with envelope");
repo.write_registration(
user,
b"envelope",
1,
StoredKsf {
memory_kib: 47_104,
iterations: 1,
parallelism: 1,
},
)
.await
.expect("prime with envelope");
repo.clear_registration(user)
.await
.expect("clear registration");
@@ -429,9 +529,18 @@ mod integration_tests {
// `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");
repo.write_registration(
user,
&envelope_bytes,
1,
StoredKsf {
memory_kib: 47_104,
iterations: 1,
parallelism: 1,
},
)
.await
.expect("persist envelope");
// ── LOGIN — reads the envelope back the way `login/ke1` will ─────
let stored = repo
@@ -181,12 +181,25 @@ pub struct OpaqueRegisterStartResponse {
/// 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).
///
/// `ksf*` fields carry the Argon2id parameters the client actually
/// used at `startRegistration`/`finishRegistration` time. Server
/// persists them per-envelope so future changes to the server's
/// `OpaqueConfig::ksf_*` don't invalidate this envelope. Optional
/// on the wire (older clients that predate per-envelope storage
/// omit them; server falls back to its current config in that case).
#[derive(Debug, Deserialize, ToSchema)]
pub struct OpaqueRegisterFinishDto {
#[serde(rename = "registrationRecord")]
pub registration_record: String,
#[serde(rename = "ciphersuiteVersion")]
pub ciphersuite_version: i16,
#[serde(rename = "ksfMemoryKib", default)]
pub ksf_memory_kib: Option<u32>,
#[serde(rename = "ksfIterations", default)]
pub ksf_iterations: Option<u32>,
#[serde(rename = "ksfParallelism", default)]
pub ksf_parallelism: Option<u32>,
}
/// KE1 (register/start): parse client `RegistrationRequest`, run
@@ -329,7 +342,25 @@ pub async fn register_finish(
let stored = ServerRegistration::<OxiCloudSuite>::finish(record);
let envelope_bytes = stored.serialize();
repo.write_registration(user_id, &envelope_bytes, svc.ciphersuite_version())
// KSF params the client used. Client-declared per migration
// 20261005000000; older clients omit and we fall back to the
// server's CURRENT config values (best guess: the client fetched
// /params right before registering, so current-config is what it
// saw). Persisting the exact per-envelope values means future
// config changes don't break this envelope on login.
let ksf = crate::application::ports::opaque_ports::StoredKsf {
memory_kib: dto
.ksf_memory_kib
.unwrap_or_else(|| svc.config_ksf_memory_kib()),
iterations: dto
.ksf_iterations
.unwrap_or_else(|| svc.config_ksf_iterations()),
parallelism: dto
.ksf_parallelism
.unwrap_or_else(|| svc.config_ksf_parallelism()),
};
repo.write_registration(user_id, &envelope_bytes, svc.ciphersuite_version(), ksf)
.await
.map_err(|e| {
tracing::error!(
@@ -387,6 +418,25 @@ pub struct OpaqueLookupDto {
pub struct OpaqueLookupResponse {
#[serde(rename = "hasOpaque")]
pub has_opaque: bool,
/// KSF parameters this user's envelope was minted under. Present
/// only when `has_opaque = true`. The client MUST use these values
/// (not the ones from `GET /params`) on the login handshake — the
/// envelope's OPRF derivation was bound to them at register time
/// and a mismatch will fail the AKE with `InvalidCredentials`.
///
/// `None` when: (a) `has_opaque = false` (nothing to publish),
/// or (b) the envelope predates per-envelope-KSF storage
/// (migration `20261005000000`) — in which case the client falls
/// back to `/params` values, which is the same behaviour as
/// before per-envelope storage existed.
///
/// Anti-enum note: the presence of this field ONLY signals what
/// `has_opaque` already signals (positive existence). Value
/// differences across users could reveal timing of registration
/// but not identity — same low-severity leak as the existing
/// per-identifier probe, no additional exposure.
#[serde(rename = "ksf", skip_serializing_if = "Option::is_none")]
pub ksf: Option<OpaqueKsfParams>,
}
/// Resolve `userIdentifier` → envelope-existence check. Used by the
@@ -418,19 +468,35 @@ pub async fn login_lookup(
return Err(malformed("userIdentifier is empty"));
}
// Resolve the identifier → user_id → envelope presence. Any miss
// (unknown user, user without envelope, DB blip) collapses to
// `hasOpaque: false` — the anti-enum contract on the wire shape.
// No audit event here: a successful lookup isn't a login attempt,
// and logging every miss would flood the channel without adding
// signal (rate limiter already caps volume; enumeration attempts
// show up in the login-lockout / rate-limit metrics).
let has_opaque = match auth.lookup_user_for_login(identifier).await {
Ok(user) => matches!(repo.read_registration(user.id()).await, Ok(Some(_))),
Err(_) => false,
// Resolve the identifier → user_id → envelope presence + KSF.
// Any miss (unknown user, user without envelope, DB blip) collapses
// to `hasOpaque: false, ksf: None` — the anti-enum contract on the
// wire shape. No audit event here: a successful lookup isn't a
// login attempt, and logging every miss would flood the channel
// without adding signal (rate limiter already caps volume;
// enumeration attempts show up in the login-lockout / rate-limit
// metrics).
//
// KSF fallback: if the envelope has NULL KSF (predates per-envelope
// storage migration 20261005000000), we return `ksf: None` — the
// client then uses the server's current `/params` values, which is
// the pre-per-envelope-storage behaviour.
let (has_opaque, ksf) = match auth.lookup_user_for_login(identifier).await {
Ok(user) => match repo.read_registration(user.id()).await {
Ok(Some(stored)) => {
let ksf = stored.ksf.map(|k| OpaqueKsfParams {
memory_kib: k.memory_kib,
iterations: k.iterations,
parallelism: k.parallelism,
});
(true, ksf)
}
_ => (false, None),
},
Err(_) => (false, None),
};
Ok(Json(OpaqueLookupResponse { has_opaque }))
Ok(Json(OpaqueLookupResponse { has_opaque, ksf }))
}
// ── Login: KE1 + KE3 ─────────────────────────────────────────────────