mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-05-23 13:15:36 +00:00
feat(auth): require signed NIP-42 event for Nostr login
Closes a security flaw where the server trusted any pubkey the client sent. The frontend now signs a per-session, time-bound challenge (kind-22242 event) that the backend verifies with swentel/nostr-php before establishing the session. - NostrAuth: issueChallenge() + loginWithSignedEvent() with full schnorr/id verification, TTL window, and idempotent re-entry for concurrent Livewire listeners. - auth-button: mounts a fresh challenge, exposes it via data-attribute + requestNostrChallenge() fallback, renders a full-viewport AAA-style loading overlay while the wallet signs. - NostrSessionGuard: override logout() to drop the cookie-jar dep so programmatic logout works in any context.
This commit is contained in:
@@ -38,4 +38,17 @@ class NostrSessionGuard extends SessionGuard
|
||||
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr auth has no remember-me cookie, so we override the parent's
|
||||
* cookie-clearing logout to avoid CookieJar dependencies in code paths
|
||||
* (e.g. tests) where the cookie jar isn't bound.
|
||||
*/
|
||||
public function logout(): void
|
||||
{
|
||||
$this->session->remove($this->getName());
|
||||
|
||||
$this->user = null;
|
||||
$this->loggedOut = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ trait WithNostrAuth
|
||||
public bool $canEdit = false;
|
||||
|
||||
#[On('nostrLoggedIn')]
|
||||
public function handleNostrLogin(string $pubkey): void
|
||||
public function handleNostrLogin($signedEvent = null): void
|
||||
{
|
||||
$executed = RateLimiter::attempt(
|
||||
'nostr-login:'.request()->ip(),
|
||||
@@ -30,7 +30,7 @@ trait WithNostrAuth
|
||||
abort(429, 'Too many login attempts.');
|
||||
}
|
||||
|
||||
NostrAuth::login($pubkey);
|
||||
$pubkey = NostrAuth::loginWithSignedEvent($signedEvent);
|
||||
|
||||
$this->currentPubkey = $pubkey;
|
||||
$this->currentPleb = EinundzwanzigPleb::query()
|
||||
|
||||
+131
-13
@@ -5,12 +5,19 @@ 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
|
||||
{
|
||||
/**
|
||||
* Login a user by their Nostr pubkey
|
||||
*/
|
||||
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);
|
||||
@@ -18,34 +25,145 @@ class NostrAuth
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout the current Nostr user
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
$eventJson = json_encode([
|
||||
'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'],
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$sigValid = false;
|
||||
try {
|
||||
$sigValid = (new NostrEvent)->verify($eventJson);
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently authenticated Nostr user
|
||||
*/
|
||||
public static function user(): ?NostrUser
|
||||
{
|
||||
return Auth::guard('nostr')->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Nostr user is authenticated
|
||||
*/
|
||||
public static function check(): bool
|
||||
{
|
||||
return Auth::guard('nostr')->check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current pubkey (convenience method)
|
||||
*/
|
||||
public static function pubkey(): ?string
|
||||
{
|
||||
return self::user()?->getPubkey();
|
||||
|
||||
Reference in New Issue
Block a user