options() now validates the label before returning creation options, so clicking
Add with a blank/invalid name aborts before navigator.credentials.create() and
never leaves an orphaned credential on the authenticator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persist the updated PublicKeyCredentialSource (not just the sign_count column)
so the next assertion validates against the latest counter — keeping the
library's cloned-authenticator / counter-rollback detection effective.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A "Security-Keys" panel in Settings → Security: add (name + navigator.credentials
.create via resources/js/webauthn.js), list (name/added/last-used), remove (R5
confirm modal), all audited. Hidden with a hint when domain+HTTPS is absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the panel runs on a domain+HTTPS and the user has a registered key, the 2FA
challenge offers "Mit Security-Key anmelden" alongside the TOTP/backup-code field.
A verified assertion completes login exactly like a TOTP success; rate-limit +
session gate reused. TOTP and backup codes remain fully usable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WebauthnService builds creation/request options (rpId = active domain, ES256/RS256,
attestation none, exclude/allow lists) via web-auth/webauthn-lib v5, stores the
challenge in the session, and verifies attestation/assertion responses (allowed
origin = https://<rpId>, sign-count tracked). Option building + serialization are
unit-tested; the cryptographic verify path is browser-verified on a domain host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WebAuthn is offered only with an active domain (the rpId) over HTTPS; bare-IP/HTTP
returns false so a ceremony never runs with an invalid IP rpId.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add web-auth/webauthn-lib ^5.3, the webauthn_credentials table (per-user unique
credential_id), the WebauthnCredential model and User hasMany relation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Optional security-key 2nd factor via web-auth/webauthn-lib, gated on domain +
HTTPS (rpId = active domain). TOTP + backup codes stay the required 2FA and the
only recovery. Build now with unit/feature tests (mocked ceremony) + R12 gating;
real YubiKey end-to-end deferred to a domain+HTTPS host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Account recovery: 2FA backup codes (show-once/download/regenerate, usable at the
login challenge), forgot-password via 2FA or backup code (email link gated on a
configured mailer), and a clusev:reset-admin CLI lockout fallback. Resets rotate
remember_token; codes consumed atomically. WebAuthn/YubiKey remains a Phase-2
spec. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both reset paths flash a `status` and redirect to login, but it was never
rendered. Show it as a success banner so the user gets confirmation their
password changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex review follow-ups: extract the notify toaster into a shared partial and
include it in the auth layout too, so sendResetLink + recovery-code regenerate
actually surface their confirmation (the auth layout had no toaster). Record an
AuditEvent for password resets via the email-token path, matching the 2FA path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Codex security review:
- ForgotPassword + clusev:reset-admin now rotate remember_token, so a stolen
remember-me cookie cannot survive a password reset (the email-token path
already did this).
- useRecoveryCode reads/checks/removes under a row lock (DB transaction +
lockForUpdate), so two concurrent requests can't both spend the same one-time
code (replay).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shell-only emergency recovery: resets the admin password (prints a random one if
omitted) and, with --disable-2fa, clears the 2FA secret + backup codes so the
operator can re-enroll. Requires server access — the safe last resort.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ForgotPassword gains sendResetLink() + a ResetPassword component at
/reset-password/{token} using Laravel's password broker. The email option is
shown only when mail.default is a real mailer (hidden while MAIL_MAILER=log), so
it activates automatically once SMTP is configured. Tested with Notification::fake.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Passwort vergessen?" on login -> a reset view that proves identity with a TOTP
or one-time backup code (no email needed), then sets a new password under the
min(12) mixedCase numbers policy. Rate-limited, generic messages (no account
enumeration), audited.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2FA confirm now generates 8 backup codes and redirects to a show-once recovery
view (download as .txt, regenerate). Reachable again from Settings. Codes are
shown before the onboarding gate so a fresh enrollee can save them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The challenge now falls back to consuming a one-time recovery code when the TOTP
check fails. Relaxed the code input (was numeric, maxlength 6) so a backup code
is typable, and added a hint. Bilingual.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1: 2FA recovery codes (encrypted, show-once + downloadable, usable at the
challenge), a forgot-password flow (2FA-code proof primary, email link only when
SMTP is configured), and a clusev:reset-admin CLI lockout fallback. WebAuthn is
deferred to a Phase-2 spec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was no lang/{de,en}/validation.php, so Laravel fell back to its built-in
English messages for every input-field validation error regardless of locale.
Add the full validation language file in German (source of truth) and English,
plus a localized `attributes` map so :attribute reads naturally (e.g. "SSH-Port",
"E-Mail-Adresse"). Per-form validationAttributes() still override these.
Verified: Validator resolves DE/EN messages + attribute labels; submitting the
empty login form renders "E-Mail-Adresse muss ausgefüllt werden." in German.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branding + custom error pages: full favicon/PWA icon set wired into both
layouts, and branded bilingual error pages (403/404/419/429/500/503) replacing
Laravel's defaults with APP_DEBUG pinned off in prod — no raw status text or
stack traces. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The withExceptions render hook called SetLocale::apply(), which resolves the
user via the DB — if the original error was a DB/user-provider failure, that
second access would throw inside the renderer and prevent the custom error page
from rendering (exactly when it's needed). Wrap it so any failure is swallowed
and the page renders in the default locale.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CSRF (419), unmatched-route 404 and maintenance (503) errors are thrown before
the web-group SetLocale middleware runs, so the custom error pages rendered in
the default locale for EN users. Extract SetLocale::apply() (session-guarded, so
it is safe pre-session) and call it from a withExceptions render hook that falls
through to the normal renderer — the error views now honour the user's language
where it is known.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Branding assets (public/, served directly):
- favicon.svg (scalable, primary), favicon.ico (16/32/48 PNG-embedded),
apple-touch-icon.png (180), icon-192/512.png, site.webmanifest. The mark is
the in-app brand glyph (orange Lucide "server" on dark). Wired into both
layouts via a shared partials/head-icons (rel=icon svg+ico, apple-touch,
manifest, theme-color #06080A). nginx serves the manifest as
application/manifest+json.
Never show raw error text (custom messages only):
- Custom branded resources/views/errors/{403,404,419,429,500,503}.blade.php on a
self-contained error layout (design tokens, brand mark, bilingual via
lang/errors.php) — replaces Laravel's default Symfony/whoops pages.
- docker-compose.prod.yml pins APP_DEBUG=false so a prod error can never render a
stack trace (the custom pages show instead).
- Files download error dispatched a bare $e->getMessage(); wrap it in the
localized files.download_failed (consistent with upload/delete).
Verified in-browser: icon links present + all assets 200; custom 404 renders the
branded German message (no raw "Not Found"/trace), responsive at 375, 0 console.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stop iOS zoom-on-focus: an unlayered @media (pointer: coarse) rule forces 16px
on input/textarea/select for touch devices (phone + tablet of any size), keeping
the dense desktop sizing for mouse pointers. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iOS Safari zooms the viewport when a focused form control's font-size is < 16px.
The dense design uses text-xs/sm/[11px] on inputs, which triggered the zoom on
every input across the app. Add one unlayered media query forcing 16px on
input/textarea/select for touch-primary devices (@media (pointer: coarse)) —
covering phones AND tablets of any size (incl. 1024/1366px iPad Pro that a
width cap would miss) while keeping the small desktop sizing for mouse pointers.
Unlayered so it wins over Tailwind's utility classes.
Verified via touch emulation: inputs render 16px on phone/iPad/iPad-Pro widths
(matchMedia(pointer:coarse)=true) and stay 14px on mouse desktops at any width.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Responsive audit (R7) over all views at 375/768: hardened mobile overflow from
long panel domains (break-all/min-w-0) and banned IPv6 addresses in the fail2ban
list. Live pages were already overflow-free; only data-dependent edge cases
fixed. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Responsive audit (R7) at 375/768 — all pages were overflow-free with current
data, but three elements overflow with long data the live fleet doesn't have:
- system: the panel URL link (domain up to 253 chars) had no break-all, and the
access-address dd had truncate without min-w-0 (so a flex child won't clip).
Add break-all to the link, min-w-0 to the dd, shrink-0 to its label.
- servers/show: the fail2ban banned-IP span had no min-w-0/break, so a banned
IPv6 pushed the row past 375px. Add min-w-0 break-all + shrink-0 on the button.
Verified by injecting a 100-char domain and long IPv6 into those elements at
375px: document no longer overflows (was 1685px → 375px).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Server-Details UX + hardening polish: uniform bordered buttons (danger-soft),
installed-only firewall/fail2ban grid, auto-updates as a neutral preference,
SSH-verified server creation with a "pending" (Initialisierung) status, and an
SSH key-only checklist hint — plus the fail2ban/UFW "nicht installiert"
false-negative fix. See CHANGELOG.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Note the seven fixes (buttons, panel grid, auto-updates reframe, create-verify,
pending status, SSH hint) as completed on the branch, with the verification
trail, and flag the pre-existing stock ExampleTest failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auth success alone does not prove the server can run commands (a server that
authenticates but rejects exec channels would be accepted, yet every fleet
operation runs commands). The probe now echoes a unique marker and requires it
in the output — proving exec works, while still tolerating servers that omit
the optional exit-status packet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
testConnection gated success on `exit code === 0`, but phpseclib returns false
from getExitStatus() when a server omits the optional exit-status message, which
would reject a valid login with an empty error. A successful connect() already
proves the credential (it throws on auth failure), so the probe now succeeds on
any exception-free connect + exec.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address Codex review of the panel-grid + pending-status work:
- Firewall/fail2ban services report installed=false together with readError=true,
so gating panel visibility on `installed` alone hid the read-error message and
made a failed probe indistinguishable from an absent tool. Show the panel when
installed OR readError.
- The server-details hero only mapped online/warning/offline, so a freshly
created `pending` server rendered an untranslated "Pending" label and an
uncolored icon. Add the localized cyan pending state to $statusLabel + hero.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a muted one-line hint on the SSH-Passwort-Login checklist row while
password auth is still on, so the operator knows access becomes SSH-key only
(and to deposit a key first) before toggling — mirroring the lockout guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Each panel renders only when its tool is actually installed (the Sicherheit
checklist carries the install/Aktivieren path otherwise), removing the redundant
"nicht installiert" boxes. Both shown -> 2-col responsive grid (lg, items-start);
one shown -> full width. Trash uses danger-soft, unban uses secondary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CreateServer now probes the entered SSH login inside the create transaction; a
failed probe throws and rolls back (no half-registered server), surfacing the
SSH reason on the form. Successful creation starts the server as `pending`
("Initialisierung") instead of red/offline until first contact promotes it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Connect + trivial exec against a server's stored credential; never throws,
returns {ok,error}. Used by the create-server guard to verify the SSH login
before persisting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Automatic updates are a choice, not a security gate: the unattended row now
reports secure=true always and carries a `neutral` flag. The checklist renders
it muted (aktiv/inaktiv) instead of the green/orange SICHER/OFFEN verdict, so
"off" is never flagged as insecure. Detection is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New `pending` status renders cyan (dot + pill, soft ping) instead of red, for
freshly created servers that have not been contacted yet. Wired into the
status-pill/dot components, the fleet list, and server-item, with a bilingual
`servers.status_pending` label.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every x-btn variant now carries a persistent border + background. Replace the
borderless `ghost`/`ghost-danger` (the latter only reddened on hover) with the
bordered `secondary` and a new `danger-soft` (red-tinted border+bg, always).
Migrate all 10 usages across servers/settings/files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Eight-task TDD plan covering the seven fixes: button kit, pending status,
auto-updates reframe, FleetService::testConnection, create-verify, panel grid,
SSH hint, and a final R12+Codex gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Design for seven reported fixes: unified bordered button kit (retire
ghost/ghost-danger, add danger-soft), firewall+fail2ban panels in an
installed-only responsive grid, auto-updates reframed as a neutral operator
preference (never an insecure verdict), create-server SSH verification with
atomic rollback, a new "Initialisierung" (pending) server status, and an
SSH key-only hint on the checklist. Includes file map, TDD + R12 plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The detection false-negative was already fixed in the working tree; it is now
committed on feat/v1-foundation with a passing unit test, Pint clean, Codex
clean. Update the handoff so the next session sees it as resolved rather than
open. Notes the still-pending items (live acceptance check on 10.10.90.162; no
version bump/tag/push yet) and that the prior task chip is now stale.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hardening checklist read "installed" from the package manager alone
(dpkg `^ii` / rpm `-q`), so it disagreed with the live fail2ban/firewall
panels, which use `command -v` + `systemctl is-active`. On a host where the
component is genuinely present but not recorded as an apt `^ii` package —
installed from source, or left in dpkg `rc` (config-only) state — the
checklist showed `nicht installiert` while the panel reported it active with
real bans (the reported contradiction).
The installed probe now ORs three conditions: the package-manager test, the
client binary on PATH, and the service already running. Since a component can
never be active without being installed, this only removes FALSE negatives —
it can never produce a false positive. Detection is now consistent with
Fail2banService::status and FirewallTool. Applies to both fail2ban and the
firewall (ufw/firewalld).
Adds HardeningServiceTest covering the Debian/apt source-install fallback:
the emitted probe gains `command -v fail2ban-client` and `dpkg -l ufw`, and an
active component reads as featureOn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two fixes/hardenings on top of v0.4.0:
1) Blade raw-block bug (R17). The topbar's @php block had a COMMENT containing the literal
tokens @php/@endphp; Blade's non-greedy raw-block matcher took the @endphp in the comment
as the block end and dumped the remainder ("$title ??= …") as plain text into <header> on
every authenticated page (and left the page title empty). R12 missed it: the page still
returned 200 with no console error and the <title> comes from the Livewire component. Fixed
the comment; added rule R17 (no directive tokens in Blade comments; block over inline @php;
R12 must inspect the rendered DOM, not just status/console) to rules.md + CLAUDE.md.
2) Broadcast channels are now PRIVATE (security hardening — closes the follow-up flagged in
v0.4.0). MetricsTicked rides a PrivateChannel authorized via /broadcasting/auth
(withBroadcasting + routes/channels.php), so fleet metrics can no longer be subscribed to by
anyone holding the bundled app key — including over a stale Caddy host after a domain change.
The channel callback requires User::securityOnboarded() (rotated password + 2FA), mirroring
the panel's EnsureSecurityOnboarded gate — authentication alone is not enough. Echo sends the
CSRF token for the auth handshake. Convention documented (CLAUDE.md §3 / channels.php): every
channel is private.
Bump 0.4.0 -> 0.4.1; CHANGELOG.
Verified: Pint clean; npm build; R12 all routes 200 + 0 console errors (both locales); topbar
<header> rendered text clean (no @/{{ }}/$var/key leaks); private broadcast publishes to
reverb:8080 and /broadcasting/auth + the onboarding-gated callback authorize correctly; Codex
review clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>