diff --git a/CHANGELOG.md b/CHANGELOG.md index 6430f0c..9149935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] — v1.1 ### Added +- **Passkey authentication (WebAuthn)** — Registered passkeys can replace the + TOTP code for everyday logins. Registration itself still requires a valid + code, so a passkey can never be created without already holding the secret. + - New dependency: `web-auth/webauthn-lib` `^5.3` (resolved to 5.3.9); + `composer audit` reports no advisories. + - **Requires central auth** (`SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`) so + there is one relying party for the whole domain, and **requires HTTPS in + every environment, development included**. Enabling it in a configuration + that cannot work fails at container start rather than in a browser. + - Registration is a checkbox on the login form; login is a button. Passive + keys and OS pickers work normally, with the code as a fallback. + - New `PasskeyListener` (priority 70) — after the rate-limit gate so a + blocked IP never reaches a ceremony, and before `LoginListener` so a + ceremony request is not misfiled as a failed login. + - New env vars: `PASSKEY_ENABLED`, `PASSKEY_RP_NAME`, + `PASSKEY_USER_VERIFICATION`, `PASSKEY_TIMEOUT`, `PASSKEY_BUTTON_NAME`, + `PASSKEY_REGISTER_NAME`, `PASSKEY_BEGIN_BURST_COUNT`, + `PASSKEY_BEGIN_BURST_TIME`. + - New `docs/examples/Caddyfile` section describing local development over + real TLS, because there is deliberately no `http://` exemption. - **Public rate-limited access** — Select paths can now be made publicly accessible without TOTP authentication, with separate per-IP rate limiting. This is useful for exposing public content (e.g., public Gitea repositories) @@ -25,7 +45,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New `PublicPathMatcher` service for path pattern matching. - New `PublicAccessListener` (priority 84) in the request pipeline. +### Security +- **Passkey ceremonies** — The challenge is issued and stored server-side and + the client's copy is never trusted; it is single-use, deleted before + verification so a failed or replayed attempt cannot be retried against the + same challenge. Only the derived `https://{AUTH_SUBDOMAIN}` origin is ever + accepted, and an unknown credential produces the same response as a wrong + code so the endpoint cannot be used for enumeration. + ### Changed +- **Session issuing extracted into `SessionIssuer`** — `LoginManager` + previously built the session cookie itself. Both login paths now share one + implementation, so a passkey login and a code login set an identical cookie; + two copies would have drifted, most likely in cookie attributes, where the + difference is invisible until it breaks in a browser. - **Upgraded Symfony 7.4 → 8.1** — All `symfony/*` components bumped to `8.1.*` (resolved to 8.1.2–8.1.6). The 7.4 deprecation sweep was clean (test suite runs with `failOnDeprecation`), so the major-version jump diff --git a/DESIGN_CONSIDERATIONS.md b/DESIGN_CONSIDERATIONS.md index 32203db..afc9ebc 100644 --- a/DESIGN_CONSIDERATIONS.md +++ b/DESIGN_CONSIDERATIONS.md @@ -8,6 +8,60 @@ This document was originally prepared as a design review. Items that have been a --- +## 0. Passkey implementation notes + +Four decisions are worth recording because the reasoning is not obvious from the +code, and each looks like an odd choice without it. + +### 0.1 The signature counter is checked leniently, against the library's default + +`webauthn-lib`'s default checker requires a *strictly increasing* counter. That +is wrong for the passkeys this feature targets: a synchronised passkey reports a +constant `0` forever, so the default rejects a brand-new credential on its +**first** login. Measured against the installed version: stored `0`, reported +`0` → `CounterException`. + +The failure mode is what makes this worth a note. It cannot happen in a unit +test that increments the counter — only on real hardware, and only for the most +common kind of passkey. `PasskeyCounterChecker` therefore accepts equal-or-greater +and rejects only a counter that moves *backwards*. Clone detection is explicitly +not claimed as a property of this feature. + +### 0.2 The ceremony replies are the only browser-facing 2xx + +`SecurityHeadersListener` applies `no-store` to non-2xx responses only, on the +assumption that a 2xx is consumed by the proxy's `forward_auth` check. That +assumption is false for a ceremony reply: the auth subdomain is `reverse_proxy`-ed +with no `forward_auth` in front of it, so the JSON goes straight to the browser. +Left alone it would be cacheable, and a browser could replay a stale challenge. + +The producer marks the response (`PasskeyListener` or `LoginManager`) and the +caching policy lives in one place that consumes the marker, rather than being +duplicated at each site that happens to return 2xx. + +### 0.3 Registration is a checkbox on the login form, not an endpoint + +A separate `register-begin` endpoint was the first design and would have been a +vulnerability: it hands out a challenge without proving anything. The TOTP check +is what authorises registration, and that check happens inside `LoginManager` as +part of an ordinary login submission — so `LoginManager` is where the ceremony +starts. + +There is no session cookie to check at that point either, which makes the point +neatly: the whole flow is what *produces* the session, so anything gated on one +cannot be part of it. The capability at `register-finish` is the single-use +ceremony id, issued server-side and bound to the identity that passed the check. + +### 0.4 Both login paths share one session-issuing implementation + +`SessionIssuer` was extracted from `LoginManager` when the passkey ceremony +needed the same behaviour. Two implementations would have drifted, and the most +likely place to drift is cookie attributes — where a difference is invisible +until it breaks in a browser, on one path only. A functional test compares the +cookies the two paths produce, field by field. + +--- + ## 1. Security ### 1.1 Missing Security Response Headers [HIGH PRIORITY] ✅ Addressed diff --git a/ROADMAP.md b/ROADMAP.md index 0a49503..bc57cf4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -295,6 +295,9 @@ the management surface is incomplete. ### Phase 2c — Passkey Authentication +**Status: complete.** See `docs/passkey-auth-subdomain-plan.md` for the full +plan, the evidence behind each decision, and the deviations noted below. + **Goal:** Add WebAuthn/FIDO2 passkey support as an alternative authentication method alongside TOTP and backup codes. @@ -305,39 +308,31 @@ codes. For a pre-auth gate that friends and family use, passkeys would be a major UX improvement — especially for non-technical users who struggle with TOTP apps. -**Design considerations:** +**What was built**, and how it differs from the sketch above: -- Passkeys are **per-device**, not shared secrets. Unlike TOTP (one - secret shared with all devices), each device registers its own - passkey. This is actually better for a family-use gate — you can - register mom's phone separately from dad's laptop. +- **A Symfony bundle was not used**, only `web-auth/webauthn-lib`. The bundle + brings a database-backed credential repository and a controller setup that do + not fit a no-database, listener-only application; the library alone is a + clean fit and its types are confined to `PasskeyManager` and + `PasskeyCeremonyFactory` so a major-version rename touches two files. +- **Registration happens in the browser, not a console command.** The checkbox + on the login form is authorised by the TOTP code in the same submission, so + it needs no separate token and no CLI. This also settles the "how does the + identity get specified" question: it is the identity that just authenticated. +- **Central auth is a hard prerequisite.** A passkey is scoped to one relying + party, so passkeys require `SUBDOMAIN_REDIRECT` + `AUTH_SUBDOMAIN`; the RP ID + is always that base domain. Without it the feature stays off, rather than + quietly scoping credentials to a single host. +- **HTTPS is required with no exemption**, development included, since an + `http://` escape hatch is how the same weakness reaches production. +- **Failed attempts share the TOTP rate-limit budget**, so passkeys cannot be + used to sidestep a lockout. +- **Attestation is `none`**, measured rather than assumed — see SECURITY.md and + plan §2.3. -- WebAuthn requires a **challenge-response flow**: - 1. Client requests a challenge (preauth generates and stores a - challenge nonce, similar to the existing nonce system) - 2. Browser prompts for biometric/PIN, creates a signed assertion - 3. Server verifies the assertion against the registered credential - -- This is a **two-step flow** unlike TOTP's single-step, which means - the login page JS and `LoginListener` need to handle an additional - round-trip. The existing nonce + AJAX pattern in `_script.html.twig` - is a good foundation — extend it with a "use passkey" button that - initiates the `navigator.credentials.get()` flow. - -- Library: `web-auth/webauthn-framework` (PHP WebAuthn library, - Symfony bundle available). Would add registration ceremony (console - command or initial-setup flow to register a passkey). - -- [ ] Research `web-auth/webauthn-framework` integration with Symfony - 8.1 and FrankenPHP -- [ ] Design passkey registration flow (console command? first-visit - setup? separate registration endpoint?) -- [ ] Implement challenge generation and storage (extend existing - nonce/cache infrastructure) -- [ ] Implement assertion verification in a new `PasskeyManager` - service (implements a shared `AuthMethodInterface`?) -- [ ] Add passkey option to login page JS (`navigator.credentials.get()`) -- [ ] Handle multiple registered passkeys (per-device) +**Remaining work:** none for the feature itself. Discoverable-credential +(usernameless) login is possible but not needed, since the login page already +lists registered credentials. - [ ] Console command: `app:list-passkeys` — show registered devices - [ ] Console command: `app:remove-passkey` — revoke a passkey - [ ] Config: `PASSKEY_ENABLED=false` — enable/disable passkey auth diff --git a/SECURITY.md b/SECURITY.md index 0c26c81..31fb0c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -51,6 +51,59 @@ calls it per request to decide whether a request may reach the upstream service. secrets belong in `.env.local` or `bin/console secrets:set`, read via `%env(...)%`. `.env.example` and `.env.test` are the committed env files. +### Passkeys (WebAuthn) + +Off by default (`PASSKEY_ENABLED=0`). When on, the following hold: + +- **Registration requires a valid TOTP code.** The checkbox rides on an + ordinary login submission, and the code check in that same request is what + authorises the ceremony. There is no enrolment token, no CLI path, and no way + to create a credential without already holding the secret. The identity comes + from the authenticated session, never from the request body. +- **The challenge is server-authoritative and single-use.** It is generated and + stored server-side; the client's copy is never trusted. The stored record is + deleted *before* verification runs, so a failed or replayed attempt cannot be + retried against the same challenge. Records live in the in-memory `nonceCache` + with a 300-second TTL and deliberately do not survive a restart. +- **Only the derived origin is accepted.** The allowed origin is always + `https://{AUTH_SUBDOMAIN}`, computed from configuration and never from the + request. `http://` is therefore rejected regardless of how the request + arrived, and there is no setting that re-enables it. The library's deprecated + `setSecuredRelyingPartyId()` escape hatch is not used, and development uses + real TLS instead of an exemption. +- **The RP ID is the base domain**, so a credential is scoped to every service + on that domain. This is the intended behaviour and the reason central auth is + a hard prerequisite: without a single shared domain there is no sane RP ID. +- **Failures are indistinguishable.** An unknown credential, a bad signature + and a wrong origin all produce the same response as a wrong TOTP code, so the + endpoint cannot be used to enumerate credentials. +- **Failures share the login rate-limit budget.** A failed ceremony costs the + same token as a wrong code, and once the limit is reached every method is + blocked. Passkeys cannot be used to sidestep a lockout, and the resource guard + that bounds ceremony *starts* is deliberately separate, so a legitimate login + never spends failure budget. +- **The signature counter is not a security control.** Most passkeys — + anything synchronised through a keychain — report a constant counter, so a + counter-based clone check would lock users out of their own credentials. + preauth accepts an unchanged counter and rejects only one that moves + *backwards*, which is the only signal the value can carry. **Clone detection is + deliberately not a property this feature claims.** +- **Attestation is deliberately not requested** (`attestation: 'none'`). + Attestation conveyance is only a preference a client may ignore, and the FIDO + metadata service is bypassed both by the zero AAGUID that privacy-preserving + passkeys already send and by self attestation — while still refusing + legitimate authenticators newer than its cached blob. This was measured rather + than assumed; see §2.3 of + `docs/passkey-auth-subdomain-plan.md` for the evidence. **Revisit if** a + deployment needs to prove which make and model of authenticator is enrolled, + or if a policy (rather than a preference) requires attested keys — in which + case the metadata service must be pinned and kept current, and the zero-AAGUID + case decided explicitly rather than by omission. +- **The ceremony replies are not cacheable.** They are the only 2xx this + application returns straight to a browser (every other 2xx is consumed by the + proxy's `forward_auth` check), so they carry the same anti-caching headers as + the rest of the login flow. + ## Scope In scope: the application code in `src/`, the shipped `Caddyfile`, the diff --git a/docs/passkey-auth-subdomain-plan.md b/docs/passkey-auth-subdomain-plan.md index a55ded8..4cc2e92 100644 --- a/docs/passkey-auth-subdomain-plan.md +++ b/docs/passkey-auth-subdomain-plan.md @@ -652,6 +652,10 @@ the `doctrine/deprecations` triggers). ## 8. Implementation order +**Status: complete.** All steps below are implemented and on +`feat/passkey-auth-subdomain`. Two deviations from the order as written, both +noted inline. + Each step is independently committable and leaves the suite green. 1. **Dependency** *(done on the spike branch)* — `composer require @@ -667,11 +671,29 @@ Each step is independently committable and leaves the suite green. 5. **`PasskeyListener`** — priority 70, header dispatch, always terminate, no-store marker. Unit-test every branch incl. "post-shaped request must not reach `LoginListener`". + > **Deviation 1 — done after step 6.** The listener needs the shared session + > issuing that step 6 extracts, so the order had to be inverted. + > + > **Deviation 2 — three operations, not four.** `register-begin` is not a + > listener operation, and the first implementation was wrong to make it one. + > It would have handed out a challenge without proving anything; there is + > also no session cookie to check at that point, since the whole flow is what + > produces the session. The ceremony is started by `LoginManager`, after it + > verifies the code and nonce. A test pins that the listener refuses the + > operation. 6. **Extract session issuing** from `LoginManager` so both paths share it — prove equality against the existing `LoginManagerTest`/`AuthenticationFlowTest` before touching anything else (Q3.8). + > Verified as intended: all 18 existing `LoginManagerTest` cases passed + > unchanged, and a functional test now compares the two paths' cookies + > field by field. 7. **Registration UI** — checkbox in `login.html.twig`, the `Payload`-intent hand-off described in §3.1, `_passkey_register.html.twig`. + > Note: the separate script template was not needed — both handlers share + > helpers, so `_passkey.html.twig` holds them and `login.html.twig` stays a + > single readable file. The checkbox is also **not** rendered where the form + > does not POST, since registration authorises itself with the code carried + > in that submission. 8. **Login UI + CSP** — `_passkey.html.twig`, `SecurityHeadersListener`, extend `CacheControlFlowTest` and `SecurityHeadersListenerTest`. 9. **Functional tests** with real crypto (§7.2). diff --git a/readme.md b/readme.md index cb12330..53790ed 100644 --- a/readme.md +++ b/readme.md @@ -152,6 +152,56 @@ Rate limiting **cannot be disabled**. It uses a compound sliding window: | `UPPER_COUNT` | `10` | Max attempts per upper window. | | `UPPER_TIME` | `3600` | Upper window in seconds (1 hour). | +### Passkey Authentication + +Passkeys (WebAuthn) can replace the TOTP code for everyday logins, while the +code remains the way a new device is enrolled. + +**Two prerequisites, both enforced.** The feature refuses to operate without +them rather than degrading quietly: + +1. **Central auth must be configured** (`SUBDOMAIN_REDIRECT=true` and a real + `AUTH_SUBDOMAIN`). A passkey is scoped to one relying party, so there has to + be a single shared domain for the whole family of services. Without it, + passkeys are switched off — they would otherwise be scoped to a single host + and confuse users with multiple, unrelated passkeys. +2. **HTTPS is required, in development too.** There is no `http://localhost` + exemption and no setting that re-enables one, because such an exemption is + exactly how the same weakness ends up enabled in production. To exercise + passkeys locally, see the TLS note in `docs/examples/Caddyfile`. + +`localhost` therefore cannot be used for passkeys: it has no base domain, so +central auth cannot be configured at all. + +| Variable | Default | Description | +|----------|---------|-------------| +| `PASSKEY_ENABLED` | `0` | Master switch. Enabling it without the prerequisites above makes the container fail at startup, rather than offering a feature that cannot work. | +| `PASSKEY_RP_NAME` | `TITLE` | Name shown in the authenticator prompt. | +| `PASSKEY_USER_VERIFICATION` | `required` | `required`, `preferred`, or `discouraged`. An unrecognised value falls back to `required`, never to something weaker. | +| `PASSKEY_TIMEOUT` | `60000` | Ceremony timeout in milliseconds. | +| `PASSKEY_BUTTON_NAME` | `Sign in with a passkey` | Label for the sign-in button. | +| `PASSKEY_REGISTER_NAME` | `Register this device as a passkey` | Label for the registration checkbox. | +| `PASSKEY_BEGIN_BURST_COUNT` | `30` | Ceremonies one caller may start per window. A resource guard, not part of the login budget. | +| `PASSKEY_BEGIN_BURST_TIME` | `60` | Window for the above, in seconds. | + +**Registering a device.** Log in with your code as usual, tick *Register this +device as a passkey*, and approve the prompt. The TOTP check in that same +submission is what authorises the registration — there is no separate enrolment +token and no CLI command, so a passkey cannot be created without already holding +a valid code. + +**Logging in.** Once registered, *Sign in with a passkey* signs you in with a +fingerprint, face, or device PIN instead of typing a code. Failed passkey +attempts count against the same rate-limit budget as wrong codes, so passkeys +cannot be used to sidestep a lockout, and after the limit is reached *every* +method is blocked equally. + +> **On signature counters.** Many passkeys (anything synchronised through a +> keychain) report a constant counter, so a counter-based clone check would lock +> users out of their own credentials. Preauth accepts an unchanged counter and +> rejects only one that moves *backwards*. Clone detection is deliberately not a +> property this feature claims — see `SECURITY.md`. + ### Public Rate-Limited Access Preauth can provide rate-limited unauthenticated access to select public @@ -235,9 +285,17 @@ passes through a priority-ordered chain of listeners: 3. **PublicAccessListener** (priority 84) — If public paths are configured, allows rate-limited unauthenticated access to matching paths. 4. **RejectListener** (priority 77) — Rate-limiting gate. -5. **LoginListener** (priority 66) — Processes login attempts. -6. **InterceptListener** (priority 55) — Renders login page or redirects. -7. **SecurityHeadersListener** (response) — Adds security headers. +5. **PasskeyListener** (priority 70) — WebAuthn ceremonies, when enabled. +6. **LoginListener** (priority 66) — Processes login attempts. +7. **InterceptListener** (priority 55) — Renders login page or redirects. +8. **SecurityHeadersListener** (response) — Adds security headers. + +`PasskeyListener` sits deliberately between the rate-limit gate and the login +handler: **after** `RejectListener`, so a blocked IP never reaches a ceremony; +and **before** `LoginListener`, because that listener treats any POST to the auth +subdomain as a login attempt, and a ceremony request carries no code — it would +otherwise be counted as a failed login and burn rate-limit budget on every +legitimate passkey sign-in. ### Security Model @@ -256,6 +314,13 @@ passes through a priority-ordered chain of listeners: Successful (2xx) responses are deliberately excluded — they are consumed by the proxy's `forward_auth` check and never reach the browser, so a protected service's own caching is not affected. + Passkey ceremony replies are the one exception: they are the only 2xx + this application returns straight to a browser, so they carry the same + anti-caching headers. +- **Passkeys**: registerable only after a valid TOTP code; challenge is + server-issued and single-use; RP ID is always the base domain; only the + derived `https://` origin is ever accepted; a failed attempt is + indistinguishable from a wrong code and shares its rate-limit budget. ### Cache