From 27ebc570f433aa5a3444beede104fa35b521ec43 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 18:12:32 +0200 Subject: [PATCH] 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) --- .../2026-06-14-webauthn-yubikey-design.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-webauthn-yubikey-design.md diff --git a/docs/superpowers/specs/2026-06-14-webauthn-yubikey-design.md b/docs/superpowers/specs/2026-06-14-webauthn-yubikey-design.md new file mode 100644 index 0000000..898b51f --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-webauthn-yubikey-design.md @@ -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`).