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