Skip to content

Commit e600782

Browse files
nostr sign-in — NIP-98 verified requests, xlogin UI, did:nostr agents
did:nostr agents are now first-class at level 0: an Authorization: Nostr header (NIP-98 kind-27235 event) authenticates as did:nostr:<pubkey> with no registration. lib/nip98.js verifies structure per the nip98 client library's contract and adds the half it deliberately leaves out — NIP-01 event id + BIP-340 schnorr via @noble/curves (the repo's one dependency, replacing the zero-dep claim). Payload-tag binding is strict both ways: a body must hash to the tag, and a body-carrying request without a payload tag is refused (else a signed GET header could replay onto a write). Body is read before auth so the exact wire bytes are verified. UI gains "Sign in with Nostr" via the vendored xlogin widget (extension, guest key, or pasted privkey — it's a testnet), served at /xlogin.js (AGPL-3.0-or-later, same author, licensing noted in the header); api() picks bearer or window.xlogin.authFetch automatically. Spec §10 now defines both auth schemes normatively (+ NIP-98 reference); landing + roadmap updated; signed *transitions* remain level 1. +6 tests incl. all forged/stale/ mismatched/replayed header shapes; 25 green.
1 parent b869834 commit e600782

11 files changed

Lines changed: 1138 additions & 35 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ node server.js
2020
# solidpay node listening on port 3480 → open http://localhost:3480
2121
```
2222

23-
Zero dependencies — plain Node.js ≥ 20. Behind a proxy:
23+
One dependency — [`@noble/curves`](https://github.com/paulmillr/noble-curves) for BIP-340 signature verification — on plain Node.js ≥ 20. Behind a proxy:
2424

2525
```bash
2626
PUBLIC_URL=https://testnet.solidpay.org PORT=3480 DATA=/var/solidpay node server.js
@@ -49,7 +49,7 @@ micro-units; no float drift.
4949
## Repo
5050

5151
```
52-
server.js the node: HTTP API + accounts + UI serving (zero deps)
52+
server.js the node: HTTP API + accounts + nostr auth + UI serving
5353
lib/engine.js the ledger engine (pure model + transitions)
5454
lib/ui.js the product UI (one server-rendered document)
5555
docs/spec/ the protocol spec (Editor's Draft, HTML) + pointer md

docs/roadmap.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
- [x] Product UI: sign-in/signup, overview tiles, pay with live route
1010
preview, trustline management, chain-verified activity feed
1111
- [x] 16 tests including a full-HTTP integration run and restart persistence
12+
- [x] did:nostr sign-in — NIP-98 signed requests verified server-side
13+
(BIP-340 via @noble/curves); xlogin widget in the UI (extension, guest
14+
key, or pasted privkey — it's a testnet); hex canonical, npub
15+
display-only. did:nostr agents are first-class in one graph with
16+
password agents.
1217
- [ ] Public testnet instance (pm2 on one of the boxes; `PUBLIC_URL` set)
1318
- [ ] Seed script for a demo trust network
1419

1520
## v1 — signatures & identity
16-
17-
- [ ] did:nostr sign-in (BIP340 schnorr; hex canonical, npub display-only)
1821
- [ ] `sig` on every transition over the JCS bytes (spec §7); node rejects
1922
unverifiable transitions — verify-don't-trust
2023
- [ ] WebID / external-URI agents alongside node-local `/u/name#me`

docs/spec/index.html

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -415,10 +415,23 @@ <h3 id="sig-verify"><span class="secno">9.4</span>Verification<a class="self" hr
415415

416416
<h2 id="http"><span class="secno">10.</span>HTTP API<a class="self" href="#http">§</a></h2>
417417
<p>A node exposing HTTP <span class="rfc">must</span> implement the following resources. Writes
418-
require <code>Authorization: Bearer</code> (level&nbsp;0: node-issued token; level&nbsp;1: the
419-
signature within the body makes the bearer optional). All responses are JSON; errors are
420-
<code>{"error": string}</code> with the status codes of §&nbsp;7.2. CORS
421-
<span class="rfc">should</span> be permissive on reads.</p>
418+
require an <code>Authorization</code> header in one of two schemes, and a node
419+
<span class="rfc">must</span> accept both:</p>
420+
<ul>
421+
<li><code>Bearer &lt;token&gt;</code> — a node-issued token for a node-local account
422+
(<code>/api/register</code>, <code>/api/login</code>).</li>
423+
<li><code>Nostr &lt;base64(event)&gt;</code> — a NIP-98 [<a href="#ref-nip98">NIP-98</a>] signed
424+
kind-27235 event authenticating the actor as <code>did:nostr:&lt;pubkey&gt;</code> with no prior
425+
registration. The node <span class="rfc">must</span> verify: the BIP-340 signature over the NIP-01
426+
event id; <code>u</code> equals the request's absolute URL and <code>method</code> its verb;
427+
<code>created_at</code> within ±60&nbsp;s; and the <code>payload</code> tag equals
428+
SHA-256 of the exact body bytes whenever a body is present (a body-carrying request
429+
<span class="rfc">must not</span> verify without a payload tag, or a signed bodyless header could
430+
be replayed onto a write). This is signed <em>authentication</em>; signed <em>transitions</em>
431+
remain level&nbsp;1 (<a href="#signatures">§&nbsp;9</a>).</li>
432+
</ul>
433+
<p>All responses are JSON; errors are <code>{"error": string}</code> with the status codes of
434+
§&nbsp;7.2. CORS <span class="rfc">should</span> be permissive on reads.</p>
422435
<div class="tblwrap"><table>
423436
<tr><th>Method &amp; path</th><th>Body / query</th><th>Success</th></tr>
424437
<tr><td><code>POST /api/register</code></td><td><code>{username, password}</code></td>
@@ -552,6 +565,8 @@ <h3>B.2 Informative</h3>
552565
1.0: Web Identity and Discovery</a></li>
553566
<li id="ref-nip01">[NIP-01] — <a href="https://github.com/nostr-protocol/nips/blob/master/01.md">Nostr:
554567
Basic protocol flow description</a></li>
568+
<li id="ref-nip98">[NIP-98] — <a href="https://github.com/nostr-protocol/nips/blob/master/98.md">Nostr:
569+
HTTP Auth</a></li>
555570
<li>[TRUSTLINE-V1] — <a href="https://github.com/webcontracts/webcontracts.github.io/issues/4">trustline.v1
556571
profile — Ryan Fugger's original Ripple as a web contract</a></li>
557572
<li>[BLOCKTRAILS] — <a href="https://blocktrails.org">Blocktrails: Bitcoin-anchored history</a>

index.html

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,11 @@ <h2>What 2004 didn't have</h2>
165165
<p>Every transition is hash-chained — <code>seq</code>, <code>prev</code>, SHA-256 over the
166166
RFC 8785-canonical entry. Anyone can re-derive the whole chain from
167167
<code>/api/log</code>; the UI shows the verification badge live.</p></div>
168-
<div class="card"><h3><span class="ic"></span>Signatures, not sessions <small style="color:var(--warn);font-weight:600">v1</small></h3>
169-
<p>Next: each transition schnorr-signed by the actor's <code>did:nostr</code> key over the same
170-
canonical bytes. The server drops from authority to coordinator — verify, don't trust. A signed
171-
transition is shaped like a nostr event, on purpose.</p></div>
168+
<div class="card"><h3><span class="ic"></span>Signatures, not sessions</h3>
169+
<p><strong>Live now:</strong> sign in with your nostr key — every request is NIP-98-signed and
170+
your agent is <code>did:nostr:&lt;pubkey&gt;</code>, no registration. <strong>Next (v1):</strong>
171+
the transitions themselves schnorr-signed over the same canonical bytes — the server drops from
172+
authority to coordinator. A signed transition is shaped like a nostr event, on purpose.</p></div>
172173
<div class="card"><h3><span class="ic"></span>A network of nodes <small style="color:var(--warn);font-weight:600">v2</small></h3>
173174
<p>Signed transitions make federation possible: routes that cross nodes, relayed and verified
174175
anywhere — the part that was genuinely hard in 2004. Chain tips can anchor to Bitcoin via
@@ -178,7 +179,7 @@ <h2>What 2004 didn't have</h2>
178179

179180
<section id="run"><div class="wrap">
180181
<p class="eyebrow">Run a node</p>
181-
<h2>A testnet in one file, zero dependencies</h2>
182+
<h2>A testnet in one file, one dependency</h2>
182183
<p class="lead">The whole node — engine, API, product UI — is plain Node.js. Clone it, run it,
183184
open the app, and start a trust network with your friends. Testnet: play money, real protocol.</p>
184185
<div class="cards c2">

lib/nip98.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Server-side NIP-98 (HTTP Auth for Nostr) verification.
2+
//
3+
// Companion to the `nip98` client library (github.com/nip98/nip98), which
4+
// creates the header and validates its STRUCTURE but deliberately leaves
5+
// signature verification to a schnorr library — this module is that half:
6+
// structure checks + NIP-01 event id + BIP-340 verification via
7+
// @noble/curves (the repo's one dependency).
8+
//
9+
// Authorization: Nostr <base64(signed kind-27235 event)>
10+
// tags: [["u", <absolute url>], ["method", <verb>], ["payload", <sha256 hex>]?]
11+
//
12+
// A verified header authenticates the actor as `did:nostr:<64-hex-pubkey>` —
13+
// the hex form canonical, npub display-only. This is signed AUTHENTICATION
14+
// (the request is signed); signed TRANSITIONS (the ledger entry itself) are
15+
// protocol level 1, spec §9.
16+
17+
import crypto from 'node:crypto';
18+
import { schnorr } from '@noble/curves/secp256k1';
19+
20+
const WINDOW_SECS = 60;
21+
const HEX64 = /^[0-9a-f]{64}$/;
22+
const HEX128 = /^[0-9a-f]{128}$/;
23+
24+
const sha256hex = (data) => crypto.createHash('sha256').update(data).digest('hex');
25+
26+
/** NIP-01 event id: sha256 over the canonical serialization array. */
27+
export function eventId(ev) {
28+
return sha256hex(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content]));
29+
}
30+
31+
/**
32+
* Verify a NIP-98 Authorization header against the request it arrived on.
33+
* Returns `did:nostr:<hex>` on success, or null (never throws).
34+
*
35+
* @param {string} header the Authorization header value
36+
* @param {string} url the absolute URL the client addressed
37+
* @param {string} method the HTTP method
38+
* @param {string|null} rawBody the exact body bytes as a string, if any
39+
*/
40+
export function verifyNip98(header, url, method, rawBody = null) {
41+
if (typeof header !== 'string' || !header.startsWith('Nostr ')) return null;
42+
let ev;
43+
try { ev = JSON.parse(Buffer.from(header.slice(6), 'base64').toString('utf8')); }
44+
catch { return null; }
45+
if (!ev || typeof ev !== 'object' || ev.kind !== 27235) return null;
46+
if (!HEX64.test(ev.pubkey || '') || !HEX128.test(ev.sig || '')) return null;
47+
if (!Array.isArray(ev.tags) || typeof ev.content !== 'string') return null;
48+
if (!Number.isInteger(ev.created_at)) return null;
49+
if (Math.abs(Math.floor(Date.now() / 1000) - ev.created_at) > WINDOW_SECS) return null;
50+
51+
const tag = (name) => ev.tags.find((t) => Array.isArray(t) && t[0] === name)?.[1];
52+
if (tag('u') !== url) return null;
53+
if ((tag('method') || '').toUpperCase() !== method.toUpperCase()) return null;
54+
// Payload binding: when the event carries a payload tag it must match the
55+
// body; when a body is present a payload tag is required (else a signed
56+
// GET header could be replayed onto a write within the time window).
57+
const payload = tag('payload');
58+
if (rawBody != null && rawBody !== '') {
59+
if (payload !== sha256hex(rawBody)) return null;
60+
} else if (payload != null) return null;
61+
62+
if (eventId(ev) !== ev.id) return null;
63+
try {
64+
if (!schnorr.verify(ev.sig, ev.id, ev.pubkey)) return null;
65+
} catch { return null; }
66+
return `did:nostr:${ev.pubkey}`;
67+
}
68+
69+
/**
70+
* Build a signed NIP-98 header from a raw private key (server/test side —
71+
* browsers use the `nip98`/xlogin client instead).
72+
*/
73+
export function buildNip98(privkeyHex, url, method, rawBody = null) {
74+
const pubkey = Buffer.from(schnorr.getPublicKey(privkeyHex)).toString('hex');
75+
const tags = [['u', url], ['method', method.toUpperCase()]];
76+
if (rawBody != null && rawBody !== '') tags.push(['payload', sha256hex(rawBody)]);
77+
const ev = { kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '', pubkey };
78+
ev.id = eventId(ev);
79+
ev.sig = Buffer.from(schnorr.sign(ev.id, privkeyHex)).toString('hex');
80+
return 'Nostr ' + Buffer.from(JSON.stringify(ev)).toString('base64');
81+
}

