From 0e395ae15f1fa321982056b3ffc9d8872a9c030e Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 26 Jul 2026 15:04:31 +0200 Subject: [PATCH] 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 --- Cargo.lock | 55 +++ Cargo.toml | 15 + Dockerfile | 17 +- docs/config/authentication.md | 36 ++ docs/config/env.md | 18 +- example.env | 56 +++ frontend/package-lock.json | 12 + frontend/package.json | 3 + frontend/src/lib/api/endpoints/opaque.test.ts | 195 +++++++++ frontend/src/lib/api/endpoints/opaque.ts | 236 ++++++++++ migrations/20260926000000_auth_opaque.sql | 109 +++++ src/bin/opaque-setup.rs | 34 ++ src/common/config.rs | 222 ++++++++++ src/common/di.rs | 56 +++ src/infrastructure/services/mod.rs | 1 + src/infrastructure/services/opaque_service.rs | 407 ++++++++++++++++++ tests/api/opaque_substrate.hurl | 76 ++++ tests/api/run.sh | 1 + tests/common/server.env | 28 ++ 19 files changed, 1570 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/api/endpoints/opaque.test.ts create mode 100644 frontend/src/lib/api/endpoints/opaque.ts create mode 100644 migrations/20260926000000_auth_opaque.sql create mode 100644 src/bin/opaque-setup.rs create mode 100644 src/infrastructure/services/opaque_service.rs create mode 100644 tests/api/opaque_substrate.hurl diff --git a/Cargo.lock b/Cargo.lock index a69f98da..b3a354d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1846,6 +1846,7 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto", + "rand_core 0.6.4", "rustc_version", "subtle", "zeroize", @@ -1946,6 +1947,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "digest" version = "0.10.7" @@ -2672,6 +2684,7 @@ version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ + "serde", "typenum", "version_check", "zeroize", @@ -4271,6 +4284,28 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaque-ke" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb31f7b2c5760d8ffbc39652043f5cff14029b6249a2280b28ae6f0cd61f5098" +dependencies = [ + "argon2", + "curve25519-dalek", + "derive-where", + "digest 0.10.7", + "displaydoc", + "elliptic-curve", + "generic-array", + "hkdf", + "hmac 0.12.1", + "rand 0.8.6", + "serde", + "subtle", + "voprf", + "zeroize", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4375,6 +4410,7 @@ dependencies = [ "mp3-duration", "ndarray", "nom-exif", + "opaque-ke", "ort", "pdf-extract", "percent-encoding", @@ -7174,6 +7210,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "voprf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28f59c30c76e2fea54cdece6a054e2662feffa7ab19658a7887524265ee39470" +dependencies = [ + "curve25519-dalek", + "derive-where", + "digest 0.10.7", + "displaydoc", + "elliptic-curve", + "generic-array", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "vsimd" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 491fbd67..4ef0cad8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,13 @@ sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-rustls jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } argon2 = "0.5.3" rand_core = { version = "0.6", features = ["std", "getrandom"] } +# OPAQUE aPAKE (RFC 9807) — zero-knowledge password auth. Ristretto255-SHA512 +# 3DH with Argon2id as the KSF. Server-side envelope + login; matching WASM +# client bundle lives in `frontend/` (`@serenity-kit/opaque`). Ciphersuite +# frozen at bind-time (see `infrastructure::services::opaque_service`) — +# changing it invalidates every user's registration record, plan a +# migration before touching. `argon2` feature gates the memory-hard KSF. +opaque-ke = { version = "3", features = ["argon2"] } quick-xml = "0.41.0" dotenvy = "0.15.7" moka = { version = "0.12.15", features = ["future", "sync"] } @@ -167,6 +174,14 @@ path = "src/bin/generate-openapi.rs" name = "migrate-nfc-filenames" path = "src/bin/migrate-nfc-filenames.rs" +[[bin]] +name = "opaque-setup" +path = "src/bin/opaque-setup.rs" +# One-shot operator helper — prints a base64 OPAQUE ServerSetup for +# OXICLOUD_OPAQUE_SERVER_SETUP. Runs once per deployment; the output +# must be persisted verbatim (rotating invalidates every user's +# registration — see docs/config/authentication.md §OPAQUE). + [[bin]] name = "load-seed" path = "src/bin/load-seed.rs" diff --git a/Dockerfile b/Dockerfile index 21cb9e57..c1e873a2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,8 @@ RUN mkdir -p src/bin && \ echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \ echo 'fn main() {}' > src/bin/generate-openapi.rs && \ echo 'fn main() {}' > src/bin/migrate-nfc-filenames.rs && \ - cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames && \ + echo 'fn main() {}' > src/bin/opaque-setup.rs && \ + cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin opaque-setup && \ rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-* # ─── Stage 3: Build the application ────────────────────────────────────────── @@ -84,7 +85,7 @@ RUN DATABASE_URL="${DATABASE_URL}" \ GITHUB_SHA="${GITHUB_SHA}" \ GITHUB_REF_NAME="${GITHUB_REF_NAME}" \ GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \ - cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames + cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames --bin opaque-setup # The SPA is built by the Vite frontend stage; bring it in for the runtime copy # below (build.rs has no asset pipeline — it only injects git metadata). COPY --from=frontend /static-dist ./static-dist @@ -125,7 +126,8 @@ RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharin cargo build --release && \ mkdir -p /app/bin && \ cp target/release/oxicloud /app/bin/oxicloud && \ - cp target/release/migrate-nfc-filenames /app/bin/migrate-nfc-filenames + cp target/release/migrate-nfc-filenames /app/bin/migrate-nfc-filenames && \ + cp target/release/opaque-setup /app/bin/opaque-setup # ─── Stage 3c: Select the builder & normalise the binary path ───────────────── # FROM expands the global ${BUILDER} arg to alias the chosen builder stage @@ -137,7 +139,7 @@ RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry,sharin FROM ${BUILDER} AS app ARG BIN_DIR RUN mkdir -p /app/release && \ - cp "${BIN_DIR}/oxicloud" "${BIN_DIR}/migrate-nfc-filenames" /app/release/ + cp "${BIN_DIR}/oxicloud" "${BIN_DIR}/migrate-nfc-filenames" "${BIN_DIR}/opaque-setup" /app/release/ # ─── Stage 4: Minimal runtime image ────────────────────────────────────────── FROM alpine:3.24.0 @@ -168,6 +170,13 @@ COPY --from=app --chmod=755 /app/release/oxicloud /usr/local/bin/ # to preview, drop `--dry-run` to execute. One-shot tool, safe to # ship; it only mutates `storage.files` rows whose name ≠ NFC(name). COPY --from=app --chmod=755 /app/release/migrate-nfc-filenames /usr/local/bin/ +# Ship the OPAQUE server-setup generator alongside the server so operators +# can generate their `OXICLOUD_OPAQUE_SERVER_SETUP` value inside the +# container without a separate Rust toolchain: +# docker run --rm opaque-setup # prints the base64 value +# One-shot, side-effect-free — safe to include; the runtime doesn't +# invoke it, admins do (see docs/config/authentication.md §OPAQUE). +COPY --from=app --chmod=755 /app/release/opaque-setup /usr/local/bin/ COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \ chmod 755 /usr/local/bin/entrypoint.sh diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 0ff48484..a53fe84d 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -115,6 +115,42 @@ Comma-separated allowlist. Rejected registrations return 403 `RegistrationDomain The verification-piggyback flow above deliberately **bypasses the `has_password` gate** — that path is only reachable after the user has already proven identity via password on the same login request, so mailbox-only trust is not being extended beyond what the password already established. +## OPAQUE aPAKE (zero-knowledge password login) + +OPAQUE (RFC 9807) replaces the traditional "browser sends passphrase, server hashes it" flow with a two-round cryptographic exchange in which the passphrase **never leaves the client**. On registration the client encrypts a random key blob under the passphrase and uploads that opaque envelope. On login the client proves possession of the passphrase without transmitting it — the server can neither read it nor derive it from what it stores. + +This is the substrate for planned end-to-end encryption work (see `docs/plan/opaque.md` for the full multi-phase roadmap). This build ships **Phase 0 only** — the primitives, migration column, and configuration substrate. Endpoints are inert until `OXICLOUD_OPAQUE_MODE` is enabled in a future release. + +### When to enable OPAQUE + +OPAQUE only touches the password login path. If your deployment doesn't use password auth at all — you've set `OXICLOUD_AUTH_METHODS=oidc`, or `magic_link`, or the OIDC master-rule has locked things down to SSO only — OPAQUE has nothing to shadow and there's no reason to enable it. **Leave every `OXICLOUD_OPAQUE_*` variable at default** (unset). No `OXICLOUD_OPAQUE_SERVER_SETUP` is required in that case; the server won't ask for one. + +Even if you accidentally set `OXICLOUD_OPAQUE_MODE=migrate` in an OIDC-only deployment, the boot-time cross-check downgrades the effective mode to `off` and emits an audit-channel INFO explaining why. This is intentional so operators aren't blocked by a setup requirement for a feature they don't use. + +### Enabling OPAQUE (when the endpoints ship in Phase 1) + +Password-using deployments will opt in via three env vars: + +1. **`OXICLOUD_OPAQUE_MODE`** — set to `migrate` for the dual-mode phase where both OPAQUE and legacy password login are accepted, then later to `opaque_only` after most users have completed migration. +2. **`OXICLOUD_OPAQUE_SERVER_SETUP`** — generated once and persisted like your JWT secret. Rotating this invalidates every user's registration; treat it as one of the crown jewels. Two ways to generate: + ```bash + # Docker (recommended in production — no toolchain needed): + docker run --rm ghcr.io/atalayalabs/oxicloud:latest opaque-setup + + # From a source checkout: + cargo run --bin opaque-setup + ``` + Both print the base64 value on stdout (with guidance on stderr, so shell pipelines like `$(docker run ... opaque-setup)` capture cleanly). +3. **`OXICLOUD_OPAQUE_KSF_*`** — client-side Argon2id key-stretching cost. Defaults (256 MiB / 3 iter / 4 lanes) are appropriate for modern desktop / phone hardware. Bumping later is safe (only affects new registrations); lowering is not (still-registered users get a security downgrade the next time they change their passphrase). + +The `OXICLOUD_HASH_*` variables (server-side legacy Argon2) and `OXICLOUD_OPAQUE_KSF_*` (client-side OPAQUE Argon2) are intentionally separate: the server-side path is RAM-bounded by concurrent-login traffic and needs to stay modest; the client-side path is single-user per attempt and can afford much higher memory. Tuning them together would force a bad compromise in one direction or the other. + +### What OPAQUE does NOT touch + +Basic-Auth surfaces (Nextcloud sync, WebDAV `/remote.php/dav/…`, CalDAV, CardDAV) accept **app passwords only** — they never accepted the user's primary password to begin with. App passwords are issued via the SPA (`POST /api/auth/app-passwords`) or the Nextcloud Login Flow v2 device-code exchange, live in the `auth.app_passwords` table with their own Argon2id hash, and are verified against that table only. OPAQUE is orthogonal to this — the app-password model already keeps the primary password off the Basic-Auth wire. + +The **Nextcloud Login Flow v2** browser exchange (`POST /login/v2/flow` used by NC clients to bootstrap an app password) currently accepts the primary password once during that browser flow. When OPAQUE ships (Phase 1+), that surface migrates in lock-step with `POST /api/auth/login` — either the browser flow runs OPAQUE too, or it redirects the user to a device-approval flow initiated from a currently-logged-in session. Nothing operators need to configure for this; the transition ships as one piece. + ## Auth policy vector `OXICLOUD_AUTH_POLICIES` is a comma-separated list of additive policy switches. Distinct from `OXICLOUD_AUTH_METHODS` (which enables/disables a method wholesale), each entry here grants a specific exception or restriction to default auth behaviour. Vector shape so future policies can be added by appending a token instead of introducing a new env var per behaviour. Variant names carry their own polarity (`Permit...`, future `Require...` / `Deny...`). diff --git a/docs/config/env.md b/docs/config/env.md index d4e99e51..740e6fc9 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -41,15 +41,27 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_JWT_SECRET` | (auto-generated) | JWT signing secret; auto-persisted to `/.jwt_secret` if unset | | `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` | `3600` | Access token lifetime (1 hour) | | `OXICLOUD_REFRESH_TOKEN_EXPIRY_SECS` | `604800` | Refresh token lifetime (7 days); active sessions auto-renew on use | -| `OXICLOUD_HASH_MEMORY_COST` | `65536` | Argon2id memory cost in KiB (64 MiB) | -| `OXICLOUD_HASH_TIME_COST` | `3` | Argon2id iteration count | -| `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes | +| `OXICLOUD_HASH_MEMORY_COST` | `65536` | Argon2id memory cost in KiB (64 MiB). **Server-side** — used by the legacy password path (`POST /api/auth/login`) and the app-password Basic-Auth verifier. Distinct from `OXICLOUD_OPAQUE_KSF_*` (client-side). | +| `OXICLOUD_HASH_TIME_COST` | `3` | Argon2id iteration count for the server-side legacy path. | +| `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes for the server-side legacy path. | | `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts | | `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted on `POST /api/auth/register` (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed. **Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`**: this one gates SELF-registration (public sign-up), the external list gates INVITATIONS (grants + magic-link to third parties). An operator can lock sign-up to their company domain while leaving invitations open. Subdomains must be listed explicitly. Rejected registrations return 403 `RegistrationDomainNotAllowed` and emit an `audit` line. Example: `mycompany.com,mycompany-eu.com`. | | `OXICLOUD_AUTH_METHODS` | `password,magic_link` | Comma-separated allowlist of auth methods (`password`, `magic_link`, `oidc`). **Fail-fast**: unknown token → boot panic; empty allowlist → boot panic; `oidc` in list without `OXICLOUD_OIDC_ENABLED=true` → boot panic. Removing `password` disables `POST /api/auth/login` (returns 403 `PasswordLoginDisabled`) and password-based `register` (returns 403 `PasswordRegistrationDisabled`). Removing `magic_link` disables `POST /api/auth/magic-link/send` (returns 403 `MagicLinkLoginDisabled`) and the redemption path for login-purpose tokens. Setting `OXICLOUD_AUTH_METHODS=oidc` is the cleanest "SSO-only" posture. **Loose semantic (deprecation warning)**: if this list is explicitly set WITHOUT `oidc` but `OXICLOUD_OIDC_ENABLED=true`, OIDC is served regardless — a boot warning is emitted and this will become a fail-fast panic in the next major release. **Startup gate**: if `magic_link` is the only working method (no `password`, no `oidc`) AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start. **OIDC master rule**: when OIDC is enabled, magic-link login is hard-disabled regardless of this list (would otherwise bypass IdP-enforced MFA / step-up). Legacy alias (**DEPRECATED**): `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the list but emits a boot warning; removal in next major release. | | `OXICLOUD_AUTH_POLICIES` | — | Comma-separated additive policy switches. Each token grants an exception or restriction to the default auth behaviour; empty (unset) = pure defaults. Recognised tokens: `permit_magic_link_for_password_users` (allow magic-link login for accounts that also have a password — off by default because magic-link would weaken the password to mailbox-strength; OIDC-linked users are still refused regardless); `auto_redirect_if_standalone_oidc` (when OIDC is the only working login method, auto-redirect the login page to the IdP instead of showing a click-to-continue button — off by default to avoid redirect loops on IdP failure and preserve logout UX). | | `OXICLOUD_REQUIRE_VERIFIED_EMAIL` | `false` | When `true`, `POST /api/auth/login` returns 403 `EmailNotVerified` for any account whose `email_verified_at` is NULL. Users can prove control by requesting a magic-link (whose redemption stamps `email_verified_at`), so this composes with `magic_link` in `OXICLOUD_AUTH_METHODS` to give users a self-service verification path. Admin-created (`POST /api/admin/users`) and setup-admin (`POST /api/setup`) users are auto-verified. OIDC-JIT users are also stamped verified at creation. | +### OPAQUE aPAKE (zero-knowledge password login) + +OPAQUE (RFC 9807) is a zero-knowledge password-authenticated key exchange: the passphrase never leaves the client, not on registration and not on login. It's shipped in stages (see `docs/plan/opaque.md`); this build carries the **substrate only** — endpoints are inert until `OXICLOUD_OPAQUE_MODE` is set. See `docs/config/authentication.md` for the phase rollout, the migration plan, and admin-facing guidance. + +| Variable | Default | Description | +|---|---|---| +| `OXICLOUD_OPAQUE_MODE` | `off` | Runtime mode. `off` = endpoints 404 (default). `migrate` = endpoints live, legacy `POST /api/auth/login` still accepted. `opaque_only` = endpoints live, legacy refused for users with an envelope. **Effective-mode cross-check**: when `password` is not in `OXICLOUD_AUTH_METHODS`, the mode is auto-downgraded to `off` with an audit-channel INFO line (OPAQUE only replaces the password path — nothing to shadow in an OIDC-only or magic-link-only deployment). So OIDC / magic-link-only operators can safely ignore every `OXICLOUD_OPAQUE_*` variable. | +| `OXICLOUD_OPAQUE_SERVER_SETUP` | — | Base64-encoded `ServerSetup` blob. **Required** when `OXICLOUD_OPAQUE_MODE != off` AND password is enabled — the server refuses to start with a helpful error otherwise. Generate once with the `opaque-setup` CLI subcommand and persist the value like your JWT secret. **Never rotate** — rotating invalidates every user's envelope (they'd all need to reset their passphrase). | +| `OXICLOUD_OPAQUE_KSF_MEMORY_KIB` | `262144` | Client-side Argon2id memory cost in KiB (256 MiB). Runs on the user's device during OPAQUE login/registration, not on the server. Distinct from `OXICLOUD_HASH_MEMORY_COST` (server-side legacy path). Higher values slow brute-force after a hypothetical envelope leak but also slow login on the user's device. | +| `OXICLOUD_OPAQUE_KSF_ITERATIONS` | `3` | Client-side Argon2id iteration count. | +| `OXICLOUD_OPAQUE_KSF_PARALLELISM` | `4` | Client-side Argon2id parallelism lanes. | + ### Rate Limiting & Account Lockout | Variable | Default | Description | diff --git a/example.env b/example.env index 9e00b084..47bfba8c 100644 --- a/example.env +++ b/example.env @@ -188,6 +188,62 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud # Parallelism lanes (default: 2) #OXICLOUD_HASH_PARALLELISM=2 +# ----------------------------------------------------------------------------- +# OPAQUE aPAKE (zero-knowledge password login, RFC 9807) +# ----------------------------------------------------------------------------- +# OPAQUE replaces `POST /api/auth/login` with a zero-knowledge exchange: +# the passphrase never leaves the client, not on registration and not on +# login. This is the substrate for later E2EE work. +# +# Phase 0 (this build) ships the primitives only — endpoints are inert +# until `OXICLOUD_OPAQUE_MODE` is set. Leave everything commented for a +# no-op install; OIDC-only and magic-link-only deployments never need to +# touch OPAQUE at all (see the effective-mode downgrade below). + +# OPAQUE mode gate. Values: off | migrate | opaque_only +# off — endpoints 404. Default. Safe for OIDC-only / magic-link-only. +# migrate — endpoints live; legacy `POST /api/auth/login` still accepted. +# opaque_only — endpoints live; legacy refused for users with an envelope. +# Effective mode is automatically downgraded to `off` when password auth +# is disabled via OXICLOUD_AUTH_METHODS (OPAQUE has nothing to shadow) — +# an audit-channel log line explains why. So enabling this without +# password in OXICLOUD_AUTH_METHODS is a no-op, not a boot error. +#OXICLOUD_OPAQUE_MODE=off + +# Persistent OPAQUE server keypair (base64-encoded ServerSetup blob). +# Generated ONCE per deployment; rotating this invalidates every user's +# registration (they'd all be forced to re-register on next login). Only +# required when `OXICLOUD_OPAQUE_MODE != off` AND password auth is +# enabled — otherwise the value is ignored. +# +# Generate on first-time enable: +# # Docker (recommended for production): +# docker run --rm ghcr.io/atalayalabs/oxicloud:latest opaque-setup +# # Or from a source checkout: +# cargo run --bin opaque-setup +# Both print the base64 value on stdout (guidance on stderr, so shell +# pipelines capture cleanly). Paste the printed line into your env or +# secrets manager. NEVER regenerate — treat it like your JWT secret; +# losing it forces every user to reset their passphrase. +#OXICLOUD_OPAQUE_SERVER_SETUP= + +# Client-side Argon2id key-stretching parameters (RFC 9807 KSF). +# These run on the USER'S DEVICE during OPAQUE login/registration — +# distinct from OXICLOUD_HASH_* which runs on the server for the legacy +# password path. Client-side execution means we can afford higher memory +# than the server would (each user pays once for themselves rather than +# the server paying for every concurrent login). +# +# Bumping these does NOT affect existing envelopes; they'd re-mint on +# the user's next password change. +# +# Memory cost in KiB (default: 262144 = 256 MiB) +#OXICLOUD_OPAQUE_KSF_MEMORY_KIB=262144 +# Iterations (default: 3) +#OXICLOUD_OPAQUE_KSF_ITERATIONS=3 +# Parallelism lanes (default: 4) +#OXICLOUD_OPAQUE_KSF_PARALLELISM=4 + # ----------------------------------------------------------------------------- # RATE LIMITING & ACCOUNT LOCKOUT # ----------------------------------------------------------------------------- diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1f96d88c..0180a4e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "oxicloud-frontend", "version": "0.0.0", + "dependencies": { + "@serenity-kit/opaque": "^1.1.0" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@sveltejs/adapter-static": "^3.0.10", @@ -1492,6 +1495,15 @@ "win32" ] }, + "node_modules/@serenity-kit/opaque": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@serenity-kit/opaque/-/opaque-1.1.0.tgz", + "integrity": "sha512-Y6v/+hRMn0MdMEk5+/ArM0vPIiFfFEbdTZc7oAx+cWyvGODGezAQ/sMjXBVogf7NsS9z9EV0Ve5paZCVULuedw==", + "license": "MIT", + "bin": { + "opaque": "bin/index.js" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9d2974fa..daf4d24d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,5 +45,8 @@ "vite": "^6.4.3", "vite-plugin-istanbul": "^8.0.0", "vitest": "^4.1.9" + }, + "dependencies": { + "@serenity-kit/opaque": "^1.1.0" } } diff --git a/frontend/src/lib/api/endpoints/opaque.test.ts b/frontend/src/lib/api/endpoints/opaque.test.ts new file mode 100644 index 00000000..b4f2e8a0 --- /dev/null +++ b/frontend/src/lib/api/endpoints/opaque.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock the transport layer + CSRF headers so the test asserts on the wire +// shape we send to the backend, not the actual network. WASM handshake +// results are captured by mocking `@serenity-kit/opaque`'s client namespace. +vi.mock('$lib/api/client', () => ({ + apiFetch: vi.fn(), + ApiError: class ApiError extends Error { + readonly status: number; + readonly statusText: string; + readonly errorType?: string; + constructor( + status: number, + statusText: string, + _resource: unknown, + errorType?: string, + message?: string + ) { + super(message ?? `${status} ${statusText}`); + this.status = status; + this.statusText = statusText; + this.errorType = errorType; + } + } +})); +vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({ 'x-csrf-token': 'test' }) })); + +// Stub the WASM handshake with deterministic strings so the test can +// assert what the wire body contains without pulling in the real WASM. +vi.mock('@serenity-kit/opaque', () => ({ + ready: Promise.resolve(), + client: { + startRegistration: vi.fn(() => ({ + clientRegistrationState: 'STATE-R', + registrationRequest: 'REQ-R' + })), + finishRegistration: vi.fn(() => ({ + registrationRecord: 'RECORD-R', + exportKey: 'EK', + serverStaticPublicKey: 'SPK' + })), + startLogin: vi.fn(() => ({ + clientLoginState: 'STATE-L', + startLoginRequest: 'REQ-L' + })), + finishLogin: vi.fn(() => ({ + finishLoginRequest: 'FIN-L', + sessionKey: 'SK', + exportKey: 'EK', + serverStaticPublicKey: 'SPK' + })) + } +})); + +import { apiFetch, ApiError } from '$lib/api/client'; +import * as opaque from '@serenity-kit/opaque'; +import { opaqueLogin, opaqueRegister } from './opaque'; + +const f = apiFetch as unknown as ReturnType; +const fin = opaque.client.finishLogin as unknown as ReturnType; + +const KSF = { memoryKib: 8, iterations: 1, parallelism: 1 }; + +function okJson(body: unknown, status = 200) { + return { + ok: true, + status, + statusText: 'OK', + clone() { + return this; + }, + json: async () => body + }; +} + +function errJson(status: number, body: unknown) { + return { + ok: false, + status, + statusText: 'Bad', + clone() { + return this; + }, + json: async () => body + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Reset finishLogin to the truthy default; individual tests override. + fin.mockReturnValue({ + finishLoginRequest: 'FIN-L', + sessionKey: 'SK', + exportKey: 'EK', + serverStaticPublicKey: 'SPK' + }); +}); + +describe('opaqueRegister', () => { + it('POSTs both rounds with the WASM-produced payloads', async () => { + f.mockResolvedValueOnce(okJson({ registrationResponse: 'RESP-R' })).mockResolvedValueOnce( + okJson({}, 204) + ); + await opaqueRegister('correct horse battery staple', KSF, 1); + + expect(f).toHaveBeenCalledTimes(2); + const [startUrl, startInit] = f.mock.calls[0]; + expect(startUrl).toBe('/api/auth/opaque/register/start'); + expect(JSON.parse(startInit.body as string)).toEqual({ registrationRequest: 'REQ-R' }); + + const [finishUrl, finishInit] = f.mock.calls[1]; + expect(finishUrl).toBe('/api/auth/opaque/register/finish'); + expect(JSON.parse(finishInit.body as string)).toEqual({ + registrationRecord: 'RECORD-R', + ciphersuiteVersion: 1 + }); + }); + + it('throws ApiError with parsed error_type on start failure', async () => { + f.mockResolvedValueOnce( + errJson(409, { error_type: 'OpaqueAlreadyRegistered', message: 'already have envelope' }) + ); + await expect(opaqueRegister('pw', KSF, 1)).rejects.toMatchObject({ + status: 409, + errorType: 'OpaqueAlreadyRegistered' + }); + }); + + it('never puts the passphrase on the wire', async () => { + f.mockResolvedValueOnce(okJson({ registrationResponse: 'RESP-R' })).mockResolvedValueOnce( + okJson({}, 204) + ); + const secret = 'hunter2'; + await opaqueRegister(secret, KSF, 1); + for (const [, init] of f.mock.calls) { + expect(String(init.body ?? '')).not.toContain(secret); + } + }); +}); + +describe('opaqueLogin', () => { + it('POSTs KE1 with the user identifier + KE3 with the exchange id', async () => { + f.mockResolvedValueOnce( + okJson({ exchangeId: 'XID-42', loginResponse: 'RESP-L' }) + ).mockResolvedValueOnce( + okJson({ + user: { id: 'u1', email: 'a@x.test' }, + access_token: 'at', + refresh_token: 'rt', + token_type: 'Bearer', + expires_in: 3600 + }) + ); + const auth = await opaqueLogin('alice@example.com', 'pw', KSF); + expect(auth.access_token).toBe('at'); + + const [ke1Url, ke1Init] = f.mock.calls[0]; + expect(ke1Url).toBe('/api/auth/opaque/login/ke1'); + expect(JSON.parse(ke1Init.body as string)).toEqual({ + userIdentifier: 'alice@example.com', + startLoginRequest: 'REQ-L' + }); + + const [ke3Url, ke3Init] = f.mock.calls[1]; + expect(ke3Url).toBe('/api/auth/opaque/login/ke3'); + expect(JSON.parse(ke3Init.body as string)).toEqual({ + exchangeId: 'XID-42', + finishLoginRequest: 'FIN-L' + }); + }); + + it('throws InvalidCredentials without touching the server when finishLogin returns undefined', async () => { + // Wrong-passphrase case in the WASM API: finishLogin returns + // undefined. Verify we short-circuit locally with the same + // error_type shape the server would emit — anti-enumeration + // requires both paths look identical to the caller. + fin.mockReturnValueOnce(undefined); + f.mockResolvedValueOnce(okJson({ exchangeId: 'XID', loginResponse: 'RESP-L' })); + await expect(opaqueLogin('a@x.test', 'wrong', KSF)).rejects.toMatchObject({ + status: 401, + errorType: 'InvalidCredentials' + }); + expect(f).toHaveBeenCalledTimes(1); // KE1 only — KE3 must NOT fire + }); + + it('bubbles up the server error_type on KE1 failure', async () => { + f.mockResolvedValueOnce(errJson(429, { error_type: 'RateLimited', message: 'slow down' })); + const err = await opaqueLogin('a@x.test', 'pw', KSF).then( + () => null, + (e) => e + ); + expect(err).toBeInstanceOf(ApiError); + expect(err).toMatchObject({ status: 429, errorType: 'RateLimited' }); + }); +}); diff --git a/frontend/src/lib/api/endpoints/opaque.ts b/frontend/src/lib/api/endpoints/opaque.ts new file mode 100644 index 00000000..b10ee71d --- /dev/null +++ b/frontend/src/lib/api/endpoints/opaque.ts @@ -0,0 +1,236 @@ +/** + * OPAQUE aPAKE (RFC 9807) client wrapper. Phase 0 substrate — endpoints + * are wired in Phase 1; this module ships the primitives so the login form + * can adopt them in a single small change once the handlers land. + * + * The passphrase never leaves this file. All `password` inputs flow into + * the WASM handshake exclusively; nothing serialises them to the network + * or logs them. Callers are responsible for clearing their own copy from + * component state as soon as the returned promise settles. + * + * ## Wire shape + * + * Two HTTP round-trips per operation, matching what the backend expects: + * + * Registration (only reachable while already authenticated — Phase 1 flow): + * ``` + * POST /api/auth/opaque/register/start { registrationRequest } + * → { registrationResponse } + * POST /api/auth/opaque/register/finish { registrationRecord, ciphersuiteVersion } + * → 204 No Content + * ``` + * + * Login (unauthenticated): + * ``` + * POST /api/auth/opaque/login/ke1 { userIdentifier, startLoginRequest } + * → { exchangeId, loginResponse } + * POST /api/auth/opaque/login/ke3 { exchangeId, finishLoginRequest } + * → { user, access_token, refresh_token, ... } // same AuthResponse shape + * ``` + * + * The KSF params are frozen at first-time registration into + * `auth.users.opaque_ciphersuite_version` server-side; the client must + * always send matching params on login — that's what [`OpaqueKsfConfig`] + * carries. In Phase 1 the config is fetched from `/api/health` (or a + * dedicated `/api/auth/opaque/params` endpoint) at page load and cached. + */ +import { client, ready } from '@serenity-kit/opaque'; +import { ApiError, apiFetch } from '$lib/api/client'; +import { getCsrfHeaders } from '$lib/api/csrf'; +import type { AuthResponse } from '$lib/api/types'; + +/** + * Client-side Argon2id parameters — must match the server's config + * ([`OpaqueConfig::ksf_*`] in Rust). Fetched from the server at page load + * so a bump in either direction stays in lock-step; hardcoded fallbacks + * mirror the Rust defaults for offline dev. + */ +export interface OpaqueKsfConfig { + /** Memory cost in KiB. Server default: 262144 (256 MiB). */ + memoryKib: number; + /** Iterations. Server default: 3. */ + iterations: number; + /** Parallelism / lanes. Server default: 4. */ + parallelism: number; +} + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +/** Build the shape `@serenity-kit/opaque` expects for its `keyStretching` opt. */ +function ksfOption(cfg: OpaqueKsfConfig) { + return { + 'argon2id-custom': { + iterations: cfg.iterations, + memory: cfg.memoryKib, + parallelism: cfg.parallelism + } + } as const; +} + +/** + * Best-effort parse of the backend `ErrorResponse` shape into a stable + * pair. Never throws; mirrors the same shape `auth.ts` uses. + */ +async function parseErrorBody(res: Response): Promise<{ errorType?: string; message?: string }> { + try { + const body = (await res.clone().json()) as { + error_type?: unknown; + message?: unknown; + error?: unknown; + }; + const errorType = typeof body.error_type === 'string' ? body.error_type : undefined; + const rawMessage = + (typeof body.message === 'string' ? body.message : undefined) ?? + (typeof body.error === 'string' ? body.error : undefined); + return { errorType, message: rawMessage }; + } catch { + return {}; + } +} + +/** + * Register an OPAQUE envelope for the currently-authenticated caller. + * Two HTTP round-trips; the WASM handshake runs entirely client-side. + * + * Called by the migration hook after any successful legacy-password + * login (Phase 2) and by the change-password / password-reset flows + * (Phase 1+) so the envelope stays in lock-step with the passphrase. + * + * Throws [`ApiError`] with the parsed `error_type` on server rejection; + * throws a plain [`Error`] on protocol failures. + */ +export async function opaqueRegister( + password: string, + ksf: OpaqueKsfConfig, + ciphersuiteVersion: number +): Promise { + await ready; + + // ── Round 1 ───────────────────────────────────────────────────────── + const { clientRegistrationState, registrationRequest } = client.startRegistration({ password }); + const startRes = await apiFetch('/api/auth/opaque/register/start', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify({ registrationRequest }) + }); + if (!startRes.ok) { + const { errorType, message } = await parseErrorBody(startRes); + throw new ApiError( + startRes.status, + startRes.statusText, + '/api/auth/opaque/register/start', + errorType, + message ?? 'opaque register start failed' + ); + } + const { registrationResponse } = (await startRes.json()) as { registrationResponse: string }; + + // ── Round 2 ───────────────────────────────────────────────────────── + const { registrationRecord } = client.finishRegistration({ + password, + registrationResponse, + clientRegistrationState, + keyStretching: ksfOption(ksf) + }); + const finishRes = await apiFetch('/api/auth/opaque/register/finish', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify({ registrationRecord, ciphersuiteVersion }) + }); + if (!finishRes.ok) { + const { errorType, message } = await parseErrorBody(finishRes); + throw new ApiError( + finishRes.status, + finishRes.statusText, + '/api/auth/opaque/register/finish', + errorType, + message ?? 'opaque register finish failed' + ); + } +} + +/** + * Log in via OPAQUE. Two HTTP round-trips; server issues the session + * cookies + refresh token on successful `ke3`. + * + * Returns the [`AuthResponse`] identical in shape to the legacy login + * path, so callers (`LoginForm.svelte`) can flow both branches through a + * single downstream handler. + * + * Throws [`ApiError`] with the parsed `error_type` on server rejection + * — including the anti-enumeration case where the account has no + * envelope (server returns the same `InvalidCredentials` code as a + * wrong-passphrase failure to avoid leaking which one it was). + */ +export async function opaqueLogin( + userIdentifier: string, + password: string, + ksf: OpaqueKsfConfig +): Promise { + await ready; + + // ── KE1 ───────────────────────────────────────────────────────────── + const { clientLoginState, startLoginRequest } = client.startLogin({ password }); + const ke1Res = await apiFetch('/api/auth/opaque/login/ke1', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify({ userIdentifier, startLoginRequest }) + }); + if (!ke1Res.ok) { + const { errorType, message } = await parseErrorBody(ke1Res); + throw new ApiError( + ke1Res.status, + ke1Res.statusText, + '/api/auth/opaque/login/ke1', + errorType, + message ?? 'opaque login ke1 failed' + ); + } + const { exchangeId, loginResponse } = (await ke1Res.json()) as { + exchangeId: string; + loginResponse: string; + }; + + // ── KE3 ───────────────────────────────────────────────────────────── + // `finishLogin` returns undefined when the server response is + // well-formed but the passphrase is wrong — surface that as a + // terminal client-side failure (never reaches the server) rather + // than sending garbage to KE3. + const finished = client.finishLogin({ + clientLoginState, + loginResponse, + password, + keyStretching: ksfOption(ksf) + }); + if (!finished) { + throw new ApiError( + 401, + 'Unauthorized', + '/api/auth/opaque/login/ke1', + 'InvalidCredentials', + 'invalid credentials' + ); + } + const { finishLoginRequest } = finished; + + const ke3Res = await apiFetch('/api/auth/opaque/login/ke3', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify({ exchangeId, finishLoginRequest }) + }); + if (!ke3Res.ok) { + const { errorType, message } = await parseErrorBody(ke3Res); + throw new ApiError( + ke3Res.status, + ke3Res.statusText, + '/api/auth/opaque/login/ke3', + errorType, + message ?? 'opaque login ke3 failed' + ); + } + return (await ke3Res.json()) as AuthResponse; +} diff --git a/migrations/20260926000000_auth_opaque.sql b/migrations/20260926000000_auth_opaque.sql new file mode 100644 index 00000000..91c85bc9 --- /dev/null +++ b/migrations/20260926000000_auth_opaque.sql @@ -0,0 +1,109 @@ +-- ════════════════════════════════════════════════════════════════════════════ +-- OPAQUE aPAKE registration + login (Phase 0) +-- ════════════════════════════════════════════════════════════════════════════ +-- OPAQUE (RFC 9807) is a zero-knowledge password-authenticated key exchange: +-- the server never sees the user's passphrase, not on registration and not on +-- login. What lives server-side is the OPAQUE "envelope" — an opaque blob the +-- client uploads at registration time that can only be decrypted with the +-- passphrase (via a memory-hard KSF). Login is a two-round exchange that +-- proves possession of the passphrase without transmitting it. +-- +-- This migration reserves the storage for the envelope + a small set of +-- lifecycle timestamps and the client-visible pubkey. The columns are added +-- as NULLABLE so existing password accounts keep working — the OPAQUE +-- endpoints ship inert (`OXICLOUD_OPAQUE_MODE=off`) in Phase 0. Later phases +-- populate these columns via a silent-migration hook on legacy login. +-- +-- Columns: +-- opaque_envelope BYTEA — RegistrationUpload from opaque-ke, +-- serialized. Cannot be decrypted by +-- the server; only the client with the +-- correct passphrase can complete the +-- login handshake. This blob also +-- embeds the OPAQUE client static +-- pubkey used in the 3DH login step — +-- extracted at handshake time, not +-- denormalised into its own column +-- (see docs/plan/opaque.md: the E2EE +-- identity pubkey is a separate, +-- later concern with its own bridges +-- table; conflating the two now would +-- bias the E2EE design toward "one +-- KEK bridge = OPAQUE" which excludes +-- magic-link / OIDC users). +-- opaque_ciphersuite_version SMALLINT — matches +-- `AppConfig::opaque.ciphersuite_version`. +-- Reserved for a future ciphersuite +-- migration; current bind is v1 = +-- Ristretto255-SHA512-3DH-Argon2id. +-- opaque_registered_at TIMESTAMPTZ — first-ever successful registration. +-- Set by /register/finish. Presence +-- means "this user has an OPAQUE +-- envelope on file." +-- opaque_migrated_at TIMESTAMPTZ — first successful login VIA OPAQUE. +-- Presence means "legacy password +-- endpoint is refused for this user +-- (Phase 3+)." Distinct from +-- registered_at because the transition +-- from "envelope exists" to "OPAQUE is +-- mandatory" needs a separate signal. +-- +-- force_password_change_at_next_login BOOLEAN — orthogonal but shipped in +-- the same migration because the +-- admin-set-password flow depends on +-- it (see docs/plan/opaque.md §Phase +-- 0). Set by the admin reset +-- endpoint; cleared by change_password. +-- Enables the "admin picks a +-- temporary password, user must +-- replace it on first use" pattern +-- that keeps admin capability intact +-- through the OPAQUE cutover. +-- +-- Indexes: +-- idx_users_opaque_migrated — partial (WHERE opaque_migrated_at IS NOT +-- NULL). Answers "how many users have finished +-- migration?" cheaply for the ops dashboard; +-- would be sparse if unfiltered. + +ALTER TABLE auth.users + ADD COLUMN opaque_envelope BYTEA, + ADD COLUMN opaque_ciphersuite_version SMALLINT, + ADD COLUMN opaque_registered_at TIMESTAMPTZ, + ADD COLUMN opaque_migrated_at TIMESTAMPTZ, + ADD COLUMN force_password_change_at_next_login BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE INDEX idx_users_opaque_migrated + ON auth.users (opaque_migrated_at) + WHERE opaque_migrated_at IS NOT NULL; + +COMMENT ON COLUMN auth.users.opaque_envelope IS + 'OPAQUE (RFC 9807) RegistrationUpload blob. Server-opaque; the passphrase + is required client-side to complete the login handshake. NULL = user has + no OPAQUE registration (Phase 0 default, or account pre-dates the + silent-migration rollout in Phase 2). E2EE identity pubkeys live in a + separate table added in the E2EE phase — this blob is auth-scoped only.'; + +COMMENT ON COLUMN auth.users.opaque_ciphersuite_version IS + 'Version of the OPAQUE ciphersuite this envelope was minted under. Bound + at registration time; changing the server-side ciphersuite invalidates + every envelope minted before the flip. Current bind is v1 = + Ristretto255-SHA512-3DH with Argon2id KSF.'; + +COMMENT ON COLUMN auth.users.opaque_registered_at IS + 'When this account first minted an OPAQUE envelope. Presence means "the + endpoints are usable for this user." Distinct from opaque_migrated_at: + you can be registered but still fall back to legacy password auth during + the dual-mode window (Phase 2).'; + +COMMENT ON COLUMN auth.users.opaque_migrated_at IS + 'When this account first completed a successful OPAQUE login. Presence + means the legacy POST /api/auth/login endpoint is refused for this user + (Phase 3+). Once set, the admin-reset flow that goes back through legacy + also nulls this column to re-open the fallback path.'; + +COMMENT ON COLUMN auth.users.force_password_change_at_next_login IS + 'When TRUE, the next successful login (legacy or OPAQUE) redirects the + user to the change-password flow before any other action. Set by the + admin-reset endpoint so admin-picked passwords are always temporary; + cleared by change_password on success.'; diff --git a/src/bin/opaque-setup.rs b/src/bin/opaque-setup.rs new file mode 100644 index 00000000..ade94186 --- /dev/null +++ b/src/bin/opaque-setup.rs @@ -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."); +} diff --git a/src/common/config.rs b/src/common/config.rs index 85aca2ca..a3cec2b9 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -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, + /// 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::() + { + cfg.ksf_memory_kib = n; + } + if let Ok(v) = env::var("OXICLOUD_OPAQUE_KSF_ITERATIONS") + && let Ok(n) = v.parse::() + { + cfg.ksf_iterations = n; + } + if let Ok(v) = env::var("OXICLOUD_OPAQUE_KSF_PARALLELISM") + && let Ok(n) = v.parse::() + { + 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) -> 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); + } + } } diff --git a/src/common/di.rs b/src/common/di.rs index 72d33ed3..f2d38daa 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -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> = 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, pub auth_service: Option, + /// 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, + >, pub nextcloud: Option, pub admin_settings_service: Option>, /// WASM plugin management (list/install/toggle/remove), backing the admin diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 20ce00aa..52779992 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -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; diff --git a/src/infrastructure/services/opaque_service.rs b/src/infrastructure/services/opaque_service.rs new file mode 100644 index 00000000..14fc41b9 --- /dev/null +++ b/src/infrastructure/services/opaque_service.rs @@ -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, + 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 { + 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 { + &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::::new(&mut rng); + base64::engine::general_purpose::STANDARD.encode(setup.serialize()) + } +} + +fn decode_server_setup(b64: &str) -> Result, 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::::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 { + 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::::start(&mut client_rng, passphrase) + .expect("client registration start"); + let server_reg_start = ServerRegistration::::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::::finish(client_reg_finish.message); + let password_file_bytes = password_file.serialize(); + + // ── Login (correct passphrase → session keys match) ───────────── + let client_login_start = ClientLogin::::start(&mut client_rng, passphrase) + .expect("client login start"); + let stored = ServerRegistration::::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::::start(&mut client_rng, b"wrong-passphrase") + .expect("client login start (wrong pass)"); + let stored_again = + ServerRegistration::::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); + } +} diff --git a/tests/api/opaque_substrate.hurl b/tests/api/opaque_substrate.hurl new file mode 100644 index 00000000..7c1d5bed --- /dev/null +++ b/tests/api/opaque_substrate.hurl @@ -0,0 +1,76 @@ +# ============================================================= +# OxiCloud — OPAQUE aPAKE (Phase 0 substrate) — inertness smoke +# ============================================================= +# The full OPAQUE handshake is NOT testable in Hurl (every message +# contains session-random OPRF blinding + AKE nonces that can't be +# hardcoded in a .hurl body). Full-flow assertions belong in a Rust +# integration test using `opaque-ke` client-side against a real +# server. That lands with the Phase 1 endpoints. +# +# What THIS file asserts is the substrate-level contract for Phase 0: +# +# 1. The server booted with the OPAQUE substrate loaded — proved +# transitively by the fact that this suite reached the +# `--test-report` stage at all. `tests/common/server.env` sets +# `OXICLOUD_OPAQUE_MODE=migrate` + a persisted `SERVER_SETUP`; +# a boot failure (bad base64, missing setup, ciphersuite drift) +# would 500 every request or refuse to bind the port. +# +# 2. The Phase 1 endpoints are not yet routed. An unauthenticated +# POST to any `/api/*` path returns **401** (not 404) — the +# `/api` namespace is behind the auth middleware, so a missing +# route is indistinguishable from "route exists but needs +# auth". That's deliberate anti-enumeration: attackers can't +# probe which endpoints exist. +# +# When Phase 1 ships: +# - Register endpoints stay 401 unauth (they'll be +# session-required — anti-enum still applies). +# - Login KE1 / KE3 will flip to **400** because they'll be +# public and reject the placeholder payloads below as +# malformed. That's the natural regression signal: update +# this file to hit the endpoints with a valid handshake +# driven from a Rust integration test. +# +# 3. The legacy `POST /api/auth/login` continues to work under +# Migrate mode. `auth_login.hurl` asserts this thoroughly; we +# don't duplicate it here. +# ============================================================= + +# ───────────────────────────────────────────────────────────── +# Case 1 — Register-start endpoint not routed (401 anti-enum). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/opaque/register/start +Content-Type: application/json +{ "registrationRequest": "unused-phase-0" } + +HTTP 401 + +# ───────────────────────────────────────────────────────────── +# Case 2 — Register-finish endpoint not routed (401 anti-enum). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/opaque/register/finish +Content-Type: application/json +{ "registrationRecord": "unused-phase-0", "ciphersuiteVersion": 1 } + +HTTP 401 + +# ───────────────────────────────────────────────────────────── +# Case 3 — Login KE1 endpoint not routed (401 anti-enum). +# Will flip to 400 in Phase 1 (public + malformed body). +# ───────────────────────────────────────────────────────────── +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 diff --git a/tests/api/run.sh b/tests/api/run.sh index d3f913e5..d39b51f8 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -146,6 +146,7 @@ log "Running Hurl tests..." hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test --jobs 1 \ "$API_DIR/setup.hurl" \ "$API_DIR/auth_login.hurl" \ + "$API_DIR/opaque_substrate.hurl" \ "$API_DIR/user_ui_preferences.hurl" \ "$API_DIR/auth_session_lifecycle.hurl" \ "$API_DIR/auth_magic_link_login.hurl" \ diff --git a/tests/common/server.env b/tests/common/server.env index 7d7559c8..f5f8cc7e 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -117,3 +117,31 @@ OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=50 # permits IP spoofing for tests OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0 +# ── OPAQUE aPAKE (Phase 0 substrate) ──────────────────────────────── +# Boot the OPAQUE service in Migrate mode so every Hurl run exercises: +# 1. OpaqueConfig::from_env parsing all five OPAQUE env vars. +# 2. effective_mode(&auth) permitting Migrate because password IS in +# OXICLOUD_AUTH_METHODS above (would auto-downgrade to Off if we +# had disabled it — that path is unit-tested in +# common::config::tests::effective_mode_downgrades_to_off_...). +# 3. OpaqueService::from_config accepting a valid serialised setup. +# 4. AppState wiring populating `opaque_service = Some(_)`. +# +# Phase 0 has no wire-facing endpoints, so this only proves the +# substrate loads cleanly; opaque_substrate.hurl asserts the legacy +# /api/auth/login path remains intact and the future OPAQUE endpoints +# still 404 (they flip to 200 when Phase 1 lands). +# +# The SERVER_SETUP below is a throwaway keypair generated once for the +# test env — real deployments call `opaque-setup` and paste the output. +# Never reuse this value outside CI. Regenerate any time with: +# cargo run --bin opaque-setup +OXICLOUD_OPAQUE_MODE=migrate +OXICLOUD_OPAQUE_SERVER_SETUP="ZY4hAGa1MNyE7Ht+8ksLcyMmi/K2iJvxQly+DdfllUxjiH0+CjCt4hG6+9Y68jGet2L213dV0hajCbr4fXnekkWtUxqLr+butVHEksZ9NJRuZTvS6SMC73yf/yku4WUHT1NSRB2yHurAFmYn75D9wdA1VaXTuwgO/u5i1pvcsQs=" +# Fast Argon2id — CI machines are underpowered vs production (256 MiB +# default would drag every test-scaffold future OPAQUE handshake in +# Phase 1+). Matches the params used in the round-trip unit test. +OXICLOUD_OPAQUE_KSF_MEMORY_KIB=8 +OXICLOUD_OPAQUE_KSF_ITERATIONS=1 +OXICLOUD_OPAQUE_KSF_PARALLELISM=1 +