# 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 `
` with `@csrf` wrapping the button — behaves identically to a download link, but a cross-site request can no longer burn the victim's grant. ## 7. Tests — `tests/Feature/RecoveryCodesModalTest.php` - Remove `test_revealed_property_is_locked_against_client_tampering` (the `revealed` prop no longer exists) and the now-unused `CannotUpdateLockedPropertyException` import. - `manage view (no flag)`: `assertNotDispatched('reveal-codes')` + `assertDontSee($codes[0])` + `assertSee(__('auth.recovery_hidden_notice'))`. - `fresh flag reveals once`: `assertDispatched('reveal-codes', codes: $codes)` AND `assertDontSee($codes[0])` (proves codes are absent from the server-rendered HTML/snapshot) AND `assertFalse(session()->has('2fa.codes_fresh'))` (one-time). - Replay proof: after the fresh mount, `->call('$refresh')` → `assertNotDispatched('reveal-codes')` + `assertDontSee($codes[0])`; PLUS a second fresh `Livewire::test(RecoveryCodes::class)` (the flag is already pulled) → same assertions. This models a replayed/stale snapshot yielding zero codes (mount does not re-run on hydrate, and the one-time flag is already consumed). - `regenerate`: `assertDispatched('reveal-codes', codes: $new)` + `assertDontSee($old[0])` + `assertDontSee($new[0])` (codes never in HTML) + `assertTrue(session()->has('2fa.download_grant'))`. - Keep `test_download_requires_a_fresh_grant` and `test_old_recovery_route_is_gone` unchanged — `->block()` leaves the happy path green. ## 8. Grant setters + untouched - Grant setters (updated for the TTL — F2): `Auth/TwoFactorSetup.php`, `Settings/WebauthnKeys.php`, and `Modals/RecoveryCodes::regenerate()` now store `session()->put('2fa.download_grant', now()->timestamp)` instead of `true`. - Reveal-flag setters (updated for symmetry — F5): `Auth/TwoFactorSetup.php` and `Settings/WebauthnKeys.php` store `2fa.codes_fresh` as `now()->timestamp` too, and `RecoveryCodes::mount()` only reveals when that flag is within `[now-600s, now]` — a stale flag (enrollment whose modal never opened) reveals nothing. - Both timestamp checks (route grant + `mount()` flag) validate the window as `$age !== null && $age >= 0 && $age <= 600`, which also rejects a future-dated value (F6). - `Settings/Index.php` is unchanged; the modal now consumes `codes_fresh` by dispatching the codes instead of flipping a `revealed` bool. - No i18n changes: no new visible strings (the heading, subtitle, warning, hidden-notice, and button labels already exist under `auth.recovery_*` / `common.*`). The `x-for` renders raw code strings, which are not translatable. R9 / R16 not applicable. ## 9. Verification (R12 + R15) - Browser-load both states at 375 / 768 / 1280: - fresh-enrollment reveal (codes render via Alpine), - manage view (hidden notice, no codes). - HTTP 200, zero console/network errors; for the lazy modal check the loaded state. - Confirm in the openModal network response that the codes are ABSENT from the snapshot and the server-rendered HTML, present only in `effects.dispatches`. Inspect the rendered DOM for leaked `@`/`{{ }}`/`$var`/`group.key` text (R17). - All tooling runs in-container (R8). Destructive/confirm UI stays wire-elements/modal (R5). - Run `/codex:review` over the change; fix and re-run until it reports no errors and no security issues (R15). The task is not done until Codex passes. ## 10. Files touched 1. `app/Livewire/Modals/RecoveryCodes.php` — remove prop, add `dispatchCodes()`, adjust `mount()` / `regenerate()` / `render()`; grant stored as timestamp. 2. `resources/views/livewire/modals/recovery-codes.blade.php` — Alpine `x-data` + `x-for` + `x-show` panels + `x-cloak`. 3. `routes/web.php` — download route changed `GET`→`POST` (CSRF) + `->block(5, 5)` + timestamp-grant TTL (≤600s). 4. `tests/Feature/RecoveryCodesModalTest.php` — replace the locked-prop test; add dispatch + replay + snapshot-inspection + regenerate + stale-grant assertions. 5. `app/Livewire/Auth/TwoFactorSetup.php`, `app/Livewire/Settings/WebauthnKeys.php` — grant setters store `now()->timestamp` (TTL, F2). `resources/css/app.css` is NOT touched — `[x-cloak] { display: none }` is already present.