Browse Source

Bunker login

 Conflicts:
	src/Security/NostrAuthenticator.php
imwald
Nuša Pukšič 4 months ago
parent
commit
ecd944ac1a
  1. 115
      assets/controllers/amber_connect_controller.js
  2. 4
      assets/controllers/login_controller.js
  3. 36
      importmap.php
  4. 32
      src/Controller/LoginController.php
  5. 70
      src/Controller/NostrConnectController.php
  6. 18
      src/Security/NostrAuthenticator.php
  7. 27
      templates/login/amber.html.twig
  8. 21
      templates/login/index.html.twig

115
assets/controllers/amber_connect_controller.js

@ -0,0 +1,115 @@ @@ -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;
}
}

4
assets/controllers/login_controller.js

@ -8,7 +8,8 @@ export default class extends Controller { @@ -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 { @@ -34,3 +35,4 @@ export default class extends Controller {
}
}
}

36
importmap.php

@ -60,4 +60,40 @@ return [ @@ -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',
],
];

32
src/Controller/LoginController.php

@ -7,23 +7,39 @@ namespace App\Controller; @@ -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 ]);
}
// 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 ]);
}
return new JsonResponse([
'message' => 'Unauthenticated',
], 401);
#[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');
}
}

70
src/Controller/NostrConnectController.php

@ -0,0 +1,70 @@ @@ -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,
]);
}
}

18
src/Security/NostrAuthenticator.php

@ -181,6 +181,24 @@ class NostrAuthenticator extends AbstractAuthenticator implements InteractiveAut @@ -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
}
}
/**

27
templates/login/amber.html.twig

@ -0,0 +1,27 @@ @@ -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 %}

21
templates/login/index.html.twig

@ -0,0 +1,21 @@ @@ -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 %}
Loading…
Cancel
Save