aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-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
4 files changed, 89 insertions, 52 deletions
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));
+}