feat(opaque): wire API

- POST /api/auth/opaque/login/ke1 (public) — takes {userIdentifier, startLoginRequest}, resolves the identifier via the same @-dispatch as legacy login (AuthApplicationService::lookup_user_for_login, factored out), fetches envelope, runs ServerLogin::start (real branch for known users, dummy branch for anti-enum on unknown/unregistered), stashes state under a random exchange_id in the moka cache, returns {exchangeId, loginResponse}.
- POST /api/auth/opaque/login/ke3 (public) — atomic take from the cache FIRST (anti-enum + anti-replay), then decodes the payload, runs ServerLogin::finish, stamps opaque_migrated_at (Phase 3 signal), and mints a session via the new AuthApplicationService::mint_session_for_authenticated_user helper — returns the same AuthResponseDto shape as legacy login so the SPA has one downstream handler.
- Session mint factored: mint_session_for_authenticated_user(User) extracted from login() so both the legacy password path and OPAQUE KE3 converge through one implementation.
- OpaqueRepositoryPort::mark_migrated with COALESCE-preserving idempotent stamp of opaque_migrated_at.
- opaque-setup CLI + Dockerfile wiring already shipped (Step 0 hygiene).
- Routing fix: sub-prefix split (/api/auth/opaque/register vs /api/auth/opaque/login) — axum composes middleware between sibling nests at the same prefix, which was cross-applying auth+CSRF to my public login routes. Distinct prefixes side-step that cleanly. Documented in both main.rs and the router builder doc.
- Rate-limit sharing: login KE1/KE3 layered with the SAME login_limiter instance as legacy POST /api/auth/login, so an attacker can't halve the per-IP budget by spraying both endpoints.

Anti-enum + anti-replay hardening in KE3: take runs BEFORE payload parse so:
- Unknown / expired / already-consumed exchange_id → 401 InvalidCredentials (same shape as wrong-passphrase, no payload-shape leak)
- Consumed handle can't be re-used to spam parse attempts
This commit is contained in:
Edouard Vanbelle
2026-07-27 22:23:15 +02:00
parent ae65c8475f
commit 7d7621e387
7 changed files with 696 additions and 49 deletions
+50 -17
View File
@@ -56,24 +56,12 @@ Content-Type: application/json
HTTP 401
# ─────────────────────────────────────────────────────────────
# Case 3 — Login KE1 endpoint not routed (401 anti-enum).
# Will flip to 400 in Phase 1 (public + malformed body).
# NOTE — The Phase 0 "login endpoints 401 anti-enum" cases were
# retired at Phase 1 landing. KE1 / KE3 are now routed (public,
# rate-limited). Cases 7 & 8 below assert the Phase 1 shape:
# KE1 400 `OpaqueMalformedRequest` on bad body, KE3 401
# `InvalidCredentials` on unknown exchange_id.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/opaque/login/ke1
Content-Type: application/json
{ "userIdentifier": "{{username}}", "startLoginRequest": "unused-phase-0" }
HTTP 401
# ─────────────────────────────────────────────────────────────
# Case 4 — Login KE3 endpoint not routed (401 anti-enum).
# Will flip to 400 in Phase 1 (public + malformed body).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/opaque/login/ke3
Content-Type: application/json
{ "exchangeId": "unused-phase-0", "finishLoginRequest": "unused-phase-0" }
HTTP 401
# =============================================================
@@ -143,3 +131,48 @@ Content-Type: application/json
HTTP 400
[Asserts]
jsonpath "$.error_type" == "OpaqueCiphersuiteMismatch"
# =============================================================
# Phase 1 — Login endpoints (KE1 / KE3, public + rate-limited)
# =============================================================
# The KE1 / KE3 endpoints are public — no session required, no
# CSRF (bearer/basic exempt anyway). Rate-limited by the same
# per-IP budget as legacy `/api/auth/login` so an attacker can't
# double their guessing rate by spraying both endpoints.
#
# The full crypto handshake (real opaque-ke bytes) is proved in
# the Rust integration test (in-process, no HTTP). What Hurl
# covers here is the wire wiring: routing exists, error paths
# fire with the stable error_type contract.
# ─────────────────────────────────────────────────────────────
# Case 7 — KE1 with garbage base64 → 400 OpaqueMalformedRequest.
# Proves the endpoint is publicly reachable (no auth
# required — no 401), the JSON body is parsed, and the
# malformed-base64 error path is stable.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/opaque/login/ke1
Content-Type: application/json
{ "userIdentifier": "{{username}}", "startLoginRequest": "not-valid-base64!" }
HTTP 400
[Asserts]
jsonpath "$.error_type" == "OpaqueMalformedRequest"
# ─────────────────────────────────────────────────────────────
# Case 8 — KE3 with an unknown exchange_id → 401
# InvalidCredentials. The `exchange_id` handle is
# single-use and 60s-TTL; unknown ids MUST return the
# SAME error shape as a wrong-passphrase failure so
# attackers can't distinguish "id expired" from
# "wrong password" from "id already consumed".
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/opaque/login/ke3
Content-Type: application/json
{ "exchangeId": "00000000-0000-0000-0000-000000000000", "finishLoginRequest": "AAAA" }
HTTP 401
[Asserts]
jsonpath "$.error_type" == "InvalidCredentials"