diff --git a/neode-ui/src/components/EcashSeedBackup.vue b/neode-ui/src/components/EcashSeedBackup.vue index 1249c913..2acc272d 100644 --- a/neode-ui/src/components/EcashSeedBackup.vue +++ b/neode-ui/src/components/EcashSeedBackup.vue @@ -50,6 +50,7 @@ const showRevealModal = ref(false) const revealPassword = ref('') const revealCode = ref('') const revealPassphrase = ref('') +const showRevealPassphrase = ref(false) const revealing = ref(false) const revealError = ref('') const revealedWords = ref([]) @@ -60,6 +61,7 @@ function openReveal() { revealPassword.value = '' revealCode.value = '' revealPassphrase.value = '' + showRevealPassphrase.value = false revealError.value = '' revealedWords.value = [] showRevealModal.value = true @@ -83,7 +85,17 @@ async function submitReveal() { // to set up a backup that now exists. void loadStatus() } 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 { revealing.value = false } @@ -95,6 +107,7 @@ function closeReveal() { revealPassword.value = '' revealCode.value = '' revealPassphrase.value = '' + showRevealPassphrase.value = false } async function copyRevealedWords() { @@ -376,9 +389,9 @@ async function restoreFromPhrase() { -
- - +
+ +

{{ revealError }}

diff --git a/neode-ui/src/components/__tests__/EcashSeedBackup.test.ts b/neode-ui/src/components/__tests__/EcashSeedBackup.test.ts new file mode 100644 index 00000000..1904fe5f --- /dev/null +++ b/neode-ui/src/components/__tests__/EcashSeedBackup.test.ts @@ -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('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() + }) +})