From eef79d1758acbbc7bafe27d9be0dfed4ce020008 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 18:16:10 +0200 Subject: [PATCH] docs(plan): WebAuthn/YubiKey 2nd-factor implementation plan TDD plan: install web-auth/webauthn-lib + credential storage, available() gate, ceremony options/verify (against the installed v5 API), login-challenge use, settings register/list/remove + JS, and a verification gate. Real key E2E deferred to a domain+HTTPS host. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-14-webauthn-yubikey.md | 605 ++++++++++++++++++ 1 file changed, 605 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-webauthn-yubikey.md diff --git a/docs/superpowers/plans/2026-06-14-webauthn-yubikey.md b/docs/superpowers/plans/2026-06-14-webauthn-yubikey.md new file mode 100644 index 0000000..67f8ef7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-webauthn-yubikey.md @@ -0,0 +1,605 @@ +# WebAuthn / YubiKey 2nd-Factor Implementation Plan (Phase 2) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add WebAuthn security keys (YubiKey / platform authenticators) as an optional login 2nd factor, gated on a domain + HTTPS, without weakening the existing TOTP + backup-code recovery. + +**Architecture:** A single `WebauthnService` wraps `web-auth/webauthn-lib`; everything else (gating, storage, Livewire wiring, JS) is project code. The key is an *alternative* at the existing TOTP challenge — TOTP + backup codes stay required and remain the only recovery. The feature is hidden + routes abort unless `DeploymentService::domain()` is set and the request is HTTPS (rpId = the domain; an IP is not a valid rpId). + +**Tech Stack:** Laravel 13, Livewire 3, `web-auth/webauthn-lib ^5.3` (verified to resolve on L13/PHP 8.3), Vite, Pest/PHPUnit. All tooling in the `app` container (R8). Copy bilingual (R16). + +**Lib-API note:** `web-auth/webauthn-lib` v5 is NOT in context7 and its v5 serializer/validator API must not be guessed. In the `WebauthnService` task, **read the installed package** (`vendor/web-auth/webauthn-lib/src`) for the exact classes (`WebauthnSerializerFactory`, `PublicKeyCredentialCreationOptions`, `PublicKeyCredentialRequestOptions`, `AuthenticatorAttestationResponseValidator`, `AuthenticatorAssertionResponseValidator`, `PublicKeyCredentialSource`, the `*Repository`/`CeremonyStep` shape) before writing the body. The service's **public interface + behavior + tests** below are fixed; only the internal lib calls are confirmed against the source. + +**Conventions:** tests `docker compose exec -T app php artisan test --filter=`; migrate `… php artisan migrate`; Pint `… -u "1002:1002" app ./vendor/bin/pint --dirty`; build `… npm run build`. Conventional Commits + Co-Authored-By trailer. + +**Testability:** the cryptographic ceremony (real key register/login) CANNOT run on this bare-IP host (no domain+HTTPS, IP rpId invalid). It is browser-verified later on a domain. Here: unit/feature tests (gating, option shape, storage, login-on-verified with a **mocked** `WebauthnService`) + R12 that the option is correctly hidden on bare-IP. + +--- + +### Task 1: Install lib + credential storage + +**Files:** +- Modify: `composer.json`/`composer.lock` (require) +- Create: `database/migrations/2026_06_14_000002_create_webauthn_credentials_table.php`, `app/Models/WebauthnCredential.php` +- Modify: `app/Models/User.php` +- Test: `tests/Feature/WebauthnCredentialTest.php` + +- [ ] **Step 1: Require the library** + +```bash +docker compose exec -T -u "1002:1002" app composer require web-auth/webauthn-lib:^5.3 --no-interaction +``` +Expected: installs `web-auth/webauthn-lib` 5.3.x + deps (symfony/serializer, web-auth/cose-lib, spomky-labs/cbor-php). + +- [ ] **Step 2: Migration** + +```php +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('credential_id'); // base64url of the raw credential id + $table->unique(['user_id', 'credential_id'], 'webauthn_user_credential_unique'); + $table->text('public_key'); // serialized PublicKeyCredentialSource (JSON) + $table->string('aaguid')->nullable(); + $table->json('transports')->nullable(); + $table->unsignedBigInteger('sign_count')->default(0); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('webauthn_credentials'); + } +}; +``` + +- [ ] **Step 3: Write the failing test** + +```php +create(); + $this->assertFalse($user->hasWebauthnCredentials()); + + WebauthnCredential::create([ + 'user_id' => $user->id, 'name' => 'YubiKey 5C', 'credential_id' => 'abc', + 'public_key' => '{}', 'sign_count' => 0, + ]); + + $this->assertTrue($user->fresh()->hasWebauthnCredentials()); + $this->assertCount(1, $user->fresh()->webauthnCredentials); + } +} +``` + +- [ ] **Step 4: Run test (fails — model/relation missing)** + +Run: `docker compose exec -T app php artisan test --filter=WebauthnCredentialTest` → FAIL. + +- [ ] **Step 5: Model + relation** + +Create `app/Models/WebauthnCredential.php`: +```php + 'array', + 'sign_count' => 'integer', + 'last_used_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} +``` + +In `app/Models/User.php` add `use Illuminate\Database\Eloquent\Relations\HasMany;` and: +```php + public function webauthnCredentials(): HasMany + { + return $this->hasMany(WebauthnCredential::class); + } + + public function hasWebauthnCredentials(): bool + { + return $this->webauthnCredentials()->exists(); + } +``` + +- [ ] **Step 6: Run test (pass), migrate, commit** + +Run: `docker compose exec -T app php artisan test --filter=WebauthnCredentialTest` → PASS. +```bash +docker compose exec -T app php artisan migrate +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +git add composer.json composer.lock database/migrations/2026_06_14_000002_create_webauthn_credentials_table.php app/Models/WebauthnCredential.php app/Models/User.php tests/Feature/WebauthnCredentialTest.php +git commit -m "feat(webauthn): install web-auth/webauthn-lib + credential storage" +``` + +--- + +### Task 2: `WebauthnService::available()` gate + +**Files:** +- Create: `app/Services/WebauthnService.php` (initial — just the gate + ctor) +- Test: `tests/Feature/WebauthnAvailableTest.php` + +- [ ] **Step 1: Write the failing test** + +```php +mock(DeploymentService::class); + $deployment->shouldReceive('domain')->andReturn($domain); + + return app(WebauthnService::class); + } + + public function test_available_only_with_domain_and_https(): void + { + // domain set + secure request + $this->app['request']->server->set('HTTPS', 'on'); + $this->assertTrue($this->service('panel.example.com')->available()); + } + + public function test_unavailable_without_domain(): void + { + $this->app['request']->server->set('HTTPS', 'on'); + $this->assertFalse($this->service(null)->available()); + } + + public function test_unavailable_without_https(): void + { + $this->app['request']->server->remove('HTTPS'); + $this->assertFalse($this->service('panel.example.com')->available()); + } +} +``` + +- [ ] **Step 2: Run test (fails — service missing)** + +Run: `docker compose exec -T app php artisan test --filter=WebauthnAvailableTest` → FAIL. + +- [ ] **Step 3: Initial service** + +```php +deployment->domain() !== null && request()->isSecure(); + } + + /** The Relying-Party ID = the active domain (asserted present by available()). */ + public function rpId(): string + { + return (string) $this->deployment->domain(); + } +} +``` + +- [ ] **Step 4: Run test (pass) + commit** + +Run: `docker compose exec -T app php artisan test --filter=WebauthnAvailableTest` → PASS (3). +```bash +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +git add app/Services/WebauthnService.php tests/Feature/WebauthnAvailableTest.php +git commit -m "feat(webauthn): WebauthnService::available gate (domain + https)" +``` + +--- + +### Task 3: Ceremony options + verification (against the installed lib) + +**Read `vendor/web-auth/webauthn-lib/src` first** to confirm the v5 API, then implement. + +**Files:** +- Modify: `app/Services/WebauthnService.php` +- Test: `tests/Feature/WebauthnOptionsTest.php` + +**Public interface to implement (fixed; internals use the lib):** +```php +public function registrationOptions(User $user): array; // JSON-ready PublicKeyCredentialCreationOptions; stores session('webauthn.register') +public function verifyRegistration(User $user, array $response, string $name): WebauthnCredential; // validates + stores; throws RuntimeException on mismatch +public function assertionOptions(User $user): array; // JSON-ready PublicKeyCredentialRequestOptions; stores session('webauthn.login') +public function verifyAssertion(User $user, array $response): bool; // validates against stored credential + session challenge; bumps sign_count/last_used_at +``` +Behavior: +- `registrationOptions`: rpId = `rpId()`, rpName "Clusev", user entity id = a stable per-user handle (e.g. `hash('sha256', $user->id)` raw-> the user's id bytes), `excludeCredentials` = the user's stored credential ids, `userVerification = preferred`, fresh random challenge persisted in the session. Serialize with the lib's `WebauthnSerializerFactory` serializer to an array. +- `verifyRegistration`: deserialize the client response, run `AuthenticatorAttestationResponseValidator` against the session challenge + an rp host/id check, map the resulting `PublicKeyCredentialSource` into a `WebauthnCredential` row (credential_id base64url, serialized source in `public_key`, aaguid, transports, sign_count). Reject duplicates (unique index). +- `assertionOptions`: rpId, `allowCredentials` = the user's credential ids, fresh challenge in session. +- `verifyAssertion`: find the matching stored credential by raw id, run `AuthenticatorAssertionResponseValidator`; on success persist the new sign count + `last_used_at` and return true; any failure returns false (never throws into the login path). + +- [ ] **Step 1: Write the failing test (option shape — no real ceremony needed)** + +```php +mock(DeploymentService::class); + $d->shouldReceive('domain')->andReturn($domain); + + return app(WebauthnService::class); + } + + public function test_registration_options_use_domain_rpid_and_store_challenge(): void + { + $user = User::factory()->create(); + $opts = $this->service()->registrationOptions($user); + + $this->assertSame('panel.example.com', data_get($opts, 'rp.id')); + $this->assertNotEmpty(data_get($opts, 'challenge')); + $this->assertNotNull(session('webauthn.register')); + } + + public function test_assertion_options_list_user_credentials(): void + { + $user = User::factory()->create(); + WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => base64_encode('rawid'), 'public_key' => '{}', 'sign_count' => 0]); + + $opts = $this->service()->assertionOptions($user); + + $this->assertSame('panel.example.com', data_get($opts, 'rpId')); + $this->assertNotEmpty(data_get($opts, 'allowCredentials')); + $this->assertNotNull(session('webauthn.login')); + } +} +``` +(If the lib's serialized key names differ from `rp.id`/`rpId`/`allowCredentials`, adjust these asserts to the real JSON shape after reading the lib — keep the intent: rpId = domain, challenge stored, allow-list present.) + +- [ ] **Step 2: Run test (fails)** → `--filter=WebauthnOptionsTest`. + +- [ ] **Step 3: Implement the four methods** against the installed lib (read vendor first). Map `PublicKeyCredentialSource` ↔ `WebauthnCredential` in a private helper. `verifyAssertion`/`verifyRegistration` wrap the lib validators in try/catch (verify returns false / throws localized). + +- [ ] **Step 4: Run test (pass) + commit** + +```bash +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +git add app/Services/WebauthnService.php tests/Feature/WebauthnOptionsTest.php +git commit -m "feat(webauthn): registration/assertion options + verification" +``` + +--- + +### Task 4: Login challenge integration + +**Files:** +- Modify: `app/Livewire/Auth/TwoFactorChallenge.php` +- Modify: `resources/views/livewire/auth/two-factor-challenge.blade.php` +- Test: `tests/Feature/TwoFactorWebauthnTest.php` + +- [ ] **Step 1: Write the failing test (mock the service's verifyAssertion)** + +```php +create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]); + session()->put('2fa.user', $user->id); + + $svc = $this->mock(WebauthnService::class); + $svc->shouldReceive('available')->andReturn(true); + $svc->shouldReceive('verifyAssertion')->andReturn(true); + + Livewire::test(TwoFactorChallenge::class) + ->call('verifyWebauthn', ['fake' => 'assertion']); + + $this->assertAuthenticatedAs($user); + } + + public function test_failed_assertion_does_not_log_in(): void + { + $user = User::factory()->create(['two_factor_secret' => 'x', 'two_factor_confirmed_at' => now()]); + session()->put('2fa.user', $user->id); + + $svc = $this->mock(WebauthnService::class); + $svc->shouldReceive('available')->andReturn(true); + $svc->shouldReceive('verifyAssertion')->andReturn(false); + + Livewire::test(TwoFactorChallenge::class) + ->call('verifyWebauthn', ['fake' => 'assertion']) + ->assertHasErrors('code'); + + $this->assertGuest(); + } +} +``` + +- [ ] **Step 2: Run test (fails — verifyWebauthn missing)** → `--filter=TwoFactorWebauthnTest`. + +- [ ] **Step 3: Add `verifyWebauthn` + `webauthnOptions` to `TwoFactorChallenge`** + +Inject `WebauthnService`. Add a computed `webauthnAvailable()` (= service `available()` AND the pending user has credentials), an `assertionOptions()` action returning JSON for the JS, and: +```php + public function verifyWebauthn(array $assertion, WebauthnService $webauthn): mixed + { + $key = 'two-factor:'.md5(session('2fa.user').'|'.request()->ip()); + if (RateLimiter::tooManyAttempts($key, 5)) { + throw ValidationException::withMessages(['code' => __('auth.too_many_attempts', ['seconds' => RateLimiter::availableIn($key)])]); + } + + $user = User::find(session('2fa.user')); + if (! $user || ! $webauthn->available() || ! $webauthn->verifyAssertion($user, $assertion)) { + RateLimiter::hit($key, 60); + throw ValidationException::withMessages(['code' => __('auth.webauthn_failed')]); + } + + RateLimiter::clear($key); + $remember = (bool) session('2fa.remember'); + session()->forget(['2fa.user', '2fa.remember']); + Auth::login($user, $remember); + session()->regenerate(); + + return $this->redirectIntended(route('dashboard'), navigate: true); + } +``` +(Reuses the same session gate + rate-limit as `verify()`.) + +- [ ] **Step 4: View — the Security-Key button (only when available)** + +In the challenge view, below the code form, add (gated): +```blade +@if ($this->webauthnAvailable()) +
{{ __('common.or') }}
+ + {{ __('auth.webauthn_login') }} + +@endif +``` +The JS (`window.clusevWebauthn.login`, Task 5) calls `$wire.assertionOptions()`, runs `navigator.credentials.get`, then `$wire.verifyWebauthn()`. + +- [ ] **Step 5: Run test (pass) + lang + commit** + +Add `auth.webauthn_failed`, `auth.webauthn_login`, `common.or` (DE+EN). Run `--filter=TwoFactorWebauthnTest` → PASS. +```bash +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +git add app/Livewire/Auth/TwoFactorChallenge.php resources/views/livewire/auth/two-factor-challenge.blade.php lang/de/auth.php lang/en/auth.php lang/de/common.php lang/en/common.php tests/Feature/TwoFactorWebauthnTest.php +git commit -m "feat(webauthn): use a security key at the login challenge" +``` + +--- + +### Task 5: Settings registration UI + JS module + +**Files:** +- Create: `app/Livewire/Settings/WebauthnKeys.php`, `resources/views/livewire/settings/webauthn-keys.blade.php`, `resources/js/webauthn.js` +- Modify: `resources/js/app.js` (import the module), `resources/views/livewire/settings/index.blade.php` (embed the keys panel in the security tab), `lang/{de,en}/auth.php` +- Test: `tests/Feature/WebauthnKeysTest.php` + +- [ ] **Step 1: Write the failing test (register via mocked verify, list, remove)** + +```php +create(); + $this->actingAs($user); + $svc = $this->mock(WebauthnService::class); + $svc->shouldReceive('available')->andReturn(true); + $svc->shouldReceive('verifyRegistration')->andReturnUsing(fn ($u, $r, $name) => WebauthnCredential::create([ + 'user_id' => $u->id, 'name' => $name, 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0, + ])); + + Livewire::test(WebauthnKeys::class) + ->set('newName', 'YubiKey 5C') + ->call('register', ['fake' => 'attestation']) + ->assertHasNoErrors(); + + $this->assertDatabaseHas('webauthn_credentials', ['user_id' => $user->id, 'name' => 'YubiKey 5C']); + } + + public function test_remove_deletes_credential(): void + { + $user = User::factory()->create(); + $this->actingAs($user); + $cred = WebauthnCredential::create(['user_id' => $user->id, 'name' => 'k', 'credential_id' => 'cid', 'public_key' => '{}', 'sign_count' => 0]); + $this->mock(WebauthnService::class)->shouldReceive('available')->andReturn(true); + + Livewire::test(WebauthnKeys::class)->call('remove', $cred->id); + + $this->assertDatabaseMissing('webauthn_credentials', ['id' => $cred->id]); + } +} +``` + +- [ ] **Step 2: Run test (fails)** → `--filter=WebauthnKeysTest`. + +- [ ] **Step 3: `app/Livewire/Settings/WebauthnKeys.php`** + +```php +available(), 404); + + return $webauthn->registrationOptions(Auth::user()); + } + + public function register(array $attestation, WebauthnService $webauthn): void + { + abort_unless($webauthn->available(), 404); + $this->validate(); + + $cred = $webauthn->verifyRegistration(Auth::user(), $attestation, trim($this->newName)); + $this->reset('newName'); + + AuditEvent::create(['user_id' => Auth::id(), 'actor' => Auth::user()->name ?? 'system', 'action' => 'webauthn.register', 'target' => $cred->name, 'ip' => request()->ip()]); + $this->dispatch('notify', message: __('auth.webauthn_added')); + } + + public function remove(int $id, WebauthnService $webauthn): void + { + abort_unless($webauthn->available(), 404); + $cred = Auth::user()->webauthnCredentials()->whereKey($id)->first(); + if ($cred) { + $cred->delete(); + AuditEvent::create(['user_id' => Auth::id(), 'actor' => Auth::user()->name ?? 'system', 'action' => 'webauthn.remove', 'target' => $cred->name, 'ip' => request()->ip()]); + $this->dispatch('notify', message: __('auth.webauthn_removed')); + } + } + + public function render() + { + return view('livewire.settings.webauthn-keys', [ + 'available' => app(WebauthnService::class)->available(), + 'keys' => Auth::user()->webauthnCredentials()->latest()->get(), + ]); + } +} +``` + +- [ ] **Step 4: View `resources/views/livewire/settings/webauthn-keys.blade.php`** + +Panel titled "Security-Keys". When `! $available`: a muted hint (`auth.webauthn_unavailable`). When available: a name input + "Hinzufügen" (`x-on:click="window.clusevWebauthn.register($wire)"`) and a list of `$keys` (name, `created_at`, `last_used_at`) each with a `danger-soft` remove button (`wire:click="remove(id)"` via the confirm modal, R5). Reuse existing field/btn classes. + +- [ ] **Step 5: JS `resources/js/webauthn.js`** — base64url helpers + `register($wire)` (calls `$wire.options()`, `navigator.credentials.create`, `$wire.register(encoded)`) and `login()` (locates the challenge component, `$wire.assertionOptions()`, `navigator.credentials.get`, `$wire.verifyWebauthn(encoded)`); expose `window.clusevWebauthn`. Import it in `resources/js/app.js`. + +- [ ] **Step 6: Embed in Settings security tab** + +In `resources/views/livewire/settings/index.blade.php` security tab, after the 2FA panel, add ``. + +- [ ] **Step 7: Run test (pass), lang, build, commit** + +Add `auth.webauthn_added/removed/unavailable/manage_title` + the keys-panel copy (DE+EN). Run `--filter=WebauthnKeysTest` → PASS. +```bash +docker compose exec -T -u "1002:1002" app ./vendor/bin/pint --dirty +docker compose exec -T -u "1002:1002" app npm run build +git add app/Livewire/Settings/WebauthnKeys.php resources/views/livewire/settings/webauthn-keys.blade.php resources/js/webauthn.js resources/js/app.js resources/views/livewire/settings/index.blade.php lang/de/auth.php lang/en/auth.php tests/Feature/WebauthnKeysTest.php +git commit -m "feat(webauthn): register/list/remove security keys in settings" +``` + +--- + +### Task 6: Verification (R12 gating + Codex + release) + +- [ ] **Step 1: Full suite + Pint** — `php artisan test` (only the pre-existing ExampleTest may fail); `./vendor/bin/pint --test`. +- [ ] **Step 2: Build** — `npm run build`. +- [ ] **Step 3: R12 (bare-IP, DE+EN, 375/768/1280):** Settings security tab shows the "Security-Keys" panel with the **unavailable hint** (no register button) since `domain()` is null; the login challenge shows **no** Security-Key button. HTTP 200, 0 console, DOM scan (R17). (Real key register/login is NOT testable here — see deferred note.) +- [ ] **Step 4: Codex review (R15)** — `codex review --base `; fix + re-run until clean. +- [ ] **Step 5: Handoff + release** — record in `docs/session-handoff.md` incl. the **deferred manual check** (real YubiKey on a domain+HTTPS host); version bump + CHANGELOG + tag + sanitized push per the session cadence. + +--- + +## Self-Review + +**Spec coverage:** library (Task 1), gating `available()` (Task 2), data model (Task 1), `WebauthnService` four methods (Tasks 2-3), challenge integration (Task 4), Settings register/list/remove + JS + gating hint (Task 5), security (rpId=domain via gate, single-use challenge, sign-count via lib, audited, recovery preserved — Tasks 2-5), testing strategy + R12 + deferred E2E (Task 6). ✓ + +**Placeholder scan:** the only intentional non-literal is Task 3's lib-internal body, explicitly pinned to reading `vendor/web-auth/webauthn-lib/src` (the v5 API is not guessable / not in context7); its public interface + tests are concrete. All other steps have full code. + +**Type consistency:** `WebauthnService` methods (`available`, `rpId`, `registrationOptions`, `verifyRegistration`, `assertionOptions`, `verifyAssertion`) defined in Tasks 2-3, used in Tasks 4-5. `WebauthnCredential` fields consistent across tasks. `verifyWebauthn`/`options`/`register`/`remove` Livewire actions consistent with their tests. `window.clusevWebauthn.{login,register}` consistent between Tasks 4 and 5. + +**Note (lib API):** Task 3 test asserts JSON keys `rp.id`/`rpId`/`allowCredentials`; confirm exact serialized names against the installed lib and adjust the asserts (intent fixed: rpId=domain, challenge stored, allow-list present).