# Design — Airtight show-once recovery codes Date: 2026-06-15 Status: Approved (brainstorming) — ready for implementation plan Scope: low-priority hardening of the "show backup codes only once" feature ## 1. Problem The recovery-codes reveal (`app/Livewire/Modals/RecoveryCodes.php` + view) gates display behind a `#[Locked] public bool $revealed` prop, a one-time `2fa.codes_fresh` session flag, and a `2fa.download_grant`-gated download route. A Codex review noted a residual, low-severity vector: `#[Locked]` blocks property-update payloads but NOT Livewire snapshot hydration. A client that captured the `revealed=true` snapshot during the legitimate one-time reveal could replay that signed snapshot to re-render the (same) stored codes. Marginal exposure is ~zero — the replayer already saw those codes at the legitimate reveal, same user, no privilege escalation — so it was accepted as an inherent Livewire-statelessness limitation. This design removes the vector entirely for teams that want the show-once guarantee to be structural rather than UX-level. ## 2. Threat model Kill vector: a captured `revealed=true` snapshot replayed to `/livewire/update` re-renders stored codes server-side. Fix principle: the plaintext codes and the reveal-state must NEVER enter any persisted Livewire property or snapshot. - `mount()` does NOT run on hydrate — only on the initial component mount. So a replayed snapshot never re-triggers mount-time logic. - PHP-side dispatched events (`$this->dispatch(...)`) ride in the one-time response `effects.dispatches`, NOT in the snapshot. A replayed snapshot carries no dispatch. Therefore: if codes are delivered only via a transient dispatched event fired from `mount()`/`regenerate()`, and the component holds no codes/`revealed` state, a replayed or stale snapshot has literally nothing to re-render and fires no event. ## 3. Approach (selected) Transient dispatch → Alpine client-side render. Chosen over (a) a server-rendered design gated by a single-use hydrate nonce (more moving parts, doesn't cleanly kill the same-window race) and (b) a non-modal full-page reveal (breaks the wire-elements/modal UX across all four open sites). Approach below has the smallest blast radius and is idiomatic Livewire 3 + Alpine. All four modal-open sites go through a Livewire `openModal` roundtrip (wire-elements/modal), so `mount()` always runs inside a Livewire request and the dispatched event always rides back in that response. Open sites confirmed: - `app/Livewire/Auth/TwoFactorSetup.php` (first-factor enrollment → `put('2fa.codes_fresh')` + flash `open_recovery_modal`, redirect to settings) - `app/Livewire/Settings/Index.php` + `resources/views/livewire/settings/index.blade.php` (`x-init="$dispatch('openModal', ...)"` on the post-enrollment auto-open) - `app/Livewire/Settings/WebauthnKeys.php` (`$this->dispatch('openModal', ...)` after setting the flags) - `resources/views/livewire/settings/security.blade.php` (manage button — no fresh flag → hidden) ## 4. Component — `app/Livewire/Modals/RecoveryCodes.php` - Delete `#[Locked] public bool $revealed`. No codes property. The component holds zero secret state. - `mount()`: `if (session()->pull('2fa.codes_fresh', false)) { $this->dispatchCodes(); }` - `regenerate()`: `replaceRecoveryCodes()` + `AuditEvent::create([...])` (unchanged) + `session()->put('2fa.download_grant', true)` + `$this->dispatchCodes()` + notify. Drop the `$this->revealed = true` line. - New `protected function dispatchCodes(): void`: `$this->dispatch('reveal-codes', codes: Auth::user()->recoveryCodes());` - `render()`: returns the view with NO codes payload (drop the `'codes' => ...` array). Codes leave the server only inside the transient `reveal-codes` event detail — same visibility to the legitimate user, absent from the snapshot and from the server-rendered HTML. ## 5. View — `resources/views/livewire/modals/recovery-codes.blade.php` - Root element: `x-data="{ codes: [] }" @reveal-codes.window="codes = $event.detail.codes"`, plus `x-cloak` to suppress any raw-template flash before Alpine initializes. - Codes grid: replace the Blade `@foreach ($codes as $c)` with ``. - Reveal panel (warning + grid + download button + close) wrapped in `x-show="codes.length"`. - Hidden-notice panel (notice + regenerate button + close) wrapped in `x-show="!codes.length"`. - Dependency: ensure `[x-cloak] { display: none }` exists in `resources/css/app.css`; add it if missing. Known minor UX: on a fresh reveal there is a ~1-tick window where the hidden-notice panel can flash before the `reveal-codes` event lands and flips `codes`. No secret is exposed by this; accepted. ## 6. Download route — `routes/web.php` Append `->block(5, 5)` to the `two-factor.recovery.download` route. Laravel session blocking (Redis-backed atomic lock — Redis is in the stack) serializes concurrent same-session requests so the `session()->pull('2fa.download_grant')` cannot be double-read by a racing request. The `array`/`file` cache stores are also lock-capable, so the test suite is unaffected. Add a one-line comment noting the lock closes the concurrent-download race (true concurrency cannot be unit-tested). Grant TTL (added after Codex review — F2 defense-in-depth): the grant is stored as a Unix timestamp instead of a bool, and the route rejects any grant that is not an integer or is older than 10 minutes (600s): ```php $grantedAt = session()->pull('2fa.download_grant'); abort_unless(is_int($grantedAt) && now()->timestamp - $grantedAt <= 600, 403); ``` This bounds an unused grant's replay window to 10 minutes instead of the whole authenticated session. All three grant setters store `now()->timestamp` (see §8). CSRF (added after Codex review — F4): consuming the one-time grant is a state change, so the route is `POST` (not `GET`) and carries Laravel's CSRF protection. The download control in the view is a small `