diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 23:54:43 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 23:54:43 -0700 |
| commit | a70f34f2a5d35352f99bae8f10cbab12637e08ee (patch) | |
| tree | 7a5b83fd62d0737f4b856b04c2405ba440c39e63 /src | |
| parent | 3d6bf81a19ca080d3710893b1e65f5aa3f01315f (diff) | |
| download | coinosv3-a70f34f2a5d35352f99bae8f10cbab12637e08ee.tar.gz coinosv3-a70f34f2a5d35352f99bae8f10cbab12637e08ee.zip | |
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).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 40 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 41 | ||||
| -rw-r--r-- | src/i18n.js | 4 |
3 files changed, 75 insertions, 10 deletions
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; diff --git a/src/i18n.js b/src/i18n.js index 32c55b0..e80e2f8 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -515,6 +515,8 @@ const STR = { addrRead: '{min} min read', addrList: 'A list by {name}', msgSignerOff: "Your nostr signer isn't connected, so you can't post as yourself yet.", + msgAmberDms: 'Signed in with Amber, messages sent to you can’t be opened here, only ones you sent from this device.', + msgAmberConnect: 'Connect Amber', msgReconnect: 'Reconnect', msgDmsTitle: 'Messages', msgCommunitiesTitle: 'Communities', @@ -1226,6 +1228,8 @@ const STR = { nlDisconnected: 'not connected', nlReconnectDesc: "Your signer isn't connected, so this app can't sign as you. Reconnect it to post and update your profile. Your wallet and balance aren't affected.", nlReconnectTitle: 'Reconnect your signer', + nlAmberUpgradeTitle: 'Read your messages', + nlAmberUpgradeBody: 'Amber can only open messages here when it’s connected as a remote signer. Tap below, approve in Amber (allow decrypting), and your messages will load. Signing works as before.', nlReconnectBody: 'Your Nostr signer isn’t connected on this device. Reconnect to keep acting as {npub}…', nlWrongAccount: 'That signer is a different Nostr account. Reconnect with {npub}.', nlLinked: 'Wallet linked to your Nostr account', |
