From a70f34f2a5d35352f99bae8f10cbab12637e08ee Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 9 Oct 2026 23:54:43 -0700 Subject: Messages: Amber users can read their DMs. Signed in with Amber by intent (NIP-55), nothing sealed to the login npub was ever decrypted — each decrypt would be a trip through the app — so isolabellart's thread with Adam showed only what that phone had sent. Worse, decryptorsComplete() counted the Amber signer as complete, so those wraps were tried on the wallet key alone and logged as opened, hidden for good. Now an interactive signer counts as incomplete (wraps wait, uncounted); Messages and the DM thread say "messages sent to you can't be opened here" with Connect Amber, which opens a nostrconnect:// link for the SAME account over NIP-46 (Amber's bunker mode; a different npub is refused, a callbackUrl reload resumes the handshake). Once a signer that can decrypt takes over, a wallet that ran with Amber re-runs the heal pass over every wrap the relays hold, so messages the old code wrote off come back. tools/amber-dm-upgrade-test.js (plays the remote signer over a shimmed relay). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3 --- src/features/messages.js | 40 ++++++++++++++++++++++++++++++++++++---- src/features/nostrlogin.js | 41 +++++++++++++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 10 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 06371cf..597cb23 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -2898,11 +2898,28 @@ export function messagesFeature(ctx) { // "nothing to wait for": counting those passes gave every wrap sealed to // the login npub up after ~40 s with only the wallet key tried, and the // wrap log then hid it from every later session (a phone kept 9 of 186 - // messages). An interactive signer (Amber) is live from the moment it - // resumes and never decrypts here, so it counts as complete. + // messages). An interactive signer (Amber by intent) never decrypts here + // either, so it is incomplete too: counted as complete, every wrap sealed + // to the login npub was given up on the wallet key and logged as opened + // — an Amber user's threads held only what that device had sent. function decryptorsComplete() { const login = hook('nostrLoginIdentity'); - return !login || !!(login.signer && (login.signer.interactive || login.signer.decryptFrom)); + return !login || !!(login.signer && !login.signer.interactive && login.signer.decryptFrom); + } + const interactiveLogin = () => { const l = hook('nostrLoginIdentity'); return !!(l && l.signer && l.signer.interactive); }; + // Wraps logged while an interactive signer stood in for the login npub + // may be messages no key here ever tried. Once a signer that can decrypt + // takes over (Amber reconnected over NIP-46), the heal pass re-opens + // every wrap the relays still hold. + function noteSignerGap() { + const s = st(); + if (interactiveLogin()) { if (!s.dmSignerGap) { s.dmSignerGap = true; save(s); } return; } + if (!s.dmSignerGap || !decryptorsComplete()) return; + delete s.dmSignerGap; + s.dmHeal = 0; + save(s); + wrapLog = null; healed.clear(); seenWraps.clear(); + if (dmStarted) resubscribeStreams(); } // Raw-key unwraps (seed-derived / pasted nsec — the common case) run in @@ -3055,6 +3072,7 @@ export function messagesFeature(ctx) { const pks = dmSubKeys(); if (!pks.length) return; dmStarted = true; + if (interactiveLogin()) noteSignerGap(); loadSeenWraps(); // warm threads from the local cache. A self-thread of our own messages is // the residue of the old sent-copy misthreading (each welcome DM landed @@ -12140,6 +12158,17 @@ export function messagesFeature(ctx) { }, ui.msgReconnecting ? h('span', { class: 'spinner sm' }) : t('msgReconnect'))); } + // Amber by intent signs, but each decrypt would be a trip through the app, + // so nothing sent TO the login npub is opened here — a thread shows only + // what this device sent. Amber also speaks NIP-46; connected that way it + // decrypts quietly, and the messages held back open. + function amberDmNotice() { + if (wallet.watchOnly || !interactiveLogin()) return null; + return h('div', { class: 'row gap6 chat-signer-off', style: 'align-items:center' }, + h('span', { class: 'small muted grow' }, t('msgAmberDms')), + h('button', { class: 'btn-sm', onClick: () => hook('nostrSignerUpgrade') }, t('msgAmberConnect'))); + } + // Enter sends; Shift+Enter (the textarea's native edit) and Ctrl+J insert // a newline. Height follows the text as it's typed — imperatively, since // typing doesn't re-render — with rows as the re-render fallback. @@ -12402,6 +12431,8 @@ export function messagesFeature(ctx) { // that quietly won't open are listed. const lockedRow = signerNotice(); if (lockedRow && wallet.watchOnly) kids.push(lockedRow); + const amberRow = amberDmNotice(); + if (amberRow) kids.push(amberRow); if (pendingLink && pendingLink.where !== 'communities') kids.push(linkInviteCard()); for (const [rid, inv] of pendingDirect) kids.push(directInviteCard(rid, inv)); @@ -13319,6 +13350,7 @@ export function messagesFeature(ctx) { h('div', { class: 'chat-time' }, timeLabel(m.rumor.created_at * 1000))))) : [h('div', { class: 'muted small', style: 'text-align:center;padding:24px 0' }, t('msgNoDmsYet'))])), dmReplyBar(), + amberDmNotice(), composer(t('msgDmPlaceholder'), () => sendDM(peer), null, 'dm:' + peer, (f) => sendDMFile(peer, f)), ui.msgSheet ? dmSheet(peer) : null, choice && ui.msgRoutePick ? routeSheet(peer, choice) : null); @@ -13796,7 +13828,7 @@ export function messagesFeature(ctx) { }, // nostr-login just connected or resumed a signer: wraps the backfill // couldn't open a moment ago can be opened now — drain right away. - nostrSignerLive() { scheduleDrain(0); }, + nostrSignerLive() { noteSignerGap(); scheduleDrain(0); }, // Anyone (ark's history, other features) can open a profile or render a // small clickable identity chip. showProfile(pk) { openProfile(pk); return true; }, diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 306a12f..2023931 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -278,6 +278,16 @@ export function nostrLoginFeature(ctx) { // approval and the login finishes on its own. Deferred a beat so the boot // restore has decided whether this tab is even logged out. setTimeout(() => { + // the same, for a wallet that was reconnecting its own account (Amber + // moving to NIP-46 so messages can be read): back to that screen + const again = !nc && wallet.mnemonic && load().pubkey && nostrConnectPending(); + if (again) { + ui.nostrReconnect = 'upgrade'; + ui.nostrConnectOpen = true; + startNostrConnect(reconnectRun, again); + render(); + return; + } const pending = !nc && !wallet.mnemonic && ui.screen === 'unlock' && nostrConnectPending(); if (!pending) return; if (ui.onb && ['welcome', 'signin'].includes(ui.onb.step)) ui.onb = { step: 'signin' }; @@ -676,6 +686,11 @@ export function nostrLoginFeature(ctx) { linkSection()); } + const reconnectRun = (makeSigner) => reconnectSigner(makeSigner).then(() => { + // an upgrade is done only once the new signer can decrypt + if (liveSigner() && !(ui.nostrReconnect === 'upgrade' && live.interactive)) { ui.nostrReconnect = null; ui.nostrConnectOpen = false; render(); } + }); + // The reconnect takeover: opened by any feature whose action just found // the signer missing (a reload dropped it; Amethyst and friends can't // reattach silently). Same guarded buttons as the settings card — an @@ -683,15 +698,18 @@ export function nostrLoginFeature(ctx) { // refuses a different account. function reconnectScreen() { const st = load(); - const run = (makeSigner) => reconnectSigner(makeSigner).then(() => { - if (liveSigner()) { ui.nostrReconnect = null; render(); } - }); + // 'upgrade': Amber by intent is live but can't read messages — offer + // only its NIP-46 door (nostrconnect), same account enforced + const upgrade = ui.nostrReconnect === 'upgrade'; + const run = reconnectRun; + // straight to the link: the screen exists for this one door + if (upgrade && ui.nostrUpgradeStart) { ui.nostrUpgradeStart = false; ui.nostrConnectOpen = true; startNostrConnect(run); } return h('div', { class: 'col', style: 'gap:16px' }, ctx.brandHeader(false), h('div', { class: 'card col', style: 'gap:12px' }, - h('h3', { style: 'margin:0' }, t('nlReconnectTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectBody', { npub: (npubOf(st.pubkey) || '').slice(0, 12) })), - signerButtons(run, { reconnectVia: reconnectViaOf(st) }), + h('h3', { style: 'margin:0' }, t(upgrade ? 'nlAmberUpgradeTitle' : 'nlReconnectTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, upgrade ? t('nlAmberUpgradeBody') : t('nlReconnectBody', { npub: (npubOf(st.pubkey) || '').slice(0, 12) })), + upgrade ? nostrConnectSection(run) : signerButtons(run, { reconnectVia: reconnectViaOf(st) }), ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null), h('button', { class: 'btn-ghost btn-block', onClick: () => { stopNostrConnect(); @@ -710,6 +728,17 @@ export function nostrLoginFeature(ctx) { // An action needed the login signer and it isn't there: open the // reconnect screen (and try the silent extension reattach in the // background — it closes the screen by itself when it lands). + // Messages found an Amber-by-intent login that can't decrypt: offer to + // reconnect the same account over NIP-46. + nostrSignerUpgrade() { + const st = load(); + if (!st.pubkey) return false; + ui.nostrReconnect = 'upgrade'; + ui.nostrUpgradeStart = true; + ui.nostrLoginError = ''; + render(); + return true; + }, nostrReconnectPrompt() { const st = load(); if (!st.pubkey || liveSigner()) return false; -- cgit v1.2.3