aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-03 22:28:53 -0700
committerAdam Soltys <[email protected]>2026-08-03 22:28:53 -0700
commit75ec27747ec05da8990208d67052ad11d25f3c44 (patch)
tree0d404cd0e8c9ecce57ba3c55359e17efa0d4b799
parent1f96f513060d65c3a4e7c7cda100cde4ef8ee2c3 (diff)
downloadcoinosv3-75ec27747ec05da8990208d67052ad11d25f3c44.tar.gz
coinosv3-75ec27747ec05da8990208d67052ad11d25f3c44.zip
Speak as the account you logged in with, or not at all
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
-rw-r--r--src/features/messages.js6
-rw-r--r--src/features/nostrlogin.js63
-rw-r--r--src/i18n.js10
-rw-r--r--src/nostr-login.js31
-rw-r--r--tools/identity-test.js89
5 files changed, 181 insertions, 18 deletions
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 = '<svg width="23" height="23" style="display:block" viewBox="0 0 2000 2000" fill="none" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="ng" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#B84FE8"/><stop offset="100%" stop-color="#6D28D9"/></linearGradient></defs><circle cx="1000" cy="1000" r="1000" fill="url(#ng)"/><g transform="translate(1000 1000) scale(0.82) translate(-1000 -1000)"><path d="M1574.29 770.408C1532.04 840.612 1445.92 888.367 1437.96 892.449C1421.84 901.02 1415.92 913.469 1416.94 932.041C1417.96 950.612 1413.47 1042.65 1357.35 1083.27C1328.57 1104.08 1212.45 1131.02 1159.18 1140C1131.84 1144.69 1125.51 1153.06 1110.41 1162.04C1090 1174.69 1017.55 1275.1 1005.31 1297.35C1006.94 1297.14 1233.67 1226.73 1243.88 1225.1C1270.82 1221.02 1291.22 1228.78 1304.49 1248.37C1317.14 1267.14 1329.59 1286.12 1342.04 1305.1C1346.53 1311.84 1350.82 1318.78 1355.1 1325.71L1359.59 1332.86C1361.22 1335.51 1363.47 1338.57 1364.49 1342.65C1365.31 1345.51 1367.14 1355.1 1361.63 1360.82C1356.53 1366.12 1347.76 1366.12 1343.06 1364.9C1338.57 1363.67 1331.84 1361.02 1326.33 1356.53L1318.78 1350.2C1312.86 1345.1 1301.63 1342.65 1292.86 1343.06C1282.65 1343.47 1269.18 1340.2 1262.65 1336.12C1250 1327.96 1246.73 1307.55 1246.73 1307.35C1245.1 1300.61 1240.2 1298.78 1235.71 1298.78C1227.55 1298.57 1221.22 1301.02 1214.69 1302.86C1184.9 1311.84 1155.31 1321.02 1125.51 1330.41L1082.86 1343.67C1061.84 1350.2 1040.82 1356.73 1020 1363.67C1014.29 1365.51 1008.78 1368.37 1002.86 1371.43C1000.41 1372.65 997.959 1373.88 995.51 1375.1C993.061 1376.33 990.408 1377.76 987.959 1379.18C982.449 1382.24 976.531 1385.51 970 1387.35C948.776 1393.67 930.204 1384.49 921.429 1363.27C915.714 1349.59 920.816 1322.45 923.878 1314.49C943.061 1264.69 1005.51 1170.82 1005.51 1170.2C1004.49 1169.8 936.327 1197.14 925.102 1205.71C894.286 1229.18 822.041 1284.69 821.02 1290C816.327 1314.69 801.02 1333.27 777.551 1342.45C760.612 1348.98 750.408 1362.45 740.408 1375.51C740.408 1375.51 559.388 1624.9 547.143 1638.37C543.469 1642.45 509.592 1677.96 500.612 1694.49C498.571 1698.57 490.816 1714.9 488.776 1718.98C487.143 1722.24 483.469 1731.02 469.592 1730.41C455.714 1729.8 455.306 1710.41 454.694 1702.04C453.265 1681.43 457.755 1661.22 468.571 1640C469.592 1637.96 481.837 1619.39 477.347 1604.49C473.265 1591.43 469.388 1571.22 475.51 1561.02C485.102 1544.9 502.041 1544.29 524.694 1540.61C540.408 1537.96 551.837 1530.2 562.857 1514.08C586.122 1480.2 673.674 1355.92 693.469 1327.14C698.776 1319.59 702.857 1309.8 704.694 1300.2C710.408 1271.22 727.959 1251.02 758.571 1238.57C765.102 1235.92 886.939 1134.08 886.939 1121.02C886.939 1111.63 873.674 1106.53 863.469 1103.67C862.041 1103.27 785.51 1083.47 750.612 1067.55C731.633 1058.98 717.347 1051.43 700.408 1039.18C677.347 1022.24 631.225 1030.41 624.49 1031.43C591.837 1036.12 567.551 1050.82 540 1069.39C535.102 1072.86 510 1081.84 497.347 1074.69C472.653 1060.61 418.367 1035.31 398.367 1004.9C389.184 990.816 390.408 967.959 396.939 942.653C417.959 883.878 448.98 857.551 506.327 841.633C547.143 827.551 629.388 824.49 668.98 821.429C671.633 820.612 759.388 818.571 821.02 786.939C862.041 765.918 983.878 684.898 1159.18 690.408C1230 692.653 1363.88 761.837 1415.51 763.878C1468.16 766.122 1494.49 760.204 1524.9 728.776C1533.47 719.796 1570 641.02 1535.51 597.959C1522.65 582.041 1509.39 567.755 1494.08 554.694C1473.47 537.143 1452.04 520.408 1433.47 500.612C1392.45 457.143 1379.8 397.755 1399.18 351.429C1411.63 318.98 1437.76 304.898 1475.51 312.245C1502.04 317.347 1521.43 341.225 1540.41 353.878C1551.02 361.02 1568.78 365.102 1580.41 367.959C1595.1 371.429 1606.12 382.653 1605.51 390.204C1604.9 397.755 1585.51 404.286 1566.94 402.857C1544.9 401.429 1507.14 401.225 1484.49 408.776C1469.39 414.286 1462.65 431.02 1467.14 447.551C1470.82 461.02 1499.59 485.102 1510.41 494.082C1532.04 511.837 1555.1 527.959 1573.06 550.204C1593.27 575.51 1603.88 604.286 1607.14 636.122C1612.04 684.898 1599.18 728.776 1574.29 770.408Z" fill="#fff"/></g></svg>';
@@ -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()]; },
diff --git a/src/i18n.js b/src/i18n.js
index 177de06..53cd643 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -124,11 +124,11 @@ const STR = {
howItWorks: 'How it works',
installApp: 'Install app',
hiwBasicsTitle: 'The basics',
- hiwBasics1: 'When you make a wallet, the page generates twelve random words — the key to your money. Anyone who has them can spend your coins, and if you lose them the coins are gone for good: there’s no reset link and no one to call. Write them down on paper before you put money in.',
- hiwBasics2: 'There’s no server and no account. The whole app is one page that runs in your browser — nobody holds your coins, nothing on a server ever sees your keys, and you can even save the page and use it offline.',
- hiwBasics3: 'It follows the standard Bitcoin formats, so the same twelve words will always restore your wallet in Sparrow, Electrum, or any other standard wallet — even if this site disappears.',
+ hiwBasics1: 'When you make a wallet, the page generates twelve random words which are the keys to the wallet. Anyone who has them can spend the coins in the wallet. If you lose them, the coins are gone for good. Write them down on paper before you put money in.',
+ hiwBasics2: 'There’s no server and no account. The whole app is one page that runs in your browser. You can save the page and use it offline.',
+ hiwBasics3: 'It follows the standard Bitcoin formats, so the same twelve words will restore your wallet in Sparrow, Electrum, or any other standard wallet even if this site disappears.',
hiwLnTitle: 'Spending and Savings',
- hiwLn1: 'Your money sits in two places, like a savings account and the cash in your pocket. Savings is ordinary Bitcoin on the blockchain — safest for money you’re keeping, but each move costs a mining fee and waits for a confirmation. Spending sends and arrives instantly and free, and is still only yours: it runs on Ark, a newer layer on top of Bitcoin where every coin stays locked to your key, and it speaks Lightning, so you can pay and be paid by any bitcoin app or shop.',
+ hiwLn1: 'Your money sits in two places. Savings is ordinary Bitcoin on the blockchain. Withdrawals costs a mining fee and deposits take time to confirm. Deposits into the spending wallet arrive instantly and payments out are free: it runs on Ark and speaks Lightning, so you can pay and be paid by any bitcoin app or shop.',
hiwLn2: 'Move money either way under your balance — a small fee each direction, free inside Spending. The wallet handles all the upkeep on its own (just open the app now and then), and every coin in Spending carries a pre-signed escape route: even if the coinos server vanished, you could pull your money back on-chain yourself.',
hiwGiftsTitle: 'Gifts',
hiwGifts1: 'You can gift bitcoin with a link: whoever opens it claims the coins into their own wallet — no app, no signup. On-chain gifts lock the coins until claimed and can be cancelled or reclaimed; Ark gifts claim instantly and free, even into a brand-new empty wallet.',
@@ -157,7 +157,7 @@ const STR = {
msgAdmin: 'admin',
msgEdited: '(edited)',
msgDelete: 'Delete message',
- msgNoIdentity: 'No nostr identity available to sign with',
+ msgNoIdentity: "Your nostr signer isn't connected — reconnect it to send as yourself",
msgSendFailed: 'No relay accepted the message',
msgDmsTitle: 'Messages',
msgCommunitiesTitle: 'Communities',
diff --git a/src/nostr-login.js b/src/nostr-login.js
index 8a4603f..ec18942 100644
--- a/src/nostr-login.js
+++ b/src/nostr-login.js
@@ -102,12 +102,37 @@ export async function bunkerSigner(uri, { onAuth } = {}) {
});
await signer.connect();
const pubkey = await signer.getPublicKey();
- return bunkerAdapter(signer, pubkey);
+ return bunkerAdapter(signer, pubkey, local);
}
-function bunkerAdapter(signer, pubkey) {
+// Reconnect a remote signer a page reload dropped. The remote end granted
+// permission to this client key, not to this page load, so rebuilding from
+// the same client key and pointer picks the session back up — usually without
+// prompting the user again.
+export async function resumeBunker(session, { onAuth, timeoutMs = 8000 } = {}) {
+ if (!session || !session.local || !session.bp || !session.bp.pubkey) return null;
+ const local = hex.decode(session.local);
+ const signer = BunkerSigner.fromBunker(local, session.bp, {
+ onauth: (url) => { if (onAuth) onAuth(url); },
+ });
+ const give = new Promise((_, rej) => setTimeout(() => rej(new Error('signer did not answer')), timeoutMs));
+ try {
+ await Promise.race([signer.connect(), give]);
+ const pubkey = await Promise.race([signer.getPublicKey(), give]);
+ return bunkerAdapter(signer, pubkey, local);
+ } catch (e) {
+ try { signer.close(); } catch {}
+ throw e;
+ }
+}
+
+function bunkerAdapter(signer, pubkey, local) {
return {
kind: 'bunker', pubkey, label: 'remote signer',
+ // Everything needed to rebuild this connection later. `local` is the
+ // client key the remote signer authorised — not the user's identity key,
+ // which never leaves the signer.
+ session: local ? { local: hex.encode(local), bp: signer.bp } : null,
signEvent: (e) => signer.signEvent(e),
encryptSelf: (txt) => signer.nip44Encrypt(pubkey, txt),
decryptSelf: (ct) => signer.nip44Decrypt(pubkey, ct),
@@ -138,7 +163,7 @@ export function nostrConnect({ relays = ['wss://relay.coinos.io', 'wss://nos.lol
const ready = (async () => {
const signer = await BunkerSigner.fromURI(local, uri, {}, controller.signal);
const pubkey = await signer.getPublicKey();
- return bunkerAdapter(signer, pubkey);
+ return bunkerAdapter(signer, pubkey, local);
})();
ready.finally(() => clearTimeout(timer)).catch(() => {});
return { uri, ready, cancel: (reason) => controller.abort(reason || new Error('cancelled')) };
diff --git a/tools/identity-test.js b/tools/identity-test.js
new file mode 100644
index 0000000..179d640
--- /dev/null
+++ b/tools/identity-test.js
@@ -0,0 +1,89 @@
+// Who the chat speaks as. If you logged in with a nostr account, everything
+// this app signs must be signed by THAT account — the alternative is what
+// people actually saw: their own avatar in the header, and their messages
+// arriving under the wallet key's pubkey, looking like a stranger.
+// Run: bun tools/identity-test.js
+import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { messagesFeature } from '../src/features/messages.js';
+
+let fails = 0;
+const check = (name, ok, detail = '') => {
+ console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + String(detail).slice(0, 120) : ''}`);
+ if (!ok) fails++;
+};
+
+globalThis.localStorage = {
+ _d: {}, getItem(k) { return this._d[k] ?? null; }, setItem(k, v) { this._d[k] = String(v); },
+ removeItem(k) { delete this._d[k]; }, key(i) { return Object.keys(this._d)[i] ?? null; },
+ get length() { return Object.keys(this._d).length; },
+};
+globalThis.fetch = async () => { throw new Error('offline'); };
+
+const walletSk = generateSecretKey();
+const walletPk = getPublicKey(walletSk);
+const loginSk = generateSecretKey();
+const loginPk = getPublicKey(loginSk);
+
+// Build the feature with a given login situation and see which key it would
+// sign a profile with — publishProfile is the shortest path through identity().
+function build({ login, resume }) {
+ const store = {};
+ const signed = [];
+ const wallet = {
+ mnemonic: 'x '.repeat(11) + 'y', utxos: [], txs: [],
+ nostr: { sk: walletSk, pk: walletPk, ck: new Uint8Array(32) },
+ loadFeatureState: (n, fb) => JSON.parse(store[n] || 'null') || fb,
+ saveFeatureState: (n, s) => { store[n] = JSON.stringify(s); },
+ _cacheKey: () => 'identity-test', saveCache: () => {},
+ registerCacheExtension: () => {}, registerLoadHook: () => {},
+ };
+ const feature = messagesFeature({
+ h: (tag, attrs, ...kids) => ({ tag, attrs, kids }),
+ ui: {}, render: () => {}, wallet, toast: () => {}, brandHeader: () => null,
+ hook: (name) => {
+ if (name === 'nostrLoginIdentity') return login;
+ if (name === 'nostrLoginResume') return resume;
+ return null;
+ },
+ });
+ return { feature, signed };
+}
+
+console.log('[logged in with nostr, signer live]');
+{
+ const signer = { pubkey: loginPk, signEvent: async (e) => ({ ...e, pubkey: loginPk, id: 'x', sig: 'y' }) };
+ const { feature } = build({ login: { pubkey: loginPk, signer }, resume: null });
+ let signedBy = null;
+ try { await feature.publishProfile({ name: 'me' }); } catch (e) { signedBy = e.message; }
+ // it fails at the relay (offline), but only AFTER choosing an identity —
+ // what matters is that it never refused for want of one
+ check('does not refuse for want of an identity', !/signer isn't connected/i.test(signedBy || ''), signedBy || 'reached the relay');
+}
+
+console.log('\n[logged in with nostr, signer lost to a reload]');
+{
+ const { feature } = build({ login: { pubkey: loginPk, signer: null }, resume: null });
+ let err = null;
+ try { await feature.publishProfile({ name: 'me' }); } catch (e) { err = e.message; }
+ check('refuses rather than signing as someone else', /signer isn't connected/i.test(err || ''), err || 'no error!');
+}
+
+console.log('\n[logged in with nostr, signer resumes]');
+{
+ const signer = { pubkey: loginPk, signEvent: async (e) => ({ ...e, pubkey: loginPk, id: 'x', sig: 'y' }) };
+ const { feature } = build({ login: { pubkey: loginPk, signer: null }, resume: Promise.resolve(signer) });
+ let err = null;
+ try { await feature.publishProfile({ name: 'me' }); } catch (e) { err = e.message; }
+ check('a resumed signer is accepted', !/signer isn't connected/i.test(err || ''), err || 'reached the relay');
+}
+
+console.log('\n[no nostr login at all]');
+{
+ const { feature } = build({ login: null, resume: null });
+ let err = null;
+ try { await feature.publishProfile({ name: 'me' }); } catch (e) { err = e.message; }
+ check('the wallet key still speaks for a plain seed wallet', !/signer isn't connected/i.test(err || ''), err || 'reached the relay');
+}
+
+console.log(fails ? `\n${fails} failing` : '\nall passing');
+process.exit(fails ? 1 : 0);