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

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

$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 (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 GETPOST (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.