fix(ui): seed QRs use the SeedQR standard so hardware wallets can scan them
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m52s

Plain-text seed QRs didn't scan into Passport Prime — wallets that
import seeds by QR (Passport, SeedSigner, Keystone, Nunchuk, Sparrow)
expect the SeedQR standard: each BIP39 word as its zero-padded 4-digit
wordlist index, concatenated into a numeric QR.

- new utils/seedqr.ts encodes BIP39 words per the SeedSigner spec
  (@scure/bip39 wordlist; vector-checked abandon=0000, zoo=2047)
- new shared SeedRevealPanel (Words/QR tabs, tap-to-reveal blur) now
  backs the LND reveal AND the Settings→Backup recovery-phrase reveal,
  so every current and future seed reveal behaves the same
- onboarding seed + Settings reveal: QR defaults to SeedQR with a
  plain-text toggle
- LND seed stays plain-text-only with an explicit note: aezeed is not
  BIP39 and only restores into LND-based wallets (Zeus/Blixt/another
  node) — SeedQR-encoding it would just mislead

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-26 12:09:43 -04:00
parent c0d34bd836
commit 0880824fb3
7 changed files with 217 additions and 79 deletions

View File

@ -8,6 +8,7 @@
"name": "neode-ui", "name": "neode-ui",
"version": "1.7.113-alpha", "version": "1.7.113-alpha",
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",
"buffer": "^6.0.3", "buffer": "^6.0.3",
@ -2882,6 +2883,18 @@
"url": "https://opencollective.com/js-sdsl" "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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -3538,6 +3551,28 @@
"win32" "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": { "node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1", "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", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",

View File

@ -24,6 +24,7 @@
"generate-welcome-speech": "node scripts/generate-welcome-speech.js" "generate-welcome-speech": "node scripts/generate-welcome-speech.js"
}, },
"dependencies": { "dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1", "@vue-leaflet/vue-leaflet": "^0.10.1",
"buffer": "^6.0.3", "buffer": "^6.0.3",

View File

@ -0,0 +1,124 @@
<template>
<div>
<div class="flex gap-1 mb-3 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
:key="tab.key"
type="button"
@click="seedTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
<template v-if="seedTab === 'words'">
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ hidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="hidden ? 'blur-md' : ''"
@click="hidden = !hidden"
>
<div v-for="(w, i) in words" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
</div>
</template>
<template v-else>
<p class="text-sm text-white/60 mb-3">
{{ aezeed ? 'Scan to copy the words into another device.' : 'Scan into a wallet that imports seeds by QR.' }}
Tap to {{ hidden ? 'reveal' : 'hide' }}.
</p>
<div class="relative">
<div
class="flex justify-center p-3 bg-white/5 rounded-lg transition-all"
:class="hidden ? 'blur-md' : ''"
@click="hidden = !hidden"
>
<canvas ref="qrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
</div>
<div v-if="!aezeed && seedQrAvailable" class="flex justify-center mt-2">
<div class="flex p-0.5 bg-white/5 rounded-md">
<button
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
:key="f.key"
type="button"
@click="qrFormat = f.key"
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ f.label }}</button>
</div>
</div>
<p class="text-xs text-white/40 mt-2">
<template v-if="aezeed">
The code contains your seed words as plain text treat it exactly like the words
themselves. Note: this is an LND <span class="font-mono">aezeed</span>, not a BIP39
phrase it restores into LND-based wallets (Zeus, Blixt, another Archipelago node),
not into hardware wallets like Passport.
</template>
<template v-else-if="qrFormat === 'seedqr'">
SeedQR scans into Passport, SeedSigner, Keystone and other wallets that import
seeds by QR. Treat this code exactly like the words themselves.
</template>
<template v-else>
Plain text words for wallets that read the phrase as text. Treat this code exactly
like the words themselves.
</template>
</p>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
// Shared seed reveal body: Words / QR code tabs behind a tap-to-reveal blur.
// Words are always the first view. For BIP39 seeds the QR defaults to the
// SeedQR standard (4-digit wordlist indices what Passport Prime, SeedSigner,
// Keystone etc. import), with a plain-text option. `aezeed` seeds (LND) are
// NOT BIP39 and no hardware wallet can import them, so they only ever get the
// plain-text QR plus an explanation SeedQR-encoding one would be dishonest.
const props = defineProps<{ words: string[]; aezeed?: boolean }>()
const seedTab = ref<'words' | 'qr'>('words')
const qrFormat = ref<'seedqr' | 'text'>(props.aezeed ? 'text' : 'seedqr')
const seedQrAvailable = ref(!props.aezeed)
const hidden = ref(true)
const qrCanvas = ref<HTMLCanvasElement | null>(null)
async function renderQr() {
await nextTick()
if (!qrCanvas.value || props.words.length === 0) return
try {
let payload = props.words.join(' ')
if (!props.aezeed && qrFormat.value === 'seedqr') {
const { toSeedQrDigits } = await import('@/utils/seedqr')
const digits = await toSeedQrDigits(props.words)
if (digits) {
payload = digits
} else {
// Not a BIP39 phrase after all only plain text is honest.
seedQrAvailable.value = false
qrFormat.value = 'text'
return // the qrFormat watcher re-renders as text
}
}
const QRCode = await import('qrcode')
await QRCode.toCanvas(qrCanvas.value, payload, { width: 260, margin: 1 })
} catch { /* QR is a convenience — the words remain authoritative */ }
}
watch(seedTab, (t) => { if (t === 'qr') void renderQr() })
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderQr() })
watch(() => props.words, () => {
seedTab.value = 'words' // fresh reveal always shows words first
hidden.value = true
seedQrAvailable.value = !props.aezeed
qrFormat.value = props.aezeed ? 'text' : 'seedqr'
})
</script>

