From f7000a63a122da40726e79d9a77fb096106d4675 Mon Sep 17 00:00:00 2001 From: owlsua Date: Sun, 23 Aug 2026 23:05:54 +0200 Subject: [PATCH 1/7] test: run the real mintlayer wasm under jest instead of a mock The suite resolved every wasm_wrappers import to a hand-written mock, so nothing exercised the real bindings and several suites had to stay disabled. - load the real module through src/tests/helpers/initWasm.js - stub import.meta in the test env only, guarded so new.target still works - map bare "buffer" to the npm polyfill; Buffer instances from node's builtin belong to another realm and the wasm bindings reject them - polyfill structuredClone for fake-indexeddb - delete the 1627-line mock, now unreferenced --- babel.config.js | 15 + eslint.config.mjs | 2 +- jest.config.js | 2 +- .../Crypto/Mintlayer/Mintlayer.test.js | 48 + src/setupTests.js | 20 + src/tests/helpers/initWasm.js | 17 + src/tests/mock/wasmCrypro/wasmCrypto.js | 1627 ----------------- 7 files changed, 102 insertions(+), 1629 deletions(-) create mode 100644 src/services/Crypto/Mintlayer/Mintlayer.test.js create mode 100644 src/tests/helpers/initWasm.js delete mode 100644 src/tests/mock/wasmCrypro/wasmCrypto.js diff --git a/babel.config.js b/babel.config.js index aa1b3db6..0ca083ce 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,7 +1,22 @@ +const stubImportMeta = () => ({ + visitor: { + MetaProperty(path) { + if (path.node.meta?.name !== 'import') return + + path.replaceWithSourceString('({ url: "file:///" })') + }, + }, +}) + module.exports = { presets: [ ['@babel/preset-env', { targets: { node: 'current' } }], ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', ], + env: { + test: { + plugins: [stubImportMeta], + }, + }, } diff --git a/eslint.config.mjs b/eslint.config.mjs index 1cdfe6e0..eab3e38a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -70,7 +70,7 @@ export default [ }, }, { - files: ['src/tests/mock/**/*.js'], + files: ['src/tests/**/*.js'], languageOptions: { globals: { ...globals.node, diff --git a/jest.config.js b/jest.config.js index aedc5619..b8ff43fa 100644 --- a/jest.config.js +++ b/jest.config.js @@ -39,7 +39,7 @@ module.exports = { '^react-router-dom$': '/node_modules/react-router-dom/dist/index.js', '^src/(.*)$': '/src/$1', - '.*wasm_wrappers.js': '/src/tests/mock/wasmCrypro/wasmCrypto.js', + '^buffer$': '/node_modules/buffer/index.js', }, collectCoverageFrom: ['!src/pages'], coveragePathIgnorePatterns: [ diff --git a/src/services/Crypto/Mintlayer/Mintlayer.test.js b/src/services/Crypto/Mintlayer/Mintlayer.test.js new file mode 100644 index 00000000..6692e3e4 --- /dev/null +++ b/src/services/Crypto/Mintlayer/Mintlayer.test.js @@ -0,0 +1,48 @@ +import { AppInfo } from '@Constants' +import initWasm from 'src/tests/helpers/initWasm' +import * as ML from './Mintlayer' + +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + +beforeAll(() => initWasm()) + +test('Mintlayer - the real wasm module is loaded, not a mock', () => { + const key = ML.getPrivateKeyFromMnemonic( + MNEMONIC, + AppInfo.NETWORK_TYPES.TESTNET, + ) + + expect(key).toBeInstanceOf(Uint8Array) + expect(key.length).toBeGreaterThan(0) +}) + +test('Mintlayer - key derivation is deterministic and network specific', () => { + const testnet = ML.getPrivateKeyFromMnemonic( + MNEMONIC, + AppInfo.NETWORK_TYPES.TESTNET, + ) + const mainnet = ML.getPrivateKeyFromMnemonic( + MNEMONIC, + AppInfo.NETWORK_TYPES.MAINNET, + ) + + expect( + ML.getPrivateKeyFromMnemonic(MNEMONIC, AppInfo.NETWORK_TYPES.TESTNET), + ).toStrictEqual(testnet) + expect(Buffer.from(testnet)).not.toStrictEqual(Buffer.from(mainnet)) +}) + +test('Mintlayer - initWasm is idempotent', async () => { + await expect(ML.initWasm()).resolves.toBeUndefined() + await expect(ML.initWasm()).resolves.toBeUndefined() +}) + +test('Mintlayer - the import.meta stub does not touch new.target', () => { + function Callable() { + return new.target + } + + expect(new Callable()).toBe(Callable) + expect(Callable()).toBeUndefined() +}) diff --git a/src/setupTests.js b/src/setupTests.js index 72aeee58..c1b7cad1 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -26,6 +26,26 @@ if (typeof global.crypto === 'undefined') { // Buffer polyfill global.Buffer = Buffer +const deepClone = (value) => { + if (value === null || typeof value !== 'object') return value + if (ArrayBuffer.isView(value)) return new value.constructor(value) + if (value instanceof ArrayBuffer) return value.slice(0) + if (value instanceof Date) return new Date(value) + if (value instanceof RegExp) return new RegExp(value) + if (value instanceof Map) + return new Map([...value].map(([k, v]) => [deepClone(k), deepClone(v)])) + if (value instanceof Set) return new Set([...value].map(deepClone)) + if (Array.isArray(value)) return value.map(deepClone) + + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, deepClone(v)]), + ) +} + +if (typeof global.structuredClone === 'undefined') { + global.structuredClone = deepClone +} + // Save the original fetch for integration tests global.originalFetch = global.fetch diff --git a/src/tests/helpers/initWasm.js b/src/tests/helpers/initWasm.js new file mode 100644 index 00000000..fa20f4a3 --- /dev/null +++ b/src/tests/helpers/initWasm.js @@ -0,0 +1,17 @@ +import fs from 'fs' +import path from 'path' +import { initSync } from 'src/services/Crypto/Mintlayer/@mintlayerlib-js/wasm_wrappers.js' + +const WASM_PATH = path.resolve( + process.cwd(), + 'src/services/Crypto/Mintlayer/@mintlayerlib-js/wasm_wrappers_bg.wasm', +) + +let compiled + +const initWasm = () => { + if (!compiled) compiled = new WebAssembly.Module(fs.readFileSync(WASM_PATH)) + initSync({ module: compiled }) +} + +export default initWasm diff --git a/src/tests/mock/wasmCrypro/wasmCrypto.js b/src/tests/mock/wasmCrypro/wasmCrypto.js deleted file mode 100644 index ccf2068a..00000000 --- a/src/tests/mock/wasmCrypro/wasmCrypto.js +++ /dev/null @@ -1,1627 +0,0 @@ -/* eslint-disable no-new-func */ -/* eslint-disable eqeqeq */ -/* eslint-disable no-restricted-globals */ -/* eslint-disable max-depth */ -/* eslint-disable no-undef */ -/* eslint-disable max-params */ -let wasm - -const cachedTextDecoder = - typeof TextDecoder !== 'undefined' - ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) - : { - decode: () => { - throw Error('TextDecoder not available') - }, - } - -if (typeof TextDecoder !== 'undefined') { - cachedTextDecoder.decode() -} - -let cachedUint8Memory0 = null - -function getUint8Memory0() { - if (cachedUint8Memory0 === null || cachedUint8Memory0.byteLength === 0) { - cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer) - } - return cachedUint8Memory0 -} - -function getStringFromWasm0(ptr, len) { - ptr = ptr >>> 0 - return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len)) -} - -const heap = new Array(128).fill(undefined) - -heap.push(undefined, null, true, false) - -let heap_next = heap.length - -function addHeapObject(obj) { - if (heap_next === heap.length) heap.push(heap.length + 1) - const idx = heap_next - heap_next = heap[idx] - - heap[idx] = obj - return idx -} - -function getObject(idx) { - return heap[idx] -} - -function dropObject(idx) { - if (idx < 132) return - heap[idx] = heap_next - heap_next = idx -} - -function takeObject(idx) { - const ret = getObject(idx) - dropObject(idx) - return ret -} - -let WASM_VECTOR_LEN = 0 - -const cachedTextEncoder = - typeof TextEncoder !== 'undefined' - ? new TextEncoder('utf-8') - : { - encode: () => { - throw Error('TextEncoder not available') - }, - } - -const encodeString = - typeof cachedTextEncoder.encodeInto === 'function' - ? function (arg, view) { - return cachedTextEncoder.encodeInto(arg, view) - } - : function (arg, view) { - const buf = cachedTextEncoder.encode(arg) - view.set(buf) - return { - read: arg.length, - written: buf.length, - } - } - -function passStringToWasm0(arg, malloc, realloc) { - if (realloc === undefined) { - const buf = cachedTextEncoder.encode(arg) - const ptr = malloc(buf.length, 1) >>> 0 - getUint8Memory0() - .subarray(ptr, ptr + buf.length) - .set(buf) - WASM_VECTOR_LEN = buf.length - return ptr - } - - let len = arg.length - let ptr = malloc(len, 1) >>> 0 - - const mem = getUint8Memory0() - - let offset = 0 - - for (; offset < len; offset++) { - const code = arg.charCodeAt(offset) - if (code > 0x7f) break - mem[ptr + offset] = code - } - - if (offset !== len) { - if (offset !== 0) { - arg = arg.slice(offset) - } - ptr = realloc(ptr, len, (len = offset + arg.length * 3), 1) >>> 0 - const view = getUint8Memory0().subarray(ptr + offset, ptr + len) - const ret = encodeString(arg, view) - - offset += ret.written - ptr = realloc(ptr, len, offset, 1) >>> 0 - } - - WASM_VECTOR_LEN = offset - return ptr -} - -function isLikeNone(x) { - return x === undefined || x === null -} - -let cachedInt32Memory0 = null - -function getInt32Memory0() { - if (cachedInt32Memory0 === null || cachedInt32Memory0.byteLength === 0) { - cachedInt32Memory0 = new Int32Array(wasm.memory.buffer) - } - return cachedInt32Memory0 -} - -function passArray8ToWasm0(arg, malloc) { - const ptr = malloc(arg.length * 1, 1) >>> 0 - getUint8Memory0().set(arg, ptr / 1) - WASM_VECTOR_LEN = arg.length - return ptr -} - -function getArrayU8FromWasm0(ptr, len) { - ptr = ptr >>> 0 - return getUint8Memory0().subarray(ptr / 1, ptr / 1 + len) -} -/** - * A utxo can either come from a transaction or a block reward. - * Given a source id, whether from a block reward or transaction, this function - * takes a generic id with it, and returns serialized binary data of the id - * with the given source id. - * @param {Uint8Array} id - * @param {SourceId} source - * @returns {Uint8Array} - */ -export function encode_outpoint_source_id(id, source) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(id, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.encode_outpoint_source_id(retptr, ptr0, len0, source) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Generates a new, random private key from entropy - * @returns {Uint8Array} - */ -export function make_private_key() { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.make_private_key(retptr) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Create the default account's extended private key for a given mnemonic - * derivation path: 44'/mintlayer_coin_type'/0' - * @param {string} mnemonic - * @param {Network} network - * @returns {Uint8Array} - */ -export function make_default_account_privkey(mnemonic, network) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - mnemonic, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - wasm.make_default_account_privkey(retptr, ptr0, len0, network) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * From an extended private key create a receiving private key for a given key index - * derivation path: 44'/mintlayer_coin_type'/0'/0/key_index - * @param {Uint8Array} private_key_bytes - * @param {number} key_index - * @returns {Uint8Array} - */ -export function make_receiving_address(private_key_bytes, key_index) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(private_key_bytes, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.make_receiving_address(retptr, ptr0, len0, key_index) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * From an extended private key create a change private key for a given key index - * derivation path: 44'/mintlayer_coin_type'/0'/1/key_index - * @param {Uint8Array} private_key_bytes - * @param {number} key_index - * @returns {Uint8Array} - */ -export function make_change_address(private_key_bytes, key_index) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(private_key_bytes, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.make_change_address(retptr, ptr0, len0, key_index) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a public key (as bytes) and a network type (mainnet, testnet, etc), - * return the address public key hash from that public key as an address - * @param {Uint8Array} public_key_bytes - * @param {Network} network - * @returns {string} - */ -export function pubkey_to_pubkeyhash_address(public_key_bytes, network) { - let deferred3_0 - let deferred3_1 - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(public_key_bytes, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.pubkey_to_pubkeyhash_address(retptr, ptr0, len0, network) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - var ptr2 = r0 - var len2 = r1 - if (r3) { - ptr2 = 0 - len2 = 0 - throw takeObject(r2) - } - deferred3_0 = ptr2 - deferred3_1 = len2 - return getStringFromWasm0(ptr2, len2) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - wasm.__wbindgen_free(deferred3_0, deferred3_1, 1) - } -} - -/** - * Given a private key, as bytes, return the bytes of the corresponding public key - * @param {Uint8Array} private_key - * @returns {Uint8Array} - */ -export function public_key_from_private_key(private_key) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(private_key, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.public_key_from_private_key(retptr, ptr0, len0) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a message and a private key, sign the message with the given private key - * This kind of signature is to be used when signing spend requests, such as transaction - * input witness. - * @param {Uint8Array} private_key - * @param {Uint8Array} message - * @returns {Uint8Array} - */ -export function sign_message_for_spending(private_key, message) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(private_key, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(message, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - wasm.sign_message_for_spending(retptr, ptr0, len0, ptr1, len1) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a digital signature, a public key and a message. Verify that - * the signature is produced by signing the message with the private key - * that derived the given public key. - * Note that this function is used for verifying messages related to spending, - * such as transaction input witness. - * @param {Uint8Array} public_key - * @param {Uint8Array} signature - * @param {Uint8Array} message - * @returns {boolean} - */ -export function verify_signature_for_spending(public_key, signature, message) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(public_key, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(signature, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - const ptr2 = passArray8ToWasm0(message, wasm.__wbindgen_malloc) - const len2 = WASM_VECTOR_LEN - wasm.verify_signature_for_spending( - retptr, - ptr0, - len0, - ptr1, - len1, - ptr2, - len2, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - if (r2) { - throw takeObject(r1) - } - return r0 !== 0 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -function _assertClass(instance, klass) { - if (!(instance instanceof klass)) { - throw new Error(`expected instance of ${klass.name}`) - } - return instance.ptr -} -/** - * Given a destination address, an amount and a network type (mainnet, testnet, etc), this function - * creates an output of type Transfer, and returns it as bytes. - * @param {Amount} amount - * @param {string} address - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_transfer(amount, address, network) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(amount, Amount) - var ptr0 = amount.__destroy_into_raw() - const ptr1 = passStringToWasm0( - address, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - wasm.encode_output_transfer(retptr, ptr0, ptr1, len1, network) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given the current block height and a network type (mainnet, testnet, etc), - * this function returns the number of blocks, after which a pool that decommissioned, - * will have its funds unlocked and available for spending. - * The current block height information is used in case a network upgrade changed the value. - * @param {bigint} current_block_height - * @param {Network} network - * @returns {bigint} - */ -export function staking_pool_spend_maturity_block_count( - current_block_height, - network, -) { - const ret = wasm.staking_pool_spend_maturity_block_count( - current_block_height, - network, - ) - return BigInt.asUintN(64, ret) -} - -/** - * Given a number of blocks, this function returns the output timelock - * which is used in locked outputs to lock an output for a given number of blocks - * since that output's transaction is included the blockchain - * @param {bigint} block_count - * @returns {Uint8Array} - */ -export function encode_lock_for_block_count(block_count) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.encode_lock_for_block_count(retptr, block_count) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a number of clock seconds, this function returns the output timelock - * which is used in locked outputs to lock an output for a given number of seconds - * since that output's transaction is included in the blockchain - * @param {bigint} total_seconds - * @returns {Uint8Array} - */ -export function encode_lock_for_seconds(total_seconds) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.encode_lock_for_seconds(retptr, total_seconds) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a timestamp represented by as unix timestamp, i.e., number of seconds since unix epoch, - * this function returns the output timelock which is used in locked outputs to lock an output - * until the given timestamp - * @param {bigint} timestamp_since_epoch_in_seconds - * @returns {Uint8Array} - */ -export function encode_lock_until_time(timestamp_since_epoch_in_seconds) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.encode_lock_until_time(retptr, timestamp_since_epoch_in_seconds) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a block height, this function returns the output timelock which is used in - * locked outputs to lock an output until that block height is reached. - * @param {bigint} block_height - * @returns {Uint8Array} - */ -export function encode_lock_until_height(block_height) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.encode_lock_until_height(retptr, block_height) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a valid receiving address, and a locking rule as bytes (available in this file), - * and a network type (mainnet, testnet, etc), this function creates an output of type - * LockThenTransfer with the parameters provided. - * @param {Amount} amount - * @param {string} address - * @param {Uint8Array} lock - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_lock_then_transfer( - amount, - address, - lock, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(amount, Amount) - var ptr0 = amount.__destroy_into_raw() - const ptr1 = passStringToWasm0( - address, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - const ptr2 = passArray8ToWasm0(lock, wasm.__wbindgen_malloc) - const len2 = WASM_VECTOR_LEN - wasm.encode_output_lock_then_transfer( - retptr, - ptr0, - ptr1, - len1, - ptr2, - len2, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v4 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v4 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given an amount, this function creates an output (as bytes) to burn a given amount of coins - * @param {Amount} amount - * @returns {Uint8Array} - */ -export function encode_output_coin_burn(amount) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(amount, Amount) - var ptr0 = amount.__destroy_into_raw() - wasm.encode_output_coin_burn(retptr, ptr0) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a pool id as string, an owner address and a network type (mainnet, testnet, etc), - * this function returns an output (as bytes) to create a delegation to the given pool. - * The owner address is the address that is authorized to withdraw from that delegation. - * @param {string} pool_id - * @param {string} owner_address - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_create_delegation( - pool_id, - owner_address, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - pool_id, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - const ptr1 = passStringToWasm0( - owner_address, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - wasm.encode_output_create_delegation( - retptr, - ptr0, - len0, - ptr1, - len1, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a delegation id (as string, in address form), an amount and a network type (mainnet, testnet, etc), - * this function returns an output (as bytes) that would delegate coins to be staked in the specified delegation id. - * @param {Amount} amount - * @param {string} delegation_id - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_delegate_staking(amount, delegation_id, network) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(amount, Amount) - var ptr0 = amount.__destroy_into_raw() - const ptr1 = passStringToWasm0( - delegation_id, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - wasm.encode_output_delegate_staking(retptr, ptr0, ptr1, len1, network) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * This function returns the staking pool data needed to create a staking pool in an output as bytes, - * given its parameters and the network type (testnet, mainnet, etc). - * @param {Amount} value - * @param {string} staker - * @param {string} vrf_public_key - * @param {string} decommission_key - * @param {number} margin_ratio_per_thousand - * @param {Amount} cost_per_block - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_stake_pool_data( - value, - staker, - vrf_public_key, - decommission_key, - margin_ratio_per_thousand, - cost_per_block, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(value, Amount) - var ptr0 = value.__destroy_into_raw() - const ptr1 = passStringToWasm0( - staker, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - const ptr2 = passStringToWasm0( - vrf_public_key, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len2 = WASM_VECTOR_LEN - const ptr3 = passStringToWasm0( - decommission_key, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len3 = WASM_VECTOR_LEN - _assertClass(cost_per_block, Amount) - var ptr4 = cost_per_block.__destroy_into_raw() - wasm.encode_stake_pool_data( - retptr, - ptr0, - ptr1, - len1, - ptr2, - len2, - ptr3, - len3, - margin_ratio_per_thousand, - ptr4, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v6 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v6 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a pool id, staking data as bytes and the network type (mainnet, testnet, etc), - * this function returns an output that creates that staking pool. - * Note that the pool id is mandated to be taken from the hash of the first input. - * It's not arbitrary. - * @param {string} pool_id - * @param {Uint8Array} pool_data - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_create_stake_pool(pool_id, pool_data, network) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - pool_id, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(pool_data, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - wasm.encode_output_create_stake_pool( - retptr, - ptr0, - len0, - ptr1, - len1, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given the parameters needed to issue a fungible token, and a network type (mainnet, testnet, etc), - * this function creates an output that issues that token. - * @param {string} authority - * @param {Uint8Array} token_ticker - * @param {Uint8Array} metadata_uri - * @param {number} number_of_decimals - * @param {TotalSupply} total_supply - * @param {Amount | undefined} supply_amount - * @param {FreezableToken} is_token_freezable - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_output_issue_fungible_token( - authority, - token_ticker, - metadata_uri, - number_of_decimals, - total_supply, - supply_amount, - is_token_freezable, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - authority, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(token_ticker, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - const ptr2 = passArray8ToWasm0(metadata_uri, wasm.__wbindgen_malloc) - const len2 = WASM_VECTOR_LEN - let ptr3 = 0 - if (!isLikeNone(supply_amount)) { - _assertClass(supply_amount, Amount) - ptr3 = supply_amount.__destroy_into_raw() - } - wasm.encode_output_issue_fungible_token( - retptr, - ptr0, - len0, - ptr1, - len1, - ptr2, - len2, - number_of_decimals, - total_supply, - ptr3, - is_token_freezable, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v5 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v5 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given data to be deposited in the blockchain, this function provides the output that deposits this data - * @param {Uint8Array} data - * @returns {Uint8Array} - */ -export function encode_output_data_deposit(data) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.encode_output_data_deposit(retptr, ptr0, len0) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given an output source id as bytes, and an output index, together representing a utxo, - * this function returns the input that puts them together, as bytes. - * @param {Uint8Array} outpoint_source_id - * @param {number} output_index - * @returns {Uint8Array} - */ -export function encode_input_for_utxo(outpoint_source_id, output_index) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(outpoint_source_id, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - wasm.encode_input_for_utxo(retptr, ptr0, len0, output_index) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v2 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v2 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a delegation id, an amount and a network type (mainnet, testnet, etc), this function - * creates an input that withdraws from a delegation. - * A nonce is needed because this spends from an account. The nonce must be in sequence for everything in that account. - * @param {string} delegation_id - * @param {Amount} amount - * @param {bigint} nonce - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_input_for_withdraw_from_delegation( - delegation_id, - amount, - nonce, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passStringToWasm0( - delegation_id, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - _assertClass(amount, Amount) - var ptr1 = amount.__destroy_into_raw() - wasm.encode_input_for_withdraw_from_delegation( - retptr, - ptr0, - len0, - ptr1, - nonce, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -let cachedUint32Memory0 = null - -function getUint32Memory0() { - if (cachedUint32Memory0 === null || cachedUint32Memory0.byteLength === 0) { - cachedUint32Memory0 = new Uint32Array(wasm.memory.buffer) - } - return cachedUint32Memory0 -} - -function passArrayJsValueToWasm0(array, malloc) { - const ptr = malloc(array.length * 4, 4) >>> 0 - const mem = getUint32Memory0() - for (let i = 0; i < array.length; i++) { - mem[ptr / 4 + i] = addHeapObject(array[i]) - } - WASM_VECTOR_LEN = array.length - return ptr -} -/** - * Given inputs, each input's destination (from the UTXO or Account) and outputs, estimate the transaction size. - * @param {Uint8Array} inputs - * @param {(string)[]} input_destinations - * @param {Uint8Array} outputs - * @param {Network} network - * @returns {number} - */ -export function estimate_transaction_size( - inputs, - input_destinations, - outputs, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(inputs, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArrayJsValueToWasm0( - input_destinations, - wasm.__wbindgen_malloc, - ) - const len1 = WASM_VECTOR_LEN - const ptr2 = passArray8ToWasm0(outputs, wasm.__wbindgen_malloc) - const len2 = WASM_VECTOR_LEN - wasm.estimate_transaction_size( - retptr, - ptr0, - len0, - ptr1, - len1, - ptr2, - len2, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - if (r2) { - throw takeObject(r1) - } - return r0 >>> 0 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given inputs as bytes, outputs as bytes, and flags settings, this function returns - * the transaction that contains them all, as bytes. - * @param {Uint8Array} inputs - * @param {Uint8Array} outputs - * @param {bigint} flags - * @returns {Uint8Array} - */ -export function encode_transaction(inputs, outputs, flags) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(inputs, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(outputs, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - wasm.encode_transaction(retptr, ptr0, len0, ptr1, len1, flags) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Encode an input witness of the variant that contains no signature. - * @returns {Uint8Array} - */ -export function encode_witness_no_signature() { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.encode_witness_no_signature(retptr) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var v1 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v1 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given a private key, inputs and an input number to sign, and the destination that owns that output (through the utxo), - * and a network type (mainnet, testnet, etc), this function returns a witness to be used in a signed transaction, as bytes. - * @param {SignatureHashType} sighashtype - * @param {Uint8Array} private_key_bytes - * @param {string} input_owner_destination - * @param {Uint8Array} transaction_bytes - * @param {Uint8Array} inputs - * @param {number} input_num - * @param {Network} network - * @returns {Uint8Array} - */ -export function encode_witness( - sighashtype, - private_key_bytes, - input_owner_destination, - transaction_bytes, - inputs, - input_num, - network, -) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(private_key_bytes, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passStringToWasm0( - input_owner_destination, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len1 = WASM_VECTOR_LEN - const ptr2 = passArray8ToWasm0(transaction_bytes, wasm.__wbindgen_malloc) - const len2 = WASM_VECTOR_LEN - const ptr3 = passArray8ToWasm0(inputs, wasm.__wbindgen_malloc) - const len3 = WASM_VECTOR_LEN - wasm.encode_witness( - retptr, - sighashtype, - ptr0, - len0, - ptr1, - len1, - ptr2, - len2, - ptr3, - len3, - input_num, - network, - ) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v5 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v5 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Given an unsigned transaction, and signatures, this function returns a SignedTransaction object as bytes. - * @param {Uint8Array} transaction_bytes - * @param {Uint8Array} signatures - * @returns {Uint8Array} - */ -export function encode_signed_transaction(transaction_bytes, signatures) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - const ptr0 = passArray8ToWasm0(transaction_bytes, wasm.__wbindgen_malloc) - const len0 = WASM_VECTOR_LEN - const ptr1 = passArray8ToWasm0(signatures, wasm.__wbindgen_malloc) - const len1 = WASM_VECTOR_LEN - wasm.encode_signed_transaction(retptr, ptr0, len0, ptr1, len1) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - var r3 = getInt32Memory0()[retptr / 4 + 3] - if (r3) { - throw takeObject(r2) - } - var v3 = getArrayU8FromWasm0(r0, r1).slice() - wasm.__wbindgen_free(r0, r1 * 1, 1) - return v3 - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -/** - * Calculate the "effective balance" of a pool, given the total pool balance and pledge by the pool owner/staker. - * The effective balance is how the influence of a pool is calculated due to its balance. - * @param {Network} network - * @param {Amount} pledge_amount - * @param {Amount} pool_balance - * @returns {Amount} - */ -export function effective_pool_balance(network, pledge_amount, pool_balance) { - try { - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - _assertClass(pledge_amount, Amount) - var ptr0 = pledge_amount.__destroy_into_raw() - _assertClass(pool_balance, Amount) - var ptr1 = pool_balance.__destroy_into_raw() - wasm.effective_pool_balance(retptr, network, ptr0, ptr1) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - var r2 = getInt32Memory0()[retptr / 4 + 2] - if (r2) { - throw takeObject(r1) - } - return Amount.__wrap(r0) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - } -} - -function handleError(f, args) { - try { - return f.apply(this, args) - } catch (e) { - wasm.__wbindgen_exn_store(addHeapObject(e)) - } -} -/** - * The token supply of a specific token, set on issuance - */ -export const TotalSupply = Object.freeze({ - /** - * Can be issued with no limit, but then can be locked to have a fixed supply. - */ - Lockable: 0, - 0: 'Lockable', - /** - * Unlimited supply, no limits except for numeric limits due to u128 - */ - Unlimited: 1, - 1: 'Unlimited', - /** - * On issuance, the total number of coins is fixed - */ - Fixed: 2, - 2: 'Fixed', -}) -/** - * Indicates whether a token can be frozen - */ -export const FreezableToken = Object.freeze({ - No: 0, - 0: 'No', - Yes: 1, - 1: 'Yes', -}) -/** - * The network, for which an operation to be done. Mainnet, testnet, etc. - */ -export const Network = Object.freeze({ - Mainnet: 0, - 0: 'Mainnet', - Testnet: 1, - 1: 'Testnet', - Regtest: 2, - 2: 'Regtest', - Signet: 3, - 3: 'Signet', -}) -/** - * A utxo can either come from a transaction or a block reward. This enum signifies that. - */ -export const SourceId = Object.freeze({ - Transaction: 0, - 0: 'Transaction', - BlockReward: 1, - 1: 'BlockReward', -}) -/** - * The part of the transaction that will be committed in the signature. Similar to bitcoin's sighash. - */ -export const SignatureHashType = Object.freeze({ - ALL: 0, - 0: 'ALL', - NONE: 1, - 1: 'NONE', - SINGLE: 2, - 2: 'SINGLE', - ANYONECANPAY: 3, - 3: 'ANYONECANPAY', -}) - -const AmountFinalization = - typeof FinalizationRegistry === 'undefined' - ? { register: () => {}, unregister: () => {} } - : new FinalizationRegistry((ptr) => wasm.__wbg_amount_free(ptr >>> 0)) -/** - * Amount type abstraction. The amount type is stored in a string - * since JavaScript number type cannot fit 128-bit integers. - * The amount is given as an integer in units of "atoms". - * Atoms are the smallest, indivisible amount of a coin or token. - */ -export class Amount { - static __wrap(ptr) { - ptr = ptr >>> 0 - const obj = Object.create(Amount.prototype) - obj.__wbg_ptr = ptr - AmountFinalization.register(obj, obj.__wbg_ptr, obj) - return obj - } - - __destroy_into_raw() { - const ptr = this.__wbg_ptr - this.__wbg_ptr = 0 - AmountFinalization.unregister(this) - return ptr - } - - free() { - const ptr = this.__destroy_into_raw() - wasm.__wbg_amount_free(ptr) - } - /** - * @param {string} atoms - * @returns {Amount} - */ - static from_atoms(atoms) { - const ptr0 = passStringToWasm0( - atoms, - wasm.__wbindgen_malloc, - wasm.__wbindgen_realloc, - ) - const len0 = WASM_VECTOR_LEN - const ret = wasm.amount_from_atoms(ptr0, len0) - return Amount.__wrap(ret) - } - /** - * @returns {string} - */ - atoms() { - let deferred1_0 - let deferred1_1 - try { - const ptr = this.__destroy_into_raw() - const retptr = wasm.__wbindgen_add_to_stack_pointer(-16) - wasm.amount_atoms(retptr, ptr) - var r0 = getInt32Memory0()[retptr / 4 + 0] - var r1 = getInt32Memory0()[retptr / 4 + 1] - deferred1_0 = r0 - deferred1_1 = r1 - return getStringFromWasm0(r0, r1) - } finally { - wasm.__wbindgen_add_to_stack_pointer(16) - wasm.__wbindgen_free(deferred1_0, deferred1_1, 1) - } - } -} - -async function __wbg_load(module, imports) { - if (typeof Response === 'function' && module instanceof Response) { - if (typeof WebAssembly.instantiateStreaming === 'function') { - try { - return await WebAssembly.instantiateStreaming(module, imports) - } catch (e) { - if (module.headers.get('Content-Type') != 'application/wasm') { - console.warn( - '`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n', - e, - ) - } else { - throw e - } - } - } - - const bytes = await module.arrayBuffer() - return await WebAssembly.instantiate(bytes, imports) - } else { - const instance = await WebAssembly.instantiate(module, imports) - - if (instance instanceof WebAssembly.Instance) { - return { instance, module } - } else { - return instance - } - } -} - -function __wbg_get_imports() { - const imports = {} - imports.wbg = {} - imports.wbg.__wbindgen_string_new = function (arg0, arg1) { - const ret = getStringFromWasm0(arg0, arg1) - return addHeapObject(ret) - } - imports.wbg.__wbindgen_object_drop_ref = function (arg0) { - takeObject(arg0) - } - imports.wbg.__wbg_crypto_d05b68a3572bb8ca = function (arg0) { - const ret = getObject(arg0).crypto - return addHeapObject(ret) - } - imports.wbg.__wbindgen_is_object = function (arg0) { - const val = getObject(arg0) - const ret = typeof val === 'object' && val !== null - return ret - } - imports.wbg.__wbg_process_b02b3570280d0366 = function (arg0) { - const ret = getObject(arg0).process - return addHeapObject(ret) - } - imports.wbg.__wbg_versions_c1cb42213cedf0f5 = function (arg0) { - const ret = getObject(arg0).versions - return addHeapObject(ret) - } - imports.wbg.__wbg_node_43b1089f407e4ec2 = function (arg0) { - const ret = getObject(arg0).node - return addHeapObject(ret) - } - imports.wbg.__wbindgen_is_string = function (arg0) { - const ret = typeof getObject(arg0) === 'string' - return ret - } - imports.wbg.__wbg_require_9a7e0f667ead4995 = function () { - return handleError(function () { - const ret = module.require - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbg_msCrypto_10fc94afee92bd76 = function (arg0) { - const ret = getObject(arg0).msCrypto - return addHeapObject(ret) - } - imports.wbg.__wbindgen_is_function = function (arg0) { - const ret = typeof getObject(arg0) === 'function' - return ret - } - imports.wbg.__wbg_randomFillSync_b70ccbdf4926a99d = function () { - return handleError(function (arg0, arg1) { - getObject(arg0).randomFillSync(takeObject(arg1)) - }, arguments) - } - imports.wbg.__wbg_getRandomValues_7e42b4fb8779dc6d = function () { - return handleError(function (arg0, arg1) { - getObject(arg0).getRandomValues(getObject(arg1)) - }, arguments) - } - imports.wbg.__wbg_newnoargs_e258087cd0daa0ea = function (arg0, arg1) { - const ret = new Function(getStringFromWasm0(arg0, arg1)) - return addHeapObject(ret) - } - imports.wbg.__wbg_call_27c0f87801dedf93 = function () { - return handleError(function (arg0, arg1) { - const ret = getObject(arg0).call(getObject(arg1)) - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbindgen_object_clone_ref = function (arg0) { - const ret = getObject(arg0) - return addHeapObject(ret) - } - imports.wbg.__wbg_self_ce0dbfc45cf2f5be = function () { - return handleError(function () { - const ret = self.self - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbg_window_c6fb939a7f436783 = function () { - return handleError(function () { - const ret = window.window - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbg_globalThis_d1e6af4856ba331b = function () { - return handleError(function () { - const ret = globalThis.globalThis - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbg_global_207b558942527489 = function () { - return handleError(function () { - const ret = global.global - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbindgen_is_undefined = function (arg0) { - const ret = getObject(arg0) === undefined - return ret - } - imports.wbg.__wbg_call_b3ca7c6051f9bec1 = function () { - return handleError(function (arg0, arg1, arg2) { - const ret = getObject(arg0).call(getObject(arg1), getObject(arg2)) - return addHeapObject(ret) - }, arguments) - } - imports.wbg.__wbg_buffer_12d079cc21e14bdb = function (arg0) { - const ret = getObject(arg0).buffer - return addHeapObject(ret) - } - imports.wbg.__wbg_newwithbyteoffsetandlength_aa4a17c33a06e5cb = function ( - arg0, - arg1, - arg2, - ) { - const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0) - return addHeapObject(ret) - } - imports.wbg.__wbg_new_63b92bc8671ed464 = function (arg0) { - const ret = new Uint8Array(getObject(arg0)) - return addHeapObject(ret) - } - imports.wbg.__wbg_set_a47bac70306a19a7 = function (arg0, arg1, arg2) { - getObject(arg0).set(getObject(arg1), arg2 >>> 0) - } - imports.wbg.__wbg_newwithlength_e9b4878cebadb3d3 = function (arg0) { - const ret = new Uint8Array(arg0 >>> 0) - return addHeapObject(ret) - } - imports.wbg.__wbg_subarray_a1f73cd4b5b42fe1 = function (arg0, arg1, arg2) { - const ret = getObject(arg0).subarray(arg1 >>> 0, arg2 >>> 0) - return addHeapObject(ret) - } - imports.wbg.__wbindgen_string_get = function (arg0, arg1) { - const obj = getObject(arg1) - const ret = typeof obj === 'string' ? obj : undefined - var ptr1 = isLikeNone(ret) - ? 0 - : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc) - var len1 = WASM_VECTOR_LEN - getInt32Memory0()[arg0 / 4 + 1] = len1 - getInt32Memory0()[arg0 / 4 + 0] = ptr1 - } - imports.wbg.__wbindgen_throw = function (arg0, arg1) { - throw new Error(getStringFromWasm0(arg0, arg1)) - } - imports.wbg.__wbindgen_memory = function () { - const ret = wasm.memory - return addHeapObject(ret) - } - - return imports -} - -function __wbg_init_memory(imports, maybe_memory) {} - -function __wbg_finalize_init(instance, module) { - wasm = instance.exports - __wbg_init.__wbindgen_wasm_module = module - cachedInt32Memory0 = null - cachedUint32Memory0 = null - cachedUint8Memory0 = null - - return wasm -} - -function initSync(module) { - if (wasm !== undefined) return wasm - - const imports = __wbg_get_imports() - - __wbg_init_memory(imports) - - if (!(module instanceof WebAssembly.Module)) { - module = new WebAssembly.Module(module) - } - - const instance = new WebAssembly.Instance(module, imports) - - return __wbg_finalize_init(instance, module) -} - -async function __wbg_init(input) { - if (wasm !== undefined) return wasm - - if (typeof input === 'undefined') { - input = new URL('wasm_wrappers_bg.wasm', '') - } - const imports = __wbg_get_imports() - - if ( - typeof input === 'string' || - (typeof Request === 'function' && input instanceof Request) || - (typeof URL === 'function' && input instanceof URL) - ) { - input = fetch(input) - } - - __wbg_init_memory(imports) - - const { instance, module } = await __wbg_load(await input, imports) - - return __wbg_finalize_init(instance, module) -} - -export { initSync } -export default __wbg_init From 487c376f34a4f0f7b76f2535acfe94406879d088 Mon Sep 17 00:00:00 2001 From: owlsua Date: Sun, 23 Aug 2026 23:08:49 +0200 Subject: [PATCH 2/7] feat: remove the legacy add-wallet flow Mintlayer is created by default for every new account, so the "Add wallet" tile only ever appeared for records predating that default. The flow asked for the seed phrase again and re-derived the mintlayer keys from it. Removes the component, the connect item that opened it, and the four pieces of dashboard state that fed it. An account left without mintlayer keys is fixed by recreating it. --- .../composed/AddWallet/AddWallet.css | 0 .../composed/AddWallet/AddWallet.js | 203 ------------------ .../composed/AddWallet/AddWallet.test.js | 131 ----------- src/components/composed/index.js | 2 - .../containers/Dashboard/CryptoList.js | 42 +--- .../containers/Dashboard/CryptoList.test.js | 101 +-------- src/pages/Dashboard/Dashboard.js | 40 +--- 7 files changed, 4 insertions(+), 515 deletions(-) delete mode 100644 src/components/composed/AddWallet/AddWallet.css delete mode 100644 src/components/composed/AddWallet/AddWallet.js delete mode 100644 src/components/composed/AddWallet/AddWallet.test.js diff --git a/src/components/composed/AddWallet/AddWallet.css b/src/components/composed/AddWallet/AddWallet.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/composed/AddWallet/AddWallet.js b/src/components/composed/AddWallet/AddWallet.js deleted file mode 100644 index c89ea65b..00000000 --- a/src/components/composed/AddWallet/AddWallet.js +++ /dev/null @@ -1,203 +0,0 @@ -import { useState, useContext } from 'react' -import { Button, Error } from '@BasicComponents' -import { TextField, InputList } from '@ComposedComponents' -import { VerticalGroup, CenteredLayout } from '@LayoutComponents' -import { BTC, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' -import { AccountContext } from '@Contexts' -import { Account, AccountHelpers } from '@Entities' -import { useNavigate } from 'react-router' -import { wordlists } from 'bip39' - -const AddWallet = ({ - account, - walletType, - setAllowClosing, - setOpenConnectConfirmation, -}) => { - const navigate = useNavigate() - const [pass, setPass] = useState(null) - const { accountID, setWalletInfo } = useContext(AccountContext) - const [wordsFields, setWordsFields] = useState([]) - const [passValidity, setPassValidity] = useState(false) - const [passPristinity, setPassPristinity] = useState(true) - const [passErrorMessage, setPassErrorMessage] = useState('') - const [mnemonicErrorMessage, setMnemonicErrorMessage] = useState('') - const firstStep = account.seed.encryptedMlTestnetPrivateKey ? 3 : 1 - const [step, setStep] = useState(firstStep) - - const getMnemonics = () => - wordsFields.reduce((acc, word) => `${acc} ${word.value}`, '').trim() - - const submitButtonTitle = step === 3 ? 'Add Wallet' : 'Next' - - const changePassHandle = (value) => { - setPass(value) - } - - const connectWalletHandle = async (id, walletType, mnemonic) => { - //TODO: refactor this - const unlockedAccount = await Account.unlockAccount(id, pass) - if (!pass || !unlockedAccount) { - setPassPristinity(false) - setPassValidity(false) - setPassErrorMessage('Password must be set.') - return - } - const currentAccount = await Account.getAccount(id) - const btcWalletType = - currentAccount.walletType || BTC_ADDRESS_TYPE_ENUM.NATIVE_SEGWIT - const currentWallets = currentAccount.walletsToCreate - const walletToAdd = walletType.value - - if (currentWallets.includes(walletToAdd)) { - console.error('Wallet already added') - setPassErrorMessage('Wallet already added') - return - } - - if (!account.seed.encryptedMlTestnetPrivateKey) { - const { - encryptedMlTestnetPrivateKey, - encryptedMlMainnetPrivateKey, - mlTestnetPrivKeyIv, - mlMainnetPrivKeyIv, - mlTestnetPrivKeyTag, - mlMainnetPrivKeyTag, - } = await AccountHelpers.getEncryptedPrivateKeys( - pass, - account.salt, - mnemonic, - ) - return await Account.updateAccount(id, { - iv: { - ...account.iv, - mlTestnetPrivKeyIv, - mlMainnetPrivKeyIv, - }, - seed: { - ...account.seed, - encryptedMlTestnetPrivateKey, - encryptedMlMainnetPrivateKey, - }, - tag: { - ...account.tag, - mlTestnetPrivKeyTag, - mlMainnetPrivKeyTag, - }, - walletsToCreate: [...currentWallets, walletToAdd], - }) - } - - return await Account.updateAccount(id, { - walletType: btcWalletType, - walletsToCreate: [...currentWallets, walletToAdd], - }) - } - - const onConnectSubmit = async (e) => { - e.preventDefault() - if (step === 1) { - setStep(2) - return - } - if (step === 2) { - const words = wordsFields.map((field) => field.value) - const mnemonic = words.join(' ') - const isValid = BTC.validateMnemonic(mnemonic) - if (!isValid) { - console.error('Invalid mnemonic') - setMnemonicErrorMessage('Invalid Seed Pharse, please try again') - return - } - setMnemonicErrorMessage('') - setStep(3) - return - } - try { - setAllowClosing(false) - const mnemonic = getMnemonics(wordlists) - const response = await connectWalletHandle( - accountID, - walletType, - mnemonic, - ) - if (response) { - const { addresses, name } = await Account.unlockAccount(accountID, pass) - setWalletInfo(addresses, accountID, name) - setOpenConnectConfirmation(false) - setPassValidity(true) - setPassErrorMessage('') - navigate('/') - } - return response - } catch (e) { - console.error(e) - setPassPristinity(false) - setPassValidity(false) - setPass('') - setPassErrorMessage('Incorrect password. Account could not be added.') - setAllowClosing(true) - } finally { - setAllowClosing(true) - } - } - return ( -
- - {step === 1 && ( - - -

- In order to add the wallet, we will ask you to enter your Seed - Phrase and password again. -

-

- We strongly recomend you to write down the same words you used - to create the account. -

-
-
- )} - {step === 2 && ( - -

- Please enter your 12 Seed Phrase -

- -
- )} - {step === 3 && ( - - )} - {mnemonicErrorMessage && } - - - -
-
- ) -} - -export default AddWallet diff --git a/src/components/composed/AddWallet/AddWallet.test.js b/src/components/composed/AddWallet/AddWallet.test.js deleted file mode 100644 index 1cd03c71..00000000 --- a/src/components/composed/AddWallet/AddWallet.test.js +++ /dev/null @@ -1,131 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react' -import { AccountContext } from '@Contexts' -import AddWallet from './AddWallet' -import { BrowserRouter } from 'react-router' - -describe('AddWallet', () => { - const mockContext = { - accountID: 'test-account-id', - setWalletInfo: jest.fn(), - } - - const mockPropsEmpty = { - account: { seed: {} }, - walletType: {}, - setAllowClosing: jest.fn(), - setOpenConnectConfirmation: jest.fn(), - } - - const mockProps = { - account: { - seed: { - encryptedMlTestnetPrivateKey: 'test-encrypted-ml-testnet-private-key', - }, - walletType: {}, - setAllowClosing: jest.fn(), - setOpenConnectConfirmation: jest.fn(), - }, - } - - const memoryRouterFeature = { - v7_startTransition: true, - v7_relativeSplatPath: true, - v7_partialHydration: true, - } - - test('renders without crashing', () => { - render( - - - - - , - ) - }) - - test('renders description paragraphs when step is 1', () => { - render( - - - - - , - ) - - const descriptionParagraphs = screen.getAllByTestId('description-paragraph') - - expect(descriptionParagraphs.length).toBe(2) - descriptionParagraphs.forEach((paragraph) => { - expect(paragraph).not.toBeEmptyDOMElement() - }) - }) - - test('renders InputList when step is 2', () => { - render( - - - - - , - ) - const submitButton = screen.getByText('Next') - fireEvent.click(submitButton) - const inputList = screen.getByTestId('inputs-list') - expect(inputList).toBeInTheDocument() - - const fields = screen.getAllByTestId('inputs-list-item') - expect(fields.length).toBe(12) - - const inputs = screen.getAllByTestId('input') - expect(inputs.length).toBe(12) - - inputs.forEach((input) => { - fireEvent.change(input, { target: { value: 'test' } }) - }) - - inputs.forEach((input) => { - expect(input.value).toBe('test') - expect(input).toHaveClass('valid') - }) - }) - - test('renders password field and submit button when step is 3', async () => { - render( - - - - - , - ) - - const label = screen.getByTestId('label') - const passwordField = screen.getByTestId('input') - - expect(label).toBeInTheDocument() - expect(label).toHaveTextContent('Enter your password') - - expect(passwordField).toBeInTheDocument() - - const submitButton = screen.getByTestId('button') - expect(submitButton).toBeInTheDocument() - expect(submitButton).toHaveTextContent('Add Wallet') - }) - - test('changes step on submit button click', () => { - render( - - - - - , - ) - - const submitButton = screen.getByText('Next') - fireEvent.click(submitButton) - - expect(submitButton).toBeInTheDocument() - - const inputList = screen.getByTestId('inputs-list') - expect(inputList).toBeInTheDocument() - }) -}) diff --git a/src/components/composed/index.js b/src/components/composed/index.js index fb97010f..5162a584 100644 --- a/src/components/composed/index.js +++ b/src/components/composed/index.js @@ -15,7 +15,6 @@ import FeeField from './FeeField/FeeField' import FeeFieldML from './FeeField/FeeFieldML' import ConnectionErrorPopup from './ConnectionErrorPopup/ConnectionErrorPopup' import WalletList from './WalletList/WalletList' -import AddWallet from './AddWallet/AddWallet' import CurrentStaking from './CurrentStaking/CurrentStaking' import HelpTooltip from './HelpTooltip/HelpTooltip' import RestoreSeedField from './RestoreSeedField/RestoreSeedField' @@ -52,7 +51,6 @@ export { FeeFieldML, ConnectionErrorPopup, WalletList, - AddWallet, CurrentStaking, HelpTooltip, RestoreSeedField, diff --git a/src/components/containers/Dashboard/CryptoList.js b/src/components/containers/Dashboard/CryptoList.js index 57e2aa82..5135c57a 100644 --- a/src/components/containers/Dashboard/CryptoList.js +++ b/src/components/containers/Dashboard/CryptoList.js @@ -102,39 +102,7 @@ export const CryptoItem = ({ onClickItem, item }) => { ) } -export const ConnectItem = ({ walletType, onClick }) => { - const { networkType } = useContext(SettingsContext) - const isDisabled = walletType.disabled - const symbol = - networkType === AppInfo.NETWORK_TYPES.MAINNET - ? walletType.symbol - : 'Testnet' - - const onItemClick = () => { - if (!isDisabled) onClick(walletType) - } - const message = isDisabled ? 'Coming soon' : 'Add wallet' - return ( -
  • - {walletType.name === 'Mintlayer' ? : } -
    -
    - {walletType.name} ({symbol}) -
    -
    -
    {message}
    -
  • - ) -} -const CryptoList = ({ cryptoList, onWalletItemClick, onConnectItemClick }) => { - const missingWalletTypes = AppInfo.walletTypes.filter( - (walletType) => - !cryptoList.find((crypto) => crypto.name === walletType.name), - ) +const CryptoList = ({ cryptoList, onWalletItemClick }) => { const coins = cryptoList.filter((crypto) => crypto.type !== 'token') const tokens = cryptoList.filter((crypto) => crypto.type === 'token') const showGroupTitles = tokens.some((token) => !token.isPlaceholder) @@ -153,14 +121,6 @@ const CryptoList = ({ cryptoList, onWalletItemClick, onConnectItemClick }) => { onClickItem={onWalletItemClick} /> ))} - - {missingWalletTypes.map((walletType) => ( - - ))} {tokens.length ? ( diff --git a/src/components/containers/Dashboard/CryptoList.test.js b/src/components/containers/Dashboard/CryptoList.test.js index f90fea49..afafc342 100644 --- a/src/components/containers/Dashboard/CryptoList.test.js +++ b/src/components/containers/Dashboard/CryptoList.test.js @@ -1,7 +1,7 @@ import React from 'react' import { render, fireEvent, screen } from '@testing-library/react' import { SettingsContext, MintlayerContext } from '@Contexts' -import { CryptoItem, ConnectItem } from './CryptoList' +import { CryptoItem } from './CryptoList' import CryptoList from './CryptoList' describe('CryptoItem', () => { @@ -104,93 +104,6 @@ describe('CryptoItem', () => { }) }) -describe('ConnectItem', () => { - const walletType = { - name: 'Bitcoin', - symbol: 'BTC', - disabled: false, - } - - const onClick = jest.fn() - - const renderComponent = (networkType) => - render( - - - - - , - ) - - it('renders the connect item correctly', () => { - renderComponent('mainnet') - - expect(screen.getByText('Bitcoin (BTC)')).toBeInTheDocument() - expect(screen.getByText('Add wallet')).toBeInTheDocument() - }) - - it('renders the Mintlayer logo for Mintlayer items', () => { - const mintlayerWalletType = { - ...walletType, - name: 'Mintlayer', - symbol: 'ML', - } - - render( - - - - - , - ) - - expect(screen.getByTestId('logo-round')).toBeInTheDocument() - }) - - it('calls the onClick callback when the item is clicked', () => { - renderComponent('mainnet') - - fireEvent.click(screen.getByText('Add wallet')) - - expect(onClick).toHaveBeenCalledWith(walletType) - }) - - it('does not disable the item for other wallet types on testnet', () => { - const otherWalletType = { - ...walletType, - name: 'Other', - symbol: 'OTH', - disabled: false, - } - - render( - - - - - , - ) - - expect(screen.getByText('Add wallet')).toBeInTheDocument() - expect(screen.getByTestId('connect-item')).not.toHaveClass('disabled') - }) -}) - describe('CryptoList', () => { const colorList = { btc: '#f7931a', @@ -227,7 +140,6 @@ describe('CryptoList', () => { // ] const onWalletItemClick = jest.fn() - const onConnectItemClick = jest.fn() //TDOO: enable this test when mainnet is ready // const renderComponent = (networkType) => @@ -238,7 +150,6 @@ describe('CryptoList', () => { // cryptoList={cryptoList} // colorList={colorList} // onWalletItemClick={onWalletItemClick} - // onConnectItemClick={onConnectItemClick} // /> // // , @@ -255,7 +166,6 @@ describe('CryptoList', () => { cryptoList={[]} colorList={colorList} onWalletItemClick={onWalletItemClick} - onConnectItemClick={onConnectItemClick} /> , @@ -283,14 +193,6 @@ describe('CryptoList', () => { // expect(onWalletItemClick).toHaveBeenCalledTimes(2) // }) - it('calls the onConnectItemClick callback when the add wallet item is clicked', () => { - renderEmptyComponent('mainnet') - - fireEvent.click(screen.getAllByText('Add wallet')[0]) - - expect(onConnectItemClick).toHaveBeenCalled() - }) - const coin = { id: 'Mintlayer', name: 'Mintlayer', @@ -322,7 +224,6 @@ describe('CryptoList', () => { cryptoList={list} colorList={colorList} onWalletItemClick={onWalletItemClick} - onConnectItemClick={onConnectItemClick} /> , diff --git a/src/pages/Dashboard/Dashboard.js b/src/pages/Dashboard/Dashboard.js index f75f5d63..63f1dbdc 100644 --- a/src/pages/Dashboard/Dashboard.js +++ b/src/pages/Dashboard/Dashboard.js @@ -1,8 +1,6 @@ /* eslint-disable max-params */ -import { useContext, useState, useEffect } from 'react' -import { PopUp, AddWallet } from '@ComposedComponents' +import { useContext } from 'react' import { AccountContext, SettingsContext } from '@Contexts' -import { Account } from '@Entities' import { useExchangeRates, @@ -21,14 +19,9 @@ import { BTC } from '@Helpers' import { AppInfo } from '@Constants' const DashboardPage = () => { - const { addresses, accountName, accountID } = useContext(AccountContext) + const { addresses, accountName } = useContext(AccountContext) const { networkType } = useContext(SettingsContext) - const [openConnectConfirmation, setOpenConnectConfirmation] = useState(false) - const [allowClosing, setAllowClosing] = useState(true) - const [account, setAccount] = useState(null) - - const [connectedWalletType, setConnectedWalletType] = useState('') const { balance: btcBalance, fetchingBalances: btcFetchingBalances, @@ -192,21 +185,6 @@ const DashboardPage = () => { navigate('/wallet/' + walletType.id) } - const onConnectItemClick = (walletType) => { - setConnectedWalletType(walletType) - setOpenConnectConfirmation(true) - setAllowClosing(true) - } - - const getCurrentAccount = async (accountID) => { - const currentAccount = await Account.getAccount(accountID) - return currentAccount - } - - useEffect(() => { - getCurrentAccount(accountID).then((account) => setAccount(account)) - }, [accountID]) - return (
    @@ -223,21 +201,7 @@ const DashboardPage = () => { - {openConnectConfirmation && ( - - - - )} ) } From 0f28ff38e2ad391aba7c046284e8b00fb8e5b205 Mon Sep 17 00:00:00 2001 From: owlsua Date: Sun, 23 Aug 2026 23:09:30 +0200 Subject: [PATCH 3/7] fix: run account migrations inside the upgrade transaction The migrations were fired un-awaited from onupgradeneeded and each re-opened the database from inside the versionchange transaction. They therefore resolved after the first account had already been saved and overwrote it. Each also issued its own getAll() before any of them wrote, so a later migration clobbered an earlier one's work. - migrations become pure account -> account transforms - they run inside event.target.transaction over a single read - oldVersion === 0 returns early; a brand-new database has nothing to migrate - only records the transforms actually changed are written back - SCHEMAVERSION derives from ACCOUNT_MIGRATIONS.length so the ladder and the version cannot drift apart --- src/services/Database/IndexedDB/IndexedDB.js | 26 +- .../Database/IndexedDB/IndexedDB.test.js | 51 ++++ .../Database/migrations/migrations.js | 76 +++-- .../Database/migrations/migrations.test.js | 272 ++++++++++++++++++ 4 files changed, 377 insertions(+), 48 deletions(-) create mode 100644 src/services/Database/migrations/migrations.test.js diff --git a/src/services/Database/IndexedDB/IndexedDB.js b/src/services/Database/IndexedDB/IndexedDB.js index df7311a3..0f1dcf0a 100644 --- a/src/services/Database/IndexedDB/IndexedDB.js +++ b/src/services/Database/IndexedDB/IndexedDB.js @@ -1,7 +1,7 @@ import { - accountsMigration_01_add_mlwallet_private_keys, - accountsMigration_02_add_htls_secrets_field, -} from '../migrations/migrations' + ACCOUNT_MIGRATIONS, + migrateAccount, +} from 'src/services/Database/migrations/migrations' const glob = typeof window !== 'undefined' ? window : self /* istanbul ignore next */ @@ -11,12 +11,13 @@ const IDB = glob.webkitIndexedDB || glob.msIndexedDB -const SCHEMAVERSION = 3 +const SCHEMAVERSION = ACCOUNT_MIGRATIONS.length + 1 const DATABASENAME = 'mojito' const ACCOUNTSSTORENAME = 'accounts' const createOrUpdateDatabase = (event) => { const db = event.target.result + const { oldVersion } = event if (!db.objectStoreNames.contains(ACCOUNTSSTORENAME)) { const objectStore = db.createObjectStore(ACCOUNTSSTORENAME, { @@ -27,9 +28,20 @@ const createOrUpdateDatabase = (event) => { // Create an index on the 'name' property objectStore.createIndex('name', 'name', { unique: false }) } - // Apply migrations here - accountsMigration_01_add_mlwallet_private_keys() - accountsMigration_02_add_htls_secrets_field() + + if (oldVersion === 0) return + + // Apply migrations here, inside the upgrade transaction + const store = event.target.transaction.objectStore(ACCOUNTSSTORENAME) + const request = store.getAll() + + request.onsuccess = () => { + request.result.forEach((account) => { + const migrated = migrateAccount(account, oldVersion) + + if (migrated !== account) store.put(migrated) + }) + } } const openDatabase = (DB = IDB) => { diff --git a/src/services/Database/IndexedDB/IndexedDB.test.js b/src/services/Database/IndexedDB/IndexedDB.test.js index a01b123c..eb01b907 100644 --- a/src/services/Database/IndexedDB/IndexedDB.test.js +++ b/src/services/Database/IndexedDB/IndexedDB.test.js @@ -44,6 +44,7 @@ test('IndexedDB basic functions - createOrUpdateDatabase', async () => { }) const event = { + oldVersion: 0, target: { result: { createObjectStore, @@ -55,6 +56,56 @@ test('IndexedDB basic functions - createOrUpdateDatabase', async () => { } createOrUpdateDatabase(event) + + expect(createObjectStore).toHaveBeenCalled() + expect(createIndex).toHaveBeenCalled() +}) + +test('IndexedDB basic functions - createOrUpdateDatabase skips migrations on a new database', () => { + const objectStore = jest.fn() + + createOrUpdateDatabase({ + oldVersion: 0, + target: { + result: { + createObjectStore: jest.fn(() => ({ createIndex: jest.fn() })), + objectStoreNames: { contains: jest.fn().mockReturnValue(false) }, + }, + transaction: { objectStore }, + }, + }) + + expect(objectStore).not.toHaveBeenCalled() +}) + +test('IndexedDB basic functions - createOrUpdateDatabase migrates an existing database', () => { + const request = {} + const put = jest.fn() + const getAll = jest.fn(() => request) + + createOrUpdateDatabase({ + oldVersion: 1, + target: { + result: { + createObjectStore: jest.fn(), + objectStoreNames: { contains: jest.fn().mockReturnValue(true) }, + }, + transaction: { objectStore: jest.fn(() => ({ getAll, put })) }, + }, + }) + + expect(getAll).toHaveBeenCalled() + + request.result = [{ id: 1, iv: 'iv', tag: 'tag', seed: 'seed' }] + request.onsuccess() + + expect(put).toHaveBeenCalledWith({ + id: 1, + iv: { btcIv: 'iv' }, + tag: { btcTag: 'tag' }, + seed: { btcEncryptedSeed: 'seed' }, + htlsSecrets: {}, + }) }) test('IndexedDB basic functions - openDatabase', async () => { diff --git a/src/services/Database/migrations/migrations.js b/src/services/Database/migrations/migrations.js index 3d28b1fa..d696a79c 100644 --- a/src/services/Database/migrations/migrations.js +++ b/src/services/Database/migrations/migrations.js @@ -1,51 +1,45 @@ -import { IndexedDB } from '@Databases' - -const accountsMigration_01_add_mlwallet_private_keys = async () => { - // Load the old accounts - const store = await IndexedDB.loadAccounts() - const accounts = await IndexedDB.getAll(store) - - // Transform the old accounts to the new structure - if (!accounts || accounts.length <= 0) return - const newAccounts = accounts.map((account) => { - // Add the encrypted private keys to the account - return { - ...account, - iv: { btcIv: account.iv }, - tag: { btcTag: account.tag }, - seed: { btcEncryptedSeed: account.seed }, - } - }) - - await IndexedDB.saveAccounts(newAccounts) +const accountsMigration_01_add_mlwallet_private_keys = (account) => ({ + ...account, + iv: { btcIv: account.iv }, + tag: { btcTag: account.tag }, + seed: { btcEncryptedSeed: account.seed }, +}) + +const accountsMigration_02_add_htls_secrets_field = (account) => { + const hasSecrets = + typeof account.htlsSecrets === 'object' && account.htlsSecrets !== null + + return hasSecrets ? account : { ...account, htlsSecrets: {} } } -const accountsMigration_02_add_htls_secrets_field = async () => { - const store = await IndexedDB.loadAccounts() - const accounts = await IndexedDB.getAll(store) +const unnest = (field, key) => + field && typeof field[key] === 'object' && field[key] !== null + ? field[key] + : field - if (!accounts || accounts.length <= 0) return - - const needsUpdate = accounts.some( - (account) => - account.htlsSecrets === undefined || account.htlsSecrets === null, - ) - if (!needsUpdate) return +const accountsMigration_03_repair_double_nesting = (account) => ({ + ...account, + iv: unnest(account.iv, 'btcIv'), + tag: unnest(account.tag, 'btcTag'), + seed: unnest(account.seed, 'btcEncryptedSeed'), +}) - const newAccounts = accounts.map((account) => ({ - ...account, - htlsSecrets: - account && - typeof account.htlsSecrets === 'object' && - account.htlsSecrets !== null - ? account.htlsSecrets - : {}, - })) +const ACCOUNT_MIGRATIONS = [ + accountsMigration_01_add_mlwallet_private_keys, + accountsMigration_02_add_htls_secrets_field, + accountsMigration_03_repair_double_nesting, +] - await IndexedDB.saveAccounts(newAccounts) -} +const migrateAccount = (account, oldVersion) => + ACCOUNT_MIGRATIONS.slice(oldVersion - 1).reduce( + (migrated, migrate) => migrate(migrated), + account, + ) export { accountsMigration_01_add_mlwallet_private_keys, accountsMigration_02_add_htls_secrets_field, + accountsMigration_03_repair_double_nesting, + ACCOUNT_MIGRATIONS, + migrateAccount, } diff --git a/src/services/Database/migrations/migrations.test.js b/src/services/Database/migrations/migrations.test.js new file mode 100644 index 00000000..8882838b --- /dev/null +++ b/src/services/Database/migrations/migrations.test.js @@ -0,0 +1,272 @@ +import { + DATABASENAME, + ACCOUNTSSTORENAME, + SCHEMAVERSION, + loadAccounts, + getAll, + save, +} from 'src/services/Database/IndexedDB/IndexedDB' +import { ACCOUNT_MIGRATIONS, migrateAccount } from './migrations' + +const deleteDatabase = () => + new Promise((resolve) => { + const request = indexedDB.deleteDatabase(DATABASENAME) + request.onsuccess = resolve + request.onerror = resolve + request.onblocked = resolve + }) + +const seedDatabaseAtVersion = (version, accounts) => + new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASENAME, version) + + request.onupgradeneeded = (event) => { + const db = event.target.result + + if (!db.objectStoreNames.contains(ACCOUNTSSTORENAME)) { + const objectStore = db.createObjectStore(ACCOUNTSSTORENAME, { + keyPath: 'id', + autoIncrement: true, + }) + objectStore.createIndex('name', 'name', { unique: false }) + } + + const store = event.target.transaction.objectStore(ACCOUNTSSTORENAME) + accounts.forEach((account) => store.put(account)) + } + + request.onsuccess = (event) => { + event.target.result.close() + resolve() + } + request.onerror = reject + }) + +const readAccounts = async () => getAll(await loadAccounts()) + +beforeEach(deleteDatabase) +afterAll(deleteDatabase) + +test('migrations - a v1 account is reshaped and gains htlsSecrets', async () => { + await seedDatabaseAtVersion(1, [ + { + id: 1, + name: 'Legacy', + salt: 'aabb', + iv: 'flat-iv', + tag: 'flat-tag', + seed: 'flat-seed', + }, + ]) + + const [account] = await readAccounts() + + expect(account.iv).toStrictEqual({ btcIv: 'flat-iv' }) + expect(account.tag).toStrictEqual({ btcTag: 'flat-tag' }) + expect(account.seed).toStrictEqual({ btcEncryptedSeed: 'flat-seed' }) + expect(account.htlsSecrets).toStrictEqual({}) + expect(account.name).toBe('Legacy') +}) + +test('migrations - a v2 account only gains htlsSecrets and is not reshaped again', async () => { + const nested = { + id: 1, + name: 'Nested', + iv: { btcIv: 'iv', mlTestnetPrivKeyIv: 'iv2', mlMainnetPrivKeyIv: 'iv3' }, + tag: { + btcTag: 'tag', + mlTestnetPrivKeyTag: 'tag2', + mlMainnetPrivKeyTag: 'tag3', + }, + seed: { + btcEncryptedSeed: 'seed', + encryptedMlTestnetPrivateKey: 'ml-test', + encryptedMlMainnetPrivateKey: 'ml-main', + }, + } + + await seedDatabaseAtVersion(2, [nested]) + + const [account] = await readAccounts() + + expect(account.iv).toStrictEqual(nested.iv) + expect(account.tag).toStrictEqual(nested.tag) + expect(account.seed).toStrictEqual(nested.seed) + expect(account.htlsSecrets).toStrictEqual({}) +}) + +test('migrations - existing htlsSecrets are preserved', async () => { + const secrets = { + abc: { encryptedHtlsSecret: 'x', htlsIv: 'y', htlsTag: 'z' }, + } + + await seedDatabaseAtVersion(2, [ + { + id: 1, + name: 'WithSecrets', + iv: { btcIv: 'iv' }, + tag: { btcTag: 'tag' }, + seed: { btcEncryptedSeed: 'seed' }, + htlsSecrets: secrets, + }, + ]) + + const [account] = await readAccounts() + + expect(account.htlsSecrets).toStrictEqual(secrets) +}) + +test('migrations - do not touch an account saved into a freshly created database', async () => { + const account = { + name: 'Fresh', + salt: 'aabb', + encryptionVersion: 3, + iv: { btcIv: 'iv', mlTestnetPrivKeyIv: 'iv2', mlMainnetPrivKeyIv: 'iv3' }, + tag: { + btcTag: 'tag', + mlTestnetPrivKeyTag: 'tag2', + mlMainnetPrivKeyTag: 'tag3', + }, + seed: { + btcEncryptedSeed: 'seed', + encryptedMlTestnetPrivateKey: 'ml-test', + encryptedMlMainnetPrivateKey: 'ml-main', + }, + htlsSecrets: {}, + } + + await save(await loadAccounts(), account) + await new Promise((resolve) => setTimeout(resolve, 50)) + + const [stored] = await readAccounts() + + expect(stored.seed).toStrictEqual(account.seed) + expect(stored.iv).toStrictEqual(account.iv) + expect(stored.tag).toStrictEqual(account.tag) +}) + +test('migrations - a v1 database with no accounts upgrades cleanly', async () => { + await seedDatabaseAtVersion(1, []) + + await expect(readAccounts()).resolves.toStrictEqual([]) +}) + +test('migrations - a record double-nested by the old migration is repaired', async () => { + const inner = { + btcIv: 'iv', + mlTestnetPrivKeyIv: 'iv2', + mlMainnetPrivKeyIv: 'iv3', + } + const innerTag = { + btcTag: 'tag', + mlTestnetPrivKeyTag: 'tag2', + mlMainnetPrivKeyTag: 'tag3', + } + const innerSeed = { + btcEncryptedSeed: 'seed', + encryptedMlTestnetPrivateKey: 'ml-test', + encryptedMlMainnetPrivateKey: 'ml-main', + } + + await seedDatabaseAtVersion(3, [ + { + id: 1, + name: 'Corrupted', + encryptionVersion: 3, + iv: { btcIv: inner }, + tag: { btcTag: innerTag }, + seed: { btcEncryptedSeed: innerSeed }, + htlsSecrets: {}, + }, + ]) + + const [account] = await readAccounts() + + expect(account.iv).toStrictEqual(inner) + expect(account.tag).toStrictEqual(innerTag) + expect(account.seed).toStrictEqual(innerSeed) +}) + +test('migrations - a healthy record is not changed by the repair', async () => { + const healthy = { + id: 1, + name: 'Healthy', + encryptionVersion: 3, + iv: { btcIv: 'iv', mlTestnetPrivKeyIv: 'iv2', mlMainnetPrivKeyIv: 'iv3' }, + tag: { + btcTag: 'tag', + mlTestnetPrivKeyTag: 'tag2', + mlMainnetPrivKeyTag: 'tag3', + }, + seed: { + btcEncryptedSeed: 'seed', + encryptedMlTestnetPrivateKey: 'ml-test', + encryptedMlMainnetPrivateKey: 'ml-main', + }, + htlsSecrets: {}, + } + + await seedDatabaseAtVersion(3, [healthy]) + + const [account] = await readAccounts() + + expect(account).toStrictEqual(healthy) +}) + +test('migrations - a v4 envelope record survives the schema upgrade untouched', async () => { + const envelope = { + id: 1, + name: 'Envelope', + salt: 'aabb', + encryptionVersion: 4, + wrappedDek: { + password: { encryptedData: 'w', iv: 'wi', tag: 'wt' }, + passkeys: [], + }, + iv: { btcIv: 'iv', mlTestnetPrivKeyIv: 'iv2', mlMainnetPrivKeyIv: 'iv3' }, + tag: { + btcTag: 'tag', + mlTestnetPrivKeyTag: 'tag2', + mlMainnetPrivKeyTag: 'tag3', + }, + seed: { + btcEncryptedSeed: 'seed', + encryptedMlTestnetPrivateKey: 'ml-test', + encryptedMlMainnetPrivateKey: 'ml-main', + }, + htlsSecrets: {}, + } + + await seedDatabaseAtVersion(3, [envelope]) + + const [account] = await readAccounts() + + expect(account).toStrictEqual(envelope) +}) + +test('migrations - the schema version and the ladder cannot drift apart', () => { + expect(SCHEMAVERSION).toBe(ACCOUNT_MIGRATIONS.length + 1) +}) + +test('migrations - an account only runs the migrations above its own version', () => { + const v3Account = { + seed: { btcEncryptedSeed: 'seed' }, + iv: { btcIv: 'iv' }, + tag: { btcTag: 'tag' }, + htlsSecrets: { abc: {} }, + } + + expect(migrateAccount(v3Account, 3)).toStrictEqual(v3Account) + expect(migrateAccount(v3Account, 4)).toBe(v3Account) +}) + +test('migrations - a flat v1 account walks the whole ladder', () => { + const migrated = migrateAccount({ seed: 'blob', iv: 'iv', tag: 'tag' }, 1) + + expect(migrated).toStrictEqual({ + seed: { btcEncryptedSeed: 'blob' }, + iv: { btcIv: 'iv' }, + tag: { btcTag: 'tag' }, + htlsSecrets: {}, + }) +}) From 39d263a1a7130587bd3fef6468a6787202c2c1d5 Mon Sep 17 00:00:00 2001 From: owlsua Date: Sun, 23 Aug 2026 23:10:12 +0200 Subject: [PATCH 4/7] fix: give the workers a real error contract A worker reported failure by posting { error }, and the caller resolved with that object instead of rejecting. Mintlayer API payloads legitimately carry an "error" field, so the marker is now __workerError, and an unknown job answers instead of leaving the promise pending forever. runJob also forwards the whole payload to the worker rather than re-listing the fields it expects. Re-listing is how an argument gets silently dropped, and the direct-import path used by jest never sees it. --- src/services/Crypto/BTC/BTC.worker.js | 19 +-- src/services/Crypto/Cipher/Cipher.worker.js | 24 +--- src/services/Crypto/Worker/WorkerContract.js | 23 ++++ .../Crypto/Worker/WorkerContract.test.js | 81 +++++++++++ src/services/Entity/Account/Account.worker.js | 103 ++++++-------- .../Entity/Account/Account.worker.test.js | 129 ++++++++++++++++++ 6 files changed, 278 insertions(+), 101 deletions(-) create mode 100644 src/services/Crypto/Worker/WorkerContract.js create mode 100644 src/services/Crypto/Worker/WorkerContract.test.js create mode 100644 src/services/Entity/Account/Account.worker.test.js diff --git a/src/services/Crypto/BTC/BTC.worker.js b/src/services/Crypto/BTC/BTC.worker.js index c7e7e662..25433d21 100644 --- a/src/services/Crypto/BTC/BTC.worker.js +++ b/src/services/Crypto/BTC/BTC.worker.js @@ -1,27 +1,14 @@ import { generateMnemonic, getSeedFromMnemonic } from './BTC' +import { registerWorkerJobs } from 'src/services/Crypto/Worker/WorkerContract' const WalletWorkerEnum = { GENERATE_MNEMONIC: 'GENERATE_MNEMONIC', GET_SEED_FROM_MNEMONIC: 'GET_SEED_FROM_MNEMONIC', } -const WalletWorkerJobs = { +registerWorkerJobs({ GENERATE_MNEMONIC: generateMnemonic, GET_SEED_FROM_MNEMONIC: getSeedFromMnemonic, -} - -const isValidJob = (choosenJob) => { - if (!choosenJob) return false - return Object.hasOwn(WalletWorkerJobs, choosenJob) -} - -self.onmessage = ({ data }) => { - if (!isValidJob(data.job)) return false - - const jobResult = WalletWorkerJobs[data.job](data.data) - postMessage(jobResult) - - return true -} +}) export { WalletWorkerEnum } diff --git a/src/services/Crypto/Cipher/Cipher.worker.js b/src/services/Crypto/Cipher/Cipher.worker.js index d5f2b6bf..f7a75bcd 100644 --- a/src/services/Crypto/Cipher/Cipher.worker.js +++ b/src/services/Crypto/Cipher/Cipher.worker.js @@ -1,4 +1,5 @@ import { generatePBKDF2Key, encryptAES, decryptAES } from './Cipher' +import { registerWorkerJobs } from 'src/services/Crypto/Worker/WorkerContract' const CipherWorkerEnum = { GENERATE_PBKDF2_KEY: 'GENERATE_PBKDF2_KEY', @@ -6,29 +7,10 @@ const CipherWorkerEnum = { DECRYPT_AES: 'DECRYPT_AES', } -const CipherWorkerJobs = { +registerWorkerJobs({ GENERATE_PBKDF2_KEY: generatePBKDF2Key, ENCRYPT_AES: encryptAES, DECRYPT_AES: decryptAES, -} - -const isValidJob = (choosenJob) => { - if (!choosenJob) return false - return Object.hasOwn(CipherWorkerJobs, choosenJob) -} - -self.onmessage = async ({ data }) => { - if (!isValidJob(data.job)) return false - - try { - const jobResult = await CipherWorkerJobs[data.job](data.data) - - postMessage(jobResult) - - return true - } catch (error) { - postMessage({ error: error.message }) - } -} +}) export { CipherWorkerEnum } diff --git a/src/services/Crypto/Worker/WorkerContract.js b/src/services/Crypto/Worker/WorkerContract.js new file mode 100644 index 00000000..955e2ea1 --- /dev/null +++ b/src/services/Crypto/Worker/WorkerContract.js @@ -0,0 +1,23 @@ +const WORKER_ERROR = '__workerError' + +const toWorkerError = (error) => ({ [WORKER_ERROR]: error.message }) + +const getWorkerError = (data) => + typeof data === 'object' && data !== null ? data[WORKER_ERROR] : undefined + +const registerWorkerJobs = (jobs) => { + self.onmessage = async ({ data }) => { + const name = data?.job + + if (!name || !Object.hasOwn(jobs, name)) + return postMessage(toWorkerError(new Error(`Unknown job: ${name}`))) + + try { + postMessage(await jobs[name](data.data)) + } catch (error) { + postMessage(toWorkerError(error)) + } + } +} + +export { WORKER_ERROR, toWorkerError, getWorkerError, registerWorkerJobs } diff --git a/src/services/Crypto/Worker/WorkerContract.test.js b/src/services/Crypto/Worker/WorkerContract.test.js new file mode 100644 index 00000000..25f88997 --- /dev/null +++ b/src/services/Crypto/Worker/WorkerContract.test.js @@ -0,0 +1,81 @@ +import { WORKER_ERROR, toWorkerError, getWorkerError } from './WorkerContract' + +test('WorkerContract - toWorkerError carries the message under the marker', () => { + const envelope = toWorkerError(new Error('Incorrect password')) + + expect(envelope).toStrictEqual({ [WORKER_ERROR]: 'Incorrect password' }) + expect(getWorkerError(envelope)).toBe('Incorrect password') +}) + +test('WorkerContract - real job results are never mistaken for errors', () => { + const results = [ + { key: [1, 2, 3], salt: 'aabb' }, + { encryptedData: 'x', iv: 'y', tag: 'z' }, + new Uint8Array([1, 2, 3]), + [1, 2, 3], + 'a mnemonic phrase', + 0, + null, + undefined, + ] + + results.forEach((result) => expect(getWorkerError(result)).toBeUndefined()) +}) + +test('WorkerContract - a legacy payload with a plain error field is not an error envelope', () => { + expect(getWorkerError({ error: 'Incorrect password' })).toBeUndefined() +}) + +describe('the cipher worker answers every message', () => { + const reply = (message) => + new Promise((resolve) => { + const post = globalThis.postMessage + Object.defineProperty(globalThis, 'postMessage', { + configurable: true, + writable: true, + value: (data) => { + Object.defineProperty(globalThis, 'postMessage', { + configurable: true, + writable: true, + value: post, + }) + resolve(data) + }, + }) + + self.onmessage({ data: message }) + }) + + beforeAll(() => require('src/services/Crypto/Cipher/Cipher.worker')) + + test('a valid job posts its result', async () => { + const result = await reply({ + job: 'GENERATE_PBKDF2_KEY', + data: { password: 'p', salt: 'aabb', version: 1 }, + }) + + expect(getWorkerError(result)).toBeUndefined() + expect(result.key.length).toBe(16) + }) + + test('a failing job posts an error envelope', async () => { + const result = await reply({ + job: 'DECRYPT_AES', + data: { data: 'x', key: new Array(32).fill(1), iv: 'i', tag: 't' }, + }) + + expect(getWorkerError(result)).toBe('Incorrect password') + }) + + test('an unknown job posts an error envelope instead of hanging', async () => { + const result = await reply({ job: 'NOPE' }) + + expect(getWorkerError(result)).toBe('Unknown job: NOPE') + }) + + test('a missing job posts an error envelope', async () => { + const result = await reply({}) + + expect(getWorkerError(result)).toBe('Unknown job: undefined') + }) +}) diff --git a/src/services/Entity/Account/Account.worker.js b/src/services/Entity/Account/Account.worker.js index 96f527fc..fb3a3b39 100644 --- a/src/services/Entity/Account/Account.worker.js +++ b/src/services/Entity/Account/Account.worker.js @@ -1,89 +1,64 @@ -import { CipherWorkerEnum } from '../../Crypto/Cipher/Cipher.worker' -import { WalletWorkerEnum } from '../../Crypto/BTC/BTC.worker' +import { CipherWorkerEnum } from 'src/services/Crypto/Cipher/Cipher.worker' +import { WalletWorkerEnum } from 'src/services/Crypto/BTC/BTC.worker' +import { getWorkerError } from 'src/services/Crypto/Worker/WorkerContract' const getWalletWorker = () => new Worker(new URL('../../Crypto/BTC/BTC.worker', import.meta.url)) const getCipherWorker = () => new Worker(new URL('../../Crypto/Cipher/Cipher.worker', import.meta.url)) -const generateNewAccountMnemonic = () => { - return new Promise((resolve) => { - const worker = getWalletWorker() - worker.postMessage({ - job: WalletWorkerEnum.GENERATE_MNEMONIC, - }) +const runJob = (createWorker, message) => + new Promise((resolve, reject) => { + const worker = createWorker() + worker.onmessage = ({ data }) => { worker.terminate() + + const error = getWorkerError(data) + if (error) return reject(new Error(error)) + resolve(data) } - }) -} -const generateSeed = (mnemonic) => { - return new Promise((resolve) => { - const worker = getWalletWorker() - worker.postMessage({ - job: WalletWorkerEnum.GET_SEED_FROM_MNEMONIC, - data: mnemonic, - }) - worker.onmessage = ({ data }) => { + worker.onerror = (event) => { worker.terminate() - resolve(data) + reject(new Error(event.message || 'Worker failed')) } + + worker.postMessage(message) }) -} -const generateEncryptionKey = async (password) => { - return new Promise((resolve) => { - const worker = getCipherWorker() - worker.postMessage({ - job: CipherWorkerEnum.GENERATE_PBKDF2_KEY, - data: password, - }) - worker.onmessage = ({ data }) => { - worker.terminate() - resolve(data) - } +const generateNewAccountMnemonic = () => + runJob(getWalletWorker, { job: WalletWorkerEnum.GENERATE_MNEMONIC }) + +const generateSeed = (mnemonic) => + runJob(getWalletWorker, { + job: WalletWorkerEnum.GET_SEED_FROM_MNEMONIC, + data: mnemonic, }) -} -const encryptSeed = async ({ data, key }) => { - return new Promise((resolve) => { - const worker = getCipherWorker() - worker.postMessage({ - job: CipherWorkerEnum.ENCRYPT_AES, - data: { - data, - key, - }, - }) - worker.onmessage = ({ data }) => { - worker.terminate() - resolve(data) - } +// These forward their whole payload on purpose: re-listing the fields here is how +// the aad argument was silently dropped before. +const generateEncryptionKey = async (payload) => + runJob(getCipherWorker, { + job: CipherWorkerEnum.GENERATE_PBKDF2_KEY, + data: payload, }) -} -const decryptSeed = async ({ data, key, iv, tag }) => { - return new Promise((resolve) => { - const worker = getCipherWorker() - worker.postMessage({ - job: CipherWorkerEnum.DECRYPT_AES, - data: { - data, - key, - iv, - tag, - }, - }) - worker.onmessage = ({ data }) => { - worker.terminate() - resolve(data) - } +const encryptSeed = async (payload) => + runJob(getCipherWorker, { + job: CipherWorkerEnum.ENCRYPT_AES, + data: payload, + }) + +const decryptSeed = async (payload) => + runJob(getCipherWorker, { + job: CipherWorkerEnum.DECRYPT_AES, + data: payload, }) -} export { + runJob, generateNewAccountMnemonic, generateSeed, generateEncryptionKey, diff --git a/src/services/Entity/Account/Account.worker.test.js b/src/services/Entity/Account/Account.worker.test.js new file mode 100644 index 00000000..46fb1dd6 --- /dev/null +++ b/src/services/Entity/Account/Account.worker.test.js @@ -0,0 +1,129 @@ +import { toWorkerError } from 'src/services/Crypto/Worker/WorkerContract' +import { runJob } from './Account.worker' + +const fakeWorker = (respond) => { + const worker = { + terminate: jest.fn(), + postMessage: jest.fn(() => { + Promise.resolve().then(() => respond(worker)) + }), + } + + return worker +} + +test('Account.worker - resolves with the payload the worker posts', async () => { + const payload = { key: [1, 2, 3], salt: 'aabb' } + const worker = fakeWorker((w) => w.onmessage({ data: payload })) + + await expect(runJob(() => worker, { job: 'ANY' })).resolves.toStrictEqual( + payload, + ) + expect(worker.postMessage).toHaveBeenCalledWith({ job: 'ANY' }) + expect(worker.terminate).toHaveBeenCalled() +}) + +test('Account.worker - rejects when the worker posts an error envelope', async () => { + const worker = fakeWorker((w) => + w.onmessage({ data: toWorkerError(new Error('Incorrect password')) }), + ) + + await expect(runJob(() => worker, { job: 'ANY' })).rejects.toThrow( + 'Incorrect password', + ) + expect(worker.terminate).toHaveBeenCalled() +}) + +test('Account.worker - rejects when the worker itself fails', async () => { + const worker = fakeWorker((w) => w.onerror({ message: 'boom' })) + + await expect(runJob(() => worker, { job: 'ANY' })).rejects.toThrow('boom') + expect(worker.terminate).toHaveBeenCalled() +}) + +test('Account.worker - rejects with a fallback message when the failure has none', async () => { + const worker = fakeWorker((w) => w.onerror({})) + + await expect(runJob(() => worker, { job: 'ANY' })).rejects.toThrow( + 'Worker failed', + ) +}) + +describe('the job builders forward every field they are given', () => { + let posted + let worker + + beforeEach(async () => { + posted = [] + worker = { + terminate: jest.fn(), + postMessage: (message) => { + posted.push(message) + Promise.resolve().then(() => worker.onmessage({ data: 'ok' })) + }, + } + global.Worker = jest.fn(() => worker) + }) + + afterEach(() => { + delete global.Worker + }) + + test('encryptSeed carries the additional data', async () => { + const { encryptSeed } = await import('./Account.worker') + + await encryptSeed({ + data: 'plain', + key: [1, 2], + aad: 'mojito/v4/content/x', + }) + + expect(posted[0]).toStrictEqual({ + job: 'ENCRYPT_AES', + data: { data: 'plain', key: [1, 2], aad: 'mojito/v4/content/x' }, + }) + }) + + test('decryptSeed carries the additional data', async () => { + const { decryptSeed } = await import('./Account.worker') + + await decryptSeed({ + data: 'cipher', + key: [1, 2], + iv: 'iv', + tag: 'tag', + aad: 'mojito/v4/content/x', + }) + + expect(posted[0]).toStrictEqual({ + job: 'DECRYPT_AES', + data: { + data: 'cipher', + key: [1, 2], + iv: 'iv', + tag: 'tag', + aad: 'mojito/v4/content/x', + }, + }) + }) + + test('generateEncryptionKey carries salt and version', async () => { + const { generateEncryptionKey } = await import('./Account.worker') + + await generateEncryptionKey({ password: 'p', salt: 'aabb', version: 4 }) + + expect(posted[0]).toStrictEqual({ + job: 'GENERATE_PBKDF2_KEY', + data: { password: 'p', salt: 'aabb', version: 4 }, + }) + }) + + test('every cipher payload field reaches the worker untouched', async () => { + const { encryptSeed } = await import('./Account.worker') + const payload = { data: 'a', key: [1], aad: 'b', future: 'c' } + + await encryptSeed(payload) + + expect(posted[0].data).toStrictEqual(payload) + }) +}) From 81960e95b1a3b673b27fbff678bfc6fa3ca7ac4d Mon Sep 17 00:00:00 2001 From: owlsua Date: Sun, 23 Aug 2026 23:10:57 +0200 Subject: [PATCH 5/7] feat: encrypt accounts with a v4 key envelope A random 32-byte DEK encrypts the content. Each credential that may open the wallet wraps that DEK separately: the password through PBKDF2-SHA512, and each passkey through a WebAuthn PRF output run into HKDF-SHA256. Enrolling a credential adds 92 bytes and never touches the seed. Every AES-GCM operation is bound with additionalData, so a blob cannot be moved into another field, a password wrapper cannot be opened as a passkey wrapper, and an externally supplied HTLC hash cannot collide with an internal field name. Those strings and the KDF parameters are a storage format and are pinned by known-answer tests. Versions 1 to 3 keep opening: getContentKey returns the password key and the aad helper returns undefined below version 4. Migration happens on a successful unlock, under the account lock, with the version re-checked, and is swallowed on failure - an outdated format beats a wallet that will not open. Accounts without mintlayer keys no longer fail to unlock. --- src/services/Crypto/Cipher/Cipher.js | 94 +- src/services/Crypto/Cipher/Cipher.test.js | 534 ++++++++++- src/services/Crypto/Passkey/Passkey.js | 132 +++ src/services/Crypto/Passkey/Passkey.test.js | 279 ++++++ src/services/Crypto/index.js | 2 + src/services/Entity/Account/Account.js | 434 +++++++-- src/services/Entity/Account/Account.test.js | 855 ++++++++++++++++-- .../Entity/Account/Account.workerPath.test.js | 233 +++++ src/services/Entity/Account/AccountHelpers.js | 71 +- .../Entity/Account/AccountHelpers.test.js | 345 +++++++ .../Entity/Account/AccountPasskey.test.js | 359 ++++++++ 11 files changed, 3152 insertions(+), 186 deletions(-) create mode 100644 src/services/Crypto/Passkey/Passkey.js create mode 100644 src/services/Crypto/Passkey/Passkey.test.js create mode 100644 src/services/Entity/Account/Account.workerPath.test.js create mode 100644 src/services/Entity/Account/AccountHelpers.test.js create mode 100644 src/services/Entity/Account/AccountPasskey.test.js diff --git a/src/services/Crypto/Cipher/Cipher.js b/src/services/Crypto/Cipher/Cipher.js index 4e9b2229..484a814b 100644 --- a/src/services/Crypto/Cipher/Cipher.js +++ b/src/services/Crypto/Cipher/Cipher.js @@ -1,11 +1,14 @@ // keySize is in bytes: 16 => AES-128, 32 => AES-256. // V1/V2 must keep keySize 16 so already-stored data stays decryptable. +// V4 shares V3's KDF parameters; it marks the envelope record format, not a new KDF. const V1 = { iterations: 10000, keySize: 16 } const V2 = { ...V1, iterations: 600000 } const V3 = { ...V2, keySize: 32 } +const V4 = { ...V3 } -const CURRENT_ENCRYPTION_VERSION = 3 -const ENCRYPTION_VERSIONS = { 1: V1, 2: V2, 3: V3 } +const CURRENT_ENCRYPTION_VERSION = 4 +const ENVELOPE_ENCRYPTION_VERSION = 4 +const ENCRYPTION_VERSIONS = { 1: V1, 2: V2, 3: V3, 4: V4 } const getVersionConfig = (version) => { const config = ENCRYPTION_VERSIONS[version] @@ -15,6 +18,14 @@ const getVersionConfig = (version) => { const IVSIZE = 12 const SALTSIZE = 16 +const DEKSIZE = 32 + +const PASSKEY_KDF = 'HKDF-SHA256' + +const contentAad = (field) => `mojito/v4/content/${field}` +const wrapperAad = (wrapperId) => `mojito/v4/dek/${wrapperId}` +const htlsAad = (hash) => `mojito/v4/htls/${hash}` +const PASSKEY_KEK_INFO = 'mojito/v1/passkey-kek' const hexToBytes = (hexString) => { const pairs = hexString?.match(/.{1,2}/g) @@ -70,7 +81,12 @@ const generatePBKDF2Key = async ({ const generateIV = async () => crypto.getRandomValues(new Uint8Array(IVSIZE)) -const encryptAES = async ({ data, key }) => { +const gcmParams = (iv, aad) => + aad + ? { name: 'AES-GCM', iv, additionalData: new TextEncoder().encode(aad) } + : { name: 'AES-GCM', iv } + +const encryptAES = async ({ data, key, aad }) => { const iv = await generateIV() const hex = Buffer.from(data).toString('hex') @@ -83,7 +99,7 @@ const encryptAES = async ({ data, key }) => { ) const encrypted = await crypto.subtle.encrypt( - { name: 'AES-GCM', iv }, + gcmParams(iv, aad), cryptoKey, new TextEncoder().encode(hex), ) @@ -96,7 +112,7 @@ const encryptAES = async ({ data, key }) => { } } -const decryptAES = async ({ data, key, iv, tag }) => { +const decryptAES = async ({ data, key, iv, tag, aad }) => { const cryptoKey = await crypto.subtle.importKey( 'raw', new Uint8Array(key), @@ -113,7 +129,7 @@ const decryptAES = async ({ data, key, iv, tag }) => { try { const decrypted = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: binaryStringToBytes(iv) }, + gcmParams(binaryStringToBytes(iv), aad), cryptoKey, combined, ) @@ -123,10 +139,76 @@ const decryptAES = async ({ data, key, iv, tag }) => { } } +const generateDek = async () => [ + ...crypto.getRandomValues(new Uint8Array(DEKSIZE)), +] + +const isKeyBytes = (key) => + (ArrayBuffer.isView(key) || Array.isArray(key)) && + key.length === DEKSIZE && + Array.prototype.every.call( + key, + (byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255, + ) + +const assertKeySize = (key, message) => { + if (!isKeyBytes(key)) throw new Error(message) +} + +const deriveKekFromPrf = async (prfOutput) => { + assertKeySize(prfOutput, 'Invalid PRF output') + + const ikm = await crypto.subtle.importKey( + 'raw', + new Uint8Array(prfOutput), + 'HKDF', + false, + ['deriveBits'], + ) + + const derivedBits = await crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(), + info: new TextEncoder().encode(PASSKEY_KEK_INFO), + }, + ikm, + DEKSIZE * 8, + ) + + return [...new Uint8Array(derivedBits)] +} + +const wrapDek = async ({ dek, wrappingKey, aad }) => { + assertKeySize(dek, 'Invalid data encryption key') + assertKeySize(wrappingKey, 'Invalid wrapping key') + return encryptAES({ data: new Uint8Array(dek), key: wrappingKey, aad }) +} + +const unwrapDek = async ({ data, iv, tag, wrappingKey, aad }) => { + assertKeySize(wrappingKey, 'Invalid wrapping key') + if (!data || !iv || !tag) throw new Error('Missing key wrapper') + + const dek = await decryptAES({ data, iv, tag, key: wrappingKey, aad }) + assertKeySize(dek, 'Invalid data encryption key') + return [...dek] +} + export { IVSIZE, + DEKSIZE, + PASSKEY_KDF, + contentAad, + wrapperAad, + htlsAad, CURRENT_ENCRYPTION_VERSION, + ENVELOPE_ENCRYPTION_VERSION, ENCRYPTION_VERSIONS, + generateDek, + wrapDek, + unwrapDek, + deriveKekFromPrf, getVersionConfig, generateSalt, generatePBKDF2Key, diff --git a/src/services/Crypto/Cipher/Cipher.test.js b/src/services/Crypto/Cipher/Cipher.test.js index 54398014..27b69a46 100644 --- a/src/services/Crypto/Cipher/Cipher.test.js +++ b/src/services/Crypto/Cipher/Cipher.test.js @@ -6,9 +6,18 @@ import { decryptAES, hexToBytes, IVSIZE, + DEKSIZE, CURRENT_ENCRYPTION_VERSION, + ENVELOPE_ENCRYPTION_VERSION, ENCRYPTION_VERSIONS, getVersionConfig, + generateDek, + wrapDek, + unwrapDek, + deriveKekFromPrf, + contentAad, + wrapperAad, + htlsAad, } from './Cipher' test('Cipher - HEX to Bytes', () => { @@ -335,7 +344,7 @@ test('Cipher - decryptAES tampered tag throws', async () => { }) test('Cipher - constants are correct', () => { - expect(CURRENT_ENCRYPTION_VERSION).toBe(3) + expect(CURRENT_ENCRYPTION_VERSION).toBe(4) expect(getVersionConfig(1).iterations).toBe(10000) expect(getVersionConfig(2).iterations).toBe(600000) expect(getVersionConfig(3).iterations).toBe(600000) @@ -718,3 +727,526 @@ test('Cipher - migration v1 -> v3 upgrades to AES-256 and preserves data', async expect(fromKey.length).toBe(16) // AES-128 expect(toKey.length).toBe(32) // AES-256 }) + +test('Cipher - v4 shares v3 KDF parameters', () => { + expect(ENVELOPE_ENCRYPTION_VERSION).toBe(4) + expect(getVersionConfig(4)).toStrictEqual(getVersionConfig(3)) +}) + +test('Cipher - generateDek returns a fresh 32 byte key', async () => { + const dek1 = await generateDek() + const dek2 = await generateDek() + + expect(dek1.length).toBe(DEKSIZE) + expect(Array.isArray(dek1)).toBe(true) + expect(dek1.every((byte) => byte >= 0 && byte <= 255)).toBe(true) + expect(dek1).not.toStrictEqual(dek2) +}) + +test('Cipher - wrapDek / unwrapDek round trip', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + + const wrapped = await wrapDek({ dek, wrappingKey }) + + expect(wrapped.encryptedData).toBeDefined() + expect(wrapped.iv.length).toBe(IVSIZE) + expect(wrapped.tag.length).toBe(16) + + const unwrapped = await unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey, + }) + + expect(unwrapped).toStrictEqual(dek) +}) + +test('Cipher - the same DEK can be wrapped by two independent keys', async () => { + const dek = await generateDek() + const { key: passwordKey } = await generatePBKDF2Key({ + password: 'MyStr0ng!Pass', + }) + const passkeyKey = [...crypto.getRandomValues(new Uint8Array(DEKSIZE))] + + const byPassword = await wrapDek({ dek, wrappingKey: passwordKey }) + const byPasskey = await wrapDek({ dek, wrappingKey: passkeyKey }) + + expect(byPassword.encryptedData).not.toBe(byPasskey.encryptedData) + + await expect( + unwrapDek({ + ...byPassword, + data: byPassword.encryptedData, + wrappingKey: passwordKey, + }), + ).resolves.toStrictEqual(dek) + await expect( + unwrapDek({ + ...byPasskey, + data: byPasskey.encryptedData, + wrappingKey: passkeyKey, + }), + ).resolves.toStrictEqual(dek) +}) + +test('Cipher - unwrapDek rejects a wrong wrapping key', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + const { key: wrongKey } = await generatePBKDF2Key({ password: 'WrongPass' }) + + const wrapped = await wrapDek({ dek, wrappingKey }) + + await expect( + unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey: wrongKey, + }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - wrapDek rejects a DEK of the wrong size', async () => { + const wrappingKey = await generateDek() + + const invalidDeks = [ + ['too short', [1, 2, 3]], + ['too long', new Array(DEKSIZE + 1).fill(1)], + ['empty', []], + ['undefined', undefined], + ['null', null], + ] + + for (const [, dek] of invalidDeks) { + await expect(wrapDek({ dek, wrappingKey })).rejects.toThrow( + 'Invalid data encryption key', + ) + } +}) + +test('Cipher - wrapDek rejects a wrapping key that is not 32 bytes', async () => { + const dek = await generateDek() + const { key: legacyKey } = await generatePBKDF2Key({ + password: 'MyStr0ng!Pass', + version: 1, + }) + + expect(legacyKey.length).toBe(16) + + await expect(wrapDek({ dek, wrappingKey: legacyKey })).rejects.toThrow( + 'Invalid wrapping key', + ) + await expect(wrapDek({ dek, wrappingKey: undefined })).rejects.toThrow( + 'Invalid wrapping key', + ) +}) + +test('Cipher - unwrapDek rejects a wrapping key that is not 32 bytes', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + const wrapped = await wrapDek({ dek, wrappingKey }) + + await expect( + unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey: [1, 2, 3], + }), + ).rejects.toThrow('Invalid wrapping key') +}) + +test('Cipher - unwrapDek reports a missing wrapper distinctly from a wrong key', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + const wrapped = await wrapDek({ dek, wrappingKey }) + const full = { ...wrapped, data: wrapped.encryptedData } + + const incomplete = [ + { ...full, data: undefined }, + { ...full, iv: undefined }, + { ...full, tag: undefined }, + {}, + ] + + for (const wrapper of incomplete) { + await expect(unwrapDek({ ...wrapper, wrappingKey })).rejects.toThrow( + 'Missing key wrapper', + ) + } +}) + +test('Cipher - unwrapDek rejects a payload that is not a 32 byte key', async () => { + const wrappingKey = await generateDek() + + const payloads = [new Uint8Array([1, 2, 3]), new Uint8Array(DEKSIZE + 1)] + + for (const payload of payloads) { + const notAKey = await encryptAES({ data: payload, key: wrappingKey }) + await expect( + unwrapDek({ ...notAKey, data: notAKey.encryptedData, wrappingKey }), + ).rejects.toThrow('Invalid data encryption key') + } +}) + +test('Cipher - unwrapDek rejects tampered ciphertext, tag and IV', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + const wrapped = await wrapDek({ dek, wrappingKey }) + const full = { ...wrapped, data: wrapped.encryptedData } + + const flipFirstByte = (binaryString) => + String.fromCharCode(binaryString.charCodeAt(0) ^ 0xff) + + binaryString.slice(1) + + const tampered = [ + { ...full, data: flipFirstByte(full.data) }, + { ...full, tag: flipFirstByte(full.tag) }, + { ...full, iv: flipFirstByte(full.iv) }, + ] + + for (const wrapper of tampered) { + await expect(unwrapDek({ ...wrapper, wrappingKey })).rejects.toThrow( + 'Incorrect password', + ) + } +}) + +test('Cipher - wrapDek uses a fresh IV for every wrap', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + + const first = await wrapDek({ dek, wrappingKey }) + const second = await wrapDek({ dek, wrappingKey }) + + expect(first.iv).not.toBe(second.iv) + expect(first.encryptedData).not.toBe(second.encryptedData) + + await expect( + unwrapDek({ ...first, data: first.encryptedData, wrappingKey }), + ).resolves.toStrictEqual(dek) + await expect( + unwrapDek({ ...second, data: second.encryptedData, wrappingKey }), + ).resolves.toStrictEqual(dek) +}) + +test('Cipher - generateDek uses crypto.getRandomValues', async () => { + const originalGetRandomValues = crypto.getRandomValues.bind(crypto) + const mockGetRandomValues = jest.fn((arr) => { + for (let i = 0; i < arr.length; i++) arr[i] = 0x5a + return arr + }) + crypto.getRandomValues = mockGetRandomValues + + const dek = await generateDek() + + expect(mockGetRandomValues).toHaveBeenCalled() + expect(dek).toStrictEqual(new Array(DEKSIZE).fill(0x5a)) + + crypto.getRandomValues = originalGetRandomValues +}) + +test('Cipher - wrapDek accepts a Uint8Array DEK and unwraps to a plain array', async () => { + const wrappingKey = await generateDek() + const dek = new Uint8Array(DEKSIZE).fill(7) + + const wrapped = await wrapDek({ dek, wrappingKey }) + const unwrapped = await unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey, + }) + + expect(Array.isArray(unwrapped)).toBe(true) + expect(unwrapped).toStrictEqual(new Array(DEKSIZE).fill(7)) +}) + +test('Cipher - a wrapped DEK survives a JSON round trip at byte value edges', async () => { + const wrappingKey = await generateDek() + + const edgeCases = [ + new Array(DEKSIZE).fill(0x00), + new Array(DEKSIZE).fill(0xff), + Array.from({ length: DEKSIZE }, (_, i) => (i % 2 ? 0x00 : 0xff)), + ] + + for (const dek of edgeCases) { + const wrapped = await wrapDek({ dek, wrappingKey }) + const restored = JSON.parse(JSON.stringify(wrapped)) + + const unwrapped = await unwrapDek({ + ...restored, + data: restored.encryptedData, + wrappingKey, + }) + + expect(unwrapped).toStrictEqual(dek) + } +}) + +test('Cipher - generatePBKDF2Key accepts version 4 and yields a 32 byte key', async () => { + const salt = 'fixedsalt' + const password = 'MyStr0ng!Pass' + + const { key: v4Key } = await generatePBKDF2Key({ + password, + salt, + version: ENVELOPE_ENCRYPTION_VERSION, + }) + const { key: v3Key } = await generatePBKDF2Key({ password, salt, version: 3 }) + + expect(v4Key.length).toBe(DEKSIZE) + expect(v4Key).toStrictEqual(v3Key) +}) + +test('Cipher - ENCRYPTION_VERSIONS exposes every supported version', () => { + expect(Object.keys(ENCRYPTION_VERSIONS)).toStrictEqual(['1', '2', '3', '4']) + expect(() => getVersionConfig(5)).toThrow('Unknown encryption version: 5') +}) + +test('Cipher - hexToBytes returns an empty array for unusable input', () => { + expect(hexToBytes('')).toStrictEqual(new Uint8Array()) + expect(hexToBytes(undefined)).toStrictEqual(new Uint8Array()) + expect(hexToBytes(null)).toStrictEqual(new Uint8Array()) +}) + +test('Cipher - deriveKekFromPrf is deterministic and returns a 32 byte key', async () => { + const prfOutput = [...crypto.getRandomValues(new Uint8Array(DEKSIZE))] + + const first = await deriveKekFromPrf(prfOutput) + const second = await deriveKekFromPrf(prfOutput) + + expect(first.length).toBe(DEKSIZE) + expect(Array.isArray(first)).toBe(true) + expect(first).toStrictEqual(second) +}) + +test('Cipher - deriveKekFromPrf never returns the raw PRF output', async () => { + const prfOutput = [...crypto.getRandomValues(new Uint8Array(DEKSIZE))] + const other = [...crypto.getRandomValues(new Uint8Array(DEKSIZE))] + + const kek = await deriveKekFromPrf(prfOutput) + + expect(kek).not.toStrictEqual(prfOutput) + expect(kek).not.toStrictEqual(await deriveKekFromPrf(other)) +}) + +test('Cipher - deriveKekFromPrf rejects anything that is not 32 bytes', async () => { + const invalid = [ + [1, 2, 3], + new Array(DEKSIZE + 1).fill(1), + [], + undefined, + null, + ] + + for (const prfOutput of invalid) { + await expect(deriveKekFromPrf(prfOutput)).rejects.toThrow( + 'Invalid PRF output', + ) + } +}) + +test('Cipher - a DEK wrapped by a PRF-derived key round trips', async () => { + const dek = await generateDek() + const prfOutput = [...crypto.getRandomValues(new Uint8Array(DEKSIZE))] + + const wrappingKey = await deriveKekFromPrf(prfOutput) + const wrapped = await wrapDek({ dek, wrappingKey }) + + await expect( + unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey: await deriveKekFromPrf(prfOutput), + }), + ).resolves.toStrictEqual(dek) + + await expect( + unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey: prfOutput, + }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - keys must be real bytes, not just the right length', async () => { + const dek = await generateDek() + const notBytes = [ + new Array(DEKSIZE).fill(undefined), + new Array(DEKSIZE).fill(NaN), + new Array(DEKSIZE).fill(1.5), + new Array(DEKSIZE).fill(256), + new Array(DEKSIZE).fill(-1), + 'a'.repeat(DEKSIZE), + { length: DEKSIZE }, + ] + + for (const value of notBytes) { + await expect(wrapDek({ dek: value, wrappingKey: dek })).rejects.toThrow( + 'Invalid data encryption key', + ) + await expect(wrapDek({ dek, wrappingKey: value })).rejects.toThrow( + 'Invalid wrapping key', + ) + await expect(deriveKekFromPrf(value)).rejects.toThrow('Invalid PRF output') + } +}) + +test('Cipher - typed arrays are accepted as keys', async () => { + const dek = new Uint8Array(DEKSIZE).fill(3) + const wrappingKey = new Uint8Array(DEKSIZE).fill(9) + + const wrapped = await wrapDek({ dek, wrappingKey }) + + await expect( + unwrapDek({ ...wrapped, data: wrapped.encryptedData, wrappingKey }), + ).resolves.toStrictEqual([...dek]) +}) + +test('Cipher - additional data must match on decryption', async () => { + const key = await generateDek() + const encrypted = await encryptAES({ data: 'secret', key, aad: 'context/a' }) + + await expect( + decryptAES({ + ...encrypted, + data: encrypted.encryptedData, + key, + aad: 'context/a', + }), + ).resolves.toBeDefined() + + await expect( + decryptAES({ + ...encrypted, + data: encrypted.encryptedData, + key, + aad: 'context/b', + }), + ).rejects.toThrow('Incorrect password') + + await expect( + decryptAES({ ...encrypted, data: encrypted.encryptedData, key }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - data written without additional data still decrypts without it', async () => { + const key = await generateDek() + const encrypted = await encryptAES({ data: 'legacy', key }) + + const decrypted = await decryptAES({ + ...encrypted, + data: encrypted.encryptedData, + key, + }) + + expect(Buffer.from(decrypted).toString()).toBe('legacy') +}) + +test('Cipher - a DEK wrapper is bound to the wrapper it belongs to', async () => { + const dek = await generateDek() + const wrappingKey = await generateDek() + + const wrapped = await wrapDek({ + dek, + wrappingKey, + aad: wrapperAad('password'), + }) + + await expect( + unwrapDek({ + ...wrapped, + data: wrapped.encryptedData, + wrappingKey, + aad: wrapperAad('some-credential-id'), + }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - the aad builders are distinct per purpose and per field', () => { + expect(contentAad('btcEncryptedSeed')).not.toBe( + contentAad('encryptedMlMainnetPrivateKey'), + ) + expect(wrapperAad('password')).not.toBe(wrapperAad('cred')) + expect(htlsAad('a')).not.toBe(htlsAad('b')) + expect(contentAad('x')).not.toBe(wrapperAad('x')) + expect(contentAad('x')).not.toBe(htlsAad('x')) +}) + +test('Cipher - the passkey KEK derivation is pinned to a known vector', async () => { + const prfOutput = Array.from({ length: 32 }, (_, i) => i) + + expect(await deriveKekFromPrf(prfOutput)).toStrictEqual([ + 128, 134, 211, 213, 27, 178, 194, 105, 0, 85, 111, 79, 63, 120, 223, 76, 3, + 92, 102, 175, 17, 153, 205, 237, 54, 226, 12, 209, 117, 179, 157, 239, + ]) +}) + +const KNOWN_PASSWORD = 'correct horse battery staple' +const KNOWN_SALT = '000102030405060708090a0b0c0d0e0f' +const fromCodes = (codes) => String.fromCharCode(...codes) + +test.each([ + [1, [54, 3, 60, 234, 152, 152, 20, 171, 192, 66, 40, 128, 46, 37, 81, 44]], + [ + 2, + [99, 123, 106, 23, 174, 237, 18, 166, 11, 134, 179, 112, 110, 201, 235, 79], + ], + [ + 3, + [ + 99, 123, 106, 23, 174, 237, 18, 166, 11, 134, 179, 112, 110, 201, 235, 79, + 147, 133, 30, 40, 70, 44, 26, 215, 254, 11, 159, 186, 168, 109, 179, 6, + ], + ], + [ + 4, + [ + 99, 123, 106, 23, 174, 237, 18, 166, 11, 134, 179, 112, 110, 201, 235, 79, + 147, 133, 30, 40, 70, 44, 26, 215, 254, 11, 159, 186, 168, 109, 179, 6, + ], + ], +])( + 'Cipher - the v%i key derivation is pinned to a known vector', + async (version, expected) => { + const { key } = await generatePBKDF2Key({ + password: KNOWN_PASSWORD, + salt: KNOWN_SALT, + version, + }) + + expect(key).toStrictEqual(expected) + }, +) + +test('Cipher - a blob encrypted before this change still decrypts', async () => { + const { key } = await generatePBKDF2Key({ + password: KNOWN_PASSWORD, + salt: KNOWN_SALT, + version: 4, + }) + + const decrypted = await decryptAES({ + data: fromCodes([ + 107, 143, 59, 183, 211, 112, 106, 253, 33, 97, 249, 54, 47, 237, 187, 183, + 59, 154, 142, 159, 223, 35, 203, 194, 204, 252, 98, 17, + ]), + iv: fromCodes([16, 167, 231, 76, 225, 178, 64, 6, 199, 106, 255, 246]), + tag: fromCodes([ + 27, 133, 117, 246, 162, 232, 227, 29, 64, 161, 172, 141, 11, 201, 145, 46, + ]), + key, + aad: contentAad('btcEncryptedSeed'), + }) + + expect(new TextDecoder().decode(decrypted)).toBe('attack at dawn') +}) + +test('Cipher - the additional data strings are part of the stored format', () => { + expect(contentAad('btcEncryptedSeed')).toBe( + 'mojito/v4/content/btcEncryptedSeed', + ) + expect(wrapperAad('password')).toBe('mojito/v4/dek/password') + expect(htlsAad('abc')).toBe('mojito/v4/htls/abc') +}) diff --git a/src/services/Crypto/Passkey/Passkey.js b/src/services/Crypto/Passkey/Passkey.js new file mode 100644 index 00000000..cd8273f5 --- /dev/null +++ b/src/services/Crypto/Passkey/Passkey.js @@ -0,0 +1,132 @@ +import { + uint8ArrayToString, + stringToUint8Array, +} from 'src/utils/Helpers/Array/Array' + +const RP_NAME = 'Mojito' +const FIREFOX_RP_ID = 'keys.mintlayer.org' +const PRF_SALT_SIZE = 32 +const CHALLENGE_SIZE = 32 +const USER_ID_SIZE = 16 + +const isFirefox = () => navigator.userAgent.includes('Firefox') + +// Chrome derives the RP ID from the extension origin when rp.id is omitted, which +// no website and no other extension can claim. Firefox rejects moz-extension:// +// and needs a domain listed in host_permissions. +const getRpId = () => (isFirefox() ? FIREFOX_RP_ID : undefined) + +const isSupported = () => + typeof PublicKeyCredential !== 'undefined' && + typeof navigator.credentials?.create === 'function' + +const randomBytes = (size) => crypto.getRandomValues(new Uint8Array(size)) + +const toBase64Url = (bytes) => + uint8ArrayToString(new Uint8Array(bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + +const fromBase64Url = (value) => + stringToUint8Array(value.replace(/-/g, '+').replace(/_/g, '/')) + +const createCredential = async (accountName) => { + const rpId = getRpId() + + const credential = await navigator.credentials.create({ + publicKey: { + rp: { name: RP_NAME, ...(rpId ? { id: rpId } : {}) }, + user: { + id: randomBytes(USER_ID_SIZE), + name: accountName, + displayName: accountName, + }, + challenge: randomBytes(CHALLENGE_SIZE), + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + authenticatorSelection: { + residentKey: 'required', + requireResidentKey: true, + userVerification: 'required', + }, + attestation: 'none', + extensions: { prf: {}, credProps: true }, + }, + }) + + if (!credential) throw new Error('Passkey creation was cancelled') + + // Firefox reports an unsupported extension as {} rather than {enabled:false}, + // so anything falsy has to be treated as no PRF at all. + if (!credential.getClientExtensionResults()?.prf?.enabled) + throw new Error('This authenticator cannot protect a wallet') + + return toBase64Url(credential.rawId) +} + +// Every enrolled credential is offered, each with its own salt, so the user can +// unlock with whichever authenticator they have at hand. The assertion tells us +// which one answered. +const getPrfOutput = async (passkeys) => { + if (!passkeys?.length) throw new Error('No passkey is enrolled') + + const rpId = getRpId() + + const assertion = await navigator.credentials.get({ + publicKey: { + ...(rpId ? { rpId } : {}), + challenge: randomBytes(CHALLENGE_SIZE), + allowCredentials: passkeys.map(({ credentialId }) => ({ + type: 'public-key', + id: fromBase64Url(credentialId), + })), + userVerification: 'required', + extensions: { + prf: { + evalByCredential: Object.fromEntries( + passkeys.map(({ credentialId, prfSalt }) => [ + credentialId, + { first: fromBase64Url(prfSalt) }, + ]), + ), + }, + }, + }, + }) + + if (!assertion) throw new Error('Passkey verification was cancelled') + + const prfOutput = assertion.getClientExtensionResults()?.prf?.results?.first + + if (!prfOutput) throw new Error('This authenticator did not return a key') + + return { + credentialId: toBase64Url(assertion.rawId), + prfOutput: [...new Uint8Array(prfOutput)], + } +} + +// PRF results are not returned by create() on most platforms, so enrolment always +// follows the ceremony with an assertion to obtain the first output. +const enroll = async (accountName) => { + const credentialId = await createCredential(accountName) + const prfSalt = toBase64Url(randomBytes(PRF_SALT_SIZE)) + const { prfOutput } = await getPrfOutput([{ credentialId, prfSalt }]) + + return { credentialId, prfSalt, prfOutput } +} + +export { + RP_NAME, + FIREFOX_RP_ID, + isSupported, + getRpId, + toBase64Url, + fromBase64Url, + createCredential, + getPrfOutput, + enroll, +} diff --git a/src/services/Crypto/Passkey/Passkey.test.js b/src/services/Crypto/Passkey/Passkey.test.js new file mode 100644 index 00000000..5756bfe3 --- /dev/null +++ b/src/services/Crypto/Passkey/Passkey.test.js @@ -0,0 +1,279 @@ +import { + FIREFOX_RP_ID, + isSupported, + getRpId, + toBase64Url, + fromBase64Url, + createCredential, + getPrfOutput, + enroll, +} from './Passkey' + +const CREDENTIAL_ID = new Uint8Array([1, 2, 3, 4]) +const PRF_OUTPUT = new Uint8Array(32).fill(7) + +const setUserAgent = (value) => + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value, + }) + +const mockCredentials = ({ create, get }) => { + Object.defineProperty(navigator, 'credentials', { + configurable: true, + value: { create, get }, + }) +} + +const credentialWith = (extensionResults) => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => extensionResults, +}) + +beforeEach(() => { + setUserAgent('Chrome') + global.PublicKeyCredential = function PublicKeyCredential() {} +}) + +afterEach(() => { + delete global.PublicKeyCredential +}) + +test('Passkey - base64url round trips without padding characters', () => { + const bytes = new Uint8Array([251, 255, 190, 0, 1]) + const encoded = toBase64Url(bytes) + + expect(encoded).not.toMatch(/[+/=]/) + expect(fromBase64Url(encoded)).toStrictEqual(bytes) +}) + +test('Passkey - support detection needs both the interface and the API', () => { + mockCredentials({ create: jest.fn(), get: jest.fn() }) + expect(isSupported()).toBe(true) + + mockCredentials({ create: undefined, get: jest.fn() }) + expect(isSupported()).toBe(false) + + mockCredentials({ create: jest.fn(), get: jest.fn() }) + delete global.PublicKeyCredential + expect(isSupported()).toBe(false) +}) + +test('Passkey - the RP id is omitted on Chrome and a domain on Firefox', () => { + expect(getRpId()).toBeUndefined() + + setUserAgent('Mozilla/5.0 Firefox/150.0') + expect(getRpId()).toBe(FIREFOX_RP_ID) +}) + +test('Passkey - creation asks for a discoverable credential and user verification', async () => { + const create = jest.fn(async () => + credentialWith({ prf: { enabled: true }, credProps: { rk: true } }), + ) + mockCredentials({ create, get: jest.fn() }) + + const credentialId = await createCredential('Savings') + + expect(credentialId).toBe(toBase64Url(CREDENTIAL_ID)) + + const { publicKey } = create.mock.calls[0][0] + + expect(publicKey.rp.id).toBeUndefined() + expect(publicKey.authenticatorSelection).toStrictEqual({ + residentKey: 'required', + requireResidentKey: true, + userVerification: 'required', + }) + expect(publicKey.attestation).toBe('none') + expect(publicKey.extensions.prf).toStrictEqual({}) + expect(publicKey.challenge.length).toBe(32) + expect(publicKey.user.name).toBe('Savings') +}) + +test('Passkey - creation sends the RP id on Firefox', async () => { + setUserAgent('Mozilla/5.0 Firefox/150.0') + const create = jest.fn(async () => credentialWith({ prf: { enabled: true } })) + mockCredentials({ create, get: jest.fn() }) + + await createCredential('Savings') + + expect(create.mock.calls[0][0].publicKey.rp.id).toBe(FIREFOX_RP_ID) +}) + +test('Passkey - an authenticator without PRF is refused', async () => { + mockCredentials({ + create: jest.fn(async () => credentialWith({})), + get: jest.fn(), + }) + + await expect(createCredential('Savings')).rejects.toThrow( + 'This authenticator cannot protect a wallet', + ) + + mockCredentials({ + create: jest.fn(async () => credentialWith({ prf: { enabled: false } })), + get: jest.fn(), + }) + + await expect(createCredential('Savings')).rejects.toThrow( + 'This authenticator cannot protect a wallet', + ) +}) + +test('Passkey - a cancelled creation is refused', async () => { + mockCredentials({ create: jest.fn(async () => null), get: jest.fn() }) + + await expect(createCredential('Savings')).rejects.toThrow( + 'Passkey creation was cancelled', + ) +}) + +test('Passkey - the assertion evaluates PRF with the stored salt', async () => { + const get = jest.fn(async () => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => ({ + prf: { results: { first: PRF_OUTPUT } }, + }), + })) + mockCredentials({ create: jest.fn(), get }) + + const credentialId = toBase64Url(CREDENTIAL_ID) + const prfSalt = toBase64Url(new Uint8Array(32).fill(9)) + + const result = await getPrfOutput([{ credentialId, prfSalt }]) + + expect(result).toStrictEqual({ credentialId, prfOutput: [...PRF_OUTPUT] }) + + const { publicKey } = get.mock.calls[0][0] + + expect(publicKey.userVerification).toBe('required') + expect(publicKey.allowCredentials[0].id).toStrictEqual(CREDENTIAL_ID) + expect(publicKey.extensions.prf.evalByCredential[credentialId]).toStrictEqual( + { first: new Uint8Array(32).fill(9) }, + ) +}) + +test('Passkey - every enrolled credential is offered with its own salt', async () => { + const secondId = new Uint8Array([9, 9, 9]) + const get = jest.fn(async () => ({ + rawId: secondId.buffer, + getClientExtensionResults: () => ({ + prf: { results: { first: PRF_OUTPUT } }, + }), + })) + mockCredentials({ create: jest.fn(), get }) + + const first = { + credentialId: toBase64Url(CREDENTIAL_ID), + prfSalt: toBase64Url(new Uint8Array(32).fill(1)), + } + const second = { + credentialId: toBase64Url(secondId), + prfSalt: toBase64Url(new Uint8Array(32).fill(2)), + } + + const result = await getPrfOutput([first, second]) + + expect(result.credentialId).toBe(second.credentialId) + + const { publicKey } = get.mock.calls[0][0] + + expect(publicKey.allowCredentials).toHaveLength(2) + expect(Object.keys(publicKey.extensions.prf.evalByCredential)).toStrictEqual([ + first.credentialId, + second.credentialId, + ]) + expect( + publicKey.extensions.prf.evalByCredential[second.credentialId].first, + ).toStrictEqual(new Uint8Array(32).fill(2)) +}) + +test('Passkey - an empty credential list is refused before any ceremony', async () => { + const get = jest.fn() + mockCredentials({ create: jest.fn(), get }) + + await expect(getPrfOutput([])).rejects.toThrow('No passkey is enrolled') + expect(get).not.toHaveBeenCalled() +}) + +test('Passkey - a missing PRF result fails closed', async () => { + mockCredentials({ + create: jest.fn(), + get: jest.fn(async () => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => ({}), + })), + }) + + await expect( + getPrfOutput([{ credentialId: 'AQID', prfSalt: 'AQID' }]), + ).rejects.toThrow('This authenticator did not return a key') +}) + +test('Passkey - a cancelled assertion is refused', async () => { + mockCredentials({ create: jest.fn(), get: jest.fn(async () => null) }) + + await expect( + getPrfOutput([{ credentialId: 'AQID', prfSalt: 'AQID' }]), + ).rejects.toThrow('Passkey verification was cancelled') +}) + +test('Passkey - enrolment follows creation with an assertion for the first PRF output', async () => { + const create = jest.fn(async () => credentialWith({ prf: { enabled: true } })) + const get = jest.fn(async () => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => ({ + prf: { results: { first: PRF_OUTPUT } }, + }), + })) + mockCredentials({ create, get }) + + const result = await enroll('Savings') + + expect(create).toHaveBeenCalledTimes(1) + expect(get).toHaveBeenCalledTimes(1) + expect(result.credentialId).toBe(toBase64Url(CREDENTIAL_ID)) + expect(result.prfOutput).toStrictEqual([...PRF_OUTPUT]) + expect(fromBase64Url(result.prfSalt).length).toBe(32) +}) + +test('Passkey - every enrolment generates a fresh salt', async () => { + mockCredentials({ + create: jest.fn(async () => credentialWith({ prf: { enabled: true } })), + get: jest.fn(async () => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => ({ + prf: { results: { first: PRF_OUTPUT } }, + }), + })), + }) + + const first = await enroll('Savings') + const second = await enroll('Savings') + + expect(first.prfSalt).not.toBe(second.prfSalt) +}) + +test('Passkey - the assertion sends the RP id on Firefox', async () => { + setUserAgent('Mozilla/5.0 Firefox/150.0') + const get = jest.fn(async () => ({ + rawId: CREDENTIAL_ID.buffer, + getClientExtensionResults: () => ({ + prf: { results: { first: PRF_OUTPUT } }, + }), + })) + mockCredentials({ create: jest.fn(), get }) + + await getPrfOutput([{ credentialId: 'AQID', prfSalt: 'AQID' }]) + + expect(get.mock.calls[0][0].publicKey.rpId).toBe(FIREFOX_RP_ID) +}) + +test('Passkey - base64url encoding matches the values a stored record holds', () => { + const bytes = new Uint8Array([251, 255, 190, 0, 1, 2]) + + expect(toBase64Url(bytes)).toBe('-_--AAEC') + expect(fromBase64Url('-_--AAEC')).toStrictEqual(bytes) + expect(toBase64Url(new Uint8Array([1]))).toBe('AQ') + expect(fromBase64Url('AQ')).toStrictEqual(new Uint8Array([1])) +}) diff --git a/src/services/Crypto/index.js b/src/services/Crypto/index.js index d82f48a8..899b251f 100644 --- a/src/services/Crypto/index.js +++ b/src/services/Crypto/index.js @@ -5,11 +5,13 @@ import BTC_ADDRESS_TYPE_MAP, { BTC_ADDRESS_TYPE_ENUM, } from './BTC/BTC.addressType' import * as Cipher from './Cipher/Cipher' +import * as Passkey from './Passkey/Passkey' export { BTC, ML, Cipher, + Passkey, BTCTransaction, BTC_ADDRESS_TYPE_MAP, BTC_ADDRESS_TYPE_ENUM, diff --git a/src/services/Entity/Account/Account.js b/src/services/Entity/Account/Account.js index 2a5e9088..41c47cc2 100644 --- a/src/services/Entity/Account/Account.js +++ b/src/services/Entity/Account/Account.js @@ -1,21 +1,112 @@ import { BTC, ML, BTC_ADDRESS_TYPE_MAP, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' import { IndexedDB } from '@Databases' import { AppInfo } from '@Constants' +import * as Passkey from 'src/services/Crypto/Passkey/Passkey' import { - getEncryptedPrivateKeys, + getEnvelopeEncryptedPrivateKeys, + buildPasskeyWrapper, getEncryptedHtlsSecret, } from './AccountHelpers' import { BTC as BtcHelpers } from '@Helpers' import loadAccountSubRoutines from './loadWorkers' import { LocalStorageService } from '@Storage' -import { CURRENT_ENCRYPTION_VERSION } from '../../Crypto/Cipher/Cipher' +import { + CURRENT_ENCRYPTION_VERSION, + ENVELOPE_ENCRYPTION_VERSION, + generateDek, + wrapDek, + unwrapDek, + deriveKekFromPrf, + contentAad, + wrapperAad, + htlsAad, +} from 'src/services/Crypto/Cipher/Cipher' const getAccountVersion = (account) => account.encryptionVersion || 1 +const accountLocks = new Map() + +const withAccountLock = (id, task) => { + const previous = accountLocks.get(id) ?? Promise.resolve() + const next = previous.then(task, task) + + accountLocks.set( + id, + next.catch(() => {}), + ) + + return next +} + +const isEnvelope = (version) => version >= ENVELOPE_ENCRYPTION_VERSION + +const aadFor = (account, aad) => + isEnvelope(getAccountVersion(account)) ? aad : undefined + +const PASSWORD_CREDENTIAL = 'password' +const PASSKEY_CREDENTIAL = 'passkey' + +const toCredential = (credential) => + typeof credential === 'string' + ? { kind: PASSWORD_CREDENTIAL, password: credential } + : credential + +const getWrapper = (account, credential) => { + if (credential.kind === PASSWORD_CREDENTIAL) + return account.wrappedDek?.password + + return account.wrappedDek?.passkeys?.find( + (entry) => entry.credentialId === credential.credentialId, + ) +} + +const getWrappingKey = async (account, credential) => { + if (credential.kind === PASSKEY_CREDENTIAL) + return deriveKekFromPrf(credential.prfOutput) + + const { generateEncryptionKey } = await loadAccountSubRoutines() + const { key } = await generateEncryptionKey({ + password: credential.password, + salt: account.salt, + version: getAccountVersion(account), + }) + + return key +} + +const getContentKey = async (account, credential) => { + const unlock = toCredential(credential) + const version = getAccountVersion(account) + + if (!isEnvelope(version)) { + if (unlock.kind !== PASSWORD_CREDENTIAL) + throw new Error('This account can only be unlocked with a password') + + return getWrappingKey(account, unlock) + } + + const wrapper = getWrapper(account, unlock) + const wrappingKey = await getWrappingKey(account, unlock) + const wrapperId = + unlock.kind === PASSWORD_CREDENTIAL + ? PASSWORD_CREDENTIAL + : unlock.credentialId + + return unwrapDek({ + data: wrapper?.encryptedData, + iv: wrapper?.iv, + tag: wrapper?.tag, + wrappingKey, + aad: wrapperAad(wrapperId), + }) +} + const saveAccount = async (data) => { const { name, password, mnemonic, walletType, walletsToCreate } = data const { salt, + encryptionVersion, + wrappedDek, encryptedMlTestnetPrivateKey, encryptedMlMainnetPrivateKey, btcEncryptedSeed, @@ -25,12 +116,13 @@ const saveAccount = async (data) => { mlTestnetPrivKeyTag, mlMainnetPrivKeyTag, btcTag, - } = await getEncryptedPrivateKeys(password, undefined, mnemonic) + } = await getEnvelopeEncryptedPrivateKeys(password, undefined, mnemonic) const account = { name, salt, - encryptionVersion: CURRENT_ENCRYPTION_VERSION, + encryptionVersion, + wrappedDek, iv: { btcIv, mlTestnetPrivKeyIv, mlMainnetPrivKeyIv }, tag: { btcTag, mlTestnetPrivKeyTag, mlMainnetPrivKeyTag }, seed: { @@ -81,25 +173,22 @@ const restoreAccountFromJSON = async (json) => { } const checkPasswordValidity = async (id, password) => { - const { generateEncryptionKey, decryptSeed } = await loadAccountSubRoutines() + const { decryptSeed } = await loadAccountSubRoutines() try { const account = await getAccount(id) if (!account?.salt || !account?.seed?.btcEncryptedSeed) return false - const { key } = await generateEncryptionKey({ - password, - salt: account.salt, - version: getAccountVersion(account), - }) + const key = await getContentKey(account, password) const decrypted = await decryptSeed({ data: account.seed.btcEncryptedSeed, iv: account.iv.btcIv, tag: account.tag.btcTag, key, + aad: aadFor(account, contentAad('btcEncryptedSeed')), }) - if (!decrypted || decrypted.error) return false + if (!decrypted) return false return true } catch { return false @@ -107,68 +196,205 @@ const checkPasswordValidity = async (id, password) => { } const unlockHtlsSecret = async ({ accountId, password, hash }) => { - const { generateEncryptionKey, decryptSeed } = await loadAccountSubRoutines() + const { decryptSeed } = await loadAccountSubRoutines() const account = await getAccount(accountId) - const isPasswordValid = await checkPasswordValidity(accountId, password) - if (!isPasswordValid) return Promise.reject('Invalid password') if (!account) return Promise.reject('Account not found') if (!account.htlsSecrets || !account.htlsSecrets[hash]) return Promise.reject('No secret found for the provided hash') - const { key } = await generateEncryptionKey({ - password, - salt: account.salt, - version: getAccountVersion(account), - }) + let key + + try { + key = await getContentKey(account, password) + } catch { + return Promise.reject('Invalid password') + } const data = account.htlsSecrets[hash] - const decrypted = await decryptSeed({ - data: data.encryptedHtlsSecret, - iv: data.htlsIv, - tag: data.htlsTag, - key, - }) - if (!decrypted || decrypted.error) + try { + const decrypted = await decryptSeed({ + data: data.encryptedHtlsSecret, + iv: data.htlsIv, + tag: data.htlsTag, + key, + aad: aadFor(account, htlsAad(hash)), + }) + + if (!decrypted) throw new Error('Empty secret') + + return new TextDecoder().decode(decrypted) + } catch { return Promise.reject( 'Failed to decrypt the secret. Possibly wrong password.', ) + } +} + +const saveProvidedHtlsSecret = async ({ accountId, password, data }) => + withAccountLock(accountId, async () => { + const account = await getAccount(accountId) + if (!account) return Promise.reject('Account not found') - return new TextDecoder().decode(decrypted) + let key + + try { + key = await getContentKey(account, password) + } catch { + return Promise.reject('Invalid password') + } + + const { encryptedHtlsSecret, htlsIv, htlsTag } = + await getEncryptedHtlsSecret( + key, + data.secret, + aadFor(account, htlsAad(data.hash)), + ) + + const current = await getAccount(accountId) + + await updateAccount(accountId, { + htlsSecrets: { + ...current.htlsSecrets, + [data.hash]: { + encryptedHtlsSecret, + htlsIv, + htlsTag, + txHash: data.txHash, + }, + }, + }) + }) + +const getPasskeys = async (accountId) => { + const account = await getAccount(accountId) + + return (account?.wrappedDek?.passkeys ?? []).map( + ({ credentialId, label, createdAt }) => ({ + credentialId, + label, + createdAt, + }), + ) } -const saveProvidedHtlsSecret = async ({ accountId, password, data }) => { +const enrollPasskey = async ({ accountId, password, label }) => { + if (!Passkey.isSupported()) + return Promise.reject('Passkeys are not available in this browser') + const account = await getAccount(accountId) - const isPasswordValid = await checkPasswordValidity(accountId, password) - if (!isPasswordValid) return Promise.reject('Invalid password') if (!account) return Promise.reject('Account not found') + if (!isEnvelope(getAccountVersion(account))) + return Promise.reject('Unlock this account with your password first') - const { encryptedHtlsSecret, htlsIv, htlsTag } = await getEncryptedHtlsSecret( - password, - account.salt, - data.secret, - getAccountVersion(account), + const dek = await getContentKey(account, password) + const { credentialId, prfSalt, prfOutput } = await Passkey.enroll( + account.name, ) - const updatedHtlsSecrets = { - ...account.htlsSecrets, - [data.hash]: { encryptedHtlsSecret, htlsIv, htlsTag, txHash: data.txHash }, - } + return withAccountLock(accountId, async () => { + const current = await getAccount(accountId) + + if ( + (current.wrappedDek?.passkeys ?? []).some( + (entry) => entry.credentialId === credentialId, + ) + ) + return Promise.reject('This passkey is already enrolled') + + const wrapper = await buildPasskeyWrapper({ + dek, + credentialId, + prfSalt, + prfOutput, + label, + }) + + const stored = await getAccount(accountId) + + await updateAccount(accountId, { + wrappedDek: { + ...stored.wrappedDek, + passkeys: [...(stored.wrappedDek?.passkeys ?? []), wrapper], + }, + }) + + return { credentialId, label } + }) +} + +const removePasskey = async ({ accountId, credentialId }) => + withAccountLock(accountId, async () => { + const account = await getAccount(accountId) + if (!account) return Promise.reject('Account not found') + + const passkeys = account.wrappedDek?.passkeys ?? [] - await updateAccount(accountId, { htlsSecrets: updatedHtlsSecrets }) + if (!passkeys.some((entry) => entry.credentialId === credentialId)) + return Promise.reject('Passkey not found') + + await updateAccount(accountId, { + wrappedDek: { + ...account.wrappedDek, + passkeys: passkeys.filter( + (entry) => entry.credentialId !== credentialId, + ), + }, + }) + }) + +const unlockAccountWithPasskey = async (id, options) => { + const account = await getAccount(id) + const passkeys = account?.wrappedDek?.passkeys ?? [] + + if (!passkeys.length) + return Promise.reject('No passkey is enrolled for this account') + + const { credentialId, prfOutput } = await Passkey.getPrfOutput(passkeys) + + return unlockAccount( + id, + { kind: PASSKEY_CREDENTIAL, credentialId, prfOutput }, + options, + ) } -const reEncryptAccount = async (id, password, account, decryptedSeeds) => { +const reEncryptAccount = async ({ + id, + password, + account, + decryptedSeeds, + contentKey, +}) => { + // A credential descriptor would be stringified into the KDF, and enrolled + // passkeys cannot be re-wrapped without their PRF outputs. Both must fail loudly + // rather than silently re-key the wallet to something nobody can reproduce. + if (typeof password !== 'string') + throw new Error('Re-encryption needs the account password') + + if (account.wrappedDek?.passkeys?.length) + throw new Error( + 'Remove the enrolled passkeys before migrating this account', + ) + const { generateEncryptionKey, encryptSeed, decryptSeed } = await loadAccountSubRoutines() - const { key: newKey, salt: newSalt } = await generateEncryptionKey({ + const { key: wrappingKey, salt: newSalt } = await generateEncryptionKey({ password, version: CURRENT_ENCRYPTION_VERSION, }) - const reEncrypt = async (data) => { - const { encryptedData, iv, tag } = await encryptSeed({ data, key: newKey }) + const newKey = await generateDek() + + const reEncrypt = async (data, aad) => { + if (!data) return {} + + const { encryptedData, iv, tag } = await encryptSeed({ + data, + key: newKey, + aad, + }) return { encryptedData, iv, tag } } @@ -176,53 +402,66 @@ const reEncryptAccount = async (id, password, account, decryptedSeeds) => { encryptedData: btcEncryptedSeed, iv: btcIv, tag: btcTag, - } = await reEncrypt(decryptedSeeds.seed) + } = await reEncrypt(decryptedSeeds.seed, contentAad('btcEncryptedSeed')) const { encryptedData: encryptedMlTestnetPrivateKey, iv: mlTestnetPrivKeyIv, tag: mlTestnetPrivKeyTag, - } = await reEncrypt(decryptedSeeds.mlTestnetPrivateKey) + } = await reEncrypt( + decryptedSeeds.mlTestnetPrivateKey, + contentAad('encryptedMlTestnetPrivateKey'), + ) const { encryptedData: encryptedMlMainnetPrivateKey, iv: mlMainnetPrivKeyIv, tag: mlMainnetPrivKeyTag, - } = await reEncrypt(decryptedSeeds.mlMainnetPrivateKey) + } = await reEncrypt( + decryptedSeeds.mlMainnetPrivateKey, + contentAad('encryptedMlMainnetPrivateKey'), + ) - // Re-encrypt HTLS secrets if any const updatedHtlsSecrets = {} - if (account.htlsSecrets) { - const { key: oldKey } = await generateEncryptionKey({ - password, - salt: account.salt, - version: getAccountVersion(account), - }) - for (const [hash, data] of Object.entries(account.htlsSecrets)) { + for (const [hash, data] of Object.entries(account.htlsSecrets ?? {})) { + // A secret that cannot be read is carried over untouched: it was already + // unreadable, and it must never cost the user access to the seed. + try { const decryptedSecret = await decryptSeed({ data: data.encryptedHtlsSecret, iv: data.htlsIv, tag: data.htlsTag, - key: oldKey, + key: contentKey, + aad: aadFor(account, htlsAad(hash)), }) const { encryptedData: encryptedHtlsSecret, iv: htlsIv, tag: htlsTag, - } = await reEncrypt(decryptedSecret) + } = await reEncrypt(decryptedSecret, htlsAad(hash)) updatedHtlsSecrets[hash] = { encryptedHtlsSecret, htlsIv, htlsTag, txHash: data.txHash, } + } catch (e) { + console.error(`Could not re-encrypt the HTLS secret ${hash}:`, e) + updatedHtlsSecrets[hash] = data } } + const passwordWrapper = await wrapDek({ + dek: newKey, + wrappingKey, + aad: wrapperAad(PASSWORD_CREDENTIAL), + }) + await updateAccount(id, { salt: newSalt, encryptionVersion: CURRENT_ENCRYPTION_VERSION, + wrappedDek: { password: passwordWrapper, passkeys: [] }, iv: { btcIv, mlTestnetPrivKeyIv, mlMainnetPrivKeyIv }, tag: { btcTag, mlTestnetPrivKeyTag, mlMainnetPrivKeyTag }, seed: { @@ -237,7 +476,7 @@ const reEncryptAccount = async (id, password, account, decryptedSeeds) => { const unlockAccount = async (id, password, { wallets } = {}) => { const storedNetworkType = LocalStorageService.getItem('networkType') - const { generateEncryptionKey, decryptSeed } = await loadAccountSubRoutines() + const { decryptSeed } = await loadAccountSubRoutines() const addresses = {} try { @@ -245,41 +484,43 @@ const unlockAccount = async (id, password, { wallets } = {}) => { const walletsToCreate = AppInfo.DEFAULT_WALLETS_TO_CREATE if (!account.walletsToCreate) - updateAccount(id, { walletsToCreate: AppInfo.DEFAULT_WALLETS_TO_CREATE }) + await updateAccount(id, { + walletsToCreate: AppInfo.DEFAULT_WALLETS_TO_CREATE, + }) const accountVersion = getAccountVersion(account) - const { key } = await generateEncryptionKey({ - password, - salt: account.salt, - version: accountVersion, - }) + const key = await getContentKey(account, password) const seed = await decryptSeed({ data: account.seed.btcEncryptedSeed, iv: account.iv.btcIv, tag: account.tag.btcTag, key, + aad: aadFor(account, contentAad('btcEncryptedSeed')), }) - const mlTestnetPrivateKey = await decryptSeed({ - data: account.seed.encryptedMlTestnetPrivateKey, - iv: account.iv.mlTestnetPrivKeyIv, - tag: account.tag.mlTestnetPrivKeyTag, - key, - }) - - const mlMainnetPrivateKey = await decryptSeed({ - data: account.seed.encryptedMlMainnetPrivateKey, - iv: account.iv.mlMainnetPrivKeyIv, - tag: account.tag.mlMainnetPrivKeyTag, - key, - }) - // this error just exists if the jobe was run in a worker - /* istanbul ignore next */ + const mlTestnetPrivateKey = account.seed.encryptedMlTestnetPrivateKey + ? await decryptSeed({ + data: account.seed.encryptedMlTestnetPrivateKey, + iv: account.iv.mlTestnetPrivKeyIv, + tag: account.tag.mlTestnetPrivKeyTag, + key, + aad: aadFor(account, contentAad('encryptedMlTestnetPrivateKey')), + }) + : undefined + + const mlMainnetPrivateKey = account.seed.encryptedMlMainnetPrivateKey + ? await decryptSeed({ + data: account.seed.encryptedMlMainnetPrivateKey, + iv: account.iv.mlMainnetPrivKeyIv, + tag: account.tag.mlMainnetPrivKeyTag, + key, + aad: aadFor(account, contentAad('encryptedMlMainnetPrivateKey')), + }) + : undefined const btcAddressType = account.walletType || BTC_ADDRESS_TYPE_ENUM.NATIVE_SEGWIT - if (seed.error) throw new Error(seed.error) const walletsToUnlock = wallets || walletsToCreate @@ -319,13 +560,30 @@ const unlockAccount = async (id, password, { wallets } = {}) => { } } - // Migrate old accounts to current encryption version in the background + // Migrating is opportunistic: a failure must leave the account on its old + // version rather than deny access to a wallet that just decrypted fine. if (accountVersion !== CURRENT_ENCRYPTION_VERSION) { - reEncryptAccount(id, password, account, { - seed, - mlTestnetPrivateKey, - mlMainnetPrivateKey, - }).catch((e) => console.error('Encryption migration failed:', e)) + try { + await withAccountLock(id, async () => { + const current = await getAccount(id) + + if (getAccountVersion(current) === CURRENT_ENCRYPTION_VERSION) return + + await reEncryptAccount({ + id, + password, + account: current, + contentKey: key, + decryptedSeeds: { + seed, + mlTestnetPrivateKey, + mlMainnetPrivateKey, + }, + }) + }) + } catch (e) { + console.error('Encryption migration failed:', e) + } } return { @@ -348,6 +606,10 @@ const unlockAccount = async (id, password, { wallets } = {}) => { export { saveAccount, unlockAccount, + unlockAccountWithPasskey, + getPasskeys, + enrollPasskey, + removePasskey, updateAccount, getAccount, deleteAccount, diff --git a/src/services/Entity/Account/Account.test.js b/src/services/Entity/Account/Account.test.js index 9a75074c..45990ffc 100644 --- a/src/services/Entity/Account/Account.test.js +++ b/src/services/Entity/Account/Account.test.js @@ -1,93 +1,788 @@ -/* eslint-disable no-unused-vars */ +import { BTC, ML, Cipher, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { LocalStorageService } from '@Storage' +import { IndexedDB } from '@Databases' +import { AppInfo } from '@Constants' +import initWasm from 'src/tests/helpers/initWasm' import loadAccountSubRoutines from './loadWorkers' -// import { saveAccount, unlockAccount } from './Account' -// import { BTC, BTC_ADDRESS_TYPE_MAP, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' -import { BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { + saveAccount, + unlockAccount, + updateAccount, + getAccount, + unlockHtlsSecret, + saveProvidedHtlsSecret, + checkPasswordValidity, +} from './Account' +import { buildPasskeyWrapper } from './AccountHelpers' -// TODO: The tests had been disabled to avoid the error from wasm-crypto on the JEST environment, need to be fixed later - -const ENTROPY_DATA = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] const accountName = 'Savings' const password = 'pass' const defaultWalletsToCreate = ['btc'] -// const customWalletsToCreate2 = ['btc', 'ml'] - -// test('Account creation and restoring', async () => { -// const { generateNewAccountMnemonic } = await loadAccountSubRoutines() -// const mnemonic = await generateNewAccountMnemonic(ENTROPY_DATA) -// const [pubKey] = BTC.generateKeysFromMnemonic(mnemonic) -// const originalAddress = -// BTC_ADDRESS_TYPE_MAP[BTC_ADDRESS_TYPE_ENUM.LEGACY].getAddressFromPubKey( -// pubKey, -// ) -// const data = { -// name: accountName, -// password, -// mnemonic, -// walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, -// walletsToCreate: defaultWalletsToCreate, -// } -// const id = await saveAccount(data) -// const { addresses, name } = await unlockAccount(id, password) - -// expect(addresses.btcMainnetAddress).toStrictEqual(originalAddress) -// expect(name).toBe(accountName) -// }) - -test('Account creation and restoring - error', async () => { - jest.spyOn(console, 'error').mockImplementation((message) => { - expect(typeof message).toBe('string') - console.error.mockRestore() - }) + +const btcOnly = { wallets: ['btc'] } + +beforeAll(() => initWasm()) + +beforeEach(() => { + LocalStorageService.setItem('networkType', 'testnet') +}) + +const newMnemonic = async () => { const { generateNewAccountMnemonic } = await loadAccountSubRoutines() - const mnemonic = await generateNewAccountMnemonic(ENTROPY_DATA) + return generateNewAccountMnemonic() +} + +const createAccount = async (overrides = {}) => { + const mnemonic = overrides.mnemonic || (await newMnemonic()) - const data = { + const id = await saveAccount({ name: accountName, password, mnemonic, walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, walletsToCreate: defaultWalletsToCreate, + ...overrides, + }) + + return { id, mnemonic } +} + +const saveLegacyAccount = async (version, mnemonic) => { + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version }) + const encrypt = (data) => Cipher.encryptAES({ data, key }) + + const seed = await BTC.getSeedFromMnemonic(mnemonic) + const btc = await encrypt(seed) + const mlTestnet = await encrypt( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.TESTNET), + ) + const mlMainnet = await encrypt( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.MAINNET), + ) + const secret = await encrypt('legacy-htls-secret') + + return IndexedDB.save(await IndexedDB.loadAccounts(), { + name: accountName, + salt, + encryptionVersion: version, + iv: { + btcIv: btc.iv, + mlTestnetPrivKeyIv: mlTestnet.iv, + mlMainnetPrivKeyIv: mlMainnet.iv, + }, + tag: { + btcTag: btc.tag, + mlTestnetPrivKeyTag: mlTestnet.tag, + mlMainnetPrivKeyTag: mlMainnet.tag, + }, + seed: { + btcEncryptedSeed: btc.encryptedData, + encryptedMlTestnetPrivateKey: mlTestnet.encryptedData, + encryptedMlMainnetPrivateKey: mlMainnet.encryptedData, + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: defaultWalletsToCreate, + htlsSecrets: { + abc: { + encryptedHtlsSecret: secret.encryptedData, + htlsIv: secret.iv, + htlsTag: secret.tag, + txHash: 'tx', + }, + }, + }) +} + +const receivingAddressesOf = (addresses) => + addresses.btcAddresses.btcReceivingAddresses.map( + (entry) => Object.keys(entry)[0], + ) + +test('Account - creation and unlocking returns the account name and addresses', async () => { + const { id } = await createAccount() + + const { addresses, name } = await unlockAccount(id, password, btcOnly) + + expect(name).toBe(accountName) + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan(0) + expect(addresses.btcAddresses.btcChangeAddresses.length).toBeGreaterThan(0) +}) + +test('Account - unlocking with a wrong password is rejected', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const { id } = await createAccount() + + await expect(unlockAccount(id, 'pasz', btcOnly)).rejects.toStrictEqual({ + addresses: {}, + btcPrivateKeys: { btcHDWallet: null, btcAddressData: null }, + name: '', + mlPrivKeys: { mlMainnetPrivateKey: '', mlTestnetPrivateKey: '' }, + }) + + console.error.mockRestore() +}) + +test('Account - a new account is stored as a v4 envelope', async () => { + const { id } = await createAccount() + const account = await getAccount(id) + + expect(account.encryptionVersion).toBe(4) + expect(account.salt).toMatch(/^[0-9a-f]{32}$/) + expect(account.seed.btcEncryptedSeed).toBeDefined() + expect(account.seed.encryptedMlTestnetPrivateKey).toBeDefined() + expect(account.seed.encryptedMlMainnetPrivateKey).toBeDefined() + + expect(account.wrappedDek.passkeys).toStrictEqual([]) + expect(Object.keys(account.wrappedDek.password)).toStrictEqual([ + 'encryptedData', + 'iv', + 'tag', + ]) +}) + +test('Account - an account saved without walletsToCreate still unlocks', async () => { + const { id } = await createAccount({ walletsToCreate: undefined }) + const account = await getAccount(id) + + expect(account.walletsToCreate).toBeUndefined() + + const { addresses } = await unlockAccount(id, password, btcOnly) + + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan(0) +}) + +test('Account - walletsToCreate is kept when provided', async () => { + const { id } = await createAccount({ walletsToCreate: ['btc', 'ml'] }) + const account = await getAccount(id) + + expect(account.walletsToCreate).toStrictEqual(['btc', 'ml']) +}) + +test('Account - unlocking derives a batch of BTC addresses', async () => { + const { id } = await createAccount() + + const { addresses, btcPrivateKeys } = await unlockAccount( + id, + password, + btcOnly, + ) + + const receiving = receivingAddressesOf(addresses) + + expect(receiving.length).toBeGreaterThan(1) + expect(new Set(receiving).size).toBe(receiving.length) + expect(btcPrivateKeys.btcHDWallet).not.toBeNull() + expect(btcPrivateKeys.btcAddressData).not.toBeNull() +}) + +test('Account - the same mnemonic always unlocks to the same addresses', async () => { + const mnemonic = await newMnemonic() + const { id: firstId } = await createAccount({ mnemonic }) + const { id: secondId } = await createAccount({ mnemonic, name: 'Second' }) + + const first = await unlockAccount(firstId, password, btcOnly) + const second = await unlockAccount(secondId, password, btcOnly) + + expect(receivingAddressesOf(first.addresses)).toStrictEqual( + receivingAddressesOf(second.addresses), + ) +}) + +test('Account - two accounts from the same mnemonic use different salts', async () => { + const mnemonic = await newMnemonic() + const { id: firstId } = await createAccount({ mnemonic }) + const { id: secondId } = await createAccount({ mnemonic, name: 'Second' }) + + const first = await getAccount(firstId) + const second = await getAccount(secondId) + + expect(first.salt).not.toBe(second.salt) + expect(first.seed.btcEncryptedSeed).not.toBe(second.seed.btcEncryptedSeed) +}) + +test('Account - different mnemonics unlock to different addresses', async () => { + const { id: firstId } = await createAccount() + const { id: secondId } = await createAccount({ name: 'Second' }) + + const first = await unlockAccount(firstId, password, btcOnly) + const second = await unlockAccount(secondId, password, btcOnly) + + expect(receivingAddressesOf(first.addresses)).not.toStrictEqual( + receivingAddressesOf(second.addresses), + ) +}) + +test.each([1, 2, 3])( + 'Account - a v%i account migrates to the envelope on unlock', + async (version) => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(version, mnemonic) + + const before = await getAccount(id) + expect(before.encryptionVersion).toBe(version) + expect(before.wrappedDek).toBeUndefined() + + const { addresses } = await unlockAccount(id, password, btcOnly) + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan( + 0, + ) + + const after = await getAccount(id) + expect(after.encryptionVersion).toBe(4) + expect(after.wrappedDek.passkeys).toStrictEqual([]) + expect(after.salt).not.toBe(before.salt) + expect(after.seed.btcEncryptedSeed).not.toBe(before.seed.btcEncryptedSeed) + }, +) + +test('Account - a migrated account still unlocks to the same addresses', async () => { + const mnemonic = await newMnemonic() + const legacyId = await saveLegacyAccount(3, mnemonic) + const { id: envelopeId } = await createAccount({ mnemonic }) + + const migrated = await unlockAccount(legacyId, password, btcOnly) + const fresh = await unlockAccount(envelopeId, password, btcOnly) + + expect(receivingAddressesOf(migrated.addresses)).toStrictEqual( + receivingAddressesOf(fresh.addresses), + ) + + const again = await unlockAccount(legacyId, password, btcOnly) + expect(receivingAddressesOf(again.addresses)).toStrictEqual( + receivingAddressesOf(migrated.addresses), + ) +}) + +test('Account - HTLS secrets survive the migration', async () => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + await unlockAccount(id, password, btcOnly) + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'abc' }), + ).resolves.toBe('legacy-htls-secret') +}) + +test('Account - an HTLS secret can be stored and read back on an envelope account', async () => { + const { id } = await createAccount() + + await saveProvidedHtlsSecret({ + accountId: id, + password, + data: { hash: 'def', secret: 'fresh-secret', txHash: 'tx' }, + }) + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'def' }), + ).resolves.toBe('fresh-secret') +}) + +test('Account - a wrong password cannot read an HTLS secret', async () => { + const { id } = await createAccount() + + await saveProvidedHtlsSecret({ + accountId: id, + password, + data: { hash: 'def', secret: 'fresh-secret', txHash: 'tx' }, + }) + + await expect( + unlockHtlsSecret({ accountId: id, password: 'pasz', hash: 'def' }), + ).rejects.toBe('Invalid password') +}) + +test('Account - an account whose ML blobs cannot be decrypted is left untouched', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const mnemonic = await newMnemonic() + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version: 3 }) + const otherKey = await Cipher.generateDek() + + const seed = await BTC.getSeedFromMnemonic(mnemonic) + const btc = await Cipher.encryptAES({ data: seed, key }) + const mlTestnet = await Cipher.encryptAES({ + data: 'ml-testnet', + key: otherKey, + }) + const mlMainnet = await Cipher.encryptAES({ + data: 'ml-mainnet', + key: otherKey, + }) + + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: accountName, + salt, + encryptionVersion: 3, + iv: { + btcIv: btc.iv, + mlTestnetPrivKeyIv: mlTestnet.iv, + mlMainnetPrivKeyIv: mlMainnet.iv, + }, + tag: { + btcTag: btc.tag, + mlTestnetPrivKeyTag: mlTestnet.tag, + mlMainnetPrivKeyTag: mlMainnet.tag, + }, + seed: { + btcEncryptedSeed: btc.encryptedData, + encryptedMlTestnetPrivateKey: mlTestnet.encryptedData, + encryptedMlMainnetPrivateKey: mlMainnet.encryptedData, + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: defaultWalletsToCreate, + htlsSecrets: {}, + }) + + const before = await getAccount(id) + + await expect(unlockAccount(id, password, btcOnly)).rejects.toBeDefined() + + const after = await getAccount(id) + + expect(after.encryptionVersion).toBe(3) + expect(after.salt).toBe(before.salt) + expect(after.seed).toStrictEqual(before.seed) + expect(after.wrappedDek).toBeUndefined() + + console.error.mockRestore() +}) + +const CREDENTIAL_ID = 'Y3JlZC1pZC0x' + +const enrollPasskey = async (id, prfOutput) => { + const account = await getAccount(id) + + const { key: wrappingKey } = await Cipher.generatePBKDF2Key({ + password, + salt: account.salt, + version: account.encryptionVersion, + }) + const dek = await Cipher.unwrapDek({ + data: account.wrappedDek.password.encryptedData, + iv: account.wrappedDek.password.iv, + tag: account.wrappedDek.password.tag, + wrappingKey, + aad: Cipher.wrapperAad('password'), + }) + + const wrapper = await buildPasskeyWrapper({ + dek, + credentialId: CREDENTIAL_ID, + prfSalt: 'c2FsdA', + prfOutput, + label: 'Touch ID', + }) + + await updateAccount(id, { + wrappedDek: { ...account.wrappedDek, passkeys: [wrapper] }, + }) + + return wrapper +} + +const passkeyCredential = (prfOutput, credentialId = CREDENTIAL_ID) => ({ + kind: 'passkey', + credentialId, + prfOutput, +}) + +test('Account - the same account unlocks with both a password and a passkey', async () => { + const { id } = await createAccount() + const prfOutput = await Cipher.generateDek() + + await enrollPasskey(id, prfOutput) + + const byPassword = await unlockAccount(id, password, btcOnly) + const byPasskey = await unlockAccount( + id, + passkeyCredential(prfOutput), + btcOnly, + ) + + expect(receivingAddressesOf(byPasskey.addresses)).toStrictEqual( + receivingAddressesOf(byPassword.addresses), + ) +}) + +test('Account - enrolling a passkey does not re-encrypt the seed', async () => { + const { id } = await createAccount() + const before = await getAccount(id) + + await enrollPasskey(id, await Cipher.generateDek()) + + const after = await getAccount(id) + + expect(after.seed).toStrictEqual(before.seed) + expect(after.iv).toStrictEqual(before.iv) + expect(after.tag).toStrictEqual(before.tag) + expect(after.wrappedDek.password).toStrictEqual(before.wrappedDek.password) +}) + +test('Account - a passkey wrapper records the fields the enrolment UI needs', async () => { + const { id } = await createAccount() + + const wrapper = await enrollPasskey(id, await Cipher.generateDek()) + + expect(wrapper.credentialId).toBe(CREDENTIAL_ID) + expect(wrapper.prfSalt).toBe('c2FsdA') + expect(wrapper.kdf).toBe(Cipher.PASSKEY_KDF) + expect(wrapper.label).toBe('Touch ID') + expect(typeof wrapper.createdAt).toBe('number') +}) + +test('Account - a wrong PRF output cannot unlock', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const { id } = await createAccount() + + await enrollPasskey(id, await Cipher.generateDek()) + + await expect( + unlockAccount(id, passkeyCredential(await Cipher.generateDek()), btcOnly), + ).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('Account - an unknown credential id cannot unlock', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const { id } = await createAccount() + const prfOutput = await Cipher.generateDek() + + await enrollPasskey(id, prfOutput) + + await expect( + unlockAccount(id, passkeyCredential(prfOutput, 'b3RoZXI'), btcOnly), + ).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('Account - a passkey cannot unlock a legacy account', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + await expect( + unlockAccount(id, passkeyCredential(await Cipher.generateDek()), btcOnly), + ).rejects.toBeDefined() + + const after = await getAccount(id) + expect(after.encryptionVersion).toBe(3) + + console.error.mockRestore() +}) + +test('Account - checkPasswordValidity still works on an account with a passkey', async () => { + const { id } = await createAccount() + + await enrollPasskey(id, await Cipher.generateDek()) + + await expect(checkPasswordValidity(id, password)).resolves.toBe(true) + await expect(checkPasswordValidity(id, 'pasz')).resolves.toBe(false) +}) + +const countDeriveBits = async (task) => { + const original = crypto.subtle.deriveBits.bind(crypto.subtle) + const spy = jest.fn(original) + crypto.subtle.deriveBits = spy + + try { + await task() + } finally { + crypto.subtle.deriveBits = original } - const wrongPass = 'pasz' - // const id = await saveAccount(data) - - // await expect(async () => { - // await unlockAccount(id, wrongPass) - // }).rejects.toThrow() -}) - -// test('Accouts wallets to create - default', async () => { -// const { generateNewAccountMnemonic } = await loadAccountSubRoutines() -// const mnemonic = await generateNewAccountMnemonic(ENTROPY_DATA) -// const data = { -// name: accountName, -// password, -// mnemonic, -// walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, -// } -// const id = await saveAccount(data) -// const { addresses } = await unlockAccount(id, password) - -// expect(addresses.btcMainnetAddress).toBeDefined() -// expect(addresses.btcTestnetAddress).toBeDefined() -// }) - -// test('Accouts wallets to create - custom', async () => { -// const { generateNewAccountMnemonic } = await loadAccountSubRoutines() -// const mnemonic = await generateNewAccountMnemonic(ENTROPY_DATA) -// const data = { -// name: accountName, -// password, -// mnemonic, -// walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, -// walletsToCreate: customWalletsToCreate2, -// } -// const id = await saveAccount(data) -// const { addresses } = await unlockAccount(id, password) - -// expect(addresses.btcMainnetAddress).toBeDefined() -// expect(addresses.btcTestnetAddress).toBeDefined() -// expect(addresses.mlMainnetAddress).toBeDefined() -// expect(addresses.mlTestnetAddress).toBeDefined() -// }) + + return spy.mock.calls.length +} + +test('Account - migrating an account derives the password key twice, not three times', async () => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + const migrating = await countDeriveBits(() => + unlockAccount(id, password, btcOnly), + ) + const steadyState = await countDeriveBits(() => + unlockAccount(id, password, btcOnly), + ) + + expect(migrating).toBe(2) + expect(steadyState).toBe(1) +}) + +test('Account - concurrent unlocks migrate the account exactly once', async () => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + const results = await Promise.all([ + unlockAccount(id, password, btcOnly), + unlockAccount(id, password, btcOnly), + unlockAccount(id, password, btcOnly), + ]) + + const account = await getAccount(id) + + expect(account.encryptionVersion).toBe(4) + expect(account.wrappedDek.passkeys).toStrictEqual([]) + + results.forEach((result) => + expect(receivingAddressesOf(result.addresses)).toStrictEqual( + receivingAddressesOf(results[0].addresses), + ), + ) + + await expect(unlockAccount(id, password, btcOnly)).resolves.toBeDefined() +}) + +test('Account - a secret saved while a migration runs is not lost', async () => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + await Promise.all([ + unlockAccount(id, password, btcOnly), + saveProvidedHtlsSecret({ + accountId: id, + password, + data: { hash: 'concurrent', secret: 'concurrent-secret', txHash: 'tx' }, + }), + ]) + + const account = await getAccount(id) + expect(account.encryptionVersion).toBe(4) + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'concurrent' }), + ).resolves.toBe('concurrent-secret') + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'abc' }), + ).resolves.toBe('legacy-htls-secret') +}) + +test('Account - a genuine v1 record (no mintlayer keys) unlocks and migrates', async () => { + const mnemonic = await newMnemonic() + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version: 1 }) + const seed = await BTC.getSeedFromMnemonic(mnemonic) + const btc = await Cipher.encryptAES({ data: seed, key }) + + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: accountName, + salt, + encryptionVersion: 1, + iv: { btcIv: btc.iv }, + tag: { btcTag: btc.tag }, + seed: { btcEncryptedSeed: btc.encryptedData }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + htlsSecrets: {}, + }) + + const { addresses } = await unlockAccount(id, password, btcOnly) + + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan(0) + + const after = await getAccount(id) + + expect(after.encryptionVersion).toBe(4) + expect(after.seed.encryptedMlTestnetPrivateKey).toBeUndefined() + expect(after.seed.encryptedMlMainnetPrivateKey).toBeUndefined() + expect(after.wrappedDek.passkeys).toStrictEqual([]) + + await expect(unlockAccount(id, password, btcOnly)).resolves.toBeDefined() +}) + +test('Account - swapping two content blobs under the same DEK is rejected', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const { id } = await createAccount() + const account = await getAccount(id) + + await updateAccount(id, { + seed: { + ...account.seed, + encryptedMlTestnetPrivateKey: account.seed.encryptedMlMainnetPrivateKey, + encryptedMlMainnetPrivateKey: account.seed.encryptedMlTestnetPrivateKey, + }, + iv: { + ...account.iv, + mlTestnetPrivKeyIv: account.iv.mlMainnetPrivKeyIv, + mlMainnetPrivKeyIv: account.iv.mlTestnetPrivKeyIv, + }, + tag: { + ...account.tag, + mlTestnetPrivKeyTag: account.tag.mlMainnetPrivKeyTag, + mlMainnetPrivKeyTag: account.tag.mlTestnetPrivKeyTag, + }, + }) + + await expect(unlockAccount(id, password, btcOnly)).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('Account - an HTLS secret is bound to its own hash', async () => { + const { id } = await createAccount() + + await saveProvidedHtlsSecret({ + accountId: id, + password, + data: { hash: 'first', secret: 'first-secret', txHash: 'tx' }, + }) + + const account = await getAccount(id) + + await updateAccount(id, { + htlsSecrets: { + ...account.htlsSecrets, + second: account.htlsSecrets.first, + }, + }) + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'first' }), + ).resolves.toBe('first-secret') + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'second' }), + ).rejects.toBe('Failed to decrypt the secret. Possibly wrong password.') +}) + +const saveLegacyAccountWithSecrets = async (mnemonic, secrets) => { + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version: 3 }) + const encrypt = (data, withKey = key) => + Cipher.encryptAES({ data, key: withKey }) + + const seed = await BTC.getSeedFromMnemonic(mnemonic) + const btc = await encrypt(seed) + const mlTestnet = await encrypt( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.TESTNET), + ) + const mlMainnet = await encrypt( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.MAINNET), + ) + + const htlsSecrets = {} + for (const [hash, { value, readable }] of Object.entries(secrets)) { + const blob = await encrypt( + value, + readable ? key : await Cipher.generateDek(), + ) + htlsSecrets[hash] = { + encryptedHtlsSecret: blob.encryptedData, + htlsIv: blob.iv, + htlsTag: blob.tag, + txHash: 'tx', + } + } + + return IndexedDB.save(await IndexedDB.loadAccounts(), { + name: accountName, + salt, + encryptionVersion: 3, + iv: { + btcIv: btc.iv, + mlTestnetPrivKeyIv: mlTestnet.iv, + mlMainnetPrivKeyIv: mlMainnet.iv, + }, + tag: { + btcTag: btc.tag, + mlTestnetPrivKeyTag: mlTestnet.tag, + mlMainnetPrivKeyTag: mlMainnet.tag, + }, + seed: { + btcEncryptedSeed: btc.encryptedData, + encryptedMlTestnetPrivateKey: mlTestnet.encryptedData, + encryptedMlMainnetPrivateKey: mlMainnet.encryptedData, + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: defaultWalletsToCreate, + htlsSecrets, + }) +} + +test('Account - one unreadable HTLS secret does not block the wallet', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const mnemonic = await newMnemonic() + const id = await saveLegacyAccountWithSecrets(mnemonic, { + good: { value: 'good-secret', readable: true }, + broken: { value: 'lost-secret', readable: false }, + }) + + const { addresses } = await unlockAccount(id, password, btcOnly) + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan(0) + + const migrated = await getAccount(id) + expect(migrated.encryptionVersion).toBe(4) + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'good' }), + ).resolves.toBe('good-secret') + + await expect( + unlockHtlsSecret({ accountId: id, password, hash: 'broken' }), + ).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('Account - a failing migration still lets the wallet open', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + const generateDek = jest + .spyOn(Cipher, 'generateDek') + .mockRejectedValueOnce(new Error('no entropy')) + + const { addresses } = await unlockAccount(id, password, btcOnly) + expect(addresses.btcAddresses.btcReceivingAddresses.length).toBeGreaterThan(0) + + const account = await getAccount(id) + expect(account.encryptionVersion).toBe(3) + + generateDek.mockRestore() + + await expect(unlockAccount(id, password, btcOnly)).resolves.toBeDefined() + await expect(getAccount(id)).resolves.toMatchObject({ encryptionVersion: 4 }) + + console.error.mockRestore() +}) + +test('Account - both ml private keys decrypt to the keys the mnemonic derives', async () => { + const { id, mnemonic } = await createAccount({ + walletsToCreate: ['btc', 'ml'], + }) + + const { mlPrivKeys } = await unlockAccount(id, password, btcOnly) + + expect(mlPrivKeys.mlTestnetPrivateKey).toStrictEqual( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.TESTNET), + ) + expect(mlPrivKeys.mlMainnetPrivateKey).toStrictEqual( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.MAINNET), + ) + expect(mlPrivKeys.mlTestnetPrivateKey).not.toStrictEqual( + mlPrivKeys.mlMainnetPrivateKey, + ) +}) + +test('Account - a migrated legacy account still yields the right ml keys', async () => { + const mnemonic = await newMnemonic() + const id = await saveLegacyAccount(3, mnemonic) + + await unlockAccount(id, password, btcOnly) + + expect((await getAccount(id)).encryptionVersion).toBe(4) + + const { mlPrivKeys } = await unlockAccount(id, password, btcOnly) + + expect(mlPrivKeys.mlTestnetPrivateKey).toStrictEqual( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.TESTNET), + ) + expect(mlPrivKeys.mlMainnetPrivateKey).toStrictEqual( + ML.getPrivateKeyFromMnemonic(mnemonic, AppInfo.NETWORK_TYPES.MAINNET), + ) +}) diff --git a/src/services/Entity/Account/Account.workerPath.test.js b/src/services/Entity/Account/Account.workerPath.test.js new file mode 100644 index 00000000..afa2dfcc --- /dev/null +++ b/src/services/Entity/Account/Account.workerPath.test.js @@ -0,0 +1,233 @@ +import { Cipher, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { LocalStorageService } from '@Storage' +import { IndexedDB } from '@Databases' +import initWasm from 'src/tests/helpers/initWasm' + +jest.mock('src/utils/Constants/EnvironmentVars/EnvironmentVars', () => ({ + ...jest.requireActual('src/utils/Constants/EnvironmentVars/EnvironmentVars'), + USE_WEB_WORKERS: true, +})) + +const PASSWORD = 'pass' +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const btcOnly = { wallets: ['btc'] } + +const handlers = {} + +const captureHandler = (name, load) => { + self.onmessage = undefined + load() + handlers[name] = self.onmessage +} + +const makeWorker = (handler) => { + const worker = { + terminate: jest.fn(), + postMessage: (message) => { + const original = globalThis.postMessage + const restore = () => + Object.defineProperty(globalThis, 'postMessage', { + configurable: true, + writable: true, + value: original, + }) + + Object.defineProperty(globalThis, 'postMessage', { + configurable: true, + writable: true, + value: (data) => { + restore() + worker.onmessage({ data }) + }, + }) + + Promise.resolve(handler({ data: message })).catch(restore) + }, + } + + return worker +} + +let Account + +beforeAll(async () => { + initWasm() + + captureHandler('cipher', () => + require('src/services/Crypto/Cipher/Cipher.worker'), + ) + captureHandler('btc', () => require('src/services/Crypto/BTC/BTC.worker')) + + global.Worker = jest.fn((url) => + makeWorker(String(url).includes('BTC') ? handlers.btc : handlers.cipher), + ) + + Account = await import('./Account') +}) + +afterAll(() => { + delete global.Worker +}) + +beforeEach(() => { + LocalStorageService.setItem('networkType', 'testnet') +}) + +const createAccount = async (mnemonic = MNEMONIC) => + Account.saveAccount({ + name: 'Savings', + password: PASSWORD, + mnemonic, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + }) + +const receivingAddressesOf = (addresses) => + addresses.btcAddresses.btcReceivingAddresses.map( + (entry) => Object.keys(entry)[0], + ) + +test('worker path - the subroutines really come from the worker module', async () => { + const loadAccountSubRoutines = (await import('./loadWorkers')).default + const { encryptSeed } = await loadAccountSubRoutines() + const workers = await import('./Account.worker') + + expect(encryptSeed).toBe(workers.encryptSeed) +}) + +test('worker path - an account can be created and unlocked', async () => { + const id = await createAccount() + + const { addresses, name } = await Account.unlockAccount(id, PASSWORD, btcOnly) + + expect(name).toBe('Savings') + expect(receivingAddressesOf(addresses).length).toBeGreaterThan(0) +}) + +test('worker path - a wrong password rejects', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + const id = await createAccount() + + await expect( + Account.unlockAccount(id, 'wrong', btcOnly), + ).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('worker path - additional data really protects the stored blobs', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const id = await createAccount() + const account = await Account.getAccount(id) + + // The two mintlayer blobs have identical shape, so without the aad binding the + // swap decrypts cleanly and the unlock succeeds. + await Account.updateAccount(id, { + seed: { + ...account.seed, + encryptedMlTestnetPrivateKey: account.seed.encryptedMlMainnetPrivateKey, + encryptedMlMainnetPrivateKey: account.seed.encryptedMlTestnetPrivateKey, + }, + iv: { + ...account.iv, + mlTestnetPrivKeyIv: account.iv.mlMainnetPrivKeyIv, + mlMainnetPrivKeyIv: account.iv.mlTestnetPrivKeyIv, + }, + tag: { + ...account.tag, + mlTestnetPrivKeyTag: account.tag.mlMainnetPrivKeyTag, + mlMainnetPrivKeyTag: account.tag.mlTestnetPrivKeyTag, + }, + }) + + await expect( + Account.unlockAccount(id, PASSWORD, btcOnly), + ).rejects.toBeDefined() + + console.error.mockRestore() +}) + +test('worker path - a legacy account migrates to the envelope', async () => { + const { key, salt } = await Cipher.generatePBKDF2Key({ + password: PASSWORD, + version: 3, + }) + const seed = await ( + await import('@Cryptos') + ).BTC.getSeedFromMnemonic(MNEMONIC) + const btc = await Cipher.encryptAES({ data: seed, key }) + const ml = await Cipher.encryptAES({ data: 'ml-key', key }) + + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: 'Legacy', + salt, + encryptionVersion: 3, + iv: { + btcIv: btc.iv, + mlTestnetPrivKeyIv: ml.iv, + mlMainnetPrivKeyIv: ml.iv, + }, + tag: { + btcTag: btc.tag, + mlTestnetPrivKeyTag: ml.tag, + mlMainnetPrivKeyTag: ml.tag, + }, + seed: { + btcEncryptedSeed: btc.encryptedData, + encryptedMlTestnetPrivateKey: ml.encryptedData, + encryptedMlMainnetPrivateKey: ml.encryptedData, + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + htlsSecrets: {}, + }) + + await Account.unlockAccount(id, PASSWORD, btcOnly) + + const migrated = await Account.getAccount(id) + + expect(migrated.encryptionVersion).toBe(4) + expect(migrated.wrappedDek.passkeys).toStrictEqual([]) + + await expect( + Account.unlockAccount(id, PASSWORD, btcOnly), + ).resolves.toBeDefined() +}) + +test('worker path - a legacy btc-only account without ml keys still unlocks', async () => { + const { key, salt } = await Cipher.generatePBKDF2Key({ + password: PASSWORD, + version: 3, + }) + const seed = await ( + await import('@Cryptos') + ).BTC.getSeedFromMnemonic(MNEMONIC) + const btc = await Cipher.encryptAES({ data: seed, key }) + + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: 'BtcOnly', + salt, + encryptionVersion: 3, + iv: { btcIv: btc.iv }, + tag: { btcTag: btc.tag }, + seed: { btcEncryptedSeed: btc.encryptedData }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + htlsSecrets: {}, + }) + + const { addresses } = await Account.unlockAccount(id, PASSWORD, btcOnly) + + expect(receivingAddressesOf(addresses).length).toBeGreaterThan(0) + + const migrated = await Account.getAccount(id) + + expect(migrated.encryptionVersion).toBe(4) + expect(migrated.seed.encryptedMlTestnetPrivateKey).toBeUndefined() + + await expect( + Account.unlockAccount(id, PASSWORD, btcOnly), + ).resolves.toBeDefined() +}) diff --git a/src/services/Entity/Account/AccountHelpers.js b/src/services/Entity/Account/AccountHelpers.js index 9fa13d3c..f5029885 100644 --- a/src/services/Entity/Account/AccountHelpers.js +++ b/src/services/Entity/Account/AccountHelpers.js @@ -1,18 +1,26 @@ import { AppInfo } from '@Constants' -import { ML } from '@Cryptos' +import { ML, Cipher } from '@Cryptos' import loadAccountSubRoutines from './loadWorkers' -const getEncryptedPrivateKeys = async (password, salt, mnemonic) => { +const getEnvelopeEncryptedPrivateKeys = async (password, salt, mnemonic) => { const { generateSeed, generateEncryptionKey, encryptSeed } = await loadAccountSubRoutines() - const { key, salt: usedSalt } = await generateEncryptionKey({ + + const { key: wrappingKey, salt: usedSalt } = await generateEncryptionKey({ password, salt, + version: Cipher.ENVELOPE_ENCRYPTION_VERSION, }) + + const dek = await Cipher.generateDek() const seed = await generateSeed(mnemonic) - const encryptData = async (data) => { - const { encryptedData, iv, tag } = await encryptSeed({ data, key }) + const encryptData = async (data, field) => { + const { encryptedData, iv, tag } = await encryptSeed({ + data, + key: dek, + aad: Cipher.contentAad(field), + }) return { encryptedData, iv, tag } } @@ -29,22 +37,30 @@ const getEncryptedPrivateKeys = async (password, salt, mnemonic) => { encryptedData: encryptedMlTestnetPrivateKey, iv: mlTestnetPrivKeyIv, tag: mlTestnetPrivKeyTag, - } = await encryptData(mlTestnetPrivateKey) + } = await encryptData(mlTestnetPrivateKey, 'encryptedMlTestnetPrivateKey') const { encryptedData: encryptedMlMainnetPrivateKey, iv: mlMainnetPrivKeyIv, tag: mlMainnetPrivKeyTag, - } = await encryptData(mlMainnetPrivateKey) + } = await encryptData(mlMainnetPrivateKey, 'encryptedMlMainnetPrivateKey') const { encryptedData: btcEncryptedSeed, iv: btcIv, tag: btcTag, - } = await encryptData(seed) + } = await encryptData(seed, 'btcEncryptedSeed') + + const passwordWrapper = await Cipher.wrapDek({ + dek, + wrappingKey, + aad: Cipher.wrapperAad('password'), + }) return { salt: usedSalt, + encryptionVersion: Cipher.ENVELOPE_ENCRYPTION_VERSION, + wrappedDek: { password: passwordWrapper, passkeys: [] }, encryptedMlTestnetPrivateKey, encryptedMlMainnetPrivateKey, btcEncryptedSeed, @@ -57,17 +73,46 @@ const getEncryptedPrivateKeys = async (password, salt, mnemonic) => { } } -const getEncryptedHtlsSecret = async (password, salt, secret, version) => { - const { generateEncryptionKey, encryptSeed } = await loadAccountSubRoutines() - const { key } = await generateEncryptionKey({ password, salt, version }) +const buildPasskeyWrapper = async ({ + dek, + credentialId, + prfSalt, + prfOutput, + label, +}) => { + const wrappingKey = await Cipher.deriveKekFromPrf(prfOutput) + const { encryptedData, iv, tag } = await Cipher.wrapDek({ + dek, + wrappingKey, + aad: Cipher.wrapperAad(credentialId), + }) + + return { + credentialId, + prfSalt, + kdf: Cipher.PASSKEY_KDF, + label, + createdAt: Date.now(), + encryptedData, + iv, + tag, + } +} + +const getEncryptedHtlsSecret = async (key, secret, aad) => { + const { encryptSeed } = await loadAccountSubRoutines() const { encryptedData: encryptedHtlsSecret, iv: htlsIv, tag: htlsTag, - } = await encryptSeed({ data: secret, key }) + } = await encryptSeed({ data: secret, key, aad }) return { encryptedHtlsSecret, htlsIv, htlsTag } } -export { getEncryptedPrivateKeys, getEncryptedHtlsSecret } +export { + getEnvelopeEncryptedPrivateKeys, + buildPasskeyWrapper, + getEncryptedHtlsSecret, +} diff --git a/src/services/Entity/Account/AccountHelpers.test.js b/src/services/Entity/Account/AccountHelpers.test.js new file mode 100644 index 00000000..981f4db7 --- /dev/null +++ b/src/services/Entity/Account/AccountHelpers.test.js @@ -0,0 +1,345 @@ +import { ML, Cipher, BTC } from '@Cryptos' +import { AppInfo } from '@Constants' +import initWasm from 'src/tests/helpers/initWasm' +import { + getEnvelopeEncryptedPrivateKeys, + getEncryptedHtlsSecret, +} from './AccountHelpers' + +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const PASSWORD = 'MyStr0ng!Pass' + +beforeAll(() => initWasm()) + +const unwrapWith = (envelope, wrappingKey) => + Cipher.unwrapDek({ + data: envelope.wrappedDek.password.encryptedData, + iv: envelope.wrappedDek.password.iv, + tag: envelope.wrappedDek.password.tag, + wrappingKey, + aad: Cipher.wrapperAad('password'), + }) + +const decryptWith = (data, iv, tag, key, field) => + Cipher.decryptAES({ + data, + iv, + tag, + key, + aad: field ? Cipher.contentAad(field) : undefined, + }) + +const deriveWrappingKey = ( + salt, + version = Cipher.ENVELOPE_ENCRYPTION_VERSION, +) => Cipher.generatePBKDF2Key({ password: PASSWORD, salt, version }) + +test('AccountHelpers - envelope has the v4 shape', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + expect(envelope.encryptionVersion).toBe(Cipher.ENVELOPE_ENCRYPTION_VERSION) + expect(envelope.salt).toMatch(/^[0-9a-f]{32}$/) + + expect(Object.keys(envelope.wrappedDek)).toStrictEqual([ + 'password', + 'passkeys', + ]) + expect(envelope.wrappedDek.passkeys).toStrictEqual([]) + expect(Object.keys(envelope.wrappedDek.password)).toStrictEqual([ + 'encryptedData', + 'iv', + 'tag', + ]) + expect(envelope.wrappedDek.password.iv.length).toBe(Cipher.IVSIZE) + expect(envelope.wrappedDek.password.tag.length).toBe(16) + + const blobs = [ + 'btcEncryptedSeed', + 'encryptedMlTestnetPrivateKey', + 'encryptedMlMainnetPrivateKey', + 'btcIv', + 'mlTestnetPrivKeyIv', + 'mlMainnetPrivKeyIv', + 'btcTag', + 'mlTestnetPrivKeyTag', + 'mlMainnetPrivKeyTag', + ] + blobs.forEach((field) => expect(typeof envelope[field]).toBe('string')) +}) + +test('AccountHelpers - the DEK decrypts every stored blob', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const { key: wrappingKey } = await deriveWrappingKey(envelope.salt) + const dek = await unwrapWith(envelope, wrappingKey) + + expect(dek.length).toBe(Cipher.DEKSIZE) + + const seed = await decryptWith( + envelope.btcEncryptedSeed, + envelope.btcIv, + envelope.btcTag, + dek, + 'btcEncryptedSeed', + ) + const testnetKey = await decryptWith( + envelope.encryptedMlTestnetPrivateKey, + envelope.mlTestnetPrivKeyIv, + envelope.mlTestnetPrivKeyTag, + dek, + 'encryptedMlTestnetPrivateKey', + ) + const mainnetKey = await decryptWith( + envelope.encryptedMlMainnetPrivateKey, + envelope.mlMainnetPrivKeyIv, + envelope.mlMainnetPrivKeyTag, + dek, + 'encryptedMlMainnetPrivateKey', + ) + + const expectedSeed = await BTC.getSeedFromMnemonic(MNEMONIC) + const expectedTestnetKey = ML.getPrivateKeyFromMnemonic( + MNEMONIC, + AppInfo.NETWORK_TYPES.TESTNET, + ) + const expectedMainnetKey = ML.getPrivateKeyFromMnemonic( + MNEMONIC, + AppInfo.NETWORK_TYPES.MAINNET, + ) + + expect(Buffer.from(seed)).toStrictEqual(Buffer.from(expectedSeed)) + expect(Buffer.from(testnetKey)).toStrictEqual(Buffer.from(expectedTestnetKey)) + expect(Buffer.from(mainnetKey)).toStrictEqual(Buffer.from(expectedMainnetKey)) +}) + +test('AccountHelpers - blobs are not readable with the password key', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const { key: wrappingKey } = await deriveWrappingKey(envelope.salt) + + await expect( + decryptWith( + envelope.btcEncryptedSeed, + envelope.btcIv, + envelope.btcTag, + wrappingKey, + ), + ).rejects.toThrow('Incorrect password') +}) + +test('AccountHelpers - a wrong password cannot unwrap the DEK', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const { key: wrongKey } = await Cipher.generatePBKDF2Key({ + password: 'WrongPass', + salt: envelope.salt, + version: Cipher.ENVELOPE_ENCRYPTION_VERSION, + }) + + await expect(unwrapWith(envelope, wrongKey)).rejects.toThrow( + 'Incorrect password', + ) +}) + +test('AccountHelpers - the wrapping key is derived at v4, not a legacy version', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const { key: legacyKey } = await deriveWrappingKey(envelope.salt, 1) + expect(legacyKey.length).toBe(16) + + await expect(unwrapWith(envelope, legacyKey)).rejects.toThrow( + 'Invalid wrapping key', + ) + + const { key: v4Key } = await deriveWrappingKey(envelope.salt) + await expect(unwrapWith(envelope, v4Key)).resolves.toHaveLength( + Cipher.DEKSIZE, + ) +}) + +test('AccountHelpers - reuses a provided salt and generates one otherwise', async () => { + const providedSalt = await Cipher.generateSalt(16) + + const withSalt = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + providedSalt, + MNEMONIC, + ) + const withoutSalt = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + expect(withSalt.salt).toBe(providedSalt) + expect(withoutSalt.salt).not.toBe(providedSalt) + expect(withoutSalt.salt).toMatch(/^[0-9a-f]{32}$/) +}) + +test('AccountHelpers - every call uses a fresh DEK', async () => { + const salt = await Cipher.generateSalt(16) + + const first = await getEnvelopeEncryptedPrivateKeys(PASSWORD, salt, MNEMONIC) + const second = await getEnvelopeEncryptedPrivateKeys(PASSWORD, salt, MNEMONIC) + + const { key: wrappingKey } = await deriveWrappingKey(salt) + const firstDek = await unwrapWith(first, wrappingKey) + const secondDek = await unwrapWith(second, wrappingKey) + + expect(firstDek).not.toStrictEqual(secondDek) + expect(first.btcEncryptedSeed).not.toBe(second.btcEncryptedSeed) + + const firstSeed = await decryptWith( + first.btcEncryptedSeed, + first.btcIv, + first.btcTag, + firstDek, + 'btcEncryptedSeed', + ) + const secondSeed = await decryptWith( + second.btcEncryptedSeed, + second.btcIv, + second.btcTag, + secondDek, + 'btcEncryptedSeed', + ) + + expect(Buffer.from(firstSeed)).toStrictEqual(Buffer.from(secondSeed)) +}) + +test('AccountHelpers - one DEK is shared by all three blobs', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const ivs = [ + envelope.btcIv, + envelope.mlTestnetPrivKeyIv, + envelope.mlMainnetPrivKeyIv, + ] + expect(new Set(ivs).size).toBe(3) + + const { key: wrappingKey } = await deriveWrappingKey(envelope.salt) + const dek = await unwrapWith(envelope, wrappingKey) + + await expect( + decryptWith( + envelope.encryptedMlTestnetPrivateKey, + envelope.mlTestnetPrivKeyIv, + envelope.mlTestnetPrivKeyTag, + dek, + 'encryptedMlTestnetPrivateKey', + ), + ).resolves.toBeDefined() +}) + +test('AccountHelpers - stores distinct testnet and mainnet mintlayer keys', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + + const { key: wrappingKey } = await deriveWrappingKey(envelope.salt) + const dek = await unwrapWith(envelope, wrappingKey) + + const testnetKey = await decryptWith( + envelope.encryptedMlTestnetPrivateKey, + envelope.mlTestnetPrivKeyIv, + envelope.mlTestnetPrivKeyTag, + dek, + 'encryptedMlTestnetPrivateKey', + ) + const mainnetKey = await decryptWith( + envelope.encryptedMlMainnetPrivateKey, + envelope.mlMainnetPrivKeyIv, + envelope.mlMainnetPrivKeyTag, + dek, + 'encryptedMlMainnetPrivateKey', + ) + + expect(testnetKey.length).toBeGreaterThan(0) + expect(Buffer.from(testnetKey)).not.toStrictEqual(Buffer.from(mainnetKey)) +}) + +test('AccountHelpers - the envelope survives a JSON round trip', async () => { + const envelope = await getEnvelopeEncryptedPrivateKeys( + PASSWORD, + undefined, + MNEMONIC, + ) + const restored = JSON.parse(JSON.stringify(envelope)) + + const { key: wrappingKey } = await deriveWrappingKey(restored.salt) + const dek = await unwrapWith(restored, wrappingKey) + + const seed = await decryptWith( + restored.btcEncryptedSeed, + restored.btcIv, + restored.btcTag, + dek, + 'btcEncryptedSeed', + ) + const expectedSeed = await BTC.getSeedFromMnemonic(MNEMONIC) + + expect(Buffer.from(seed)).toStrictEqual(Buffer.from(expectedSeed)) +}) + +test('AccountHelpers - HTLS secret is encrypted with the key it is given', async () => { + const key = await Cipher.generateDek() + const secret = 'htls-secret-value' + + const first = await getEncryptedHtlsSecret(key, secret) + const second = await getEncryptedHtlsSecret(key, secret) + + expect(first.htlsIv).not.toBe(second.htlsIv) + expect(first.encryptedHtlsSecret).not.toBe(second.encryptedHtlsSecret) + + const decrypted = await decryptWith( + first.encryptedHtlsSecret, + first.htlsIv, + first.htlsTag, + key, + ) + + expect(Buffer.from(decrypted).toString()).toBe(secret) +}) + +test('AccountHelpers - an HTLS secret cannot be read with another key', async () => { + const key = await Cipher.generateDek() + const otherKey = await Cipher.generateDek() + + const encrypted = await getEncryptedHtlsSecret(key, 'htls-secret-value') + + await expect( + decryptWith( + encrypted.encryptedHtlsSecret, + encrypted.htlsIv, + encrypted.htlsTag, + otherKey, + ), + ).rejects.toThrow('Incorrect password') +}) diff --git a/src/services/Entity/Account/AccountPasskey.test.js b/src/services/Entity/Account/AccountPasskey.test.js new file mode 100644 index 00000000..811796d1 --- /dev/null +++ b/src/services/Entity/Account/AccountPasskey.test.js @@ -0,0 +1,359 @@ +import { Cipher, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { LocalStorageService } from '@Storage' +import initWasm from 'src/tests/helpers/initWasm' + +jest.mock('src/services/Crypto/Passkey/Passkey', () => ({ + __esModule: true, + isSupported: jest.fn(() => true), + enroll: jest.fn(), + getPrfOutput: jest.fn(), +})) + +const accountName = 'Savings' +const password = 'pass' +const btcOnly = { wallets: ['btc'] } +const MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + +let Passkey +let Account + +beforeAll(async () => { + initWasm() + Passkey = await import('src/services/Crypto/Passkey/Passkey') + Account = await import('./Account') +}) + +beforeEach(() => { + LocalStorageService.setItem('networkType', 'testnet') + jest.clearAllMocks() + Passkey.isSupported.mockReturnValue(true) +}) + +const createAccount = async () => + Account.saveAccount({ + name: accountName, + password, + mnemonic: MNEMONIC, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + }) + +const stubCeremony = async (credentialId = 'cred-1') => { + const prfOutput = await Cipher.generateDek() + const prfSalt = 'c2FsdA' + + Passkey.enroll.mockResolvedValue({ credentialId, prfSalt, prfOutput }) + Passkey.getPrfOutput.mockResolvedValue({ credentialId, prfOutput }) + + return { credentialId, prfSalt, prfOutput } +} + +const receivingAddressesOf = (addresses) => + addresses.btcAddresses.btcReceivingAddresses.map( + (entry) => Object.keys(entry)[0], + ) + +test('AccountPasskey - enrolling stores a wrapper and unlocking uses it', async () => { + const id = await createAccount() + const { credentialId, prfSalt } = await stubCeremony() + + const result = await Account.enrollPasskey({ + accountId: id, + password, + label: 'Touch ID', + }) + + expect(result).toStrictEqual({ credentialId, label: 'Touch ID' }) + expect(Passkey.enroll).toHaveBeenCalledWith(accountName) + + const account = await Account.getAccount(id) + const [wrapper] = account.wrappedDek.passkeys + + expect(wrapper.credentialId).toBe(credentialId) + expect(wrapper.prfSalt).toBe(prfSalt) + + const byPasskey = await Account.unlockAccountWithPasskey(id, btcOnly) + const byPassword = await Account.unlockAccount(id, password, btcOnly) + + expect(Passkey.getPrfOutput).toHaveBeenCalledWith([ + expect.objectContaining({ credentialId, prfSalt }), + ]) + expect(receivingAddressesOf(byPasskey.addresses)).toStrictEqual( + receivingAddressesOf(byPassword.addresses), + ) +}) + +test('AccountPasskey - enrolling does not touch the seed or the password wrapper', async () => { + const id = await createAccount() + await stubCeremony() + + const before = await Account.getAccount(id) + await Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }) + const after = await Account.getAccount(id) + + expect(after.seed).toStrictEqual(before.seed) + expect(after.wrappedDek.password).toStrictEqual(before.wrappedDek.password) +}) + +test('AccountPasskey - a wrong password cannot enroll a passkey', async () => { + const id = await createAccount() + await stubCeremony() + + await expect( + Account.enrollPasskey({ + accountId: id, + password: 'pasz', + label: 'Touch ID', + }), + ).rejects.toBeDefined() + + expect(Passkey.enroll).not.toHaveBeenCalled() + + const account = await Account.getAccount(id) + expect(account.wrappedDek.passkeys).toStrictEqual([]) +}) + +test('AccountPasskey - enrolment is refused when the browser has no passkey support', async () => { + const id = await createAccount() + Passkey.isSupported.mockReturnValue(false) + + await expect( + Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }), + ).rejects.toBe('Passkeys are not available in this browser') +}) + +test('AccountPasskey - the same passkey cannot be enrolled twice', async () => { + const id = await createAccount() + await stubCeremony() + + await Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }) + + await expect( + Account.enrollPasskey({ accountId: id, password, label: 'Again' }), + ).rejects.toBe('This passkey is already enrolled') +}) + +test('AccountPasskey - several passkeys can be enrolled and listed', async () => { + const id = await createAccount() + + await stubCeremony('cred-1') + await Account.enrollPasskey({ accountId: id, password, label: 'Laptop' }) + + await stubCeremony('cred-2') + await Account.enrollPasskey({ accountId: id, password, label: 'Phone' }) + + const passkeys = await Account.getPasskeys(id) + + expect(passkeys.map((entry) => entry.label)).toStrictEqual([ + 'Laptop', + 'Phone', + ]) + expect(passkeys[0]).not.toHaveProperty('encryptedData') + expect(passkeys[0]).not.toHaveProperty('prfSalt') +}) + +test('AccountPasskey - removing a passkey leaves the password working', async () => { + const id = await createAccount() + const { credentialId } = await stubCeremony() + + await Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }) + await Account.removePasskey({ accountId: id, credentialId }) + + await expect(Account.getPasskeys(id)).resolves.toStrictEqual([]) + await expect( + Account.unlockAccount(id, password, btcOnly), + ).resolves.toBeDefined() + await expect(Account.unlockAccountWithPasskey(id, btcOnly)).rejects.toBe( + 'No passkey is enrolled for this account', + ) +}) + +test('AccountPasskey - removing an unknown passkey is refused', async () => { + const id = await createAccount() + + await expect( + Account.removePasskey({ accountId: id, credentialId: 'nope' }), + ).rejects.toBe('Passkey not found') +}) + +test('AccountPasskey - a legacy account must be unlocked with a password first', async () => { + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version: 3 }) + const blob = await Cipher.encryptAES({ data: 'x', key }) + + const { IndexedDB } = await import('@Databases') + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: accountName, + salt, + encryptionVersion: 3, + iv: { btcIv: blob.iv }, + tag: { btcTag: blob.tag }, + seed: { btcEncryptedSeed: blob.encryptedData }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + htlsSecrets: {}, + }) + + await expect( + Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }), + ).rejects.toBe('Unlock this account with your password first') +}) + +test('AccountPasskey - the second enrolled passkey can unlock too', async () => { + const id = await createAccount() + + const first = await stubCeremony('cred-1') + await Account.enrollPasskey({ accountId: id, password, label: 'Laptop' }) + + const second = await stubCeremony('cred-2') + await Account.enrollPasskey({ accountId: id, password, label: 'Phone' }) + + const byPassword = await Account.unlockAccount(id, password, btcOnly) + + Passkey.getPrfOutput.mockResolvedValue({ + credentialId: second.credentialId, + prfOutput: second.prfOutput, + }) + const bySecond = await Account.unlockAccountWithPasskey(id, btcOnly) + + Passkey.getPrfOutput.mockResolvedValue({ + credentialId: first.credentialId, + prfOutput: first.prfOutput, + }) + const byFirst = await Account.unlockAccountWithPasskey(id, btcOnly) + + expect(receivingAddressesOf(bySecond.addresses)).toStrictEqual( + receivingAddressesOf(byPassword.addresses), + ) + expect(receivingAddressesOf(byFirst.addresses)).toStrictEqual( + receivingAddressesOf(byPassword.addresses), + ) +}) + +test('AccountPasskey - every enrolled passkey is offered to the authenticator', async () => { + const id = await createAccount() + + await stubCeremony('cred-1') + await Account.enrollPasskey({ accountId: id, password, label: 'Laptop' }) + + const second = await stubCeremony('cred-2') + await Account.enrollPasskey({ accountId: id, password, label: 'Phone' }) + + Passkey.getPrfOutput.mockResolvedValue({ + credentialId: second.credentialId, + prfOutput: second.prfOutput, + }) + await Account.unlockAccountWithPasskey(id, btcOnly) + + const offered = Passkey.getPrfOutput.mock.calls.at(-1)[0] + + expect(offered.map((entry) => entry.credentialId)).toStrictEqual([ + 'cred-1', + 'cred-2', + ]) +}) + +const writeDuringWrap = (id, buildPatch) => { + const AccountHelpers = require('./AccountHelpers') + const original = AccountHelpers.buildPasskeyWrapper + + return jest + .spyOn(AccountHelpers, 'buildPasskeyWrapper') + .mockImplementation(async (args) => { + const current = await Account.getAccount(id) + await Account.updateAccount(id, buildPatch(current)) + + return original(args) + }) +} + +test('AccountPasskey - a re-key landing mid-write is not clobbered', async () => { + const id = await createAccount() + await stubCeremony() + + const rekeyed = { encryptedData: 'fresh', iv: 'fresh-iv', tag: 'fresh-tag' } + const spy = writeDuringWrap(id, (current) => ({ + wrappedDek: { ...current.wrappedDek, password: rekeyed }, + })) + + await Account.enrollPasskey({ accountId: id, password, label: 'Touch ID' }) + spy.mockRestore() + + const account = await Account.getAccount(id) + + expect(account.wrappedDek.password).toStrictEqual(rekeyed) + expect(account.wrappedDek.passkeys).toHaveLength(1) +}) + +test('AccountPasskey - a passkey added mid-write is not lost', async () => { + const id = await createAccount() + await stubCeremony('cred-late') + + const other = { credentialId: 'cred-other', label: 'Other', prfSalt: 'x' } + const spy = writeDuringWrap(id, (current) => ({ + wrappedDek: { + ...current.wrappedDek, + passkeys: [...(current.wrappedDek?.passkeys ?? []), other], + }, + })) + + await Account.enrollPasskey({ accountId: id, password, label: 'Late' }) + spy.mockRestore() + + const passkeys = await Account.getPasskeys(id) + + expect(passkeys.map((entry) => entry.credentialId)).toStrictEqual([ + 'cred-other', + 'cred-late', + ]) +}) + +test('AccountPasskey - migrating an account with passkeys is refused, not silent', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}) + + const { BTC } = await import('@Cryptos') + const { IndexedDB } = await import('@Databases') + + const { key, salt } = await Cipher.generatePBKDF2Key({ password, version: 3 }) + const seed = await BTC.getSeedFromMnemonic(MNEMONIC) + const btc = await Cipher.encryptAES({ data: seed, key }) + const ml = await Cipher.encryptAES({ data: 'ml-key', key }) + + const id = await IndexedDB.save(await IndexedDB.loadAccounts(), { + name: 'Legacy with passkeys', + salt, + encryptionVersion: 3, + wrappedDek: { + passkeys: [{ credentialId: 'cred-1', prfSalt: 'c2FsdA' }], + }, + iv: { + btcIv: btc.iv, + mlTestnetPrivKeyIv: ml.iv, + mlMainnetPrivKeyIv: ml.iv, + }, + tag: { + btcTag: btc.tag, + mlTestnetPrivKeyTag: ml.tag, + mlMainnetPrivKeyTag: ml.tag, + }, + seed: { + btcEncryptedSeed: btc.encryptedData, + encryptedMlTestnetPrivateKey: ml.encryptedData, + encryptedMlMainnetPrivateKey: ml.encryptedData, + }, + walletType: BTC_ADDRESS_TYPE_ENUM.LEGACY, + walletsToCreate: ['btc'], + htlsSecrets: {}, + }) + + await expect( + Account.unlockAccount(id, password, btcOnly), + ).resolves.toBeDefined() + + const after = await Account.getAccount(id) + + expect(after.encryptionVersion).toBe(3) + expect(after.wrappedDek.passkeys).toHaveLength(1) + + console.error.mockRestore() +}) From 1cdb9387f14cb356e449bd76e917216df1469ee7 Mon Sep 17 00:00:00 2001 From: owlsua Date: Sun, 23 Aug 2026 23:11:39 +0200 Subject: [PATCH 6/7] feat: let people unlock with a passkey Adds the Passkeys section to Settings - enrol, list and remove - and a second button on the unlock screen that appears only when the account has a passkey. Enrolling asks for the password, because that is the only way to obtain the DEK to wrap. Removing an entry stops that authenticator opening this wallet; the copy says plainly that backups exported earlier still accept it. A passkey unlock runs no PBKDF2 at all, so it returns as fast as the authenticator answers. --- src/assets/images/icon-passkey.svg | 3 + src/assets/images/icon-plus.svg | 3 + .../containers/Login/SetPassword.module.css | 12 +- .../containers/Login/SetPassword.tsx | 54 ++++- .../SettingsPasskey/SettingsPasskey.js | 177 +++++++++++++++ .../SettingsPasskey.module.css | 207 ++++++++++++++++++ .../SettingsPasskey/SettingsPasskey.test.js | 110 ++++++++++ src/components/containers/index.js | 2 + src/pages/Login/SetAccountPassword.tsx | 7 + src/pages/Settings/Settings.tsx | 1 + 10 files changed, 574 insertions(+), 2 deletions(-) create mode 100644 src/assets/images/icon-passkey.svg create mode 100644 src/assets/images/icon-plus.svg create mode 100644 src/components/containers/Settings/SettingsPasskey/SettingsPasskey.js create mode 100644 src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css create mode 100644 src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.js diff --git a/src/assets/images/icon-passkey.svg b/src/assets/images/icon-passkey.svg new file mode 100644 index 00000000..dc088035 --- /dev/null +++ b/src/assets/images/icon-passkey.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/icon-plus.svg b/src/assets/images/icon-plus.svg new file mode 100644 index 00000000..0ae3cf7e --- /dev/null +++ b/src/assets/images/icon-plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/containers/Login/SetPassword.module.css b/src/components/containers/Login/SetPassword.module.css index e45863a2..e2e66c41 100644 --- a/src/components/containers/Login/SetPassword.module.css +++ b/src/components/containers/Login/SetPassword.module.css @@ -39,7 +39,6 @@ height: 13px; max-width: 13px; max-height: 13px; - margin-left: var(--space-sm); } .loginPasswordSubmit { @@ -47,6 +46,17 @@ margin-top: var(--space-3xs); } +.loginPasskeySubmit { + width: 100%; +} + +.passkeyButtonIcon { + display: block; + flex: 0 0 auto; + width: 16px; + height: 16px; +} + .loginPasswordSubmit:hover .loginButtonIcon { animation: moveArrowRight 0.3s ease-in-out; } diff --git a/src/components/containers/Login/SetPassword.tsx b/src/components/containers/Login/SetPassword.tsx index b433b54f..63d61352 100644 --- a/src/components/containers/Login/SetPassword.tsx +++ b/src/components/containers/Login/SetPassword.tsx @@ -1,4 +1,4 @@ -import { useState, FormEvent, ReactNode } from 'react' +import { useState, useEffect, FormEvent, ReactNode } from 'react' import { useLocation } from 'react-router' import { Button } from '@BasicComponents' @@ -6,6 +6,7 @@ import { LoadingScreen, TextField } from '@ComposedComponents' import { VerticalGroup, CenteredLayout } from '@LayoutComponents' import { ReactComponent as IconArrowRight } from '@Assets/images/icon-arrow-right.svg' import { ReactComponent as IconShield } from '@Assets/images/icon-shield.svg' +import { ReactComponent as IconPasskey } from '@Assets/images/icon-passkey.svg' import styles from './SetPassword.module.css' @@ -29,6 +30,10 @@ interface SetPasswordProps { selectedAccount?: Account buttonTitle?: string customLabel?: string | ReactNode + passkey?: { + isEnrolled: (id: string | number) => Promise + unlock: (id: string | number) => Promise + } } const SetPassword = ({ @@ -38,6 +43,7 @@ const SetPassword = ({ selectedAccount, buttonTitle = 'Unlock wallet', customLabel, + passkey, }: SetPasswordProps) => { const location = useLocation() const account: Account = selectedAccount @@ -53,6 +59,39 @@ const SetPassword = ({ const [accountPasswordErrorMessage, setAccountPasswordErrorMessage] = useState(null) const [unlockingAccount, setUnlockingAccount] = useState(false) + const [passkeyAvailable, setPasskeyAvailable] = useState(false) + + useEffect(() => { + if (!passkey) return + + let active = true + + passkey + .isEnrolled(account.id) + .then((available) => active && setPasskeyAvailable(available)) + .catch(() => active && setPasskeyAvailable(false)) + + return () => { + active = false + } + }, [account.id, passkey]) + + const passkeyHandler = async () => { + setAccountPasswordPristinity(false) + setUnlockingAccount(true) + + try { + const validated = await passkey!.unlock(account.id) + + if (!validated?.addresses) throw new Error('Unlock failed') + + onSubmit && onSubmit(validated.addresses, account.id, account.name) + } catch { + setUnlockingAccount(false) + setAccountPasswordValid(false) + setAccountPasswordErrorMessage('Could not unlock with the passkey') + } + } const passwordFieldValidity = async () => { try { @@ -136,6 +175,19 @@ const SetPassword = ({ + {passkeyAvailable ? ( + + + + ) : null} ) : ( diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.js b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.js new file mode 100644 index 00000000..b8a2f38a --- /dev/null +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.js @@ -0,0 +1,177 @@ +import { useState, useEffect, useContext, useCallback } from 'react' + +import { Error } from '@BasicComponents' +import { TextField } from '@ComposedComponents' +import { AccountContext } from '@Contexts' +import { Account } from '@Entities' +import { Passkey } from '@Cryptos' +import { ReactComponent as PasskeyIcon } from '@Assets/images/icon-passkey.svg' +import { ReactComponent as PlusIcon } from '@Assets/images/icon-plus.svg' + +import styles from './SettingsPasskey.module.css' + +const formatDate = (value) => + value + ? new Date(value).toLocaleDateString(undefined, { + day: 'numeric', + month: 'short', + year: 'numeric', + }) + : 'This device' + +const SettingsPasskey = () => { + const { accountID } = useContext(AccountContext) + const [passkeys, setPasskeys] = useState([]) + const [password, setPassword] = useState('') + const [adding, setAdding] = useState(false) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + const supported = Passkey.isSupported() + + const refresh = useCallback(async () => { + if (!accountID) return + setPasskeys(await Account.getPasskeys(accountID)) + }, [accountID]) + + useEffect(() => { + refresh() + }, [refresh]) + + const closeForm = () => { + setAdding(false) + setPassword('') + setError('') + } + + const submitAdding = async () => { + setBusy(true) + setError('') + + try { + await Account.enrollPasskey({ + accountId: accountID, + password, + label: `Passkey ${passkeys.length + 1}`, + }) + setAdding(false) + setPassword('') + await refresh() + } catch (e) { + setError(typeof e === 'string' ? e : 'Could not add the passkey') + } finally { + setBusy(false) + } + } + + const remove = async (credentialId) => { + setError('') + + try { + await Account.removePasskey({ accountId: accountID, credentialId }) + await refresh() + } catch { + setError('Could not remove the passkey') + } + } + + return ( +
    +

    Passkeys

    + + {!supported ? ( +

    + This browser cannot use passkeys for a wallet. +

    + ) : ( + <> +

    + Your password keeps working and stays the only way to recover the + wallet. Removing a passkey stops it opening this wallet, but backups + exported earlier still accept it. +

    + + {passkeys.length ? ( +
      + {passkeys.map((passkey) => ( +
    • + + + {passkey.label} + + Added {formatDate(passkey.createdAt)} + + + +
    • + ))} +
    + ) : ( +

    No passkeys yet.

    + )} + + {adding ? ( +
    +

    Confirm with your password

    + +
    + + +
    +
    + ) : ( + + )} + + + + )} +
    + ) +} + +export default SettingsPasskey diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css new file mode 100644 index 00000000..0c146401 --- /dev/null +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.module.css @@ -0,0 +1,207 @@ +.container { + display: flex; + flex-direction: column; + gap: var(--space-md); + width: 100%; +} + +.description { + font-size: var(--font-size-sm); + line-height: 1.5; + color: rgb(var(--color-dark-gray)); + margin: 0; +} + +.list { + display: flex; + flex-direction: column; + gap: var(--space-xs); + margin: 0; + padding: 0; + list-style: none; +} + +.item { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border: 1.5px solid rgb(var(--color-medium-gray)); + border-radius: var(--border-radius-input); + background: rgb(var(--color-white)); +} + +.itemIcon { + flex: 0 0 auto; + width: 18px; + height: 18px; + color: rgb(var(--color-dark-gray)); +} + +.itemText { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + flex: 1; +} + +.itemLabel { + font-size: var(--font-size-md); + font-weight: 600; + color: rgb(var(--color-black)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemMeta { + font-size: var(--font-size-xs); + color: rgb(var(--color-dark-gray)); +} + +.remove { + flex: 0 0 auto; + padding: var(--space-2xs) var(--space-sm); + border: none; + border-radius: var(--round-size-big); + background: transparent; + font-size: var(--font-size-sm); + font-weight: 600; + color: rgb(var(--color-dark-gray)); + cursor: pointer; + transition: all 0.25s ease; +} + +.remove:hover:not(:disabled) { + background: rgb(var(--color-red), 0.1); + color: rgb(var(--color-red)); +} + +.remove:focus-visible { + outline: 2px solid rgb(var(--color-red)); + outline-offset: 2px; +} + +.remove:disabled { + opacity: 0.5; + cursor: default; +} + +.empty { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + border: 1.5px dashed rgb(var(--color-medium-gray)); + border-radius: var(--border-radius-input); + font-size: var(--font-size-sm); + color: rgb(var(--color-dark-gray)); +} + +.add { + align-self: flex-start; + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-sm) var(--space-lg); + border: 1.5px solid rgb(var(--mojito-green)); + border-radius: var(--round-size-big); + background: rgb(var(--mojito-green-soft)); + font-size: var(--font-size-md); + font-weight: 600; + color: rgb(var(--mojito-green)); + cursor: pointer; + transition: all 0.25s ease; +} + +.add:hover { + background: rgb(var(--mojito-green)); + color: rgb(var(--color-white)); +} + +.add:focus-visible { + outline: 2px solid rgb(var(--mojito-green)); + outline-offset: 2px; +} + +.add svg { + width: 16px; + height: 16px; +} + +.form { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.formTitle { + font-size: var(--font-size-sm); + font-weight: 600; + color: rgb(var(--color-black)); + margin: 0; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2xs); +} + +.confirm, +.cancel { + padding: var(--space-sm) var(--space-xl); + border: none; + border-radius: var(--round-size-big); + font-size: var(--font-size-md); + font-weight: 600; + cursor: pointer; + transition: all 0.25s ease; +} + +.confirm { + background: rgb(var(--mojito-green)); + color: rgb(var(--color-white)); +} + +.confirm:hover:not(:disabled) { + background: rgb(var(--mojito-green-dark)); +} + +.confirm:disabled { + background: rgb(var(--color-medium-gray)); + color: rgb(var(--color-dark-gray)); + cursor: default; +} + +.cancel { + background: transparent; + color: rgb(var(--color-dark-gray)); +} + +.cancel:hover:not(:disabled) { + background: rgb(var(--color-black), 0.05); +} + +.cancel:disabled { + opacity: 0.5; + cursor: default; +} + +.confirm:focus-visible, +.cancel:focus-visible { + outline: 2px solid rgb(var(--mojito-green)); + outline-offset: 2px; +} + +.notice { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + border-radius: var(--border-radius-input); + background: rgb(var(--color-gray)); + font-size: var(--font-size-sm); + color: rgb(var(--color-dark-gray)); +} diff --git a/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.js b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.js new file mode 100644 index 00000000..75743c6e --- /dev/null +++ b/src/components/containers/Settings/SettingsPasskey/SettingsPasskey.test.js @@ -0,0 +1,110 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { AccountContext } from '@Contexts' +import { Account } from '@Entities' +import { Passkey } from '@Cryptos' + +import SettingsPasskey from './SettingsPasskey' + +jest.mock('@Entities', () => ({ + Account: { + getPasskeys: jest.fn(), + enrollPasskey: jest.fn(), + removePasskey: jest.fn(), + }, +})) + +const renderComponent = () => + render( + + + , + ) + +beforeEach(() => { + jest.clearAllMocks() + Account.getPasskeys.mockResolvedValue([]) + jest.spyOn(Passkey, 'isSupported').mockReturnValue(true) +}) + +afterEach(() => jest.restoreAllMocks()) + +test('SettingsPasskey - lists the enrolled passkeys', async () => { + Account.getPasskeys.mockResolvedValue([ + { credentialId: 'a', label: 'Laptop', createdAt: 1 }, + { credentialId: 'b', label: 'Phone', createdAt: 2 }, + ]) + + renderComponent() + + expect(await screen.findByText('Laptop')).toBeInTheDocument() + expect(screen.getByText('Phone')).toBeInTheDocument() +}) + +test('SettingsPasskey - tells the user when the browser cannot do this', async () => { + Passkey.isSupported.mockReturnValue(false) + + renderComponent() + + expect( + await screen.findByText('This browser cannot use passkeys for a wallet.'), + ).toBeInTheDocument() + expect(screen.queryByTestId('add-passkey')).not.toBeInTheDocument() +}) + +test('SettingsPasskey - enrolling asks for the password and refreshes the list', async () => { + Account.enrollPasskey.mockResolvedValue({ credentialId: 'a' }) + + renderComponent() + + await userEvent.click(await screen.findByTestId('add-passkey')) + await userEvent.type(screen.getByPlaceholderText('Password'), 'pass') + + Account.getPasskeys.mockResolvedValue([ + { credentialId: 'a', label: 'Passkey 1', createdAt: 1 }, + ]) + + await userEvent.click(screen.getByTestId('confirm-passkey')) + + await waitFor(() => + expect(Account.enrollPasskey).toHaveBeenCalledWith({ + accountId: 1, + password: 'pass', + label: 'Passkey 1', + }), + ) + expect(await screen.findByText('Passkey 1')).toBeInTheDocument() +}) + +test('SettingsPasskey - a failed enrolment is reported', async () => { + Account.enrollPasskey.mockRejectedValue('This passkey is already enrolled') + + renderComponent() + + await userEvent.click(await screen.findByTestId('add-passkey')) + await userEvent.type(screen.getByPlaceholderText('Password'), 'pass') + await userEvent.click(screen.getByTestId('confirm-passkey')) + + expect( + await screen.findByText('This passkey is already enrolled'), + ).toBeInTheDocument() +}) + +test('SettingsPasskey - removing a passkey refreshes the list', async () => { + Account.getPasskeys.mockResolvedValue([ + { credentialId: 'a', label: 'Laptop', createdAt: 1 }, + ]) + Account.removePasskey.mockResolvedValue(undefined) + + renderComponent() + + await userEvent.click(await screen.findByTestId('remove-passkey-a')) + + await waitFor(() => + expect(Account.removePasskey).toHaveBeenCalledWith({ + accountId: 1, + credentialId: 'a', + }), + ) +}) diff --git a/src/components/containers/index.js b/src/components/containers/index.js index 41c73bb8..47786703 100644 --- a/src/components/containers/index.js +++ b/src/components/containers/index.js @@ -32,6 +32,7 @@ import SettingsDelete from './Settings/SettingsDelete/SettingsDelete' import SettingsTestnet from './Settings/SettingsTestnet/SettingsTestnet.tsx' import SettingsAbout from './Settings/SettingsAbout/SettingsAbout.tsx' import SettingsBackup from './Settings/SettingsBackup/SettingsBackup' +import SettingsPasskey from './Settings/SettingsPasskey/SettingsPasskey' import SettingsSection from './Settings/SettingsSection/SettingsSection.tsx' import SignMessage from './Message/SignMessage/SignMessage' @@ -74,6 +75,7 @@ const Settings = { SettingsTestnet, SettingsDelete, SettingsBackup, + SettingsPasskey, SettingsSection, } diff --git a/src/pages/Login/SetAccountPassword.tsx b/src/pages/Login/SetAccountPassword.tsx index b100cfc8..454d9792 100644 --- a/src/pages/Login/SetAccountPassword.tsx +++ b/src/pages/Login/SetAccountPassword.tsx @@ -17,6 +17,12 @@ interface SetAccountPasswordPageProps { nextAfterUnlock?: NextAfterUnlock | null } +const passkey = { + isEnrolled: async (id: string | number) => + (await Account.getPasskeys(id)).length > 0, + unlock: Account.unlockAccountWithPasskey, +} + const SetAccountPasswordPage = ({ nextAfterUnlock, }: SetAccountPasswordPageProps) => { @@ -37,6 +43,7 @@ const SetAccountPasswordPage = ({ ) diff --git a/src/pages/Settings/Settings.tsx b/src/pages/Settings/Settings.tsx index af02261c..a8d912a6 100644 --- a/src/pages/Settings/Settings.tsx +++ b/src/pages/Settings/Settings.tsx @@ -19,6 +19,7 @@ const SettingsPage = ({ unlocked }: SettingsPageProps) => { key: 'wallet', visible: unlocked, items: [ + { key: 'passkey', component: }, { key: 'backup', component: }, { key: 'delete', component: }, ], From 5162e791bce419fc7858b63640ee4bc532db92c9 Mon Sep 17 00:00:00 2001 From: owlsua Date: Sun, 23 Aug 2026 23:12:19 +0200 Subject: [PATCH 7/7] docs: add CLAUDE.md Covers the things that cost time to rediscover: the packing step that creates popup.html, the three places path aliases are declared, why the worker path is invisible to jest, that the AAD strings are a storage format, and that the account lock only serialises within one document. --- CLAUDE.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c2f0f059 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +Mojito is a **non-custodial** Bitcoin + Mintlayer wallet shipped as a Manifest V3 browser extension for both Chromium browsers and Firefox. Seeds and private keys never leave the device: everything is encrypted at rest in IndexedDB and decrypted only in memory during an unlock. There is no server to fall back on, so a bug that corrupts or mis-encrypts a stored account destroys funds permanently. Treat anything under `src/services/Crypto` and `src/services/Entity/Account` accordingly. + +Node 18 (`.nvmrc`). + +## Commands + +```bash +npm start # webpack dev server on :3000 +npm run build # production build + packing.sh -> build/, ext.zip, extFF.zip +npm test # jest (NODE_ENV=test) +npm run lint # eslint +npm run pretty-quick # prettier +npm run e2e # playwright, headless +npm run e2e:ui # playwright, visual runner +``` + +A single test file or test: + +```bash +npx jest src/services/Crypto/Cipher/Cipher.test.js +npx jest src/services/Entity/Account -t "unlocks with a passkey" +npx jest --coverage --collectCoverageFrom='src/services/Crypto/Cipher/Cipher.js' +``` + +The pre-commit hook runs prettier, eslint and the whole suite, so all three must be green before a commit will land. + +Jest ignores `/tests/` (Playwright lives there) and `src/pages`. + +## Build layout + +`webpack.config.js` emits only `index.html`. `packing.sh` then copies it to **`popup.html`** and swaps in the per-browser manifest before zipping. Two consequences: + +- `popup.html` does not exist under `npm start`. Any code path that opens it 404s in dev. +- `public/manifestDefault.json` (Chromium) and `public/manifestFirefox.json` are separate files; whichever is being renamed to `manifest.json` at packing time wins. Changes must usually be made in both. + +The same `index.html` is the side panel (`side_panel.default_path` on Chromium, `sidebar_action.default_panel` on Firefox) and `popup.html` is the standalone tab. `src/index.js` and `AccountProvider` branch on `window.location.href.includes('popup.html')` to tell the two apart. Routing is `MemoryRouter`, so **the URL carries no route** — opening a new document always starts the app at its initial screen. + +## Path aliases + +Defined three times and kept in sync by hand: `jsconfig.json`, the `aliases` object in `webpack.config.js`, and `moduleNameMapper` in `jest.config.js`. Adding an alias means editing all three. + +`@BasicComponents` `@ComposedComponents` `@LayoutComponents` `@ContainerComponents` `@Contexts` `@Hooks` `@Pages` `@APIs` `@Cryptos` `@Databases` `@Entities` `@Helpers` `@Constants` `@Storage` `@TestData` `@Assets` `@Version` + +## Architecture + +### Layering (enforced by convention, see CONTRIBUTING.md) + +`basic` → `composed` → `layouts` are generic and must stay free of app logic. Only `containers` may hold page-specific logic. **Components must not import `@APIs`, `@Databases`, `@Cryptos` or `@Entities` directly** — those belong behind `@Entities`, which pages wire into containers via props. + +### The Account entity is the hub + +`src/services/Entity/Account/Account.js` owns every read and write of a stored account: creation, unlock, passkey enrolment, HTLC secrets and the encryption-version migration. Nothing else should write to the accounts store. + +Concurrency is guarded by `withAccountLock(id, task)`, an in-module promise chain. It only serialises within one document — the side panel, a standalone tab and the background worker each run their own module instance, so cross-document writes are still last-writer-wins. When a value is derived from the stored record, read it again immediately before the write. + +### Envelope encryption (version 4) + +A random 32-byte **DEK** encrypts the content (BTC seed, both ML private keys, HTLC secrets). The DEK is then wrapped separately by each credential that may unlock the wallet: + +- password → PBKDF2-SHA512, 600k iterations → `wrappedDek.password` +- each passkey → WebAuthn PRF output → HKDF-SHA256 → `wrappedDek.passkeys[]` + +Every AES-GCM operation is bound with `additionalData`, built by `contentAad` / `wrapperAad` / `htlsAad` in `Cipher.js`. These strings and the KDF parameters are a **storage format**: changing them consistently across encrypt and decrypt keeps round-trip tests green while making every already-stored wallet unopenable. They are pinned by known-answer tests in `Cipher.test.js` — if one of those fails, the change breaks existing users, not the test. + +Versions 1/2/3 are pre-envelope (content encrypted directly with the password key). `getAccountVersion` defaults a missing field to 1, `isEnvelope()` gates behaviour, and `aadFor()` returns `undefined` for pre-v4 records so legacy ciphertext still opens. Migration to v4 happens opportunistically on a successful unlock and is deliberately swallowed on failure: a failed migration must leave the account on its old version rather than deny access to a wallet that just decrypted fine. + +### Web Workers, and why tests do not see them + +`EnvVars.USE_WEB_WORKERS` is `process.env.NODE_ENV !== 'test'`. `loadAccountSubRoutines()` returns worker-backed functions in the browser and direct imports under jest, because jest cannot handle `new Worker(new URL(..., import.meta.url))`. + +**The production path is therefore invisible to ordinary tests.** A bug that only exists in the worker plumbing — a dropped argument, a swallowed error — passes the whole suite. `Account.workerPath.test.js` exists to cover that path by stubbing `global.Worker` and driving the real worker modules; extend it when touching `Account.worker.js`. + +Workers report failure through the envelope in `src/services/Crypto/Worker/WorkerContract.js`. The marker is `__workerError` rather than `error` because Mintlayer API payloads legitimately carry an `error` field. + +### Storage + +`IndexedDB.js` holds `SCHEMAVERSION` and the `accounts` store. Migrations run **inside the `versionchange` transaction** in `createOrUpdateDatabase`, gated on `event.oldVersion`, and `oldVersion === 0` returns early because a brand-new database has nothing to migrate. Migrations in `src/services/Database/migrations/migrations.js` are pure per-account transforms applied in sequence over a single read — issuing separate `getAll()` calls per migration makes a later one overwrite an earlier one's work. + +### Test environment + +Real Mintlayer wasm runs under jest via `src/tests/helpers/initWasm.js`; call `initWasm()` in `beforeAll`. `babel.config.js` stubs `import.meta` in the test env only. `jest.config.js` maps bare `buffer` to the npm polyfill so `Buffer` instances match the realm the wasm bindings validate against. + +Network-backed code is not testable as-is: `ML.getWalletAddresses` loops until the API reports an unused address, so without a stubbed response it spins until the heap dies. Assert on decrypted key material instead of on generated addresses. + +## Conventions + +- Branches `A-[asana id]`, PR titles `A-[asana id]: description`, base branch `dev`, squash merge. +- Plain JS is the default. Some newer files are `.tsx`; do not convert existing JS to TypeScript. +- CSS Modules for new components. No `:global()`. +- Each component lives in its own folder with its own test and CSS file.