# 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=<Name>`; 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
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('webauthn_credentials', function (Blueprint $table) {
            $table->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
<?php

namespace Tests\Feature;

use App\Models\User;
use App\Models\WebauthnCredential;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class WebauthnCredentialTest extends TestCase
{
    use RefreshDatabase;

    public function test_user_has_webauthn_credentials(): void
    {
        $user = User::factory()->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
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class WebauthnCredential extends Model
{
    protected $guarded = [];

    protected $casts = [
        'transports' => '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
<?php

namespace Tests\Feature;

use App\Services\DeploymentService;
use App\Services\WebauthnService;
use Tests\TestCase;

class WebauthnAvailableTest extends TestCase
{
    private function service(?string $domain): WebauthnService
    {
        $deployment = $this->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
<?php

namespace App\Services;

use Illuminate\Http\Request;

class WebauthnService
{
    public function __construct(private DeploymentService $deployment) {}

    /** WebAuthn needs a secure context AND a domain rpId — never a bare IP. */
    public function available(): bool
    {
        return $this->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
<?php

namespace Tests\Feature;

use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\DeploymentService;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class WebauthnOptionsTest extends TestCase
{
    use RefreshDatabase;

    private function service(string $domain = 'panel.example.com'): WebauthnService
    {
        $d = $this->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
<?php

namespace Tests\Feature;

use App\Livewire\Auth\TwoFactorChallenge;
use App\Models\User;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;

class TwoFactorWebauthnTest extends TestCase
{
    use RefreshDatabase;

    public function test_verified_assertion_logs_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(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())
    <div class="flex items-center gap-3"><span class="h-px flex-1 bg-line"></span><span class="font-mono text-[10px] uppercase tracking-wider text-ink-4">{{ __('common.or') }}</span><span class="h-px flex-1 bg-line"></span></div>
    <x-btn variant="secondary" size="lg" class="w-full" x-data x-on:click="window.clusevWebauthn?.login()">
        <x-icon name="shield" class="h-4 w-4" /> {{ __('auth.webauthn_login') }}
    </x-btn>
@endif
```
The JS (`window.clusevWebauthn.login`, Task 5) calls `$wire.assertionOptions()`, runs `navigator.credentials.get`, then `$wire.verifyWebauthn(<assertion>)`.

- [ ] **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
<?php

namespace Tests\Feature;

use App\Livewire\Settings\WebauthnKeys;
use App\Models\User;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;

class WebauthnKeysTest extends TestCase
{
    use RefreshDatabase;

    public function test_register_stores_credential(): void
    {
        $user = User::factory()->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
<?php

namespace App\Livewire\Settings;

use App\Models\AuditEvent;
use App\Models\WebauthnCredential;
use App\Services\WebauthnService;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Validate;
use Livewire\Component;

class WebauthnKeys extends Component
{
    #[Validate('required|string|max:60')]
    public string $newName = '';

    public function options(WebauthnService $webauthn): array
    {
        abort_unless($webauthn->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 `<livewire:settings.webauthn-keys />`.

- [ ] **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 <pre-feature-sha>`; 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).
