Files
einundzwanzig-verein/tests/Feature/Auth/NostrLoginTest.php
T
HolgerHatGarKeineNode 6bb7d93d1d 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.
2026-05-20 01:09:20 +02:00

156 lines
5.9 KiB
PHP

<?php
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\ValidationException;
use swentel\nostr\Event\Event as NostrEvent;
use swentel\nostr\Key\Key as NostrKey;
use swentel\nostr\Sign\Sign as NostrSign;
/**
* Build a NIP-42-style kind-22242 login event signed with a freshly generated
* keypair. Returns the signed event as the plain array that the frontend
* dispatches to Livewire (post-JSON round-trip), plus the pubkey for assertions.
*
* @return array{event: array<string, mixed>, pubkey: string, privkey: string}
*/
function makeSignedLoginEvent(string $challenge, ?int $createdAt = null): array
{
$key = new NostrKey;
$privkey = $key->generatePrivateKey();
$pubkey = $key->getPublicKey($privkey);
$event = new NostrEvent;
$event->setKind(22242);
$event->setCreatedAt($createdAt ?? time());
$event->setTags([['challenge', $challenge]]);
$event->setContent('');
(new NostrSign)->signEvent($event, $privkey);
$array = $event->toArray();
// Match the shape produced by JSON.parse(JSON.stringify(signedEvent)) in
// nostrLogin.js — plain arrays, integer kind/created_at, string sig/id.
return [
'event' => [
'id' => $array['id'],
'pubkey' => $array['pubkey'],
'created_at' => $array['created_at'],
'kind' => $array['kind'],
'tags' => $array['tags'],
'content' => $array['content'],
'sig' => $array['sig'],
],
'pubkey' => $pubkey,
'privkey' => $privkey,
];
}
it('issues a fresh hex challenge and persists it to the session', function () {
$challenge = NostrAuth::issueChallenge();
expect($challenge)->toMatch('/^[0-9a-f]{64}$/');
expect(Session::get('nostr_login_challenge'))->toBe($challenge);
expect(Session::get('nostr_login_challenge_expires_at'))->toBeGreaterThan(now()->timestamp);
});
it('logs in via a valid signed login event and consumes the challenge', function () {
$challenge = NostrAuth::issueChallenge();
['event' => $signedEvent, 'pubkey' => $pubkey] = makeSignedLoginEvent($challenge);
$returned = NostrAuth::loginWithSignedEvent($signedEvent);
expect($returned)->toBe($pubkey);
expect(NostrAuth::check())->toBeTrue();
expect(NostrAuth::pubkey())->toBe($pubkey);
expect(Session::has('nostr_login_challenge'))->toBeFalse();
});
it('rejects an event whose challenge does not match the session', function () {
NostrAuth::issueChallenge();
['event' => $signedEvent] = makeSignedLoginEvent('deadbeef'.str_repeat('0', 56));
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
->toThrow(ValidationException::class);
expect(NostrAuth::check())->toBeFalse();
});
it('rejects an event of the wrong kind', function () {
$challenge = NostrAuth::issueChallenge();
['event' => $signedEvent] = makeSignedLoginEvent($challenge);
$signedEvent['kind'] = 1; // text note, not auth
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
->toThrow(ValidationException::class);
expect(NostrAuth::check())->toBeFalse();
});
it('rejects an event whose created_at is outside the TTL window', function () {
$challenge = NostrAuth::issueChallenge();
['event' => $signedEvent] = makeSignedLoginEvent($challenge, now()->subHour()->timestamp);
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
->toThrow(ValidationException::class);
expect(NostrAuth::check())->toBeFalse();
});
it('rejects an event with a tampered signature', function () {
$challenge = NostrAuth::issueChallenge();
['event' => $signedEvent] = makeSignedLoginEvent($challenge);
// Flip the first byte of the signature to break the schnorr verification.
$signedEvent['sig'] = ($signedEvent['sig'][0] === '0' ? '1' : '0').substr($signedEvent['sig'], 1);
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
->toThrow(ValidationException::class);
expect(NostrAuth::check())->toBeFalse();
});
it('rejects an event with a tampered pubkey (sig no longer matches)', function () {
$challenge = NostrAuth::issueChallenge();
['event' => $signedEvent] = makeSignedLoginEvent($challenge);
// Swap in an attacker-controlled pubkey while keeping the original sig.
$signedEvent['pubkey'] = str_repeat('a', 64);
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
->toThrow(ValidationException::class);
expect(NostrAuth::check())->toBeFalse();
});
it('rejects a non-array payload', function () {
NostrAuth::issueChallenge();
expect(fn () => NostrAuth::loginWithSignedEvent('not-an-event'))
->toThrow(ValidationException::class);
expect(fn () => NostrAuth::loginWithSignedEvent(null))
->toThrow(ValidationException::class);
});
it('is idempotent for repeated calls with the same event within one session', function () {
$challenge = NostrAuth::issueChallenge();
['event' => $signedEvent, 'pubkey' => $pubkey] = makeSignedLoginEvent($challenge);
NostrAuth::loginWithSignedEvent($signedEvent);
// Challenge is consumed after the first call. A sibling listener that
// receives the same dispatched event must still succeed.
$returned = NostrAuth::loginWithSignedEvent($signedEvent);
expect($returned)->toBe($pubkey);
expect(NostrAuth::pubkey())->toBe($pubkey);
});
it('does not allow a replay from a different (unauthenticated) session', function () {
$challenge = NostrAuth::issueChallenge();
['event' => $signedEvent] = makeSignedLoginEvent($challenge);
NostrAuth::loginWithSignedEvent($signedEvent);
// Simulate a fresh session: no challenge, no authenticated user.
NostrAuth::logout();
Session::forget(['nostr_login_challenge', 'nostr_login_challenge_expires_at']);
expect(fn () => NostrAuth::loginWithSignedEvent($signedEvent))
->toThrow(ValidationException::class);
expect(NostrAuth::check())->toBeFalse();
});