feat(auth): bring opaque (RFC 9807) auth
OPAQUE (RFC 9807) implementation (using `opaque-ke` crate)
with opaque authentfication, server will never receive the password (in the auth=password mode)
this is a must have to create trust with users to permit end to end encryption in the future
(we cannot know if user use the same password/passphrase for his asymetric key or his oxicloud auth,
this is why server must never have the password)
pass1: prepare server
This commit is contained in:
Generated
+55
@@ -1846,6 +1846,7 @@ dependencies = [
|
|||||||
"curve25519-dalek-derive",
|
"curve25519-dalek-derive",
|
||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
"fiat-crypto",
|
"fiat-crypto",
|
||||||
|
"rand_core 0.6.4",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -1946,6 +1947,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "digest"
|
name = "digest"
|
||||||
version = "0.10.7"
|
version = "0.10.7"
|
||||||
@@ -2672,6 +2684,7 @@ version = "0.14.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
@@ -4271,6 +4284,28 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
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]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -4375,6 +4410,7 @@ dependencies = [
|
|||||||
"mp3-duration",
|
"mp3-duration",
|
||||||
"ndarray",
|
"ndarray",
|
||||||
"nom-exif",
|
"nom-exif",
|
||||||
|
"opaque-ke",
|
||||||
"ort",
|
"ort",
|
||||||
"pdf-extract",
|
"pdf-extract",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -7174,6 +7210,25 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
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]]
|
[[package]]
|
||||||
name = "vsimd"
|
name = "vsimd"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|||||||
+15
@@ -52,6 +52,13 @@ sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "tls-rustls
|
|||||||
jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] }
|
jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] }
|
||||||
argon2 = "0.5.3"
|
argon2 = "0.5.3"
|
||||||
rand_core = { version = "0.6", features = ["std", "getrandom"] }
|
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"
|
quick-xml = "0.41.0"
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
moka = { version = "0.12.15", features = ["future", "sync"] }
|
moka = { version = "0.12.15", features = ["future", "sync"] }
|
||||||
@@ -167,6 +174,14 @@ path = "src/bin/generate-openapi.rs"
|
|||||||
name = "migrate-nfc-filenames"
|
name = "migrate-nfc-filenames"
|
||||||
path = "src/bin/migrate-nfc-filenames.rs"
|
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]]
|
[[bin]]
|
||||||
name = "load-seed"
|
name = "load-seed"
|
||||||
path = "src/bin/load-seed.rs"
|
path = "src/bin/load-seed.rs"
|
||||||
|
|||||||
+13
-4
@@ -44,7 +44,8 @@ RUN mkdir -p src/bin && \
|
|||||||
echo 'fn main() { println!("Dummy build for caching dependencies"); }' > src/main.rs && \
|
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/generate-openapi.rs && \
|
||||||
echo 'fn main() {}' > src/bin/migrate-nfc-filenames.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-*
|
rm -rf src static-dist target/release/deps/oxicloud* target/release/build/oxicloud-*
|
||||||
|
|
||||||
# ─── Stage 3: Build the application ──────────────────────────────────────────
|
# ─── Stage 3: Build the application ──────────────────────────────────────────
|
||||||
@@ -84,7 +85,7 @@ RUN DATABASE_URL="${DATABASE_URL}" \
|
|||||||
GITHUB_SHA="${GITHUB_SHA}" \
|
GITHUB_SHA="${GITHUB_SHA}" \
|
||||||
GITHUB_REF_NAME="${GITHUB_REF_NAME}" \
|
GITHUB_REF_NAME="${GITHUB_REF_NAME}" \
|
||||||
GITHUB_HEAD_REF="${GITHUB_HEAD_REF}" \
|
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
|
# 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).
|
# below (build.rs has no asset pipeline — it only injects git metadata).
|
||||||
COPY --from=frontend /static-dist ./static-dist
|
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 && \
|
cargo build --release && \
|
||||||
mkdir -p /app/bin && \
|
mkdir -p /app/bin && \
|
||||||
cp target/release/oxicloud /app/bin/oxicloud && \
|
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 ─────────────────
|
# ─── Stage 3c: Select the builder & normalise the binary path ─────────────────
|
||||||
# FROM expands the global ${BUILDER} arg to alias the chosen builder stage
|
# 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
|
FROM ${BUILDER} AS app
|
||||||
ARG BIN_DIR
|
ARG BIN_DIR
|
||||||
RUN mkdir -p /app/release && \
|
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 ──────────────────────────────────────────
|
# ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
|
||||||
FROM alpine:3.24.0
|
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
|
# to preview, drop `--dry-run` to execute. One-shot tool, safe to
|
||||||
# ship; it only mutates `storage.files` rows whose name ≠ NFC(name).
|
# ship; it only mutates `storage.files` rows whose name ≠ NFC(name).
|
||||||
COPY --from=app --chmod=755 /app/release/migrate-nfc-filenames /usr/local/bin/
|
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 <image> 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
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \
|
RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \
|
||||||
chmod 755 /usr/local/bin/entrypoint.sh
|
chmod 755 /usr/local/bin/entrypoint.sh
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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...`).
|
`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...`).
|
||||||
|
|||||||
+15
-3
@@ -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 `<STORAGE_PATH>/.jwt_secret` if unset |
|
| `OXICLOUD_JWT_SECRET` | (auto-generated) | JWT signing secret; auto-persisted to `<STORAGE_PATH>/.jwt_secret` if unset |
|
||||||
| `OXICLOUD_ACCESS_TOKEN_EXPIRY_SECS` | `3600` | Access token lifetime (1 hour) |
|
| `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_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_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 |
|
| `OXICLOUD_HASH_TIME_COST` | `3` | Argon2id iteration count for the server-side legacy path. |
|
||||||
| `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes |
|
| `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes for the server-side legacy path. |
|
||||||
| `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts |
|
| `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_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_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_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. |
|
| `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
|
### Rate Limiting & Account Lockout
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|
|||||||
+56
@@ -188,6 +188,62 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
|
|||||||
# Parallelism lanes (default: 2)
|
# Parallelism lanes (default: 2)
|
||||||
#OXICLOUD_HASH_PARALLELISM=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
|
# RATE LIMITING & ACCOUNT LOCKOUT
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
Generated
+12
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "oxicloud-frontend",
|
"name": "oxicloud-frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@serenity-kit/opaque": "^1.1.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
@@ -1492,6 +1495,15 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@sindresorhus/merge-streams": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
|
||||||
|
|||||||
@@ -45,5 +45,8 @@
|
|||||||
"vite": "^6.4.3",
|
"vite": "^6.4.3",
|
||||||
"vite-plugin-istanbul": "^8.0.0",
|
"vite-plugin-istanbul": "^8.0.0",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^4.1.9"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@serenity-kit/opaque": "^1.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<typeof vi.fn>;
|
||||||
|
const fin = opaque.client.finishLogin as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
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<AuthResponse> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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.';
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
@@ -1648,6 +1648,156 @@ impl Default for OidcConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OPAQUE aPAKE configuration (RFC 9807, Phase 0 substrate).
|
||||||
|
///
|
||||||
|
/// OPAQUE is a zero-knowledge password-authenticated key exchange: the
|
||||||
|
/// passphrase never leaves the client. This struct carries the runtime
|
||||||
|
/// knobs the server needs (mode, ciphersuite version, persisted
|
||||||
|
/// [`ServerSetup`] blob) plus the client-side KSF params the SPA reads
|
||||||
|
/// out of `/api/health` to configure its Argon2.
|
||||||
|
///
|
||||||
|
/// **The KSF params are client-side.** RFC 9807 runs Argon2 on the client
|
||||||
|
/// before the OPRF exchange; the server never invokes it. The params live
|
||||||
|
/// here so the operator has one source of truth and the SPA can fetch
|
||||||
|
/// them at page load — changing them requires re-registration for
|
||||||
|
/// affected users.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OpaqueConfig {
|
||||||
|
/// Runtime mode gate. See
|
||||||
|
/// [`crate::infrastructure::services::opaque_service::OpaqueMode`]
|
||||||
|
/// for the state-machine and the phase-plan mapping.
|
||||||
|
///
|
||||||
|
/// Env: `OXICLOUD_OPAQUE_MODE` (`off` | `migrate` | `opaque_only`).
|
||||||
|
/// Default: `off`.
|
||||||
|
pub mode: crate::infrastructure::services::opaque_service::OpaqueMode,
|
||||||
|
/// Base64-encoded [`opaque_ke::ServerSetup`] blob. Generated once
|
||||||
|
/// per deployment and persisted verbatim — rotating this invalidates
|
||||||
|
/// every user's registration. Runbook: on first boot with
|
||||||
|
/// `OXICLOUD_OPAQUE_MODE != off`, if this is unset, print a fatal
|
||||||
|
/// message with a fresh setup for the operator to paste into their
|
||||||
|
/// env, then exit.
|
||||||
|
///
|
||||||
|
/// Env: `OXICLOUD_OPAQUE_SERVER_SETUP`. No default.
|
||||||
|
pub server_setup_b64: Option<String>,
|
||||||
|
/// Ciphersuite version stamped into `auth.users.opaque_ciphersuite_version`
|
||||||
|
/// on registration. Bumping this without changing the actual
|
||||||
|
/// ciphersuite type alias in the service module is meaningless;
|
||||||
|
/// bumping this WITH a type change invalidates all envelopes.
|
||||||
|
///
|
||||||
|
/// Env: not exposed. Compile-time constant, currently `1`.
|
||||||
|
pub ciphersuite_version: i16,
|
||||||
|
/// Client-side Argon2id memory cost in KiB. Published to the SPA so
|
||||||
|
/// the client can construct a matching `argon2::Argon2` before
|
||||||
|
/// running `ClientRegistration::start` / `ClientLogin::start`.
|
||||||
|
///
|
||||||
|
/// Env: `OXICLOUD_OPAQUE_KSF_MEMORY_KIB`. Default: `262144` (256 MiB).
|
||||||
|
pub ksf_memory_kib: u32,
|
||||||
|
/// Client-side Argon2id iteration count.
|
||||||
|
///
|
||||||
|
/// Env: `OXICLOUD_OPAQUE_KSF_ITERATIONS`. Default: `3`.
|
||||||
|
pub ksf_iterations: u32,
|
||||||
|
/// Client-side Argon2id parallelism (lanes).
|
||||||
|
///
|
||||||
|
/// Env: `OXICLOUD_OPAQUE_KSF_PARALLELISM`. Default: `4`.
|
||||||
|
pub ksf_parallelism: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OpaqueConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
mode: crate::infrastructure::services::opaque_service::OpaqueMode::Off,
|
||||||
|
server_setup_b64: None,
|
||||||
|
ciphersuite_version: 1,
|
||||||
|
ksf_memory_kib: 262_144,
|
||||||
|
ksf_iterations: 3,
|
||||||
|
ksf_parallelism: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpaqueConfig {
|
||||||
|
/// Load OPAQUE configuration from environment variables. Mirrors the
|
||||||
|
/// pattern used by [`OidcConfig::from_env`] — every field falls back
|
||||||
|
/// to the [`Default`] impl when unset, so the config is safe to
|
||||||
|
/// construct even in `Off` mode.
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
use std::env;
|
||||||
|
let mut cfg = Self::default();
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_OPAQUE_MODE") {
|
||||||
|
match crate::infrastructure::services::opaque_service::OpaqueMode::parse(&v) {
|
||||||
|
Some(m) => cfg.mode = m,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "oxicloud::config",
|
||||||
|
value = %v,
|
||||||
|
"OXICLOUD_OPAQUE_MODE has an unrecognised value — keeping default (off). \
|
||||||
|
Accepted: off | migrate | opaque_only"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_OPAQUE_SERVER_SETUP") {
|
||||||
|
cfg.server_setup_b64 = Some(v);
|
||||||
|
}
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_OPAQUE_KSF_MEMORY_KIB")
|
||||||
|
&& let Ok(n) = v.parse::<u32>()
|
||||||
|
{
|
||||||
|
cfg.ksf_memory_kib = n;
|
||||||
|
}
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_OPAQUE_KSF_ITERATIONS")
|
||||||
|
&& let Ok(n) = v.parse::<u32>()
|
||||||
|
{
|
||||||
|
cfg.ksf_iterations = n;
|
||||||
|
}
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_OPAQUE_KSF_PARALLELISM")
|
||||||
|
&& let Ok(n) = v.parse::<u32>()
|
||||||
|
{
|
||||||
|
cfg.ksf_parallelism = n;
|
||||||
|
}
|
||||||
|
cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime mode after cross-checking against the auth-method allowlist.
|
||||||
|
///
|
||||||
|
/// OPAQUE is fundamentally a **password** mechanism — its only reason
|
||||||
|
/// to exist is to replace `POST /api/auth/login`. An operator running
|
||||||
|
/// OIDC-only or magic-link-only (`OXICLOUD_AUTH_METHODS=oidc` or
|
||||||
|
/// `=magic_link`) has no password path for OPAQUE to shadow; any
|
||||||
|
/// non-`Off` mode would be a no-op that still nagged them for
|
||||||
|
/// `OXICLOUD_OPAQUE_SERVER_SETUP` at boot.
|
||||||
|
///
|
||||||
|
/// This helper resolves the misconfig quietly: if password isn't in
|
||||||
|
/// the allowlist AND OPAQUE mode is non-`Off`, we downgrade to `Off`
|
||||||
|
/// and emit an audit-channel INFO explaining why (so it shows up in
|
||||||
|
/// operator log tailing without being a startup warning that fails
|
||||||
|
/// health checks). Every OPAQUE-facing caller — the DI factory, the
|
||||||
|
/// endpoint router, the migration hook — MUST read this and never
|
||||||
|
/// touch `self.mode` directly.
|
||||||
|
pub fn effective_mode(
|
||||||
|
&self,
|
||||||
|
auth: &AuthConfig,
|
||||||
|
) -> crate::infrastructure::services::opaque_service::OpaqueMode {
|
||||||
|
use crate::infrastructure::services::opaque_service::OpaqueMode;
|
||||||
|
if self.mode == OpaqueMode::Off {
|
||||||
|
return OpaqueMode::Off;
|
||||||
|
}
|
||||||
|
if !auth.is_method_allowed(AuthMethod::Password) {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "opaque.mode_downgraded",
|
||||||
|
reason = "password_auth_disabled",
|
||||||
|
configured_mode = ?self.mode,
|
||||||
|
"OXICLOUD_OPAQUE_MODE is configured but password auth is disabled \
|
||||||
|
via OXICLOUD_AUTH_METHODS — treating OPAQUE as off. \
|
||||||
|
OPAQUE only replaces the password login path; enable password \
|
||||||
|
in OXICLOUD_AUTH_METHODS to make this setting take effect."
|
||||||
|
);
|
||||||
|
return OpaqueMode::Off;
|
||||||
|
}
|
||||||
|
self.mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl OidcConfig {
|
impl OidcConfig {
|
||||||
/// Load OIDC configuration from environment variables only
|
/// Load OIDC configuration from environment variables only
|
||||||
pub fn from_env() -> Self {
|
pub fn from_env() -> Self {
|
||||||
@@ -2338,6 +2488,10 @@ pub struct AppConfig {
|
|||||||
pub database: DatabaseConfig,
|
pub database: DatabaseConfig,
|
||||||
/// Authentication configuration
|
/// Authentication configuration
|
||||||
pub auth: AuthConfig,
|
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
|
/// Feature configuration
|
||||||
pub features: FeaturesConfig,
|
pub features: FeaturesConfig,
|
||||||
/// OIDC configuration
|
/// OIDC configuration
|
||||||
@@ -2406,6 +2560,7 @@ impl Default for AppConfig {
|
|||||||
storage_entries: Vec::new(),
|
storage_entries: Vec::new(),
|
||||||
database: DatabaseConfig::default(),
|
database: DatabaseConfig::default(),
|
||||||
auth: AuthConfig::default(),
|
auth: AuthConfig::default(),
|
||||||
|
opaque: OpaqueConfig::default(),
|
||||||
features: FeaturesConfig::default(),
|
features: FeaturesConfig::default(),
|
||||||
oidc: OidcConfig::default(),
|
oidc: OidcConfig::default(),
|
||||||
wopi: WopiConfig::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
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4167,4 +4327,66 @@ mod tests {
|
|||||||
assert_eq!(cli_fp, parser_fp);
|
assert_eq!(cli_fp, parser_fp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OPAQUE effective-mode cross-check ────────────────────────────────
|
||||||
|
//
|
||||||
|
// OPAQUE is fundamentally a password mechanism; enabling its mode when
|
||||||
|
// password auth is disabled would be a no-op that still nagged
|
||||||
|
// operators for `OXICLOUD_OPAQUE_SERVER_SETUP` at boot. The
|
||||||
|
// `effective_mode` helper resolves that quietly by downgrading to
|
||||||
|
// Off + emitting an audit log, and these tests pin the truth table.
|
||||||
|
|
||||||
|
fn auth_with_methods(methods: Vec<AuthMethod>) -> AuthConfig {
|
||||||
|
AuthConfig {
|
||||||
|
allowed_auth_methods: methods,
|
||||||
|
..AuthConfig::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_mode_stays_off_when_configured_off() {
|
||||||
|
use crate::infrastructure::services::opaque_service::OpaqueMode;
|
||||||
|
let opaque = OpaqueConfig::default(); // mode = Off
|
||||||
|
let auth = auth_with_methods(vec![AuthMethod::Password]);
|
||||||
|
assert_eq!(opaque.effective_mode(&auth), OpaqueMode::Off);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_mode_passes_through_when_password_allowed() {
|
||||||
|
use crate::infrastructure::services::opaque_service::OpaqueMode;
|
||||||
|
for mode in [OpaqueMode::Migrate, OpaqueMode::OpaqueOnly] {
|
||||||
|
let opaque = OpaqueConfig {
|
||||||
|
mode,
|
||||||
|
..OpaqueConfig::default()
|
||||||
|
};
|
||||||
|
// Empty allowlist means "all methods allowed" per the existing
|
||||||
|
// convention, so password is implicitly in.
|
||||||
|
let empty_auth = auth_with_methods(vec![]);
|
||||||
|
assert_eq!(opaque.effective_mode(&empty_auth), mode);
|
||||||
|
// Explicit allowlist including Password.
|
||||||
|
let with_password = auth_with_methods(vec![AuthMethod::Password]);
|
||||||
|
assert_eq!(opaque.effective_mode(&with_password), mode);
|
||||||
|
// Multi-method allowlist including Password.
|
||||||
|
let mixed = auth_with_methods(vec![AuthMethod::Password, AuthMethod::MagicLink]);
|
||||||
|
assert_eq!(opaque.effective_mode(&mixed), mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_mode_downgrades_to_off_when_password_disabled() {
|
||||||
|
use crate::infrastructure::services::opaque_service::OpaqueMode;
|
||||||
|
for mode in [OpaqueMode::Migrate, OpaqueMode::OpaqueOnly] {
|
||||||
|
let opaque = OpaqueConfig {
|
||||||
|
mode,
|
||||||
|
..OpaqueConfig::default()
|
||||||
|
};
|
||||||
|
// Magic-link-only deployment — no password path for OPAQUE
|
||||||
|
// to shadow, so effective mode must be Off regardless of the
|
||||||
|
// configured value. The audit log line is a side effect we
|
||||||
|
// don't try to assert on (tracing capture would be overkill
|
||||||
|
// for this straightforward truth table).
|
||||||
|
let magic_only = auth_with_methods(vec![AuthMethod::MagicLink]);
|
||||||
|
assert_eq!(opaque.effective_mode(&magic_only), OpaqueMode::Off);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1909,6 +1909,50 @@ impl AppServiceFactory {
|
|||||||
user_lifecycle_handle = Some(user_lifecycle);
|
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
|
// Shared App Password service — created once, used by both NC routes and native API
|
||||||
let shared_app_pw_svc: Option<Arc<AppPasswordService>> =
|
let shared_app_pw_svc: Option<Arc<AppPasswordService>> =
|
||||||
if self.config.nextcloud.enabled || self.config.features.enable_auth {
|
if self.config.nextcloud.enabled || self.config.features.enable_auth {
|
||||||
@@ -2003,6 +2047,7 @@ impl AppServiceFactory {
|
|||||||
maintenance_pool: Some(maintenance_pool),
|
maintenance_pool: Some(maintenance_pool),
|
||||||
mount_router,
|
mount_router,
|
||||||
auth_service: auth_services,
|
auth_service: auth_services,
|
||||||
|
opaque_service,
|
||||||
nextcloud: nextcloud_services,
|
nextcloud: nextcloud_services,
|
||||||
admin_settings_service: None,
|
admin_settings_service: None,
|
||||||
storage_settings_service: None,
|
storage_settings_service: None,
|
||||||
@@ -2749,6 +2794,17 @@ pub struct AppState {
|
|||||||
pub mount_router:
|
pub mount_router:
|
||||||
Arc<crate::application::services::external_mount_router::MountRouter>,
|
Arc<crate::application::services::external_mount_router::MountRouter>,
|
||||||
pub auth_service: Option<AuthServices>,
|
pub auth_service: Option<AuthServices>,
|
||||||
|
/// 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<crate::infrastructure::services::opaque_service::OpaqueService>,
|
||||||
|
>,
|
||||||
pub nextcloud: Option<NextcloudServices>,
|
pub nextcloud: Option<NextcloudServices>,
|
||||||
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
pub admin_settings_service: Option<Arc<AdminSettingsService>>,
|
||||||
/// WASM plugin management (list/install/toggle/remove), backing the admin
|
/// WASM plugin management (list/install/toggle/remove), backing the admin
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ pub mod noop_face_analyzer;
|
|||||||
pub mod oidc_service;
|
pub mod oidc_service;
|
||||||
#[cfg(feature = "faces-onnx")]
|
#[cfg(feature = "faces-onnx")]
|
||||||
pub mod onnx_face_analyzer;
|
pub mod onnx_face_analyzer;
|
||||||
|
pub mod opaque_service;
|
||||||
pub mod password_hasher;
|
pub mod password_hasher;
|
||||||
pub mod path_resolver_service;
|
pub mod path_resolver_service;
|
||||||
pub mod path_service;
|
pub mod path_service;
|
||||||
|
|||||||
@@ -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<OxiCloudSuite>,
|
||||||
|
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<Self, DomainError> {
|
||||||
|
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<OxiCloudSuite> {
|
||||||
|
&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::<OxiCloudSuite>::new(&mut rng);
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(setup.serialize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_server_setup(b64: &str) -> Result<ServerSetup<OxiCloudSuite>, 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::<OxiCloudSuite>::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<Self> {
|
||||||
|
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::<OxiCloudSuite>::start(&mut client_rng, passphrase)
|
||||||
|
.expect("client registration start");
|
||||||
|
let server_reg_start = ServerRegistration::<OxiCloudSuite>::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::<OxiCloudSuite>::finish(client_reg_finish.message);
|
||||||
|
let password_file_bytes = password_file.serialize();
|
||||||
|
|
||||||
|
// ── Login (correct passphrase → session keys match) ─────────────
|
||||||
|
let client_login_start = ClientLogin::<OxiCloudSuite>::start(&mut client_rng, passphrase)
|
||||||
|
.expect("client login start");
|
||||||
|
let stored = ServerRegistration::<OxiCloudSuite>::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::<OxiCloudSuite>::start(&mut client_rng, b"wrong-passphrase")
|
||||||
|
.expect("client login start (wrong pass)");
|
||||||
|
let stored_again =
|
||||||
|
ServerRegistration::<OxiCloudSuite>::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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -146,6 +146,7 @@ log "Running Hurl tests..."
|
|||||||
hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test --jobs 1 \
|
hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test --jobs 1 \
|
||||||
"$API_DIR/setup.hurl" \
|
"$API_DIR/setup.hurl" \
|
||||||
"$API_DIR/auth_login.hurl" \
|
"$API_DIR/auth_login.hurl" \
|
||||||
|
"$API_DIR/opaque_substrate.hurl" \
|
||||||
"$API_DIR/user_ui_preferences.hurl" \
|
"$API_DIR/user_ui_preferences.hurl" \
|
||||||
"$API_DIR/auth_session_lifecycle.hurl" \
|
"$API_DIR/auth_session_lifecycle.hurl" \
|
||||||
"$API_DIR/auth_magic_link_login.hurl" \
|
"$API_DIR/auth_magic_link_login.hurl" \
|
||||||
|
|||||||
@@ -117,3 +117,31 @@ OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=50
|
|||||||
# permits IP spoofing for tests
|
# permits IP spoofing for tests
|
||||||
OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0
|
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user