View File

@ -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 (00002047), 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<string | null> {
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('')
}

View File

@ -69,9 +69,21 @@
<div v-else class="flex flex-col items-center gap-2 py-2"> <div v-else class="flex flex-col items-center gap-2 py-2">
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas> <canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
<div class="flex p-0.5 bg-white/5 rounded-md">
<button
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
:key="f.key"
type="button"
@click="qrFormat = f.key"
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ f.label }}</button>
</div>
<p class="text-xs text-white/50 text-center max-w-[420px]"> <p class="text-xs text-white/50 text-center max-w-[420px]">
The code contains your seed words scan only into a wallet you trust, {{ qrFormat === 'seedqr'
and treat it exactly like the words themselves. ? '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.
</p> </p>
</div> </div>
@ -121,17 +133,30 @@ const continueButton = ref<HTMLButtonElement | null>(null)
const words = ref<string[]>([]) const words = ref<string[]>([])
// Words / QR code view of the seed words are the default first view. // 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 seedTab = ref<'words' | 'qr'>('words')
const qrFormat = ref<'seedqr' | 'text'>('seedqr')
const seedQrCanvas = ref<HTMLCanvasElement | null>(null) const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
watch(seedTab, async (tab) => {
if (tab !== 'qr') return async function renderSeedQr() {
await nextTick() await nextTick()
if (!seedQrCanvas.value || words.value.length === 0) return if (!seedQrCanvas.value || words.value.length === 0) return
try { 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') 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 */ } } 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 confirmed = ref(false)
const loading = ref(false) const loading = ref(false)
const waitingForServer = ref(false) const waitingForServer = ref(false)

View File

@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
// Lightning seed (aezeed) backup card. Shown on the LND app detail page. // Lightning seed (aezeed) backup card. Shown on the LND app detail page.
// The backend captures the aezeed at wallet-init time; until the user // The backend captures the aezeed at wallet-init time; until the user
@ -65,25 +66,9 @@ function openReveal() {
revealError.value = '' revealError.value = ''
revealedWords.value = [] revealedWords.value = []
wordsHidden.value = true wordsHidden.value = true
seedTab.value = 'words' // words first QR is the opt-in second view
showRevealModal.value = true showRevealModal.value = true
} }
// Words / QR code tabs (wallet-settings segmented style). The QR encodes the
// space-joined seed words for wallets that support seed import by scan.
const seedTab = ref<'words' | 'qr'>('words')
const seedQrCanvas = ref<HTMLCanvasElement | null>(null)
async function renderSeedQr() {
await nextTick()
if (!seedQrCanvas.value || revealedWords.value.length === 0) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(seedQrCanvas.value, revealedWords.value.join(' '), { width: 260, margin: 1 })
} catch { /* QR is a convenience — the words remain authoritative */ }
}
watch(seedTab, (t) => { if (t === 'qr') void renderSeedQr() })
async function submitReveal() { async function submitReveal() {
if (revealing.value || !revealPassword.value) return if (revealing.value || !revealPassword.value) return
revealing.value = true revealing.value = true
@ -182,48 +167,7 @@ async function confirmBackedUp() {
</template> </template>
<template v-else> <template v-else>
<div class="flex gap-1 mb-3 p-1 bg-white/5 rounded-lg"> <SeedRevealPanel :words="revealedWords" aezeed />
<button
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
:key="tab.key"
type="button"
@click="seedTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
<template v-if="seedTab === 'words'">
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="wordsHidden ? 'blur-md' : ''"
@click="wordsHidden = !wordsHidden"
>
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
</div>
</template>
<template v-else>
<p class="text-sm text-white/60 mb-3">Scan with a wallet that supports seed import by QR. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="flex justify-center p-3 bg-white/5 rounded-lg transition-all"
:class="wordsHidden ? 'blur-md' : ''"
@click="wordsHidden = !wordsHidden"
>
<canvas ref="seedQrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
</div>
<p class="text-xs text-white/40 mt-2">The code contains your seed words treat it exactly like the words themselves.</p>
</template>
<div class="flex gap-2 pt-4"> <div class="flex gap-2 pt-4">
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button> <button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>

View File

@ -2,6 +2,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
const { t } = useI18n() const { t } = useI18n()
@ -329,20 +330,7 @@ defineExpose({ loadBackups })
</template> </template>
<template v-else> <template v-else>
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ wordsHidden ? 'reveal' : 'hide' }}.</p> <SeedRevealPanel :words="revealedWords" />
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="wordsHidden ? 'blur-md' : ''"
@click="wordsHidden = !wordsHidden"
>
<div v-for="(w, i) in revealedWords" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="wordsHidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="wordsHidden = false">Tap to reveal</button>
</div>
<div class="flex gap-2 pt-4"> <div class="flex gap-2 pt-4">
<button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button> <button type="button" @click="copyRevealedWords" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">{{ wordsCopied ? 'Copied!' : 'Copy' }}</button>
<button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button> <button type="button" @click="closeReveal" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>