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(); } }