diff --git a/assets/controllers/amber_connect_controller.js b/assets/controllers/amber_connect_controller.js new file mode 100644 index 0000000..cb7bb3f --- /dev/null +++ b/assets/controllers/amber_connect_controller.js @@ -0,0 +1,115 @@ +import { Controller } from '@hotwired/stimulus'; +import { getPublicKey, SimplePool } from 'nostr-tools'; +import { BunkerSigner } from "nostr-tools/nip46"; + +export default class extends Controller { + static targets = ['qr', 'status']; + + connect() { + this._localSecretKey = null; // hex (32 bytes) from server + this._uri = null; // nostrconnect:// URI from server (NOT re-generated client side) + this._relays = []; + this._secret = null; + this._signer = null; + this._pool = null; + this._didAuth = false; + this._init(); + } + + disconnect() { + try { this._signer?.close?.(); } catch (_) {} + try { this._pool?.close?.([]); } catch (_) {} + } + + async _init() { + try { + this._setStatus('Requesting pairing QR…'); + const res = await fetch('/nostr-connect/qr'); + if (!res.ok) throw new Error('QR fetch failed'); + const data = await res.json(); + + this._localSecretKey = data.privkey; // hex secret key (client keypair) + this._uri = data.uri; // full nostrconnect URI (already includes relays, secret, name) + this._relays = data.relays || [data.relay].filter(Boolean); + this._secret = data.secret || null; + + if (this.hasQrTarget) { + this.qrTarget.innerHTML = `Amber pairing QR`; + } + + // Integrity check: derive pubkey from provided privkey and compare to URI authority + this._checkClientPubkeyIntegrity(); + + this._setStatus('Scan with Amber (NIP-46)…'); + await this._createSigner(); + this._setStatus('Remote signer connected. Authenticating…'); + await this._attemptAuth(); + } catch (e) { + console.error('[amber-connect] init error', e); + this._setStatus('Init failed: ' + (e.message || 'unknown')); + } + } + + _checkClientPubkeyIntegrity() { + try { + if (!this._localSecretKey || !this._uri) return; + const derived = getPublicKey(this._localSecretKey); + const m = this._uri.match(/^nostrconnect:\/\/([0-9a-fA-F]{64})/); + if (!m) { + console.warn('[amber-connect] URI missing/invalid pubkey segment'); + return; + } + const uriPk = m[1].toLowerCase(); + if (uriPk !== derived.toLowerCase()) { + console.warn('[amber-connect] Pubkey mismatch: derived != URI', { derived, uriPk }); + } + } catch (e) { + console.warn('[amber-connect] integrity check failed', e); + } + } + + async _createSigner() { + this._pool = new SimplePool(); + this._setStatus('Waiting for remote signer…'); + // fromURI resolves only after remote bunker connects & authorizes (handshake done inside nostr-tools) + this._signer = await BunkerSigner.fromURI(this._localSecretKey, this._uri, { pool: this._pool }); + } + + async _attemptAuth() { + if (this._didAuth) return; + this._didAuth = true; + try { + const loginEvent = { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['u', window.location.origin + '/login'], + ['method', 'POST'], + ['t', 'bunker'] + ], + content: '' + }; + this._setStatus('Signing login request…'); + const signed = await this._signer.signEvent(loginEvent); + this._setStatus('Submitting login…'); + const resp = await fetch('/login', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) } + }); + if (resp.ok) { + this._setStatus('Authenticated. Reloading…'); + setTimeout(() => window.location.reload(), 500); + } else { + this._setStatus('Login failed (' + resp.status + ')'); + } + } catch (e) { + console.error('[amber-connect] auth error', e); + this._setStatus('Auth error: ' + (e.message || 'unknown')); + } + } + + _setStatus(msg) { + if (this.hasStatusTarget) this.statusTarget.textContent = msg; + } +} diff --git a/assets/controllers/login_controller.js b/assets/controllers/login_controller.js index 7e8da2f..b73a1cf 100644 --- a/assets/controllers/login_controller.js +++ b/assets/controllers/login_controller.js @@ -8,7 +8,8 @@ export default class extends Controller { async loginAct() { const tags = [ ['u', window.location.origin + '/login'], - ['method', 'POST'] + ['method', 'POST'], + ['t', 'extension'] ] const ev = { created_at: Math.floor(Date.now()/1000), @@ -34,3 +35,4 @@ export default class extends Controller { } } } + diff --git a/importmap.php b/importmap.php index 1d060b8..e2b1564 100644 --- a/importmap.php +++ b/importmap.php @@ -60,4 +60,40 @@ return [ 'es-module-shims' => [ 'version' => '2.0.10', ], + 'nostr-tools' => [ + 'version' => '2.17.0', + ], + '@noble/curves/secp256k1' => [ + 'version' => '1.2.0', + ], + '@noble/hashes/utils' => [ + 'version' => '1.3.1', + ], + '@noble/hashes/sha256' => [ + 'version' => '1.3.1', + ], + '@scure/base' => [ + 'version' => '1.1.1', + ], + '@noble/ciphers/aes' => [ + 'version' => '0.5.3', + ], + '@noble/ciphers/chacha' => [ + 'version' => '0.5.3', + ], + '@noble/ciphers/utils' => [ + 'version' => '0.5.3', + ], + '@noble/hashes/hkdf' => [ + 'version' => '1.3.1', + ], + '@noble/hashes/hmac' => [ + 'version' => '1.3.1', + ], + '@noble/hashes/crypto' => [ + 'version' => '1.3.1', + ], + 'nostr-tools/nip46' => [ + 'version' => '2.17.0', + ], ]; diff --git a/src/Controller/LoginController.php b/src/Controller/LoginController.php index 5c2e7de..2881032 100644 --- a/src/Controller/LoginController.php +++ b/src/Controller/LoginController.php @@ -7,23 +7,39 @@ namespace App\Controller; use App\Entity\User; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; class LoginController extends AbstractController { - #[Route('/login', name: 'app_login')] - public function index(#[CurrentUser] ?User $user): Response + #[Route('/login', name: 'app_login', methods: ['GET','POST'])] + public function index(#[CurrentUser] ?User $user, Request $request): Response { if (null !== $user) { - return new JsonResponse([ - 'message' => 'Authentication Successful', - ], 200); + // Authenticated: for API calls still return JSON for backward compatibility. + if ($request->isXmlHttpRequest() || str_contains($request->headers->get('Accept',''), 'application/json')) { + return new JsonResponse(['message' => 'Authentication Successful'], 200); + } + return $this->render('login/index.html.twig', [ 'authenticated' => true ]); } - return new JsonResponse([ - 'message' => 'Unauthenticated', - ], 401); + // If this is an authentication attempt with Authorization header let the security layer handle (401 JSON fallback) + if ($request->headers->has('Authorization')) { + return new JsonResponse(['message' => 'Unauthenticated'], 401); + } + + // Default: render login page with Amber QR. + return $this->render('login/index.html.twig', [ 'authenticated' => false ]); + } + + #[Route('/login/amber', name: 'app_login_amber', methods: ['GET'])] + public function amber(#[CurrentUser] ?User $user): Response + { + if (null !== $user) { + return $this->redirectToRoute('newsstand'); + } + return $this->render('login/amber.html.twig'); } } diff --git a/src/Controller/NostrConnectController.php b/src/Controller/NostrConnectController.php new file mode 100644 index 0000000..d4cd82b --- /dev/null +++ b/src/Controller/NostrConnectController.php @@ -0,0 +1,70 @@ +getPublicKey($privkey); + + // Relay list (extendable later; keep first as primary for backward compatibility) + $relays = ['wss://relay.nsec.app']; + + // Short secret (remote signer should return as result of its connect response) + $secret = substr(bin2hex(random_bytes(8)), 0, 12); // 12 hex chars (~48 bits truncated) + + $name = 'Decent Newsroom'; + $appUrl = $request->getSchemeAndHttpHost(); + + // Build query string: multiple relay params + secret + name + url + $queryParts = []; + foreach ($relays as $r) { + $queryParts[] = 'relay=' . rawurlencode($r); + } + $queryParts[] = 'secret=' . rawurlencode($secret); + $queryParts[] = 'name=' . rawurlencode($name); + $queryParts[] = 'url=' . rawurlencode($appUrl); + $query = implode('&', $queryParts); + + $uri = sprintf('nostrconnect://%s?%s', $pubkey, $query); + + // Generate QR using default config + $qrResult = (new Builder())->build(data: $uri); + $dataUri = $qrResult->getDataUri(); + + return new JsonResponse([ + 'uri' => $uri, + 'qr' => $dataUri, + 'pubkey' => $pubkey, + 'privkey' => $privkey, // sent to browser for nip04 encryption/decryption (ephemeral only) + 'relay' => $relays[0], // maintain existing single relay field for current JS + 'relays' => $relays, + 'secret' => $secret, + 'name' => $name, + 'url' => $appUrl, + ]); + } +} diff --git a/src/Security/NostrAuthenticator.php b/src/Security/NostrAuthenticator.php index fd329a4..63391c8 100644 --- a/src/Security/NostrAuthenticator.php +++ b/src/Security/NostrAuthenticator.php @@ -181,6 +181,24 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut } catch (\Throwable $e) { throw new AuthenticationException('Tag validation failed: ' . $e->getMessage()); } + + // Detect bunker vs extension login by presence of a client tag + try { + $tags = $event->getTags(); + if (is_array($tags)) { + foreach ($tags as $tag) { + if (is_array($tag) && isset($tag[0], $tag[1]) && $tag[0] === 't') { + $method = $tag[1]; + if (in_array($method, ['bunker', 'extension'], true)) { + $request->getSession()->set('nostr_sign_method', $method); + } + break; + } + } + } + } catch (\Throwable $e) { + // non-fatal + } } /** diff --git a/templates/login/amber.html.twig b/templates/login/amber.html.twig new file mode 100644 index 0000000..f259a42 --- /dev/null +++ b/templates/login/amber.html.twig @@ -0,0 +1,27 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Remote Signer Login | Decent Newsroom{% endblock %} + +{% block body %} +
+

Remote Signer

+

Scan the QR below with a NIP-46 compatible bunker signer to pair a remote signing session. Keep this page open while pairing.

+
+
+
+
+
+
+
+

Status

+
Initializing…
+
+

After pairing, any page that uses window.nostr will automatically use this remote signer session.

+

If pairing stalls, refresh this page to generate a new ephemeral key.

+
+
+
+
+
+{% endblock %} + diff --git a/templates/login/index.html.twig b/templates/login/index.html.twig new file mode 100644 index 0000000..bd41ab9 --- /dev/null +++ b/templates/login/index.html.twig @@ -0,0 +1,21 @@ +{% extends 'layout.html.twig' %} + +{% block title %}Login | Decent Newsroom{% endblock %} + +{% block body %} +
+

Login

+ {% if authenticated %} +
You are already authenticated.
+ {% else %} +
+
+

Browser Extension Login

+

Use your Nostr browser extension to authenticate.

+ +

Need Amber / Bunker remote signer?

+
+
+ {% endif %} +
+{% endblock %}