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:
HolgerHatGarKeineNode
2026-05-20 01:09:20 +02:00
parent 532199fe15
commit 6bb7d93d1d
9 changed files with 501 additions and 77 deletions
+110 -33
View File
@@ -1,49 +1,126 @@
export default () => ({
// Toggled while window.nostr.signEvent is awaiting the wallet so the
// button disables itself, the loading overlay renders, and downstream
// wire:poll consumers can pause if needed. Stays true through the
// backend round-trip — the auth-button component reloads the page on
// success, so resetting the flag here would just flicker the overlay
// away while navigation is already underway.
nostrLoginInProgress: false,
async init() {
/**
* Resolve the active NIP-42 login challenge issued by the auth-button
* Volt component. Falls back to the Livewire $wire proxy and then to a
* server-side re-issue so the user does not have to reload if the
* rendered snapshot drifted out of sync with the session (e.g. a tab
* left open past the challenge TTL).
*/
async resolveChallenge() {
const fromDataset = this.$root?.dataset?.nostrChallenge;
if (typeof fromDataset === 'string' && fromDataset !== '') {
return fromDataset;
}
const livewireComponent = this.$el.closest('[wire\\:id]')?.__livewire;
const fromWire = livewireComponent?.$wire?.nostrChallenge;
if (typeof fromWire === 'string' && fromWire !== '') {
return fromWire;
}
if (livewireComponent?.$wire?.requestNostrChallenge) {
try {
const refreshed = await livewireComponent.$wire.requestNostrChallenge();
if (typeof refreshed === 'string' && refreshed !== '') {
return refreshed;
}
} catch (error) {
console.error('requestNostrChallenge failed:', error);
}
}
return null;
},
async openNostrLogin() {
console.log('Starting Nostr login...');
console.log('window.nostr available:', !!window.nostr);
this.nostrLoginInProgress = true;
const pubkey = await window.nostr.getPublicKey();
console.log('Fetched pubkey:', pubkey);
try {
if (!window.nostr || typeof window.nostr.signEvent !== 'function') {
this.showAuthError('Keine Nostr-Erweiterung gefunden. Bitte installiere einen Nostr-Signer (z.B. nos2x, Alby).');
this.nostrLoginInProgress = false;
return;
}
// fetch profile from /api/nostr/profile/{publicKey}
const url = '/api/nostr/profile/' + pubkey;
console.log('Fetching profile from:', url);
const challenge = await this.resolveChallenge();
if (!challenge) {
this.showAuthError('Login-Challenge fehlt. Bitte lade die Seite neu und versuche es erneut.');
this.nostrLoginInProgress = false;
return;
}
fetch(url)
.then(response => {
console.log('Response status:', response.status);
console.log('Response ok:', response.ok);
console.log('Response headers:', response.headers);
const pubkey = await window.nostr.getPublicKey();
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const event = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [['challenge', challenge]],
content: '',
};
return response.text();
})
.then(text => {
console.log('Response text:', text);
try {
const data = JSON.parse(text);
console.log('Profile fetched', data);
// store in AlpineJS store
let signedEvent;
try {
signedEvent = await window.nostr.signEvent(event);
} catch (error) {
console.error('Nostr signEvent failed:', error);
this.showAuthError('Signatur abgebrochen oder fehlgeschlagen. Bitte versuche es erneut.');
this.nostrLoginInProgress = false;
return;
}
// Some Nostr extensions return objects wrapped in extension-specific
// proxies (e.g. cloneInto results) that Livewire cannot serialize.
// Round-trip through JSON to guarantee a plain, cloneable object.
let plainSignedEvent;
try {
plainSignedEvent = JSON.parse(JSON.stringify(signedEvent));
} catch (error) {
console.error('Nostr signedEvent serialization failed:', error);
this.showAuthError('Wallet-Signatur konnte nicht verarbeitet werden. Bitte versuche eine andere Erweiterung.');
this.nostrLoginInProgress = false;
return;
}
// Pre-fetch the profile so it lands in the Alpine store before the
// reload completes. Non-critical: failures are logged but ignored.
try {
const response = await fetch('/api/nostr/profile/' + pubkey);
if (response.ok) {
const data = await response.json();
Alpine.store('nostr', {user: data});
// dispatch Livewire event
this.$dispatch('nostrLoggedIn', {pubkey: pubkey});
} catch (e) {
console.error('JSON parse error:', e);
throw e;
}
})
.catch(error => {
console.error('Error during Nostr login:', error);
});
} catch (error) {
console.warn('Profile prefetch failed:', error);
}
// Leave nostrLoginInProgress = true: the auth-button listener
// will trigger a full page reload on success.
this.$dispatch('nostrLoggedIn', {signedEvent: plainSignedEvent});
} catch (error) {
console.error('openNostrLogin unexpected error:', error);
this.showAuthError('Authentifizierung fehlgeschlagen. Bitte versuche es erneut.');
this.nostrLoginInProgress = false;
}
},
showAuthError(message) {
if (window.Flux && window.Flux.toast) {
window.Flux.toast({
heading: 'Authentifizierung fehlgeschlagen',
text: message,
variant: 'danger',
duration: 8000,
});
} else {
console.error(message);
}
},
});