# Airtight Show-Once Recovery Codes — Implementation Plan > **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:** Remove the snapshot-replay vector from the recovery-codes reveal so the plaintext codes and the "revealed" state never enter any persisted Livewire property or snapshot. **Architecture:** The `RecoveryCodes` modal keeps zero secret state. On a fresh reveal (one-time `2fa.codes_fresh` flag in `mount()`, or `regenerate()`) it dispatches a transient `reveal-codes` browser event carrying the codes; an Alpine `x-data` holds them in JS memory only and renders them with `x-for`. Because `mount()` never runs on hydrate and dispatched events live in the one-time response `effects.dispatches` (never the snapshot), a replayed/stale snapshot has nothing to re-render and fires no event. The download route gains a Redis-backed session lock (`->block(5, 5)`) to close the concurrent-download grant race. **Tech Stack:** Laravel 13, Livewire 3 (class-based, not Volt), Alpine.js, wire-elements/modal, Tailwind v4, Redis (cache/session locks). All tooling runs in-container (R8). **Spec:** `docs/superpowers/specs/2026-06-15-recovery-codes-airtight-reveal-design.md` --- ## File Structure - `tests/Feature/RecoveryCodesModalTest.php` — rewritten test suite (the executable spec). - `app/Livewire/Modals/RecoveryCodes.php` — remove `$revealed`, add `dispatchCodes()`, adjust `mount()`/`regenerate()`/`render()`. - `resources/views/livewire/modals/recovery-codes.blade.php` — Alpine `x-data` + `x-for` + `x-show` panels. - `routes/web.php` — `->block(5, 5)` on `two-factor.recovery.download` + comment. **Not touched (verified during design):** - `resources/css/app.css` — `[x-cloak] { display: none }` already present (line ~107). No change. - `app/Livewire/Auth/TwoFactorSetup.php`, `app/Livewire/Settings/WebauthnKeys.php`, `app/Livewire/Settings/Index.php` — still set `2fa.codes_fresh` / `2fa.download_grant` as today. No change. - No i18n changes — no new visible strings. **Commit strategy:** The component, view, and route changes are coupled (the suite cannot go green until all three land), so there is a single feature commit after the suite passes (Task 5). This is intentional given the coupling. --- ### Task 1: Rewrite the test suite (the failing spec) **Files:** - Modify (replace whole file): `tests/Feature/RecoveryCodesModalTest.php` - [ ] **Step 1: Replace the test file with the new spec** Replace the entire contents of `tests/Feature/RecoveryCodesModalTest.php` with: ```php create(); $codes = $user->replaceRecoveryCodes(); // Opened from "Backup-Codes verwalten" with no fresh-generation flag: never reveal. // No reveal event fires, and the codes never appear in the server-rendered HTML. Livewire::actingAs($user)->test(RecoveryCodes::class) ->assertNotDispatched('reveal-codes') ->assertDontSee($codes[0]) ->assertDontSee($codes[7]) ->assertSee(__('auth.recovery_hidden_notice')); } public function test_fresh_flag_dispatches_codes_once_and_keeps_them_out_of_the_snapshot(): void { $user = User::factory()->create(); $codes = $user->replaceRecoveryCodes(); session(['2fa.codes_fresh' => true]); // The fresh flag delivers the codes via a one-time transient event — never as a // property and never in the server-rendered HTML, so they are absent from the snapshot. Livewire::actingAs($user)->test(RecoveryCodes::class) ->assertDispatched('reveal-codes', codes: $codes) ->assertDontSee($codes[0]) ->assertDontSee($codes[7]); // pull() forgets the flag — it is single-use. $this->assertFalse(session()->has('2fa.codes_fresh')); } public function test_replayed_or_stale_snapshot_renders_no_codes(): void { $user = User::factory()->create(); $codes = $user->replaceRecoveryCodes(); session(['2fa.codes_fresh' => true]); // Legitimate one-time reveal: consumes the flag, fires the event once, renders no codes. // A re-render of the SAME component (hydrate does not re-run mount()) renders no codes — // this models replaying a captured snapshot to /livewire/update. Livewire::actingAs($user)->test(RecoveryCodes::class) ->assertDispatched('reveal-codes', codes: $codes) ->assertDontSee($codes[0]) ->call('$refresh') ->assertDontSee($codes[0]); // A fresh mount from the now-consumed flag (the state a replayed snapshot starts from) // dispatches nothing and renders nothing — codes cannot be re-revealed. Livewire::actingAs($user)->test(RecoveryCodes::class) ->assertNotDispatched('reveal-codes') ->assertDontSee($codes[0]); } public function test_regenerate_dispatches_the_new_codes_without_rendering_them(): void { $user = User::factory()->create(); $old = $user->replaceRecoveryCodes(); // Manage view (no flag) → nothing dispatched → regenerate → the NEW set is dispatched once. $component = Livewire::actingAs($user)->test(RecoveryCodes::class) ->assertNotDispatched('reveal-codes') ->call('regenerate'); $new = $user->fresh()->recoveryCodes(); $this->assertNotEquals($old, $new); // New set delivered via the transient event; neither the old nor the new set is in the HTML. $component->assertDispatched('reveal-codes', codes: $new) ->assertDontSee($old[0]) ->assertDontSee($new[0]); // Regenerate also grants exactly one download of the fresh set. $this->assertTrue(session()->has('2fa.download_grant')); } public function test_download_requires_a_fresh_grant(): void { $user = User::factory()->create(); $user->replaceRecoveryCodes(); // Without a fresh-generation grant, a direct hit of the download route is forbidden, // so stored codes can never be re-downloaded by an authed user at will. $this->actingAs($user) ->get(route('two-factor.recovery.download')) ->assertForbidden(); // With the one-time grant (set when codes are freshly generated/regenerated) the // download succeeds exactly once. session(['2fa.download_grant' => true]); $this->actingAs($user) ->get(route('two-factor.recovery.download')) ->assertOk() ->assertDownload('clusev-recovery-codes.txt'); // The grant is single-use: pull() consumed it, so an immediate re-download 403s. $this->actingAs($user) ->get(route('two-factor.recovery.download')) ->assertForbidden(); } public function test_old_recovery_route_is_gone(): void { $this->assertFalse(Route::has('two-factor.recovery')); $this->assertTrue(Route::has('two-factor.recovery.download')); } } ``` Notes for the implementer: - The `CannotUpdateLockedPropertyException` import and the old `test_revealed_property_is_locked_against_client_tampering` test are intentionally gone — the `$revealed` prop no longer exists, so there is nothing to lock. - `assertDispatched('reveal-codes', codes: $codes)` matches the named event param against the dispatched array. `replaceRecoveryCodes()` returns the same array `recoveryCodes()` later reads, so they compare equal. - The authoritative replay proof is the second block of `test_replayed_or_stale_snapshot_renders_no_codes` (a brand-new mount with the flag already consumed). If your Livewire version accumulates dispatch effects across roundtrips on a single testable, only keep `assertDontSee` on the `$refresh` chain (already the case here) and rely on the fresh-mount block for `assertNotDispatched`. - [ ] **Step 2: Run the suite to verify it fails (red)** Run: `docker compose exec app php artisan test --filter=RecoveryCodesModalTest` Expected: FAIL. The current component dispatches no `reveal-codes` event and renders the codes into the HTML when `revealed`, so `test_fresh_flag_...` (expects a dispatch + `assertDontSee`), `test_replayed_...`, `test_regenerate_...`, and `test_manage_view_...` (expects `assertNotDispatched`) fail. The download tests still pass. Do not commit yet — the suite is red until the implementation tasks land. --- ### Task 2: Strip secret state from the component, dispatch codes transiently **Files:** - Modify (replace whole file): `app/Livewire/Modals/RecoveryCodes.php` - [ ] **Step 1: Replace the component with the stateless version** Replace the entire contents of `app/Livewire/Modals/RecoveryCodes.php` with: ```php pull('2fa.codes_fresh', false)) { $this->dispatchCodes(); } } /** Regenerate the current user's backup codes (invalidates the old set). */ public function regenerate(): void { Auth::user()->replaceRecoveryCodes(); AuditEvent::create([ 'user_id' => Auth::id(), 'actor' => Auth::user()->name ?? 'system', 'action' => 'two_factor.recovery_regenerate', 'target' => Auth::user()->email, 'ip' => request()->ip(), ]); // One-time download grant for the freshly regenerated set (consumed by the // download route). Without it a later direct hit of the route 403s. session()->put('2fa.download_grant', true); // Reveal the freshly generated set once, via the transient event (never a property). $this->dispatchCodes(); $this->dispatch('notify', message: __('auth.recovery_regenerated')); } public function render() { return view('livewire.modals.recovery-codes'); } /** * Deliver the current user's recovery codes to the browser via a one-time event. The * codes never enter a Livewire property or the snapshot — Alpine holds them in JS memory * only, so a replayed snapshot cannot re-render them. */ protected function dispatchCodes(): void { $this->dispatch('reveal-codes', codes: Auth::user()->recoveryCodes()); } } ``` What changed vs. the old file: removed the `Livewire\Attributes\Locked` import and the `#[Locked] public bool $revealed` property; `mount()` now dispatches instead of setting `$revealed`; `regenerate()` drops `$this->revealed = true` and dispatches; `render()` no longer passes a `codes` array; added `dispatchCodes()`. --- ### Task 3: Render codes client-side via Alpine (never server-rendered) **Files:** - Modify (replace whole file): `resources/views/livewire/modals/recovery-codes.blade.php` - [ ] **Step 1: Replace the view with the Alpine version** Replace the entire contents of `resources/views/livewire/modals/recovery-codes.blade.php` with: ```blade
{{ __('auth.recovery_subtitle') }}
{{ __('auth.recovery_warning') }}
{{ __('auth.recovery_hidden_notice') }}