# 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_heading') }}

{{ __('auth.recovery_subtitle') }}

{{-- Revealed panel: shown only once codes arrive via the transient `reveal-codes` event. The codes live in Alpine JS memory (x-for) and are never in the server HTML/snapshot. --}}

{{ __('auth.recovery_warning') }}

{{ __('auth.recovery_download') }} {{ __('common.close') }}
{{-- Hidden notice: the default state (no codes in memory) — manage view, or any replayed snapshot, which fires no event and therefore never populates `codes`. --}}

{{ __('auth.recovery_hidden_notice') }}

{{ __('auth.recovery_regenerate') }} {{ __('common.close') }}
``` Notes: - `[x-cloak] { display: none }` already exists in `resources/css/app.css` — no CSS change needed. It hides the modal body until Alpine initializes, preventing a raw-template flash. - Both panels exist in the server HTML; `x-show` toggles visibility client-side. The codes themselves come only from `x-for` over the JS `codes` array, so the server HTML/snapshot contains no codes. The `assertDontSee($codes[0])` assertions therefore hold in every state. --- ### Task 4: Add the session lock to the download route **Files:** - Modify: `routes/web.php` (the `two-factor.recovery.download` route, around line 57-70) - [ ] **Step 1: Append `->block(5, 5)` to the route** Find this line (end of the download route definition): ```php })->name('two-factor.recovery.download'); ``` Replace it with: ```php })->name('two-factor.recovery.download') // Session blocking (Redis-backed atomic lock) serializes concurrent same-session // requests so the one-time `2fa.download_grant` cannot be double-read by a racing // request. True concurrency cannot be unit-tested; lock-capable stores (redis in // prod, array/file in tests) keep the happy path green. ->block(5, 5); ``` Leave the route body (the `abort_unless(session()->pull('2fa.download_grant', false), 403)` and the streamed download) unchanged. --- ### Task 5: Green suite, browser verification, Codex review, commit **Files:** none (verification + commit) - [ ] **Step 1: Run the full recovery-codes suite (expect green)** Run: `docker compose exec app php artisan test --filter=RecoveryCodesModalTest` Expected: PASS — all of `test_manage_view_...`, `test_fresh_flag_...`, `test_replayed_...`, `test_regenerate_...`, `test_download_requires_a_fresh_grant`, `test_old_recovery_route_is_gone`. If `test_download_requires_a_fresh_grant` errors on the lock (rather than failing an assertion), confirm the test cache store supports atomic locks (`array`/`file`/`redis` do). If your test env uses an unsupported store, set `CACHE_STORE=array` for the test run; do not remove `->block()`. - [ ] **Step 2: Run the broader suite to catch regressions** Run: `docker compose exec app php artisan test --filter=Recovery` Expected: PASS (includes `UserRecoveryCodesTest`, `FirstFactorCodesTest`-style coverage that touches `codes_fresh`). Then run the full `docker compose exec app php artisan test` if time allows. - [ ] **Step 3: Browser verification (R12)** With the dev stack up (`docker compose up -d`), load both states and check HTTP 200 + zero console/network errors at 375 / 768 / 1280: - Fresh reveal: trigger first-factor enrollment (or `regenerate` from the modal) → confirm the 8 codes render and the download button appears. - Manage view: open the modal from Settings → Security ("Backup-Codes verwalten") with no fresh flag → confirm the hidden notice + regenerate button, no codes. - In DevTools Network, open the `openModal` (Livewire `/livewire/update`) response for the fresh reveal and confirm the codes appear ONLY under `effects.dispatches` (the `reveal-codes` event) and are ABSENT from `serverMemo`/`snapshot` and the rendered `html`. - Inspect the rendered DOM for leaked `@`/`{{ }}`/`$var`/`group.key` text (R17). - [ ] **Step 4: Codex review (R15)** Run `/codex:review` over the change. Fix and re-run until it reports no errors and no security issues. The task is not done until Codex passes. - [ ] **Step 5: Commit** ```bash cd /home/nexxo/clusev git checkout -b feature/recovery-codes-airtight-reveal git add app/Livewire/Modals/RecoveryCodes.php \ resources/views/livewire/modals/recovery-codes.blade.php \ routes/web.php \ tests/Feature/RecoveryCodesModalTest.php \ docs/superpowers/specs/2026-06-15-recovery-codes-airtight-reveal-design.md \ docs/superpowers/plans/2026-06-15-recovery-codes-airtight-reveal.md git commit -m "feat(2fa): make recovery-codes reveal airtight against snapshot replay Codes and reveal-state no longer enter any Livewire property/snapshot — they are delivered via a transient reveal-codes event and rendered client-side by Alpine, so a replayed/stale snapshot re-renders nothing. Add ->block(5,5) session lock to the download route to close the grant race." ``` (Run `git status` first to confirm no stray secrets are staged. Do not push unless asked.) --- ## Self-Review **Spec coverage:** - Threat model / structural show-once → Task 2 (no `$revealed`, no codes prop; mount/regenerate dispatch only). ✓ - Transient dispatch + Alpine render → Task 2 (`dispatchCodes`) + Task 3 (`x-data`/`x-for`/`x-show`). ✓ - Download-race lock → Task 4 (`->block(5, 5)`). ✓ - Replay-proof tests → Task 1 (`test_replayed_or_stale_snapshot_renders_no_codes`). ✓ - Removed locked-prop test → Task 1 (dropped + import removed). ✓ - No i18n / no CSS change → confirmed in File Structure (x-cloak already present). ✓ - Verification R12/R15 → Task 5. ✓ **Placeholder scan:** No TBD/TODO; every code step shows full file or exact edit. ✓ **Type/name consistency:** `dispatchCodes()` defined in Task 2 and referenced only there; event name `reveal-codes` and detail key `codes` match across component dispatch (Task 2), Alpine listener `$event.detail.codes` (Task 3), and tests `assertDispatched('reveal-codes', codes: ...)` (Task 1). Route name `two-factor.recovery.download` unchanged. ✓ --- ## Post-review additions (Codex review, 2026-06-15) Codex raised three findings; resolutions: - **F1 (Alpine reveal-state persists across morphs):** Not an exploitable vector — the snapshot-replay threat needs the codes *in the snapshot*, and they are not. The persisting state is client-side JS memory in the same tab that already saw the codes legitimately. No code change; proven by the snapshot assertions below. - **F2 (download grant had no expiry):** Implemented a 10-minute TTL. The grant is now stored as `now()->timestamp` by all three setters (`Modals/RecoveryCodes::regenerate()`, `Auth/TwoFactorSetup.php`, `Settings/WebauthnKeys.php`), and `routes/web.php` rejects any non-integer or >600s-old grant: `abort_unless(is_int($grantedAt) && now()->timestamp - $grantedAt <= 600, 403);` New test `test_a_stale_download_grant_is_rejected` covers the stale, legacy-bool, and fresh cases. The existing `test_download_requires_a_fresh_grant` now seeds `now()->timestamp`. - **F3 (snapshot leak not directly tested):** Added explicit snapshot assertions via a `assertSnapshotHidesCodes(array $codes, $component)` helper that loops over EVERY code and asserts `assertStringNotContainsString($code, json_encode($component->snapshot))` in the fresh-flag, replay, and regenerate tests, plus `assertNotDispatched('reveal-codes')` after `$refresh` (hydration). - **F4 (state-changing GET is CSRF-exposable — a cross-site request could burn the victim's one-time grant):** Changed the download route `GET`→`POST` (CSRF-protected) and made the view's download control a `
` + `@csrf`; download tests use `->post(...)`. No code disclosure was possible (cross-origin response is unreadable); the fix prevents the grant-consumption nuisance. - **F5 (`2fa.codes_fresh` reveal flag had no expiry — symmetric to F2):** The flag is now a timestamp set by `Auth/TwoFactorSetup.php` and `Settings/WebauthnKeys.php`, and `RecoveryCodes::mount()` reveals only within a 10-minute window. New test `test_a_stale_codes_fresh_flag_reveals_nothing`. `tests/Feature/FirstFactorCodesTest.php` updated to assert the flag is an int (`assertIsInt`) rather than `true`. - **F6 (timestamp comparison accepted a future value):** Both checks (download route and `mount()`) now validate `$age >= 0 && $age <= 600`, rejecting future-dated timestamps. New case added to `test_a_stale_download_grant_is_rejected`. Touched beyond the original plan: `app/Livewire/Auth/TwoFactorSetup.php`, `app/Livewire/Settings/WebauthnKeys.php` (both flag types → timestamps), and `tests/Feature/FirstFactorCodesTest.php` (flag type assertion). Final suite: `RecoveryCodesModalTest` 8 tests / 65 assertions, all green; recovery/2FA regression set green.