Files
Oxicloud/docs/plan/dpop.md
T
2026-08-12 00:11:17 +02:00

35 KiB
Raw Blame History

DPoP (Demonstrating Proof-of-Possession) implementation plan

Status: CLOSED — Phase 1 complete (2026-08-11). All gates 1–10 + C shipped, including the post-Gate-10 nonce-cookie hand-off (Gate 5c, below). Deferred-to-Phase-2 items remain scoped for a future project when the underlying dependencies (IdP DPoP support, WebAuthn attestation UX, native NC client crypto) mature. This document stays as the reference for how each mechanism was designed and where it lives in the codebase — new work touching DPoP should cross-check against these gates before diverging.

Companion: docs/plan/opaque-only.md (OPAQUE is orthogonal but complementary — OPAQUE authenticates the user, DPoP binds the resulting session to a specific browser).

Motivation

OxiCloud's current session-binding stack:

  1. HttpOnly cookies (JS can't read the token)
  2. SameSite=Strict (cross-site can't send the cookie)
  3. Secure (HTTPS-only in production)
  4. CSRF double-submit (X-CSRF-Token header echoing a same-origin cookie)
  5. Session.user_agent recorded (for the sessions-list UI — not verified per request)

This stack defends well against XSS-exfiltration and cross-site forgery. It does not defend against local host compromise — an info-stealer that reads Chrome's Cookies SQLite DB via the OS keyring (DPAPI on Windows, Keychain on macOS) walks away with a working session that replays from anywhere. This is the dominant threat for a self-hosted cloud used from mixed-trust endpoints.

DPoP (RFC 9449) closes this gap by binding the session to a browser-held private key. Every request carries a JWT signed with that key. Stealing the cookie without the private key gets you nothing.

Design decisions locked upfront

Scope: DPoP-lite for OxiCloud's own session cookies (OPAQUE, OIDC, magic-link, admin-password). NOT full RFC 9449 resource-server mode or OIDC-bearer binding — those are Phase 2 (deferred, see end).

Crypto: ES256 (ECDSA P-256 + SHA-256). Universal SubtleCrypto support, RFC 9449 mandatory-to-support, ~90-byte public keys, ~50µs verify on modern CPUs.

Anti-replay + clock-independence: DPoP-Nonce (RFC 9449 §8). Server issues an opaque nonce in a response header; client MUST include it as a nonce claim in subsequent proofs. Server rotates the nonce every few minutes. Because the nonce is server-generated with a server-known issue time, the client clock never appears in the trust chain — no ±30s skew tolerance needed, no dead-mobile-clock failures.

Storage:

  • Browser: single IndexedDB entry per origin holding a CryptoKey created with extractable: false. JS can call sign() on the handle but never exportKey(). The raw bytes live in the browser's crypto subsystem, at rest encrypted by the browser's per-profile key store.
  • Server: auth.sessions gains a nullable dpop_jkt VARCHAR(64) column holding the JWK thumbprint (RFC 7638, base64url-encoded SHA-256 of the canonical JWK).

Threat targets:

Attack Result under DPoP
Info-stealer copies cookies to attacker's machine ✅ Attacker has cookie but no private key → 401 on first request
DB backup leaked / dumped ✅ Attacker gets thumbprints (public), useless for forgery
XSS reads document.cookie Already blocked by HttpOnly — DPoP neither helps nor hurts
Same-origin XSS calls the fetch interceptor ⚠️ Attacker's script signs its own requests through the interceptor. Mitigation is CSP hardening, not DPoP
Browser process compromised at login time ❌ Attacker enrolls their own keypair. Only WebAuthn-attested keys close this — out of scope
Malicious extension with webRequest permission ❌ Extension can wrap fetch. Same as above; browser-trust is a prerequisite

Feature flag: OXICLOUD_DPOP_MODE ∈ {off, opportunistic, required}

  • off (default in dev): middleware pass-through, client sends nothing.
  • opportunistic (staged rollout): if proof present → verify; if absent → allow. Server logs dpop.header_missing_but_session_bound when a bound session skips a proof, so operators can spot broken clients before flipping to required.
  • required (final): sessions with dpop_jkt IS NOT NULL MUST present a valid proof. Sessions with dpop_jkt IS NULL (app passwords, legacy pre-DPoP sessions) remain exempt.

Non-goals for Phase 1:

  • Native Nextcloud sync clients (mobile/desktop) — Basic Auth over app passwords, no SubtleCrypto. Exempt via dpop_jkt IS NULL.
  • OIDC bearer tokens minted by an upstream IdP — separate downstream problem, needs IdP cooperation.
  • Multi-device attested keys via WebAuthn — major UX shift.

Gate 0 — Design record

  • This document, plus a lightweight docs/adr/dpop-crypto-choice.md pinning ES256 + DPoP-Nonce + htu canonicalisation rules.
  • No code.
  • Deliverable: PR-reviewable design doc.

Gate 1 — Schema + DTO plumbing

  • Migration <timestamp>_dpop_session_binding.sql: ALTER TABLE auth.sessions ADD COLUMN dpop_jkt VARCHAR(64) (nullable).
  • Extend Session domain entity: dpop_jkt: Option<String>.
  • Extend SessionRepository::create_session signature + PG implementation (append column to INSERT, expose Option<&str> in the trait).
  • No middleware, no verification, no client changes.
  • Test: existing session tests unchanged (thumbprint stays None); a new test writes a fake thumbprint and reads it back.
  • Rollback: ALTER TABLE ... DROP COLUMN dpop_jkt — nothing depends on it yet.

Gate 2 — Client keypair lifecycle

  • New module frontend/src/lib/auth/dpop.ts:
    • ensureKeypair(): Promise<CryptoKeyPair> — read from IndexedDB (db: "oxicloud-dpop", store: "keypair"), else generate P-256 with extractable: false, persist, return.
    • computeJkt(pubKey: CryptoKey): Promise<string> — export public key JWK, canonicalise (RFC 7638), SHA-256, base64url — the thumbprint.
    • clearKeypair(): Promise<void> — deletes the IndexedDB entry; called from logout.
    • Concurrency: wrap the ensure path in navigator.locks.request("dpop-keypair", ...) so two tabs opened simultaneously don't race to generate two keypairs.
  • Zero server changes in this gate — keypair exists only in the browser.
  • Test: Vitest with fake-indexeddb, verify that a second ensureKeypair() call in the same session returns the SAME CryptoKey handle (identity), and that the JWK thumbprint is stable across page reloads.

Gate 3 — Bind ceremony on login

  • Fold the thumbprint into the login request body (single round trip, no separate bind endpoint):
    • OPAQUE POST /api/auth/opaque/login/ke3: DTO gains dpop_jkt: Option<String>.
    • OIDC callback GET/POST /api/auth/oidc/callback: harder — the browser redirects through the IdP, so the thumbprint has to survive the redirect. Two options:
      • (a) Include it in the state param (base64url-encoded JSON, signed).
      • (b) Post-callback: server issues a temporary "unbound" session; client immediately calls POST /api/auth/dpop/bind with the thumbprint and gets a bound cookie back.
      • Pick (b) — simpler, doesn't inflate the state param, keeps OIDC parity across IdPs. Adds one round trip to OIDC login only.
    • Magic-link exchange POST /api/auth/magic-link/redeem: DTO gains dpop_jkt: Option<String>.
    • Legacy password POST /api/auth/login: DTO gains dpop_jkt: Option<String>.
  • SPA changes: each login*() helper in frontend/src/lib/api/endpoints/auth.ts calls ensureKeypair() + computeJkt() before dispatch, threads the thumbprint into the body.
  • Server: validate JKT is well-formed (43 chars, base64url); persist as session.dpop_jkt. Field ABSENCE is not a rejection at this gate — only Gate 5 in required mode enforces presence.
  • Test: Hurl scenario per login path — POST with a fake JKT, then SELECT dpop_jkt FROM auth.sessions WHERE id = <token> returns exactly the sent value.
  • Rollback: server ignores the extra field, client stops sending it. No lingering state.

Gate 4 — Fetch interceptor emits DPoP header (nonce-aware)

  • Extend frontend/src/lib/api/client.ts::apiFetch:
    • Before each request, load the keypair via ensureKeypair().
    • Build the DPoP JWT:
      • Header: {typ: "dpop+jwt", alg: "ES256", jwk: <public-key-JWK>}
      • Claims: {htm: <method>, htu: <canonical URL, no query>, iat: <now-seconds>, jti: <crypto.randomUUID()>, nonce: <current-nonce-or-omitted>}
    • Sign with crypto.subtle.sign({name: "ECDSA", hash: "SHA-256"}, privateKey, payload).
    • Set DPoP: <compact-JWT> header.
  • Nonce handling:
    • Maintain a per-origin currentNonce: string | null in a module-level state (mirror in sessionStorage so cross-tab reads work).
    • Every response with a DPoP-Nonce header updates currentNonce.
    • On any 401 with WWW-Authenticate: DPoP error="use_dpop_nonce", extract the fresh nonce from DPoP-Nonce, update currentNonce, retry the ORIGINAL request ONCE with the new nonce. Reject the response if the retry also fails.
    • First request in a fresh browser has currentNonce = null → server issues a challenge → client retries with nonce → done. One extra round trip per session bootstrap.
  • No server-side verification yet in this gate — server logs when it sees the header for observability. Nonce issuance lives in Gate 5.
  • Test: Vitest with a real ES256 keypair — verify the emitted JWT parses, signature validates, htu/htm match, jti is unique per request, nonce is included when set.

Gate 5 — Server-side verifier + middleware (opportunistic mode)

  • New src/infrastructure/services/dpop_verifier.rs:
    • Parse the DPoP header as JWS compact serialization (3 base64url segments).
    • Extract the JWK from the JWS header. Reject if alg != "ES256", typ != "dpop+jwt", jwk.kty != "EC", jwk.crv != "P-256".
    • Verify the JWS signature using the embedded public key (p256::ecdsa::Signature::from_der(...).verify(...) — see Gate 5 dependency note).
    • Validate claims:
      • htm == request method
      • htu == canonical URL (scheme://authority/path, no query, external scheme/host from X-Forwarded-* if behind a proxy — mirror the same helper the request-span uses for client_ip)
      • iat — informational only when nonce present; when nonce absent (very first request), fall back to ±30s tolerance
      • nonce — validated by the nonce service (Gate 5b)
      • jti — replay-cache lookup (Gate 6), scoped to nonce
    • Compute JWK thumbprint (RFC 7638), compare to session.dpop_jkt. Mismatch → 401 with reason = "jkt_mismatch".
  • New middleware require_dpop_layer in src/interfaces/middleware/dpop.rs:
    • Reads Arc<AppState> for the verifier + nonce service + mode flag.
    • Behaviour by mode:
      • off → pass through.
      • opportunistic → if header present, verify (401 on failure); if absent, pass. If session.dpop_jkt IS NOT NULL but header absent → log dpop.header_missing_but_session_bound.
      • required → header MUST be present and verify, OR session.dpop_jkt IS NULL (exempt).
    • Response shape on failure: 401 with WWW-Authenticate: DPoP error="<invalid_dpop_proof|use_dpop_nonce>" and {error_type: "DpopVerificationFailed"}.
  • Mount on the same /api/* subtrees as require_no_password_change_pending_layer.
  • Exempt paths (allowlist, not on wildcard): /api/auth/login/*, /api/auth/opaque/register/*, /api/auth/oidc/callback, /api/auth/dpop/bind (Gate 3 endpoint), and public discovery endpoints.
  • Test: Rust unit tests for the verifier (happy path, wrong-alg, wrong-htm, wrong-htu, expired-iat when nonce absent, mismatched-jkt, malformed-JWS). Hurl scenarios for each mode.
  • Dependency check: confirm the jsonwebtoken crate supports ES256 with an embedded JWK in the header. If not, use p256 + base64 + serde_json and hand-parse the compact serialization (~50 LoC, more control, no dep surprise).

Gate 5b — DPoP-Nonce service

  • New src/infrastructure/services/dpop_nonce_service.rs:
    • Nonce format: 32 random bytes, base64url-encoded (~43 chars).
    • Store: moka Cache<String, NonceMeta> (in-memory, per-instance). No PG persistence — nonces are ephemeral by design; on server restart clients fetch a new one via the challenge flow.
    • Lifetime: 5 minutes rolling window (max_time_to_live = 5min). Store issued_at.
    • Reuse policy: nonces are REUSABLE within their lifetime — one round trip per session bootstrap, not one per request. Replay protection is per-jti within a nonce (Gate 6).
    • Rotation: on any response, if the current session's nonce is older than 2 minutes, issue a fresh nonce via DPoP-Nonce response header. Client's fetch interceptor picks it up automatically. This gives an overlap window (client's cached nonce is still valid for 3 more minutes while it starts using the fresh one) so requests in flight during rotation don't fail.
    • Challenge on missing/stale: if verify() finds nonce claim absent OR not in the store OR expired → return 401 + WWW-Authenticate: DPoP error="use_dpop_nonce" + DPoP-Nonce: <fresh-nonce> response header. Client's Gate 4 retry logic handles the round trip.
  • Cap the cache size (moka LRU max 100k entries by default) to bound memory under attack.
  • Test: Rust unit test — issue nonce, verify accepts within window, rejects after expiry; issuing a fresh nonce doesn't invalidate the previous one until its own TTL.
  • Why nonce eliminates client-clock dependence: the nonce is generated at a server-known moment and expires by server clock. A proof carrying that nonce is provably "recent" from the server's own perspective, regardless of what the client's clock says. iat becomes advisory (useful for logs, ignored for freshness) unless the client hasn't yet received a nonce (the very first request).

Problem — Gate 5b's challenge-and-retry flow means the FIRST bound request after every login eats a 401 use_dpop_nonce challenge before its retry lands. Under DPOP=required mode this is one useless round trip per session, per tab, on every login. Visible in the network tab as /api/folders/…/ancestors 401 → 200, and audible in the audit log as a dpop.nonce_challenge_issued on each bind ceremony.

The header approach doesn't work uniformly. The middleware stamps DPoP-Nonce on every authenticated response, but login endpoints aren't authenticated — they mint the session, they aren't behind the DPoP layer. So the login response has no nonce header. Worse, two of the four login paths (OIDC callback, magic-link redemption) are HTTP 302 redirects: even if the redirect response carried the header, the browser drops response headers on the follow. Only Set-Cookie survives a redirect.

Design — one-shot oxicloud_dpop_nonce cookie, stamped alongside the auth cookies on every login-success response:

  • Server helper: cookie_auth::maybe_append_dpop_nonce_cookie(&mut headers, &nonce_service, dpop_mode) — self-gated (no-op when dpop_mode = off), reads nonce_service.current_or_rotate(), sets the cookie with SameSite=Strict; Path=/; Max-Age=60; non-HttpOnly (client MUST read it).
  • Stamped on: legacy /api/auth/login, OPAQUE /opaque/login/ke3, OIDC /oidc/exchange, magic-link /magic/v1/{token} redemption, /api/auth/setup, /api/auth/refresh. Every path that mints or rotates a session.
  • Client seed: seedNonceFromCookie() in $lib/auth/dpop-proof — reads the cookie, calls updateNonceFromHeader(value), clears the cookie so a stale value can't confuse a later flow. Called at SPA boot (hooks.client.ts::init) for redirect-flow logins, AND at session.setUser() for POST-flow logins (SPA already booted).
  • Short TTL: 60 s. Well within the server-side nonce pool TTL (5 min); if the SPA doesn't consume it in a minute the client either lacks DPoP support (harmless — non-HttpOnly cookie just sits there) or is broken (a stale cookie doesn't hurt — the middleware challenge-retry still kicks in).

Result — first bound request after login lands with a valid nonce → no 401, no retry. When dpop_mode = off server-side, the helper is a no-op and no cookie is set. When the client lacks DPoP support, the seed call reads the cookie into an in-memory cache that's never consulted — dead bytes, no harm.

Why not a prime probe (HEAD /api/auth/dpop/nonce) — considered, rejected: (a) adds a network round trip after every login, worse than the current 401-retry it was replacing; (b) doesn't help redirect flows without a matching client-side handler post-mount anyway; (c) the cookie approach handles POST and redirect uniformly with zero extra requests.

Gate 6 — Replay cache (nonce-scoped)

  • Moka Cache<(String /* nonce */, String /* jti */), ()> with TTL = 5 minutes (matches max nonce lifetime).
  • Every verified proof inserts (nonce, jti). If the same key is inserted again → 401 with reason = "replay_detected" and audit line event = "dpop.replay_detected".
  • Test: send the same proof twice → second call fails; send two proofs with same jti but different nonces → both accepted (they belong to different scopes).

Gate 6b — HTTP-level DPoP crypto-handshake test binary

Why not Hurl. Hurl scripts are declarative — request template plus expected response. Every DPoP proof is unique per request (fresh jti, current iat, htm/htu matching the actual method+URL, ES256 signature from a persistent browser-held keypair, threaded nonce). Hurl has no scripting hook to compute a signed JWT per request. Same fundamental limitation that made us build the OPAQUE crypto-handshake test binary (task #19); same solution.

Approach. A new src/bin/dpop-hurl-helper.rs following the same pattern as src/bin/opaque-hurl-helper.rs (task #19). Same invocation shape — env-var driven from tests/api/run.sh, exit-code contract, no cleanup (server tears down DB between run.sh invocations), full crypto against a live server. The -hurl-helper naming is deliberate: this binary IS the DPoP counterpart of what Hurl covers for other protocols.

Structure:

  • Generate a P-256 keypair once at binary start (persistent across the run — simulates one browser session).
  • Compute JWK thumbprint (RFC 7638) for the public key.
  • Reqwest-based HTTP client wrapped in a small helper that, for every request:
    • Builds a fresh DPoP proof with the correct htm (request method), htu (canonical URL), iat (unix seconds now), jti (random UUID), and current nonce if one is cached.
    • Signs with the persistent P-256 key.
    • Attaches DPoP: <compact-JWT> header.
  • Auto-handle the use_dpop_nonce challenge: on 401 + WWW-Authenticate: DPoP error="use_dpop_nonce", extract fresh nonce from the DPoP-Nonce response header, retry the ORIGINAL request once with the new nonce. Mirrors the SPA fetch interceptor from Gate 4.
  • Reuse the OPAQUE handshake helper for login, so this binary exercises the OPAQUE+DPoP composed path end-to-end.

Scenarios:

  1. Happy path — OPAQUE login includes dpop_jkt, first API request lacks nonce → challenge → retry with nonce → 200. Follow-up requests reuse the nonce until rotation.
  2. Fail-open — login WITHOUT dpop_jkt, subsequent requests succeed with no DPoP header even in required mode (session exempt via dpop_jkt IS NULL).
  3. Bound session missing proof — session created with dpop_jkt, request sent WITHOUT DPoP header. Expect 401 with error_type: "DpopVerificationFailed" in required mode; 200 in opportunistic mode with a dpop.header_missing_but_session_bound audit line.
  4. Wrong htm — sign proof declaring htm: "POST" but send GET → 401 reason = "wrong_htm".
  5. Wrong htu — sign for /api/files/list but send to /api/auth/me → 401 reason = "wrong_htu".
  6. htu canonicalisation behind proxy — send X-Forwarded-Proto/X-Forwarded-Host headers matching the client-side htu; verifier must canonicalise identically. (Guards against Risk #1.)
  7. Stale iat on first request (no-nonce path) — sign with iat far in the past → 401 in the no-nonce branch. Establishes the bootstrap-only clock check works.
  8. Nonce rotation — issue a proof with nonce A, wait past the rotation window so server issues nonce B, present a fresh proof still bearing nonce A but within A's overlap window → still 200. Then wait past A's expiry → 401 challenge for B.
  9. Replay detection — send the same signed proof twice → second call 401 reason = "replay_detected". Confirms Gate 6.
  10. Thumbprint mismatch — generate a SECOND keypair mid-run, sign with it → 401 reason = "jkt_mismatch". Session's bound JKT is immutable.
  11. Refresh continuity — DPoP-signed POST /api/auth/refresh succeeds, new session inherits the same dpop_jkt, subsequent requests continue to verify with the same keypair. Confirms Gate 7.
  12. Logout wipe — POST /api/auth/logout succeeds; a fresh login on the same reqwest client (new keypair generated by the helper) gets a DIFFERENT dpop_jkt — no correlation across the logout boundary.
  13. Malformed proof — send an unsigned JWT, wrong-alg (RS256), wrong-typ (jwt instead of dpop+jwt), missing jwk in header → 401 with the expected reason value in each case.
  14. Bind-time downgrade attempt — attempt to POST login twice, once with dpop_jkt and once without, and confirm the resulting sessions honour their per-session bind status independently.

Wiring:

  • Invocation mirrors opaque-hurl-helper: tests/api/run.sh sets DPOP_HELPER_BASE_URL / DPOP_HELPER_USERNAME / DPOP_HELPER_PASSWORD env vars, then runs ./target/debug/dpop-hurl-helper. Exit 0 = all scenarios passed; exit 1 = diagnostic to stderr.
  • Reuses the same running-server test target already spun up for the OPAQUE helper — no extra server process. Test run sets OXICLOUD_DPOP_MODE=required so scenarios exercise the strict path; the fail-open scenario logs in without the JKT to confirm the exemption still works.
  • No Cargo.toml juggling — the binary is a [[bin]] entry alongside the other helpers; the workspace already builds all bins in cargo build.
  • CI's just api-test continues to run everything (Hurl scenarios + opaque-hurl-helper + dpop-hurl-helper).

Test-only deps: reuse p256 + base64 + serde_json + reqwest already introduced in Gate 5. No extra crates just for tests.

What this doesn't cover — SPA-side journeys (fetch interceptor, IndexedDB persistence, multi-tab, cross-tab logout via BroadcastChannel). Those get Playwright coverage under Gate 8. The Rust binary owns the wire-protocol contract; Playwright owns the browser-side integration.

Gate 7 — Refresh + logout flows

  • POST /api/auth/refresh: currently unauthenticated (rate-limited public path minting new access tokens from a refresh token cookie). Two changes:
    • Client sends DPoP header on the refresh call. Verifier looks up the CURRENT session (pre-refresh) to fetch the dpop_jkt for comparison.
    • After minting the new session, copy dpop_jkt over. The same browser continues to sign with the same key.
  • POST /api/auth/logout: server clears the session row (already does); client calls clearKeypair() to also wipe IndexedDB. Next login generates a fresh keypair (which is desirable — post-logout state is fully clean, no correlation between pre- and post-logout activity).
  • Session revocation from admin panel: no client-side coordination possible. Server clears the row, next client request 401s at the auth layer (session gone), client redirects to login, generates fresh keypair. Same effect.
  • Test: full login → several DPoP-signed requests → refresh (with DPoP) → several more requests → logout → new login uses different dpop_jkt.

Gate 8 — Multi-tab handoff

  • IndexedDB is per-origin, shared across tabs of the same profile → concurrent READ is fine.
  • Concurrent WRITE (both tabs racing to generate the initial keypair): handled at Gate 2 via navigator.locks.request("dpop-keypair", ...).
  • Cross-tab logout: BroadcastChannel("dpop-cleared").postMessage() on logout so other open tabs invalidate their in-memory keypair reference and re-ensureKeypair() on next request.
  • Nonce cache: sessionStorage per-tab is fine — each tab does its own initial challenge on cold start. No coordination needed.
  • Test: Playwright scenario — open two tabs, log in on one, both make requests, both succeed with the same dpop_jkt on the server side.

Gate 9 — Enforcement rollout

  • Ship OXICLOUD_DPOP_MODE=opportunistic as default in the release that lands Gates 1-8.
  • Operators monitor:
    • dpop.header_missing_but_session_bound count — should trend to zero as clients update.
    • dpop.verify_failed{reason} breakdown — spikes indicate client bugs, not attacks (attacks would be at trickle rate).
  • After 2-4 weeks of clean opportunistic-mode telemetry, flip default to required in a later release. Pre-existing app-password / legacy sessions with dpop_jkt IS NULL still work; they're exempt at the middleware.
  • Documentation deliverable: operator guide entry explaining the flag, the modes, the observability signals, the upgrade path.

Gate C — Content-serve + streaming allowlist (SUPERSEDED by Service Worker)

Status: retired. A Service Worker at frontend/src/service-worker.ts now intercepts every same-origin /api/* request the browser initiates and attaches a DPoP proof — including <img src>, <a href download>, and EventSource streams that JS-space fetch can never touch. The middleware allowlist that this section documented has been deleted (matches_content_path, is_content_serve_get, looks_like_uuid, the enforcement branch, plus 9 tests). No exempt paths remain; the DPoP posture is uniform across the whole /api/* surface.

Left in place for the historical rationale:

Discovered during the required-mode rollout: some SPA endpoints are fetched by the browser itself, not by JS via fetch(). These paths have no JS in the loop to sign a DPoP proof:

  • <img src> — thumbnails, photo previews.
  • <a href download> — file downloads, folder ZIP downloads.
  • <a href> — file inline previews.
  • EventSource — streaming endpoints (RFC 9449 known gap: EventSource cannot set custom request headers, only cookies).

Without a carve-out, flipping to DPOP=required breaks all of these on bound sessions. Ships a middleware allowlist keyed on (method, path) that exempts these specific shapes from the missing-proof reject:

  • GET /api/files/{uuid} — download / inline
  • GET /api/files/{uuid}/thumbnail/{size} — thumbnails
  • GET /api/folders/{uuid}/download — zip
  • GET /api/photos/{uuid}/preview — preview (best-effort; adds no risk if the endpoint doesn't exist)
  • GET /api/admin/plugins/{id}/logs/stream — plugin log tail SSE

The allowlist exempts ONLY the missing-proof reject. Proofs that ARE sent on these paths (e.g. an SPA image preloader that went through apiFetch and blob'd) still get fully verified.

Security posture (accepted trade-off). An attacker with a stolen cookie can GET one of these URLs if and only if they already know a specific 128-bit UUID. Every listing / discovery endpoint (/api/folders/{id}/children, /api/photos, /api/files/by-hash, /search, …) still requires a DPoP proof — those go through apiFetch. So a bare stolen cookie gives the attacker "download the exact IDs you already know" — effectively nothing without prior knowledge. Plugin log SSE additionally has admin-only AuthZ at the handler.

Refactor considered, superseded. A split-router variant (exempt routes on one sub-router without require_dpop_layer) and a signed short-lived URL token variant (Option B) were both scoped in earlier revisions. Both are moot now that the Service Worker intercepts uniformly. Option B remains in the Deferred section as a fallback if SW registration ever needs to be optional.

Gate 10 — Observability + admin UX

  • Audit events (all with target: "audit"):
    • dpop.bound_at_login — info, records session_id, dpop_jkt prefix, auth method.
    • dpop.header_missing_but_session_bound — info, opportunistic-mode warning.
    • dpop.verify_failed with reason ∈ {invalid_sig, wrong_htm, wrong_htu, expired_iat, jkt_mismatch, replay_detected, nonce_missing, nonce_stale, nonce_unknown, malformed_jws}.
    • dpop.nonce_challenge_issued — debug-level, high volume; behind target: "oxicloud::dpop" not audit.
  • Admin session-list UI: show a lock icon on sessions where dpop_jkt IS NOT NULL. Complements the auth-badges work (task #26).
  • Metrics: Prometheus counters for dpop_verify_failed_total{reason}, dpop_nonce_challenges_total. Alerts on verify_failed spikes.

Deferred to Phase 2

  • RFC 9449 full compliance — resource-server mode, ath claim binding for OIDC access tokens.
  • OIDC-bearer DPoP — configure the upstream IdP to mint DPoP-bound tokens; resource-side validation. Depends on IdP support (Keycloak ≥ 20, Auth0, Okta, Zitadel all have it).
  • Native Nextcloud client support — no SubtleCrypto; would need embedded ECDSA + secure keystore (Android Keystore / iOS Keychain / OS keyring). Substantially larger project; the current Basic-Auth-over-app-password path stays unchanged.
  • Attested keys via WebAuthn — bind to TPM / Secure Enclave. Blocks the login-time-compromised-browser attack. Major UX shift (per-request user gesture unless resident-key + silent-assertion flows mature).
  • Detect DPoP capability on upstream IdP — parse .well-known/openid-configuration, warn at boot when dpop_signing_alg_values_supported is absent. Half-day of work, orthogonal to this plan, worth its own tiny PR.
  • Signed short-lived URL tokens for content-serve paths (Option B, replaces Gate C). The SPA (through apiFetch, so DPoP-verified) mints a per-user, per-URL token like ?dl_token=<sig> for each browser-direct URL; the server accepts EITHER a valid DPoP proof OR a valid short-lived token (~5 min TTL, HMAC over (user_id, path, expiry)). Attacker with a stolen cookie loses the ability to GET any content path because tokens are user-scoped and expire fast — removes the "known UUID = downloadable" trade-off Gate C accepts today. Retires the Gate C allowlist entirely (plus its router-split follow-up). Effort: ~2-3 person-days (token mint endpoint + verifier middleware + SPA URL rewriter for <img src>/<a href>/EventSource URLs).

Effort estimate

Gate Rough effort
0 — Design record 0.5 day
1 — Schema 0.5 day
2 — Client keypair 1 day
3 — Bind on login 1.5 days (touches 4 login paths, OIDC needs the bind endpoint)
4 — Fetch interceptor (nonce-aware) 1 day
5 — Server verifier + middleware 2 days
5b — DPoP-Nonce service 1 day
5c — Nonce-cookie hand-off on login 0.5 day (added post-Gate-10 as a first-post-login-request-401 fix)
6 — Replay cache 0.5 day
6b — DPoP hurl-helper binary 1.5 days
7 — Refresh + logout 1 day
8 — Multi-tab 0.5 day
9 — Rollout 0 (calendar time, no engineering work)
C — Content-serve + streaming allowlist 0.5 day (shipped alongside Gate 9)
10 — Observability + admin UX 1 day
Total (Phase 1) ~12 person-days

Risks worth naming

  1. htu claim + reverse proxies. Client sees https://oxicloud.example; server behind nginx / Cloudflare sees http://internal:8086. Verifier MUST canonicalise both sides identically — scheme://authority/path with scheme and authority pulled from X-Forwarded-Proto / X-Forwarded-Host / Forwarded. Reuse the exact same helper the audit-log request span uses for client_ip; if it doesn't exist yet, extract one first. Getting this wrong = every request fails with wrong_htu in production but passes in dev.

  2. JWK thumbprint canonicalisation subtlety. RFC 7638 requires a specific JSON member order ({"crv":..., "kty":..., "x":..., "y":...} alphabetical) and NO whitespace. Small deviations from library defaults produce different SHA-256s and thus mismatched thumbprints. Write a unit test with the RFC 7638 §3.1 example vector to lock the canonicalisation on both client and server before shipping.

  3. JWK header inflation on every request. Each proof carries a ~300-byte JWT (mostly the JWK header). At high RPS this is measurable network overhead. Not a blocker at OxiCloud's expected load; note it for the perf budget review.

  4. Nonce cache DoS. An attacker generating requests without valid sessions can force the server to issue nonces indefinitely, growing the moka cache. Mitigations: (a) cache is size-capped (LRU eviction), (b) rate-limit /api/auth/dpop/bind and other unauthenticated paths that could trigger issuance. Neither is DPoP-specific — existing rate limits apply.

  5. Chromium bug landscape for non-extractable IndexedDB CryptoKeys. There have been historical issues where a browser update invalidates the stored CryptoKey structure (schema migration in the crypto subsystem). Mitigation: on sign() failure, drop the stored keypair, generate fresh, force re-bind on next login. This is a one-time inconvenience per browser upgrade, not a security issue.

  6. jsonwebtoken crate ES256 + embedded JWK support. Confirm before Gate 5 that the crate handles JWS with a JWK in the header (not a kid reference). If not, hand-parse with p256 + base64 + serde_json — ~50 LoC, no dep surprise.

  7. Plan assumes cookies stay the primary session carrier. If a future refactor moves to Authorization: Bearer <token> headers, the DPoP shape shifts slightly (the ath claim becomes relevant to bind the DPoP proof to the specific access token). Not a blocker for Phase 1 — cookies-only.

  8. Interaction with force_password_change_at_next_login gate. Order of middleware layers matters: auth → DPoP verify → password-change gate. A user in reset-pending state must still pass DPoP verification (their session is bound); the reset-flow allowlist endpoints must also be DPoP-verified. Add explicit tests for this interaction.


Success criteria (Phase 1 complete)

All met as of 2026-08-11:

  • ✅ All four login paths bind a keypair thumbprint to the new session.
  • ✅ Every /api/* request from an SPA session carries a valid DPoP proof (verified in required mode).
  • ✅ App-password sessions (dpop_jkt IS NULL) continue to work — no regression for Nextcloud sync clients.
  • ✅ Copying the session cookie to curl on another machine reproducibly fails with 401.
  • ✅ Audit stream contains actionable telemetry for verify failures, replay attempts, nonce challenges — plus dpop.bound_at_login success events (Gate 10 refinement).
  • ✅ Documentation in docs/config/authentication.md explains the modes, the flag, and the rollout guidance for operators.
  • ✅ First bound request after login lands nonce-primed (Gate 5c cookie hand-off) — no use_dpop_nonce 401 → retry cycle on every login.