clusev/docs/superpowers/plans/2026-06-15-recovery-codes-a...

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, 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

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 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

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 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):

    })->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 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
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 GETPOST (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_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.