diff options
| author | Adam Soltys <[email protected]> | 2026-08-04 09:46:54 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-04 09:46:54 -0700 |
| commit | 399a100248792fe8d6aaf19849eab3ee3baa1984 (patch) | |
| tree | 6afb67e1801997f63eb278c384b1d424bed4371c /src | |
| parent | e462bc1e7756dcbf7c651ee282e96ee8c48b5a14 (diff) | |
| download | coinosv3-399a100248792fe8d6aaf19849eab3ee3baa1984.tar.gz coinosv3-399a100248792fe8d6aaf19849eab3ee3baa1984.zip | |
The Nostr card owns its built-in account; the exported key opens this wallet anywhere
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01M5kFodm9azZJVTQxnkDdKe
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.js | 1 | ||||
| -rw-r--r-- | src/features/nostrlogin.js | 77 | ||||
| -rw-r--r-- | src/i18n.js | 8 | ||||
| -rw-r--r-- | src/nostr.js | 4 |
4 files changed, 84 insertions, 6 deletions
@@ -2572,6 +2572,7 @@ function tabsBar() { ui.tab = id; ui.sendError = ''; // a stale send error never survives a tab change ui.revealShown = false; // re-mask the recovery phrase whenever tabs change + ui.nostrExportStep = false; // and the exported nostr key ui.txDetail = null; // back to the history list when leaving/returning ui.arkMoveDetail = null; ui.giftDetail = null; diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index 222b516..4c28ac5 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -9,7 +9,7 @@ // published, because it means the nostr key can recover the money. import { newMnemonic } from '../wallet.js'; -import { npubOf } from '../nostr.js'; +import { npubOf, nsecOf } from '../nostr.js'; import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { @@ -289,6 +289,68 @@ export function nostrLoginFeature(ctx) { } catch (e) { fail(e); } } + // Reveal the seed-derived nostr key. A self-backup is published FIRST so + // the promise on the reveal screen is true: pasting this nsec into any + // coinos login finds the backup and opens THIS wallet, instead of + // key-deriving a fresh empty one (walletForSigner checks relays before + // deriving). + async function exportWalletKey() { + ui.nostrLoginError = ''; + busy(true); + try { + const sk = wallet.nostr && wallet.nostr.sk; + if (!sk || !wallet.mnemonic) throw new Error(t('nlNeedSeed')); + await publishWalletBackup(keySigner(sk), { mnemonic: wallet.mnemonic, passphrase: wallet.passphrase || '' }); + ui.nostrExportStep = 'shown'; + busy(false); + } catch (e) { fail(e); } + } + + // The built-in key, behind the same two-step gate as the recovery phrase: + // a warning you have to act on before anything secret is in the DOM. + function exportSection() { + const sk = wallet.nostr && wallet.nostr.sk; + if (!sk) return null; + if (ui.nostrExportStep === 'shown') { + const nsec = nsecOf(sk); + return h('div', { class: 'col', style: 'gap:8px' }, + h('div', { class: 'addr-box break' }, nsec), + h('p', { class: 'small faint', style: 'margin:0' }, t('nlExportedNote')), + h('div', { class: 'row gap6' }, + copyBtn(nsec, t('copy')), + h('button', { class: 'btn-sm grow', onClick: () => { ui.nostrExportStep = false; render(); } }, t('hide')))); + } + if (ui.nostrExportStep === 'warn') { + return h('div', { class: 'col', style: 'gap:8px' }, + h('div', { class: 'warn-box' }, t('nlExportWarn')), + ui.nostrLoginError ? h('div', { class: 'notice err' }, ui.nostrLoginError) : null, + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-primary grow', disabled: ui.nostrLoginBusy, onClick: exportWalletKey }, + ui.nostrLoginBusy ? h('span', { class: 'spinner' }) : t('nlExportReveal')), + h('button', { class: 'btn-sm', onClick: () => { ui.nostrExportStep = false; ui.nostrLoginError = ''; render(); } }, t('cancel')))); + } + return h('button', { + class: 'btn-block', disabled: ui.nostrLoginBusy, + onClick: () => { ui.nostrExportStep = 'warn'; ui.nostrLoginError = ''; render(); }, + }, t('nlExportBtn')); + } + + function linkSection() { + if (!ui.nostrLinkOpen) { + return h('button', { + class: 'btn-block', disabled: ui.nostrLoginBusy, + onClick: () => { ui.nostrLinkOpen = true; ui.nostrLoginError = ''; render(); }, + }, t('nlChangeBtn')); + } + return h('div', { class: 'col', style: 'gap:10px' }, + h('div', { class: 'row between' }, + h('strong', { class: 'small' }, t('nlChangeBtn')), + h('span', { class: 'linklike small', onClick: () => { ui.nostrLinkOpen = false; ui.nostrLoginError = ''; render(); } }, t('cancel'))), + h('p', { class: 'small muted', style: 'margin:0' }, t('nlLinkDesc')), + h('div', { class: 'notice info small' }, t('nlBackupWarning')), + signerButtons(linkOpenWallet)); + } + function settingsCard() { if (wallet.watchOnly || !wallet.mnemonic) return null; const st = load(); @@ -306,11 +368,18 @@ export function nostrLoginFeature(ctx) { h('p', { class: 'small muted', style: 'margin:0' }, t('nlReconnectDesc')), signerButtons(reconnectSigner))); } + // No external account linked — but the wallet still HAS a nostr account: + // the one derived from its seed, already signing chat and holding the + // payment address. A bare login form here read as "no account yet", so + // the built-in identity comes first and both exporting its key and + // switching to another account are explicit steps behind buttons. + const npub = wallet.nostrNpub && wallet.nostrNpub(); return h('div', { class: 'card col' }, h('h3', {}, t('nlLinkTitle')), - h('p', { class: 'small muted', style: 'margin:0' }, t('nlLinkDesc')), - h('div', { class: 'notice info small' }, t('nlBackupWarning')), - signerButtons(linkOpenWallet)); + npub ? h('div', { class: 'small muted break' }, npub) : null, + h('p', { class: 'small muted', style: 'margin:0' }, t('nlBuiltinDesc')), + exportSection(), + linkSection()); } return { diff --git a/src/i18n.js b/src/i18n.js index fa0a48f..3295358 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -579,7 +579,13 @@ const STR = { nlCreateBtn: 'Create my wallet', nlCreated: 'Wallet created and linked', nlLinkTitle: 'Nostr account', - nlLinkDesc: 'Link this wallet to your Nostr account so you can open it on other devices by logging in with Nostr.', + nlLinkDesc: 'Log in with your own Nostr account instead — chat and your payment address will use its identity, and logging in with it on another device opens this wallet.', + nlBuiltinDesc: 'This wallet has a built-in Nostr account, derived from its seed. Chat and your payment address already use it — you don’t need to link anything.', + nlExportBtn: 'Export Nostr key', + nlExportWarn: 'This key opens this wallet. Anyone who has it can spend your money, read your chats, and change your payment address — guard it like your recovery phrase.', + nlExportReveal: 'Show the key', + nlExportedNote: 'Logging in with Nostr using this key opens this wallet.', + nlChangeBtn: 'Use a different Nostr account', nlLinkedDesc: 'This wallet opens when you log in with this Nostr account.', nlConnected: 'connected', nlDisconnected: 'not connected', diff --git a/src/nostr.js b/src/nostr.js index cf3cc88..98515fe 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -13,7 +13,7 @@ import * as nip06 from 'nostr-tools/nip06'; import * as nip44 from 'nostr-tools/nip44'; import * as nip04 from 'nostr-tools/nip04'; import { getPublicKey, finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; -import { decode as nip19decode, npubEncode } from 'nostr-tools/nip19'; +import { decode as nip19decode, npubEncode, nsecEncode } from 'nostr-tools/nip19'; import { SimplePool } from 'nostr-tools/pool'; import { randomBytes } from '@noble/hashes/utils'; import { base64urlnopad } from '@scure/base'; @@ -112,6 +112,8 @@ export function parseNostrPubkey(input) { } // An npub or 64-hex nostr pubkey → hex, or null if it isn't one. export function npubOf(pkHex) { try { return npubEncode(pkHex); } catch { return null; } } +// A raw secret key → nsec, for the one place that deliberately exports it. +export function nsecOf(sk) { try { return nsecEncode(sk); } catch { return null; } } // Profiles live across the network, so look them up on popular relays (broader // than the sync relays). |
