Files
einundzwanzig-verein/app/Support/NostrAuth.php
T
HolgerHatGarKeineNode 52cf81abca fix(auth): route all nostrLoggedIn listeners through signed-event verification
The previous commit only updated auth-button + the WithNostrAuth trait,
but six Volt pages (profile, benefits, election/*, members/admin) carry
their own handleNostrLoggedIn(string $pubkey) handlers. The dispatched
payload is now an array, so Livewire's container could not resolve the
string parameter and threw BindingResolutionException on every login.

- All six per-page handlers now accept the signed event and route it
  through NostrAuth::loginWithSignedEvent() like the trait does.
- NostrAuth: add currentOrIssueChallenge() so the sidebar + navbar
  auth-button mounts share one live session challenge instead of
  overwriting each other.
- verifySignedEvent: pass a normalized stdClass to swentel's verify()
  directly, skipping an unnecessary json_encode + json_decode round-trip.
- auth-button: gate the global Escape/Tab capture so it only intercepts
  keys while the overlay is actually visible.
- Update three test files that still called handleNostrLoggedIn with a
  raw pubkey to authenticate via NostrAuth::login() instead.
2026-05-20 01:51:31 +02:00

194 lines
6.6 KiB
PHP

<?php
namespace App\Support;
use App\Auth\NostrUser;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\ValidationException;
use swentel\nostr\Event\Event as NostrEvent;
class NostrAuth
{
private const CHALLENGE_SESSION_KEY = 'nostr_login_challenge';
private const CHALLENGE_EXPIRES_SESSION_KEY = 'nostr_login_challenge_expires_at';
private const CHALLENGE_TTL_SECONDS = 300;
private const LOGIN_EVENT_KIND = 22242;
public static function login(string $pubkey): void
{
Auth::guard('nostr')->loginByPubkey($pubkey);
Session::regenerate();
}
/**
* Generate a fresh NIP-42-style login challenge, persist it to the session,
* and return it. The frontend embeds this challenge into a kind-22242 event
* that the Nostr signer must sign before we accept the login.
*/
public static function issueChallenge(): string
{
$challenge = bin2hex(random_bytes(32));
Session::put(self::CHALLENGE_SESSION_KEY, $challenge);
Session::put(self::CHALLENGE_EXPIRES_SESSION_KEY, now()->addSeconds(self::CHALLENGE_TTL_SECONDS)->timestamp);
return $challenge;
}
/**
* Return the active session challenge if still valid, otherwise issue a
* fresh one. Keeps the sidebar + navbar auth-button mounts in sync —
* without this, the second mount would overwrite the first in the
* session and leave the first instance's rendered data-attribute
* pointing at a stale challenge.
*/
public static function currentOrIssueChallenge(): string
{
$existing = Session::get(self::CHALLENGE_SESSION_KEY);
$expiresAt = (int) Session::get(self::CHALLENGE_EXPIRES_SESSION_KEY, 0);
if (is_string($existing) && $existing !== '' && $expiresAt >= now()->timestamp) {
return $existing;
}
return self::issueChallenge();
}
/**
* Verify a signed NIP-42-style login event and log the holder of the pubkey in.
*
* Idempotent across concurrent Livewire listeners: once the challenge has been
* consumed, a second call with the same event still succeeds as long as the
* caller's session is already authenticated with the matching pubkey.
*
* @return string the verified pubkey
*/
public static function loginWithSignedEvent(mixed $signedEvent): string
{
$pubkey = self::verifySignedEvent($signedEvent);
if (! self::check() || self::pubkey() !== $pubkey) {
self::login($pubkey);
}
return $pubkey;
}
/**
* Verify the cryptographic signature of a kind-22242 event and that its
* challenge tag matches the value stored on this session. Consumes the
* stored challenge on success so it cannot be reused.
*
* @return string the verified pubkey
*/
public static function verifySignedEvent(mixed $signedEvent): string
{
if (! is_array($signedEvent)) {
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
}
foreach (['id', 'pubkey', 'created_at', 'kind', 'tags', 'content', 'sig'] as $field) {
if (! array_key_exists($field, $signedEvent)) {
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
}
}
if ((int) $signedEvent['kind'] !== self::LOGIN_EVENT_KIND) {
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
}
$createdAt = (int) $signedEvent['created_at'];
if (abs(now()->timestamp - $createdAt) > self::CHALLENGE_TTL_SECONDS) {
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
}
$challengeFromEvent = null;
foreach ($signedEvent['tags'] as $tag) {
if (is_array($tag) && ($tag[0] ?? null) === 'challenge') {
$challengeFromEvent = (string) ($tag[1] ?? '');
break;
}
}
if ($challengeFromEvent === null || $challengeFromEvent === '') {
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
}
$expectedChallenge = Session::get(self::CHALLENGE_SESSION_KEY);
$expiresAt = (int) Session::get(self::CHALLENGE_EXPIRES_SESSION_KEY, 0);
$challengeMatchesSession = is_string($expectedChallenge)
&& $expectedChallenge !== ''
&& $expiresAt >= now()->timestamp
&& hash_equals($expectedChallenge, $challengeFromEvent);
// swentel's verify() accepts an object directly, so we pass a normalized
// stdClass and skip an unnecessary json_encode + json_decode round-trip.
// Casts here enforce the property types swentel checks against.
$normalizedEvent = (object) [
'id' => (string) $signedEvent['id'],
'pubkey' => (string) $signedEvent['pubkey'],
'created_at' => $createdAt,
'kind' => self::LOGIN_EVENT_KIND,
'tags' => $signedEvent['tags'],
'content' => (string) $signedEvent['content'],
'sig' => (string) $signedEvent['sig'],
];
$sigValid = false;
try {
$sigValid = (new NostrEvent)->verify($normalizedEvent);
} catch (\Throwable) {
$sigValid = false;
}
if (! $sigValid) {
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
}
$eventPubkey = (string) $signedEvent['pubkey'];
if ($challengeMatchesSession) {
Session::forget([self::CHALLENGE_SESSION_KEY, self::CHALLENGE_EXPIRES_SESSION_KEY]);
return $eventPubkey;
}
// Idempotent path: the challenge has already been consumed (e.g. a
// sibling Livewire listener processed the same event microseconds
// earlier). Only accept if the current session is already
// authenticated with this exact pubkey.
if (self::check() && self::pubkey() === $eventPubkey) {
return $eventPubkey;
}
throw ValidationException::withMessages(['nostr' => __('auth.failed')]);
}
public static function logout(): void
{
if (Auth::guard('nostr')->check()) {
Auth::guard('nostr')->logout();
Session::flush();
}
}
public static function user(): ?NostrUser
{
return Auth::guard('nostr')->user();
}
public static function check(): bool
{
return Auth::guard('nostr')->check();
}
public static function pubkey(): ?string
{
return self::user()?->getPubkey();
}
}