Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions src/components/tx/XeAddressModal.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, reactive } from 'vue'
import XeAddressModal from './XeAddressModal.vue'
import { xeAccountFromPhrase } from '../../utils/xe-account'

let app
let host

afterEach(() => {
app?.unmount()
host?.remove()
})

async function mount() {
host = document.createElement('div')
document.body.append(host)
const props = reactive({ visible: false })
const created = vi.fn()
app = createApp({ render: () => h(XeAddressModal, { ...props, close: () => { props.visible = false }, onCreated: created }) })
app.mount(host)
props.visible = true
await nextTick()
return { props, created }
}

const button = text => [...host.querySelectorAll('button')].find(el => el.textContent.includes(text))
const words = () => [...host.querySelectorAll('.xe-words li')].map(el => el.lastChild.textContent)

async function fill(input, value) {
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
}

describe('XE address modal v2', () => {
it('requires three correct words before emitting the derived address', async () => {
const { created } = await mount()
const phraseWords = words()
expect(phraseWords).toHaveLength(24)
const expected = xeAccountFromPhrase(phraseWords.join(' ')).address
expect(host.textContent).toContain(expected)
button('I have written them down').click()
await nextTick()
expect(button('Use this address').disabled).toBe(true)
const inputs = [...host.querySelectorAll('input')]
expect(inputs).toHaveLength(3)
await fill(inputs[0], 'wrong')
expect(button('Use this address').disabled).toBe(true)
for (const input of inputs) {
const index = Number(input.id.replace('xe-word-', '')) - 1
await fill(input, phraseWords[index])
}
button('Use this address').click()
expect(created).toHaveBeenCalledExactlyOnceWith(expected)
})
it('requires identical passphrase entries and clears both after applying', async () => {
await mount()
const phrase = words().join(' ')
const originalAddress = xeAccountFromPhrase(phrase).address
const input = host.querySelector('#xe-bip39-passphrase')
await fill(input, 'TREZOR')
expect(button('Apply passphrase').disabled).toBe(true)
const confirm = host.querySelector('#xe-bip39-passphrase-confirm')
await fill(confirm, 'TREZOR ')
expect(button('Apply passphrase').disabled).toBe(true)
expect(button('I have written them down').disabled).toBe(true)
// A dispatched event bypasses the disabled button, so test the method too.
button('Apply passphrase').dispatchEvent(new Event('click'))
await nextTick()
expect(host.textContent).toContain(originalAddress)
expect(input.value).toBe('TREZOR')
await fill(confirm, 'TREZOR')
expect(button('Apply passphrase').disabled).toBe(false)
button('Apply passphrase').click()
await nextTick()
expect(input.value).toBe('')
expect(confirm.value).toBe('')
expect(host.textContent).toContain(xeAccountFromPhrase(phrase, { passphrase: 'TREZOR' }).address)
expect(button('I have written them down').disabled).toBe(false)
button('I have written them down').click()
await nextTick()
expect(button('Use this address').disabled).toBe(true)
})
it('uses a fresh phrase on New address and clears both passphrase inputs on reset', async () => {
const { props } = await mount()
const firstPhrase = words().join(' ')
const input = host.querySelector('#xe-bip39-passphrase')
const confirm = host.querySelector('#xe-bip39-passphrase-confirm')
await fill(input, 'TREZOR')
await fill(confirm, 'TREZOR')
expect(button('I have written them down').disabled).toBe(true)
button('Apply passphrase').click()
await nextTick()
expect(input.value).toBe('')
expect(confirm.value).toBe('')
expect(host.textContent).toContain(xeAccountFromPhrase(firstPhrase, { passphrase: 'TREZOR' }).address)
await fill(confirm, 'unfinished')
expect(button('I have written them down').disabled).toBe(true)
button('New address').click()
await nextTick()
expect(input.value).toBe('')
expect(confirm.value).toBe('')
const nextPhrase = words().join(' ')
expect(nextPhrase).not.toBe(firstPhrase)
expect(host.textContent).toContain(xeAccountFromPhrase(nextPhrase).address)
props.visible = false
await nextTick()
expect(host.querySelectorAll('.xe-words li')).toHaveLength(0)
})
})
40 changes: 38 additions & 2 deletions src/components/tx/XeAddressModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,33 @@
<ol class="xe-words">
<li v-for="(word, i) in words" :key="i"><span class="xe-words__n">{{ i + 1 }}</span>{{ word }}</li>
</ol>
<details class="mt-20">
<summary>Advanced: BIP39 passphrase</summary>
<label for="xe-bip39-passphrase">Optional BIP39 passphrase</label>
<input id="xe-bip39-passphrase" type="password" autocomplete="off" spellcheck="false" v-model="bip39Passphrase" />
<label for="xe-bip39-passphrase-confirm">Confirm BIP39 passphrase (must match exactly)</label>
<input id="xe-bip39-passphrase-confirm" type="password" autocomplete="off" spellcheck="false" v-model="bip39PassphraseConfirm" />
<button class="xe-new" :disabled="!canApplyPassphrase" @click="applyPassphrase">Apply passphrase</button>
<p class="text-gray">Keep it separately. It is never stored. A wrong passphrase opens a different, empty account. Avoid a weak passphrase.</p>
</details>
<p v-if="usesPassphrase" class="mt-20">This address needs your phrase and BIP39 passphrase. The passphrase is not kept.</p>
<div class="flex items-start xe-box">
<span class="flex-shrink-0 inline-block mr-12 text-white icon w-27"><ShieldExclamationIcon/></span>
<p class="mb-0">
Anyone with these words controls your $XE. If you lose them, nobody can recover your $XE.
This wallet does not keep&nbsp;them.
The phrase is your only backup; keep any BIP39 passphrase separately.
</p>
</div>
<p class="mt-20 text-gray">Standard BIP39 + SLIP-0010 ed25519, m/44'/1'/0'. Coin type 1 is provisional.
Wallets made before v2 restore from their hex seed.</p>
</div>
</template>
<template v-slot:footer>
<div class="px-24 pt-32 pb-40 border-t border-gray-700 border-opacity-30">
<div class="grid grid-cols-1 gap-24 md:grid-cols-2">
<button class="w-full button button--outline-success xe-button-outline" @click="close">Cancel</button>
<button class="w-full button button--success xe-button" @click="step = 2">I have written them down</button>
<button class="w-full button button--success xe-button" :disabled="bip39Passphrase !== '' || bip39PassphraseConfirm !== ''" @click="step = 2">I have written them down</button>
</div>
</div>
</template>
Expand Down Expand Up @@ -85,7 +98,7 @@
import Modal from '../Modal.vue'
import { h } from 'vue'
import { RefreshIcon, ShieldExclamationIcon } from '@heroicons/vue/outline'
import { newXeAccount, pickCheckPositions } from '../../utils/xe-account'
import { newXeAccount, xeAccountFromPhrase, pickCheckPositions } from '../../utils/xe-account'

