aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--build.js2
-rw-r--r--names/server.js82
-rw-r--r--src/features/index.js2
-rw-r--r--src/features/names.js84
-rw-r--r--src/features/nostrlogin.js27
-rw-r--r--src/nostr-login.js28
-rw-r--r--tools/nostr-login-test.js17
7 files changed, 165 insertions, 77 deletions
diff --git a/build.js b/build.js
index 6a596e8..02eb13d 100644
--- a/build.js
+++ b/build.js
@@ -181,7 +181,7 @@ export async function buildSwNwc({ minify = true } = {}) {
// feature's code (and its network endpoints) never enters the bundle.
// NB order matters and must match src/features/index.js — nwc sits after ark
// because it drives ark's headless pay/balance seam.
-const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', nostrlogin: 'nostrLoginFeature', sync: 'syncFeature' };
+const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', nostrlogin: 'nostrLoginFeature', names: 'namesFeature', zaps: 'zapsFeature', nwc: 'nwcFeature', sync: 'syncFeature' };
export function enabledFeatures(spec = process.env.HAL_FEATURES) {
if (spec == null) return Object.keys(ALL_FEATURES);
diff --git a/names/server.js b/names/server.js
index a43191e..bd693b2 100644
--- a/names/server.js
+++ b/names/server.js
@@ -1,6 +1,8 @@
// BIP-353 name registrar for halwallet.app — gives every hal user a
// ₿[email protected] payment address.
//
+// Auth on every write is NIP-98 (HTTP Auth, kind 27235).
+//
// A name is a DNSSEC-signed TXT record at
// <name>.user._bitcoin-payment.halwallet.app
// whose content is a BIP-21 `bitcoin:` URI (for hal users: an ark address,
@@ -16,6 +18,8 @@
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { verifyEvent } from 'nostr-tools/pure';
+import { npubEncode } from 'nostr-tools/nip19';
+import { createHash } from 'node:crypto';
import { HDKey } from '@scure/bip32';
import { mnemonicToSeedSync } from '@scure/bip39';
import { lnBackend } from '../bridge/ln.js';
@@ -31,8 +35,6 @@ const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG
const DOMAIN = CFG.domain || 'coinos.io'; // the default domain for claims
const DOMAINS = CFG.domains || { [DOMAIN]: { zoneId: CFG.zoneId } };
const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'names.json');
-const AUTH_KIND = 21353; // arbitrary custom kind for signed claims
-const MAX_SKEW_SEC = 600; // claim events must be fresh
const TTL = 300; // record TTL: BIP-353 wallets cache by this
const log = (...a) => console.log(new Date().toISOString(), ...a);
@@ -182,15 +184,35 @@ setInterval(async () => {
// claims
// ---------------------------------------------------------------------------
-// The claim is a signed nostr event: proof the caller controls the wallet's
-// nostr key, which becomes (or must match) the name's owner.
-function checkAuth(auth) {
- if (!auth || auth.kind !== AUTH_KIND) return 'bad auth kind';
- if (Math.abs(Date.now() / 1000 - (auth.created_at || 0)) > MAX_SKEW_SEC) return 'auth event too old';
- if (!verifyEvent(auth)) return 'bad signature';
- return null;
+// NIP-98 HTTP Auth (kind 27235). The signature covers this exact URL, method
+// and body hash, so a captured header is useless anywhere else.
+async function checkNip98(req, url, bodyText) {
+ const h = req.headers.get('authorization') || '';
+ const m = h.match(/^Nostr\s+(.+)$/i);
+ if (!m) return { error: 'missing Nostr authorization' };
+ let evt;
+ try { evt = JSON.parse(Buffer.from(m[1], 'base64').toString('utf8')); } catch { return { error: 'bad auth encoding' }; }
+ if (evt.kind !== 27235) return { error: 'auth must be kind 27235' };
+ if (Math.abs(Date.now() / 1000 - (evt.created_at || 0)) > 60) return { error: 'auth event expired' };
+ if (!verifyEvent(evt)) return { error: 'bad signature' };
+ const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1];
+ if ((tag('method') || '').toUpperCase() !== req.method) return { error: 'method mismatch' };
+ let got;
+ try { got = new URL(tag('u') || ''); } catch { return { error: 'bad url tag' }; }
+ if (got.pathname !== url.pathname) return { error: 'url mismatch' };
+ if (bodyText) {
+ const want = createHash('sha256').update(bodyText).digest('hex');
+ if (tag('payload') !== want) return { error: 'payload hash mismatch' };
+ }
+ return { pubkey: evt.pubkey };
}
+// npub-prefix names are the free default everyone gets, so they must not be
+// squattable: a name that looks like one may only be claimed by that very
+// identity — or by a key that identity nominated as its manager (the wallet
+// key, which is always available, even offline and in the service worker).
+const npubPrefixOwner = (name) => (/^npub1[a-z0-9]+$/.test(name) ? name : null);
+
// coinos.io names that belong to existing coinos users are reserved for
// them until the migration gives those users a way to claim their own.
async function takenByCoinosUser(domain, name) {
@@ -271,24 +293,33 @@ Bun.serve({
if (url.pathname === '/register' && req.method === 'POST') {
if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const body = await req.json().catch(() => null);
- const auth = body?.auth;
- const authErr = checkAuth(auth);
- if (authErr) return json({ error: authErr }, 400);
+ const bodyText = await req.text();
+ const a = await checkNip98(req, url, bodyText);
+ if (a.error) return json({ error: a.error }, 401);
let claim;
- try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); }
+ try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
+ const auth = { pubkey: a.pubkey };
const name = String(claim.name || '').toLowerCase();
const domain = String(claim.domain || 'halwallet.app').toLowerCase();
if (!DOMAINS[domain]) return json({ error: 'unknown domain' }, 400);
if (!NAME_RE.test(name)) return json({ error: 'invalid name (a-z, 0-9, . _ -, max 30)' }, 400);
if (RESERVED.has(name)) return json({ error: 'name is reserved' }, 400);
- if (claim.action !== 'register') return json({ error: 'wrong action' }, 400);
if (!validUri(claim.uri)) return json({ error: 'uri must be a bitcoin: URI' }, 400);
const key = `${name}@${domain}`;
const existing = state.names[key];
- if (existing && existing.pubkey !== auth.pubkey) return json({ error: 'name is taken' }, 409);
+ // owner OR the manager key the owner nominated may update a record
+ if (existing && existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) {
+ return json({ error: 'name is taken' }, 409);
+ }
if (!existing && await takenByCoinosUser(domain, name)) return json({ error: 'name is taken' }, 409);
+ // an npub-shaped name belongs to that identity alone
+ if (npubPrefixOwner(name)) {
+ const managerOk = existing && existing.manager === auth.pubkey;
+ if (!npubEncode(auth.pubkey).startsWith(name) && !managerOk) {
+ return json({ error: 'that name belongs to another Nostr identity' }, 403);
+ }
+ }
if (/[?&]lno=/.test(claim.uri)) return json({ error: 'lno is added by the registrar' }, 400);
// Every name also gets a static Lightning offer on our node; payments
@@ -299,7 +330,11 @@ Bun.serve({
const recordId = await cfWrite(name, domain, published, existing?.recordId);
state.names[key] = {
- pubkey: auth.pubkey, uri: published, recordId, updated: Date.now(), domain,
+ pubkey: existing?.pubkey || auth.pubkey,
+ // the owner may nominate a key that manages the record from here on
+ manager: (claim.manager && /^[0-9a-f]{64}$/.test(claim.manager) && auth.pubkey !== claim.manager)
+ ? claim.manager : existing?.manager,
+ uri: published, recordId, updated: Date.now(), domain,
offerId: offer?.offerId, bolt12: offer?.bolt12,
};
persist();
@@ -309,19 +344,18 @@ Bun.serve({
if (url.pathname === '/register' && req.method === 'DELETE') {
if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
- const body = await req.json().catch(() => null);
- const auth = body?.auth;
- const authErr = checkAuth(auth);
- if (authErr) return json({ error: authErr }, 400);
+ const bodyText = await req.text();
+ const a = await checkNip98(req, url, bodyText);
+ if (a.error) return json({ error: a.error }, 401);
let claim;
- try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); }
+ try { claim = JSON.parse(bodyText); } catch { return json({ error: 'bad body' }, 400); }
+ const auth = { pubkey: a.pubkey };
const name = String(claim.name || '').toLowerCase();
const domain = String(claim.domain || 'halwallet.app').toLowerCase();
- if (claim.action !== 'delete') return json({ error: 'wrong action' }, 400);
const key = `${name}@${domain}`;
const existing = state.names[key];
if (!existing) return json({ error: 'unknown name' }, 404);
- if (existing.pubkey !== auth.pubkey) return json({ error: 'not your name' }, 403);
+ if (existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) return json({ error: 'not your name' }, 403);
await cfDelete(domain, existing.recordId).catch(() => {});
if (existing.offerId && ln) await ln.call('disableoffer', { offer_id: existing.offerId }).catch(() => {});
delete state.names[key];
diff --git a/src/features/index.js b/src/features/index.js
index 8f3e5d1..70f8421 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -20,5 +20,5 @@ import { nwcFeature } from './nwc.js';
// names sits between ark and zaps: a pasted user@domain resolves over DNS
// (BIP-353) before the zaps feature treats it as a plain Lightning address.
export function buildFeatures(ctx) {
- return [giftsFeature(ctx), arkFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), nostrLoginFeature(ctx), syncFeature(ctx)];
+ return [giftsFeature(ctx), arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)];
}
diff --git a/src/features/names.js b/src/features/names.js
index 970a690..33bddd7 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -12,6 +12,7 @@
// record falls back to the Lightning-address flow (zaps feature).
import { resolveBip353, parsePaymentName, parseBip21 } from '../bip353.js';
+import { nip98Header } from '../nostr-login.js';
import { qrSvg } from '../qr.js';
import { isArkAddress } from './ark.js';
import { getNetwork } from '../api.js';
@@ -24,6 +25,25 @@ const AUTH_KIND = 21353;
export function namesFeature(ctx) {
const { h, ui, render, wallet, hook, toast, copyBtn } = ctx;
+ // The wallet's own key signs registrar requests: it's the one key that is
+ // always available — offline, in the background, with no signer attached.
+ const walletSigner = () => ({
+ pubkey: wallet.nostrPubkey && wallet.nostrPubkey(),
+ signEvent: async (e) => wallet.nostrSign(e),
+ });
+
+ async function post(path, method, payload, signer) {
+ const url = `${REGISTRAR}${path}`;
+ const body = JSON.stringify(payload);
+ const auth = await nip98Header(signer || walletSigner(), url, method, body);
+ const r = await fetch(url, {
+ method, headers: { 'content-type': 'application/json', authorization: auth }, body,
+ });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
+ return j;
+ }
+
const load = () => wallet.loadFeatureState('names', {});
const save = (st) => wallet.saveFeatureState('names', st);
@@ -35,31 +55,19 @@ export function namesFeature(ctx) {
return addr ? `bitcoin:?ark=${encodeURIComponent(addr)}` : null;
}
- async function claim(name) {
+ async function claim(name, { signer, manager } = {}) {
const uri = await currentUri();
if (!uri) throw new Error(t('namesNeedArk'));
- const auth = wallet.nostrSign({
- kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [],
- content: JSON.stringify({ action: 'register', name, uri, domain: DOMAIN }),
- });
- if (!auth) throw new Error('wallet cannot sign');
- const r = await fetch(`${REGISTRAR}/register`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ auth }),
- });
- const j = await r.json();
- if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
+ const j = await post('/register', 'POST', {
+ name, uri, domain: DOMAIN,
+ // when the login identity claims its own npub name, it nominates the
+ // wallet key to keep the record updated afterwards
+ ...(manager ? { manager } : {}),
+ }, signer);
const prev = load().name;
save({ name, domain: DOMAIN, uri, updated: Date.now() });
if (prev && prev !== name) {
- const bye = wallet.nostrSign({
- kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [],
- content: JSON.stringify({ action: 'delete', name: prev, domain: DOMAIN }),
- });
- fetch(`${REGISTRAR}/register`, {
- method: 'DELETE', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ auth: bye }),
- }).catch(() => {});
+ post('/register', 'DELETE', { name: prev, domain: DOMAIN }).catch(() => {});
}
return j;
}
@@ -67,30 +75,10 @@ export function namesFeature(ctx) {
async function release() {
const st = load();
if (!st.name) return;
- const auth = wallet.nostrSign({
- kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [],
- content: JSON.stringify({ action: 'delete', name: st.name, domain: st.domain || DOMAIN }),
- });
- await fetch(`${REGISTRAR}/register`, {
- method: 'DELETE', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ auth }),
- }).catch(() => {});
+ await post('/register', 'DELETE', { name: st.name, domain: st.domain || DOMAIN }).catch(() => {});
save({});
}
- // An imported seed doesn't know its username — the registrar does. Ask by
- // the wallet's nostr pubkey and adopt what it says before deciding whether
- // onboarding needs to prompt.
- let checked = false; // lookup completed (gate stays closed until then)
- async function lookupMine() {
- try {
- const pk = wallet.nostrPubkey && wallet.nostrPubkey();
- if (!pk) return;
- const r = await fetch(`${REGISTRAR}/pubkey/${pk}`).then((x) => x.json());
- if (r && r.name) save({ ...load(), name: r.name, domain: r.domain || DOMAIN, uri: r.uri });
- } catch {}
- }
-
// Everyone gets an address without being asked: the default username is a
// prefix of the wallet's npub — unique, boring, and free. Custom names are
// an optional change (and may cost something one day, to keep squatters
@@ -101,8 +89,18 @@ export function namesFeature(ctx) {
let st = load();
if (!st.name) { await lookupMine(); st = load(); }
if (!st.name) {
- const npub = wallet.nostrNpub && wallet.nostrNpub();
- if (npub) { await claim(npub.slice(0, 20)); st = load(); }
+ // The address people see should be the identity they know the user
+ // by: their real npub when a nostr account is linked, else the
+ // wallet's own. Both are claimed the same way; the registrar checks
+ // that an npub-shaped name really belongs to its claimant.
+ const linked = hook('nostrLoginIdentity');
+ const npub = (linked && linked.npub) || (wallet.nostrNpub && wallet.nostrNpub());
+ if (npub) {
+ await claim(npub.slice(0, 20), linked
+ ? { signer: linked.signer, manager: wallet.nostrPubkey() }
+ : {});
+ st = load();
+ }
}
checked = true;
render();
diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js
index 60e3759..c5369e9 100644
--- a/src/features/nostrlogin.js
+++ b/src/features/nostrlogin.js
@@ -22,6 +22,11 @@ export function nostrLoginFeature(ctx) {
const load = () => wallet.loadFeatureState('nostrlogin', {});
const save = (st) => wallet.saveFeatureState('nostrlogin', st);
+ // The signer that opened this session, kept so the names feature can claim
+ // the user's real npub as their payment address (and nominate the wallet
+ // key as manager, so later updates work without the signer).
+ let live = null;
+
const busy = (v) => { ui.nostrLoginBusy = v; render(); };
const fail = (e) => { ui.nostrLoginError = e.message || String(e); busy(false); };
@@ -40,11 +45,16 @@ export function nostrLoginFeature(ctx) {
busy(false);
return;
}
- await ctx.hook('openMnemonic', res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey });
+ // Same wallet however you log in: a key-derived seed is published too,
+ // so an extension login later finds this wallet instead of making a new
+ // one. Costs nothing — anyone with the key could derive it anyway.
+ if (res.publish) await publishWalletBackup(signer, { mnemonic: res.mnemonic }).catch(() => {});
+ live = signer;
+ await ctx.openMnemonic(res.mnemonic, res.passphrase || '', { nostrPubkey: signer.pubkey });
+ save({ ...load(), pubkey: signer.pubkey, linked: Date.now() });
toast(res.mode === 'derived' ? t('nlOpenedDerived') : t('nlOpenedRestored'));
busy(false);
} catch (e) { fail(e); }
- finally { if (signer && signer.close && !ui.nostrLoginNew) signer.close(); }
}
async function createForSigner() {
@@ -55,7 +65,9 @@ export function nostrLoginFeature(ctx) {
const mnemonic = newMnemonic();
await publishWalletBackup(st.signer, { mnemonic });
ui.nostrLoginNew = null;
- await ctx.hook('openMnemonic', mnemonic, '', { nostrPubkey: st.signer.pubkey });
+ live = st.signer;
+ await ctx.openMnemonic(mnemonic, '', { nostrPubkey: st.signer.pubkey });
+ save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now() });
toast(t('nlCreated'));
busy(false);
} catch (e) { fail(e); }
@@ -75,11 +87,11 @@ export function nostrLoginFeature(ctx) {
throw new Error(t('nlAlreadyLinked'));
}
await publishWalletBackup(signer, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' });
+ live = signer;
save({ ...load(), pubkey: signer.pubkey, linked: Date.now() });
toast(t('nlLinked'));
busy(false);
} catch (e) { fail(e); }
- finally { if (signer && signer.close) signer.close(); }
}
// ---- UI ---------------------------------------------------------------
@@ -157,6 +169,13 @@ export function nostrLoginFeature(ctx) {
return {
id: 'nostrlogin',
+ // The nostr identity this session is logged in as, for features that
+ // should speak as the user (the payment address defaults to this npub).
+ nostrLoginIdentity() {
+ const st = load();
+ if (!st.pubkey) return null;
+ return { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live };
+ },
unlockExtra() { return unlockExtra(); },
settingsCards() { return [settingsCard()]; },
};
diff --git a/src/nostr-login.js b/src/nostr-login.js
index 6f23e2a..00106c4 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -150,12 +150,32 @@ export async function publishWalletBackup(signer, { mnemonic, passphrase }, rela
// The whole login: given a signer, produce the wallet mnemonic to open.
// `mode` reports what happened so the UI can be honest about it.
+//
+// ONE flow for all three signer kinds, because an identity must open the
+// SAME wallet however you log into it: look for the published association
+// FIRST (that's where the money already is), and only then fall back to
+// deriving (key in hand) or generating (signer only).
export async function walletForSigner(signer) {
- // A pasted key derives its wallet — no lookup, no storage, no exposure.
- if (signer.secret) {
- return { mnemonic: seedFromNostrKey(signer.secret), mode: 'derived' };
- }
const found = await fetchWalletBackup(signer);
if (found) return { mnemonic: found.mnemonic, passphrase: found.passphrase, mode: 'restored' };
+ if (signer.secret) {
+ // Publishing the derived seed costs nothing: the derivation is public,
+ // so anyone holding this key could compute it anyway. It buys the thing
+ // that matters — an extension login later finds this same wallet.
+ return { mnemonic: seedFromNostrKey(signer.secret), mode: 'derived', publish: true };
+ }
return { mnemonic: null, mode: 'new' }; // caller decides + confirms publishing
}
+
+// NIP-98 HTTP Auth (kind 27235): the standard way to authenticate an HTTP
+// request with a nostr key. `u` and `method` bind the signature to this exact
+// request and `payload` to its body, so a captured header can't be replayed
+// against a different endpoint or a tampered body.
+export async function nip98Header(signer, url, method, bodyText) {
+ const tags = [['u', url], ['method', method.toUpperCase()]];
+ if (bodyText) tags.push(['payload', hex.encode(sha256(new TextEncoder().encode(bodyText)))]);
+ const evt = await signer.signEvent({
+ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags, content: '',
+ });
+ return 'Nostr ' + btoa(JSON.stringify(evt));
+}
diff --git a/tools/nostr-login-test.js b/tools/nostr-login-test.js
index 039ff84..42580fe 100644
--- a/tools/nostr-login-test.js
+++ b/tools/nostr-login-test.js
@@ -56,5 +56,22 @@ let denied = false;
try { await stranger.decryptSelf(ct); } catch { denied = true; }
check('another account cannot decrypt it', denied);
+console.log('\n[NIP-98 auth]');
+{
+ const { nip98Header } = await import('../src/nostr-login.js');
+ const { verifyEvent } = await import('nostr-tools/pure');
+ const { sha256: sha } = await import('@noble/hashes/sha256');
+ const body = JSON.stringify({ name: 'x' });
+ const hdr = await nip98Header(keySigner(sk), 'https://names.coinos.io/register', 'POST', body);
+ check('header uses the Nostr scheme', hdr.startsWith('Nostr '));
+ const evt = JSON.parse(Buffer.from(hdr.slice(6), 'base64').toString('utf8'));
+ check('is kind 27235', evt.kind === 27235);
+ check('signature verifies', verifyEvent(evt));
+ const tag = (k) => (evt.tags.find((x) => x[0] === k) || [])[1];
+ check('binds the url', tag('u') === 'https://names.coinos.io/register');
+ check('binds the method', tag('method') === 'POST');
+ check('binds the body hash', tag('payload') === hex.encode(sha(new TextEncoder().encode(body))));
+}
+
console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ nostr login behaves');
process.exit(fails ? 1 : 0);