Document passkey authentication
Covers the README (prerequisites, the two hard requirements, every new env var, how registration and login work, and the counter caveat), SECURITY.md (the ceremony model, single-use challenges, origin handling, the shared rate-limit budget, and the attestation rationale with the conditions that would reverse it), CHANGELOG (Added/Security/Changed), ROADMAP (Phase 2c complete, with the deviations from the original sketch) and DESIGN_CONSIDERATIONS (the four decisions whose reasoning is not visible in the code). The docs lead with the two prerequisites because both are enforced rather than advisory: enabling passkeys without central auth, or on a host that cannot serve HTTPS, fails at container start. Neither is a runtime surprise, and a reader needs to know that before they turn the feature on. The plan document is updated to record that it is complete, and to note the two places where implementation deviated from it — the listener/extraction order, and register-begin not being a listener operation. The second was a design error in the plan, not just an ordering change, so it is called out explicitly.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-31
@@ -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
|
||||
|
||||
+53
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user