diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 7111e898..5d1b4390 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -8,6 +8,7 @@ "name": "neode-ui", "version": "1.7.113-alpha", "dependencies": { + "@scure/bip39": "^2.2.0", "@types/dompurify": "^3.0.5", "@vue-leaflet/vue-leaflet": "^0.10.1", "buffer": "^6.0.3", @@ -2882,6 +2883,18 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3538,6 +3551,28 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "version": "3.0.0-pre1", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", diff --git a/neode-ui/package.json b/neode-ui/package.json index 80361d39..92c10cd3 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -24,6 +24,7 @@ "generate-welcome-speech": "node scripts/generate-welcome-speech.js" }, "dependencies": { + "@scure/bip39": "^2.2.0", "@types/dompurify": "^3.0.5", "@vue-leaflet/vue-leaflet": "^0.10.1", "buffer": "^6.0.3", diff --git a/neode-ui/src/components/SeedRevealPanel.vue b/neode-ui/src/components/SeedRevealPanel.vue new file mode 100644 index 00000000..a480bdcb --- /dev/null +++ b/neode-ui/src/components/SeedRevealPanel.vue @@ -0,0 +1,124 @@ + + + diff --git a/neode-ui/src/utils/seedqr.ts b/neode-ui/src/utils/seedqr.ts new file mode 100644 index 00000000..7bfc5f59 --- /dev/null +++ b/neode-ui/src/utils/seedqr.ts @@ -0,0 +1,21 @@ +/** + * SeedQR encoding (SeedSigner standard, supported by Passport/Passport Prime, + * SeedSigner, Keystone, Nunchuk, Sparrow, …): each BIP39 word becomes its + * zero-padded 4-digit wordlist index (0000–2047), concatenated into one + * digit stream and rendered as a numeric-mode QR. A 24-word seed is 96 + * digits. Spec: github.com/SeedSigner/seedsigner/blob/main/docs/seed_qr + * + * Only valid for real BIP39 mnemonics — LND's aezeed shares the wordlist but + * is NOT BIP39, and hardware wallets cannot import it; never SeedQR-encode it. + */ +export async function toSeedQrDigits(words: string[]): Promise { + if (words.length === 0) return null + const { wordlist } = await import('@scure/bip39/wordlists/english.js') + const digits: string[] = [] + for (const raw of words) { + const idx = wordlist.indexOf(raw.trim().toLowerCase()) + if (idx < 0) return null // not a BIP39 word — caller falls back to text + digits.push(idx.toString().padStart(4, '0')) + } + return digits.join('') +} diff --git a/neode-ui/src/views/OnboardingSeedGenerate.vue b/neode-ui/src/views/OnboardingSeedGenerate.vue index 45f3a4a0..03786ed9 100644 --- a/neode-ui/src/views/OnboardingSeedGenerate.vue +++ b/neode-ui/src/views/OnboardingSeedGenerate.vue @@ -69,9 +69,21 @@
+
+ +

- The code contains your seed words — scan only into a wallet you trust, - and treat it exactly like the words themselves. + {{ qrFormat === 'seedqr' + ? 'SeedQR — scans into Passport, SeedSigner, Keystone and other wallets that import seeds by QR.' + : 'Plain text words — for wallets that read the phrase as text.' }} + Treat this code exactly like the words themselves.

@@ -121,17 +133,30 @@ const continueButton = ref(null) const words = ref([]) // Words / QR code view of the seed — words are the default first view. +// The QR tab defaults to SeedQR (BIP39 word-index digit stream), the format +// hardware wallets like Passport Prime / SeedSigner actually import; plain +// text stays available for wallets that read the phrase as text. const seedTab = ref<'words' | 'qr'>('words') +const qrFormat = ref<'seedqr' | 'text'>('seedqr') const seedQrCanvas = ref(null) -watch(seedTab, async (tab) => { - if (tab !== 'qr') return + +async function renderSeedQr() { await nextTick() if (!seedQrCanvas.value || words.value.length === 0) return try { + let payload = words.value.join(' ') + if (qrFormat.value === 'seedqr') { + const { toSeedQrDigits } = await import('@/utils/seedqr') + const digits = await toSeedQrDigits(words.value) + if (digits) payload = digits + else qrFormat.value = 'text' // non-BIP39 word — only text is honest + } const QRCode = await import('qrcode') - await QRCode.toCanvas(seedQrCanvas.value, words.value.join(' '), { width: 240, margin: 1 }) + await QRCode.toCanvas(seedQrCanvas.value, payload, { width: 240, margin: 1 }) } catch { /* QR is a convenience — the words remain authoritative */ } -}) +} +watch(seedTab, (tab) => { if (tab === 'qr') void renderSeedQr() }) +watch(qrFormat, () => { if (seedTab.value === 'qr') void renderSeedQr() }) const confirmed = ref(false) const loading = ref(false) const waitingForServer = ref(false) diff --git a/neode-ui/src/views/appDetails/LndSeedBackup.vue b/neode-ui/src/views/appDetails/LndSeedBackup.vue index 668b9b81..475c6e1e 100644 --- a/neode-ui/src/views/appDetails/LndSeedBackup.vue +++ b/neode-ui/src/views/appDetails/LndSeedBackup.vue @@ -1,7 +1,8 @@