From ecd944ac1a4cf13e9c3a4a7b2dd9404b68d009ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= <nusa.puksic@gmail.com>
Date: Mon, 22 Sep 2025 17:25:38 +0200
Subject: [PATCH] Bunker login

 Conflicts:
	src/Security/NostrAuthenticator.php
---
 .../controllers/amber_connect_controller.js   | 115 ++++++++++++++++++
 assets/controllers/login_controller.js        |   4 +-
 importmap.php                                 |  36 ++++++
 src/Controller/LoginController.php            |  32 +++--
 src/Controller/NostrConnectController.php     |  70 +++++++++++
 src/Security/NostrAuthenticator.php           |  18 +++
 templates/login/amber.html.twig               |  27 ++++
 templates/login/index.html.twig               |  21 ++++
 8 files changed, 314 insertions(+), 9 deletions(-)
 create mode 100644 assets/controllers/amber_connect_controller.js
 create mode 100644 src/Controller/NostrConnectController.php
 create mode 100644 templates/login/amber.html.twig
 create mode 100644 templates/login/index.html.twig

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 = `<img alt="Amber pairing QR" src="${data.qr}" style="width:260px;height:260px;" />`;
+      }
+
+      // 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 @@
+<?php
+
+namespace App\Controller;
+
+use Endroid\QrCode\Builder\Builder;
+use Endroid\QrCode\Exception\ValidationException;
+use Random\RandomException;
+use swentel\nostr\Key\Key;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\Attribute\Route;
+
+class NostrConnectController
+{
+    /**
+     * Build a nostrconnect URI according to NIP-46 with explicit query params:
+     *  - relay: one or more relay URLs (repeated param)
+     *  - secret: short random string remote signer must echo back
+     *  - name (optional): client application name
+     *  - url (optional): canonical client url
+     *
+     * @throws RandomException
+     * @throws ValidationException
+     */
+    #[Route('/nostr-connect/qr', name: 'nostr_connect_qr', methods: ['GET'])]
+    public function qr(Request $request): JsonResponse
+    {
+        // Ephemeral key pair (client side session)
+        $privkey = bin2hex(random_bytes(32));
+        $key = new Key();
+        $pubkey = $key->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 %}
+<div class="container my-5">
+  <h1 class="h3 mb-4">Remote Signer</h1>
+  <p class="text-muted">Scan the QR below with a NIP-46 compatible bunker signer to pair a remote signing session. Keep this page open while pairing.</p>
+  <div class="card">
+    <div class="card-body" data-controller="amber-connect">
+      <div class="row">
+        <div class="col-md-6 text-center mb-3">
+          <div data-amber-connect-target="qr" class="mb-2"></div>
+        </div>
+        <div class="col-md-6">
+          <h2 class="h6">Status</h2>
+          <div data-amber-connect-target="status" class="small text-muted">Initializing…</div>
+          <hr>
+          <p class="small text-muted mb-1">After pairing, any page that uses window.nostr will automatically use this remote signer session.</p>
+          <p class="small text-muted">If pairing stalls, refresh this page to generate a new ephemeral key.</p>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+{% 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 %}
+<div class="container my-5">
+  <h1 class="h3 mb-4">Login</h1>
+  {% if authenticated %}
+    <div class="alert alert-success">You are already authenticated.</div>
+  {% else %}
+    <div class="card">
+      <div class="card-body" data-controller="login">
+        <h2 class="h5 mb-2">Browser Extension Login</h2>
+        <p class="text-muted small mb-3">Use your Nostr browser extension to authenticate.</p>
+        <button type="button" class="btn btn-primary" data-action="click->login#loginAct">Login with Nostr</button>
+        <p class="mt-3 small mb-0"><a href="{{ path('app_login_amber') }}">Need Amber / Bunker remote signer?</a></p>
+      </div>
+    </div>
+  {% endif %}
+</div>
+{% endblock %}
