docs(spec): WebAuthn/YubiKey 2nd-factor design (Phase 2)

Optional security-key 2nd factor via web-auth/webauthn-lib, gated on domain +
HTTPS (rpId = active domain). TOTP + backup codes stay the required 2FA and the
only recovery. Build now with unit/feature tests (mocked ceremony) + R12 gating;
real YubiKey end-to-end deferred to a domain+HTTPS host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 18:12:32 +02:00
parent 7faa5c7d00
commit 27ebc570f4
1 changed files with 121 additions and 0 deletions

View File

@ -0,0 +1,121 @@
# WebAuthn / YubiKey as a Login 2nd Factor — Design (Phase 2)
**Date:** 2026-06-14 · **Branch:** `feat/v1-foundation` · **Status:** approved
Adds **WebAuthn security keys (YubiKey / platform authenticators)** as an *optional* second
factor at login, on top of the existing TOTP + backup-code recovery (v0.5.0). It is **gated on a
domain + HTTPS**: WebAuthn requires a secure context and a domain-based Relying-Party ID — a bare
IP literal is not a valid rpId, so the feature is hidden (with a hint) until the panel runs under
a domain with a valid certificate. TOTP + backup codes remain the required 2FA and the only
recovery; a security key never weakens recovery.
Auth is hand-rolled (not Fortify): Livewire `Auth\{Login,TwoFactorChallenge,...}`, TOTP via
`pragmarx/google2fa`. Domain/scheme state comes from `DeploymentService::domain()` (active
domain, null on bare-IP) and `Request::isSecure()` (real scheme behind Caddy/TrustProxies).
## Library
`web-auth/webauthn-lib ^5.3` — framework-agnostic, actively maintained, resolves cleanly on
Laravel 13.8 / PHP 8.3 (verified by `composer require --dry-run`). Chosen over `laragear/webauthn`
(abandoned) and `laravel/passkeys` (oriented to passwordless passkey *login*, not a step-up 2nd
factor). It provides the ceremony primitives; we wire them into the existing custom auth.
## Gating — `WebauthnService::available()`
`available(): bool` = `app(DeploymentService::class)->domain() !== null && request()->isSecure()`.
The **rpId = the active domain**; rpName = "Clusev". When `available()` is false:
- Settings shows a muted hint ("Security-Keys sind nur unter einer Domain mit HTTPS verfügbar")
and no register button.
- The login challenge shows no "Security-Key" option.
- All WebAuthn routes/actions `abort(404)` (defense in depth — never run a ceremony with an IP rpId).
## Data model
Migration `create_webauthn_credentials_table`:
- `id`, `user_id` (fk, cascade), `name` (operator label), `credential_id` (binary, unique),
`public_key` (text), `aaguid` (nullable), `transports` (json nullable), `sign_count`
(unsigned big int, default 0), `last_used_at` (nullable), timestamps.
`WebauthnCredential` model (`user_id`, `name`, `credential_id`, `public_key`, `aaguid`,
`transports`, `sign_count`, `last_used_at`); `User hasMany(WebauthnCredential)` +
`hasWebauthnCredentials(): bool`.
The library's `PublicKeyCredentialSource` is mapped to/from this row (credential_id, public key,
sign count, transports). A small mapper lives in `WebauthnService`.
## `WebauthnService` (the one place that touches the lib)
- `available(): bool` — the gate above.
- `registrationOptions(User $user): array` — build `PublicKeyCredentialCreationOptions`
(rpId=domain, user handle = stable per-user id, `excludeCredentials` = the user's existing
credentials, `residentKey=discouraged`, `userVerification=preferred`), serialize to a
JSON-ready array via the lib's Webauthn serializer; persist the challenge in the session
(`webauthn.register.challenge`).
- `verifyRegistration(User $user, array $response, string $name): WebauthnCredential` — validate
the attestation against the session challenge with `AuthenticatorAttestationResponseValidator`
(attestation type `none` accepted — for a 2nd factor we verify the credential, not provenance),
store the resulting source as a `WebauthnCredential` named `$name`. Throws on mismatch.
- `assertionOptions(User $user): array` — build `PublicKeyCredentialRequestOptions`
(rpId=domain, `allowCredentials` = the user's credentials); persist `webauthn.login.challenge`.
- `verifyAssertion(User $user, array $response): bool` — validate with
`AuthenticatorAssertionResponseValidator` against the matching stored credential + session
challenge; on success bump `sign_count` + `last_used_at` and return true; false otherwise.
Serialization uses the lib's `WebauthnSerializerFactory` (symfony/serializer) for options → JSON
and incoming JSON → response objects.
## Flows
### Registration (Settings → Security, only when `available()` and TOTP already enrolled)
1. Operator clicks "Security-Key hinzufügen" → JS fetches `registrationOptions` (Livewire action
returns the JSON options) → `navigator.credentials.create({publicKey})`.
2. JS base64url-encodes the attestation response and posts it back (Livewire action) with a label.
3. `verifyRegistration()` stores the credential. The key appears in the list (name, added date,
last used) with a remove action (confirm modal, R5; audited `webauthn.register` /
`webauthn.remove`).
### Login (TwoFactorChallenge, only when `available()` and the user has ≥1 credential)
1. Alongside the existing code field, show "Mit Security-Key anmelden".
2. JS fetches `assertionOptions``navigator.credentials.get({publicKey})` → posts the assertion.
3. `verifyAssertion()` true → complete login exactly like a TOTP success (clear rate-limit,
`Auth::login($user, $remember)`, regenerate session, redirect intended). TOTP code and backup
codes remain fully usable in parallel.
The `2fa.user`/`2fa.remember` session gate and the 5/min rate-limit are reused unchanged.
## JS
A small `resources/js/webauthn.js` module: base64url encode/decode helpers + `register(options)`
and `authenticate(options)` wrappers around `navigator.credentials`. Wired to the Livewire
components via dispatched browser events / `$wire` calls. No new build system; bundled by Vite.
## Security
- rpId strictly = the active domain (never an IP); ceremonies refuse to run otherwise (the gate).
- Challenges are single-use, stored server-side in the session, compared by the lib.
- `sign_count` regression check (the lib flags a cloned authenticator).
- Registration requires an authenticated, already-2FA-enrolled session; removal + registration are
audited. Removing the last key never removes TOTP/backup-code 2FA (recovery preserved).
- User verification `preferred` (PIN/biometric when the authenticator supports it).
## Testing
- **Unit/feature (now):**
- `WebauthnService::available()` across domain×scheme combos (mock `DeploymentService` +
request scheme): true only for domain + secure.
- `registrationOptions`/`assertionOptions` produce options with rpId = the domain and the right
allow/exclude credential lists; challenge persisted in session.
- Credential storage + `User::hasWebauthnCredentials` + remove.
- Challenge integration: with `WebauthnService` **mocked** so `verifyAssertion` returns true, a
posted assertion completes login (`assertAuthenticatedAs`); when false, it does not.
- Routes/actions abort when `available()` is false (bare-IP).
- **R12 (now, bare-IP):** Settings + challenge correctly **hide** the WebAuthn option and show the
hint; no console errors.
- **Deferred to a domain+HTTPS host:** real YubiKey register + login end-to-end (the cryptographic
ceremony cannot run with an IP rpId). Documented in the handoff as the remaining manual check.
## Out of scope
- Passwordless / passkey first-factor login (this is a 2nd factor only).
- Changing onboarding (TOTP + backup codes stay required) or the recovery flows.
- Attestation-provenance / MDS validation (not needed for a 2nd factor; attestation `none`).