From 906934ba25e1ab482b7f56b3d452ddd7c21eaadc Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 4 Aug 2026 21:17:24 +0200 Subject: [PATCH] fix(opaque): use safe url base64 --- src/bin/opaque-hurl-helper.rs | 20 ++++- .../api/handlers/opaque_auth_handler.rs | 77 +++++++++++++++---- 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/src/bin/opaque-hurl-helper.rs b/src/bin/opaque-hurl-helper.rs index a73e16db..77f8f3f8 100644 --- a/src/bin/opaque-hurl-helper.rs +++ b/src/bin/opaque-hurl-helper.rs @@ -45,7 +45,21 @@ //! because subsequent hurl files don't assume `hasOpaque=false`. use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as B64; +use base64::engine::general_purpose::{ + STANDARD as B64, URL_SAFE_NO_PAD as B64_URL_NO_PAD, +}; + +/// Decode base64 emitted by the server. The server emits URL-safe-no-pad +/// (matching what the SPA's WASM client expects); this helper accepts +/// both flavours so a future format change on either side doesn't +/// silently break the round-trip. Mirrors `decode_opaque_b64` in the +/// server-side handler. +fn decode_opaque_b64(input: &str) -> Result, base64::DecodeError> { + let trimmed = input.trim(); + B64_URL_NO_PAD + .decode(trimmed) + .or_else(|_| B64.decode(trimmed)) +} use opaque_ke::{ ClientLogin, ClientLoginFinishParameters, ClientRegistration, ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse, @@ -213,7 +227,7 @@ async fn main() -> ExitCode { } Err(e) => return fail(format!("register/start network: {e}")), }; - let reg_response_bytes = match B64.decode(reg_start.registration_response.trim()) { + let reg_response_bytes = match decode_opaque_b64(®_start.registration_response) { Ok(b) => b, Err(e) => return fail(format!("decode registration_response: {e}")), }; @@ -280,7 +294,7 @@ async fn main() -> ExitCode { } Err(e) => return fail(format!("login/ke1 network: {e}")), }; - let cred_bytes = match B64.decode(ke1.login_response.trim()) { + let cred_bytes = match decode_opaque_b64(&ke1.login_response) { Ok(b) => b, Err(e) => return fail(format!("decode loginResponse: {e}")), }; diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index 5ffca4f2..511a7493 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -30,11 +30,15 @@ //! //! ## 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. +//! All OPAQUE messages are opaque byte blobs, round-tripped through +//! `serde_json` as a `String` field. The server emits **URL-safe-no-pad** +//! base64 (`-`/`_`, no `=`) via `B64.encode(...)` — the WASM client +//! (`@serenity-kit/opaque`) rejects standard base64 with an +//! `Invalid symbol` error on the first `+`/`/`. On decode the server +//! accepts BOTH flavours via `decode_opaque_b64` so the Rust +//! `opaque-hurl-helper` test binary (which emits standard) still +//! round-trips. Asymmetry is intentional: URL-safe is the compatible +//! superset for the client mix we support. //! //! ## Ciphersuite version handshake //! @@ -64,7 +68,32 @@ use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::{get, post}; use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as B64; +use base64::engine::general_purpose::{ + STANDARD as B64_STANDARD, URL_SAFE_NO_PAD as B64_URL_NO_PAD, +}; + +/// Decode base64 payloads received from OPAQUE clients, accepting BOTH +/// standard (`+`/`/`, padded) and URL-safe-no-pad (`-`/`_`, no padding) +/// alphabets. `@serenity-kit/opaque` (the WASM client the SPA uses) +/// emits URL-safe-no-pad; the `opaque-hurl-helper` binary and the +/// original spec docs use standard. Accepting both means neither +/// side has to renormalize. +fn decode_opaque_b64(input: &str) -> Result, base64::DecodeError> { + let trimmed = input.trim(); + B64_STANDARD + .decode(trimmed) + .or_else(|_| B64_URL_NO_PAD.decode(trimmed)) +} + +/// Encode OPAQUE payloads emitted BY the server. Emits **URL-safe-no-pad** +/// (`-`/`_`, no `=`) because the WASM client (`@serenity-kit/opaque` — +/// the SPA's OPAQUE library) decodes strictly as URL-safe-no-pad and +/// rejects standard base64 with an `Invalid symbol` error on the first +/// `+`/`/` in the payload. Rust `opaque-hurl-helper` and any other +/// client parses through `decode_opaque_b64` above which accepts BOTH +/// flavours, so this direction is asymmetric on purpose — URL-safe is +/// the compatible superset for the client mix we support. +const B64: base64::engine::general_purpose::GeneralPurpose = B64_URL_NO_PAD; use opaque_ke::{ CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload, ServerLoginStartParameters, ServerRegistration, @@ -186,11 +215,28 @@ pub async fn register_start( ) -> Result { let svc = require_opaque_service(&state)?; - let req_bytes = B64 - .decode(dto.registration_request.trim()) - .map_err(|_| malformed("registrationRequest is not valid base64"))?; - let req = RegistrationRequest::::deserialize(&req_bytes) - .map_err(|_| malformed("registrationRequest failed to deserialize"))?; + let req_bytes = decode_opaque_b64(&dto.registration_request).map_err(|e| { + tracing::info!( + target: "audit", + event = "opaque.register_start_rejected", + reason = "malformed_base64", + user_id = %user_id, + error = %e, + "👮🏻‍♂️ OPAQUE register/start rejected: registrationRequest is not valid base64" + ); + malformed("registrationRequest is not valid base64") + })?; + let req = RegistrationRequest::::deserialize(&req_bytes).map_err(|e| { + tracing::info!( + target: "audit", + event = "opaque.register_start_rejected", + reason = "malformed_registration_request", + user_id = %user_id, + error = %e, + "👮🏻‍♂️ OPAQUE register/start rejected: RegistrationRequest deserialize failed" + ); + 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 @@ -270,8 +316,7 @@ pub async fn register_finish( )); } - let record_bytes = B64 - .decode(dto.registration_record.trim()) + let record_bytes = decode_opaque_b64(&dto.registration_record) .map_err(|_| malformed("registrationRecord is not valid base64"))?; let record = RegistrationUpload::::deserialize(&record_bytes) .map_err(|_| malformed("registrationRecord failed to deserialize"))?; @@ -464,8 +509,7 @@ pub async fn login_ke1( let repo = require_opaque_repo(&state)?; let exchange = require_opaque_exchange(&state)?; - let cred_bytes = B64 - .decode(dto.start_login_request.trim()) + let cred_bytes = decode_opaque_b64(&dto.start_login_request) .map_err(|_| malformed("startLoginRequest is not valid base64"))?; let cred_request = CredentialRequest::::deserialize(&cred_bytes) .map_err(|_| malformed("startLoginRequest failed to deserialize"))?; @@ -596,8 +640,7 @@ pub async fn login_ke3( invalid_credentials() })?; - let cred_bytes = B64 - .decode(dto.finish_login_request.trim()) + let cred_bytes = decode_opaque_b64(&dto.finish_login_request) .map_err(|_| malformed("finishLoginRequest is not valid base64"))?; let cred_final = CredentialFinalization::::deserialize(&cred_bytes) .map_err(|_| malformed("finishLoginRequest failed to deserialize"))?;