lib/ui.js

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,15 @@ input.mono{font-family:var(--mono);font-size:13px}
146146
<div class="field"><label for="a-user">Username</label><input type="text" id="a-user" autocomplete="username"></div>
147147
<div class="field"><label for="a-pass">Password</label><input type="password" id="a-pass" autocomplete="current-password"></div>
148148
<button class="btn primary" id="a-go" style="width:100%">Sign in</button>
149+
<div style="display:flex;align-items:center;gap:10px;margin:14px 0">
150+
<span style="flex:1;height:1px;background:var(--line)"></span>
151+
<span style="font-size:12px;color:var(--faint)">or</span>
152+
<span style="flex:1;height:1px;background:var(--line)"></span>
153+
</div>
154+
<button class="btn" id="a-nostr" style="width:100%">⚡ Sign in with Nostr</button>
149155
<div class="formmsg" id="a-msg"></div>
150156
</div></div>
151-
<p class="help" style="text-align:center;margin-top:14px">Testnet — play money, real protocol. did:nostr sign-in arrives in v1.</p>
157+
<p class="help" style="text-align:center;margin-top:14px">Testnet — play money, real protocol. Nostr sign-in: extension, guest key, or paste a private key (via <a href="https://github.com/melvincarvalho/xlogin">xlogin</a>); requests are NIP-98 signed, your agent is <code>did:nostr:&lt;pubkey&gt;</code>.</p>
152158
</div>
153159
</section>
154160
@@ -247,7 +253,17 @@ function whoChip(id){return '<span class="who">'+avatar(id)+'<span class="nm" ti
247253
function toast(m){var t=$('toast');t.textContent=m;t.classList.add('show');clearTimeout(toast._t);toast._t=setTimeout(function(){t.classList.remove('show')},2300)}
248254
function token(){return localStorage.getItem('solidpayToken')||''}
249255
function hdrs(){var h={'content-type':'application/json'};if(token())h.authorization='Bearer '+token();return h}
250-
function api(p,opt){return fetch('/api'+p,opt).then(function(r){return r.json().then(function(b){return{ok:r.ok,status:r.status,body:b}}).catch(function(){return{ok:r.ok,status:r.status,body:{}}})})}
256+
function nostrOn(){return !token()&&window.xlogin&&window.xlogin.type==='nostr'&&window.xlogin.id}
257+
/* One fetch for both auth schemes: a stored bearer wins; else, with an active
258+
xlogin nostr session, authFetch NIP-98-signs the ABSOLUTE url + body. */
259+
function api(p,opt){
260+
opt=opt||{};
261+
var abs=location.origin+'/api'+p;
262+
var wrap=function(r){return r.json().then(function(b){return{ok:r.ok,status:r.status,body:b}}).catch(function(){return{ok:r.ok,status:r.status,body:{}}})};
263+
if(token()){opt.headers=Object.assign({},opt.headers||{},{authorization:'Bearer '+token()});return fetch(abs,opt).then(wrap)}
264+
if(nostrOn())return window.xlogin.authFetch(abs,opt).then(wrap);
265+
return fetch(abs,opt).then(wrap);
266+
}
251267
function resolvePeer(v){
252268
v=(v||'').trim(); if(!v)return null;
253269
if(/^[A-Za-z0-9._-]+$/.test(v)&&v.indexOf(':')<0)return location.origin+'/u/'+v.toLowerCase()+'#me';
@@ -305,9 +321,15 @@ function render(){renderAccount();renderPeers();if(!S.me){route();return}
305321
function renderAccount(){
306322
var el=$('account');
307323
if(S.me){
308-
el.innerHTML=avatar(S.me)+'<a class="acct-name" style="color:var(--ink);text-decoration:none" href="'+esc(S.me.replace(/#.*$/,''))+'" title="'+esc(S.me)+'">'+esc(shortName(S.me))+'</a>'
309-
+'<button class="btn quiet sm" id="signout">Sign out</button>';
310-
$('signout').onclick=function(){localStorage.removeItem('solidpayToken');S.me=null;location.hash='#overview';refresh()};
324+
var nm=S.me.indexOf('http')===0
325+
?'<a class="acct-name" style="color:var(--ink);text-decoration:none" href="'+esc(S.me.replace(/#.*$/,''))+'" title="'+esc(S.me)+'">'+esc(shortName(S.me))+'</a>'
326+
:'<span class="acct-name" title="'+esc(S.me)+'">'+esc(shortName(S.me))+'</span>';
327+
el.innerHTML=avatar(S.me)+nm+'<button class="btn quiet sm" id="signout">Sign out</button>';
328+
$('signout').onclick=function(){
329+
localStorage.removeItem('solidpayToken');
330+
if(window.xlogin&&window.xlogin.id){try{window.xlogin.logout()}catch(e){}}
331+
S.me=null;location.hash='#overview';refresh();
332+
};
311333
}else{
312334
el.innerHTML='<button class="btn primary sm" onclick="location.hash=\\'#overview\\'">Sign in</button>';
313335
}
@@ -421,6 +443,12 @@ function authGo(){
421443
}
422444
$('a-go').onclick=authGo;
423445
$('a-pass').addEventListener('keydown',function(e){if(e.key==='Enter')authGo()});
446+
$('a-nostr').onclick=function(){
447+
if(window.xlogin&&window.xlogin.login)window.xlogin.login();
448+
else toast('Login widget still loading — try again in a moment');
449+
};
450+
document.addEventListener('xlogin',function(){toast('Signed in with Nostr');refresh()});
451+
document.addEventListener('xlogout',function(){S.me=null;refresh()});
424452
425453
var previewRoute=debounce(function(){
426454
var to=resolvePeer($('p-to').value),amt=Number($('p-amt').value),cur=($('p-cur').value||'').toUpperCase().trim();
@@ -480,5 +508,6 @@ refresh();
480508
setInterval(function(){if(document.visibilityState==='visible')refresh()},6000);
481509
})();
482510
</script>
511+
<script src="/xlogin.js"></script>
483512
</body></html>`;
484513
}

0 commit comments

Comments
 (0)