8 changed files with 314 additions and 9 deletions
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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, |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 %} |
||||||
|
|
||||||
@ -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…
Reference in new issue