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

177 lines
10 KiB
Markdown

# 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):
```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 `<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 `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.