fix(wallet): disclose backup passphrase only when needed (#127)

This commit is contained in:
archipelago
2026-08-30 10:23:58 -04:00
parent 758332d63d
commit d79ca54019
2 changed files with 69 additions and 4 deletions
+17 -4
View File
@@ -50,6 +50,7 @@ const showRevealModal = ref(false)
const revealPassword = ref('') const revealPassword = ref('')
const revealCode = ref('') const revealCode = ref('')
const revealPassphrase = ref('') const revealPassphrase = ref('')
const showRevealPassphrase = ref(false)
const revealing = ref(false) const revealing = ref(false)
const revealError = ref('') const revealError = ref('')
const revealedWords = ref<string[]>([]) const revealedWords = ref<string[]>([])
@@ -60,6 +61,7 @@ function openReveal() {
revealPassword.value = '' revealPassword.value = ''
revealCode.value = '' revealCode.value = ''
revealPassphrase.value = '' revealPassphrase.value = ''
showRevealPassphrase.value = false
revealError.value = '' revealError.value = ''
revealedWords.value = [] revealedWords.value = []
showRevealModal.value = true showRevealModal.value = true
@@ -83,7 +85,17 @@ async function submitReveal() {
// to set up a backup that now exists. // to set up a backup that now exists.
void loadStatus() void loadStatus()
} catch (e: unknown) { } catch (e: unknown) {
revealError.value = e instanceof Error ? e.message : 'Failed to reveal the ecash phrase' const message = e instanceof Error ? e.message : 'Failed to reveal the ecash phrase'
// Most operators used their login password as the backup passphrase. Do
// not confront everyone with an unexplained third credential up front;
// disclose it only when the authenticated password could not decrypt the
// node seed and a distinct setup-time passphrase may actually exist.
if (!status.value?.active && /could not decrypt the saved seed/i.test(message)) {
showRevealPassphrase.value = true
revealError.value = 'Your login password did not unlock the saved seed. Enter the separate backup passphrase you chose during setup.'
} else {
revealError.value = message
}
} finally { } finally {
revealing.value = false revealing.value = false
} }
@@ -95,6 +107,7 @@ function closeReveal() {
revealPassword.value = '' revealPassword.value = ''
revealCode.value = '' revealCode.value = ''
revealPassphrase.value = '' revealPassphrase.value = ''
showRevealPassphrase.value = false
} }
async function copyRevealedWords() { async function copyRevealedWords() {
@@ -376,9 +389,9 @@ async function restoreFromPhrase() {
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label> <label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" /> <input v-model="revealCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div> </div>
<div v-if="!status?.active"> <div v-if="showRevealPassphrase">
<label class="block text-xs text-white/60 mb-1">Backup passphrase <span class="text-white/30">(only if different from password)</span></label> <label class="block text-xs text-white/60 mb-1">Separate backup passphrase</label>
<input v-model="revealPassphrase" type="password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Leave blank to use password" /> <input v-model="revealPassphrase" type="password" autocomplete="off" autofocus class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Passphrase chosen during setup" />
</div> </div>
<p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p> <p v-if="revealError" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ revealError }}</p>
<div class="flex gap-2 pt-1"> <div class="flex gap-2 pt-1">
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
import { rpcClient } from '@/api/rpc-client'
import EcashSeedBackup from '../EcashSeedBackup.vue'
let wrapper: VueWrapper | null = null
describe('EcashSeedBackup reveal credentials (#127)', () => {
beforeEach(() => {
document.body.innerHTML = ''
vi.clearAllMocks()
})
afterEach(() => {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
})
it('asks for a separate backup passphrase only after password decryption fails', async () => {
vi.mocked(rpcClient.call)
.mockResolvedValueOnce({
active: false,
source: null,
can_activate: true,
derivable_from_node_seed: true,
})
.mockRejectedValueOnce(new Error(
'Could not decrypt the saved seed. If you set a separate backup passphrase during setup, enter that passphrase.',
))
wrapper = mount(EcashSeedBackup, { attachTo: document.body })
await flushPromises()
await wrapper.get('button').trigger('click')
expect(document.body.textContent).not.toContain('Separate backup passphrase')
const password = document.body.querySelector<HTMLInputElement>('input[autocomplete="current-password"]')!
password.value = 'login-password'
password.dispatchEvent(new Event('input', { bubbles: true }))
document.body.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await flushPromises()
expect(document.body.textContent).toContain('Separate backup passphrase')
expect(document.body.textContent).toContain('Your login password did not unlock the saved seed')
expect(document.body.querySelector('input[placeholder="Passphrase chosen during setup"]')).not.toBeNull()
})
})