# 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
<?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
<?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:

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

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