# 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`).
