mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-05-23 13:15:36 +00:00
6bb7d93d1d
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.
156 lines
5.9 KiB
PHP
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();
|
|
});
|