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 = ``;
+ }
+
+ // 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 %}
+
Scan the QR below with a NIP-46 compatible bunker signer to pair a remote signing session. Keep this page open while pairing.
+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.
+Use your Nostr browser extension to authenticate.
+ + +