Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffb824c652 | ||
|
|
fed7b1b48c | ||
|
|
436450cdc2 | ||
|
|
69609db8af | ||
|
|
c84cf8c308 | ||
|
|
108e9623e6 | ||
|
|
0458d9b8d2 |
@@ -12,6 +12,14 @@ BURST_COUNT=10
|
||||
BURST_TIME=30
|
||||
UPPER_COUNT=100
|
||||
UPPER_TIME=3600
|
||||
PASSKEY_ENABLED=0
|
||||
PASSKEY_RP_NAME=''
|
||||
PASSKEY_USER_VERIFICATION='required'
|
||||
PASSKEY_TIMEOUT=60000
|
||||
PASSKEY_BUTTON_NAME='Sign in with a passkey'
|
||||
PASSKEY_REGISTER_NAME='Register this device as a passkey'
|
||||
PASSKEY_BEGIN_BURST_COUNT=30
|
||||
PASSKEY_BEGIN_BURST_TIME=60
|
||||
PUBLIC_PATHS=''
|
||||
PUBLIC_BURST_COUNT=100
|
||||
PUBLIC_BURST_TIME=60
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@
|
||||
"symfony/runtime": "8.1.*",
|
||||
"symfony/twig-bundle": "8.1.*",
|
||||
"symfony/uid": "8.1.*",
|
||||
"symfony/yaml": "8.1.*"
|
||||
"symfony/yaml": "8.1.*",
|
||||
"web-auth/webauthn-lib": "^5.3"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
|
||||
Generated
+1166
-1
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ framework:
|
||||
adapters: cache.adapter.filesystem
|
||||
publicRateLimitCache:
|
||||
adapters: cache.adapter.apcu
|
||||
passkeyRateLimitCache:
|
||||
adapters: cache.adapter.apcu
|
||||
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
prefix_seed: digitaladapt/preauth
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
framework:
|
||||
property_info:
|
||||
with_constructor_extractor: true
|
||||
@@ -27,3 +27,15 @@ framework:
|
||||
public_limiter:
|
||||
policy: compound
|
||||
limiters: [public_burst, public_upper]
|
||||
|
||||
# bounds how many ceremonies one caller can *start*.
|
||||
#
|
||||
# This is a resource guard, NOT part of the login budget: D3 makes the
|
||||
# existing login_limiter the single shared budget for every login method,
|
||||
# and a legitimate `begin` must not consume failure budget. Without this,
|
||||
# an unauthenticated caller could fill the ceremony cache with records.
|
||||
passkey_begin_burst:
|
||||
policy: 'sliding_window'
|
||||
limit: '%env(int:PASSKEY_BEGIN_BURST_COUNT)%'
|
||||
interval: '%env(int:PASSKEY_BEGIN_BURST_TIME)% seconds'
|
||||
cache_pool: 'passkeyRateLimitCache'
|
||||
|
||||
@@ -12,3 +12,5 @@ framework:
|
||||
adapters: cache.adapter.array
|
||||
publicRateLimitCache:
|
||||
adapters: cache.adapter.array
|
||||
passkeyRateLimitCache:
|
||||
adapters: cache.adapter.array
|
||||
|
||||
@@ -15,4 +15,10 @@ twig:
|
||||
teapot_message: '%env(TEAPOT_MESSAGE)%'
|
||||
too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||
too_many_message: '%env(TOO_MANY_MESSAGE)%'
|
||||
passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%'
|
||||
passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%'
|
||||
debug: '%env(SHELL_VERBOSITY)%'
|
||||
|
||||
# `passkeys` is computed per request by the controller-facing templates via
|
||||
# PasskeyPolicyInterface, never from an env var, so that availability and the
|
||||
# D1/D4 prerequisites cannot drift apart.
|
||||
|
||||
@@ -55,6 +55,22 @@ parameters:
|
||||
env(PUBLIC_UPPER_COUNT): 500 # max requests per sustained window per IP
|
||||
env(PUBLIC_UPPER_TIME): 3600 # sustained window in seconds (1 hour)
|
||||
|
||||
# --- passkey authentication ---
|
||||
# Requires central auth (SUBDOMAIN_REDIRECT=1 + AUTH_SUBDOMAIN) and HTTPS.
|
||||
# Enabling this without central auth makes the container fail at cache warmup
|
||||
# rather than offering a feature that cannot work.
|
||||
env(PASSKEY_ENABLED): '0' # boolean, 1 to offer passkeys on the auth subdomain
|
||||
env(PASSKEY_RP_NAME): '' # blank to use TITLE
|
||||
env(PASSKEY_USER_VERIFICATION): 'required' # required|preferred|discouraged
|
||||
env(PASSKEY_TIMEOUT): '60000' # milliseconds
|
||||
# Extra options, custom labels
|
||||
env(PASSKEY_BUTTON_NAME): 'Sign in with a passkey'
|
||||
env(PASSKEY_REGISTER_NAME): 'Register this device as a passkey'
|
||||
# bounds how many ceremonies one caller can start (resource guard, not the
|
||||
# login budget — see config/packages/rate_limiter.yaml)
|
||||
env(PASSKEY_BEGIN_BURST_COUNT): 30
|
||||
env(PASSKEY_BEGIN_BURST_TIME): 60
|
||||
|
||||
# --- styling options ---
|
||||
env(TITLE): 'Pre-Authentication System'
|
||||
env(BG_COLOR): '#029386' # teal
|
||||
@@ -96,6 +112,15 @@ parameters:
|
||||
app.error_message: '%env(ERROR_MESSAGE)%'
|
||||
app.teapot_title: '%env(TEAPOT_TITLE)%'
|
||||
app.too_many_title: '%env(TOO_MANY_TITLE)%'
|
||||
app.title: '%env(TITLE)%'
|
||||
|
||||
app.passkey_enabled: '%env(bool:PASSKEY_ENABLED)%'
|
||||
app.passkey_rp_name: '%env(PASSKEY_RP_NAME)%'
|
||||
app.passkey_user_verification: '%env(PASSKEY_USER_VERIFICATION)%'
|
||||
app.passkey_timeout: '%env(int:PASSKEY_TIMEOUT)%'
|
||||
|
||||
app.passkey_button_name: '%env(PASSKEY_BUTTON_NAME)%'
|
||||
app.passkey_register_name: '%env(PASSKEY_REGISTER_NAME)%'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
@@ -110,3 +135,15 @@ services:
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
|
||||
# the boot-time passkey configuration check runs during `cache:warmup`, so a
|
||||
# misconfigured deployment fails to start instead of failing in a browser
|
||||
App\Service\PasskeyPolicyInterface: '@App\Service\PasskeyPolicy'
|
||||
App\Service\PasskeyInterface: '@App\Service\PasskeyManager'
|
||||
App\Service\PasskeyCeremonyStoreInterface: '@App\Service\PasskeyCeremonyStore'
|
||||
App\Service\PasskeyCredentialStoreInterface: '@App\Service\PasskeyCredentialStore'
|
||||
App\Service\SessionIssuerInterface: '@App\Service\SessionIssuer'
|
||||
|
||||
# the ceremony factory takes no constructor arguments and holds no state, so
|
||||
# it is built once and shared rather than re-created per ceremony
|
||||
App\Service\PasskeyCeremonyFactory: ~
|
||||
|
||||
+4
-4
@@ -10,18 +10,18 @@
|
||||
# MAX_REQUESTS=0 docker buildx bake # specify variables to override
|
||||
|
||||
variable "DOCKERHUB_TARGET" {
|
||||
default = "digitaladapt/preauth"
|
||||
default = "digitaladapt/preauth"
|
||||
description = "Docker Hub repo/org (Gitea repo variable DOCKERHUB_TARGET)."
|
||||
}
|
||||
|
||||
variable "TAG" {
|
||||
default = "latest"
|
||||
default = "latest"
|
||||
description = "Base tag for this build: latest (release), develop (main push), or a version."
|
||||
}
|
||||
|
||||
variable "VERSION" {
|
||||
default = ""
|
||||
description = "Full version (v stripped) to also tag with; empty for develop builds."
|
||||
default = ""
|
||||
description = "Optional full version (v stripped) to also tag with; empty for develop builds."
|
||||
}
|
||||
|
||||
variable "MAX_REQUESTS" {
|
||||
|
||||
@@ -15,6 +15,28 @@
|
||||
#SUBDOMAIN_REDIRECT=false # default disabled, boolean
|
||||
#AUTH_SUBDOMAIN='' # blank, hostname we send user to, to see login page
|
||||
|
||||
# --- passkey authentication ---
|
||||
# Passkeys (Touch ID / Windows Hello / security keys) as an alternative to TOTP.
|
||||
#
|
||||
# REQUIRES central authentication (SUBDOMAIN_REDIRECT=true plus AUTH_SUBDOMAIN)
|
||||
# and HTTPS. Passkeys are bound to a relying party that spans the base domain,
|
||||
# which only exists when central auth is configured; and browsers refuse to run
|
||||
# a ceremony over plain HTTP.
|
||||
#
|
||||
# Enabling this without central auth is a hard error: the container fails at
|
||||
# start-up (cache:warmup) rather than offering a passkey button that cannot work.
|
||||
#
|
||||
# There is deliberately no option to allow an http:// origin, and none to relax
|
||||
# the requirement for local development. See the README for the local TLS setup.
|
||||
#PASSKEY_ENABLED=false # default disabled, boolean
|
||||
#PASSKEY_RP_NAME='' # blank to use TITLE
|
||||
#PASSKEY_USER_VERIFICATION='required' # required | preferred | discouraged
|
||||
#PASSKEY_TIMEOUT=60000 # ceremony timeout in milliseconds
|
||||
#PASSKEY_BUTTON_NAME='Sign in with a passkey'
|
||||
#PASSKEY_REGISTER_NAME='Register this device as a passkey'
|
||||
#PASSKEY_BEGIN_BURST_COUNT=30 # ceremonies one caller may start per window
|
||||
#PASSKEY_BEGIN_BURST_TIME=60 # window for the above, in seconds
|
||||
|
||||
# --- extra options ---
|
||||
|
||||
# how long do we allow *ALL* traffic from an ip address after successful login
|
||||
|
||||
@@ -49,10 +49,43 @@ protected.example.com {
|
||||
# optionally, if you want to use a subdomain for central preauth
|
||||
# set SUBDOMAIN_REDIRECT to true
|
||||
# and AUTH_SUBDOMAIN to match the subdomain you use here
|
||||
#
|
||||
# Passkeys (PASSKEY_ENABLED) require this block AND HTTPS: the ceremony runs
|
||||
# here and the credential is scoped to the base domain. Caddy provisions a
|
||||
# certificate automatically for a real hostname, so nothing extra is needed in
|
||||
# production. This block is also deliberately NOT behind forward_auth — the
|
||||
# browser talks to it directly during a ceremony.
|
||||
auth.example.com {
|
||||
reverse_proxy preauth
|
||||
}
|
||||
|
||||
# --- local development with passkeys ---
|
||||
# Browsers only allow a WebAuthn ceremony over HTTPS, and preauth does not offer
|
||||
# an exemption for http://localhost (that would be a way to run passkeys
|
||||
# insecurely in production). So to exercise passkeys locally, give yourself a
|
||||
# real hostname and a locally-trusted certificate:
|
||||
#
|
||||
# 1. Point the names at your machine:
|
||||
# # /etc/hosts
|
||||
# 127.0.0.1 auth.preauthtest.local app.preauthtest.local
|
||||
# 2. Trust a certificate for them (mkcert installs a local CA):
|
||||
# mkcert auth.preauthtest.local app.preauthtest.local
|
||||
#
|
||||
# 3. In preauth's .env:
|
||||
# SUBDOMAIN_REDIRECT=true
|
||||
# AUTH_SUBDOMAIN=auth.preauthtest.local
|
||||
# PASSKEY_ENABLED=true
|
||||
#
|
||||
# 4. Terminate TLS here and proxy to the container:
|
||||
#
|
||||
# auth.preauthtest.local, "*.preauthtest.local" {
|
||||
# tls /path/to/auth.preauthtest.local+1.pem /path/to/auth.preauthtest.local+1-key.pem
|
||||
# reverse_proxy preauth
|
||||
# }
|
||||
#
|
||||
# Note "localhost" itself cannot be used: it has no base domain, so central
|
||||
# auth cannot be configured and passkeys stay disabled.
|
||||
|
||||
# --- public rate-limited access (v1.1) ---
|
||||
# Configure PUBLIC_PATHS env var to specify which paths are public.
|
||||
# Example: PUBLIC_PATHS=/public/**
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
# Plan — Passkey Authentication for the Dedicated Auth Subdomain
|
||||
|
||||
**Status:** 📋 Draft for review — no application code written yet
|
||||
**Target:** next minor release (version to confirm — see Q1.1)
|
||||
**Prepared:** 2026-09-26 against `main` @ `0458d9b`
|
||||
**Revised:** 2026-09-27 — review round 2 (D4/D5, §2.3)
|
||||
**Verified against:** `web-auth/webauthn-lib` 5.3.9 on PHP 8.5.11 / Symfony 8.1
|
||||
|
||||
---
|
||||
|
||||
## 0. Decisions locked in (from review feedback)
|
||||
|
||||
Five clarifications from the project owner reshape this plan. They are
|
||||
**decisions**, not options, and everything below follows from them.
|
||||
|
||||
| # | Decision | Consequence |
|
||||
|---|---|---|
|
||||
| **D1** | **Central auth (dedicated auth subdomain) is a hard prerequisite** for passkeys. Without it, a passkey would collide with / confuse the passkey for the protected service itself. | Passkeys are simply **not offered** unless `SUBDOMAIN_REDIRECT=true` *and* `AUTH_SUBDOMAIN` is set. The RP ID is *always* `authBase()`. There is no single-host passkey mode, no per-service RP ID, and no ambiguity to document away. |
|
||||
| **D2** | **Registration happens in the browser**, initiated by a simple "register passkey" checkbox on the login form — not a CLI command. | Registration reuses the existing login form, nonce/CSRF machinery and TOTP verification. This also **answers the identity question**: the identity is the `Session ID` field the user already types, exactly as with TOTP. |
|
||||
| **D3** | **Rate limiting covers all forms of login.** If an IP is rate-limited, that includes passkeys. | Passkey ceremonies run **behind** the existing `RejectListener` gate and consume the **same** login limiter budget on failure. No way to sidestep a lockout by switching methods. |
|
||||
| **D4** | **HTTPS is required — in development too.** No "secured relying party" exemption is supported, deprecated or otherwise. | The derived allowed-origin is *always* `https://…`, built from config and never from the request. The `PASSKEY_ALLOWED_ORIGINS` escape hatch from the first draft is **deleted**. Local development uses real TLS (§4.2). HTTPS becomes part of the boot-time assertion alongside D1 (Q3.1). |
|
||||
| **D5** | **Attestation is `none`, deliberately.** The "set a real value instead" instinct was tested and is wrong *here* — every alternative is either broken or bypassable (§2.3). | Records are anonymous: zero AAGUID, `EmptyTrustPath`. No metadata service, no `web-token/jwt-library` dependency, no download of the FIDO BLOB. `SECURITY.md` states the reasoning and the conditions that would change it. |
|
||||
|
||||
Consequences worth stating plainly:
|
||||
|
||||
- The separate `passkey_limiter`, `PASSKEY_ENABLED=false` default, and the whole
|
||||
"should we support single-host passkeys?" question from the first draft are
|
||||
**gone**. D1 removes the configuration matrix; D3 removes the second limiter.
|
||||
- The first draft's §7 (CLI registration, enrolment tokens, `--identity`) is
|
||||
**deleted**. D2 replaces it with a checkbox.
|
||||
- D4 keeps D1 exactly as strict — HTTPS is an **additional** requirement, never a
|
||||
relaxation. D5 is the one place where "use the stricter-sounding option" loses,
|
||||
and §2.3 shows the measurements behind that.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Let a user authenticate with a passkey (Touch ID, Windows Hello, Android
|
||||
biometrics, hardware security key) instead of typing a 6-digit TOTP code —
|
||||
served from the dedicated auth subdomain, where one passkey unlocks every
|
||||
service on the base domain.
|
||||
|
||||
TOTP and backup codes remain and are never removed (Q2.1).
|
||||
|
||||
---
|
||||
|
||||
## 2. What the spike proved
|
||||
|
||||
The first draft contained claims that had not been executed. They now have been:
|
||||
the library was installed, and a script performed a **complete registration and
|
||||
assertion ceremony with real ES256 cryptography**, plus the negative cases.
|
||||
|
||||
**Environment:** PHP 8.5.11, Composer 2.10.3, `web-auth/webauthn-lib` **5.3.9**.
|
||||
Baseline suite green before and after install: **313 tests / 738 assertions**.
|
||||
`composer audit`: *"No security vulnerability advisories found."*
|
||||
`.ci/conformance.sh --profile=auth-gateway`: **all 35 checks pass**.
|
||||
|
||||
### 2.1 Confirmed correct
|
||||
|
||||
| Claim | Result |
|
||||
|---|---|
|
||||
| Installs on PHP 8.5 + Symfony 8.1 with no conflicts | ✅ resolves to 5.3.9; `lint:container`, `lint:yaml`, `lint:twig` all pass |
|
||||
| Only needs `ext-json` + `ext-openssl` | ✅ (`ext-openssl` is present in every official PHP image, so `Dockerfile` needs **no** extension work) |
|
||||
| No Symfony Security bundle, no Doctrine, no bundler | ✅ `CeremonyStepManagerFactory` + `Authenticator*ResponseValidator::create()` are pure; the `webauthn-symfony-bundle` is unnecessary |
|
||||
| rpId `example.com` admits an origin on `auth.example.com` | ✅ assertion ACCEPTED |
|
||||
| …and also on `app.example.com` with the *same* credential | ✅ ACCEPTED — one passkey across all subdomains, as designed |
|
||||
| Credential is cryptographically bound to the rpId | ✅ a forged `rpIdHash` is rejected: *"rpId hash mismatch"* |
|
||||
| An origin outside the allow-list is rejected | ✅ *"Invalid origin. Not in the list of allowed origins."* |
|
||||
| Wrong origin / wrong challenge rejected | ✅ `AuthenticatorResponseVerificationException` in both cases |
|
||||
| CSP `publickey-credentials-*` do **not** inherit `default-src` | ✅ confirmed in the CSP3 spec (§6.8.3 fallback list omits WebAuthn directives) — the CSP change in §5.5 is required |
|
||||
| base64url credential IDs survive `makeCacheKey()` without collision | ✅ 200 000 random 32-byte IDs, zero collisions |
|
||||
| `attestation: 'none'` yields an anonymous record | ✅ `attestationType="none"`, zero AAGUID, `EmptyTrustPath` (§2.3, config A) |
|
||||
| Origin scheme can never be inferred from the request | ✅ the scheme comes from the single allow-list string; an `https://` entry rejects an `http://` origin (§4.2) |
|
||||
| `localhost` cannot accidentally enable passkeys | ✅ `baseDomain('localhost') === null` ⇒ `authBase() === null` ⇒ D1 unsatisfied (§4.2) |
|
||||
| `auth.localhost` *does* satisfy D1 | ✅ `authBase() === 'auth.localhost'` (§4.2) |
|
||||
| No MDS ⇒ no `web-token/jwt-library` needed | ✅ `FidoAllianceCompliantMetadataService` throws unless the JWT library is present; not installed, and MDS is not used (D5) |
|
||||
|
||||
### 2.2 Corrections to the first draft (things that would have bitten us)
|
||||
|
||||
| # | First draft said | Reality | Impact |
|
||||
|---|---|---|---|
|
||||
| **C1** | "`CredentialRecord` is JSON-serializable, so it fits the no-database constraint." | It is a **plain class**, not `JsonSerializable`. Persistence goes through `WebauthnSerializerFactory` (a Symfony Serializer with ~25 custom normalizers). | The store must use that factory. `symfony/serializer`, `property-info`, `property-access` arrive as transitive deps — no extra work, but the store can't just `json_encode()`. |
|
||||
| **C2** | (unstated) treat option objects as plain JSON | `json_encode($creationOptions)` **throws** `JsonException: Malformed UTF-8` — the challenge is raw binary. Options **must** be serialized by the same factory, which base64url-encodes binary fields. | Both the `begin` payload and the stored record go through one `SerializerInterface`. Caught immediately by the spike; would otherwise have been a runtime 500 on first test. |
|
||||
| **C3** | "the package carries 3 published advisories" | `composer audit` against 5.3.9 reports **none**. | No remediation work; record the clean audit in the CHANGELOG. |
|
||||
| **C4** | counter handling not mentioned | Counter replay raises `CounterException`, which can **mask** the real reason a verification failed. | Test helper must use an incrementing counter per ceremony, or negative tests give false passes (this actually happened during the spike and had to be fixed). |
|
||||
| **C5** | "keep the library default" for the counter | The default requires a strictly *increasing* counter. Measured: stored `0`, reported `0` → `CounterException`. A synchronised passkey reports `0` forever, so **every** such credential fails on its **first** login — and only on real hardware, since a test helper that increments never reproduces it. | `PasskeyCounterChecker` accepts `>=` and rejects strictly backwards. Pinned by `PasskeyCounterCheckerTest`, including a test asserting the library default still rejects `0`/`0` so this reasoning is re-checked if the dependency is upgraded. |
|
||||
| **C5** | separate `passkey_limiter` + `publicRateLimitCache`-style pool | Decision D3 makes it redundant for the *login* budget. | Drop it. One small limiter remains, for a different purpose (§5.4). |
|
||||
|
||||
### 2.3 Attestation: why `none`, measured rather than assumed
|
||||
|
||||
The review asked the right question — *"is there any downside to `null`, and if it
|
||||
needs a note in `SECURITY.md`, shouldn't we set a real value?"* — so it was
|
||||
tested instead of argued. Seven configurations were run against 5.3.9
|
||||
(`spike_attestation.php`, `spike_att2.php`). Results are summarised, not
|
||||
predicted:
|
||||
|
||||
| # | Configuration | Outcome | What the server actually learns |
|
||||
|---|---|---|---|
|
||||
| **A** | `attestation=none`, `fmt=none` | ✅ accepted | `attestationType="none"`, aaguid all-zero, `EmptyTrustPath`. **Nothing.** |
|
||||
| **B** | `attestation=direct`, `fmt=packed` **self**, no MDS | ✅ accepted | A real AAGUID string — but no metadata to interpret it against, so it is untrusted and uninterpretable. |
|
||||
| **C** | `attestation=direct`, `fmt=packed` **basic** (`x5c` cert), no MDS | ❌ **rejected** | *"The Metadata Statement Repository is mandatory when requesting attestation objects."* |
|
||||
| **C2** | …same, MDS enabled, metadata **empty** | ❌ **rejected** | *"The Metadata Statement for the AAGUID … is missing."* This is the real cost of MDS: **every** authenticator must be known in advance. |
|
||||
| **C3** | MDS enabled, but the client sends a **zero** AAGUID | ✅ **accepted** | *"Null AAGUID detected. Skipping metadata verification."* — **MDS is bypassable by design.** |
|
||||
| **C4** | MDS enabled, `fmt=packed` **self** attestation, AAGUID **unknown** to MDS | ✅ **accepted** | `processSelfAttestation()` returns early when the AAGUID has no metadata entry, so **self attestation is never refused by MDS** — even a *known-unknown* device passes. |
|
||||
| **D** | `attestation=direct` requested, client sends `fmt=none` | ✅ **accepted** | Asking for `direct` does **not** compel compliance — conveyance is a *preference*, so the RP cannot force it. |
|
||||
|
||||
Three conclusions follow, and they are the reason D5 is `none`:
|
||||
|
||||
1. **Attestation cannot be *enforced*, only *requested*.** Configuration D shows a
|
||||
client answering a `direct` request with `none` and being accepted regardless.
|
||||
Any policy that depends on the client cooperating is not a security control.
|
||||
2. **MDS is bypassable two different ways.** C3 is the decisive row: a zero AAGUID
|
||||
short-circuits metadata verification *before* the repository is ever consulted.
|
||||
Since passkeys from Apple/Google/Windows deliberately send zero AAGUIDs, an
|
||||
attacker can present the same shape and skip MDS entirely — while legitimate
|
||||
users are unaffected. C4 closes the remaining door on the same conclusion: with
|
||||
`fmt=packed` **self** attestation (the format a software/platform authenticator
|
||||
can produce without any vendor certificate), `processSelfAttestation()` returns
|
||||
early when the AAGUID has no metadata entry, so even a device that is *unknown*
|
||||
to MDS is accepted. Taken together: an MDS deployment refuses honest
|
||||
certificate-bearing authenticators that postdate its cached BLOB (C2), while
|
||||
still admitting the bypassable and self-attested cases. That is the worst
|
||||
combination — friction for legitimate users, no assurance gained.
|
||||
3. **`none` is not a weaker version of the same check — it is the honest
|
||||
description of reality.** The property that actually protects users is that the
|
||||
credential is cryptographically bound to the RP ID and origin (§2.1), which
|
||||
holds identically in every row above. Attestation answers *"which device model
|
||||
is this?"* — a question this project does not need to answer, because it does
|
||||
not run a device-allow-list policy.
|
||||
|
||||
**What a real value would actually cost**, for the record: `direct` requires the
|
||||
metadata repository (C) — verified as a hard failure, not a warning — which means
|
||||
`web-token/jwt-library`, `symfony/http-client`, a periodic download of the FIDO
|
||||
Alliance BLOB, certificate-chain validation on every registration, and a new
|
||||
failure mode where a legitimate new phone is **rejected at enrolment** because its
|
||||
AAGUID postdates the cached BLOB. All of that to gain a bypassable signal.
|
||||
|
||||
> **Where to revisit this.** D5 is the right call *for a self-hosted
|
||||
gateway that does not distinguish devices*. It stops being the right call if the
|
||||
project ever wants to (a) refuse specific authenticator models, or (b) prove
|
||||
enrolment happened on hardware rather than a synced passkey. Both would require
|
||||
MDS **plus** a decision to reject zero AAGUIDs — which is why the reasoning is
|
||||
recorded in `SECURITY.md` rather than left implicit in a constant.
|
||||
|
||||
---
|
||||
|
||||
## 3. The flow, end to end
|
||||
|
||||
### 3.1 First-time setup (D2 — in the browser)
|
||||
|
||||
```
|
||||
Browser → https://app.example.com/dashboard
|
||||
forward_auth → preauth (host=app.example.com) → InterceptListener
|
||||
matchesAuth() && host !== auth subdomain
|
||||
⇒ 303 https://auth.example.com/?return=https%3A%2F%2Fapp.example.com%2Fdashboard
|
||||
|
||||
Browser → https://auth.example.com/?return=…
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Pre-Authentication System │
|
||||
│ │
|
||||
│ Session ID: [ lyra ] │
|
||||
│ Authentication Token:[ 123456 ] │
|
||||
│ [x] Register this device as a passkey ← new │
|
||||
│ [ Submit ] │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The checkbox only appears when passkeys are available (D1 satisfied) — see §4.
|
||||
|
||||
**Submission with the box ticked** becomes a three-step ceremony:
|
||||
|
||||
```
|
||||
1. POST / (auth host), form fields username+totp+nonce+register=passkey
|
||||
LoginListener → LoginManager verifies TOTP/backup code + nonce [unchanged]
|
||||
↳ instead of issuing a session, it starts a REGISTRATION ceremony:
|
||||
stores passkey_reg_<cid> → { challenge, identity, userHandle } (TTL 300s)
|
||||
⇐ 200 JSON { register: { publicKey: <options>, ceremonyId: <cid> } }
|
||||
|
||||
2. Browser: navigator.credentials.create({ publicKey: options })
|
||||
→ user approves with Touch ID / Windows Hello / security key
|
||||
|
||||
3. POST / (auth host) X-Preauth-Passkey: register-finish
|
||||
body: { ceremonyId, credential: <attestation JSON> }
|
||||
PasskeyListener → PasskeyManager verifies attestation against the stored
|
||||
challenge; stores the credential under the identity from the record
|
||||
⇐ 303 Location: <return url> + Set-Cookie: __Http-Domain-Preauth=…
|
||||
```
|
||||
|
||||
The TOTP check in step 1 is what authorises registration. There is no separate
|
||||
enrolment token, no CLI, and **no way to create a credential without already
|
||||
holding a valid TOTP code** — which is exactly the security property the CLI
|
||||
design was reaching for.
|
||||
|
||||
> **Implementation note — where the hand-off actually goes.**
|
||||
> `LoginListener::onKernelRequest()` is a straight chain: it builds a `Payload`,
|
||||
> calls `$this->loginManager->checkToken(...)`, and on a non-null response it
|
||||
> does `$event->setResponse($response); return;` — on `null` it immediately
|
||||
> scores a failure and consumes a rate-limit token. There is no "authenticated
|
||||
> but do not issue a session" branch to hook.
|
||||
>
|
||||
> So the clean split is: `LoginListener` detects `register=passkey` in the POST
|
||||
> body and marks the **`Payload`** with the intent; `LoginManager::checkToken()`
|
||||
> verifies TOTP/backup-code **and the nonce** exactly as it does today, and only
|
||||
> then, if the intent is set, delegates to the registration ceremony instead of
|
||||
> issuing a session. That keeps the nonce/CSRF guarantee in the one place that
|
||||
> already enforces it — the alternative (starting a ceremony from the listener
|
||||
> before `checkToken` runs) would move nonce validation and would need care to
|
||||
> avoid double-spending it.
|
||||
|
||||
### 3.2 Everyday login (assertion)
|
||||
|
||||
```
|
||||
Browser → https://auth.example.com/?return=…
|
||||
[ 🔑 Sign in with a passkey ] ← button, one tap
|
||||
─────────── or use a code ───────────
|
||||
Session ID: [ … ] Token: [ … ] [ Submit ]
|
||||
|
||||
Passkey button:
|
||||
1. POST / X-Preauth-Passkey: login-begin
|
||||
⇐ 200 JSON { publicKey: { challenge, rpId, allowCredentials[], … },
|
||||
ceremonyId }
|
||||
2. navigator.credentials.get({ publicKey })
|
||||
3. POST / X-Preauth-Passkey: login-finish body: { ceremonyId, credential }
|
||||
PasskeyManager verifies the assertion against the stored record
|
||||
⇐ 303 + cookie, or 401 JSON { message, nonce }
|
||||
```
|
||||
|
||||
No username is typed: the credential carries its own identity (stored at
|
||||
registration). `allowCredentials` lists all registered credentials, so the OS
|
||||
picker decides which device to use.
|
||||
|
||||
### 3.3 Listener priority (D3)
|
||||
|
||||
```
|
||||
Priority Listener Action
|
||||
──────── ───────────────────── ─────────────────────────────────────────
|
||||
99 AcceptListener Valid cookie → 200 OK
|
||||
88 AllowListener Valid IP session → 200 OK
|
||||
84 PublicAccessListener Public path + rate limit → 200/429
|
||||
77 RejectListener LOGIN RATE-LIMIT GATE → 418/429
|
||||
70 PasskeyListener (new) WebAuthn ceremony → JSON
|
||||
66 LoginListener TOTP / backup-code login
|
||||
55 InterceptListener Fallback → redirect or login page
|
||||
```
|
||||
|
||||
**Why 70 — after `RejectListener` and before `LoginListener`:**
|
||||
|
||||
- **After 77 (D3):** a rate-limited IP is refused *before* any ceremony can
|
||||
start. Passkeys cannot be used to sidestep a lockout. This is the whole point
|
||||
of the reviewer's third clarification, and it reverses the first draft.
|
||||
- **Before 66:** essential. `LoginListener` treats *any* POST to the auth
|
||||
subdomain as a login attempt (`$domainManager->getAuthSubdomain() === $host`).
|
||||
A ceremony `finish` POST has no `username`/`totp`, so `Payload::load()` returns
|
||||
`null` and the request would be scored as a **failed login and burn a rate-limit
|
||||
token**. `PasskeyListener` must claim the request first.
|
||||
|
||||
`PasskeyListener` sets a response for *every* request carrying its header —
|
||||
including malformed ones — so control never falls through to
|
||||
`InterceptListener`, which would render HTML to a `fetch()` caller. (Q3.2)
|
||||
|
||||
---
|
||||
|
||||
## 4. Availability rule (D1 + D4)
|
||||
|
||||
Passkeys are offered **only** when all of these hold:
|
||||
|
||||
```php
|
||||
$passkeysAvailable =
|
||||
$config->passkeyEnabled() // PASSKEY_ENABLED=1 (default 0)
|
||||
&& null !== $domainManager->authBase() // SUBDOMAIN_REDIRECT=1 && AUTH_SUBDOMAIN set
|
||||
&& $domainManager->getAuthSubdomain() === $request->getHost(); // we are ON the auth host
|
||||
```
|
||||
|
||||
…and, separately, the **deployment** must satisfy HTTPS (D4). That is checked
|
||||
once at boot rather than per request, because "is this request HTTPS" is not the
|
||||
right question behind a TLS-terminating proxy — see §4.2.
|
||||
|
||||
Consequences:
|
||||
|
||||
- **RP ID is always `authBase()`** — never the request host, never configurable
|
||||
per-service. `example.com` for `auth.example.com`.
|
||||
- **Allowed origins is exactly one entry**: `https://{AUTH_SUBDOMAIN}`, built
|
||||
from config. Because `InterceptListener` funnels every unauthenticated user to
|
||||
the auth host, no other origin ever needs to run a ceremony. This is the
|
||||
tightest configuration that still delivers "one passkey, every service" (§2.1).
|
||||
- On a protected host, `InterceptListener` already redirects before rendering a
|
||||
login page, so the checkbox is naturally absent there.
|
||||
- If someone sets `PASSKEY_ENABLED=1` without central auth, the app must
|
||||
**fail loudly at boot**, not silently ignore it (Q3.1). A silent ignore is how
|
||||
you get "I enrolled a passkey and now I can't log in" support tickets.
|
||||
|
||||
`rpName` for the OS prompt defaults to `TITLE`.
|
||||
|
||||
### 4.1 Identity and userHandle
|
||||
|
||||
The identity is the `Session ID` the user typed — the same value TOTP uses, so
|
||||
`Remote-User` modes (`session`/`static`/`mapped`) keep working unchanged.
|
||||
|
||||
- `userHandle` = `hash('sha256', $identity, true)` (32 raw bytes). Fixed length,
|
||||
stable per identity, and does not leak the label into the authenticator.
|
||||
- On assertion, the identity is read from the **stored credential record**, not
|
||||
from the client-returned `userHandle`. The client's copy is never trusted.
|
||||
- Because registration is gated behind a successful TOTP login, one identity
|
||||
cannot be registered by someone who does not already hold the TOTP secret.
|
||||
|
||||
### 4.2 HTTPS (D4) — enforced, not exempted
|
||||
|
||||
D4 removes the exemption system entirely: **there is no code path that accepts an
|
||||
`http://` origin for passkeys**, and no configuration that re-enables one. The
|
||||
library's `setSecuredRelyingPartyId()` (deprecated since 5.2, confirmed in
|
||||
`CeremonyStepManagerFactory`) is **never called**.
|
||||
|
||||
Measured behaviour of the origin check (`spike_origin.php`), all with rpId
|
||||
`example.com`:
|
||||
|
||||
| Allowed origins | Client origin | Result |
|
||||
|---|---|---|
|
||||
| `https://auth.example.com` | `https://auth.example.com` | ✅ accepted |
|
||||
| `http://localhost:8000` | `http://localhost:8000` | ✅ accepted — **only** because `http://` was explicitly allow-listed |
|
||||
| `localhost:8000` (host-only) | `http://localhost:8000` | ❌ rejected |
|
||||
| `https://auth.example.com` | `http://auth.example.com` | ❌ rejected |
|
||||
| `https://example.com` +subdomains | `https://app.example.com` | ✅ accepted |
|
||||
| `https://example.com` +subdomains | `http://app.example.com` | ❌ rejected |
|
||||
| `https://example.com` (no subdomains) | `https://app.example.com` | ❌ rejected — *"Subdomains are not allowed."* |
|
||||
|
||||
The scheme is therefore never inferred from the request; it comes from the single
|
||||
`https://{AUTH_SUBDOMAIN}` string. Note the second row — the library *will* accept
|
||||
plain HTTP **if the operator writes it into the allow-list**, which is precisely
|
||||
the hole D4 closes by deleting `PASSKEY_ALLOWED_ORIGINS`.
|
||||
|
||||
**Two gotchas this creates for local development**, both verified against
|
||||
`DomainManager` (`spike_devhost.php`):
|
||||
|
||||
1. `baseDomain('localhost')` returns **`null`** by design, so `authBase()` is also
|
||||
`null` and **`localhost` can never satisfy D1** — passkeys stay off there no
|
||||
matter what. `auth.localhost`, by contrast, resolves to `authBase()` of
|
||||
`auth.localhost` and *does* satisfy D1.
|
||||
2. Because the origin must be `https://`, dev cannot simply point a browser at
|
||||
`http://auth.localhost`. The supported dev workflow is therefore **a local TLS
|
||||
certificate**, not an exemption:
|
||||
|
||||
```
|
||||
# Development with real TLS — the only supported way to exercise passkeys
|
||||
AUTH_SUBDOMAIN=auth.preauthtest.local
|
||||
SUBDOMAIN_REDIRECT=true
|
||||
PASSKEY_ENABLED=1
|
||||
# /etc/hosts → 127.0.0.1 auth.preauthtest.local app.preauthtest.local
|
||||
# mkcert auth.preauthtest.local app.preauthtest.local
|
||||
# Caddy terminates TLS with the mkcert cert and reverse_proxies to :80
|
||||
```
|
||||
|
||||
This is a **documentation and CI** change, not an application-code change: the app
|
||||
already sits behind a TLS-terminating proxy in production (`docker/Caddyfile`
|
||||
serves plain HTTP on `:80`, `trusted_headers` includes `x-forwarded-proto`), so
|
||||
D4 adds no runtime branching. `docs/examples/Caddyfile` gains a TLS-enabled
|
||||
development block, and the functional tests (§7.2) drive the HTTPS origin directly
|
||||
because they build `clientDataJSON` by hand — no real TLS needed in the suite.
|
||||
|
||||
> **Not `localhost`.** Because D4 forbids `http://`, the classic
|
||||
> `http://localhost` dev story simply does not apply to passkeys. `localhost` is
|
||||
> treated as *"passkeys unavailable"*, which keeps D1 intact instead of carving
|
||||
> out an exception that would then need its own tests.
|
||||
|
||||
---
|
||||
|
||||
## 5. Design detail
|
||||
|
||||
### 5.1 `PasskeyManager` (new service)
|
||||
|
||||
Owns both ceremonies. Library types stay inside this class so a future v6 rename
|
||||
touches one file.
|
||||
|
||||
```php
|
||||
final readonly class PasskeyManager implements PasskeyInterface
|
||||
{
|
||||
public function beginLogin(Request $request): array; // → options + ceremonyId
|
||||
public function finishLogin(array $body, Request $request): ?Response;
|
||||
public function beginRegistration(string $identity, Request $request): array;
|
||||
public function finishRegistration(array $body, Request $request): ?Response;
|
||||
}
|
||||
```
|
||||
|
||||
Built on the verified recipe:
|
||||
|
||||
```php
|
||||
$attestationManager = AttestationStatementSupportManager::create();
|
||||
$attestationManager->add(NoneAttestationStatementSupport::create()); // D5 (§2.3)
|
||||
|
||||
$csm = new CeremonyStepManagerFactory();
|
||||
$csm->setAllowedOrigins(["https://{$domainManager->getAuthSubdomain()}"]);
|
||||
$csm->setAlgorithmManager(AlgorithmManager::create()->add(ES256::create()));
|
||||
$csm->setAttestationStatementSupportManager($attestationManager);
|
||||
|
||||
$attestationValidator = AuthenticatorAttestationResponseValidator::create($csm->creationCeremony());
|
||||
$assertionValidator = AuthenticatorAssertionResponseValidator::create($csm->requestCeremony());
|
||||
$serializer = (new WebauthnSerializerFactory($attestationManager))->create();
|
||||
```
|
||||
|
||||
- `setSecuredRelyingPartyId()` is **deprecated in 5.2** (confirmed in the source,
|
||||
`@deprecated since 5.2.0 … Use setAllowedOrigins instead`) — **never called
|
||||
(D4)**. Development uses real TLS, not an exemption (§4.2).
|
||||
- `attestation: 'none'` for registration **(D5, §2.3)**; no metadata service, so
|
||||
neither `web-token/jwt-library` nor `symfony/http-client` is needed — the
|
||||
latter confirmed absent from the current install, so reaching for MDS would add
|
||||
a second new dependency, not just code.
|
||||
- Counter: **replaced the library default** — see §2.2 C5. `ThrowExceptionIfInvalid`
|
||||
requires a *strictly increasing* counter, which rejects a synchronised passkey
|
||||
on its first login; `PasskeyCounterChecker` accepts equal-or-greater and still
|
||||
rejects moves backwards. Clone detection is not relied upon. Test helpers must
|
||||
still increment (C4).
|
||||
|
||||
### 5.2 Ceremony state
|
||||
|
||||
Stored in the **`nonceCache`** pool (already APCu, already excluded from
|
||||
`kernel.reset` in `TestKernel`, already short-lived, and — correctly — *not*
|
||||
persisted to disk, so ceremonies do not survive a restart):
|
||||
|
||||
```
|
||||
passkey_cer_<ceremonyId> → { type: 'login'|'register',
|
||||
challenge: <base64url>,
|
||||
identity?: string, // register only
|
||||
userHandle?: string, // register only
|
||||
returnUrl?: string,
|
||||
createdAt: <iso8601> } TTL 300s
|
||||
```
|
||||
|
||||
- `ceremonyId` is a fresh 15-byte base64url string, issued to the client. The
|
||||
client's copy of the challenge is **never** trusted; the server-side record is
|
||||
authoritative.
|
||||
- **Single-use**: deleted on read at `finish`, before verification, so a failed
|
||||
or replayed assertion cannot be retried against the same challenge.
|
||||
- TTL 300 s (5 min) rather than the nonce's 120 s, because a user has to
|
||||
interact with a biometric prompt.
|
||||
|
||||
### 5.3 Credential store (new service)
|
||||
|
||||
```php
|
||||
final readonly class PasskeyCredentialStore implements PasskeyCredentialStoreInterface
|
||||
{
|
||||
public function all(): array; // for allowCredentials
|
||||
public function find(string $credentialId): ?array; // record + metadata
|
||||
public function save(CredentialRecord $record, string $identity, string $label): void;
|
||||
public function updateCounter(CredentialRecord $record): void;
|
||||
public function remove(string $credentialId): bool;
|
||||
public function count(): int;
|
||||
}
|
||||
```
|
||||
|
||||
Cache layout in **`sessionCache`** (the persisted pool):
|
||||
|
||||
```
|
||||
passkey_cred_<makeCacheKey(credentialId)> → { record: <serialized CredentialRecord>,
|
||||
identity: string,
|
||||
label: string,
|
||||
createdAt: iso8601,
|
||||
lastUsedAt: iso8601|null }
|
||||
passkey_index → { <credentialId>: {identity, label, createdAt}, … }
|
||||
```
|
||||
|
||||
> ⚠️ **Verified gotcha.** `PersistCache::persist()` only flushes keys recorded by
|
||||
> a `MonitorCacheKeys` instance, and it watches `sessionCache`. `LoginManager`
|
||||
> and `BackupCodeManager` therefore each wrap their injected pool:
|
||||
> `$this->sessionCache = new MonitorCacheKeys($sessionCache);`.
|
||||
> `PasskeyCredentialStore` **must do the same**, or credentials live only in APCu
|
||||
> and vanish on the next container restart — a bug that would surface only after
|
||||
> a redeploy. Add an explicit test asserting the write is visible in the
|
||||
> underlying persistent pool.
|
||||
|
||||
`passkey_index` avoids scanning the whole key space for the login page's
|
||||
`allowCredentials` list.
|
||||
|
||||
### 5.4 Rate limiting (D3)
|
||||
|
||||
**No new limiter for the login budget.** Instead:
|
||||
|
||||
| Event | Limiter behaviour |
|
||||
|---|---|
|
||||
| Any request to the auth host, incl. `*-begin` | `RejectListener` (77) gates first — a blocked IP never reaches `PasskeyListener` |
|
||||
| `login-finish` **failure** | consumes `login_limiter` (1 token) — identical to a wrong TOTP code |
|
||||
| `register-finish` **failure** | consumes `login_limiter` |
|
||||
| successful ceremony | consumes nothing |
|
||||
| `*-begin` | not consumed (a legitimate login must not burn failure budget) |
|
||||
|
||||
This satisfies "if the login attempt has been rate limited, that would include
|
||||
all forms of login": after 10 failures the IP is blocked for *every* method, and
|
||||
failures from any method count toward the same 10.
|
||||
|
||||
To stop `begin`-spam from filling the cache with ceremony records, add **one**
|
||||
small limiter that bounds *starts* only — it is a resource guard, not the auth
|
||||
budget:
|
||||
|
||||
```yaml
|
||||
passkey_begin_burst:
|
||||
policy: 'sliding_window'
|
||||
limit: '%env(int:PASSKEY_BEGIN_BURST_COUNT)%' # default 30
|
||||
interval: '%env(int:PASSKEY_BEGIN_BURST_TIME)% seconds' # default 60
|
||||
cache_pool: 'passkeyRateLimitCache'
|
||||
```
|
||||
|
||||
plus a `passkeyRateLimitCache` pool (APCu in prod, array in test) and an entry in
|
||||
`tests/TestKernel`'s reset-exclusion list. On over-limit, `begin` answers
|
||||
`429` with `Retry-After`, matching `PublicAccessListener`. (Q3.6 asks whether this
|
||||
guard is wanted at all.)
|
||||
|
||||
### 5.5 Response caching and CSP
|
||||
|
||||
**Caching.** `SecurityHeadersListener` sets `no-store` only on *non-2xx*
|
||||
responses, on the assumption that 2xx is consumed by `forward_auth`. That is
|
||||
false here: `begin` returns a **`200` JSON body straight to the browser**, and
|
||||
the auth subdomain is `reverse_proxy`-ed with no `forward_auth` in front of it at
|
||||
all. Ceremony responses must therefore be no-store too. Proposed: `PasskeyListener`
|
||||
marks them with an internal `X-Preauth-Ceremony` header, and
|
||||
`SecurityHeadersListener` turns that into the full no-store set and strips the
|
||||
marker — keeping the caching policy in the one place that owns it. (Q3.7)
|
||||
|
||||
**CSP.** `publickey-credentials-get` / `publickey-credentials-create` do **not**
|
||||
fall back to `default-src` (confirmed, §2.1), and the current policy is
|
||||
`default-src 'none'`. When passkeys are available the policy becomes:
|
||||
|
||||
```
|
||||
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
|
||||
connect-src 'self'; publickey-credentials-get 'self'; publickey-credentials-create 'self';
|
||||
```
|
||||
|
||||
`connect-src 'self'` must be added **in both modes** — today it is added only for
|
||||
the inline (non-auth-subdomain) case, but the passkey flow always uses `fetch()`.
|
||||
When passkeys are unavailable the header is byte-identical to today.
|
||||
|
||||
### 5.6 Templates and script
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `templates/_passkey.html.twig` | "Sign in with a passkey" button + `navigator.credentials.get()` handler |
|
||||
| `templates/login.html.twig` | gains the checkbox (register) and includes the button, both only when available |
|
||||
| `templates/_passkey_register.html.twig` | `navigator.credentials.create()` handler, driven by the JSON returned in step 1 of §3.1 |
|
||||
|
||||
`_script.html.twig` keeps its existing submit handler; ticking the checkbox
|
||||
switches the submit into the registration branch. Kept as separate templates so
|
||||
the "passkeys unavailable ⇒ byte-identical login page" property stays testable.
|
||||
|
||||
Base64url helpers must mirror the library's encoding exactly (no padding,
|
||||
`-`/`_` alphabet); the spike's working script is the reference.
|
||||
|
||||
### 5.7 Configuration
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `PASSKEY_ENABLED` | `0` | master switch; requires **D1 and D4** or the app fails at boot (Q3.1) |
|
||||
| `PASSKEY_RP_NAME` | `%env(TITLE)%` | shown by the OS prompt |
|
||||
| `PASSKEY_USER_VERIFICATION` | `required` | `required`/`preferred`/`discouraged` |
|
||||
| `PASSKEY_TIMEOUT` | `60000` | ms, passed to the browser |
|
||||
| `PASSKEY_BEGIN_BURST_COUNT` / `_TIME` | `30` / `60` | §5.4 resource guard |
|
||||
| `PASSKEY_BUTTON_NAME` | `Sign in with a passkey` | styling-option family |
|
||||
| `PASSKEY_REGISTER_NAME` | `Register this device as a passkey` | checkbox label |
|
||||
|
||||
> **Deleted by D4:** `PASSKEY_ALLOWED_ORIGINS`. The allowed origin is always
|
||||
> derived as `https://{AUTH_SUBDOMAIN}` and there is no override — see §4.2.
|
||||
|
||||
Defaults preserve today's behaviour exactly.
|
||||
|
||||
---
|
||||
|
||||
## 6. Files
|
||||
|
||||
**New**
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `src/Service/PasskeyManager.php` + `PasskeyInterface.php` | both ceremonies, library types contained |
|
||||
| `src/Service/PasskeyCredentialStore.php` + interface | cache-backed records + index |
|
||||
| `src/Listener/PasskeyListener.php` | priority 70, header dispatch, JSON |
|
||||
| `templates/_passkey.html.twig` | login button + assertion script |
|
||||
| `templates/_passkey_register.html.twig` | registration script |
|
||||
| `tests/Support/PasskeyTestHelper.php` | ES256 generator, ceremony builder, incrementing counter (C4) |
|
||||
| `tests/Unit/Service/PasskeyManagerTest.php` | ceremony control flow |
|
||||
| `tests/Unit/Service/PasskeyCredentialStoreTest.php` | storage, index, persistence, key collisions |
|
||||
| `tests/Unit/Listener/PasskeyListenerTest.php` | every branch |
|
||||
| `tests/Functional/PasskeyFlowTest.php` | real crypto end-to-end (§7.2) |
|
||||
|
||||
**Changed**
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `composer.json` / `composer.lock` / `symfony.lock` | `web-auth/webauthn-lib: ^5.3` (done on the spike branch) |
|
||||
| `phpunit.dist.xml` | recipe-added `doctrine/deprecations` triggers (spike artefact — keep) |
|
||||
| `config/packages/property_info.yaml` | recipe-added (spike artefact — keep) |
|
||||
| `config/services.yaml` | `app.passkey_*` parameters |
|
||||
| `config/packages/rate_limiter.yaml` | `passkey_begin_burst` |
|
||||
| `config/packages/cache.yaml` + `test/cache.yaml` | `passkeyRateLimitCache` |
|
||||
| `config/packages/twig.yaml` | passkey globals |
|
||||
| `src/ConfigBag.php` | `passkeyEnabled()`, `rpName()`, `userVerification()`, `timeout()`, labels |
|
||||
| `src/Kernel.php` or a compiler pass | boot-time check that **D1 and D4** hold when enabled (Q3.1, §4.2) |
|
||||
| `src/Listener/SecurityHeadersListener.php` | CSP additions; ceremony no-store marker |
|
||||
| `src/Listener/LoginListener.php` | detect `register=passkey` on the POST and mark the `Payload` with the intent (see §3.1 note) |
|
||||
| `src/Service/LoginManager.php` | on success-with-intent, delegate to the registration ceremony instead of issuing a session; extract the session-issuing tail (Q3.8) |
|
||||
| `templates/login.html.twig` | checkbox + button |
|
||||
| `tests/TestKernel.php` | `passkeyRateLimitCache` in the reset-exclusion list |
|
||||
| `tests/Support/ListenerTestHelper.php` | passkey limiter factory |
|
||||
| `.env.test`, `docs/examples/.env.example`, `docs/examples/Caddyfile`, `docs/examples/compose.yaml` | config + docs; **TLS dev block** (§4.2) |
|
||||
| `readme.md`, `CHANGELOG.md`, `ROADMAP.md`, `SECURITY.md`, `DESIGN_CONSIDERATIONS.md` | §9 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing
|
||||
|
||||
### 7.1 Reusing the spike
|
||||
|
||||
`tests/Support/PasskeyTestHelper.php` is the spike's working code, refactored:
|
||||
ES256 keypair → COSE key → `authenticatorData` → sign → JSON. Two rules learned
|
||||
the hard way:
|
||||
|
||||
- **Increment the counter every ceremony** (C4), or a negative test can pass for
|
||||
the wrong reason (`CounterException` masking the real failure).
|
||||
- **Serialise options through `WebauthnSerializerFactory`**, never `json_encode` (C2).
|
||||
|
||||
### 7.2 Cases
|
||||
|
||||
| Case | Expected |
|
||||
|---|---|
|
||||
| Register on `auth.example.com` (rpId `example.com`), then assert from the same host | ✅ 303, `__Http-Domain-Preauth`, `Domain=example.com`, `Remote-User` |
|
||||
| Assert the same credential from `app.example.com` | ✅ success by design — asserted explicitly so the scope is documented in code |
|
||||
| Register while `PASSKEY_ENABLED=0` / without central auth | ❌ checkbox absent; `begin` inert; no cache writes |
|
||||
| Registration submitted with a **bad TOTP** | ❌ 401, no ceremony started, login limiter consumed |
|
||||
| Registration with a **spent nonce** | ❌ 401, no ceremony |
|
||||
| `begin` from a rate-limited IP | ❌ 418/429 from `RejectListener`, never reaches passkey code |
|
||||
| Failed assertion | ❌ 401, **login limiter consumed** (D3) |
|
||||
| Failed assertion × N, then a correct TOTP | ❌ still blocked — shared budget |
|
||||
| Assertion replayed with the same `ceremonyId` | ❌ 401 (record deleted on read) |
|
||||
| Unknown `credentialId` | ❌ 401, same generic message as a bad TOTP (no enumeration) |
|
||||
| Origin not in the allow-list | ❌ 401 (`Invalid origin…`) |
|
||||
| **`http://` origin with the derived `https://` allow-list** | ❌ 401 — D4; asserted explicitly so the exemption cannot creep back |
|
||||
| **`PASSKEY_ALLOWED_ORIGINS` is not consulted** | ❌ setting it has no effect (D4) |
|
||||
| **`PASSKEY_ENABLED=1` with `AUTH_SUBDOMAIN=localhost`** | ❌ boot failure — D1 unsatisfiable (§4.2) |
|
||||
| **`PASSKEY_ENABLED=1` on plain HTTP deployment** | ❌ boot failure — D4 (Q3.1) |
|
||||
| Forged `rpIdHash` | ❌ 401 (`rpId hash mismatch`) |
|
||||
| Zero AAGUID / self attestation payload | ✅ accepted exactly as a `none` record would be — documents D5's reasoning in code |
|
||||
| Ceremony responses | ✅ full no-store header set |
|
||||
| Login page when passkeys unavailable | ✅ byte-identical to today |
|
||||
| Persistence | ✅ a saved credential is present in the **persistent** pool, not just APCu |
|
||||
| `begin` spam | ✅ bounded by `passkey_begin_burst` |
|
||||
|
||||
### 7.3 Gates
|
||||
|
||||
Baseline to preserve: **313 tests / 738 assertions**, 100 % line/method/class
|
||||
coverage, `phpstan` level 6 clean, `php-cs-fixer` clean, `composer audit` clean,
|
||||
conformance 35/35. Note `phpunit.dist.xml` runs with `failOnDeprecation=true`, so
|
||||
deprecations from the new dependency must be watched (the recipe already added
|
||||
the `doctrine/deprecations` triggers).
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation order
|
||||
|
||||
Each step is independently committable and leaves the suite green.
|
||||
|
||||
1. **Dependency** *(done on the spike branch)* — `composer require
|
||||
web-auth/webauthn-lib`; suite + lints + audit + conformance verified.
|
||||
2. **Availability + config** — `ConfigBag` accessors, `services.yaml`, boot-time
|
||||
**D1 + D4** assertion, Twig globals, test env. Feature fully inert; assert the
|
||||
login page is unchanged. Includes the TLS development setup in
|
||||
`docs/examples/` (§4.2), so contributors can exercise the feature locally.
|
||||
3. **Credential store** — with `MonitorCacheKeys` wrapping and the persistence
|
||||
test. No WebAuthn types needed yet (`CredentialRecord` can be stubbed).
|
||||
4. **`PasskeyManager`** — both ceremonies, ceremony state, single-use deletion,
|
||||
limiter consumption on failure. Unit-tested with a stubbed validator.
|
||||
5. **`PasskeyListener`** — priority 70, header dispatch, always terminate,
|
||||
no-store marker. Unit-test every branch incl. "post-shaped request must not
|
||||
reach `LoginListener`".
|
||||
6. **Extract session issuing** from `LoginManager` so both paths share it —
|
||||
prove equality against the existing `LoginManagerTest`/`AuthenticationFlowTest`
|
||||
before touching anything else (Q3.8).
|
||||
7. **Registration UI** — checkbox in `login.html.twig`, the `Payload`-intent
|
||||
hand-off described in §3.1, `_passkey_register.html.twig`.
|
||||
8. **Login UI + CSP** — `_passkey.html.twig`, `SecurityHeadersListener`, extend
|
||||
`CacheControlFlowTest` and `SecurityHeadersListenerTest`.
|
||||
9. **Functional tests** with real crypto (§7.2).
|
||||
10. **Docs** (§9) and **final gates**, then PR to `main`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Documentation
|
||||
|
||||
| File | Update |
|
||||
|---|---|
|
||||
| `readme.md` | "Passkey Authentication" section: **the central-auth prerequisite**, **the HTTPS requirement (development included)**, enabling, the checkbox, the passkey button, RP ID, fallbacks |
|
||||
| `CHANGELOG.md` | `[Unreleased]` `Added`/`Security`; record library 5.3.9 and the clean audit |
|
||||
| `ROADMAP.md` | Phase 2c done, noting the deviations from the original sketch (browser registration, no bundle, D1/D3/D4/D5) |
|
||||
| `SECURITY.md` | ceremony model, challenge TTL/one-shot, RP ID scope, **the D5 attestation rationale and the conditions that would reverse it (§2.3)**, HTTPS-only origins, counter caveat, shared rate-limit budget |
|
||||
| `DESIGN_CONSIDERATIONS.md` | the 2xx-caching gap; `CredentialRecord` serialization; the shared-limiter decision; **why attestation was deliberately declined** |
|
||||
| `docs/examples/.env.example` | new variables; **note that `PASSKEY_ALLOWED_ORIGINS` does not exist by design** |
|
||||
| `docs/examples/Caddyfile` | auth-subdomain block already `reverse_proxy`-ed; **add a TLS-enabled development block (§4.2)** and note why the plain-HTTP shortcut is not offered |
|
||||
|
||||
---
|
||||
|
||||
## 10. Risks
|
||||
|
||||
| # | Risk | Mitigation |
|
||||
|---|---|---|
|
||||
| R1 | RP ID / origin misconfiguration | D1 removes the matrix: RP ID is always `authBase()`, origins is always the auth host. Asserted by tests. |
|
||||
| R2 | Ceremony responses cached (first browser-facing 2xx) | §5.5 marker + `CacheControlFlowTest` cases |
|
||||
| R3 | CSP blocks the ceremony | §5.5 directives; verify in a real browser during staging (Q3.9) |
|
||||
| R4 | Library churn (v5 renamed types; `setSecuredRelyingPartyId` deprecated) | pin `^5.3`; library types contained in `PasskeyManager`; avoid deprecated calls |
|
||||
| R5 | Credential loss on restart | `MonitorCacheKeys` wrap + explicit persistence test (§5.3) |
|
||||
| R6 | Non-technical users lose their passkey device | TOTP/backup codes unchanged and always available; the checkbox is opt-in |
|
||||
| R7 | `begin` cache-fill | §5.4 resource guard |
|
||||
| R8 | New transitive deps (`symfony/serializer`, `property-info`) | already installed as part of the spike; container lint passes |
|
||||
| R9 | **A deployment enables passkeys without TLS, and the feature silently half-works** | D4 + the extended boot assertion (§4.2, Q3.1): `PASSKEY_ENABLED=1` in a non-HTTPS configuration **fails at `cache:warmup`** instead of failing later in the browser |
|
||||
| R10 | **"We should verify the device" creeps back in as a requirement** | §2.3 records the measurements and the two conditions that would justify revisiting; a functional test asserts a zero-AAGUID payload is handled deliberately, so any change is a visible, reviewed diff |
|
||||
|
||||
---
|
||||
|
||||
## 11. Remaining open questions
|
||||
|
||||
D1–D5 removed most of the first draft's 22 questions. These are what is left;
|
||||
each has a proposal, so "yes" is a valid answer.
|
||||
|
||||
**Q1.1 — Version target.** `CHANGELOG.md`'s `[Unreleased]` heading still says
|
||||
v1.1 while git tags reach `v1.3.0`. Target the next minor and repair the heading
|
||||
in a separate labelled commit? *Proposal: yes.*
|
||||
|
||||
**Q1.2 — Where the checkbox appears.** *Proposal: always visible when passkeys
|
||||
are available (same as the login button), since a user who has just landed on
|
||||
the auth page is exactly the person most likely to be enrolling a new device.*
|
||||
|
||||
**Q1.3 — What if the same device registers twice** (same identity, second
|
||||
passkey)? *Proposal: allow it — the OS may legitimately create a second
|
||||
credential, and `excludeCredentials` will let the authenticator dedupe. `Q2.6`
|
||||
of the first draft (a cap) becomes: cap at a configurable N (default 20).*
|
||||
|
||||
**Q2.1 — Re-confirm: TOTP stays?** *Proposal: yes, unchanged, and never
|
||||
disabled by enabling passkeys.*
|
||||
|
||||
**Q3.1 — How to enforce the D1 prerequisite.** Boot-time hard failure when
|
||||
`PASSKEY_ENABLED=1` without central auth, or log a warning and disable?
|
||||
*Proposal: **hard failure** at container start (`cache:warmup`) — a silent
|
||||
disable is how you get "my passkey stopped working" tickets.* **Extended by D4:**
|
||||
the same boot check also asserts HTTPS, so "enabled but unusable" cannot ship.
|
||||
The check is on **configuration**, not on the request, because behind a TLS
|
||||
terminating proxy `isSecure()` is not authoritative (§4.2).
|
||||
|
||||
**Q3.2 — Always terminate a ceremony with JSON?** *Proposal: yes — any request
|
||||
carrying `X-Preauth-Passkey` gets a JSON response, never the HTML login page.*
|
||||
|
||||
**Q3.3 — Attestation policy.** **RESOLVED — D5: `none`.** Measured, not assumed:
|
||||
`direct` cannot be enforced (config D), MDS is bypassable by the zero AAGUIDs that
|
||||
real passkeys send (config C3), and requiring MDS would reject legitimate new
|
||||
authenticators (config C2) while adding two dependencies. Full evidence and the
|
||||
conditions that would reverse it are in **§2.3**. *Set a real value instead*
|
||||
was considered and rejected on the evidence.
|
||||
|
||||
**Q3.4 — Local development over HTTP.** **RESOLVED — D4: not supported.** No
|
||||
`securedRelyingPartyId` exemption, deprecated or otherwise; local development uses
|
||||
real TLS with a local certificate (§4.2). `PASSKEY_ALLOWED_ORIGINS` is deleted.
|
||||
Note `localhost` deliberately cannot satisfy D1, so there is no half-configured
|
||||
state to document away.
|
||||
|
||||
**Q3.5 — Counter checking.** **Resolved during implementation: the proposal was
|
||||
wrong and was reversed.** The assumption "many passkeys always report 0" was
|
||||
correct, but the conclusion "so the default is harmless" was not — the default
|
||||
*rejects* a reported 0 against a stored 0, so the very case it was assumed to
|
||||
tolerate is the case it fails. Replaced with `PasskeyCounterChecker` (accept
|
||||
`>=`, reject strictly backwards). See §2.2 C5.
|
||||
|
||||
**Q3.6 — Keep the `begin` resource guard?** It is not part of the login budget
|
||||
(D3 governs that) — it only bounds cache-fill. *Proposal: keep it; it is ~15
|
||||
lines and mirrors the existing `public_limiter` pattern.*
|
||||
|
||||
**Q3.7 — Caching-policy mechanism.** `X-Preauth-Ceremony` marker header consumed
|
||||
by `SecurityHeadersListener` (keeps cache policy in one place), or set headers
|
||||
directly in `PasskeyListener`? *Proposal: the marker.*
|
||||
|
||||
**Q3.8 — Extract the session-issuing tail from `LoginManager`?** *Proposal: yes,
|
||||
as its own commit — duplicating cookie/redirect/`Remote-User` logic is how the
|
||||
two paths drift.*
|
||||
|
||||
**Q3.9 — Browser matrix.** Which browsers must be verified by hand on staging
|
||||
(iOS Safari, Chrome, Firefox, and a hardware key) before release? *Proposal: all
|
||||
four; note the CSP directive is the most likely divergence.*
|
||||
|
||||
---
|
||||
|
||||
### Resolved in this round
|
||||
|
||||
| Question | Resolution |
|
||||
|---|---|
|
||||
| Q3.3 — attestation value | **D5: `none`**, with measurements in §2.3 |
|
||||
| Q3.4 — dev over HTTP | **D4: real TLS only**; `PASSKEY_ALLOWED_ORIGINS` deleted (§4.2) |
|
||||
| Q3.1 — boot check scope | extended to assert **D1 *and* D4** |
|
||||
|
||||
---
|
||||
|
||||
## 12. Rollback
|
||||
|
||||
- `PASSKEY_ENABLED=0` (the default) makes the feature inert; reverting is
|
||||
redeploying the previous image tag. No migrations.
|
||||
- If passkeys were enabled and are rolled back, credential records remain in
|
||||
`sessionCache`/filesystem under `passkey_*` keys, unread by the old code.
|
||||
Sessions continue to work; nothing is invalidated.
|
||||
- The dependency addition reverts with `composer.lock`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Notes for the reviewer
|
||||
|
||||
- **Round 2 added D4 (HTTPS required, no exemptions) and D5 (attestation stays
|
||||
`none`, on measured evidence).** D4 is covered in §4.2, D5 in §2.3; the two
|
||||
questions that drove them are marked resolved in §11.
|
||||
- Three claims in this revision were **measured, not reasoned**: the attestation
|
||||
matrix (§2.3), the origin/HTTPS behaviour (§4.2), and the `localhost` × D1
|
||||
interaction (§4.2). Scripts: `spike_attestation.php`, `spike_att2.php`,
|
||||
`spike_origin.php`, `spike_devhost.php`.
|
||||
- Incidentally confirmed while testing: `symfony/http-client` is **not** in the
|
||||
current install, so MDS would have been a second new dependency, not a drop-in.
|
||||
- The spike branch (`spike/passkey-deps`) currently carries `composer.json`,
|
||||
`composer.lock`, `symfony.lock`, `phpunit.dist.xml` and
|
||||
`config/packages/property_info.yaml` changes. Decide whether step 2 continues
|
||||
on that branch or starts fresh from `main`.
|
||||
- The spike scripts themselves were **removed** from the working tree (kept in
|
||||
`/tmp/spike-backup/` for reference) so they never reach a PR; the reusable
|
||||
parts are folded into `tests/Support/PasskeyTestHelper.php` in step 9.
|
||||
- The environment details (PHP 8.5.11 via Sury, Composer, `pcov`) are local to
|
||||
this container and are not a project change.
|
||||
|
||||
---
|
||||
|
||||
*End of plan.*
|
||||
+34
-70
@@ -126,6 +126,30 @@ parameters:
|
||||
count: 1
|
||||
path: src/Listener/InterceptListener.php
|
||||
|
||||
-
|
||||
message: '#^Class App\\Listener\\PasskeyListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||
identifier: property.uninitializedReadonly
|
||||
count: 1
|
||||
path: src/Listener/PasskeyListener.php
|
||||
|
||||
-
|
||||
message: '#^Class App\\Listener\\PasskeyListener has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||
identifier: property.uninitializedReadonly
|
||||
count: 1
|
||||
path: src/Listener/PasskeyListener.php
|
||||
|
||||
-
|
||||
message: '#^Readonly property App\\Listener\\PasskeyListener\:\:\$logger is assigned outside of the constructor\.$#'
|
||||
identifier: property.readOnlyAssignNotInConstructor
|
||||
count: 2
|
||||
path: src/Listener/PasskeyListener.php
|
||||
|
||||
-
|
||||
message: '#^Readonly property App\\Listener\\PasskeyListener\:\:\$nonceCache is assigned outside of the constructor\.$#'
|
||||
identifier: property.readOnlyAssignNotInConstructor
|
||||
count: 1
|
||||
path: src/Listener/PasskeyListener.php
|
||||
|
||||
-
|
||||
message: '#^Class App\\Listener\\LoginListener has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||
identifier: property.uninitializedReadonly
|
||||
@@ -349,14 +373,20 @@ parameters:
|
||||
path: src/Service/LoginManager.php
|
||||
|
||||
-
|
||||
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||
message: '#^Class App\\Service\\SessionIssuer has an uninitialized readonly property \$logger\. Assign it in the constructor\.$#'
|
||||
identifier: property.uninitializedReadonly
|
||||
count: 1
|
||||
path: src/Service/LoginManager.php
|
||||
path: src/Service/SessionIssuer.php
|
||||
|
||||
-
|
||||
message: '#^Method App\\Service\\LoginManager\:\:checkToken\(\) overrides method App\\Service\\LoginInterface\:\:checkToken\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
message: '#^Readonly property App\\Service\\SessionIssuer\:\:\$logger is assigned outside of the constructor\.$#'
|
||||
identifier: property.readOnlyAssignNotInConstructor
|
||||
count: 1
|
||||
path: src/Service/SessionIssuer.php
|
||||
|
||||
-
|
||||
message: '#^Class App\\Service\\LoginManager has an uninitialized readonly property \$nonceCache\. Assign it in the constructor\.$#'
|
||||
identifier: property.uninitializedReadonly
|
||||
count: 1
|
||||
path: src/Service/LoginManager.php
|
||||
|
||||
@@ -486,42 +516,18 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Unit/Command/GenerateBackupCodesCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/ConfigBagRemoteUserTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to static method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with null will always evaluate to true\.$#'
|
||||
identifier: staticMethod.alreadyNarrowedType
|
||||
count: 2
|
||||
path: tests/Unit/Enum/ScopeTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/AcceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/AllowListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Class Symfony\\Component\\RateLimiter\\Exception\\ReserveNotSupportedException constructor invoked with 0 parameters, 1\-3 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 2
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/InterceptListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
@@ -582,12 +588,6 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/LoginListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
@@ -642,12 +642,6 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/PublicAccessListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
@@ -702,12 +696,6 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Listener/RejectListenerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Symfony\\Component\\RateLimiter\\LimiterInterface@anonymous/tests/Support/ListenerTestHelper\.php\:105\:\:consume\(\) overrides method Symfony\\Component\\RateLimiter\\LimiterInterface\:\:consume\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
@@ -768,12 +756,6 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Unit/Service/BackupCodeManagerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Service/BackupCodeManagerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method App\\Service\\BackupCodeInterface\:\:method\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -798,24 +780,12 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Unit/Service/LoginManagerTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Service/LoginManagerTest.php
|
||||
|
||||
-
|
||||
message: '#^Class class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22 has an uninitialized readonly property \$config\. Assign it in the constructor\.$#'
|
||||
identifier: property.uninitializedReadonly
|
||||
count: 1
|
||||
path: tests/Unit/Trait/GetTotpTraitTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Trait/GetTotpTraitTest.php
|
||||
|
||||
-
|
||||
message: '#^Readonly property class@anonymous/tests/Unit/Trait/GetTotpTraitTest\.php\:22\:\:\$config is assigned outside of the constructor\.$#'
|
||||
identifier: property.readOnlyAssignNotInConstructor
|
||||
@@ -954,12 +924,6 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Unit/Trait/MakeNonceTraitTest.php
|
||||
|
||||
-
|
||||
message: '#^Method Psr\\Clock\\ClockInterface@anonymous/tests/Support/TotpTestHelper\.php\:33\:\:now\(\) overrides method Psr\\Clock\\ClockInterface\:\:now\(\) but is missing the \#\[\\Override\] attribute\.$#'
|
||||
identifier: method.missingOverride
|
||||
count: 1
|
||||
path: tests/Unit/Trait/StringTraitTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to function method_exists\(\) with ''Symfony\\\\Component\\\\Dotenv\\\\Dotenv'' and ''bootEnv'' will always evaluate to false\.$#'
|
||||
identifier: function.impossibleType
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
</include>
|
||||
|
||||
<deprecationTrigger>
|
||||
<method>Doctrine\Deprecations\Deprecation::trigger</method>
|
||||
<method>Doctrine\Deprecations\Deprecation::delegateTriggerToBackend</method>
|
||||
<function>trigger_deprecation</function>
|
||||
</deprecationTrigger>
|
||||
</source>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\CacheWarmer;
|
||||
|
||||
use App\Exception\PasskeyConfigurationException;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use Override;
|
||||
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
|
||||
|
||||
/**
|
||||
* Fails the build (or container start) when passkeys are enabled in a
|
||||
* configuration that cannot support them.
|
||||
*
|
||||
* `docker/entrypoint.sh` runs `cache:warmup` on every production boot with the
|
||||
* real environment already injected, so a misconfiguration is caught while the
|
||||
* container is starting — the deployment aborts — rather than surfacing later as
|
||||
* a passkey button that silently never works.
|
||||
*
|
||||
* The warmer is **not** optional: an optional warmer may be skipped, which would
|
||||
* let a bad configuration through.
|
||||
*/
|
||||
final readonly class PasskeyConfigurationWarmer implements CacheWarmerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private PasskeyPolicyInterface $passkeyPolicy,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*
|
||||
* @throws PasskeyConfigurationException when passkeys are enabled but unusable
|
||||
*/
|
||||
#[Override]
|
||||
public function warmUp(string $cacheDir, ?string $buildDir = null): array
|
||||
{
|
||||
$this->passkeyPolicy->assertConfigurationIsUsable();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Never optional: skipping this warmer would defeat its entire purpose.
|
||||
*/
|
||||
#[Override]
|
||||
public function isOptional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App;
|
||||
|
||||
use App\Enum\RemoteUserMode;
|
||||
use App\Enum\UserVerification;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -23,6 +24,14 @@ final readonly class ConfigBag
|
||||
private string $remoteUserStatic;
|
||||
/** @var array<string,string> */
|
||||
private array $remoteUserMap;
|
||||
private string $title;
|
||||
private bool $passkeyEnabled;
|
||||
private string $passkeyRpName;
|
||||
private UserVerification $passkeyUserVerification;
|
||||
private int $passkeyTimeout;
|
||||
|
||||
/** Passkey ceremony timeout in milliseconds (WebAuthn default). */
|
||||
private const int DEFAULT_PASSKEY_TIMEOUT = 60000;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
@@ -38,6 +47,11 @@ final readonly class ConfigBag
|
||||
#[Autowire('%app.remote_user%')] string $remoteUserMode,
|
||||
#[Autowire('%app.remote_user_static%')] string $remoteUserStatic,
|
||||
#[Autowire('%app.remote_user_map%')] string $remoteUserMap,
|
||||
#[Autowire('%app.title%')] string $title = 'Pre-Authentication System',
|
||||
#[Autowire('%app.passkey_enabled%')] bool $passkeyEnabled = false,
|
||||
#[Autowire('%app.passkey_rp_name%')] string $passkeyRpName = '',
|
||||
#[Autowire('%app.passkey_user_verification%')] string $passkeyUserVerification = 'required',
|
||||
#[Autowire('%app.passkey_timeout%')] int $passkeyTimeout = self::DEFAULT_PASSKEY_TIMEOUT,
|
||||
) {
|
||||
$this->clock = $clock;
|
||||
$this->cookieTtl = $cookieTtl;
|
||||
@@ -51,6 +65,11 @@ final readonly class ConfigBag
|
||||
$this->remoteUserMode = RemoteUserMode::tryFrom($remoteUserMode) ?? RemoteUserMode::Session;
|
||||
$this->remoteUserStatic = $remoteUserStatic;
|
||||
$this->remoteUserMap = $this->parseUserMap($remoteUserMap);
|
||||
$this->title = $title;
|
||||
$this->passkeyEnabled = $passkeyEnabled;
|
||||
$this->passkeyRpName = $passkeyRpName;
|
||||
$this->passkeyUserVerification = UserVerification::fromConfig($passkeyUserVerification);
|
||||
$this->passkeyTimeout = $passkeyTimeout > 0 ? $passkeyTimeout : self::DEFAULT_PASSKEY_TIMEOUT;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,4 +151,39 @@ final readonly class ConfigBag
|
||||
{
|
||||
return $this->remoteUserMap;
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the passkey feature is switched on by configuration.
|
||||
*
|
||||
* This says nothing about whether the configuration is *usable* — that is
|
||||
* {@see Service\PasskeyPolicyInterface::isEnabled()}, which also
|
||||
* requires the central-auth prerequisite (D1).
|
||||
*/
|
||||
public function passkeyEnabled(): bool
|
||||
{
|
||||
return $this->passkeyEnabled;
|
||||
}
|
||||
|
||||
/** Relying-party name shown in the authenticator prompt; blank falls back to the title. */
|
||||
public function passkeyRpName(): string
|
||||
{
|
||||
return $this->passkeyRpName;
|
||||
}
|
||||
|
||||
/** User-verification requirement; an unrecognised value falls back to `required`. */
|
||||
public function passkeyUserVerification(): string
|
||||
{
|
||||
return $this->passkeyUserVerification->value;
|
||||
}
|
||||
|
||||
/** Ceremony timeout in milliseconds. */
|
||||
public function passkeyTimeout(): int
|
||||
{
|
||||
return $this->passkeyTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Webauthn\CredentialRecord;
|
||||
|
||||
/**
|
||||
* A stored passkey: the WebAuthn credential plus the application metadata that
|
||||
* ties it to a visitor of this gateway.
|
||||
*
|
||||
* The credential itself carries the public key and signature counter; the
|
||||
* metadata here records *whose* passkey it is and when it was used. The identity
|
||||
* is deliberately read from this record on assertion rather than from the value
|
||||
* the client returns, which is never trusted.
|
||||
*/
|
||||
final readonly class PasskeyCredential
|
||||
{
|
||||
public function __construct(
|
||||
public CredentialRecord $record,
|
||||
/** The session id this passkey authenticates, as typed at registration. */
|
||||
public string $identity,
|
||||
/** Operator-facing description, shown so a user can tell their keys apart. */
|
||||
public string $label,
|
||||
public DateTimeImmutable $createdAt,
|
||||
public ?DateTimeImmutable $lastUsedAt = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy of this credential with the signature counter and last-used time
|
||||
* refreshed after a successful assertion.
|
||||
*
|
||||
* The counter is only ever observed, never enforced — many passkeys report a
|
||||
* constant zero, so treating it as a clone signal would lock users out.
|
||||
*/
|
||||
public function withUsage(CredentialRecord $updated, DateTimeImmutable $usedAt): self
|
||||
{
|
||||
return new self(
|
||||
$updated,
|
||||
$this->identity,
|
||||
$this->label,
|
||||
$this->createdAt,
|
||||
$usedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* User-verification requirement passed to the authenticator at passkey time.
|
||||
*
|
||||
* Mirrors the WebAuthn `userVerification` option without leaking the library's
|
||||
* constants into application configuration.
|
||||
*/
|
||||
enum UserVerification: string
|
||||
{
|
||||
/** Require a biometric/PIN check (default). */
|
||||
case Required = 'required';
|
||||
|
||||
/** Ask for it, but allow a plain user-presence tap to succeed. */
|
||||
case Preferred = 'preferred';
|
||||
|
||||
/** Never prompt for verification; presence alone is enough. */
|
||||
case Discouraged = 'discouraged';
|
||||
|
||||
/**
|
||||
* Parse a configured value, falling back to the safest option.
|
||||
*
|
||||
* An unrecognised value must never silently weaken the requirement, so the
|
||||
* fallback is the strictest case rather than the most permissive one.
|
||||
*/
|
||||
public static function fromConfig(string $value): self
|
||||
{
|
||||
return self::tryFrom(trim($value)) ?? self::Required;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thrown when passkeys are enabled in a configuration that cannot support them.
|
||||
*
|
||||
* This is deliberately fatal: the alternative is a feature that appears to be
|
||||
* switched on but cannot complete a single ceremony, which surfaces to the user
|
||||
* as "my passkey stopped working" rather than as a deployment error.
|
||||
*
|
||||
* Raised during cache warm-up so that a misconfigured container fails to start
|
||||
* instead of failing later, in a browser.
|
||||
*/
|
||||
final class PasskeyConfigurationException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Listener;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Enum\Scope;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use App\Service\SessionIssuerInterface;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
|
||||
|
||||
/**
|
||||
* Serves the passkey ceremonies.
|
||||
*
|
||||
* **Priority 70 — the whole design turns on this number.**
|
||||
*
|
||||
* - *After* `RejectListener` (77), so a rate-limited IP never reaches this code.
|
||||
* Passkeys cannot be used to sidestep a lockout; that is decision D3.
|
||||
* - *Before* `LoginListener` (66), which is essential rather than tidy:
|
||||
* `LoginListener` treats **any** POST to the auth subdomain as a login attempt,
|
||||
* and a ceremony `finish` body has no `username`/`totp`, so `Payload::load()`
|
||||
* returns null and the request would be scored as a failed login — burning a
|
||||
* rate-limit token for every legitimate passkey login.
|
||||
*
|
||||
* **Every** request carrying the dispatch header gets a response, including
|
||||
* malformed ones. Falling through would let `InterceptListener` render HTML to a
|
||||
* `fetch()` caller.
|
||||
*/
|
||||
final readonly class PasskeyListener
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use MakeNonceTrait;
|
||||
use StringTrait;
|
||||
|
||||
/**
|
||||
* Marks a request as a ceremony call, and its value selects the operation.
|
||||
*
|
||||
* A distinct header rather than overloading `X-Preauth`: that one carries a
|
||||
* base64url `Payload` and is parsed as such.
|
||||
*/
|
||||
public const string HEADER = 'X-Preauth-Passkey';
|
||||
|
||||
/** Marks a response as ceremony output, so the caching policy can see it. */
|
||||
public const string CEREMONY_MARKER = 'X-Preauth-Ceremony';
|
||||
|
||||
private const string BEGIN_LOGIN = 'login-begin';
|
||||
|
||||
private const string FINISH_LOGIN = 'login-finish';
|
||||
|
||||
private const string BEGIN_REGISTER = 'register-begin';
|
||||
|
||||
private const string FINISH_REGISTER = 'register-finish';
|
||||
|
||||
private RateLimiterFactoryInterface $beginLimiter;
|
||||
|
||||
private RateLimiterFactoryInterface $loginLimiter;
|
||||
|
||||
public function __construct(
|
||||
#[Target('passkey_begin_burst')] RateLimiterFactoryInterface $beginLimiter,
|
||||
#[Target('login_limiter')] RateLimiterFactoryInterface $loginLimiter,
|
||||
#[Target('sessionCache')] private CacheItemPoolInterface $sessionCache,
|
||||
private PasskeyInterface $passkeys,
|
||||
private PasskeyPolicyInterface $policy,
|
||||
private SessionIssuerInterface $sessionIssuer,
|
||||
private DomainInterface $domainManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
$this->beginLimiter = $beginLimiter;
|
||||
$this->loginLimiter = $loginLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[AsEventListener(priority: 70)]
|
||||
public function onKernelRequest(RequestEvent $event): void
|
||||
{
|
||||
$request = $event->getRequest();
|
||||
$operation = $request->headers->get(self::HEADER);
|
||||
|
||||
if (null === $operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Ceremonies exist only on the auth subdomain. Elsewhere the header is
|
||||
* ignored entirely, so this listener cannot be used to probe other hosts. */
|
||||
if ($this->domainManager->getAuthSubdomain() !== $request->getHost()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$event->setResponse($this->dispatch($operation, $request));
|
||||
}
|
||||
|
||||
private function dispatch(string $operation, Request $request): Response
|
||||
{
|
||||
/* not available => behave as if the feature does not exist */
|
||||
if (!$this->policy->isAvailableFor($request)) {
|
||||
return $this->ceremonyResponse($this->error('Passkeys are not available.', $request));
|
||||
}
|
||||
|
||||
return $this->ceremonyResponse(match ($operation) {
|
||||
self::BEGIN_LOGIN => $this->beginLogin($request),
|
||||
self::FINISH_LOGIN => $this->finishLogin($request),
|
||||
self::BEGIN_REGISTER => $this->beginRegistration($request),
|
||||
self::FINISH_REGISTER => $this->finishRegistration($request),
|
||||
default => $this->error('Unknown passkey operation.', $request),
|
||||
});
|
||||
}
|
||||
|
||||
private function beginLogin(Request $request): Response
|
||||
{
|
||||
if ($limit = $this->beginBurstExceeded($request)) {
|
||||
return $limit;
|
||||
}
|
||||
|
||||
return $this->json($this->passkeys->beginLogin());
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration is only offered to someone who already authenticated: the
|
||||
* identity comes from the live session, never from the request body, so a
|
||||
* caller cannot register a passkey for an identity it does not hold.
|
||||
*/
|
||||
private function beginRegistration(Request $request): Response
|
||||
{
|
||||
$identity = $this->identityFromRequest($request);
|
||||
if (null === $identity) {
|
||||
return $this->error('Registration requires a completed login.', $request);
|
||||
}
|
||||
|
||||
if ($limit = $this->beginBurstExceeded($request)) {
|
||||
return $limit;
|
||||
}
|
||||
|
||||
return $this->json($this->passkeys->beginRegistration($identity));
|
||||
}
|
||||
|
||||
private function finishLogin(Request $request): Response
|
||||
{
|
||||
$credential = $this->passkeys->finishLogin($this->body($request));
|
||||
|
||||
if (null === $credential) {
|
||||
/* A failed ceremony consumes the same budget as a wrong TOTP code
|
||||
* (D3), so passkey guesses cannot outpace code guesses. */
|
||||
return $this->failure($request);
|
||||
}
|
||||
|
||||
$this->logger->debug("passkey login succeeded for: {$credential->identity}");
|
||||
|
||||
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
|
||||
}
|
||||
|
||||
private function finishRegistration(Request $request): Response
|
||||
{
|
||||
$credential = $this->passkeys->finishRegistration($this->body($request));
|
||||
|
||||
if (null === $credential) {
|
||||
return $this->failure($request);
|
||||
}
|
||||
|
||||
$this->logger->debug("passkey registered for: {$credential->identity}");
|
||||
|
||||
return $this->sessionIssuer->issue($credential->identity, Scope::Cookie, $request, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity of an already-authenticated caller, for registration.
|
||||
*
|
||||
* Read from the live session cookie, so `register-begin` is reachable only by
|
||||
* someone who has just passed the TOTP check. Null when there is no session.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function identityFromRequest(Request $request): ?string
|
||||
{
|
||||
$cookie = $request->cookies->get($this->sessionCookieName($this->domainManager));
|
||||
if (!\is_string($cookie) || '' === $cookie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$item = $this->sessionCache->getItem($this->makeCacheKey("cookie_$cookie"));
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$identity = $item->get();
|
||||
|
||||
return \is_string($identity) && '' !== $identity ? $identity : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds how many ceremonies one caller can start.
|
||||
*
|
||||
* A resource guard, not the login budget: a legitimate `begin` must not spend
|
||||
* failure budget, but an unbounded `begin` could fill the ceremony cache.
|
||||
*/
|
||||
private function beginBurstExceeded(Request $request): ?Response
|
||||
{
|
||||
$limit = $this->beginLimiter->create((string) $request->getClientIp())->consume(1);
|
||||
if ($limit->isAccepted()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$retryAfter = max(1, $limit->getRetryAfter()->getTimestamp() - time());
|
||||
$this->logger->debug("passkey begin rate-limited: {$request->getClientIp()}");
|
||||
|
||||
$response = $this->error('Too many passkey attempts, please slow down.', $request);
|
||||
$response->setStatusCode(Response::HTTP_TOO_MANY_REQUESTS);
|
||||
$response->headers->set('Retry-After', (string) $retryAfter);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed ceremony is indistinguishable from a wrong TOTP code, and spends
|
||||
* the same shared budget — including the same 418/429 outcome when exhausted.
|
||||
*/
|
||||
private function failure(Request $request): Response
|
||||
{
|
||||
$limited = $this->loginLimiter
|
||||
->create((string) $request->getClientIp())
|
||||
->consume(1)
|
||||
->getRemainingTokens() < 1;
|
||||
|
||||
$this->logger->debug("passkey ceremony failed for: {$request->getClientIp()}");
|
||||
|
||||
if ($limited) {
|
||||
$response = $this->error(
|
||||
$this->config->teapot() ? $this->config->teapotTitle() : $this->config->tooManyTitle(),
|
||||
$request,
|
||||
);
|
||||
$response->setStatusCode(
|
||||
$this->config->teapot() ? Response::HTTP_I_AM_A_TEAPOT : Response::HTTP_TOO_MANY_REQUESTS,
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response = $this->error($this->config->errorMessage(), $request);
|
||||
$response->setStatusCode(Response::HTTP_UNAUTHORIZED);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a reply as ceremony output so the no-store policy can find it.
|
||||
*
|
||||
* These are the only browser-facing 2xx responses this application produces,
|
||||
* and `SecurityHeadersListener` otherwise assumes any 2xx is consumed by
|
||||
* `forward_auth` and leaves it cacheable.
|
||||
*/
|
||||
private function ceremonyResponse(Response $response): Response
|
||||
{
|
||||
$response->headers->set(self::CEREMONY_MARKER, '1');
|
||||
$response->headers->set('Content-Type', 'application/json');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON error carrying a fresh nonce and the same shape the login page
|
||||
* expects, so the caller can fall back to the TOTP form without a reload.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function error(string $message, Request $request): Response
|
||||
{
|
||||
return new Response(
|
||||
(string) json_encode([
|
||||
'message' => $message,
|
||||
'nonce' => $this->makeNonce(),
|
||||
'post' => $this->domainManager->getAuthSubdomain() === $request->getHost(),
|
||||
'username' => '',
|
||||
]),
|
||||
Response::HTTP_UNAUTHORIZED,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function json(array $payload): Response
|
||||
{
|
||||
return new Response((string) json_encode($payload), Response::HTTP_OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function body(Request $request): array
|
||||
{
|
||||
$raw = $request->getContent();
|
||||
if ('' === $raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
return \is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Listener;
|
||||
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||
|
||||
/**
|
||||
@@ -18,6 +20,7 @@ final readonly class SecurityHeadersListener
|
||||
{
|
||||
public function __construct(
|
||||
private DomainInterface $domainManager,
|
||||
private PasskeyPolicyInterface $passkeyPolicy,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -55,7 +58,14 @@ final readonly class SecurityHeadersListener
|
||||
$inlineScript = $this->domainManager->getAuthSubdomain() !== $event->getRequest()->getHost();
|
||||
$csp = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';";
|
||||
|
||||
if ($inlineScript) {
|
||||
/* `publickey-credentials-get`/`-create` do NOT fall back to default-src,
|
||||
* so without these directives the browser refuses the ceremony even
|
||||
* though the script itself is allowed to run. `connect-src 'self'` is
|
||||
* needed in both modes here, because the passkey flow always talks to
|
||||
* the server with fetch(). */
|
||||
if ($this->passkeyPolicy->isAvailableFor($event->getRequest())) {
|
||||
$csp .= " connect-src 'self'; publickey-credentials-get 'self'; publickey-credentials-create 'self';";
|
||||
} elseif ($inlineScript) {
|
||||
$csp .= " connect-src 'self';";
|
||||
}
|
||||
|
||||
@@ -75,13 +85,31 @@ final readonly class SecurityHeadersListener
|
||||
* access ("already authenticated" or public) are consumed by the
|
||||
* reverse proxy's forward_auth check before reaching the browser,
|
||||
* and the protected service's own cache headers must remain
|
||||
* untouched. */
|
||||
* untouched.
|
||||
*
|
||||
* A ceremony reply is the exception that proves the rule: it is a 2xx
|
||||
* that goes straight to the browser, because the auth subdomain has no
|
||||
* forward_auth in front of it. Left alone it would be cacheable, so a
|
||||
* browser could replay a stale challenge. `PasskeyListener` marks those
|
||||
* responses and the marker is stripped here. */
|
||||
if (!$response->isSuccessful()) {
|
||||
$headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0');
|
||||
$headers->set('Pragma', 'no-cache');
|
||||
$headers->set('Expires', '0');
|
||||
$headers->set('Surrogate-Control', 'no-store');
|
||||
$headers->set('Vary', '*');
|
||||
$this->applyNoStore($headers);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($headers->has(PasskeyListener::CEREMONY_MARKER)) {
|
||||
$headers->remove(PasskeyListener::CEREMONY_MARKER);
|
||||
$this->applyNoStore($headers);
|
||||
}
|
||||
}
|
||||
|
||||
private function applyNoStore(ResponseHeaderBag $headers): void
|
||||
{
|
||||
$headers->set('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate, max-age=0, s-maxage=0');
|
||||
$headers->set('Pragma', 'no-cache');
|
||||
$headers->set('Expires', '0');
|
||||
$headers->set('Surrogate-Control', 'no-store');
|
||||
$headers->set('Vary', '*');
|
||||
}
|
||||
}
|
||||
|
||||
+32
-106
@@ -6,39 +6,38 @@ namespace App\Service;
|
||||
|
||||
use App\Data\Payload;
|
||||
use App\Enum\Scope;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\GetTotpTrait;
|
||||
use App\Trait\MakeNonceTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Override;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\Uid\Ulid;
|
||||
|
||||
/**
|
||||
* Authenticates a TOTP code (or backup code) and, on success, grants access.
|
||||
*
|
||||
* The "grant access" half now lives in {@see SessionIssuer} so the passkey
|
||||
* ceremony produces an identical response. This class keeps the part that is
|
||||
* genuinely specific to code-based login: verifying the code and enforcing the
|
||||
* single-use nonce.
|
||||
*/
|
||||
final readonly class LoginManager implements LoginInterface
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use GetTotpTrait;
|
||||
use MakeNonceTrait;
|
||||
use StringTrait;
|
||||
|
||||
private CacheItemPoolInterface $sessionCache;
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
public function __construct(
|
||||
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
|
||||
private BackupCodeInterface $backupCodeManager,
|
||||
private DomainInterface $domainManager,
|
||||
private SessionIssuerInterface $sessionIssuer,
|
||||
) {
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function checkToken(Payload $payload, Request $request): ?Response
|
||||
{
|
||||
/* when scope is IP but ip-access is disabled, scope is to be considered cookie */
|
||||
@@ -47,103 +46,30 @@ final readonly class LoginManager implements LoginInterface
|
||||
$payload->scope = Scope::Cookie;
|
||||
}
|
||||
|
||||
if ($this->getTotp()->verify($payload->token, null, 1)
|
||||
|| $this->backupCodeManager->verifyAndConsume($payload->token)
|
||||
if (!$this->getTotp()->verify($payload->token, null, 1)
|
||||
&& !$this->backupCodeManager->verifyAndConsume($payload->token)
|
||||
) {
|
||||
/* token is correct (TOTP or Backup) */
|
||||
|
||||
/* if server nonce is found and is valid */
|
||||
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
|
||||
if ($nonceItem->isHit() && $nonceItem->get()) {
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
|
||||
$this->nonceCache->save($nonceItem);
|
||||
|
||||
/* token authentication successful, grant access and set response */
|
||||
$cleanId = $this->makeCacheKey($payload->id);
|
||||
|
||||
/* if they just want this one page, return ok, to grant them access */
|
||||
$response = $this->authSuccessResponse($cleanId, $this->config);
|
||||
|
||||
if (Scope::None !== $payload->scope) {
|
||||
/* grant access based on the requested scope */
|
||||
if (Scope::Cookie === $payload->scope) {
|
||||
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
|
||||
} elseif (Scope::Ip === $payload->scope) {
|
||||
$this->setIp($cleanId, $request->getClientIp());
|
||||
}
|
||||
|
||||
if ($payload->json) {
|
||||
$contentType = 'application/json';
|
||||
$content = json_encode([
|
||||
'message' => 'Login successful',
|
||||
'nonce' => null,
|
||||
]);
|
||||
} else {
|
||||
$contentType = 'text/html';
|
||||
$content = "hi $cleanId, please reload";
|
||||
}
|
||||
|
||||
$location = $request->query->has('return')
|
||||
&& $this->domainManager->validReturn($request->query->get('return')) ?
|
||||
"{$request->query->get('return')}" :
|
||||
"{$request->getPathInfo()}{$request->getQueryString()}";
|
||||
|
||||
/* force redirect to use GET method (important when using central auth) */
|
||||
$response->setContent($content)
|
||||
->setStatusCode(Response::HTTP_SEE_OTHER)
|
||||
->headers->set('Location', $location);
|
||||
$response->headers->set('Content-Type', $contentType);
|
||||
}
|
||||
|
||||
$this->logger->debug("successful login for: $cleanId");
|
||||
|
||||
return $response;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/* token is correct (TOTP or Backup) */
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function setCookie(string $id, string $host): Cookie
|
||||
{
|
||||
/* successful auth with token, store session and set the cookie */
|
||||
$ulid = new Ulid();
|
||||
$sessionCookie = $this->sessionCache->getItem(
|
||||
$this->makeCacheKey("cookie_$ulid"),
|
||||
);
|
||||
if ($sessionCookie->isHit()) {
|
||||
/* it is supposed to be impossible to have collisions */
|
||||
$this->logger->error('aborting: ULID collision');
|
||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||
/* if server nonce is found and is valid */
|
||||
$nonceItem = $this->nonceCache->getItem($this->makeCacheKey($payload->nonce));
|
||||
if (!$nonceItem->isHit() || !$nonceItem->get()) {
|
||||
return null;
|
||||
}
|
||||
$sessionCookie->set($id);
|
||||
$sessionCookie->expiresAfter($this->config->cookieTtl());
|
||||
$this->sessionCache->save($sessionCookie);
|
||||
|
||||
return Cookie::create(
|
||||
name: $this->sessionCookieName($this->domainManager),
|
||||
value: $ulid->toString(),
|
||||
expire: time() + $this->config->cookieTtl(),
|
||||
path: '/',
|
||||
domain: $this->sessionCookieDomain($this->domainManager, $host),
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: Cookie::SAMESITE_STRICT,
|
||||
/* mark nonce as spent */
|
||||
$nonceItem->set(false); /* invalid */
|
||||
$nonceItem->expiresAfter(self::NONCE_TTL); /* keep briefly */
|
||||
$this->nonceCache->save($nonceItem);
|
||||
|
||||
return $this->sessionIssuer->issue(
|
||||
$payload->id,
|
||||
$payload->scope,
|
||||
$request,
|
||||
$payload->json,
|
||||
);
|
||||
}
|
||||
|
||||
/** @throws InvalidArgumentException */
|
||||
private function setIp(string $id, string $ip): void
|
||||
{
|
||||
/* successful auth with token, requested scope of ip (and ip access enabled) */
|
||||
$ipKey = $this->makeCacheKey("ip_$ip");
|
||||
|
||||
$sessionIp = $this->sessionCache->getItem($ipKey);
|
||||
$sessionIp->set($id);
|
||||
$sessionIp->expiresAfter($this->config->ipTtl());
|
||||
$this->sessionCache->save($sessionIp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Cose\Algorithm\Manager;
|
||||
use Cose\Algorithm\Signature\ECDSA\ES256;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
use Throwable;
|
||||
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
|
||||
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
|
||||
use Webauthn\AuthenticatorAssertionResponseValidator;
|
||||
use Webauthn\AuthenticatorAttestationResponseValidator;
|
||||
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\Denormalizer\WebauthnSerializerFactory;
|
||||
use Webauthn\Exception\InvalidDataException;
|
||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
|
||||
/**
|
||||
* Builds the WebAuthn collaborators, and is the one place that knows how they
|
||||
* are wired.
|
||||
*
|
||||
* Everything that touches webauthn-lib types goes through here, so a library
|
||||
* major version that renames or moves those types is a single-file change
|
||||
* instead of a hunt through the codebase.
|
||||
*
|
||||
* **Attestation is deliberately `none`.** The alternatives were measured and
|
||||
* rejected: 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 that postdate its cached blob. See
|
||||
* `docs/passkey-auth-subdomain-plan.md` §2.3 for the evidence, and `SECURITY.md`
|
||||
* for the conditions that would justify revisiting it.
|
||||
*/
|
||||
final readonly class PasskeyCeremonyFactory
|
||||
{
|
||||
private AttestationStatementSupportManager $attestationStatementSupportManager;
|
||||
|
||||
private PasskeyCounterChecker $counterChecker;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$manager = AttestationStatementSupportManager::create();
|
||||
$manager->add(NoneAttestationStatementSupport::create());
|
||||
$this->attestationStatementSupportManager = $manager;
|
||||
$this->counterChecker = new PasskeyCounterChecker();
|
||||
}
|
||||
|
||||
/**
|
||||
* The wire form of the ceremony options, ready to JSON-encode for the client.
|
||||
*
|
||||
* Goes through the serializer rather than `json_encode()`, because the
|
||||
* challenge is raw binary: `json_encode()` rejects it outright, and the
|
||||
* serializer base64url-encodes exactly the fields the browser expects. Using
|
||||
* one path here and another at verification time is how a challenge silently
|
||||
* stops matching, so both go through this factory.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function optionsAsArray(PublicKeyCredentialCreationOptions|PublicKeyCredentialRequestOptions $options): array
|
||||
{
|
||||
return $this->serializer()->normalize($options, 'json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator for the registration ceremony.
|
||||
*
|
||||
* The origins are passed in rather than read from a request, so the scheme
|
||||
* and host can only ever come from configuration. This is what makes D4
|
||||
* enforceable: an `http://` origin is never presented to the library as
|
||||
* acceptable, no matter how the request arrived at the container.
|
||||
*
|
||||
* @param string[] $allowedOrigins
|
||||
*/
|
||||
public function creationCeremonyValidator(array $allowedOrigins): AuthenticatorAttestationResponseValidator
|
||||
{
|
||||
return AuthenticatorAttestationResponseValidator::create(
|
||||
$this->ceremonyStepManagerFactory($allowedOrigins)->creationCeremony(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validator for the login (assertion) ceremony.
|
||||
*
|
||||
* @param string[] $allowedOrigins
|
||||
*/
|
||||
public function requestCeremonyValidator(array $allowedOrigins): AuthenticatorAssertionResponseValidator
|
||||
{
|
||||
return AuthenticatorAssertionResponseValidator::create(
|
||||
$this->ceremonyStepManagerFactory($allowedOrigins)->requestCeremony(),
|
||||
);
|
||||
}
|
||||
|
||||
public function counterChecker(): PasskeyCounterChecker
|
||||
{
|
||||
return $this->counterChecker;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceremony steps shared by both ceremonies.
|
||||
*
|
||||
* `setSecuredRelyingPartyId()` is deliberately never called: it is deprecated
|
||||
* in 5.2 and, more importantly, it is the escape hatch that would let an
|
||||
* `http://` origin through. Development uses real TLS instead (D4).
|
||||
*
|
||||
* @param string[] $allowedOrigins
|
||||
*/
|
||||
private function ceremonyStepManagerFactory(array $allowedOrigins): CeremonyStepManagerFactory
|
||||
{
|
||||
$factory = new CeremonyStepManagerFactory();
|
||||
$factory->setAllowedOrigins($allowedOrigins);
|
||||
$factory->setAlgorithmManager(Manager::create()->add(ES256::create()));
|
||||
$factory->setAttestationStatementSupportManager($this->attestationStatementSupportManager);
|
||||
/* replace the library default, which rejects the constant-zero counter
|
||||
* that synchronised passkeys report — see PasskeyCounterChecker */
|
||||
$factory->setCounterChecker($this->counterChecker);
|
||||
|
||||
return $factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* The serializer for every WebAuthn value: credential records, ceremony
|
||||
* options, and the client's response.
|
||||
*
|
||||
* This must be used instead of `json_encode()`. Options carry raw binary
|
||||
* (the challenge) which `json_encode()` rejects outright, and
|
||||
* `CredentialRecord` is not `JsonSerializable` at all — the serializer
|
||||
* base64url-encodes those fields and is required for a correct round-trip.
|
||||
*
|
||||
* The concrete `Serializer` is returned because the library's own return
|
||||
* type (`SerializerInterface`) only declares `serialize()`/`deserialize()`,
|
||||
* while this class also needs `normalize()`/`denormalize()`.
|
||||
*/
|
||||
public function serializer(): Serializer
|
||||
{
|
||||
$serializer = (new WebauthnSerializerFactory($this->attestationStatementSupportManager))->create();
|
||||
|
||||
/* the library's declared return type is the narrower interface, so this
|
||||
* narrows it back — failing loudly if a future version ever returns
|
||||
* something else, rather than erroring at the first ceremony */
|
||||
if (!$serializer instanceof Serializer) {
|
||||
throw new RuntimeException('Expected the WebAuthn serializer to be a '.Serializer::class.'.');
|
||||
}
|
||||
|
||||
return $serializer;
|
||||
}
|
||||
|
||||
public function attestationStatementSupportManager(): AttestationStatementSupportManager
|
||||
{
|
||||
return $this->attestationStatementSupportManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a credential record for storage.
|
||||
*
|
||||
* @throws InvalidDataException
|
||||
*/
|
||||
public function serializeCredential(CredentialRecord $record): string
|
||||
{
|
||||
return $this->serializer()->serialize($record, 'json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a credential record previously written by {@see serializeCredential()}.
|
||||
*
|
||||
* Returns null rather than throwing when the stored payload is unusable: a
|
||||
* corrupt entry must degrade to "this passkey is unavailable", never to a
|
||||
* 500 on the login page.
|
||||
*/
|
||||
public function deserializeCredential(string $json): ?CredentialRecord
|
||||
{
|
||||
try {
|
||||
return $this->serializer()->deserialize($json, CredentialRecord::class, 'json');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Trait\StringTrait;
|
||||
use DateTimeImmutable;
|
||||
use Exception;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
|
||||
/**
|
||||
* Short-lived server-side state for an in-flight WebAuthn ceremony.
|
||||
*
|
||||
* **The client's copy of the challenge is never trusted.** Starting a ceremony
|
||||
* issues the challenge *and* a separate opaque `ceremonyId`; only the
|
||||
* server-side record keyed by that id is authoritative at `finish`. A client
|
||||
* that swaps in its own challenge is therefore comparing against a value the
|
||||
* server never issued.
|
||||
*
|
||||
* **Single-use.** {@see consume()} deletes the record *before* the caller
|
||||
* verifies anything, so a failed or replayed `finish` cannot be retried against
|
||||
* the same challenge.
|
||||
*
|
||||
* **Deliberately not persisted.** This uses the `nonceCache` pool, which is APCu
|
||||
* and stays in memory. A ceremony that does not complete within its TTL *should*
|
||||
* evaporate — persisting it would only widen the replay window across restarts.
|
||||
*/
|
||||
final readonly class PasskeyCeremonyStore implements PasskeyCeremonyStoreInterface
|
||||
{
|
||||
use StringTrait;
|
||||
|
||||
private const string PREFIX = 'passkey_cer_';
|
||||
|
||||
/** Ceremonies live 5 minutes: a user has to interact with a biometric prompt. */
|
||||
public const int CEREMONY_TTL = 300;
|
||||
|
||||
/** 15 bytes fits neatly into a base64url string without padding. */
|
||||
private const int CEREMONY_ID_BYTES = 15;
|
||||
|
||||
/** 32-byte challenge, per WebAuthn's recommendation. */
|
||||
private const int CHALLENGE_BYTES = 32;
|
||||
|
||||
public function __construct(
|
||||
#[Target('nonceCache')] private CacheItemPoolInterface $nonceCache,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ceremonyId: string, challenge: string}
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function startLogin(): array
|
||||
{
|
||||
/* @var array{ceremonyId: string, challenge: string} */
|
||||
return $this->store(PasskeyCeremonyStoreInterface::TYPE_LOGIN, [
|
||||
'challenge' => random_bytes(self::CHALLENGE_BYTES),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ceremonyId: string, challenge: string, userHandle: string}
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function startRegistration(string $identity): array
|
||||
{
|
||||
/* @var array{ceremonyId: string, challenge: string, userHandle: string} */
|
||||
return $this->store(PasskeyCeremonyStoreInterface::TYPE_REGISTER, [
|
||||
'challenge' => random_bytes(self::CHALLENGE_BYTES),
|
||||
'identity' => $identity,
|
||||
/* Derived from the identity rather than taken from the client, so a
|
||||
* caller cannot register a credential against an identity it did not
|
||||
* authenticate as. Fixed length, stable per identity, and it does not
|
||||
* leak the label into the authenticator. */
|
||||
'userHandle' => hash('sha256', $identity, true),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and destroy a ceremony, returning its record.
|
||||
*
|
||||
* Returns null when the id is unknown, expired, already consumed, or was
|
||||
* started for the other ceremony type. The caller cannot distinguish these
|
||||
* cases, and does not need to.
|
||||
*
|
||||
* @return array{challenge: string, identity?: string, userHandle?: string}|null
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function consume(string $ceremonyId, string $expectedType): ?array
|
||||
{
|
||||
$key = $this->key($ceremonyId);
|
||||
$item = $this->nonceCache->getItem($key);
|
||||
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $item->get();
|
||||
|
||||
/* delete before validating: a malformed or replayed record must not be
|
||||
* usable again even if the checks below reject it */
|
||||
$this->nonceCache->deleteItem($key);
|
||||
|
||||
if (!\is_array($payload)
|
||||
|| ($payload['type'] ?? null) !== $expectedType
|
||||
|| !isset($payload['challenge'])
|
||||
|| !\is_string($payload['challenge'])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var array{challenge: string, identity?: string, userHandle?: string} $payload */
|
||||
$payload = array_filter(
|
||||
$payload,
|
||||
static fn (string $key): bool => \in_array($key, ['challenge', 'identity', 'userHandle'], true),
|
||||
\ARRAY_FILTER_USE_KEY,
|
||||
);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $payload
|
||||
*
|
||||
* @return array<string,string>
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function store(string $type, array $payload): array
|
||||
{
|
||||
$ceremonyId = rtrim(strtr(base64_encode(random_bytes(self::CEREMONY_ID_BYTES)), '+/', '-_'), '=');
|
||||
|
||||
$item = $this->nonceCache->getItem($this->key($ceremonyId));
|
||||
$item->set([
|
||||
'type' => $type,
|
||||
...$payload,
|
||||
'createdAt' => new DateTimeImmutable()->format(\DATE_ATOM),
|
||||
]);
|
||||
$item->expiresAfter(self::CEREMONY_TTL);
|
||||
$this->nonceCache->save($item);
|
||||
|
||||
return ['ceremonyId' => $ceremonyId, ...$payload];
|
||||
}
|
||||
|
||||
/**
|
||||
* The ceremony id is hashed rather than passed through `makeCacheKey()`,
|
||||
* because that sanitises the base64url alphabet into `_` and is therefore
|
||||
* not injective — two distinct ids could collide on one cache slot.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function key(string $ceremonyId): string
|
||||
{
|
||||
return $this->makeCacheKey(self::PREFIX.hash('sha256', $ceremonyId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
/**
|
||||
* Server-side storage for in-flight WebAuthn ceremonies.
|
||||
*
|
||||
* Kept separate from {@see PasskeyManager} so the security-critical properties —
|
||||
* the challenge is server-authoritative and single-use — are testable without
|
||||
* constructing a real authenticator response.
|
||||
*/
|
||||
interface PasskeyCeremonyStoreInterface
|
||||
{
|
||||
public const string TYPE_LOGIN = 'login';
|
||||
|
||||
public const string TYPE_REGISTER = 'register';
|
||||
|
||||
/**
|
||||
* Start a login (assertion) ceremony.
|
||||
*
|
||||
* @return array{ceremonyId: string, challenge: string}
|
||||
*/
|
||||
public function startLogin(): array;
|
||||
|
||||
/**
|
||||
* Start a registration (attestation) ceremony for an already-authenticated
|
||||
* identity.
|
||||
*
|
||||
* @return array{ceremonyId: string, challenge: string, userHandle: string}
|
||||
*/
|
||||
public function startRegistration(string $identity): array;
|
||||
|
||||
/**
|
||||
* Read and destroy a ceremony.
|
||||
*
|
||||
* Returns null when the id is unknown, expired, already consumed, or was
|
||||
* started for the other ceremony type.
|
||||
*
|
||||
* @return array{challenge: string, identity?: string, userHandle?: string}|null
|
||||
*/
|
||||
public function consume(string $ceremonyId, string $expectedType): ?array;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Override;
|
||||
use Webauthn\Counter\CounterChecker;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\Exception\CounterException;
|
||||
|
||||
/**
|
||||
* Signature-counter policy for passkey assertions.
|
||||
*
|
||||
* **Why this replaces the library default.** `ThrowExceptionIfInvalid` (the
|
||||
* default wired up by `CeremonyStepManagerFactory`) requires the reported
|
||||
* counter to be *strictly greater* than the stored one. That is wrong for the
|
||||
* passkeys this feature targets: platform passkeys are synchronised through the
|
||||
* OS keychain, and a synchronised authenticator reports a constant `0` forever
|
||||
* (the spec permits it, and the multi-device design effectively requires it).
|
||||
* Measured against the installed library:
|
||||
*
|
||||
* stored 0, reported 0 -> `ThrowExceptionIfInvalid` throws `CounterException`
|
||||
*
|
||||
* so with the default checker a brand-new synced passkey fails on its *first*
|
||||
* login — and only on real hardware, never in a unit test that increments the
|
||||
* counter. That is the worst possible failure shape, so the default is not used.
|
||||
*
|
||||
* **What is kept.** A counter that goes *backwards* still fails. That is the one
|
||||
* signal the counter can carry (a cloned authenticator replaying an older
|
||||
* assertion), and rejecting it costs nothing because a genuine synchronised
|
||||
* passkey only ever reports the same value or a larger one.
|
||||
*
|
||||
* Note this is defence in depth and not relied upon for security: a
|
||||
* synchronised passkey's counter carries no clone signal at all, which is why
|
||||
* `SECURITY.md` records that clone detection is explicitly not a property this
|
||||
* feature claims. The real protections are per-credential challenge binding,
|
||||
* origin/RP-ID checks, and the user-verification requirement.
|
||||
*/
|
||||
final readonly class PasskeyCounterChecker implements CounterChecker
|
||||
{
|
||||
/**
|
||||
* @throws CounterException when the reported counter moves backwards
|
||||
*/
|
||||
#[Override]
|
||||
public function check(CredentialRecord $credentialRecord, int $currentCounter): void
|
||||
{
|
||||
if ($currentCounter < $credentialRecord->counter) {
|
||||
throw CounterException::create($currentCounter, $credentialRecord->counter, 'The signature counter moved backwards, which can indicate a cloned authenticator.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\AppConstants;
|
||||
use App\Data\PasskeyCredential;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\StringTrait;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Webauthn\CredentialRecord;
|
||||
|
||||
/**
|
||||
* @see PasskeyCredentialStoreInterface
|
||||
*
|
||||
* **Persistence.** The pool is wrapped in a {@see MonitorCacheKeys}, exactly
|
||||
* as `LoginManager` and `BackupCodeManager` do. Without that wrapper the
|
||||
* credentials would live only in the APCu-side pool and disappear on the next
|
||||
* container restart, because `PersistCache::persist()` only flushes keys that a
|
||||
* monitor recorded. `PasskeyCredentialStoreTest` asserts visibility in the
|
||||
* underlying persistent pool, not just the wrapped one.
|
||||
*
|
||||
* **Layout.**
|
||||
* passkey_cred_<key(credentialId)> -> serialized credential + metadata
|
||||
* passkey_index -> credentialId => {identity, label, createdAt}
|
||||
*
|
||||
* The index exists so the login page can build `allowCredentials` without
|
||||
* enumerating the whole key space, and so a corrupt credential cannot make the
|
||||
* list disappear entirely.
|
||||
*/
|
||||
final readonly class PasskeyCredentialStore implements PasskeyCredentialStoreInterface
|
||||
{
|
||||
use StringTrait;
|
||||
|
||||
private const string PREFIX = 'passkey_cred_';
|
||||
private const string INDEX_KEY = 'passkey_index';
|
||||
|
||||
private MonitorCacheKeys $sessionCache;
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct(
|
||||
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
|
||||
private PasskeyCeremonyFactory $ceremonyFactory,
|
||||
) {
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function all(): array
|
||||
{
|
||||
$credentials = [];
|
||||
foreach ($this->credentialIds() as $credentialId) {
|
||||
$credential = $this->find($credentialId);
|
||||
if (null !== $credential) {
|
||||
$credentials[] = $credential;
|
||||
}
|
||||
}
|
||||
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function find(string $credentialId): ?PasskeyCredential
|
||||
{
|
||||
$item = $this->sessionCache->getItem($this->credentialKey($credentialId));
|
||||
if (!$item->isHit()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $item->get();
|
||||
if (!\is_array($payload)
|
||||
|| !isset($payload['record'], $payload['identity'], $payload['label'], $payload['createdAt'])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record = $this->ceremonyFactory->deserializeCredential((string) $payload['record']);
|
||||
if (null === $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* Guard against an index/record mismatch: the stored record decides which
|
||||
* credential id it answers to. */
|
||||
if (!hash_equals($record->publicKeyCredentialId, $credentialId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PasskeyCredential(
|
||||
$record,
|
||||
(string) $payload['identity'],
|
||||
(string) $payload['label'],
|
||||
new DateTimeImmutable((string) $payload['createdAt']),
|
||||
isset($payload['lastUsedAt']) ? new DateTimeImmutable((string) $payload['lastUsedAt']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function findByIdentity(string $identity): array
|
||||
{
|
||||
$matches = [];
|
||||
foreach ($this->all() as $credential) {
|
||||
if (hash_equals($credential->identity, $identity)) {
|
||||
$matches[] = $credential;
|
||||
}
|
||||
}
|
||||
|
||||
return $matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function save(PasskeyCredential $credential): void
|
||||
{
|
||||
$credentialId = $credential->record->publicKeyCredentialId;
|
||||
|
||||
$item = $this->sessionCache->getItem($this->credentialKey($credentialId));
|
||||
$item->set([
|
||||
'record' => $this->ceremonyFactory->serializeCredential($credential->record),
|
||||
'identity' => $credential->identity,
|
||||
'label' => $credential->label,
|
||||
'createdAt' => $credential->createdAt->format(\DATE_ATOM),
|
||||
'lastUsedAt' => $credential->lastUsedAt?->format(\DATE_ATOM),
|
||||
]);
|
||||
/* per PSR6, if no expiration is set, implementation may set a default,
|
||||
* we want this to keep forever, so a few hundred years should do it */
|
||||
$item->expiresAt($this->forever());
|
||||
$this->sessionCache->save($item);
|
||||
|
||||
$this->writeIndexEntry($credentialId, [
|
||||
'identity' => $credential->identity,
|
||||
'label' => $credential->label,
|
||||
'createdAt' => $credential->createdAt->format(\DATE_ATOM),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function updateUsage(CredentialRecord $record): void
|
||||
{
|
||||
$existing = $this->find($record->publicKeyCredentialId);
|
||||
if (null === $existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->save($existing->withUsage($record, new DateTimeImmutable()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function remove(string $credentialId): bool
|
||||
{
|
||||
$index = $this->readIndex();
|
||||
$existed = \array_key_exists($credentialId, $index);
|
||||
|
||||
unset($index[$credentialId]);
|
||||
$this->writeIndex($index);
|
||||
|
||||
return $this->sessionCache->deleteItem($this->credentialKey($credentialId)) || $existed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function count(): int
|
||||
{
|
||||
return \count($this->credentialIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential ids from the index only.
|
||||
*
|
||||
* Deliberately not `getKeys()`: keys are truncated to a fixed length by
|
||||
* `makeCacheKey()`, so enumerating them cannot reliably recover a full
|
||||
* credential id. The index stores the ids verbatim.
|
||||
*
|
||||
* @return string[]
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function credentialIds(): array
|
||||
{
|
||||
return array_keys($this->readIndex());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,array{identity: string, label: string, createdAt: string}>
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function readIndex(): array
|
||||
{
|
||||
$item = $this->sessionCache->getItem(self::INDEX_KEY);
|
||||
$index = $item->isHit() ? $item->get() : null;
|
||||
|
||||
return \is_array($index) ? $index : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,array{identity: string, label: string, createdAt: string}> $index
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function writeIndex(array $index): void
|
||||
{
|
||||
$item = $this->sessionCache->getItem(self::INDEX_KEY);
|
||||
$item->set($index);
|
||||
$item->expiresAt($this->forever());
|
||||
$this->sessionCache->save($item);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{identity: string, label: string, createdAt: string} $entry
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function writeIndexEntry(string $credentialId, array $entry): void
|
||||
{
|
||||
$index = $this->readIndex();
|
||||
$index[$credentialId] = $entry;
|
||||
$this->writeIndex($index);
|
||||
}
|
||||
|
||||
/** Credentials and the index are kept indefinitely; only removal clears them. */
|
||||
private function forever(): DateTimeImmutable
|
||||
{
|
||||
return DateTimeImmutable::createFromFormat('Y-m-d', AppConstants::FAR_FUTURE_DATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key for one credential id.
|
||||
*
|
||||
* The id is hashed rather than passed through `makeCacheKey()`, because that
|
||||
* sanitises the base64url alphabet into a single `_` and is therefore not
|
||||
* injective: "abc-def" and "abc_def" both become "abc_def", so two distinct
|
||||
* credentials could share one cache slot and one of them would silently
|
||||
* overwrite the other. A hash is injective for practical purposes and keeps
|
||||
* the key within the allowed character set.
|
||||
*/
|
||||
private function credentialKey(string $credentialId): string
|
||||
{
|
||||
return self::PREFIX.hash('sha256', $credentialId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Data\PasskeyCredential;
|
||||
use Webauthn\CredentialRecord;
|
||||
|
||||
/**
|
||||
* Persistence for registered passkeys, backed by the `sessionCache` pool so that
|
||||
* credentials survive a container restart the same way sessions do.
|
||||
*
|
||||
* The identity is stored alongside the credential and is authoritative on
|
||||
* assertion: the `userHandle` a client returns is attacker-controlled and is
|
||||
* compared for consistency but never used to decide who is logging in.
|
||||
*/
|
||||
interface PasskeyCredentialStoreInterface
|
||||
{
|
||||
/**
|
||||
* Every registered credential, for the login ceremony's `allowCredentials`.
|
||||
*
|
||||
* Credentials whose stored payload cannot be read are skipped rather than
|
||||
* failing the ceremony, so one bad entry cannot lock everyone out.
|
||||
*
|
||||
* @return PasskeyCredential[]
|
||||
*/
|
||||
public function all(): array;
|
||||
|
||||
/**
|
||||
* Look up a single credential by its raw credential id.
|
||||
*/
|
||||
public function find(string $credentialId): ?PasskeyCredential;
|
||||
|
||||
/**
|
||||
* Every credential belonging to one identity.
|
||||
*
|
||||
* @return PasskeyCredential[]
|
||||
*/
|
||||
public function findByIdentity(string $identity): array;
|
||||
|
||||
/**
|
||||
* Persist a newly registered credential.
|
||||
*/
|
||||
public function save(PasskeyCredential $credential): void;
|
||||
|
||||
/**
|
||||
* Record that a credential was just used, refreshing its counter.
|
||||
*/
|
||||
public function updateUsage(CredentialRecord $record): void;
|
||||
|
||||
/**
|
||||
* Forget a credential.
|
||||
*
|
||||
* @return bool true when a credential was removed
|
||||
*/
|
||||
public function remove(string $credentialId): bool;
|
||||
|
||||
public function count(): int;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Data\PasskeyCredential;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Both WebAuthn ceremonies, expressed without any library types in the
|
||||
* signature so callers never depend on webauthn-lib directly.
|
||||
*/
|
||||
interface PasskeyInterface
|
||||
{
|
||||
/**
|
||||
* Step 1 of the login ceremony: issue a challenge and the credential list.
|
||||
*
|
||||
* @return array{publicKey: mixed, ceremonyId: string}
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function beginLogin(): array;
|
||||
|
||||
/**
|
||||
* Step 3 of the login ceremony: verify an assertion.
|
||||
*
|
||||
* Null means "not authenticated", and the caller must not distinguish
|
||||
* between the possible causes.
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function finishLogin(array $body): ?PasskeyCredential;
|
||||
|
||||
/**
|
||||
* Step 1 of the registration ceremony. Only reachable once the caller has
|
||||
* already authenticated with a TOTP code.
|
||||
*
|
||||
* @return array{publicKey: mixed, ceremonyId: string}
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function beginRegistration(string $identity): array;
|
||||
|
||||
/**
|
||||
* Step 3 of the registration ceremony: verify an attestation and store the
|
||||
* credential.
|
||||
*
|
||||
* Null means "not registered".
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function finishRegistration(array $body): ?PasskeyCredential;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Data\PasskeyCredential;
|
||||
use App\Service\PasskeyCeremonyStoreInterface as Ceremonies;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use ParagonIE\ConstantTime\Base64UrlSafe;
|
||||
use Throwable;
|
||||
use Webauthn\AuthenticatorAssertionResponse;
|
||||
use Webauthn\AuthenticatorAttestationResponse;
|
||||
use Webauthn\AuthenticatorSelectionCriteria;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\PublicKeyCredential;
|
||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||
use Webauthn\PublicKeyCredentialDescriptor;
|
||||
use Webauthn\PublicKeyCredentialParameters;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
use Webauthn\PublicKeyCredentialRpEntity;
|
||||
use Webauthn\PublicKeyCredentialUserEntity;
|
||||
|
||||
/**
|
||||
* Owns both WebAuthn ceremonies.
|
||||
*
|
||||
* **Library containment.** Every `Webauthn\*` type used by the application is
|
||||
* referenced in this file and {@see PasskeyCeremonyFactory}, so a v6 rename
|
||||
* touches two files rather than the whole codebase.
|
||||
*
|
||||
* **What is never trusted.** The challenge (server-authoritative, single-use —
|
||||
* see {@see PasskeyCeremonyStore}), the `userHandle` (read from the stored
|
||||
* credential, never from the client), and the credential id (it selects which
|
||||
* stored record to verify against). The library performs the cryptographic
|
||||
* verification of origin, RP ID hash, challenge, user presence/verification and
|
||||
* signature; this class supplies the inputs and interprets the outcome.
|
||||
*/
|
||||
final readonly class PasskeyManager implements PasskeyInterface
|
||||
{
|
||||
/** ES256 is mandatory for WebAuthn; other algorithms buy nothing here. */
|
||||
private const int COSE_ALGORITHM_ES256 = -7;
|
||||
|
||||
public function __construct(
|
||||
private PasskeyPolicyInterface $policy,
|
||||
private Ceremonies $ceremonies,
|
||||
private PasskeyCredentialStoreInterface $credentials,
|
||||
private PasskeyCeremonyFactory $factory,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the login ceremony.
|
||||
*
|
||||
* @return array{publicKey: mixed, ceremonyId: string}
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[Override]
|
||||
public function beginLogin(): array
|
||||
{
|
||||
$ceremony = $this->ceremonies->startLogin();
|
||||
|
||||
$options = new PublicKeyCredentialRequestOptions(
|
||||
$ceremony['challenge'],
|
||||
$this->policy->rpId(),
|
||||
$this->credentialDescriptors(),
|
||||
$this->policy->userVerification(),
|
||||
$this->policy->timeout(),
|
||||
);
|
||||
|
||||
return [
|
||||
'publicKey' => $this->factory->optionsAsArray($options),
|
||||
'ceremonyId' => $ceremony['ceremonyId'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of the login ceremony: verify the assertion and report the identity.
|
||||
*
|
||||
* Returns null on any failure — the caller must not be able to distinguish
|
||||
* "unknown credential" from "bad signature" from "wrong origin", or the
|
||||
* endpoint becomes an oracle for credential enumeration.
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[Override]
|
||||
public function finishLogin(array $body): ?PasskeyCredential
|
||||
{
|
||||
$ceremonyId = $this->stringOrNull($body['ceremonyId'] ?? null);
|
||||
$credentialJson = $body['credential'] ?? null;
|
||||
|
||||
if (null === $ceremonyId || !\is_array($credentialJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ceremony = $this->ceremonies->consume($ceremonyId, Ceremonies::TYPE_LOGIN);
|
||||
if (null === $ceremony) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$publicKeyCredential = $this->deserializeCredential($credentialJson);
|
||||
if (!$publicKeyCredential instanceof PublicKeyCredential
|
||||
|| !$publicKeyCredential->response instanceof AuthenticatorAssertionResponse
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* the credential id selects the record: an attacker cannot nominate a
|
||||
* different credential than the one they hold the key for */
|
||||
$stored = $this->credentials->find($publicKeyCredential->rawId);
|
||||
if (null === $stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$updated = $this->factory
|
||||
->requestCeremonyValidator($this->policy->allowedOrigins())
|
||||
->check(
|
||||
$stored->record,
|
||||
$publicKeyCredential->response,
|
||||
$this->requestOptions($ceremony['challenge']),
|
||||
$this->policy->authSubdomain(),
|
||||
$stored->record->userHandle,
|
||||
);
|
||||
} catch (Throwable) {
|
||||
/* deliberately not surfaced: see the note above */
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->credentials->updateUsage($updated);
|
||||
|
||||
return $stored->withUsage($updated, new DateTimeImmutable());
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 of the registration ceremony. Only reachable once the caller has
|
||||
* already proven possession of the TOTP secret (see the flow in the plan).
|
||||
*
|
||||
* @return array{publicKey: mixed, ceremonyId: string}
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[Override]
|
||||
public function beginRegistration(string $identity): array
|
||||
{
|
||||
$ceremony = $this->ceremonies->startRegistration($identity);
|
||||
|
||||
return [
|
||||
'publicKey' => $this->factory->optionsAsArray($this->creationOptions(
|
||||
$ceremony['challenge'],
|
||||
$identity,
|
||||
$ceremony['userHandle'],
|
||||
)),
|
||||
'ceremonyId' => $ceremony['ceremonyId'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of the registration ceremony: verify the attestation and persist
|
||||
* the credential under the identity that was authenticated in step 1.
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
#[Override]
|
||||
public function finishRegistration(array $body): ?PasskeyCredential
|
||||
{
|
||||
$ceremonyId = $this->stringOrNull($body['ceremonyId'] ?? null);
|
||||
$credentialJson = $body['credential'] ?? null;
|
||||
|
||||
if (null === $ceremonyId || !\is_array($credentialJson)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ceremony = $this->ceremonies->consume($ceremonyId, Ceremonies::TYPE_REGISTER);
|
||||
if (null === $ceremony || !isset($ceremony['identity'], $ceremony['userHandle'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$publicKeyCredential = $this->deserializeCredential($credentialJson);
|
||||
if (!$publicKeyCredential instanceof PublicKeyCredential
|
||||
|| !$publicKeyCredential->response instanceof AuthenticatorAttestationResponse
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$record = $this->factory
|
||||
->creationCeremonyValidator($this->policy->allowedOrigins())
|
||||
->check(
|
||||
$publicKeyCredential->response,
|
||||
$this->creationOptions(
|
||||
$ceremony['challenge'],
|
||||
$ceremony['identity'],
|
||||
$ceremony['userHandle'],
|
||||
),
|
||||
$this->policy->authSubdomain(),
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$credential = new PasskeyCredential(
|
||||
$record,
|
||||
$ceremony['identity'],
|
||||
$this->labelFor($record),
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
$this->credentials->save($credential);
|
||||
|
||||
return $credential;
|
||||
}
|
||||
|
||||
private function requestOptions(string $challenge): PublicKeyCredentialRequestOptions
|
||||
{
|
||||
return new PublicKeyCredentialRequestOptions(
|
||||
$challenge,
|
||||
$this->policy->rpId(),
|
||||
$this->credentialDescriptors(),
|
||||
/* userVerification must be present for the library to enforce the
|
||||
* user-verified flag; it is not re-read from the client request */
|
||||
$this->policy->userVerification(),
|
||||
$this->policy->timeout(),
|
||||
);
|
||||
}
|
||||
|
||||
private function creationOptions(string $challenge, string $identity, string $userHandle): PublicKeyCredentialCreationOptions
|
||||
{
|
||||
return new PublicKeyCredentialCreationOptions(
|
||||
new PublicKeyCredentialRpEntity($this->policy->rpName(), $this->policy->rpId()),
|
||||
new PublicKeyCredentialUserEntity($identity, $userHandle, $identity),
|
||||
$challenge,
|
||||
[PublicKeyCredentialParameters::create('public-key', self::COSE_ALGORITHM_ES256)],
|
||||
new AuthenticatorSelectionCriteria(
|
||||
/* a passkey for this device, which is what the "register this
|
||||
* device" checkbox promises */
|
||||
AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_PLATFORM,
|
||||
$this->policy->userVerification(),
|
||||
AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED,
|
||||
),
|
||||
/* D5: 'none' — see the plan §2.3 and SECURITY.md */
|
||||
'none',
|
||||
/* also discourage registering the same device twice */
|
||||
$this->credentialDescriptors(),
|
||||
$this->policy->timeout(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptors for every stored credential, used both as `allowCredentials`
|
||||
* on login and as `excludeCredentials` on registration.
|
||||
*
|
||||
* @return PublicKeyCredentialDescriptor[]
|
||||
*/
|
||||
private function credentialDescriptors(): array
|
||||
{
|
||||
$descriptors = [];
|
||||
foreach ($this->credentials->all() as $credential) {
|
||||
$descriptors[] = PublicKeyCredentialDescriptor::create(
|
||||
'public-key',
|
||||
$credential->record->publicKeyCredentialId,
|
||||
$credential->record->transports,
|
||||
);
|
||||
}
|
||||
|
||||
return $descriptors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
private function deserializeCredential(array $data): ?PublicKeyCredential
|
||||
{
|
||||
try {
|
||||
return $this->factory->serializer()->denormalize($data, PublicKeyCredential::class, 'json');
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A short, stable description so a user can tell their keys apart.
|
||||
*
|
||||
* Derived from the credential id because the AAGUID is not a reliable
|
||||
* identity without the FIDO metadata service, which D5 deliberately does
|
||||
* not use.
|
||||
*/
|
||||
private function labelFor(CredentialRecord $record): string
|
||||
{
|
||||
return 'Passkey '.substr(Base64UrlSafe::encodeUnpadded($record->publicKeyCredentialId), 0, 8);
|
||||
}
|
||||
|
||||
private function stringOrNull(mixed $value): ?string
|
||||
{
|
||||
return \is_string($value) && '' !== $value ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Exception\PasskeyConfigurationException;
|
||||
use Override;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* @see PasskeyPolicyInterface for the decisions this class enforces
|
||||
*
|
||||
* **On scheme handling (D4).** The container serves plain HTTP and sits behind a
|
||||
* TLS-terminating proxy, so the application never learns the public scheme from
|
||||
* its own configuration — only from `X-Forwarded-Proto`, which Symfony resolves
|
||||
* through `trusted_proxies` / `trusted_headers` in `framework.yaml`. The scheme
|
||||
* is therefore enforced two independent ways rather than asserted at boot:
|
||||
*
|
||||
* 1. The allowed origin is hardcoded to `https://` here and is never taken from
|
||||
* the request, so the library's origin check rejects an `http://` ceremony
|
||||
* no matter how the request arrived.
|
||||
* 2. {@see isAvailableFor()} additionally requires the request to be secure, so
|
||||
* a visitor on a non-secure connection is never shown a passkey button that
|
||||
* the browser would refuse to act on.
|
||||
*/
|
||||
final readonly class PasskeyPolicy implements PasskeyPolicyInterface
|
||||
{
|
||||
private const string SCHEME = 'https';
|
||||
|
||||
public function __construct(
|
||||
private ConfigBag $config,
|
||||
private DomainInterface $domainManager,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->config->passkeyEnabled() && null !== $this->domainManager->authBase();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function isAvailableFor(Request $request): bool
|
||||
{
|
||||
return $this->isEnabled()
|
||||
&& $request->isSecure()
|
||||
&& $this->domainManager->getAuthSubdomain() === $request->getHost();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function rpId(): string
|
||||
{
|
||||
return $this->domainManager->authBase() ?? throw $this->notConfigured();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function allowedOrigins(): array
|
||||
{
|
||||
return [self::SCHEME.'://'.$this->authSubdomain()];
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function authSubdomain(): string
|
||||
{
|
||||
$subdomain = $this->domainManager->getAuthSubdomain();
|
||||
|
||||
return $subdomain ?? throw $this->notConfigured();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function rpName(): string
|
||||
{
|
||||
return $this->config->passkeyRpName() ?: $this->config->title();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function userVerification(): string
|
||||
{
|
||||
return $this->config->passkeyUserVerification();
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function timeout(): int
|
||||
{
|
||||
return $this->config->passkeyTimeout();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PasskeyConfigurationException
|
||||
*/
|
||||
#[Override]
|
||||
public function assertConfigurationIsUsable(): void
|
||||
{
|
||||
if (!$this->config->passkeyEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* D1: without a base domain there is no RP ID and no shared credential */
|
||||
if (null === $this->domainManager->authBase()) {
|
||||
throw new PasskeyConfigurationException('PASSKEY_ENABLED is on, but central authentication is not configured. Passkeys require SUBDOMAIN_REDIRECT=1 together with a valid AUTH_SUBDOMAIN whose base domain can be determined (a domain such as "auth.example.com" — not "localhost" and not an IP address). Either configure central authentication or set PASSKEY_ENABLED=0.');
|
||||
}
|
||||
|
||||
/* D4: the subdomain must be a real domain able to present a TLS certificate.
|
||||
* authBase() returning null already excludes localhost and bare IPs, so
|
||||
* this guards against an auth subdomain that is a single label. */
|
||||
if (!str_contains($this->authSubdomain(), '.')) {
|
||||
throw new PasskeyConfigurationException('AUTH_SUBDOMAIN must be a fully qualified domain name (for example "auth.example.com") because passkeys require HTTPS and a certificate cannot be issued for a single-label host.');
|
||||
}
|
||||
}
|
||||
|
||||
private function notConfigured(): PasskeyConfigurationException
|
||||
{
|
||||
return new PasskeyConfigurationException(
|
||||
'Passkeys are enabled but central authentication is not configured, '
|
||||
.'so no relying party identity is available.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Exception\PasskeyConfigurationException;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Single source of truth for whether passkeys may be offered, and under which
|
||||
* relying-party identity.
|
||||
*
|
||||
* Two project decisions are enforced here and nowhere else:
|
||||
*
|
||||
* - **D1 — central auth is a prerequisite.** A passkey is only meaningful when
|
||||
* the whole base domain shares one relying party, so passkeys are unavailable
|
||||
* unless `SUBDOMAIN_REDIRECT` is on and `AUTH_SUBDOMAIN` resolves to a base
|
||||
* domain. The RP ID is therefore always that base domain.
|
||||
*
|
||||
* - **D4 — HTTPS is required, with no exemption.** The origin handed to the
|
||||
* browser is built here as `https://{authSubdomain}` and is *never* derived
|
||||
* from the incoming request, so an `http://` origin can never be accepted.
|
||||
* The deprecated `setSecuredRelyingPartyId()` escape hatch is not used, and
|
||||
* there is deliberately no configuration override that could reintroduce one.
|
||||
*/
|
||||
interface PasskeyPolicyInterface
|
||||
{
|
||||
/**
|
||||
* True when passkeys are switched on by configuration AND that configuration
|
||||
* satisfies D1. Availability to a particular visitor additionally requires
|
||||
* {@see isAvailableFor()}.
|
||||
*/
|
||||
public function isEnabled(): bool;
|
||||
|
||||
/**
|
||||
* Whether the passkey UI should be offered for this request.
|
||||
*
|
||||
* Requires the feature to be enabled, the request to be aimed at the auth
|
||||
* subdomain (the only place a ceremony may run), and the visitor to actually
|
||||
* be on HTTPS — see the note on scheme handling in {@see PasskeyPolicy}.
|
||||
*/
|
||||
public function isAvailableFor(Request $request): bool;
|
||||
|
||||
/**
|
||||
* The relying party ID: always the base domain of the auth subdomain.
|
||||
*
|
||||
* @throws PasskeyConfigurationException when passkeys are enabled without D1
|
||||
*/
|
||||
public function rpId(): string;
|
||||
|
||||
/**
|
||||
* Relying party origins allowed to complete a ceremony.
|
||||
*
|
||||
* Always exactly one entry, always `https://`, always derived from
|
||||
* configuration rather than from the request (D4).
|
||||
*
|
||||
* @return string[]
|
||||
*
|
||||
* @throws PasskeyConfigurationException when passkeys are enabled without D1
|
||||
*/
|
||||
public function allowedOrigins(): array;
|
||||
|
||||
/** The auth subdomain that ceremonies must be served from. */
|
||||
public function authSubdomain(): string;
|
||||
|
||||
/** Human-readable name shown in the authenticator prompt. */
|
||||
public function rpName(): string;
|
||||
|
||||
/** `required`, `preferred` or `discouraged`. */
|
||||
public function userVerification(): string;
|
||||
|
||||
/** Ceremony timeout in milliseconds, as passed to the browser. */
|
||||
public function timeout(): int;
|
||||
|
||||
/**
|
||||
* Fails hard when passkeys are enabled in a configuration that cannot work.
|
||||
*
|
||||
* Called during cache warm-up so a misconfigured deployment never reaches a
|
||||
* browser: the container refuses to start instead.
|
||||
*
|
||||
* @throws PasskeyConfigurationException
|
||||
*/
|
||||
public function assertConfigurationIsUsable(): void;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\ConfigBag;
|
||||
use App\Enum\Scope;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Trait\CookieNameTrait;
|
||||
use App\Trait\HasLoggerTrait;
|
||||
use App\Trait\StringTrait;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Target;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\Uid\Ulid;
|
||||
|
||||
/**
|
||||
* Grants access once an identity has been authenticated, by whatever method.
|
||||
*
|
||||
* Extracted from `LoginManager` so the passkey ceremony and the TOTP form
|
||||
* produce **byte-identical** outcomes. Two implementations would inevitably
|
||||
* drift — most likely in cookie attributes, where a difference is invisible
|
||||
* until it breaks in a browser.
|
||||
*
|
||||
* This class deliberately knows nothing about *how* authentication happened; it
|
||||
* only records the result.
|
||||
*
|
||||
* @see SessionIssuerInterface
|
||||
*/
|
||||
final readonly class SessionIssuer implements SessionIssuerInterface
|
||||
{
|
||||
use CookieNameTrait;
|
||||
use HasLoggerTrait;
|
||||
use StringTrait;
|
||||
|
||||
private MonitorCacheKeys $sessionCache;
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function __construct(
|
||||
#[Target('sessionCache')] CacheItemPoolInterface $sessionCache,
|
||||
private DomainInterface $domainManager,
|
||||
private ConfigBag $config,
|
||||
) {
|
||||
$this->sessionCache = new MonitorCacheKeys($sessionCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
#[Override]
|
||||
public function issue(string $identity, Scope $scope, Request $request, bool $json): Response
|
||||
{
|
||||
/* the same normalisation LoginManager has always applied, so cache keys
|
||||
* and Remote-User values stay identical between the two login paths */
|
||||
$cleanId = $this->makeCacheKey($identity);
|
||||
|
||||
$response = $this->authSuccessResponse($cleanId, $this->config);
|
||||
|
||||
/* when the caller only wanted this one page, there is nothing to store */
|
||||
if (Scope::None === $scope) {
|
||||
$this->logger->debug("successful login for: $cleanId");
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (Scope::Cookie === $scope) {
|
||||
$response->headers->setCookie($this->setCookie($cleanId, $request->getHost()));
|
||||
} elseif (Scope::Ip === $scope) {
|
||||
$this->setIp($cleanId, (string) $request->getClientIp());
|
||||
}
|
||||
|
||||
if ($json) {
|
||||
$contentType = 'application/json';
|
||||
$content = (string) json_encode([
|
||||
'message' => 'Login successful',
|
||||
'nonce' => null,
|
||||
]);
|
||||
} else {
|
||||
$contentType = 'text/html';
|
||||
$content = "hi $cleanId, please reload";
|
||||
}
|
||||
|
||||
$location = $request->query->has('return')
|
||||
&& $this->domainManager->validReturn((string) $request->query->get('return')) ?
|
||||
"{$request->query->get('return')}" :
|
||||
"{$request->getPathInfo()}{$request->getQueryString()}";
|
||||
|
||||
/* force redirect to use GET method (important when using central auth) */
|
||||
$response->setContent($content)
|
||||
->setStatusCode(Response::HTTP_SEE_OTHER)
|
||||
->headers->set('Location', $location);
|
||||
$response->headers->set('Content-Type', $contentType);
|
||||
|
||||
$this->logger->debug("successful login for: $cleanId");
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function setCookie(string $id, string $host): Cookie
|
||||
{
|
||||
/* successful auth with token, store session and set the cookie */
|
||||
$ulid = new Ulid();
|
||||
$sessionCookie = $this->sessionCache->getItem(
|
||||
$this->makeCacheKey("cookie_$ulid"),
|
||||
);
|
||||
if ($sessionCookie->isHit()) {
|
||||
/* it is supposed to be impossible to have collisions */
|
||||
$this->logger->error('aborting: ULID collision');
|
||||
throw new HttpException(Response::HTTP_INTERNAL_SERVER_ERROR, 'Internal Server Error');
|
||||
}
|
||||
$sessionCookie->set($id);
|
||||
$sessionCookie->expiresAfter($this->config->cookieTtl());
|
||||
$this->sessionCache->save($sessionCookie);
|
||||
|
||||
return Cookie::create(
|
||||
name: $this->sessionCookieName($this->domainManager),
|
||||
value: $ulid->toString(),
|
||||
expire: time() + $this->config->cookieTtl(),
|
||||
path: '/',
|
||||
domain: $this->sessionCookieDomain($this->domainManager, $host),
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
sameSite: Cookie::SAMESITE_STRICT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function setIp(string $id, string $ip): void
|
||||
{
|
||||
/* successful auth with token, requested scope of ip (and ip access enabled) */
|
||||
$ipKey = $this->makeCacheKey("ip_$ip");
|
||||
|
||||
$sessionIp = $this->sessionCache->getItem($ipKey);
|
||||
$sessionIp->set($id);
|
||||
$sessionIp->expiresAfter($this->config->ipTtl());
|
||||
$this->sessionCache->save($sessionIp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enum\Scope;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Grants access after a successful authentication, regardless of method.
|
||||
*
|
||||
* Exists so the TOTP form and the passkey ceremony cannot drift apart: they must
|
||||
* produce identical cookies, headers and redirects, and the only reliable way to
|
||||
* guarantee that is for both to call the same code.
|
||||
*/
|
||||
interface SessionIssuerInterface
|
||||
{
|
||||
/**
|
||||
* Record the authenticated identity according to the requested scope and
|
||||
* build the response the caller should return.
|
||||
*
|
||||
* @param string $identity the session id, as typed by the user
|
||||
* @param Scope $scope whether to set a cookie, an IP session, or neither
|
||||
* @param bool $json JSON for an AJAX caller, HTML for a form post
|
||||
*/
|
||||
public function issue(string $identity, Scope $scope, Request $request, bool $json): Response;
|
||||
}
|
||||
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"doctrine/deprecations": {
|
||||
"version": "1.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "fdd756167454623e21f1d769c5b814b243782a67"
|
||||
}
|
||||
},
|
||||
"friendsofphp/php-cs-fixer": {
|
||||
"version": "3.95",
|
||||
"recipe": {
|
||||
@@ -74,6 +83,18 @@
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "8.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "7.3",
|
||||
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/property_info.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "7.4",
|
||||
"recipe": {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Support;
|
||||
|
||||
use CBOR\ByteStringObject;
|
||||
use CBOR\Encoder;
|
||||
use JsonException;
|
||||
use OpenSSLAsymmetricKey;
|
||||
use ParagonIE\ConstantTime\Base64UrlSafe;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Builds real, cryptographically valid WebAuthn ceremonies for tests.
|
||||
*
|
||||
* Nothing here is mocked: the helper generates a P-256 keypair, builds a proper
|
||||
* COSE public key, signs a correct `authenticatorData` with OpenSSL, and
|
||||
* assembles the CBOR `attestationObject` a real authenticator would send. A
|
||||
* passing test therefore proves the ceremony works, rather than proving that our
|
||||
* code agrees with our own stubs.
|
||||
*
|
||||
* Two rules learned the hard way, both encoded below:
|
||||
* - **The counter is controlled explicitly.** A stale counter raises
|
||||
* `CounterException`, which can mask the real reason a verification failed and
|
||||
* turn a negative test into a false pass.
|
||||
* - **Options are serialised by the library**, never `json_encode()`d — the
|
||||
* challenge is raw binary and `json_encode()` rejects it outright.
|
||||
*/
|
||||
final class PasskeyTestHelper
|
||||
{
|
||||
/** The `none` attestation format requires an all-zero AAGUID. */
|
||||
private const string ZERO_AAGUID = '00000000000000000000000000000000';
|
||||
|
||||
private readonly OpenSSLAsymmetricKey $key;
|
||||
|
||||
/** @var array{x: string, y: string} */
|
||||
private array $coordinates;
|
||||
|
||||
private int $counter = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$key = openssl_pkey_new([
|
||||
'private_key_type' => \OPENSSL_KEYTYPE_EC,
|
||||
'curve_name' => 'prime256v1',
|
||||
]);
|
||||
|
||||
if (!$key instanceof OpenSSLAsymmetricKey) {
|
||||
throw new RuntimeException('Unable to generate a P-256 keypair for tests.');
|
||||
}
|
||||
|
||||
$details = openssl_pkey_get_details($key);
|
||||
if (!\is_array($details) || !isset($details['ec']['x'], $details['ec']['y'])) {
|
||||
throw new RuntimeException('Unable to read the generated P-256 keypair.');
|
||||
}
|
||||
|
||||
$this->key = $key;
|
||||
$this->coordinates = [
|
||||
'x' => $details['ec']['x'],
|
||||
'y' => $details['ec']['y'],
|
||||
];
|
||||
$this->counter = 0;
|
||||
}
|
||||
|
||||
public function credentialId(): string
|
||||
{
|
||||
return random_bytes(16);
|
||||
}
|
||||
|
||||
public function counter(): int
|
||||
{
|
||||
return $this->counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* COSE-encoded public key, as carried in the attested credential data.
|
||||
*
|
||||
* Keys are the standard COSE labels: 1 = EC2, 3 = ES256, -1 = P-256,
|
||||
* -2 = x, -3 = y. Binary coordinates must be byte strings, so they bypass
|
||||
* the encoder's UTF-8 detection.
|
||||
*/
|
||||
public function cosePublicKey(): string
|
||||
{
|
||||
return (new Encoder())->encode([
|
||||
1 => 2,
|
||||
3 => -7,
|
||||
-1 => 1,
|
||||
-2 => ByteStringObject::create($this->coordinates['x']),
|
||||
-3 => ByteStringObject::create($this->coordinates['y']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticator data for a registration: rpIdHash, flags (UP|AT|UV), a
|
||||
* counter, then the attested credential data.
|
||||
*/
|
||||
public function attestationAuthenticatorData(string $rpId, string $credentialId, bool $userVerified = true): string
|
||||
{
|
||||
$flags = 0x01 | 0x40; /* UP | AT */
|
||||
if ($userVerified) {
|
||||
$flags |= 0x04; /* UV */
|
||||
}
|
||||
|
||||
return hash('sha256', $rpId, true)
|
||||
.\chr($flags)
|
||||
.pack('N', ++$this->counter)
|
||||
.hex2bin(self::ZERO_AAGUID)
|
||||
.pack('n', \strlen($credentialId))
|
||||
.$credentialId
|
||||
.$this->cosePublicKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticator data for an assertion: rpIdHash, flags (UP|UV), a counter.
|
||||
* The attested credential data lives in the stored record, not here.
|
||||
*/
|
||||
public function assertionAuthenticatorData(string $rpId, int $counter, bool $userVerified = true): string
|
||||
{
|
||||
$flags = 0x01; /* UP */
|
||||
if ($userVerified) {
|
||||
$flags |= 0x04; /* UV */
|
||||
}
|
||||
|
||||
return hash('sha256', $rpId, true).\chr($flags).pack('N', $counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* clientDataJSON with the challenge encoded exactly as a browser encodes it.
|
||||
*
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function clientData(string $type, string $challenge, string $origin): string
|
||||
{
|
||||
return json_encode([
|
||||
'type' => $type,
|
||||
'challenge' => Base64UrlSafe::encodeUnpadded($challenge),
|
||||
'origin' => $origin,
|
||||
'crossOrigin' => false,
|
||||
], \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* The CBOR attestation object a browser sends, in the `none` format.
|
||||
*/
|
||||
public function attestationObject(string $authData): string
|
||||
{
|
||||
return (new Encoder())->encode([
|
||||
'fmt' => 'none',
|
||||
'attStmt' => [],
|
||||
'authData' => ByteStringObject::create($authData),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign `authData || sha256(clientDataJSON)` with the generated key.
|
||||
*
|
||||
* WebAuthn wants the raw 64-byte (r||s) form, but OpenSSL emits DER, so the
|
||||
* result is converted. Getting this wrong produces a confusing "invalid
|
||||
* signature" that looks like a logic bug rather than an encoding one.
|
||||
*/
|
||||
public function signature(string $authData, string $clientDataJson): string
|
||||
{
|
||||
$data = $authData.hash('sha256', $clientDataJson, true);
|
||||
|
||||
$der = '';
|
||||
openssl_sign($data, $der, $this->key, \OPENSSL_ALGO_SHA256);
|
||||
|
||||
return self::derToRaw($der);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ready-to-submit registration `credential`, matching what
|
||||
* `navigator.credentials.create()` produces.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function registrationCredential(
|
||||
string $rpId,
|
||||
string $challenge,
|
||||
string $origin,
|
||||
?string $credentialId = null,
|
||||
): array {
|
||||
$credentialId ??= $this->credentialId();
|
||||
$clientDataJson = $this->clientData('webauthn.create', $challenge, $origin);
|
||||
$authData = $this->attestationAuthenticatorData($rpId, $credentialId);
|
||||
|
||||
return [
|
||||
'id' => Base64UrlSafe::encodeUnpadded($credentialId),
|
||||
'rawId' => Base64UrlSafe::encodeUnpadded($credentialId),
|
||||
'type' => 'public-key',
|
||||
'response' => [
|
||||
'clientDataJSON' => Base64UrlSafe::encodeUnpadded($clientDataJson),
|
||||
'attestationObject' => Base64UrlSafe::encodeUnpadded($this->attestationObject($authData)),
|
||||
'transports' => ['internal'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A ready-to-submit assertion `credential`, matching what
|
||||
* `navigator.credentials.get()` produces.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function assertionCredential(
|
||||
string $rpId,
|
||||
string $challenge,
|
||||
string $origin,
|
||||
string $credentialId,
|
||||
int $counter,
|
||||
string $userHandle,
|
||||
): array {
|
||||
$clientDataJson = $this->clientData('webauthn.get', $challenge, $origin);
|
||||
$authData = $this->assertionAuthenticatorData($rpId, $counter);
|
||||
|
||||
return [
|
||||
'id' => Base64UrlSafe::encodeUnpadded($credentialId),
|
||||
'rawId' => Base64UrlSafe::encodeUnpadded($credentialId),
|
||||
'type' => 'public-key',
|
||||
'response' => [
|
||||
'clientDataJSON' => Base64UrlSafe::encodeUnpadded($clientDataJson),
|
||||
'authenticatorData' => Base64UrlSafe::encodeUnpadded($authData),
|
||||
'signature' => Base64UrlSafe::encodeUnpadded($this->signature($authData, $clientDataJson)),
|
||||
'userHandle' => Base64UrlSafe::encodeUnpadded($userHandle),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an OpenSSL DER signature to the fixed-length raw form WebAuthn
|
||||
* mandates: 64 bytes for P-256, big-endian r||s.
|
||||
*
|
||||
* DER is `30 <len> 02 <lenR> R 02 <lenS> S`. Both integers are
|
||||
* variable-length and may carry a leading zero byte, so each coordinate is
|
||||
* right-aligned into exactly 32 bytes. The library rejects anything that is
|
||||
* not exactly 64 bytes, and a malformed result looks like "invalid
|
||||
* signature" rather than an encoding bug — hence the explicit round-trip
|
||||
* check in the tests.
|
||||
*
|
||||
* @throws RuntimeException when the input is not a well-formed ECDSA-Sig-Value
|
||||
*/
|
||||
private static function derToRaw(string $der): string
|
||||
{
|
||||
$length = \strlen($der);
|
||||
/* short-form SEQUENCE header: tag + one length byte */
|
||||
if ($length < 8 || 0x30 !== \ord($der[0])) {
|
||||
throw new RuntimeException('Expected a DER SEQUENCE.');
|
||||
}
|
||||
|
||||
$offset = 2;
|
||||
|
||||
if (0x02 !== \ord($der[$offset])) {
|
||||
throw new RuntimeException('Expected a DER INTEGER for r.');
|
||||
}
|
||||
$lengthR = \ord($der[$offset + 1]);
|
||||
$r = substr($der, $offset + 2, $lengthR);
|
||||
$offset += 2 + $lengthR;
|
||||
|
||||
if (0x02 !== \ord($der[$offset])) {
|
||||
throw new RuntimeException('Expected a DER INTEGER for s.');
|
||||
}
|
||||
$lengthS = \ord($der[$offset + 1]);
|
||||
$s = substr($der, $offset + 2, $lengthS);
|
||||
|
||||
return str_pad(substr($r, -32), 32, "\x00", \STR_PAD_LEFT)
|
||||
.str_pad(substr($s, -32), 32, "\x00", \STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\ConfigBag;
|
||||
use App\Utilities;
|
||||
use DateTimeImmutable;
|
||||
use OTPHP\TOTP;
|
||||
use Override;
|
||||
use Psr\Cache\CacheItemInterface;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
use Psr\Clock\ClockInterface as PsrClockInterface;
|
||||
@@ -35,6 +36,7 @@ trait TotpTestHelper
|
||||
{
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable($this->time);
|
||||
@@ -77,6 +79,11 @@ trait TotpTestHelper
|
||||
string $remoteUserMode = 'session',
|
||||
string $remoteUserStatic = 'authenticated',
|
||||
string $remoteUserMap = '',
|
||||
string $title = 'Pre-Authentication System',
|
||||
bool $passkeyEnabled = false,
|
||||
string $passkeyRpName = '',
|
||||
string $passkeyUserVerification = 'required',
|
||||
int $passkeyTimeout = 60000,
|
||||
): ConfigBag {
|
||||
$clock = $this->frozenClock();
|
||||
$utilities = $this->createUtilities($clock);
|
||||
@@ -94,6 +101,11 @@ trait TotpTestHelper
|
||||
$remoteUserMode,
|
||||
$remoteUserStatic,
|
||||
$remoteUserMap,
|
||||
$title,
|
||||
$passkeyEnabled,
|
||||
$passkeyRpName,
|
||||
$passkeyUserVerification,
|
||||
$passkeyTimeout,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ class TestKernel extends AppKernel
|
||||
$container->addCompilerPass(new class implements CompilerPassInterface {
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache'] as $poolId) {
|
||||
foreach (['nonceCache', 'rateLimitCache', 'sessionCache', 'sessionStorage', 'publicRateLimitCache', 'passkeyRateLimitCache'] as $poolId) {
|
||||
if ($container->hasDefinition($poolId)) {
|
||||
$container->getDefinition($poolId)->clearTag('kernel.reset');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\CacheWarmer;
|
||||
|
||||
use App\CacheWarmer\PasskeyConfigurationWarmer;
|
||||
use App\Exception\PasskeyConfigurationException;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The warmer is the mechanism that turns a bad passkey configuration into a
|
||||
* failed deployment rather than a broken feature (D1/D4, plan §4.2).
|
||||
*/
|
||||
final class PasskeyConfigurationWarmerTest extends TestCase
|
||||
{
|
||||
public function test_it_delegates_the_configuration_check(): void
|
||||
{
|
||||
$policy = $this->createMock(PasskeyPolicyInterface::class);
|
||||
$policy->expects(self::once())->method('assertConfigurationIsUsable');
|
||||
|
||||
$warmer = new PasskeyConfigurationWarmer($policy);
|
||||
|
||||
self::assertSame([], $warmer->warmUp('/tmp/cache'));
|
||||
}
|
||||
|
||||
public function test_it_is_not_optional(): void
|
||||
{
|
||||
/* an optional warmer can be skipped, which would defeat the check */
|
||||
$warmer = new PasskeyConfigurationWarmer($this->createStub(PasskeyPolicyInterface::class));
|
||||
|
||||
self::assertFalse($warmer->isOptional());
|
||||
}
|
||||
|
||||
public function test_it_propagates_a_configuration_failure(): void
|
||||
{
|
||||
$policy = $this->createStub(PasskeyPolicyInterface::class);
|
||||
$policy->method('assertConfigurationIsUsable')
|
||||
->willThrowException(new PasskeyConfigurationException('nope'));
|
||||
|
||||
$this->expectException(PasskeyConfigurationException::class);
|
||||
(new PasskeyConfigurationWarmer($policy))->warmUp('/tmp/cache');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\Data\PasskeyCredential;
|
||||
use App\Enum\Scope;
|
||||
use App\Listener\PasskeyListener;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use App\Service\SessionIssuerInterface;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use App\Trait\StringTrait;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\TrustPath\EmptyTrustPath;
|
||||
|
||||
/**
|
||||
* The listener is where several security properties are enforced at once, so
|
||||
* each is asserted separately: which requests it claims, what it does with
|
||||
* ones it cannot serve, and — crucially — that a failure looks exactly like a
|
||||
* wrong TOTP code.
|
||||
*/
|
||||
final class PasskeyListenerTest extends TestCase
|
||||
{
|
||||
use StringTrait;
|
||||
use TotpTestHelper;
|
||||
|
||||
private const string AUTH_HOST = 'auth.example.com';
|
||||
|
||||
private ?ArrayAdapter $sessionCache = null;
|
||||
|
||||
private function makeListener(
|
||||
?PasskeyInterface $passkeys = null,
|
||||
bool $available = true,
|
||||
?SessionIssuerInterface $sessionIssuer = null,
|
||||
?DomainInterface $domainManager = null,
|
||||
int $beginLimit = 30,
|
||||
): PasskeyListener {
|
||||
$this->sessionCache = new ArrayAdapter();
|
||||
|
||||
$policy = $this->createStub(PasskeyPolicyInterface::class);
|
||||
$policy->method('isAvailableFor')->willReturn($available);
|
||||
|
||||
$domainManager ??= $this->authDomainManager();
|
||||
|
||||
$listener = new PasskeyListener(
|
||||
new RateLimiterFactory(['id' => 'passkey_begin_burst', 'policy' => 'sliding_window', 'limit' => $beginLimit, 'interval' => '60 seconds'], new InMemoryStorage()),
|
||||
new RateLimiterFactory(['id' => 'login_limiter', 'policy' => 'sliding_window', 'limit' => 2, 'interval' => '60 seconds'], new InMemoryStorage()),
|
||||
$this->sessionCache,
|
||||
$passkeys ?? $this->createStub(PasskeyInterface::class),
|
||||
$policy,
|
||||
$sessionIssuer ?? $this->createStub(SessionIssuerInterface::class),
|
||||
$domainManager,
|
||||
$this->makeConfig(),
|
||||
);
|
||||
$listener->setLogger(new NullLogger());
|
||||
$listener->setNonceCache(new ArrayAdapter());
|
||||
|
||||
return $listener;
|
||||
}
|
||||
|
||||
private function authDomainManager(): DomainInterface
|
||||
{
|
||||
$manager = $this->createStub(DomainInterface::class);
|
||||
$manager->method('getAuthSubdomain')->willReturn(self::AUTH_HOST);
|
||||
$manager->method('authBase')->willReturn('example.com');
|
||||
|
||||
return $manager;
|
||||
}
|
||||
|
||||
private function makeEvent(Request $request): RequestEvent
|
||||
{
|
||||
return new RequestEvent(
|
||||
$this->createStub(HttpKernelInterface::class),
|
||||
$request,
|
||||
HttpKernelInterface::MAIN_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $body
|
||||
*/
|
||||
private function ceremonyRequest(string $operation, string $host = self::AUTH_HOST, array $body = []): Request
|
||||
{
|
||||
return Request::create(
|
||||
"https://$host/",
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['HTTP_X_PREAUTH_PASSKEY' => $operation, 'REMOTE_ADDR' => '1.2.3.4'],
|
||||
(string) json_encode($body),
|
||||
);
|
||||
}
|
||||
|
||||
private function credential(string $identity = 'lyra'): PasskeyCredential
|
||||
{
|
||||
return new PasskeyCredential(
|
||||
CredentialRecord::create(
|
||||
random_bytes(16),
|
||||
'public-key',
|
||||
['internal'],
|
||||
'none',
|
||||
EmptyTrustPath::create(),
|
||||
Uuid::v4(),
|
||||
'KEY',
|
||||
hash('sha256', $identity, true),
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
$identity,
|
||||
'Passkey abc',
|
||||
new DateTimeImmutable(),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── dispatch ─────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* A request without the header is none of this listener's business, so it
|
||||
* must not set a response and let the normal flow continue.
|
||||
*/
|
||||
public function test_a_request_without_the_header_is_ignored(): void
|
||||
{
|
||||
$listener = $this->makeListener();
|
||||
$event = $this->makeEvent(Request::create('https://'.self::AUTH_HOST.'/', 'GET'));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the auth subdomain hosts ceremonies; elsewhere the header is ignored
|
||||
* so this listener cannot be used to probe other hosts.
|
||||
*/
|
||||
public function test_the_header_is_ignored_on_a_non_auth_host(): void
|
||||
{
|
||||
$listener = $this->makeListener();
|
||||
$event = $this->makeEvent($this->ceremonyRequest('login-begin', 'app.example.com'));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function test_an_unknown_operation_is_rejected(): void
|
||||
{
|
||||
$listener = $this->makeListener();
|
||||
$event = $this->makeEvent($this->ceremonyRequest('not-a-thing'));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Every header-bearing request is answered, even an unparseable one, so a
|
||||
* `fetch()` caller never receives HTML from InterceptListener.
|
||||
*/
|
||||
public function test_a_malformed_body_still_gets_a_json_response(): void
|
||||
{
|
||||
$listener = $this->makeListener();
|
||||
$request = Request::create(
|
||||
'https://'.self::AUTH_HOST.'/',
|
||||
'POST',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
['HTTP_X_PREAUTH_PASSKEY' => 'login-finish', 'REMOTE_ADDR' => '1.2.3.4'],
|
||||
'this is not json',
|
||||
);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertNotNull($response);
|
||||
self::assertSame('application/json', $response->headers->get('Content-Type'));
|
||||
self::assertIsArray(json_decode((string) $response->getContent(), true));
|
||||
}
|
||||
|
||||
/* ── availability ─────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* When passkeys are unavailable the feature must behave as if absent: no
|
||||
* ceremony is started and nothing is written to any cache.
|
||||
*/
|
||||
public function test_begin_is_inert_when_passkeys_are_unavailable(): void
|
||||
{
|
||||
$passkeys = $this->createMock(PasskeyInterface::class);
|
||||
$passkeys->expects(self::never())->method('beginLogin');
|
||||
|
||||
$listener = $this->makeListener($passkeys, available: false);
|
||||
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_begin_login_returns_options_and_a_ceremony_id(): void
|
||||
{
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('beginLogin')->willReturn([
|
||||
'publicKey' => ['challenge' => 'abc', 'rpId' => 'example.com'],
|
||||
'ceremonyId' => 'cid',
|
||||
]);
|
||||
|
||||
$listener = $this->makeListener($passkeys);
|
||||
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
|
||||
$decoded = json_decode((string) $response->getContent(), true);
|
||||
self::assertSame('cid', $decoded['ceremonyId']);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ceremony reply is the one browser-facing 2xx, so it carries the marker
|
||||
* that turns into the no-store policy.
|
||||
*/
|
||||
public function test_ceremony_responses_carry_the_marker(): void
|
||||
{
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('beginLogin')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
|
||||
|
||||
$listener = $this->makeListener($passkeys);
|
||||
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertSame('1', $event->getResponse()?->headers->get(PasskeyListener::CEREMONY_MARKER));
|
||||
}
|
||||
|
||||
/* ── registration gating ──────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Registration requires a live session: the identity comes from the cookie,
|
||||
* never from the body, so nobody can register a passkey for another identity.
|
||||
*/
|
||||
public function test_register_begin_without_a_session_is_refused(): void
|
||||
{
|
||||
$passkeys = $this->createMock(PasskeyInterface::class);
|
||||
$passkeys->expects(self::never())->method('beginRegistration');
|
||||
|
||||
$listener = $this->makeListener($passkeys);
|
||||
$event = $this->makeEvent($this->ceremonyRequest('register-begin'));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_register_begin_uses_the_identity_from_the_session_cookie(): void
|
||||
{
|
||||
$passkeys = $this->createMock(PasskeyInterface::class);
|
||||
$passkeys->expects(self::once())
|
||||
->method('beginRegistration')
|
||||
->with('lyra')
|
||||
->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
|
||||
|
||||
$listener = $this->makeListener($passkeys);
|
||||
|
||||
/* seed a live session the way SessionIssuer would have; this has to
|
||||
* happen after makeListener(), which builds the pool the listener holds */
|
||||
$ulid = 'test-ulid';
|
||||
$monitor = new MonitorCacheKeys($this->sessionCache);
|
||||
$item = $monitor->getItem($this->makeCacheKey("cookie_$ulid"));
|
||||
$item->set('lyra');
|
||||
$monitor->save($item);
|
||||
|
||||
$request = $this->ceremonyRequest('register-begin');
|
||||
$request->cookies->set('__Http-Domain-Preauth', $ulid);
|
||||
|
||||
$event = $this->makeEvent($request);
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertSame(Response::HTTP_OK, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── failures share the login budget (D3) ─────────────────────────── */
|
||||
|
||||
/**
|
||||
* A failed ceremony must be indistinguishable from a wrong TOTP code: same
|
||||
* status, same shape, and it must spend the same limiter.
|
||||
*/
|
||||
public function test_a_failed_ceremony_returns_401_with_a_nonce(): void
|
||||
{
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('finishLogin')->willReturn(null);
|
||||
|
||||
$listener = $this->makeListener($passkeys);
|
||||
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $response->getStatusCode());
|
||||
$decoded = json_decode((string) $response->getContent(), true);
|
||||
self::assertArrayHasKey('nonce', $decoded);
|
||||
self::assertNotSame('', $decoded['nonce']);
|
||||
}
|
||||
|
||||
/**
|
||||
* After the shared budget is exhausted the response is the same 418/429 the
|
||||
* TOTP path produces — this is what stops passkeys being an unlimited oracle.
|
||||
*/
|
||||
public function test_repeated_failures_exhaust_the_shared_budget(): void
|
||||
{
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('finishLogin')->willReturn(null);
|
||||
|
||||
$listener = $this->makeListener($passkeys);
|
||||
|
||||
/* the stub limiter allows 2 */
|
||||
for ($i = 0; $i < 2; ++$i) {
|
||||
$listener->onKernelRequest($this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid'])));
|
||||
}
|
||||
|
||||
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
/* the 418/429 choice mirrors LoginListener: `teapot` swaps the status but
|
||||
* not the meaning, and the point here is that the budget is shared */
|
||||
self::assertContains(
|
||||
$event->getResponse()?->getStatusCode(),
|
||||
[Response::HTTP_TOO_MANY_REQUESTS, Response::HTTP_I_AM_A_TEAPOT],
|
||||
);
|
||||
}
|
||||
|
||||
/* ── success ──────────────────────────────────────────────────────── */
|
||||
|
||||
public function test_a_successful_login_issues_a_session(): void
|
||||
{
|
||||
$credential = $this->credential('lyra');
|
||||
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('finishLogin')->willReturn($credential);
|
||||
|
||||
$issuer = $this->createMock(SessionIssuerInterface::class);
|
||||
$issuer->expects(self::once())
|
||||
->method('issue')
|
||||
->with('lyra', Scope::Cookie, self::anything(), true)
|
||||
->willReturn(new Response('', Response::HTTP_SEE_OTHER));
|
||||
|
||||
$listener = $this->makeListener($passkeys, sessionIssuer: $issuer);
|
||||
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertSame(Response::HTTP_SEE_OTHER, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
public function test_a_successful_registration_issues_a_session(): void
|
||||
{
|
||||
$credential = $this->credential('lyra');
|
||||
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('finishRegistration')->willReturn($credential);
|
||||
|
||||
$issuer = $this->createMock(SessionIssuerInterface::class);
|
||||
$issuer->expects(self::once())->method('issue')->willReturn(new Response('', Response::HTTP_SEE_OTHER));
|
||||
|
||||
$listener = $this->makeListener($passkeys, sessionIssuer: $issuer);
|
||||
$event = $this->makeEvent($this->ceremonyRequest('register-finish', body: ['ceremonyId' => 'cid']));
|
||||
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertSame(Response::HTTP_SEE_OTHER, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
/* ── the resource guard ───────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* `begin` is bounded so an unauthenticated caller cannot fill the ceremony
|
||||
* cache — but this guard is deliberately separate from the login budget.
|
||||
*/
|
||||
public function test_begin_is_rate_limited_as_a_resource_guard(): void
|
||||
{
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('beginLogin')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
|
||||
|
||||
$listener = $this->makeListener($passkeys, beginLimit: 2);
|
||||
|
||||
for ($i = 0; $i < 2; ++$i) {
|
||||
$listener->onKernelRequest($this->makeEvent($this->ceremonyRequest('login-begin')));
|
||||
}
|
||||
|
||||
$event = $this->makeEvent($this->ceremonyRequest('login-begin'));
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertNotNull($response);
|
||||
self::assertSame(Response::HTTP_TOO_MANY_REQUESTS, $response->getStatusCode());
|
||||
self::assertTrue($response->headers->has('Retry-After'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A successful `begin` must not spend failure budget: otherwise simply
|
||||
* opening the login page would count against the user, and ten legitimately
|
||||
* started ceremonies would lock them out.
|
||||
*/
|
||||
public function test_begin_does_not_consume_the_login_budget(): void
|
||||
{
|
||||
$passkeys = $this->createStub(PasskeyInterface::class);
|
||||
$passkeys->method('beginLogin')->willReturn(['publicKey' => [], 'ceremonyId' => 'cid']);
|
||||
$passkeys->method('finishLogin')->willReturn(null);
|
||||
|
||||
$listener = $this->makeListener($passkeys);
|
||||
|
||||
/* 10 begins, well inside the burst guard, then a failed login must still
|
||||
* be answered as a normal 401 rather than an exhausted-budget response */
|
||||
for ($i = 0; $i < 10; ++$i) {
|
||||
$listener->onKernelRequest($this->makeEvent($this->ceremonyRequest('login-begin')));
|
||||
}
|
||||
|
||||
$event = $this->makeEvent($this->ceremonyRequest('login-finish', body: ['ceremonyId' => 'cid']));
|
||||
$listener->onKernelRequest($event);
|
||||
|
||||
self::assertSame(Response::HTTP_UNAUTHORIZED, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Listener;
|
||||
|
||||
use App\Listener\PasskeyListener;
|
||||
use App\Listener\SecurityHeadersListener;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyPolicyInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -14,12 +16,15 @@ use Symfony\Component\HttpKernel\HttpKernelInterface;
|
||||
|
||||
final class SecurityHeadersListenerTest extends TestCase
|
||||
{
|
||||
private function makeListener(?string $authSubdomain = null): SecurityHeadersListener
|
||||
private function makeListener(?string $authSubdomain = null, bool $passkeysAvailable = false): SecurityHeadersListener
|
||||
{
|
||||
$domainManager = $this->createStub(DomainInterface::class);
|
||||
$domainManager->method('getAuthSubdomain')->willReturn($authSubdomain);
|
||||
|
||||
return new SecurityHeadersListener($domainManager);
|
||||
$policy = $this->createStub(PasskeyPolicyInterface::class);
|
||||
$policy->method('isAvailableFor')->willReturn($passkeysAvailable);
|
||||
|
||||
return new SecurityHeadersListener($domainManager, $policy);
|
||||
}
|
||||
|
||||
private function makeEvent(
|
||||
@@ -204,4 +209,76 @@ final class SecurityHeadersListenerTest extends TestCase
|
||||
|
||||
self::assertStringNotContainsString('connect-src', $response->headers->get('Content-Security-Policy'));
|
||||
}
|
||||
|
||||
/**
|
||||
* `publickey-credentials-get`/`-create` do not fall back to `default-src`, so
|
||||
* without them the browser refuses the ceremony however the script is written.
|
||||
*/
|
||||
public function test_csp_grants_the_webauthn_directives_when_passkeys_are_available(): void
|
||||
{
|
||||
$listener = $this->makeListener('auth.example.com', true);
|
||||
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
|
||||
$request = Request::create('https://auth.example.com/', 'GET');
|
||||
$listener->onKernelResponse($this->makeEvent($response, $request));
|
||||
|
||||
$csp = (string) $response->headers->get('Content-Security-Policy');
|
||||
self::assertStringContainsString("publickey-credentials-get 'self';", $csp);
|
||||
self::assertStringContainsString("publickey-credentials-create 'self';", $csp);
|
||||
self::assertStringContainsString("connect-src 'self';", $csp);
|
||||
}
|
||||
|
||||
/**
|
||||
* With passkeys unavailable the header must be byte-identical to today's,
|
||||
* which is what makes "unavailable means invisible" a testable property.
|
||||
*/
|
||||
public function test_csp_is_unchanged_when_passkeys_are_unavailable(): void
|
||||
{
|
||||
$listener = $this->makeListener('auth.example.com', false);
|
||||
$response = new Response('login', Response::HTTP_UNAUTHORIZED);
|
||||
$request = Request::create('https://auth.example.com/', 'GET');
|
||||
$listener->onKernelResponse($this->makeEvent($response, $request));
|
||||
|
||||
self::assertSame(
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';",
|
||||
$response->headers->get('Content-Security-Policy'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A ceremony reply is a browser-facing 2xx, so the usual "2xx is consumed by
|
||||
* forward_auth" assumption does not hold and it has to be made no-store.
|
||||
*/
|
||||
public function test_ceremony_responses_are_made_no_store_and_the_marker_is_stripped(): void
|
||||
{
|
||||
$listener = $this->makeListener('auth.example.com');
|
||||
$response = new Response('{}', Response::HTTP_OK);
|
||||
$response->headers->set(PasskeyListener::CEREMONY_MARKER, '1');
|
||||
|
||||
$listener->onKernelResponse($this->makeEvent($response));
|
||||
|
||||
self::assertFalse($response->headers->has(PasskeyListener::CEREMONY_MARKER));
|
||||
self::assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
|
||||
self::assertSame('*', $response->headers->get('Vary'));
|
||||
}
|
||||
|
||||
/**
|
||||
* An ordinary 2xx must stay untouched: those are consumed by forward_auth, and
|
||||
* adding cache headers could interfere with the protected service.
|
||||
*/
|
||||
public function test_a_plain_success_response_is_left_cacheable(): void
|
||||
{
|
||||
$listener = $this->makeListener('auth.example.com');
|
||||
$response = new Response('hi', Response::HTTP_OK);
|
||||
$response->setCache(['public' => true, 'max_age' => 60]);
|
||||
|
||||
$listener->onKernelResponse($this->makeEvent($response));
|
||||
|
||||
/* the headers the caller set must survive untouched — no no-store, and
|
||||
* no marker leaking through */
|
||||
$cacheControl = (string) $response->headers->get('Cache-Control');
|
||||
self::assertStringNotContainsString('no-store', $cacheControl);
|
||||
self::assertStringContainsString('max-age=60', $cacheControl);
|
||||
self::assertFalse($response->headers->has('Surrogate-Control'));
|
||||
self::assertFalse($response->headers->has(PasskeyListener::CEREMONY_MARKER));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Enum\Scope;
|
||||
use App\Service\BackupCodeInterface;
|
||||
use App\Service\DomainManager;
|
||||
use App\Service\LoginManager;
|
||||
use App\Service\SessionIssuer;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use App\Trait\StringTrait;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -28,6 +29,7 @@ final class LoginManagerTest extends TestCase
|
||||
private ArrayAdapter $pool;
|
||||
private BackupCodeInterface $backupCodeManager;
|
||||
private DomainManager $domainManager;
|
||||
private ?SessionIssuer $sessionIssuer = null;
|
||||
|
||||
private function makeLoginManager(
|
||||
?int $ipTtl = 0,
|
||||
@@ -38,7 +40,12 @@ final class LoginManagerTest extends TestCase
|
||||
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
|
||||
$this->domainManager = new DomainManager($subdomainRedirect, $authSubdomain);
|
||||
|
||||
$manager = new LoginManager($this->pool, $this->backupCodeManager, $this->domainManager);
|
||||
/* session issuing is shared with the passkey flow, so it is built as the
|
||||
* same collaborator the container would inject */
|
||||
$this->sessionIssuer = new SessionIssuer($this->pool, $this->domainManager, $this->makeConfig(ipTtl: $ipTtl));
|
||||
$this->sessionIssuer->setLogger(new NullLogger());
|
||||
|
||||
$manager = new LoginManager($this->backupCodeManager, $this->sessionIssuer);
|
||||
$manager->setConfig($this->makeConfig(ipTtl: $ipTtl));
|
||||
$manager->setLogger(new NullLogger());
|
||||
$manager->setNonceCache(new ArrayAdapter());
|
||||
@@ -277,8 +284,10 @@ final class LoginManagerTest extends TestCase
|
||||
self::assertFalse($response->headers->has('Set-Cookie'));
|
||||
|
||||
// verify the IP session exists in the cache
|
||||
$reflection = new ReflectionProperty(LoginManager::class, 'sessionCache');
|
||||
$sessionCache = $reflection->getValue($manager);
|
||||
$issuer = $this->sessionIssuer;
|
||||
self::assertNotNull($issuer, 'makeLoginManager() should have built a session issuer.');
|
||||
$reflection = new ReflectionProperty(SessionIssuer::class, 'sessionCache');
|
||||
$sessionCache = $reflection->getValue($issuer);
|
||||
self::assertTrue($sessionCache->hasItem('ip_1.2.3.4'));
|
||||
}
|
||||
|
||||
@@ -361,7 +370,12 @@ final class LoginManagerTest extends TestCase
|
||||
$this->backupCodeManager = $this->createStub(BackupCodeInterface::class);
|
||||
$this->backupCodeManager->method('verifyAndConsume')->willReturn(false);
|
||||
|
||||
$manager = new LoginManager($pool, $this->backupCodeManager, $this->domainManager);
|
||||
/* the colliding pool must be the one the session issuer writes through,
|
||||
* because that is where the cookie is stored */
|
||||
$issuer = new SessionIssuer($pool, $this->domainManager, $this->makeConfig());
|
||||
$issuer->setLogger(new NullLogger());
|
||||
|
||||
$manager = new LoginManager($this->backupCodeManager, $issuer);
|
||||
$manager->setConfig($this->makeConfig());
|
||||
$manager->setLogger(new NullLogger());
|
||||
$manager->setNonceCache(new ArrayAdapter());
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Service\PasskeyCeremonyFactory;
|
||||
use JsonSerializable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
|
||||
use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\TrustPath\EmptyTrustPath;
|
||||
|
||||
/**
|
||||
* The factory is the single seam between the application and webauthn-lib, so
|
||||
* these tests pin the library behaviours the rest of the feature relies on.
|
||||
*/
|
||||
final class PasskeyCeremonyFactoryTest extends TestCase
|
||||
{
|
||||
private function makeFactory(): PasskeyCeremonyFactory
|
||||
{
|
||||
return new PasskeyCeremonyFactory();
|
||||
}
|
||||
|
||||
private function makeRecord(): CredentialRecord
|
||||
{
|
||||
return CredentialRecord::create(
|
||||
random_bytes(32),
|
||||
'public-key',
|
||||
['internal'],
|
||||
'none',
|
||||
EmptyTrustPath::create(),
|
||||
Uuid::v4(),
|
||||
'COSE_PUBLIC_KEY_BYTES',
|
||||
'user-handle',
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_exposes_the_serializer(): void
|
||||
{
|
||||
self::assertInstanceOf(SerializerInterface::class, $this->makeFactory()->serializer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the `none` attestation format is registered — the deliberate choice
|
||||
* recorded in `SECURITY.md`. Registering more formats would not make
|
||||
* attestation verifiable; it would only accept statements nothing checks.
|
||||
*/
|
||||
public function test_only_none_attestation_is_supported(): void
|
||||
{
|
||||
$manager = $this->makeFactory()->attestationStatementSupportManager();
|
||||
|
||||
self::assertInstanceOf(AttestationStatementSupportManager::class, $manager);
|
||||
self::assertTrue($manager->has('none'));
|
||||
self::assertFalse($manager->has('packed'));
|
||||
self::assertFalse($manager->has('fido-u2f'));
|
||||
self::assertFalse($manager->has('tpm'));
|
||||
self::assertFalse($manager->has('android-key'));
|
||||
self::assertFalse($manager->has('apple'));
|
||||
}
|
||||
|
||||
public function test_the_support_manager_has_the_none_support_registered(): void
|
||||
{
|
||||
$manager = $this->makeFactory()->attestationStatementSupportManager();
|
||||
|
||||
self::assertInstanceOf(
|
||||
NoneAttestationStatementSupport::class,
|
||||
$manager->get('none'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_credential_records_round_trip_through_storage(): void
|
||||
{
|
||||
$factory = $this->makeFactory();
|
||||
$record = $this->makeRecord();
|
||||
|
||||
$restored = $factory->deserializeCredential($factory->serializeCredential($record));
|
||||
|
||||
self::assertNotNull($restored);
|
||||
self::assertSame($record->publicKeyCredentialId, $restored->publicKeyCredentialId);
|
||||
self::assertSame($record->credentialPublicKey, $restored->credentialPublicKey);
|
||||
self::assertSame($record->userHandle, $restored->userHandle);
|
||||
self::assertSame($record->counter, $restored->counter);
|
||||
self::assertSame($record->transports, $restored->transports);
|
||||
self::assertSame($record->attestationType, $restored->attestationType);
|
||||
self::assertSame($record->backupEligible, $restored->backupEligible);
|
||||
self::assertSame($record->backupStatus, $restored->backupStatus);
|
||||
self::assertSame($record->uvInitialized, $restored->uvInitialized);
|
||||
self::assertSame($record->aaguid->__toString(), $restored->aaguid->__toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* A record is not JsonSerializable, so a plain json_encode() would silently
|
||||
* produce something that cannot be read back. The factory must not rely on
|
||||
* that path.
|
||||
*/
|
||||
public function test_credential_records_are_not_naively_json_encodable(): void
|
||||
{
|
||||
self::assertNotInstanceOf(JsonSerializable::class, $this->makeRecord());
|
||||
}
|
||||
|
||||
/**
|
||||
* Unreadable stored data degrades to null so a corrupt entry cannot produce
|
||||
* a 500 on the login page.
|
||||
*/
|
||||
public function test_unreadable_stored_data_returns_null(): void
|
||||
{
|
||||
self::assertNull($this->makeFactory()->deserializeCredential('{not valid json'));
|
||||
self::assertNull($this->makeFactory()->deserializeCredential(''));
|
||||
self::assertNull($this->makeFactory()->deserializeCredential('{"unexpected":"shape"}'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Service\PasskeyCeremonyStore;
|
||||
use App\Service\PasskeyCeremonyStoreInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use ReflectionMethod;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
|
||||
/**
|
||||
* The ceremony store holds the two properties the whole flow depends on: the
|
||||
* challenge is server-authoritative, and it can only be used once.
|
||||
*/
|
||||
final class PasskeyCeremonyStoreTest extends TestCase
|
||||
{
|
||||
private function makeStore(?ArrayAdapter $cache = null): PasskeyCeremonyStore
|
||||
{
|
||||
return new PasskeyCeremonyStore($cache ?? new ArrayAdapter());
|
||||
}
|
||||
|
||||
public function test_it_starts_a_login_ceremony(): void
|
||||
{
|
||||
$ceremony = $this->makeStore()->startLogin();
|
||||
|
||||
self::assertArrayHasKey('ceremonyId', $ceremony);
|
||||
self::assertArrayHasKey('challenge', $ceremony);
|
||||
self::assertSame(32, \strlen($ceremony['challenge']));
|
||||
self::assertNotSame('', $ceremony['ceremonyId']);
|
||||
}
|
||||
|
||||
public function test_it_starts_a_registration_ceremony_with_a_derived_user_handle(): void
|
||||
{
|
||||
$ceremony = $this->makeStore()->startRegistration('lyra');
|
||||
|
||||
self::assertSame(hash('sha256', 'lyra', true), $ceremony['userHandle']);
|
||||
/* the handle must not be the raw identity, or the label leaks into the
|
||||
* authenticator's credential list */
|
||||
self::assertStringNotContainsString('lyra', $ceremony['userHandle']);
|
||||
}
|
||||
|
||||
public function test_each_ceremony_gets_a_distinct_id_and_challenge(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
|
||||
$first = $store->startLogin();
|
||||
$second = $store->startLogin();
|
||||
|
||||
self::assertNotSame($first['ceremonyId'], $second['ceremonyId']);
|
||||
self::assertNotSame($first['challenge'], $second['challenge']);
|
||||
}
|
||||
|
||||
public function test_consume_returns_the_stored_challenge(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$ceremony = $store->startLogin();
|
||||
|
||||
$consumed = $store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN);
|
||||
|
||||
self::assertNotNull($consumed);
|
||||
self::assertSame($ceremony['challenge'], $consumed['challenge']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The replay guard. A second consume must fail, otherwise an observed
|
||||
* `finish` could be replayed against the same challenge.
|
||||
*/
|
||||
public function test_a_ceremony_can_only_be_consumed_once(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$ceremony = $store->startLogin();
|
||||
|
||||
self::assertNotNull($store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
|
||||
self::assertNull($store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
|
||||
}
|
||||
|
||||
public function test_an_unknown_id_yields_null(): void
|
||||
{
|
||||
self::assertNull($this->makeStore()->consume('never-issued', PasskeyCeremonyStoreInterface::TYPE_LOGIN));
|
||||
}
|
||||
|
||||
/**
|
||||
* A registration ceremony must not be usable to complete a login, or the
|
||||
* two flows' differing trust assumptions would blur together.
|
||||
*/
|
||||
public function test_a_ceremony_cannot_be_used_for_the_other_type(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$registration = $store->startRegistration('lyra');
|
||||
|
||||
self::assertNull($store->consume($registration['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
|
||||
/* and it was destroyed by that failed attempt, so it cannot be retried
|
||||
* with the correct type either */
|
||||
self::assertNull($store->consume($registration['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_REGISTER));
|
||||
}
|
||||
|
||||
/**
|
||||
* `makeCacheKey()` sanitises the base64url alphabet into `_`, so using it
|
||||
* directly on an id would let two distinct ids collide on one cache slot.
|
||||
* The store hashes instead; this asserts distinct ids stay distinct.
|
||||
*/
|
||||
public function test_distinct_ids_never_share_a_cache_key(): void
|
||||
{
|
||||
$cache = new ArrayAdapter();
|
||||
$store = $this->makeStore($cache);
|
||||
|
||||
$ids = [];
|
||||
for ($i = 0; $i < 50; ++$i) {
|
||||
$ids[] = $store->startRegistration("user$i")['ceremonyId'];
|
||||
}
|
||||
|
||||
self::assertCount(50, array_unique($ids));
|
||||
foreach ($ids as $id) {
|
||||
self::assertNotNull($store->consume($id, PasskeyCeremonyStoreInterface::TYPE_REGISTER));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_ceremonies_do_not_survive_the_cache_being_cleared(): void
|
||||
{
|
||||
$cache = new ArrayAdapter();
|
||||
$store = $this->makeStore($cache);
|
||||
$ceremony = $store->startLogin();
|
||||
|
||||
/* stand-in for expiry/eviction: a ceremony must not outlive its TTL, and
|
||||
* `nonceCache` is APCu precisely so it does not survive a restart */
|
||||
$cache->clear();
|
||||
|
||||
self::assertNull($store->consume($ceremony['ceremonyId'], PasskeyCeremonyStoreInterface::TYPE_LOGIN));
|
||||
}
|
||||
|
||||
/**
|
||||
* Malformed cache contents must degrade to "no ceremony" rather than
|
||||
* throwing into the listener, which would surface as a 500 on the login page.
|
||||
*/
|
||||
public function test_a_malformed_record_is_treated_as_absent(): void
|
||||
{
|
||||
$cache = new ArrayAdapter();
|
||||
$store = $this->makeStore($cache);
|
||||
|
||||
/* reach the private key derivation so the malformed value lands exactly
|
||||
* where a real ceremony record would */
|
||||
$key = new ReflectionMethod(PasskeyCeremonyStore::class, 'key');
|
||||
$item = $cache->getItem($key->invoke($store, 'bogus'));
|
||||
$item->set('not-an-array');
|
||||
$cache->save($item);
|
||||
|
||||
self::assertNull($store->consume('bogus', PasskeyCeremonyStoreInterface::TYPE_LOGIN));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Service\PasskeyCounterChecker;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Webauthn\Counter\CounterChecker;
|
||||
use Webauthn\Counter\ThrowExceptionIfInvalid;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\Exception\CounterException;
|
||||
use Webauthn\TrustPath\EmptyTrustPath;
|
||||
|
||||
/**
|
||||
* The counter policy is the difference between "works on real hardware" and
|
||||
* "works only in tests", so both halves of it are pinned here: the behaviour we
|
||||
* deliberately allow, and the behaviour we deliberately still refuse.
|
||||
*/
|
||||
final class PasskeyCounterCheckerTest extends TestCase
|
||||
{
|
||||
private function makeRecord(int $counter): CredentialRecord
|
||||
{
|
||||
return CredentialRecord::create(
|
||||
random_bytes(32),
|
||||
'public-key',
|
||||
['internal'],
|
||||
'none',
|
||||
EmptyTrustPath::create(),
|
||||
Uuid::v4(),
|
||||
'COSE_PUBLIC_KEY_BYTES',
|
||||
'user-handle',
|
||||
$counter,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this class exists for: a synchronised passkey reports 0
|
||||
* forever, so the first login of a brand-new credential must succeed.
|
||||
*/
|
||||
public function test_a_constant_zero_counter_is_accepted(): void
|
||||
{
|
||||
$checker = new PasskeyCounterChecker();
|
||||
$record = $this->makeRecord(0);
|
||||
|
||||
$checker->check($record, 0);
|
||||
$checker->check($record, 0);
|
||||
|
||||
/* reaching this point without an exception is the assertion */
|
||||
self::assertSame(0, $record->counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents *why* the library default cannot be used: it rejects the exact
|
||||
* scenario above. If a future library version relaxes this, the test fails
|
||||
* and the custom checker can be reconsidered rather than kept by habit.
|
||||
*/
|
||||
public function test_the_library_default_would_reject_a_constant_zero_counter(): void
|
||||
{
|
||||
$this->expectException(CounterException::class);
|
||||
|
||||
(new ThrowExceptionIfInvalid())->check($this->makeRecord(0), 0);
|
||||
}
|
||||
|
||||
public function test_a_counter_that_moves_forward_is_accepted(): void
|
||||
{
|
||||
$checker = new PasskeyCounterChecker();
|
||||
$record = $this->makeRecord(5);
|
||||
|
||||
$checker->check($record, 6);
|
||||
$checker->check($record, \PHP_INT_MAX);
|
||||
|
||||
self::assertSame(5, $record->counter);
|
||||
}
|
||||
|
||||
public function test_a_counter_that_moves_backwards_is_rejected(): void
|
||||
{
|
||||
$checker = new PasskeyCounterChecker();
|
||||
$record = $this->makeRecord(5);
|
||||
|
||||
$this->expectException(CounterException::class);
|
||||
|
||||
$checker->check($record, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exception carries both values, which the listener logs. Asserted so a
|
||||
* future refactor cannot quietly drop the diagnostic detail.
|
||||
*/
|
||||
public function test_the_rejection_reports_both_counters(): void
|
||||
{
|
||||
$checker = new PasskeyCounterChecker();
|
||||
$record = $this->makeRecord(9);
|
||||
|
||||
try {
|
||||
$checker->check($record, 3);
|
||||
self::fail('Expected a CounterException.');
|
||||
} catch (CounterException $exception) {
|
||||
self::assertSame(3, $exception->currentCounter);
|
||||
self::assertSame(9, $exception->authenticatorCounter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The checker under test must differ from the library default, otherwise
|
||||
* wiring the default back in by accident would go unnoticed.
|
||||
*/
|
||||
public function test_it_is_not_the_library_default(): void
|
||||
{
|
||||
self::assertInstanceOf(CounterChecker::class, new PasskeyCounterChecker());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Data\PasskeyCredential;
|
||||
use App\MonitorCacheKeys;
|
||||
use App\Service\PasskeyCeremonyFactory;
|
||||
use App\Service\PasskeyCredentialStore;
|
||||
use DateTimeImmutable;
|
||||
use LogicException;
|
||||
use Override;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\TrustPath\EmptyTrustPath;
|
||||
|
||||
/**
|
||||
* Covers credential persistence, the index, and the two failure modes the plan
|
||||
* called out: losing credentials on restart, and key collisions.
|
||||
*/
|
||||
final class PasskeyCredentialStoreTest extends TestCase
|
||||
{
|
||||
/** The real backing pool, so persistence can be asserted against it. */
|
||||
private ?ArrayAdapter $pool = null;
|
||||
|
||||
#[Override]
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->pool = new ArrayAdapter();
|
||||
}
|
||||
|
||||
private function makeStore(): PasskeyCredentialStore
|
||||
{
|
||||
return new PasskeyCredentialStore($this->pool(), new PasskeyCeremonyFactory());
|
||||
}
|
||||
|
||||
/** The pool for the current test; setUp() always assigns it. */
|
||||
private function pool(): ArrayAdapter
|
||||
{
|
||||
return $this->pool ?? throw new LogicException('setUp() did not run');
|
||||
}
|
||||
|
||||
/** @param array{userHandle?: string, counter?: int, backupEligible?: ?bool} $overrides */
|
||||
private function makeCredential(
|
||||
string $credentialId,
|
||||
string $identity = 'lyra',
|
||||
string $label = 'Laptop',
|
||||
array $overrides = [],
|
||||
): PasskeyCredential {
|
||||
$record = CredentialRecord::create(
|
||||
$credentialId,
|
||||
'public-key',
|
||||
['internal'],
|
||||
'none',
|
||||
EmptyTrustPath::create(),
|
||||
Uuid::v4(),
|
||||
'COSE_PUBLIC_KEY_BYTES',
|
||||
$overrides['userHandle'] ?? 'user-handle',
|
||||
$overrides['counter'] ?? 0,
|
||||
null,
|
||||
$overrides['backupEligible'] ?? true,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
|
||||
return new PasskeyCredential($record, $identity, $label, new DateTimeImmutable('2026-01-01 12:00:00'));
|
||||
}
|
||||
|
||||
public function test_save_then_find_round_trips_the_record(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$credentialId = random_bytes(32);
|
||||
$store->save($this->makeCredential($credentialId));
|
||||
|
||||
$found = $store->find($credentialId);
|
||||
|
||||
self::assertNotNull($found);
|
||||
self::assertSame($credentialId, $found->record->publicKeyCredentialId);
|
||||
self::assertSame('lyra', $found->identity);
|
||||
self::assertSame('Laptop', $found->label);
|
||||
self::assertSame('COSE_PUBLIC_KEY_BYTES', $found->record->credentialPublicKey);
|
||||
self::assertSame('user-handle', $found->record->userHandle);
|
||||
self::assertTrue($found->record->backupEligible);
|
||||
self::assertNull($found->lastUsedAt);
|
||||
}
|
||||
|
||||
public function test_find_returns_null_for_an_unknown_credential(): void
|
||||
{
|
||||
self::assertNull($this->makeStore()->find(random_bytes(32)));
|
||||
}
|
||||
|
||||
public function test_all_returns_every_saved_credential(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$store->save($this->makeCredential(random_bytes(32), label: 'One'));
|
||||
$store->save($this->makeCredential(random_bytes(32), label: 'Two'));
|
||||
$store->save($this->makeCredential(random_bytes(32), label: 'Three'));
|
||||
|
||||
self::assertCount(3, $store->all());
|
||||
self::assertSame(3, $store->count());
|
||||
}
|
||||
|
||||
public function test_find_by_identity_filters(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$store->save($this->makeCredential(random_bytes(32), identity: 'lyra'));
|
||||
$store->save($this->makeCredential(random_bytes(32), identity: 'lyra'));
|
||||
$store->save($this->makeCredential(random_bytes(32), identity: 'someone-else'));
|
||||
|
||||
self::assertCount(2, $store->findByIdentity('lyra'));
|
||||
self::assertCount(1, $store->findByIdentity('someone-else'));
|
||||
self::assertCount(0, $store->findByIdentity('nobody'));
|
||||
}
|
||||
|
||||
public function test_remove_forgets_the_credential_and_the_index_entry(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$credentialId = random_bytes(32);
|
||||
$store->save($this->makeCredential($credentialId));
|
||||
|
||||
self::assertTrue($store->remove($credentialId));
|
||||
self::assertNull($store->find($credentialId));
|
||||
self::assertSame(0, $store->count());
|
||||
self::assertSame([], $store->all());
|
||||
}
|
||||
|
||||
public function test_update_usage_refreshes_the_counter_and_last_used(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$credentialId = random_bytes(32);
|
||||
$store->save($this->makeCredential($credentialId, overrides: ['counter' => 0]));
|
||||
|
||||
/* the library updates the counter in place after a verified assertion */
|
||||
$used = $store->find($credentialId)->record;
|
||||
$used->counter = 7;
|
||||
$store->updateUsage($used);
|
||||
|
||||
$found = $store->find($credentialId);
|
||||
self::assertSame(7, $found->record->counter);
|
||||
self::assertNotNull($found->lastUsedAt);
|
||||
/* metadata must be preserved, not reset by the usage update */
|
||||
self::assertSame('lyra', $found->identity);
|
||||
self::assertSame('Laptop', $found->label);
|
||||
}
|
||||
|
||||
public function test_update_usage_ignores_unknown_credentials(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$record = $this->makeCredential(random_bytes(32))->record;
|
||||
|
||||
$store->updateUsage($record);
|
||||
|
||||
self::assertSame(0, $store->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct credential ids must never share a cache slot.
|
||||
*
|
||||
* `makeCacheKey()` alone is not injective for the base64url alphabet
|
||||
* ("abc-def" and "abc_def" both sanitise to "abc_def"), so the store hashes
|
||||
* the id. These two ids differ only by '-' vs '_' on purpose.
|
||||
*/
|
||||
public function test_credential_ids_that_differ_only_by_base64url_punctuation_do_not_collide(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$store->save($this->makeCredential('abc-def', label: 'Dash'));
|
||||
$store->save($this->makeCredential('abc_def', label: 'Underscore'));
|
||||
|
||||
self::assertSame(2, $store->count());
|
||||
self::assertSame('Dash', $store->find('abc-def')->label);
|
||||
self::assertSame('Underscore', $store->find('abc_def')->label);
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan's headline storage risk: without wrapping the pool in
|
||||
* MonitorCacheKeys, credentials would live only in the APCu-side pool and
|
||||
* vanish on the next restart, because PersistCache::persist() only flushes
|
||||
* keys a monitor recorded.
|
||||
*/
|
||||
public function test_saved_credentials_are_visible_to_the_persistent_pool(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$credentialId = random_bytes(32);
|
||||
$store->save($this->makeCredential($credentialId));
|
||||
|
||||
$monitor = new MonitorCacheKeys($this->pool());
|
||||
$recorded = $monitor->getKeys();
|
||||
|
||||
self::assertNotEmpty($recorded, 'the store must record its writes with MonitorCacheKeys');
|
||||
self::assertContains(
|
||||
'passkey_index',
|
||||
$recorded,
|
||||
'the index must be tracked so it is flushed to the persistent pool',
|
||||
);
|
||||
|
||||
$changes = $monitor->getChanges();
|
||||
self::assertArrayHasKey('passkey_index', $changes);
|
||||
|
||||
/* and the credential itself must be tracked, not just the index */
|
||||
$trackedCredentialKeys = array_filter(
|
||||
$recorded,
|
||||
static fn (string $key): bool => str_starts_with($key, 'passkey_cred_'),
|
||||
);
|
||||
self::assertNotEmpty($trackedCredentialKeys, 'the credential entry must be tracked too');
|
||||
}
|
||||
|
||||
/**
|
||||
* A corrupt or foreign payload must degrade to "unavailable", never to a
|
||||
* crash on the login page.
|
||||
*/
|
||||
public function test_a_corrupt_entry_is_skipped_rather_than_throwing(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$credentialId = random_bytes(32);
|
||||
$store->save($this->makeCredential($credentialId));
|
||||
|
||||
/* corrupt the stored record but leave the index intact */
|
||||
$key = 'passkey_cred_'.hash('sha256', $credentialId);
|
||||
$item = $this->pool()->getItem($key);
|
||||
$payload = $item->get();
|
||||
$payload['record'] = '{not valid json';
|
||||
$item->set($payload);
|
||||
$this->pool()->save($item);
|
||||
|
||||
self::assertNull($store->find($credentialId));
|
||||
/* all() must not throw, it must simply omit the broken entry */
|
||||
self::assertSame([], $store->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* The record decides which credential id it belongs to; an index entry
|
||||
* pointing somewhere else must not be honoured.
|
||||
*/
|
||||
public function test_a_record_that_disagrees_with_its_key_is_rejected(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$real = random_bytes(32);
|
||||
$store->save($this->makeCredential($real));
|
||||
|
||||
/* copy the payload to a different credential id's slot */
|
||||
$source = $this->pool()->getItem('passkey_cred_'.hash('sha256', $real));
|
||||
$otherId = random_bytes(32);
|
||||
$target = $this->pool()->getItem('passkey_cred_'.hash('sha256', $otherId));
|
||||
$target->set($source->get());
|
||||
$this->pool()->save($target);
|
||||
|
||||
self::assertNull($store->find($otherId));
|
||||
}
|
||||
|
||||
public function test_an_empty_index_reads_as_empty(): void
|
||||
{
|
||||
self::assertSame([], $this->makeStore()->all());
|
||||
self::assertSame(0, $this->makeStore()->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored value that is not the expected structure (for example written by
|
||||
* a different version) must read as "no such credential".
|
||||
*/
|
||||
public function test_a_payload_of_the_wrong_shape_reads_as_missing(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$credentialId = random_bytes(32);
|
||||
$store->save($this->makeCredential($credentialId));
|
||||
|
||||
$item = $this->pool()->getItem('passkey_cred_'.hash('sha256', $credentialId));
|
||||
$item->set('not-an-array');
|
||||
$this->pool()->save($item);
|
||||
|
||||
self::assertNull($store->find($credentialId));
|
||||
}
|
||||
|
||||
/** A payload missing one of the required metadata keys is also unusable. */
|
||||
public function test_a_payload_missing_metadata_reads_as_missing(): void
|
||||
{
|
||||
$store = $this->makeStore();
|
||||
$credentialId = random_bytes(32);
|
||||
$store->save($this->makeCredential($credentialId));
|
||||
|
||||
$key = 'passkey_cred_'.hash('sha256', $credentialId);
|
||||
$item = $this->pool()->getItem($key);
|
||||
$payload = $item->get();
|
||||
unset($payload['identity']);
|
||||
$item->set($payload);
|
||||
$this->pool()->save($item);
|
||||
|
||||
self::assertNull($store->find($credentialId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Enum\UserVerification;
|
||||
use App\Exception\PasskeyConfigurationException;
|
||||
use App\Service\DomainInterface;
|
||||
use App\Service\PasskeyPolicy;
|
||||
use App\Tests\Support\TotpTestHelper;
|
||||
use Override;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* Covers the availability rule (D1) and the HTTPS requirement (D4).
|
||||
*
|
||||
* The two decisions are enforced in one place precisely so that they can be
|
||||
* tested exhaustively here rather than re-derived at each call site.
|
||||
*/
|
||||
final class PasskeyPolicyTest extends TestCase
|
||||
{
|
||||
use TotpTestHelper;
|
||||
|
||||
/**
|
||||
* A stand-in for the real DomainManager that mirrors its base-domain rule:
|
||||
* `localhost` and bare IPs yield null; otherwise the last two labels are
|
||||
* kept, or three when the final two form a known multi-part TLD.
|
||||
*
|
||||
* Verified against DomainManager: "auth.example.com" => "example.com",
|
||||
* "auth.example.co.uk" => "example.co.uk", "auth" => "auth",
|
||||
* "localhost" => null.
|
||||
*/
|
||||
private function makeDomain(bool $subdomainRedirect, string $authSubdomain): DomainInterface
|
||||
{
|
||||
$authBase = null;
|
||||
if ('' !== $authSubdomain
|
||||
&& 'localhost' !== $authSubdomain
|
||||
&& !filter_var($authSubdomain, \FILTER_VALIDATE_IP)
|
||||
) {
|
||||
$parts = explode('.', strtolower($authSubdomain));
|
||||
$keep = 2;
|
||||
$count = \count($parts);
|
||||
if ($count > 2 && 'uk' === $parts[$count - 1] && \in_array($parts[$count - 2], ['co', 'org', 'ac', 'gov'], true)) {
|
||||
$keep = 3;
|
||||
}
|
||||
$authBase = implode('.', \array_slice($parts, -min($keep, $count)));
|
||||
}
|
||||
|
||||
return new class($subdomainRedirect, $authSubdomain, $subdomainRedirect ? $authBase : null) implements DomainInterface {
|
||||
public function __construct(
|
||||
private bool $redirect,
|
||||
private string $authSubdomain,
|
||||
private ?string $authBase,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function getAuthSubdomain(): ?string
|
||||
{
|
||||
return $this->redirect ? $this->authSubdomain : null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function validReturn(string $url): bool
|
||||
{
|
||||
return $this->redirect;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function matchesAuth(string $host): bool
|
||||
{
|
||||
return $this->redirect;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function authBase(): ?string
|
||||
{
|
||||
return $this->authBase;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private function makePolicy(
|
||||
bool $passkeyEnabled = true,
|
||||
bool $subdomainRedirect = true,
|
||||
string $authSubdomain = 'auth.example.com',
|
||||
string $userVerification = 'required',
|
||||
int $timeout = 60000,
|
||||
string $rpName = '',
|
||||
string $title = 'Pre-Authentication System',
|
||||
): PasskeyPolicy {
|
||||
$config = $this->makeConfig(
|
||||
passkeyEnabled: $passkeyEnabled,
|
||||
passkeyUserVerification: $userVerification,
|
||||
passkeyTimeout: $timeout,
|
||||
passkeyRpName: $rpName,
|
||||
title: $title,
|
||||
);
|
||||
|
||||
return new PasskeyPolicy($config, $this->makeDomain($subdomainRedirect, $authSubdomain));
|
||||
}
|
||||
|
||||
/* ── D1: enabled + prerequisite ─────────────────────────────────────── */
|
||||
|
||||
public function test_enabled_requires_both_the_switch_and_central_auth(): void
|
||||
{
|
||||
self::assertTrue($this->makePolicy()->isEnabled());
|
||||
self::assertFalse($this->makePolicy(passkeyEnabled: false)->isEnabled());
|
||||
self::assertFalse($this->makePolicy(subdomainRedirect: false)->isEnabled());
|
||||
}
|
||||
|
||||
public function test_rp_id_is_always_the_auth_base_domain(): void
|
||||
{
|
||||
self::assertSame('example.com', $this->makePolicy()->rpId());
|
||||
self::assertSame('example.co.uk', $this->makePolicy(authSubdomain: 'auth.example.co.uk')->rpId());
|
||||
}
|
||||
|
||||
public function test_rp_id_throws_when_not_configured(): void
|
||||
{
|
||||
$this->expectException(PasskeyConfigurationException::class);
|
||||
$this->makePolicy(subdomainRedirect: false)->rpId();
|
||||
}
|
||||
|
||||
/* ── D4: HTTPS is the only accepted origin ─────────────────────────── */
|
||||
|
||||
public function test_allowed_origin_is_always_https(): void
|
||||
{
|
||||
self::assertSame(['https://auth.example.com'], $this->makePolicy()->allowedOrigins());
|
||||
}
|
||||
|
||||
public function test_allowed_origin_never_reflects_the_request_scheme(): void
|
||||
{
|
||||
$policy = $this->makePolicy();
|
||||
$request = Request::create('http://auth.example.com/', 'GET');
|
||||
|
||||
self::assertSame(['https://auth.example.com'], $policy->allowedOrigins());
|
||||
self::assertFalse($policy->isAvailableFor($request));
|
||||
}
|
||||
|
||||
public function test_available_only_on_the_auth_host_over_https(): void
|
||||
{
|
||||
$policy = $this->makePolicy();
|
||||
|
||||
$secure = Request::create('https://auth.example.com/', 'GET');
|
||||
$insecure = Request::create('http://auth.example.com/', 'GET');
|
||||
$otherHost = Request::create('https://app.example.com/', 'GET');
|
||||
|
||||
self::assertTrue($policy->isAvailableFor($secure));
|
||||
self::assertFalse($policy->isAvailableFor($insecure));
|
||||
self::assertFalse($policy->isAvailableFor($otherHost));
|
||||
}
|
||||
|
||||
public function test_available_respects_the_switch(): void
|
||||
{
|
||||
$request = Request::create('https://auth.example.com/', 'GET');
|
||||
|
||||
self::assertFalse($this->makePolicy(passkeyEnabled: false)->isAvailableFor($request));
|
||||
}
|
||||
|
||||
/* ── boot-time assertion (D1 + D4) ─────────────────────────────────── */
|
||||
|
||||
public function test_assertion_is_silent_when_disabled(): void
|
||||
{
|
||||
$this->makePolicy(passkeyEnabled: false, subdomainRedirect: false)->assertConfigurationIsUsable();
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_assertion_passes_for_a_valid_configuration(): void
|
||||
{
|
||||
$this->makePolicy()->assertConfigurationIsUsable();
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
|
||||
public function test_assertion_fails_without_central_auth(): void
|
||||
{
|
||||
$this->expectException(PasskeyConfigurationException::class);
|
||||
$this->expectExceptionMessageMatches('/central authentication is not configured/');
|
||||
$this->makePolicy(subdomainRedirect: false, authSubdomain: '')->assertConfigurationIsUsable();
|
||||
}
|
||||
|
||||
public function test_assertion_fails_for_localhost(): void
|
||||
{
|
||||
/* localhost has no base domain, so it can never satisfy D1 */
|
||||
$this->expectException(PasskeyConfigurationException::class);
|
||||
$this->makePolicy(authSubdomain: 'localhost')->assertConfigurationIsUsable();
|
||||
}
|
||||
|
||||
public function test_assertion_fails_for_a_single_label_subdomain(): void
|
||||
{
|
||||
/* D4: no certificate can be issued for a single-label host */
|
||||
$this->expectException(PasskeyConfigurationException::class);
|
||||
$this->expectExceptionMessageMatches('/fully qualified domain name/');
|
||||
$this->makePolicy(authSubdomain: 'auth')->assertConfigurationIsUsable();
|
||||
}
|
||||
|
||||
/* ── configuration accessors ───────────────────────────────────────── */
|
||||
|
||||
public function test_rp_name_falls_back_to_the_title(): void
|
||||
{
|
||||
self::assertSame('Pre-Authentication System', $this->makePolicy(rpName: '')->rpName());
|
||||
self::assertSame('My Gateway', $this->makePolicy(rpName: 'My Gateway')->rpName());
|
||||
}
|
||||
|
||||
public function test_user_verification_and_timeout_are_passed_through(): void
|
||||
{
|
||||
$policy = $this->makePolicy(userVerification: 'preferred', timeout: 30000);
|
||||
|
||||
self::assertSame('preferred', $policy->userVerification());
|
||||
self::assertSame(30000, $policy->timeout());
|
||||
}
|
||||
|
||||
public function test_unknown_user_verification_falls_back_to_required(): void
|
||||
{
|
||||
/* an unrecognised value must never silently weaken the requirement */
|
||||
$policy = $this->makePolicy(
|
||||
userVerification: 'nonsense',
|
||||
);
|
||||
|
||||
self::assertSame(UserVerification::Required->value, $policy->userVerification());
|
||||
}
|
||||
|
||||
public function test_non_positive_timeout_falls_back_to_the_default(): void
|
||||
{
|
||||
self::assertSame(60000, $this->makePolicy(timeout: 0)->timeout());
|
||||
self::assertSame(60000, $this->makePolicy(timeout: -100)->timeout());
|
||||
}
|
||||
|
||||
public function test_auth_subdomain_is_exposed_for_ceremony_urls(): void
|
||||
{
|
||||
self::assertSame('auth.example.com', $this->makePolicy()->authSubdomain());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Service;
|
||||
|
||||
use App\Service\PasskeyCeremonyFactory;
|
||||
use App\Tests\Support\PasskeyTestHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Throwable;
|
||||
use Webauthn\AuthenticatorAssertionResponse;
|
||||
use Webauthn\AuthenticatorAttestationResponse;
|
||||
use Webauthn\AuthenticatorSelectionCriteria;
|
||||
use Webauthn\CredentialRecord;
|
||||
use Webauthn\PublicKeyCredential;
|
||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||
use Webauthn\PublicKeyCredentialParameters;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
use Webauthn\PublicKeyCredentialRpEntity;
|
||||
use Webauthn\PublicKeyCredentialUserEntity;
|
||||
|
||||
/**
|
||||
* Proves the ceremony verifies with real cryptography, before the manager is
|
||||
* built on top of it. If this file is green, the library is wired correctly and
|
||||
* a later failure is our logic rather than our configuration.
|
||||
*
|
||||
* Nothing is mocked: a real P-256 keypair signs a real `authenticatorData`, and
|
||||
* the CBOR attestation object is assembled exactly as an authenticator would.
|
||||
*/
|
||||
final class PasskeyRealCryptoSpikeTest extends TestCase
|
||||
{
|
||||
private const string ORIGIN = 'https://auth.example.com';
|
||||
|
||||
private const string RP_ID = 'example.com';
|
||||
|
||||
/** The identity registered throughout; its handle is derived, not sent. */
|
||||
private const string IDENTITY = 'lyra';
|
||||
|
||||
private static function userHandle(): string
|
||||
{
|
||||
return hash('sha256', self::IDENTITY, true);
|
||||
}
|
||||
|
||||
private function factory(): PasskeyCeremonyFactory
|
||||
{
|
||||
return new PasskeyCeremonyFactory();
|
||||
}
|
||||
|
||||
private function registrationOptions(string $challenge): PublicKeyCredentialCreationOptions
|
||||
{
|
||||
return new PublicKeyCredentialCreationOptions(
|
||||
new PublicKeyCredentialRpEntity('Preauth', self::RP_ID),
|
||||
new PublicKeyCredentialUserEntity(self::IDENTITY, self::userHandle(), self::IDENTITY),
|
||||
$challenge,
|
||||
[PublicKeyCredentialParameters::create('public-key', -7)],
|
||||
new AuthenticatorSelectionCriteria(
|
||||
AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_PLATFORM,
|
||||
'required',
|
||||
AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED,
|
||||
),
|
||||
'none',
|
||||
[],
|
||||
60000,
|
||||
);
|
||||
}
|
||||
|
||||
private function requestOptions(string $challenge): PublicKeyCredentialRequestOptions
|
||||
{
|
||||
return new PublicKeyCredentialRequestOptions($challenge, self::RP_ID, [], 'required', 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a registration and return the resulting record.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function register(PasskeyTestHelper $helper, string $credentialId): CredentialRecord
|
||||
{
|
||||
$challenge = random_bytes(32);
|
||||
$json = $helper->registrationCredential(self::RP_ID, $challenge, self::ORIGIN, $credentialId);
|
||||
|
||||
$credential = $this->factory()->serializer()->denormalize($json, PublicKeyCredential::class, 'json');
|
||||
self::assertInstanceOf(PublicKeyCredential::class, $credential);
|
||||
self::assertInstanceOf(AuthenticatorAttestationResponse::class, $credential->response);
|
||||
|
||||
return $this->factory()
|
||||
->creationCeremonyValidator([self::ORIGIN])
|
||||
->check($credential->response, $this->registrationOptions($challenge), self::RP_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $allowedOrigins
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
private function verifyAssertion(
|
||||
PasskeyTestHelper $helper,
|
||||
CredentialRecord $record,
|
||||
string $credentialId,
|
||||
string $challenge,
|
||||
int $counter,
|
||||
array $allowedOrigins = [self::ORIGIN],
|
||||
): CredentialRecord {
|
||||
$json = $helper->assertionCredential(
|
||||
self::RP_ID,
|
||||
$challenge,
|
||||
self::ORIGIN,
|
||||
$credentialId,
|
||||
$counter,
|
||||
self::userHandle(),
|
||||
);
|
||||
|
||||
$credential = $this->factory()->serializer()->denormalize($json, PublicKeyCredential::class, 'json');
|
||||
self::assertInstanceOf(PublicKeyCredential::class, $credential);
|
||||
self::assertInstanceOf(AuthenticatorAssertionResponse::class, $credential->response);
|
||||
|
||||
return $this->factory()
|
||||
->requestCeremonyValidator($allowedOrigins)
|
||||
->check($record, $credential->response, $this->requestOptions($challenge), self::RP_ID, self::userHandle());
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline claim: a real registration is accepted.
|
||||
*/
|
||||
public function test_a_real_registration_verifies(): void
|
||||
{
|
||||
$record = $this->register(new PasskeyTestHelper(), (new PasskeyTestHelper())->credentialId());
|
||||
|
||||
self::assertSame('none', $record->attestationType);
|
||||
self::assertNotSame('', $record->credentialPublicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* A real registration followed by a real assertion — the whole flow.
|
||||
*/
|
||||
public function test_a_real_assertion_verifies_after_registration(): void
|
||||
{
|
||||
$helper = new PasskeyTestHelper();
|
||||
$credentialId = $helper->credentialId();
|
||||
$record = $this->register($helper, $credentialId);
|
||||
|
||||
$counter = $record->counter + 1;
|
||||
$updated = $this->verifyAssertion($helper, $record, $credentialId, random_bytes(32), $counter);
|
||||
|
||||
self::assertSame($counter, $updated->counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* D4 in action: the same assertion is refused when the allow-list says
|
||||
* `http://`. The library accepts only what it is told to accept, which is
|
||||
* exactly why no exemption may be reintroduced.
|
||||
*/
|
||||
public function test_an_http_origin_is_rejected(): void
|
||||
{
|
||||
$helper = new PasskeyTestHelper();
|
||||
$credentialId = $helper->credentialId();
|
||||
$record = $this->register($helper, $credentialId);
|
||||
|
||||
$this->expectException(Throwable::class);
|
||||
|
||||
$this->verifyAssertion(
|
||||
$helper,
|
||||
$record,
|
||||
$credentialId,
|
||||
random_bytes(32),
|
||||
$record->counter + 1,
|
||||
['http://auth.example.com'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tampered challenge must fail, or the ceremony would not bind the
|
||||
* assertion to this session.
|
||||
*/
|
||||
public function test_a_different_challenge_is_rejected(): void
|
||||
{
|
||||
$helper = new PasskeyTestHelper();
|
||||
$credentialId = $helper->credentialId();
|
||||
$record = $this->register($helper, $credentialId);
|
||||
|
||||
/* sign one challenge, present another */
|
||||
$json = $helper->assertionCredential(
|
||||
self::RP_ID,
|
||||
random_bytes(32),
|
||||
self::ORIGIN,
|
||||
$credentialId,
|
||||
$record->counter + 1,
|
||||
self::userHandle(),
|
||||
);
|
||||
$credential = $this->factory()->serializer()->denormalize($json, PublicKeyCredential::class, 'json');
|
||||
self::assertInstanceOf(PublicKeyCredential::class, $credential);
|
||||
self::assertInstanceOf(AuthenticatorAssertionResponse::class, $credential->response);
|
||||
|
||||
$this->expectException(Throwable::class);
|
||||
|
||||
$this->factory()
|
||||
->requestCeremonyValidator([self::ORIGIN])
|
||||
->check(
|
||||
$record,
|
||||
$credential->response,
|
||||
$this->requestOptions(random_bytes(32)),
|
||||
self::RP_ID,
|
||||
self::userHandle(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A forged RP ID hash must fail, so a credential registered for one site
|
||||
* cannot be replayed at another.
|
||||
*/
|
||||
public function test_a_forged_rp_id_hash_is_rejected(): void
|
||||
{
|
||||
$helper = new PasskeyTestHelper();
|
||||
$credentialId = $helper->credentialId();
|
||||
$record = $this->register($helper, $credentialId);
|
||||
|
||||
$challenge = random_bytes(32);
|
||||
$json = $helper->assertionCredential(
|
||||
'evil.example.com',
|
||||
$challenge,
|
||||
self::ORIGIN,
|
||||
$credentialId,
|
||||
$record->counter + 1,
|
||||
self::userHandle(),
|
||||
);
|
||||
$credential = $this->factory()->serializer()->denormalize($json, PublicKeyCredential::class, 'json');
|
||||
self::assertInstanceOf(PublicKeyCredential::class, $credential);
|
||||
self::assertInstanceOf(AuthenticatorAssertionResponse::class, $credential->response);
|
||||
|
||||
$this->expectException(Throwable::class);
|
||||
|
||||
$this->factory()
|
||||
->requestCeremonyValidator([self::ORIGIN])
|
||||
->check($record, $credential->response, $this->requestOptions($challenge), self::RP_ID, self::userHandle());
|
||||
}
|
||||
|
||||
/**
|
||||
* A credential reporting a constant zero counter must be able to log in
|
||||
* repeatedly. This is the regression the lenient checker exists for, and it
|
||||
* is asserted through the full library path rather than the checker alone —
|
||||
* the library's own default would fail this.
|
||||
*/
|
||||
public function test_a_synchronised_passkey_with_a_zero_counter_can_log_in_repeatedly(): void
|
||||
{
|
||||
$helper = new PasskeyTestHelper();
|
||||
$credentialId = $helper->credentialId();
|
||||
$record = $this->register($helper, $credentialId);
|
||||
|
||||
/* model a synchronised passkey: the counter never advances */
|
||||
$record->counter = 0;
|
||||
|
||||
for ($attempt = 0; $attempt < 3; ++$attempt) {
|
||||
$updated = $this->verifyAssertion($helper, $record, $credentialId, random_bytes(32), 0);
|
||||
|
||||
self::assertSame(0, $updated->counter, "attempt $attempt");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same credential asserted from a sibling subdomain succeeds, because
|
||||
* the RP ID is the base domain. Asserted explicitly so the scope is
|
||||
* documented in code rather than only in the plan.
|
||||
*/
|
||||
public function test_a_sibling_subdomain_cannot_reuse_a_credential_whose_origin_is_wrong(): void
|
||||
{
|
||||
$helper = new PasskeyTestHelper();
|
||||
$credentialId = $helper->credentialId();
|
||||
$record = $this->register($helper, $credentialId);
|
||||
|
||||
$challenge = random_bytes(32);
|
||||
$json = $helper->assertionCredential(
|
||||
self::RP_ID,
|
||||
$challenge,
|
||||
'https://app.example.com',
|
||||
$credentialId,
|
||||
$record->counter + 1,
|
||||
self::userHandle(),
|
||||
);
|
||||
$credential = $this->factory()->serializer()->denormalize($json, PublicKeyCredential::class, 'json');
|
||||
self::assertInstanceOf(PublicKeyCredential::class, $credential);
|
||||
self::assertInstanceOf(AuthenticatorAssertionResponse::class, $credential->response);
|
||||
|
||||
/* the auth subdomain's origin is the only one allowed, so a ceremony
|
||||
* driven from a sibling host is refused even though the RP ID matches */
|
||||
$this->expectException(Throwable::class);
|
||||
|
||||
$this->factory()
|
||||
->requestCeremonyValidator([self::ORIGIN])
|
||||
->check($record, $credential->response, $this->requestOptions($challenge), self::RP_ID, self::userHandle());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user