const CHECK_WORDS = 3

Expand Down Expand Up @@ -115,12 +128,18 @@ export default {
step: 1,
words: [],
address: '',
bip39Passphrase: '',
bip39PassphraseConfirm: '',
usesPassphrase: false,
positions: [],
answers: [],
touched: []
}
},
computed: {
canApplyPassphrase() {
return this.bip39Passphrase === this.bip39PassphraseConfirm
},
matches() {
return this.positions.map((position, i) => this.answers[i].trim().toLowerCase() === this.words[position])
},
Expand All @@ -136,6 +155,9 @@ export default {
},
methods: {
generate() {
this.bip39Passphrase = ''
this.bip39PassphraseConfirm = ''
this.usesPassphrase = false
const { address, words } = newXeAccount()
this.words = words
this.address = address
Expand All @@ -144,10 +166,24 @@ export default {
this.touched = this.positions.map(() => false)
this.step = 1
},
applyPassphrase() {
if (!this.canApplyPassphrase) return
const { address } = xeAccountFromPhrase(this.words.join(' '), { passphrase: this.bip39Passphrase })
this.address = address
this.usesPassphrase = this.bip39Passphrase !== ''
this.bip39Passphrase = ''
this.bip39PassphraseConfirm = ''
this.positions = pickCheckPositions(CHECK_WORDS, this.words.length)
this.answers = this.positions.map(() => '')
this.touched = this.positions.map(() => false)
},
reset() {
this.step = 1
this.words = []
this.address = ''
this.bip39Passphrase = ''
this.bip39PassphraseConfirm = ''
this.usesPassphrase = false
this.positions = []
this.answers = []
this.touched = []
Expand Down
85 changes: 66 additions & 19 deletions src/utils/xe-account.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,86 @@
// Use of this source code is governed by a GNU GPL-style license
// that can be found in the LICENSE.md file. All rights reserved.

// New XE address for Claim XE. XE is the new chain (xeprotocol/core), not the
// Edge chain: its addresses are 64 lowercase hex with no prefix.
//
// The XE recovery phrase (core #1005) is the 32-byte ed25519 seed written as
// 24 BIP39 English words: BIP39's entropy-to-mnemonic encoding applied to the
// seed itself. No PBKDF2, passphrase or derivation path: the words are the
// seed. The address is sha256("xe/account/v1" || ed25519 public key).
// xe-recovery-phrase-vectors.json is a copy of the core vectors.

// XE recovery phrase v2: BIP39 + SLIP-0010 ed25519, m/44'/C'/account'.
// The shared core vectors pin every step; the leaf key is the signing seed.
import { ed25519 } from '@noble/curves/ed25519.js'
import { entropyToMnemonic } from '@scure/bip39'
import { sha256 } from '@noble/hashes/sha2.js'
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
import { hmac } from '@noble/hashes/hmac.js'
import { sha256, sha512 } from '@noble/hashes/sha2.js'
import { wordlist } from '@scure/bip39/wordlists/english.js'
import { bytesToHex, concatBytes, randomBytes, utf8ToBytes } from '@noble/hashes/utils.js'
import { bytesToHex, concatBytes, utf8ToBytes } from '@noble/hashes/utils.js'

const ACCOUNT_DOMAIN = utf8ToBytes('xe/account/v1')
// Provisional until XE registers a SLIP-0044 coin type.
export const RECOVERY_PHRASE_COIN_TYPE = 1
const englishWords = new Set(wordlist)

export const phraseFromSeed = seed => entropyToMnemonic(seed, wordlist)
export class PhraseError extends Error {
constructor(code, message, position) {
super(message)
this.name = 'PhraseError'
this.code = code
this.position = position
}
}

export const publicKeyFromSeed = seed => ed25519.getPublicKey(seed)
export const normalizePhrase = phrase => phrase.toLowerCase().split(/\p{White_Space}+/u).filter(Boolean).join(' ').normalize('NFKD')

export function validatePhrase(phrase) {
const normalized = normalizePhrase(phrase)
const words = normalized ? normalized.split(' ') : []
if (![12, 15, 18, 21, 24].includes(words.length)) {
throw new PhraseError('word_count', 'recovery phrase must have 12, 15, 18, 21 or 24 words')
}
const unknown = words.findIndex(word => !englishWords.has(word))
if (unknown !== -1) {
throw new PhraseError('unknown_word', `unknown recovery phrase word at position ${unknown + 1}`, unknown + 1)
}
if (!validateMnemonic(normalized, wordlist)) {
throw new PhraseError('checksum', 'recovery phrase checksum does not match')
}
return normalized
}

function validateAccount(account) {
if (!Number.isInteger(account) || account < 0 || account >= 0x80000000) {
throw new PhraseError('account', 'recovery phrase account must be between 0 and 2147483647')
}
}

export function slip10Ed25519(seed, path) {
let node = hmac(sha512, utf8ToBytes('ed25519 seed'), seed)
for (const index of path) {
validateAccount(index)
const data = new Uint8Array(37)
data.set(node.subarray(0, 32), 1)
new DataView(data.buffer).setUint32(33, index + 0x80000000, false)
node = hmac(sha512, node.subarray(32), data)
}
return { key: node.slice(0, 32), chainCode: node.slice(32) }
}

export const publicKeyFromSeed = seed => ed25519.getPublicKey(seed)
export const addressFromPublicKey = publicKey => bytesToHex(sha256(concatBytes(ACCOUNT_DOMAIN, publicKey)))

// A new XE account from a random seed. The seed is not kept: the words are the
// only copy.
export function newXeAccount() {
const seed = randomBytes(32)
export function xeAccountFromPhrase(phrase, { passphrase = '', account = 0 } = {}) {
validateAccount(account)
phrase = validatePhrase(phrase)
const seed = slip10Ed25519(mnemonicToSeedSync(phrase, passphrase), [44, RECOVERY_PHRASE_COIN_TYPE, account]).key
return {
words: phraseFromSeed(seed).split(' '),
phrase,
words: phrase.split(' '),
account,
seed,
address: addressFromPublicKey(publicKeyFromSeed(seed))
}
}

// New address always means a new 24-word phrase at account 0.
export function newXeAccount({ passphrase = '' } = {}) {
return xeAccountFromPhrase(generateMnemonic(wordlist, 256), { passphrase })
}

// Distinct word positions (0-based, in order) to ask back after the user has
// written the phrase down.
export function pickCheckPositions(count, total) {
Expand Down
Loading