177 lines
10 KiB
Markdown
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.
|