clusev/docs/superpowers/specs/2026-06-14-webauthn-yubikey...

7.1 KiB
Raw Blame History

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 assertionOptionsnavigator.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).