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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 18:16:10 +02:00
parent 27ebc570f4
commit eef79d1758
1 changed files with 605 additions and 0 deletions

View File

@ -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=<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).