23 KiB
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, adddispatchCodes(), adjustmount()/regenerate()/render().resources/views/livewire/modals/recovery-codes.blade.php— Alpinex-data+x-for+x-showpanels.routes/web.php—->block(5, 5)ontwo-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 set2fa.codes_fresh/2fa.download_grantas 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
namespace Tests\Feature;
use App\Livewire\Modals\RecoveryCodes;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Livewire\Livewire;
use Tests\TestCase;
class RecoveryCodesModalTest extends TestCase
{
use RefreshDatabase;
public function test_manage_view_without_fresh_flag_dispatches_nothing_and_hides_codes(): void
{
$user = User::factory()->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
CannotUpdateLockedPropertyExceptionimport and the oldtest_revealed_property_is_locked_against_client_tamperingtest are intentionally gone — the$revealedprop 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 arrayrecoveryCodes()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 keepassertDontSeeon the$refreshchain (already the case here) and rely on the fresh-mount block forassertNotDispatched. -
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
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use Illuminate\Support\Facades\Auth;
use LivewireUI\Modal\ModalComponent;
/**
* Backup (recovery) codes, shown once in a modal. Opened on first-factor enrollment and
* from Settings → Security ("Backup-Codes verwalten"). The download endpoint stays a route.
*
* Show-once is structural, not UX-level: the component holds NO codes and NO "revealed"
* state. Codes reach the browser only via a transient `reveal-codes` event fired from
* mount() (one-time `2fa.codes_fresh` flag) or regenerate(). Because mount() never runs on
* hydrate and dispatched events live in the one-time response effects (never the snapshot),
* a replayed/stale snapshot has nothing to re-render and fires no event — captured codes
* cannot be re-revealed by replaying a signed snapshot.
*/
class RecoveryCodes extends ModalComponent
{
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
// One-time, server-set flag (put on first-factor enrollment). pull() reads + forgets,
// so a refresh of the manage view will no longer reveal the codes. mount() does not
// run on hydrate, so a replayed snapshot never re-triggers this.
if (session()->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:
<div class="p-5 sm:p-6" x-data="{ codes: [] }" @reveal-codes.window="codes = $event.detail.codes" x-cloak>
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
<x-icon name="shield" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">{{ __('auth.recovery_heading') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('auth.recovery_subtitle') }}</p>
</div>
</div>
{{-- 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. --}}
<div x-show="codes.length">
<div class="mt-4 flex items-start gap-2.5 rounded-md border border-warning/25 bg-warning/10 px-4 py-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p class="text-sm text-ink-2">{{ __('auth.recovery_warning') }}</p>
</div>
<div class="mt-4 grid grid-cols-1 gap-2 rounded-md border border-line bg-inset p-4 sm:grid-cols-2">
<template x-for="c in codes" :key="c">
<span class="font-mono text-sm tracking-wide text-ink" x-text="c"></span>
</template>
</div>
<div class="mt-6 flex flex-wrap items-center justify-between gap-2">
<x-btn href="{{ route('two-factor.recovery.download') }}" variant="secondary">
<x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('auth.recovery_download') }}
</x-btn>
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
</div>
</div>
{{-- Hidden notice: the default state (no codes in memory) — manage view, or any replayed
snapshot, which fires no event and therefore never populates `codes`. --}}
<div x-show="!codes.length">
<div class="mt-4 flex items-start gap-2.5 rounded-md border border-line bg-inset px-4 py-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-ink-3" />
<p class="text-sm text-ink-2">{{ __('auth.recovery_hidden_notice') }}</p>
</div>
<div class="mt-6 flex flex-wrap items-center justify-between gap-2">
<x-btn variant="secondary" wire:click="regenerate" wire:target="regenerate" wire:loading.attr="disabled">
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('auth.recovery_regenerate') }}
</x-btn>
<x-btn variant="primary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
</div>
</div>
</div>
Notes:
[x-cloak] { display: none }already exists inresources/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-showtoggles visibility client-side. The codes themselves come only fromx-forover the JScodesarray, so the server HTML/snapshot contains no codes. TheassertDontSee($codes[0])assertions therefore hold in every state.
Task 4: Add the session lock to the download route
Files:
-
Modify:
routes/web.php(thetwo-factor.recovery.downloadroute, around line 57-70) -
Step 1: Append
->block(5, 5)to the route
Find this line (end of the download route definition):
})->name('two-factor.recovery.download');
Replace it with:
})->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
regeneratefrom 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 undereffects.dispatches(thereveal-codesevent) and are ABSENT fromserverMemo/snapshotand the renderedhtml. -
Inspect the rendered DOM for leaked
@/{{ }}/$var/group.keytext (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
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()->timestampby all three setters (Modals/RecoveryCodes::regenerate(),Auth/TwoFactorSetup.php,Settings/WebauthnKeys.php), androutes/web.phprejects any non-integer or >600s-old grant:abort_unless(is_int($grantedAt) && now()->timestamp - $grantedAt <= 600, 403);New testtest_a_stale_download_grant_is_rejectedcovers the stale, legacy-bool, and fresh cases. The existingtest_download_requires_a_fresh_grantnow seedsnow()->timestamp. -
F3 (snapshot leak not directly tested): Added explicit snapshot assertions via a
assertSnapshotHidesCodes(array $codes, $component)helper that loops over EVERY code and assertsassertStringNotContainsString($code, json_encode($component->snapshot))in the fresh-flag, replay, and regenerate tests, plusassertNotDispatched('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<form method="POST">+@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_freshreveal flag had no expiry — symmetric to F2): The flag is now a timestamp set byAuth/TwoFactorSetup.phpandSettings/WebauthnKeys.php, andRecoveryCodes::mount()reveals only within a 10-minute window. New testtest_a_stale_codes_fresh_flag_reveals_nothing.tests/Feature/FirstFactorCodesTest.phpupdated to assert the flag is an int (assertIsInt) rather thantrue. -
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 totest_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.