From 75ec27747ec05da8990208d67052ad11d25f3c44 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 3 Aug 2026 22:28:53 -0700 Subject: Speak as the account you logged in with, or not at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logging in with a remote signer and reloading left the app showing your avatar and profile while signing everything with the wallet's own key — so chat messages arrived under a different npub with a stranger's punk on them. identity() had a fallback: when the login signer wasn't available it quietly used the wallet key instead. That fallback is gone. If you logged in as a nostr account, the app speaks as that account or it refuses and says so. Refusing is only reasonable if the signer usually comes back, so a login now persists what it takes to be that identity again. A remote signer stores its NIP-46 client key and pointer — the signer authorised that client key, not one page load, so rebuilding from it resumes the session without prompting. A pasted key stores the key, which is already the seed of everything here since the wallet's mnemonic derives from it. Resume is single-flight with a pause after failure, because identity() is asked on every send and a signer that isn't answering must not mean a relay connection per keystroke. Existing remote-signer logins predate the stored session, so they need one more sign-in to become resumable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi --- src/features/messages.js | 6 ++++- src/features/nostrlogin.js | 63 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 10 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 5aa8dfe..74abdd8 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -114,11 +114,15 @@ export function messagesFeature(ctx) { // ---- identity ----------------------------------------------------------- + // Who we speak as. If the user logged in with a nostr account we speak as + // that account or not at all — falling back to the wallet's own key would + // sign under a pubkey that isn't the one their avatar and profile show, + // which reads to everyone else as a different person entirely. async function identity() { const id = hook('nostrLoginIdentity'); if (id) { const signer = id.signer || (await hook('nostrLoginResume')); - if (signer) return { pubkey: id.pubkey, signer }; + return signer ? { pubkey: id.pubkey, signer } : null; } if (wallet.nostr && wallet.nostr.sk) return { pubkey: wallet.nostr.pk, signer: wallet.nostr.sk }; return null; diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 777a11e..fa0ad3a 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -12,9 +12,10 @@ import { newMnemonic } from '../wallet.js'; import { npubOf } from '../nostr.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; +import { hex } from '@scure/base'; import { extensionSigner, bunkerSigner, keySigner, parseNostrSecret, - walletForSigner, publishWalletBackup, nostrConnect} from '../nostr-login.js'; + walletForSigner, publishWalletBackup, nostrConnect, resumeBunker } from '../nostr-login.js'; // The nostr ostrich, in the brand's monochrome. const OSTRICH = ''; @@ -32,6 +33,25 @@ export function nostrLoginFeature(ctx) { // 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; + let resuming = null; + let resumeFailedAt = 0; + + // What it takes to be this identity again after a reload. Without it the app + // shows your face in the header but signs everything else with the wallet's + // own key — a different pubkey, which is exactly the mismatch people notice + // when their chat messages come out under a stranger's name. + // + // A remote signer stores its NIP-46 client key, which the signer authorised + // for this app and which cannot sign anything on its own. A pasted key + // stores the key itself: it is already the seed of everything here (the + // wallet's mnemonic derives from it), so this is the same secret the app is + // holding either way, not a new one. + const sessionOf = (signer) => { + if (!signer) return {}; + if (signer.session) return { session: signer.session, secret: null }; + if (signer.secret instanceof Uint8Array) return { session: null, secret: hex.encode(signer.secret) }; + return { session: null, secret: null }; + }; const busy = (v) => { ui.nostrLoginBusy = v; render(); }; const fail = (e) => { ui.nostrLoginError = e.message || String(e); busy(false); }; @@ -57,7 +77,7 @@ export function nostrLoginFeature(ctx) { 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() }); + save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), ...sessionOf(signer) }); // claim the real npub as the payment address while this signer is live ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {}); toast(res.mode === 'derived' ? t('nlOpenedDerived') : t('nlOpenedRestored')); @@ -75,7 +95,7 @@ export function nostrLoginFeature(ctx) { ui.nostrLoginNew = null; live = st.signer; await ctx.openMnemonic(mnemonic, '', { nostrPubkey: st.signer.pubkey }); - save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now() }); + save({ ...load(), pubkey: st.signer.pubkey, linked: Date.now(), ...sessionOf(st.signer) }); ctx.hook('namesAdoptIdentity', st.signer, npubOf(st.signer.pubkey))?.catch?.(() => {}); toast(t('nlCreated')); busy(false); @@ -97,7 +117,7 @@ export function nostrLoginFeature(ctx) { } await publishWalletBackup(signer, { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' }); live = signer; - save({ ...load(), pubkey: signer.pubkey, linked: Date.now() }); + save({ ...load(), pubkey: signer.pubkey, linked: Date.now(), ...sessionOf(signer) }); ctx.hook('namesAdoptIdentity', signer, npubOf(signer.pubkey))?.catch?.(() => {}); toast(t('nlLinked')); busy(false); @@ -258,13 +278,38 @@ export function nostrLoginFeature(ctx) { async nostrLoginResume() { if (live) return live; const st = load(); - if (!st.pubkey || typeof window === 'undefined' || !window.nostr) return null; + if (!st.pubkey) return null; + // One attempt at a time, and a pause after a failure: identity() asks on + // every send, and a signer that isn't answering shouldn't mean a new + // relay connection per keystroke. + if (resuming) return resuming; + if (Date.now() - resumeFailedAt < 20_000) return null; + resuming = (async () => { + if (typeof window !== 'undefined' && window.nostr) { + try { + const s = await extensionSigner(); + if (s.pubkey === st.pubkey) return (live = s); + } catch {} + } + if (st.secret) { + try { + const s = keySigner(hex.decode(st.secret)); + if (s.pubkey === st.pubkey) return (live = s); + } catch {} + } + if (st.session) { + try { + const s = await resumeBunker(st.session, { onAuth: (url) => { ui.nostrLoginAuthUrl = url; render(); } }); + if (s && s.pubkey === st.pubkey) return (live = s); + } catch {} + } + return null; + })(); try { - const s = await extensionSigner(); - if (s.pubkey !== st.pubkey) return null; - live = s; + const s = await resuming; + if (!s) resumeFailedAt = Date.now(); return s; - } catch { return null; } + } finally { resuming = null; } }, unlockExtra() { return unlockExtra(); }, nostrSettingsCards() { return [settingsCard()]; }, -- cgit v1.2.3