10 KiB
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 responseeffects.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)
- flash
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 = trueline.- 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", plusx-cloakto suppress any raw-template flash before Alpine initializes. - Codes grid: replace the Blade
@foreach ($codes as $c)with<template x-for="c in codes" :key="c"><span class="font-mono text-sm tracking-wide text-ink" x-text="c"></span></template>. - 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 inresources/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):
$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 <form method="POST"> 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(therevealedprop no longer exists) and the now-unusedCannotUpdateLockedPropertyExceptionimport. manage view (no flag):assertNotDispatched('reveal-codes')+assertDontSee($codes[0])assertSee(__('auth.recovery_hidden_notice')).
fresh flag reveals once:assertDispatched('reveal-codes', codes: $codes)ANDassertDontSee($codes[0])(proves codes are absent from the server-rendered HTML/snapshot) ANDassertFalse(session()->has('2fa.codes_fresh'))(one-time).- Replay proof: after the fresh mount,
->call('$refresh')→assertNotDispatched('reveal-codes')+assertDontSee($codes[0]); PLUS a second freshLivewire::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_grantandtest_old_recovery_route_is_goneunchanged —->block()leaves the happy path green.
8. Grant setters + untouched
- Grant setters (updated for the TTL — F2):
Auth/TwoFactorSetup.php,Settings/WebauthnKeys.php, andModals/RecoveryCodes::regenerate()now storesession()->put('2fa.download_grant', now()->timestamp)instead oftrue. - Reveal-flag setters (updated for symmetry — F5):
Auth/TwoFactorSetup.phpandSettings/WebauthnKeys.phpstore2fa.codes_freshasnow()->timestamptoo, andRecoveryCodes::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.phpis unchanged; the modal now consumescodes_freshby dispatching the codes instead of flipping arevealedbool.- No i18n changes: no new visible strings (the heading, subtitle, warning, hidden-notice, and
button labels already exist under
auth.recovery_*/common.*). Thex-forrenders 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.keytext (R17). - All tooling runs in-container (R8). Destructive/confirm UI stays wire-elements/modal (R5).
- Run
/codex:reviewover 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
app/Livewire/Modals/RecoveryCodes.php— remove prop, adddispatchCodes(), adjustmount()/regenerate()/render(); grant stored as timestamp.resources/views/livewire/modals/recovery-codes.blade.php— Alpinex-data+x-for+x-showpanels +x-cloak.routes/web.php— download route changedGET→POST(CSRF) +->block(5, 5)+ timestamp-grant TTL (≤600s).tests/Feature/RecoveryCodesModalTest.php— replace the locked-prop test; add dispatch + replay + snapshot-inspection + regenerate + stale-grant assertions.app/Livewire/Auth/TwoFactorSetup.php,app/Livewire/Settings/WebauthnKeys.php— grant setters storenow()->timestamp(TTL, F2).
resources/css/app.css is NOT touched — [x-cloak] { display: none } is already present.