From 5f0d138c4b4b790c6f423786a7c2453461606272 Mon Sep 17 00:00:00 2001 From: owlsua Date: Fri, 3 Apr 2026 23:20:40 +0200 Subject: [PATCH 1/8] refactor: migrate Cipher to Web Crypto API and add KDF iteration support Replace node-forge and pbkdf2 with native crypto.subtle for PBKDF2, AES-GCM, and random byte generation. Store kdfIterations per account and auto-migrate legacy accounts from 10k to 600k iterations on login. --- package-lock.json | 11 -- package.json | 24 ++-- src/services/Crypto/Cipher/Cipher.js | 130 +++++++++++------- src/services/Crypto/Cipher/Cipher.worker.js | 5 +- src/services/Entity/Account/Account.js | 88 ++++++++++++ src/services/Entity/Account/AccountHelpers.js | 4 +- 6 files changed, 180 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index c835dafc..30494a0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,8 +25,6 @@ "decimal.js": "^10.6.0", "ecpair": "3.0.0", "konva": "^10.2.0", - "node-forge": "^1.3.3", - "pbkdf2": "^3.1.5", "process": "^0.11.10", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -15526,15 +15524,6 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, - "node_modules/node-forge": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", - "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", diff --git a/package.json b/package.json index 17ab27c4..001fc3c4 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,6 @@ "decimal.js": "^10.6.0", "ecpair": "3.0.0", "konva": "^10.2.0", - "node-forge": "^1.3.3", - "pbkdf2": "^3.1.5", "process": "^0.11.10", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -64,7 +62,6 @@ "@babel/preset-env": "^7.29.0", "@babel/preset-react": "^7.28.5", "@eslint/js": "^9.39.2", - "eslint": "^9.39.2", "@playwright/test": "^1.58.2", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.1", @@ -74,13 +71,18 @@ "@types/node": "^25.2.3", "@types/tiny-secp256k1": "^2.0.1", "babel-jest": "^30.2.0", + "babel-loader": "^10.0.0", + "copy-webpack-plugin": "^13.0.1", + "css-loader": "^7.1.3", "dotenv": "^17.2.4", "dotenv-webpack": "^8.1.1", "env-cmd": "^11.0.0", + "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "fake-indexeddb": "^6.2.5", "globals": "^17.3.0", + "html-webpack-plugin": "^5.6.6", "http-server": "^14.1.1", "husky": "^9.1.7", "identity-obj-proxy": "^3.0.0", @@ -88,21 +90,17 @@ "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^30.2.0", "jest-webgl-canvas-mock": "^2.5.3", + "mini-css-extract-plugin": "^2.10.0", "prettier": "^3.8.1", "pretty-quick": "^4.2.2", "style-loader": "^4.0.0", - "css-loader": "^7.1.3", - "url-loader": "^4.1.1", - "babel-loader": "^10.0.0", - "html-webpack-plugin": "^5.6.6", - "mini-css-extract-plugin": "^2.10.0", - "copy-webpack-plugin": "^13.0.1", - "webpack": "^5.105.1", - "webpack-cli": "^6.0.1", - "webpack-dev-server": "^5.2.3", "typescript": "^5.9.3", + "url-loader": "^4.1.1", "vm-browserify": "^1.1.2", "wallet-address-validator": "^0.2.4", - "webextension-polyfill": "^0.12.0" + "webextension-polyfill": "^0.12.0", + "webpack": "^5.105.1", + "webpack-cli": "^6.0.1", + "webpack-dev-server": "^5.2.3" } } diff --git a/src/services/Crypto/Cipher/Cipher.js b/src/services/Crypto/Cipher/Cipher.js index 79698b3b..84ff31b0 100644 --- a/src/services/Crypto/Cipher/Cipher.js +++ b/src/services/Crypto/Cipher/Cipher.js @@ -1,11 +1,5 @@ -import { pbkdf2Sync } from 'pbkdf2' -import { - random as forgeRandom, - util as forgeUtil, - cipher as forgeCipher, -} from 'node-forge' - -const ITERATIONAMOUNT = 10_000 +const DEFAULT_ITERATIONS = 600000 +const LEGACY_ITERATIONS = 10000 const KEYSIZE = 16 const IVSIZE = 12 const SALTSIZE = 16 @@ -13,25 +7,39 @@ const SALTSIZE = 16 const hexToBytes = (hexString) => Uint8Array.from(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))) -const generateSalt = async ( - bytesAmount, - random = forgeRandom, - util = forgeUtil, -) => { - const bytes = await random.getBytes(bytesAmount) - return util.bytesToHex(bytes) +const generateSalt = async (bytesAmount) => { + const bytes = new Uint8Array(bytesAmount) + crypto.getRandomValues(bytes) + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') } -// * KEYS are kept in memory until we manage to migrate this part to WASM const generatePBKDF2Key = async ({ password, salt, - derivationFn = pbkdf2Sync, + iterations = DEFAULT_ITERATIONS, }) => { const currentSalt = salt || (await generateSalt(SALTSIZE)) - const key = [ - ...derivationFn(password, currentSalt, ITERATIONAMOUNT, KEYSIZE, 'sha512'), - ] + const encoder = new TextEncoder() + const baseKey = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveBits'], + ) + const derivedBits = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt: encoder.encode(currentSalt), + iterations, + hash: 'SHA-512', + }, + baseKey, + KEYSIZE * 8, + ) + const key = [...new Uint8Array(derivedBits)] return { key, @@ -39,52 +47,70 @@ const generatePBKDF2Key = async ({ } } -const generateIV = async (random = forgeRandom) => await random.getBytes(IVSIZE) +const generateIV = async () => crypto.getRandomValues(new Uint8Array(IVSIZE)) -// * KEYS are kept in memory until we manage to migrate this part to WASM -const encryptAES = async ({ - data, - key, - cipherFn = forgeCipher, - util = forgeUtil, - ivGenerationFn = generateIV, -}) => { - const IV = await ivGenerationFn() - const cipher = cipherFn.createCipher('AES-GCM', key) +const binaryStringToBytes = (str) => + new Uint8Array([...str].map((c) => c.charCodeAt(0))) + +const bytesToBinaryString = (bytes) => String.fromCharCode(...bytes) + +const encryptAES = async ({ data, key }) => { + const iv = await generateIV() const hex = Buffer.from(data).toString('hex') - cipher.start({ iv: IV }) - cipher.update(util.createBuffer(hex)) - cipher.finish() + const cryptoKey = await crypto.subtle.importKey( + 'raw', + new Uint8Array(key), + 'AES-GCM', + false, + ['encrypt'], + ) + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + new TextEncoder().encode(hex), + ) + + const result = new Uint8Array(encrypted) return { - encryptedData: cipher.output.getBytes(), - iv: IV, - tag: cipher.mode.tag.getBytes(), + encryptedData: bytesToBinaryString(result.slice(0, -16)), + iv: bytesToBinaryString(iv), + tag: bytesToBinaryString(result.slice(-16)), } } -// * KEYS are kept in memory until we manage to migrate this part to WASM -const decryptAES = ({ - data, - key, - iv, - tag, - cipherFn = forgeCipher, - util = forgeUtil, -}) => { - const decipher = cipherFn.createDecipher('AES-GCM', key) +const decryptAES = async ({ data, key, iv, tag }) => { + const cryptoKey = await crypto.subtle.importKey( + 'raw', + new Uint8Array(key), + 'AES-GCM', + false, + ['decrypt'], + ) - decipher.start({ iv, tag }) - decipher.update(util.createBuffer(data)) - const result = decipher.finish() + const dataBytes = binaryStringToBytes(data) + const tagBytes = binaryStringToBytes(tag) + const combined = new Uint8Array(dataBytes.length + tagBytes.length) + combined.set(dataBytes) + combined.set(tagBytes, dataBytes.length) - if (!result) throw new Error('Incorrect password') - return hexToBytes(decipher.output.data) + try { + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: binaryStringToBytes(iv) }, + cryptoKey, + combined, + ) + return hexToBytes(new TextDecoder().decode(decrypted)) + } catch { + throw new Error('Incorrect password') + } } export { IVSIZE, + DEFAULT_ITERATIONS, + LEGACY_ITERATIONS, generateSalt, generatePBKDF2Key, generateIV, diff --git a/src/services/Crypto/Cipher/Cipher.worker.js b/src/services/Crypto/Cipher/Cipher.worker.js index 44380cf2..9f7bf63f 100644 --- a/src/services/Crypto/Cipher/Cipher.worker.js +++ b/src/services/Crypto/Cipher/Cipher.worker.js @@ -22,10 +22,7 @@ self.onmessage = async ({ data }) => { if (!isValidJob(data.job)) return false try { - let jobResult - data.job === CipherWorkerEnum.DECRYPT_AES - ? (jobResult = CipherWorkerJobs[data.job](data.data)) - : (jobResult = await CipherWorkerJobs[data.job](data.data)) + const jobResult = await CipherWorkerJobs[data.job](data.data) postMessage(jobResult) diff --git a/src/services/Entity/Account/Account.js b/src/services/Entity/Account/Account.js index d502a65c..1eec7042 100644 --- a/src/services/Entity/Account/Account.js +++ b/src/services/Entity/Account/Account.js @@ -8,6 +8,7 @@ import { import { BTC as BtcHelpers } from '@Helpers' import loadAccountSubRoutines from './loadWorkers' import { LocalStorageService } from '@Storage' +import { DEFAULT_ITERATIONS, LEGACY_ITERATIONS } from '../../Crypto/Cipher/Cipher' const saveAccount = async (data) => { const { generateEncryptionKey } = await loadAccountSubRoutines() @@ -28,6 +29,7 @@ const saveAccount = async (data) => { const account = { name, salt, + kdfIterations: DEFAULT_ITERATIONS, iv: { btcIv, mlTestnetPrivKeyIv, mlMainnetPrivKeyIv }, tag: { btcTag, mlTestnetPrivKeyTag, mlMainnetPrivKeyTag }, seed: { @@ -88,6 +90,7 @@ const checkPasswordValidity = async (id, password) => { const { key } = await generateEncryptionKey({ password, salt: account.salt, + iterations: account.kdfIterations || LEGACY_ITERATIONS, }) const decrypted = await decryptSeed({ @@ -117,6 +120,7 @@ const unlockHtlsSecret = async ({ accountId, password, hash }) => { const { key } = await generateEncryptionKey({ password, salt: account.salt, + iterations: account.kdfIterations || LEGACY_ITERATIONS, }) const data = account.htlsSecrets[hash] @@ -146,6 +150,7 @@ const saveProvidedHtlsSecret = async ({ accountId, password, data }) => { password, account.salt, data.secret, + account.kdfIterations || LEGACY_ITERATIONS, ) const updatedHtlsSecrets = { @@ -156,6 +161,77 @@ const saveProvidedHtlsSecret = async ({ accountId, password, data }) => { await updateAccount(accountId, { htlsSecrets: updatedHtlsSecrets }) } +const reEncryptAccount = async (id, password, account, decryptedSeeds) => { + const { generateEncryptionKey, encryptSeed } = await loadAccountSubRoutines() + + const { key: newKey, salt: newSalt } = await generateEncryptionKey({ + password, + iterations: DEFAULT_ITERATIONS, + }) + + const reEncrypt = async (data) => { + const { encryptedData, iv, tag } = await encryptSeed({ data, key: newKey }) + return { encryptedData, iv, tag } + } + + const { + encryptedData: btcEncryptedSeed, + iv: btcIv, + tag: btcTag, + } = await reEncrypt(decryptedSeeds.seed) + + const { + encryptedData: encryptedMlTestnetPrivateKey, + iv: mlTestnetPrivKeyIv, + tag: mlTestnetPrivKeyTag, + } = await reEncrypt(decryptedSeeds.mlTestnetPrivateKey) + + const { + encryptedData: encryptedMlMainnetPrivateKey, + iv: mlMainnetPrivKeyIv, + tag: mlMainnetPrivKeyTag, + } = await reEncrypt(decryptedSeeds.mlMainnetPrivateKey) + + // Re-encrypt HTLS secrets if any + const updatedHtlsSecrets = {} + if (account.htlsSecrets) { + const { decryptSeed } = await loadAccountSubRoutines() + const { key: oldKey } = await generateEncryptionKey({ + password, + salt: account.salt, + iterations: account.kdfIterations || LEGACY_ITERATIONS, + }) + + for (const [hash, data] of Object.entries(account.htlsSecrets)) { + const decryptedSecret = await decryptSeed({ + data: data.encryptedHtlsSecret, + iv: data.htlsIv, + tag: data.htlsTag, + key: oldKey, + }) + const { + encryptedData: encryptedHtlsSecret, + iv: htlsIv, + tag: htlsTag, + } = await reEncrypt(decryptedSecret) + updatedHtlsSecrets[hash] = { encryptedHtlsSecret, htlsIv, htlsTag, txHash: data.txHash } + } + } + + await updateAccount(id, { + salt: newSalt, + kdfIterations: DEFAULT_ITERATIONS, + iv: { btcIv, mlTestnetPrivKeyIv, mlMainnetPrivKeyIv }, + tag: { btcTag, mlTestnetPrivKeyTag, mlMainnetPrivKeyTag }, + seed: { + btcEncryptedSeed, + encryptedMlTestnetPrivateKey, + encryptedMlMainnetPrivateKey, + }, + htlsSecrets: updatedHtlsSecrets, + }) +} + const unlockAccount = async (id, password, { wallets } = {}) => { const storedNetworkType = LocalStorageService.getItem('networkType') @@ -170,9 +246,12 @@ const unlockAccount = async (id, password, { wallets } = {}) => { if (!account.walletsToCreate) updateAccount(id, { walletsToCreate: AppInfo.DEFAULT_WALLETS_TO_CREATE }) + const accountIterations = account.kdfIterations || LEGACY_ITERATIONS + const { key } = await generateEncryptionKey({ password, salt: account.salt, + iterations: accountIterations, }) const seed = await decryptSeed({ @@ -239,6 +318,15 @@ const unlockAccount = async (id, password, { wallets } = {}) => { } } + // Migrate old accounts to stronger KDF in the background + if (accountIterations < DEFAULT_ITERATIONS) { + reEncryptAccount(id, password, account, { + seed, + mlTestnetPrivateKey, + mlMainnetPrivateKey, + }).catch((e) => console.error('KDF migration failed:', e)) + } + return { addresses, btcPrivateKeys: { btcHDWallet, btcAddressData }, diff --git a/src/services/Entity/Account/AccountHelpers.js b/src/services/Entity/Account/AccountHelpers.js index dbdb724f..40fb8494 100644 --- a/src/services/Entity/Account/AccountHelpers.js +++ b/src/services/Entity/Account/AccountHelpers.js @@ -53,9 +53,9 @@ const getEncryptedPrivateKeys = async (password, salt, mnemonic) => { } } -const getEncryptedHtlsSecret = async (password, salt, secret) => { +const getEncryptedHtlsSecret = async (password, salt, secret, iterations) => { const { generateEncryptionKey, encryptSeed } = await loadAccountSubRoutines() - const { key } = await generateEncryptionKey({ password, salt }) + const { key } = await generateEncryptionKey({ password, salt, iterations }) const { encryptedData: encryptedHtlsSecret, From 6eb07316606a532e2ec6bd8fc637e7ced47e1504 Mon Sep 17 00:00:00 2001 From: owlsua Date: Sat, 4 Apr 2026 11:02:48 +0200 Subject: [PATCH 2/8] refactor: remove entropy drawing board, use crypto.getRandomValues for mnemonic generation Replace mouse-drawn entropy with cryptographically secure crypto.getRandomValues(16 bytes) for BIP39 mnemonic generation. Remove Entropy component, related state, and renumber CreateAccount steps from 6 to 5. --- .../composed/Entropy/DrawingBoard.css | 41 ------ .../composed/Entropy/DrawingBoard.js | 91 ------------- .../composed/Entropy/DrawingBoard.test.js | 69 ---------- src/components/composed/Entropy/Entropy.css | 7 - src/components/composed/Entropy/Entropy.js | 33 ----- .../composed/Entropy/Entropy.test.js | 45 ------- .../composed/Entropy/EntropyDescription.css | 8 -- .../composed/Entropy/EntropyDescription.js | 36 ----- .../Entropy/EntropyDescription.test.js | 21 --- src/components/composed/index.js | 2 - .../containers/CreateAccount/CreateAccount.js | 124 +++++------------- .../CreateAccount/CreateAccount.test.js | 27 ++-- .../AccountProvider/AccountProvider.js | 6 - src/pages/CreateAccount/CreateAccount.js | 29 +--- src/services/Crypto/BTC/BTC.js | 6 +- src/services/Crypto/BTC/BTC.test.js | 4 +- src/services/Entity/Account/Account.worker.js | 3 +- 17 files changed, 57 insertions(+), 495 deletions(-) delete mode 100644 src/components/composed/Entropy/DrawingBoard.css delete mode 100644 src/components/composed/Entropy/DrawingBoard.js delete mode 100644 src/components/composed/Entropy/DrawingBoard.test.js delete mode 100644 src/components/composed/Entropy/Entropy.css delete mode 100644 src/components/composed/Entropy/Entropy.js delete mode 100644 src/components/composed/Entropy/Entropy.test.js delete mode 100644 src/components/composed/Entropy/EntropyDescription.css delete mode 100644 src/components/composed/Entropy/EntropyDescription.js delete mode 100644 src/components/composed/Entropy/EntropyDescription.test.js diff --git a/src/components/composed/Entropy/DrawingBoard.css b/src/components/composed/Entropy/DrawingBoard.css deleted file mode 100644 index f68421e0..00000000 --- a/src/components/composed/Entropy/DrawingBoard.css +++ /dev/null @@ -1,41 +0,0 @@ -.drawingBoard { - position: relative; - display: flex; - align-items: center; - width: 50%; - max-width: 400px; - height: 238px; - padding: 0 5px; - border: 1px solid rgb(var(--color-black)); - border-radius: 30px; -} - -.drawingBoardStage { - width: 100%; - height: 100%; -} - -.layer { - width: 100%; - height: 100%; -} - -.clearButton { - position: absolute; - display: flex; - align-items: center; - top: 10px; - right: 12px; - padding: 0; - width: 75px; - height: 25px; - font-size: 16px; - font-weight: 400; - border: 1px solid rgb(var(--color-black)); -} - -.clearButton svg { - width: 11px; - height: 11px; - margin-right: 5px; -} diff --git a/src/components/composed/Entropy/DrawingBoard.js b/src/components/composed/Entropy/DrawingBoard.js deleted file mode 100644 index 492def8d..00000000 --- a/src/components/composed/Entropy/DrawingBoard.js +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useRef, useContext } from 'react' -import { Stage, Layer, Line } from 'react-konva' -import { Button } from '@BasicComponents' -import { AccountContext } from '@Contexts' - -import { ReactComponent as IconClose } from '@Assets/images/icon-close.svg' - -import './DrawingBoard.css' - -const DrawingBoard = () => { - const { lines, setLines, setEntropy } = useContext(AccountContext) - const tool = 'pen' - const isDrawing = useRef(false) - - const handleMouseDown = (e) => { - isDrawing.current = true - const pos = e.target.getStage().getPointerPosition() - setLines([...lines, { tool, points: [pos.x, pos.y] }]) - } - - const handleMouseMove = (e) => { - // no drawing - skipping - if (!isDrawing.current) { - return - } - const stage = e.target.getStage() - const point = stage.getPointerPosition() - const lastLine = lines[lines.length - 1] - // add point - lastLine.points = lastLine.points.concat([point.x, point.y]) - - // replace last - lines.splice(lines.length - 1, 1, lastLine) - setLines(lines.concat()) - } - - const handleMouseUp = () => { - isDrawing.current = false - } - - const clearButtonClickHandler = () => { - setLines([]) - setEntropy([]) - } - - return ( -
- - - {lines.map((line, i) => ( - - ))} - - - -
- ) -} - -export default DrawingBoard diff --git a/src/components/composed/Entropy/DrawingBoard.test.js b/src/components/composed/Entropy/DrawingBoard.test.js deleted file mode 100644 index bf22eb62..00000000 --- a/src/components/composed/Entropy/DrawingBoard.test.js +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react' -import { render, screen } from '@testing-library/react' -import DrawingBoard from './DrawingBoard' -import { AccountProvider } from '@Contexts' -import 'konva/lib/shapes/Line' - -// TODO: Mock the react-konva library to pass the test cause react-konva is not properly working with jest. Need to find a bettr way to test this component. -jest.mock('react-konva', () => ({ - Stage: ({ children, ...props }) => ( -
- {children} -
- ), - Layer: ({ children, ...props }) =>
{children}
, - Line: ({ ...props }) =>
, -})) - -// const setLines = jest.fn() -// const setEntropy = jest.fn() - -test('Render Drawing Board', () => { - render( - - - , - ) - - const drawingBoard = screen.getByTestId('entropy-drawing-board') - const layer = screen.getByRole('presentation') - const clearButton = screen.getByTestId('button') - - expect(drawingBoard).toBeInTheDocument() - expect(layer).toBeInTheDocument() - expect(clearButton).toBeInTheDocument() - expect(clearButton).toHaveTextContent('Clear') -}) - -// test('allows drawing on the canvas', () => { -// render( -// -// -// , -// ) -// const drawingBoard = screen.getByTestId('entropy-drawing-board') - -// fireEvent.mouseDown(drawingBoard, { clientX: 50, clientY: 50 }) -// fireEvent.mouseUp(drawingBoard) -// fireEvent.mouseDown(drawingBoard, { clientX: 60, clientY: 60 }) -// fireEvent.mouseUp(drawingBoard) - -// expect(setLines).toHaveBeenCalledTimes(2) -// }) - -// test('Clears the canvas when the Clear button is clicked', () => { -// render( -// -// -// , -// ) - -// const clearButton = screen.getByText('Clear') -// fireEvent.click(clearButton) - -// expect(setLines).toHaveBeenCalledWith([]) -// expect(setEntropy).toHaveBeenCalledWith([]) -// }) diff --git a/src/components/composed/Entropy/Entropy.css b/src/components/composed/Entropy/Entropy.css deleted file mode 100644 index 18d58301..00000000 --- a/src/components/composed/Entropy/Entropy.css +++ /dev/null @@ -1,7 +0,0 @@ -.entropy { - display: flex; - align-items: center; - width: 100%; - height: 100%; - padding: 0 5px; -} diff --git a/src/components/composed/Entropy/Entropy.js b/src/components/composed/Entropy/Entropy.js deleted file mode 100644 index c3ccbead..00000000 --- a/src/components/composed/Entropy/Entropy.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react' - -import EntropyDescription from './EntropyDescription' -import DrawingBoard from './DrawingBoard' -import { Error } from '@BasicComponents' - -import './Entropy.css' - -const DESCRIPTION_ITEMS = [ - 'In the blank screen aside please draw anything you want.', - 'We are going to use this drawing to generate a random seed for your wallet.', - 'The more random the drawing is, the more secure your wallet will be.', - 'Express your art.', -] - -const errorMessages = 'Your entropy is too small. Please draw more lines.' - -const Entropy = ({ isError }) => { - return ( - <> -
- - -
- {isError && } - - ) -} - -export default Entropy diff --git a/src/components/composed/Entropy/Entropy.test.js b/src/components/composed/Entropy/Entropy.test.js deleted file mode 100644 index a22b11bb..00000000 --- a/src/components/composed/Entropy/Entropy.test.js +++ /dev/null @@ -1,45 +0,0 @@ -import { render, screen } from '@testing-library/react' -import Entropy from './Entropy' -import { AccountProvider } from '@Contexts' - -// TODO: Mock the react-konva library to pass the test cause react-konva is not properly working with jest. Need to find a bettr way to test this component. -jest.mock('react-konva', () => ({ - Stage: ({ children, ...props }) =>
{children}
, - Layer: ({ children, ...props }) =>
{children}
, - Line: ({ ...props }) =>
, -})) - -test('Render Entropy', async () => { - render( - - - , - ) - - const entropy = screen.getByTestId('entropy') - const description = screen.getByTestId('entropy-description') - const drawingBoard = screen.getByTestId('entropy-drawing-board') - - expect(entropy).toBeInTheDocument() - expect(description).toBeInTheDocument() - expect(drawingBoard).toBeInTheDocument() -}) - -test('Render Entropy with error', async () => { - const errorMessages = 'Your entropy is too small. Please draw more lines.' - render( - - - , - ) - - const entropy = screen.getByTestId('entropy') - const description = screen.getByTestId('entropy-description') - const drawingBoard = screen.getByTestId('entropy-drawing-board') - const errorMessage = screen.getByTestId('error-message') - - expect(entropy).toBeInTheDocument() - expect(description).toBeInTheDocument() - expect(drawingBoard).toBeInTheDocument() - expect(errorMessage).toHaveTextContent(errorMessages) -}) diff --git a/src/components/composed/Entropy/EntropyDescription.css b/src/components/composed/Entropy/EntropyDescription.css deleted file mode 100644 index 3fc29f3d..00000000 --- a/src/components/composed/Entropy/EntropyDescription.css +++ /dev/null @@ -1,8 +0,0 @@ -.entropy-description { - width: 50%; - height: 100%; -} - -.entropy-paragraph { - font-size: 1.125rem; -} diff --git a/src/components/composed/Entropy/EntropyDescription.js b/src/components/composed/Entropy/EntropyDescription.js deleted file mode 100644 index 9d92db1c..00000000 --- a/src/components/composed/Entropy/EntropyDescription.js +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react' - -import './EntropyDescription.css' - -import { VerticalGroup } from '@LayoutComponents' - -const EntropyDescriptionItem = ({ description }) => { - return ( -

- {description} -

- ) -} - -const EntropyDescription = ({ descriptionItems }) => { - return ( -
- - {descriptionItems.map((item, index) => ( - - ))} - -
- ) -} - -export default EntropyDescription diff --git a/src/components/composed/Entropy/EntropyDescription.test.js b/src/components/composed/Entropy/EntropyDescription.test.js deleted file mode 100644 index 679d48c0..00000000 --- a/src/components/composed/Entropy/EntropyDescription.test.js +++ /dev/null @@ -1,21 +0,0 @@ -import { render, screen } from '@testing-library/react' -import EntropyDescription from './EntropyDescription' - -const DESCRIPTION_ITEMS = ['one', 'two'] - -test('Render account balance', () => { - render() - const description = screen.getByTestId('entropy-description') - const verticalContainer = screen.getByTestId('vertical-group-container') - const descriptionItems = screen.getAllByTestId('entropy-paragraph') - - expect(description).toBeInTheDocument() - expect(verticalContainer).toBeInTheDocument() - expect(description).toHaveClass('entropy-description') - - descriptionItems.forEach((item, index) => { - expect(item).toHaveTextContent(DESCRIPTION_ITEMS[index]) - }) - - expect(descriptionItems).toHaveLength(DESCRIPTION_ITEMS.length) -}) diff --git a/src/components/composed/index.js b/src/components/composed/index.js index 2a24006d..8fc543da 100644 --- a/src/components/composed/index.js +++ b/src/components/composed/index.js @@ -13,7 +13,6 @@ import CryptoFiatField from './CryptoFiatField/CryptoFiatField' import FeeField from './FeeField/FeeField' import FeeFieldML from './FeeField/FeeFieldML' import ConnectionErrorPopup from './ConnectionErrorPopup/ConnectionErrorPopup' -import Entropy from './Entropy/Entropy' import WalletList from './WalletList/WalletList' import AddWallet from './AddWallet/AddWallet' import CurrentStaking from './CurrentStaking/CurrentStaking' @@ -46,7 +45,6 @@ export { FeeField, FeeFieldML, ConnectionErrorPopup, - Entropy, WalletList, AddWallet, CurrentStaking, diff --git a/src/components/containers/CreateAccount/CreateAccount.js b/src/components/containers/CreateAccount/CreateAccount.js index 8e86de6b..ec3a91ca 100644 --- a/src/components/containers/CreateAccount/CreateAccount.js +++ b/src/components/containers/CreateAccount/CreateAccount.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useContext } from 'react' +import React, { useState, useMemo } from 'react' import { useNavigate } from 'react-router' import { AppInfo, Expressions } from '@Constants' @@ -10,7 +10,6 @@ import { InputList, ProgressTracker, TextField, - Entropy, } from '@ComposedComponents' import { ReactComponent as IconArrowRight } from '@Assets/images/icon-arrow-right.svg' @@ -18,22 +17,19 @@ import { ReactComponent as IconArrowRight } from '@Assets/images/icon-arrow-righ import WordsDescription from './WordsListDescription' import './CreateAccount.css' -import { AccountContext } from '@Contexts' -import { generateEntropy, normalize } from '@mintlayer/entropy-generator' const CreateAccount = ({ step, setStep, words = [], onStepsFinished, + onGenerateMnemonic, validateMnemonicFn, defaultBTCWordList, }) => { - const { setEntropy, lines } = useContext(AccountContext) const inputExtraclasses = ['set-account-input'] const passwordPattern = Expressions.PASSWORD const [wordsFields, setWordsFields] = useState([]) - const [accountWordsValid, setAccountWordsValid] = useState(false) const [direction, setDirection] = useState('forward') const [accountNameValue, setAccountNameValue] = useState('') @@ -41,12 +37,13 @@ const CreateAccount = ({ const [accountNameValid, setAccountNameValid] = useState(false) const [accountPasswordValid, setAccountPasswordValid] = useState(false) - const [accountEntropyValid, setAccountEntropyValid] = useState(false) - const [accountNameErrorMessage, setAccountNameErrorMessage] = useState(null) - const [accountPasswordErrorMessage, setAccountPasswordErrorMessage] = - useState(null) - const [showEntropyError, setShowEntropyError] = useState(false) + const accountNameErrorMessage = !accountNameValid + ? AppInfo.WALLET_NAME_ERROR + : null + const accountPasswordErrorMessage = !accountPasswordValid + ? AppInfo.WALLET_PASSWORD_ERROR + : null const [accountNamePristinity, setAccountNamePristinity] = useState(true) const [accountPasswordPristinity, setAccountPasswordPristinity] = @@ -55,62 +52,10 @@ const CreateAccount = ({ const navigate = useNavigate() - const calculateEntropy = useCallback( - (size) => { - const points = lines.flatMap((line) => line.points) - const normalizedPoints = normalize( - points.map((point) => Math.round(point)), - ) - return size - ? generateEntropy(normalizedPoints, size) - : generateEntropy(normalizedPoints) - }, - [lines], - ) - - useEffect(() => { - const message = !accountNameValid - ? 'The wallet name should have at least 4 characteres.' - : null - - setAccountNameErrorMessage(message) - }, [accountNameValid]) - - useEffect(() => { - const message = !accountPasswordValid - ? [ - 'Your password should have at least 8 characteres.', - 'Also it should have a lowercase letter, an uppercase letter, a digit, and a special char like: /\\*()&^%$#@-_=+\'"?!:;<>~`', - ] - : null - - setAccountPasswordErrorMessage(message) - }, [accountPasswordValid]) - - const accountEntropyValidity = (lines) => { - const points = lines.flatMap((line) => line.points) - return points.length >= AppInfo.minEntropyLength - } - - useEffect(() => { - if (!lines) return - const isEntropyValid = accountEntropyValidity(lines) - setAccountEntropyValid(isEntropyValid) - if (step < 3 || isEntropyValid) { - setShowEntropyError(false) - } - }, [lines, step, accountEntropyValid]) - - const thirdStepSubmitHandler = () => { - if (!accountEntropyValid) { - setShowEntropyError(true) - } - setEntropy(calculateEntropy()) - } - const goToNextStep = () => { setDirection('forward') - return step < 6 + if (step === 2) onGenerateMnemonic() + return step < 5 ? setStep(step + 1) : onStepsFinished(accountNameValue, accountPasswordValue, selectedWallets) } @@ -123,27 +68,30 @@ const CreateAccount = ({ const steps = [ { value: 1, name: 'Wallet Name', active: step === 1 }, { value: 2, name: 'Wallet Password', active: step === 2 }, - { value: 3, name: 'Entropy Generation', active: step === 3 }, { - value: 4, + value: 3, name: 'Seed Phrases', - active: step > 3, + active: step > 2, }, ] + const accountWordsValid = useMemo( + () => wordsFields.every((word) => word.validity), + [wordsFields], + ) + const stepsValidations = { 1: accountNameValid, 2: accountPasswordValid, - 3: accountEntropyValid, + 3: true, 4: true, - 5: true, - 6: accountWordsValid, + 5: accountWordsValid, } const titles = { - 4: 'I understand', - 5: 'Backup done!', - 6: 'Create Wallet', + 3: 'I understand', + 4: 'Backup done!', + 5: 'Create Wallet', } const nameFieldValidity = (value) => { @@ -166,14 +114,9 @@ const CreateAccount = ({ const genButtonTitle = (currentStep) => titles[currentStep] || 'Continue' - useEffect(() => { - const wordsValidity = wordsFields.every((word) => word.validity) - setAccountWordsValid(wordsValidity) - }, [wordsFields, step]) - const handleError = (step) => { - if (step < 6) return - if (step === 6) { + if (step < 5) return + if (step === 5) { alert( 'These words do not match the previously generated mnemonic. Check if you had any typos or if you inserted them in a different order', ) @@ -192,10 +135,9 @@ const CreateAccount = ({ if (step === 1) setAccountNamePristinity(false) if (step === 2) setAccountPasswordPristinity(false) - if (step === 3) thirdStepSubmitHandler() let validForm = stepsValidations[step] - if (step === 6) validForm = validForm && isMnemonicValid() + if (step === 5) validForm = validForm && isMnemonicValid() validForm ? goToNextStep() : handleError(step) } @@ -208,14 +150,14 @@ const CreateAccount = ({ direction={direction} />
4 && 'set-account-form-words'}`} + className={`set-account-form ${step > 3 && 'set-account-form-words'}`} method="POST" data-testid="set-account-form" onSubmit={handleSubmit} > {step === 1 && ( @@ -236,7 +178,6 @@ const CreateAccount = ({ value={accountPasswordValue} onChangeHandle={accountPasswordChangeHandler} validity={accountPasswordValid} - pattern={passwordPattern} password label={'Create a password for your wallet'} placeHolder={'Password'} @@ -246,9 +187,8 @@ const CreateAccount = ({ alternate /> )} - {step === 3 && } - {step === 4 && } - {step === 5 && ( + {step === 3 && } + {step === 4 && ( )} - {step === 6 && ( + {step === 5 && ( diff --git a/src/components/basic/Input/InputFloat.js b/src/components/basic/Input/InputFloat.js index 66e5516c..dee6b89e 100644 --- a/src/components/basic/Input/InputFloat.js +++ b/src/components/basic/Input/InputFloat.js @@ -1,6 +1,5 @@ import { AppInfo, Expressions } from '@Constants' import { NumbersHelper } from '@Helpers' -import { useEffect, useState } from 'react' import Input from './Input' const InputFloat = (props) => { @@ -12,7 +11,7 @@ const InputFloat = (props) => { const [regexIntegerPartIndex, regexDecimalPartIndex] = [1, 5] const breakersRegex = /[.,]/g - const [value, setValue] = useState(props.value || '') + const value = props.value || '' const removeBreakers = (value) => value.replaceAll(breakersRegex, '') @@ -76,10 +75,6 @@ const InputFloat = (props) => { return parsedVal.value || ev.target.value } - useEffect(() => { - setValue(props.value) - }, [props.value]) - return ( { const mask = Expressions.FIELDS.INTEGER - const [value, setValue] = useState(0) - useEffect(() => { - setValue(~~props.value) - !NumbersHelper.isInteger(props.value) && + const value = useMemo(() => { + if (!NumbersHelper.isInteger(props.value)) { console.warn( 'A non-integer value was passed to InputInteger. It has been converted to integer.', ) + } + return ~~props.value }, [props.value]) const parseValue = ({ target: { value, matchedValue } }) => { diff --git a/src/components/basic/Svg/Svg.js b/src/components/basic/Svg/Svg.js index 9331de73..faef6c9a 100644 --- a/src/components/basic/Svg/Svg.js +++ b/src/components/basic/Svg/Svg.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React from 'react' const Svg = ({ children, @@ -8,13 +8,8 @@ const Svg = ({ width = '100px', height = '100px', }) => { - const [viewboxWidth, setViewBoxWidth] = useState(size) - const [viewboxHeight, setViewBoxHeight] = useState(size) - - useEffect(() => { - sizeH ? setViewBoxHeight(sizeH) : setViewBoxHeight(size) - sizeW ? setViewBoxWidth(sizeW) : setViewBoxWidth(size) - }, [sizeH, sizeW, size]) + const viewboxWidth = sizeW || size + const viewboxHeight = sizeH || size return ( { - const [size, setSize] = useState({ w: 0, h: 0 }) - const [proportionalHeight, setProportionalHeight] = useState() - - useEffect(() => { - setSize({ + const size = useMemo( + () => ({ w: maxWidthPoint(points) + EXTRABOUNDARIES, h: parseInt(height) + EXTRABOUNDARIES, - }) - }, [points, height]) + }), + [points, height], + ) - useEffect(() => { - setProportionalHeight(height || getProportionalHeight(size, width)) - }, [width, height, size]) + const proportionalHeight = useMemo( + () => height || getProportionalHeight(size, width), + [width, height, size], + ) return ( { @@ -52,7 +51,7 @@ const TextField = ({ setIsPristine(pristinity) }, [pristinity]) - const setPristineState = (e) => setIsPristine(false) + const setPristineState = () => setIsPristine(false) return ( diff --git a/src/components/containers/Login/Login.css b/src/components/containers/Login/Login.css index 8e21b376..eaa02ef6 100644 --- a/src/components/containers/Login/Login.css +++ b/src/components/containers/Login/Login.css @@ -4,6 +4,16 @@ align-content: center; display: flex; flex-direction: column; + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } } .subtitle { margin-top: 2rem; diff --git a/src/components/containers/SendTransaction/AmountField.js b/src/components/containers/SendTransaction/AmountField.js index b392b415..53336e90 100644 --- a/src/components/containers/SendTransaction/AmountField.js +++ b/src/components/containers/SendTransaction/AmountField.js @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { CryptoFiatField } from '@ComposedComponents' import TransactionField from './TransactionField' @@ -15,11 +15,7 @@ const AmountField = ({ totalFeeInCrypto, transactionMode, }) => { - const [message, setMessage] = useState(errorMessage) - - useEffect(() => { - setMessage(errorMessage) - }, [errorMessage, setMessage]) + const [localMessage, setLocalMessage] = useState(undefined) return ( @@ -31,14 +27,14 @@ const AmountField = ({ transactionData={transactionData} validity={validity} changeValueHandle={amountChanged} - setErrorMessage={setMessage} + setErrorMessage={setLocalMessage} exchangeRate={exchangeRate} maxValueInToken={maxValueInToken} setAmountValidity={setAmountValidity} totalFeeInCrypto={totalFeeInCrypto} transactionMode={transactionMode} /> -

{message}

+

{localMessage ?? errorMessage}

) } diff --git a/src/components/containers/SendTransaction/FeesField.js b/src/components/containers/SendTransaction/FeesField.js index 72690da2..5356e199 100644 --- a/src/components/containers/SendTransaction/FeesField.js +++ b/src/components/containers/SendTransaction/FeesField.js @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { FeeField, FeeFieldML } from '@ComposedComponents' import TransactionField from './TransactionField' @@ -12,11 +12,7 @@ const FeesField = ({ setFeeValidity, walletType, }) => { - const [message, setMessage] = useState(errorMessage) - - useEffect(() => { - setMessage(errorMessage) - }, [errorMessage, setMessage]) + const [localMessage, setLocalMessage] = useState(undefined) return ( @@ -27,7 +23,7 @@ const FeesField = ({ id="fee" changeValueHandle={feeChanged} value={value} - setErrorMessage={setMessage} + setErrorMessage={setLocalMessage} setFeeValidity={setFeeValidity} /> ) : ( @@ -35,12 +31,12 @@ const FeesField = ({ id="fee" changeValueHandle={feeChanged} value={value} - setErrorMessage={setMessage} + setErrorMessage={setLocalMessage} setFeeValidity={setFeeValidity} /> )} -

{message}

+

{localMessage ?? errorMessage}

) } diff --git a/src/components/layouts/VerticalGroup/VerticalGroup.js b/src/components/layouts/VerticalGroup/VerticalGroup.js index c0ae41c4..861c8828 100644 --- a/src/components/layouts/VerticalGroup/VerticalGroup.js +++ b/src/components/layouts/VerticalGroup/VerticalGroup.js @@ -1,5 +1,4 @@ -import React, { useEffect } from 'react' -import { useStyleClasses } from '@Hooks' +import React, { useMemo } from 'react' import './VerticalGroup.css' @@ -12,39 +11,16 @@ const VerticalGroup = ({ grow = false, center = false, }) => { - const classesList = ['v-group'] - bigGap && classesList.push('bigGap') - midGap && classesList.push('midGap') - smallGap && classesList.push('smallGap') - fullWidth && classesList.push('fullWidth') - grow && classesList.push('grow') - center && classesList.push('center') - const { styleClasses, addStyleClass, removeStyleClass } = - useStyleClasses(classesList) - - useEffect(() => { - bigGap ? addStyleClass('bigGap') : removeStyleClass('bigGap') - }, [bigGap, addStyleClass, removeStyleClass]) - - useEffect(() => { - midGap ? addStyleClass('midGap') : removeStyleClass('midGap') - }, [midGap, addStyleClass, removeStyleClass]) - - useEffect(() => { - smallGap ? addStyleClass('smallGap') : removeStyleClass('smallGap') - }, [smallGap, addStyleClass, removeStyleClass]) - - useEffect(() => { - fullWidth && addStyleClass('fullWidth') - }, [fullWidth, addStyleClass, removeStyleClass]) - - useEffect(() => { - grow && addStyleClass('grow') - }, [grow, addStyleClass, removeStyleClass]) - - useEffect(() => { - center && addStyleClass('center') - }, [center, addStyleClass, removeStyleClass]) + const styleClasses = useMemo(() => { + const classes = ['v-group'] + if (bigGap) classes.push('bigGap') + if (midGap) classes.push('midGap') + if (smallGap) classes.push('smallGap') + if (fullWidth) classes.push('fullWidth') + if (grow) classes.push('grow') + if (center) classes.push('center') + return classes.join(' ') + }, [bigGap, midGap, smallGap, fullWidth, grow, center]) return (
{ const pools_data = await ML.getBatchData(uniquePools, '/pool/:address') const emptyPoolsDataMap = uniquePools.reduce((acc, pool, index) => { - if (pools_data[index].staker_balance.atoms === '0') { + if (pools_data[index]?.staker_balance?.atoms === '0') { acc[pool] = pools_data[index] } return acc diff --git a/src/hooks/UseStyleClasses/useStyleClasses.js b/src/hooks/UseStyleClasses/useStyleClasses.js index cf87a1a1..1613dcee 100644 --- a/src/hooks/UseStyleClasses/useStyleClasses.js +++ b/src/hooks/UseStyleClasses/useStyleClasses.js @@ -22,7 +22,7 @@ const removeItemsFromList = (oldList = '', newList = []) => { const useStyleClasses = (classesList = []) => { const effectCalled = useRef(false) - const [styleClasses, _setStyleClasses] = useState([]) + const [styleClasses, _setStyleClasses] = useState(formatClasses(classesList)) const setStyleClasses = useCallback((classes = []) => { _setStyleClasses(formatClasses(ensureClassesAreArray(classes))) diff --git a/src/pages/Home/Home.js b/src/pages/Home/Home.js index 42abde81..b6bd04aa 100644 --- a/src/pages/Home/Home.js +++ b/src/pages/Home/Home.js @@ -1,4 +1,4 @@ -import { useContext, useEffect, useRef, useState } from 'react' +import { useContext, useEffect, useRef } from 'react' import { useLocation, useNavigate } from 'react-router' import { AccountContext } from '@Contexts' @@ -9,23 +9,23 @@ import './Home.css' const HomePage = () => { const effectCalled = useRef(false) - const navigatedRef = useRef(false) - const [unlocked, setUnlocked] = useState(false) + const unlockChecked = useRef(false) const location = useLocation() const navigate = useNavigate() const { isAccountUnlocked, accounts, verifyAccountsExistence } = useContext(AccountContext) + const unlocked = isAccountUnlocked(false) + useEffect(() => { - if (navigatedRef.current) return - const currentUnlocked = isAccountUnlocked(true) - setUnlocked(currentUnlocked) - if (currentUnlocked) { - navigatedRef.current = true + if (unlockChecked.current) return + if (unlocked) { + unlockChecked.current = true + isAccountUnlocked(true) navigate('/dashboard') } - }, [isAccountUnlocked, navigate]) + }, [unlocked, isAccountUnlocked, navigate]) useEffect(() => { if (effectCalled.current) return @@ -33,22 +33,14 @@ const HomePage = () => { verifyAccountsExistence() }, [accounts, verifyAccountsExistence]) - const Home = () => { - if (accounts === null) return - - return !accounts.length || location.state?.fromLogin ? ( - - ) : ( - - ) - } - - return ( - !unlocked && ( - <> - - - ) + if (unlocked) return null + + return accounts === null ? ( + + ) : !accounts.length || location.state?.fromLogin ? ( + + ) : ( + ) } diff --git a/src/setupTests.js b/src/setupTests.js index f61ca1e6..72aeee58 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1,3 +1,4 @@ +/* eslint-disable no-undef */ // jest-dom adds custom jest matchers for asserting on DOM nodes. // allows you to do things like: // expect(element).toHaveTextContent(/react/i) @@ -15,9 +16,11 @@ global.TextEncoder = TextEncoder global.TextDecoder = TextDecoder // Crypto polyfill for Node.js environment +const { webcrypto } = require('crypto') if (typeof global.crypto === 'undefined') { - const { webcrypto } = require('crypto') global.crypto = webcrypto +} else if (typeof global.crypto.subtle === 'undefined') { + global.crypto.subtle = webcrypto.subtle } // Buffer polyfill diff --git a/webpack.config.js b/webpack.config.js index 489432c9..3cd61a57 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -66,6 +66,9 @@ module.exports = { }, hot: true, port: process.env.PORT || 3000, + client: { + webSocketURL: 'auto://0.0.0.0:0/ws', + }, open: true, historyApiFallback: { disableDotRule: true, From fcf331b16572f28fd5d4cd42a13275f6da7f6045 Mon Sep 17 00:00:00 2001 From: owlsua Date: Mon, 6 Apr 2026 21:35:23 +0200 Subject: [PATCH 4/8] feat: update constants --- src/utils/Constants/AppInfo/AppInfo.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/utils/Constants/AppInfo/AppInfo.js b/src/utils/Constants/AppInfo/AppInfo.js index cfa67111..2d4678d8 100644 --- a/src/utils/Constants/AppInfo/AppInfo.js +++ b/src/utils/Constants/AppInfo/AppInfo.js @@ -11,7 +11,6 @@ const appAccounts = async () => { const decimalSeparator = '.' const thousandsSeparator = ' ' const amountRegex = /^\d+(.\d+)?$/ -const minEntropyLength = 192 const DEFAULT_WALLETS_TO_CREATE = ['btc', 'ml'] const ML_ATOMS_PER_COIN = 100000000000 const DEFAULT_ML_WALLET_OFFSET = 21 @@ -26,9 +25,9 @@ const BATCH_REQUEST_BITCOIN_LIMIT = 10 const ML_EXPLORER_MAINNET = 'https://explorer.mintlayer.org/' const ML_EXPLORER_TESTNET = 'https://lovelace.explorer.mintlayer.org/' const BTC_EXPLORER_MAINNET = 'https://blockstream.info/' -const BTC_EXPLORER_TESTNET = 'https://blockstream.info/testnet/' +const BTC_EXPLORER_TESTNET = 'https://explorer.gomaestro.org/bitcoin/testnet/' const BTC_DEFAULT_ADDRESSES_BATCH = 3 -const BTC_MAX_TRANSACTION_FEE = 100_000 // 0.001 BTC +const BTC_MAX_TRANSACTION_FEE = 100000 // 0.001 BTC const BTC_MAX_FEERATE = 200 const COLOR_LIST = { btc: '#F7931A', @@ -125,6 +124,12 @@ const WALLETS_NAVIGATION = [ }, ] +const WALLET_NAME_ERROR = 'The wallet name should have at least 4 characters.' +const WALLET_PASSWORD_ERROR = [ + 'Your password should have at least 8 characters.', + 'Also it should have a lowercase letter, an uppercase letter, a digit, and a special char like: /\\*()&^%$#@-_=+\'"?!:;<>~`', +] + const MAX_ML_FEE = 500000000000 // 5 ML in atoms const REFRESH_INTERVAL = 1000 * 60 * 2 // one per two minutes @@ -133,7 +138,6 @@ export { decimalSeparator, thousandsSeparator, amountRegex, - minEntropyLength, walletTypes, DEFAULT_WALLETS_TO_CREATE, NETWORK_TYPES, @@ -160,4 +164,6 @@ export { BTC_MAX_TRANSACTION_FEE, BTC_MAX_FEERATE, COLOR_LIST, + WALLET_NAME_ERROR, + WALLET_PASSWORD_ERROR, } From faab88ef9c98899070705817a29f5982e24e4e8f Mon Sep 17 00:00:00 2001 From: owlsua Date: Mon, 6 Apr 2026 21:45:39 +0200 Subject: [PATCH 5/8] test: update unit tests --- src/services/Crypto/Cipher/Cipher.test.js | 336 ++++++++++++++++++---- tests/01-create-account.spec.js | 51 ---- 2 files changed, 279 insertions(+), 108 deletions(-) diff --git a/src/services/Crypto/Cipher/Cipher.test.js b/src/services/Crypto/Cipher/Cipher.test.js index e4909ae7..2b4ca585 100644 --- a/src/services/Crypto/Cipher/Cipher.test.js +++ b/src/services/Crypto/Cipher/Cipher.test.js @@ -6,6 +6,8 @@ import { decryptAES, hexToBytes, IVSIZE, + DEFAULT_ITERATIONS, + LEGACY_ITERATIONS, } from './Cipher' test('Cipher - HEX to Bytes', () => { @@ -16,35 +18,39 @@ test('Cipher - HEX to Bytes', () => { expect(Buffer.from(bytes).toString('hex')).toBe(hex) }) -test('Cipher - generateSalt', async () => { +test('Cipher - generateSalt returns correct length hex string', async () => { const salt1 = await generateSalt(1) const salt2 = await generateSalt(2) + const salt16 = await generateSalt(16) expect(hexToBytes(salt1).length).toBe(1) expect(hexToBytes(salt2).length).toBe(2) + expect(hexToBytes(salt16).length).toBe(16) + + expect(typeof salt1).toBe('string') + expect(salt1).toMatch(/^[0-9a-f]+$/) + expect(salt1.length).toBe(2) + expect(salt2.length).toBe(4) + expect(salt16.length).toBe(32) }) -test('Cipher - generateSalt random', async () => { - const random = { - getBytes: jest.fn(() => new Uint8Array([97])), - } - const salt1 = await generateSalt(1, random) +test('Cipher - generateSalt 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] = 0xab + return arr + }) + crypto.getRandomValues = mockGetRandomValues - expect(hexToBytes(salt1).length).toBe(1) - expect(random.getBytes).toHaveBeenCalled() -}) + const salt = await generateSalt(3) -test('Cipher - generateSalt utils', async () => { - const util = { - bytesToHex: jest.fn(() => 61), - } - const salt = await generateSalt(1, undefined, util) + expect(mockGetRandomValues).toHaveBeenCalled() + expect(salt).toBe('ababab') - expect(salt).toBe(61) - expect(util.bytesToHex).toHaveBeenCalled() + crypto.getRandomValues = originalGetRandomValues }) -test('Cipher - generatePBKDF2Key', async () => { +test('Cipher - generatePBKDF2Key deterministic with same salt', async () => { const password = 'test' const { key: key1, salt: salt1 } = await generatePBKDF2Key({ password }) @@ -58,69 +64,166 @@ test('Cipher - generatePBKDF2Key', async () => { expect(salt1).toStrictEqual(salt2) }) -test('Cipher - generatePBKDF2Key derivationFn', async () => { +test('Cipher - generatePBKDF2Key returns correct key length', async () => { + const { key } = await generatePBKDF2Key({ + password: 'test', + salt: 'a1b2c3d4', + }) + + expect(Array.isArray(key)).toBe(true) + expect(key.length).toBe(16) + key.forEach((byte) => { + expect(byte).toBeGreaterThanOrEqual(0) + expect(byte).toBeLessThanOrEqual(255) + }) +}) + +test('Cipher - generatePBKDF2Key uses provided salt', async () => { + const salt = 'deadbeef' + const { salt: returnedSalt } = await generatePBKDF2Key({ + password: 'test', + salt, + }) + + expect(returnedSalt).toBe(salt) +}) + +test('Cipher - generatePBKDF2Key generates salt when not provided', async () => { + const { salt } = await generatePBKDF2Key({ password: 'test' }) + + expect(typeof salt).toBe('string') + expect(salt).toMatch(/^[0-9a-f]+$/) + expect(salt.length).toBe(32) +}) + +test('Cipher - generatePBKDF2Key different passwords produce different keys', async () => { + const salt = 'fixedsalt' + const { key: key1 } = await generatePBKDF2Key({ + password: 'password1', + salt, + }) + const { key: key2 } = await generatePBKDF2Key({ + password: 'password2', + salt, + }) + + expect(key1).not.toStrictEqual(key2) +}) + +test('Cipher - generatePBKDF2Key respects iterations parameter', async () => { + const salt = 'fixedsalt' const password = 'test' - const salt = 'salt' - const derivationFn = () => [12] - const { key: key1, salt: salt1 } = await generatePBKDF2Key({ + const { key: keyDefault } = await generatePBKDF2Key({ password, salt }) + const { key: keyLegacy } = await generatePBKDF2Key({ password, salt, - derivationFn, + iterations: LEGACY_ITERATIONS, + }) + + expect(keyDefault).not.toStrictEqual(keyLegacy) +}) + +test('Cipher - generatePBKDF2Key known value verification', async () => { + const { key } = await generatePBKDF2Key({ + password: 'testpassword', + salt: '0123456789abcdef0123456789abcdef', + iterations: 1000, }) - expect(key1).toStrictEqual([12]) - expect(salt1).toStrictEqual(salt) + expect(key.length).toBe(16) + + const { key: key2 } = await generatePBKDF2Key({ + password: 'testpassword', + salt: '0123456789abcdef0123456789abcdef', + iterations: 1000, + }) + expect(key).toStrictEqual(key2) }) -test('Cipher - generateIV', async () => { +test('Cipher - generateIV returns Uint8Array of correct size', async () => { const iv = await generateIV() + + expect(iv).toBeInstanceOf(Uint8Array) expect(iv.length).toBe(IVSIZE) + expect(iv.length).toBe(12) }) -test('Cipher - generateIV utils', async () => { - const bytes = [0] - const random = { - getBytes: jest.fn(() => bytes), - } +test('Cipher - generateIV 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] = 0x42 + return arr + }) + crypto.getRandomValues = mockGetRandomValues + + const iv = await generateIV() + + expect(mockGetRandomValues).toHaveBeenCalled() + expect(iv.length).toBe(IVSIZE) + expect(Array.from(iv)).toStrictEqual(Array(IVSIZE).fill(0x42)) - const iv = await generateIV(random) - expect(iv.length).toBe(bytes.length) - expect(random.getBytes).toHaveBeenCalled() + crypto.getRandomValues = originalGetRandomValues }) -test('Cipher - encryptAES', async () => { +test('Cipher - encryptAES returns correct structure', async () => { const data = 'data' - const expectedIV = 'aaaaaaaaaaaa' - const ivGenerationFn = async () => Promise.resolve(expectedIV) + const key = [ + 97, 98, 99, 100, 101, 97, 98, 99, 100, 101, 97, 98, 100, 101, 97, 98, + ] + + const { encryptedData, iv, tag } = await encryptAES({ data, key }) + expect(typeof encryptedData).toBe('string') + expect(typeof iv).toBe('string') + expect(typeof tag).toBe('string') + expect(encryptedData.length).toBeGreaterThan(0) + expect(iv.length).toBe(12) + expect(tag.length).toBe(16) +}) + +test('Cipher - encryptAES deterministic with mocked IV', async () => { + const data = 'data' const key = [ 97, 98, 99, 100, 101, 97, 98, 99, 100, 101, 97, 98, 100, 101, 97, 98, ] - const expectedEncryptedData = new Uint8Array([ - 194, 137, 41, 21, 195, 133, 195, 144, 123, 78, 18, - ]) + const originalGetRandomValues = crypto.getRandomValues.bind(crypto) + crypto.getRandomValues = jest.fn((arr) => { + for (let i = 0; i < arr.length; i++) arr[i] = 0x61 + return arr + }) - const expectedTag = new Uint8Array([ - 194, 131, 32, 95, 194, 170, 195, 147, 68, 195, 159, 194, 147, 194, 189, 117, - 195, 178, 91, 50, 194, 182, 195, 128, 20, - ]) + const result1 = await encryptAES({ data, key }) + const result2 = await encryptAES({ data, key }) - const { encryptedData, iv, tag } = await encryptAES({ - data, - key, - ivGenerationFn, + expect(result1.encryptedData).toBe(result2.encryptedData) + expect(result1.iv).toBe(result2.iv) + expect(result1.tag).toBe(result2.tag) + + crypto.getRandomValues = originalGetRandomValues +}) + +test('Cipher - encryptAES different data produces different output', async () => { + const key = [ + 97, 98, 99, 100, 101, 97, 98, 99, 100, 101, 97, 98, 100, 101, 97, 98, + ] + + const originalGetRandomValues = crypto.getRandomValues.bind(crypto) + crypto.getRandomValues = jest.fn((arr) => { + for (let i = 0; i < arr.length; i++) arr[i] = 0x61 + return arr }) - expect(Buffer.from(encryptedData)).toStrictEqual( - Buffer.from(expectedEncryptedData), - ) - expect(iv).toBe(expectedIV) - expect(Buffer.from(tag)).toStrictEqual(Buffer.from(expectedTag)) + const result1 = await encryptAES({ data: 'data1', key }) + const result2 = await encryptAES({ data: 'data2', key }) + + expect(result1.encryptedData).not.toBe(result2.encryptedData) + + crypto.getRandomValues = originalGetRandomValues }) -test('Cipher - decryptAES', async () => { +test('Cipher - decryptAES round-trip', async () => { const data = 'data' const key = [ 97, 98, 99, 100, 101, 97, 98, 99, 100, 101, 97, 98, 100, 101, 97, 98, @@ -136,7 +239,40 @@ test('Cipher - decryptAES', async () => { expect(Buffer.from(decrypted).toString()).toBe(data) }) -test('Cipher - decryptAES error', async () => { +test('Cipher - decryptAES round-trip with long data', async () => { + const data = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + const key = [ + 97, 98, 99, 100, 101, 97, 98, 99, 100, 101, 97, 98, 100, 101, 97, 98, + ] + const { encryptedData, iv, tag } = await encryptAES({ data, key }) + const decrypted = await decryptAES({ + data: encryptedData, + iv, + tag, + key, + }) + + expect(Buffer.from(decrypted).toString()).toBe(data) +}) + +test('Cipher - decryptAES round-trip with unicode data', async () => { + const data = 'test data with special chars @#$%' + const key = [ + 97, 98, 99, 100, 101, 97, 98, 99, 100, 101, 97, 98, 100, 101, 97, 98, + ] + const { encryptedData, iv, tag } = await encryptAES({ data, key }) + const decrypted = await decryptAES({ + data: encryptedData, + iv, + tag, + key, + }) + + expect(Buffer.from(decrypted).toString()).toBe(data) +}) + +test('Cipher - decryptAES wrong key throws', async () => { const data = 'data' const key = [ 97, 98, 99, 100, 101, 97, 98, 99, 100, 101, 97, 98, 100, 101, 97, 98, @@ -146,12 +282,98 @@ test('Cipher - decryptAES error', async () => { ] const { encryptedData, iv, tag } = await encryptAES({ data, key }) - expect( - decryptAES.bind(null, { + await expect( + decryptAES({ data: encryptedData, iv, tag, key: wrongkey, }), - ).toThrow() + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - decryptAES tampered data throws', async () => { + const data = 'data' + const key = [ + 97, 98, 99, 100, 101, 97, 98, 99, 100, 101, 97, 98, 100, 101, 97, 98, + ] + const { encryptedData, iv, tag } = await encryptAES({ data, key }) + + const tampered = + String.fromCharCode(encryptedData.charCodeAt(0) ^ 0xff) + + encryptedData.slice(1) + + await expect( + decryptAES({ + data: tampered, + iv, + tag, + key, + }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - decryptAES tampered tag throws', async () => { + const data = 'data' + const key = [ + 97, 98, 99, 100, 101, 97, 98, 99, 100, 101, 97, 98, 100, 101, 97, 98, + ] + const { encryptedData, iv, tag } = await encryptAES({ data, key }) + + const tamperedTag = + String.fromCharCode(tag.charCodeAt(0) ^ 0xff) + tag.slice(1) + + await expect( + decryptAES({ + data: encryptedData, + iv, + tag: tamperedTag, + key, + }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - constants are correct', () => { + expect(DEFAULT_ITERATIONS).toBe(600000) + expect(LEGACY_ITERATIONS).toBe(10000) + expect(IVSIZE).toBe(12) +}) + +test('Cipher - full encrypt/decrypt cycle with PBKDF2', async () => { + const password = 'MyStr0ng!Password' + const data = 'secret seed phrase data' + + const { key, salt } = await generatePBKDF2Key({ password }) + const { encryptedData, iv, tag } = await encryptAES({ data, key }) + + const { key: key2 } = await generatePBKDF2Key({ password, salt }) + const decrypted = await decryptAES({ + data: encryptedData, + iv, + tag, + key: key2, + }) + + expect(Buffer.from(decrypted).toString()).toBe(data) +}) + +test('Cipher - full cycle with wrong password fails', async () => { + const data = 'secret seed phrase data' + + const { key } = await generatePBKDF2Key({ password: 'correct' }) + const { encryptedData, iv, tag } = await encryptAES({ data, key }) + + const { key: wrongKey } = await generatePBKDF2Key({ + password: 'wrong', + salt: 'differentsalt', + }) + + await expect( + decryptAES({ + data: encryptedData, + iv, + tag, + key: wrongKey, + }), + ).rejects.toThrow('Incorrect password') }) diff --git a/tests/01-create-account.spec.js b/tests/01-create-account.spec.js index a2338070..94ad1957 100644 --- a/tests/01-create-account.spec.js +++ b/tests/01-create-account.spec.js @@ -30,57 +30,6 @@ test('Create account', async ({ page }) => { await page.fill('input[placeholder="Password"]', WALLET_PASSWORD) await page.getByRole('button', { name: 'Continue' }).click() - await expect( - page.locator( - ':text("In the blank screen aside please draw anything you want.")', - ), - ).toBeVisible() - - await expect( - page.locator( - ':text("We are going to use this drawing to generate a random seed for your wallet.")', - ), - ).toBeVisible() - - await expect( - page.locator( - ':text("The more random the drawing is, the more secure your wallet will be.")', - ), - ).toBeVisible() - - await expect(page.locator(':text("Express your art.")')).toBeVisible() - - // Helper functions - const getRandomIntBetween = (min, max) => { - return Math.floor(Math.random() * (max - min + 1)) + min - } - - const drawRandomEntropy = async (page, element) => { - const el = await page.$(element) - const coords = await el.boundingBox() - const gap = 50 - const limits = { - minX: coords.x + gap, - maxX: coords.x + coords.width - gap, - minY: coords.y + gap, - maxY: coords.y + coords.height - gap, - } - for (let i = 0; i < 55; i++) { - const x = getRandomIntBetween(limits.minX, limits.maxX) - const y = getRandomIntBetween(limits.minY, limits.maxY) - await page.mouse.move(x, y) - await page.mouse.down() - await page.mouse.move(x + 10, y + 10) // move a little bit while the mouse button is down - await page.mouse.up() - } - } - - const drawingBoardStage = 'div.drawingBoard' - - await drawRandomEntropy(page, drawingBoardStage) - - await page.getByRole('button', { name: 'Continue' }).click() - await expect( page.locator( ':text("Write down each of the words (seed phrases) that are shown on the next screen.")', From af3aeaaf220def32671e5debd0184241d08ec823 Mon Sep 17 00:00:00 2001 From: owlsua Date: Mon, 6 Apr 2026 22:08:50 +0200 Subject: [PATCH 6/8] fix: simplify mnemonic generation --- src/services/Crypto/BTC/BTC.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/services/Crypto/BTC/BTC.js b/src/services/Crypto/BTC/BTC.js index 7dd47e1c..45798ec9 100644 --- a/src/services/Crypto/BTC/BTC.js +++ b/src/services/Crypto/BTC/BTC.js @@ -12,10 +12,7 @@ const getHDWalletFromSeed = (seed) => { return root } -const generateMnemonic = () => { - const entropy = crypto.getRandomValues(new Uint8Array(16)) - return Bip39.entropyToMnemonic(Buffer.from(entropy)) -} +const generateMnemonic = () => Bip39.generateMnemonic() const validateMnemonic = (mnemonic) => Bip39.validateMnemonic(mnemonic) const getWordList = () => Bip39.wordlists[Bip39.getDefaultWordlist()] From bdbbf6d8e79a2c87910236031a6bc1196f51bcdd Mon Sep 17 00:00:00 2001 From: owlsua Date: Wed, 8 Apr 2026 09:42:00 +0200 Subject: [PATCH 7/8] refactor: replace kdfIterations with versioned encryption config Use encryptionVersion field in accounts instead of raw kdfIterations. Each version inherits from previous, making it easy to add new encryption parameters (encoding, hexWrap, etc.) in future versions. --- src/services/Crypto/Cipher/Cipher.js | 32 +++++++++++------ src/services/Crypto/Cipher/Cipher.test.js | 20 +++++++---- src/services/Entity/Account/Account.js | 35 +++++++++++-------- src/services/Entity/Account/AccountHelpers.js | 4 +-- 4 files changed, 58 insertions(+), 33 deletions(-) diff --git a/src/services/Crypto/Cipher/Cipher.js b/src/services/Crypto/Cipher/Cipher.js index 84ff31b0..8f3a6fb6 100644 --- a/src/services/Crypto/Cipher/Cipher.js +++ b/src/services/Crypto/Cipher/Cipher.js @@ -1,5 +1,15 @@ -const DEFAULT_ITERATIONS = 600000 -const LEGACY_ITERATIONS = 10000 +const V1 = { iterations: 10000 } +const V2 = { ...V1, iterations: 600000 } + +const CURRENT_ENCRYPTION_VERSION = 2 +const ENCRYPTION_VERSIONS = { 1: V1, 2: V2 } + +const getVersionConfig = (version) => { + const config = ENCRYPTION_VERSIONS[version] + if (!config) throw new Error(`Unknown encryption version: ${version}`) + return config +} + const KEYSIZE = 16 const IVSIZE = 12 const SALTSIZE = 16 @@ -7,6 +17,11 @@ const SALTSIZE = 16 const hexToBytes = (hexString) => Uint8Array.from(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))) +const binaryStringToBytes = (str) => + new Uint8Array([...str].map((c) => c.charCodeAt(0))) + +const bytesToBinaryString = (bytes) => String.fromCharCode(...bytes) + const generateSalt = async (bytesAmount) => { const bytes = new Uint8Array(bytesAmount) crypto.getRandomValues(bytes) @@ -18,8 +33,9 @@ const generateSalt = async (bytesAmount) => { const generatePBKDF2Key = async ({ password, salt, - iterations = DEFAULT_ITERATIONS, + version = CURRENT_ENCRYPTION_VERSION, }) => { + const { iterations } = getVersionConfig(version) const currentSalt = salt || (await generateSalt(SALTSIZE)) const encoder = new TextEncoder() const baseKey = await crypto.subtle.importKey( @@ -49,11 +65,6 @@ const generatePBKDF2Key = async ({ const generateIV = async () => crypto.getRandomValues(new Uint8Array(IVSIZE)) -const binaryStringToBytes = (str) => - new Uint8Array([...str].map((c) => c.charCodeAt(0))) - -const bytesToBinaryString = (bytes) => String.fromCharCode(...bytes) - const encryptAES = async ({ data, key }) => { const iv = await generateIV() const hex = Buffer.from(data).toString('hex') @@ -109,8 +120,9 @@ const decryptAES = async ({ data, key, iv, tag }) => { export { IVSIZE, - DEFAULT_ITERATIONS, - LEGACY_ITERATIONS, + CURRENT_ENCRYPTION_VERSION, + ENCRYPTION_VERSIONS, + getVersionConfig, generateSalt, generatePBKDF2Key, generateIV, diff --git a/src/services/Crypto/Cipher/Cipher.test.js b/src/services/Crypto/Cipher/Cipher.test.js index 2b4ca585..aa04ce70 100644 --- a/src/services/Crypto/Cipher/Cipher.test.js +++ b/src/services/Crypto/Cipher/Cipher.test.js @@ -6,8 +6,9 @@ import { decryptAES, hexToBytes, IVSIZE, - DEFAULT_ITERATIONS, - LEGACY_ITERATIONS, + CURRENT_ENCRYPTION_VERSION, + ENCRYPTION_VERSIONS, + getVersionConfig, } from './Cipher' test('Cipher - HEX to Bytes', () => { @@ -118,7 +119,7 @@ test('Cipher - generatePBKDF2Key respects iterations parameter', async () => { const { key: keyLegacy } = await generatePBKDF2Key({ password, salt, - iterations: LEGACY_ITERATIONS, + version: 1, }) expect(keyDefault).not.toStrictEqual(keyLegacy) @@ -128,7 +129,7 @@ test('Cipher - generatePBKDF2Key known value verification', async () => { const { key } = await generatePBKDF2Key({ password: 'testpassword', salt: '0123456789abcdef0123456789abcdef', - iterations: 1000, + version: 1, }) expect(key.length).toBe(16) @@ -136,7 +137,7 @@ test('Cipher - generatePBKDF2Key known value verification', async () => { const { key: key2 } = await generatePBKDF2Key({ password: 'testpassword', salt: '0123456789abcdef0123456789abcdef', - iterations: 1000, + version: 1, }) expect(key).toStrictEqual(key2) }) @@ -334,11 +335,16 @@ test('Cipher - decryptAES tampered tag throws', async () => { }) test('Cipher - constants are correct', () => { - expect(DEFAULT_ITERATIONS).toBe(600000) - expect(LEGACY_ITERATIONS).toBe(10000) + expect(CURRENT_ENCRYPTION_VERSION).toBe(2) + expect(getVersionConfig(1).iterations).toBe(10000) + expect(getVersionConfig(2).iterations).toBe(600000) expect(IVSIZE).toBe(12) }) +test('Cipher - getVersionConfig throws on unknown version', () => { + expect(() => getVersionConfig(999)).toThrow('Unknown encryption version: 999') +}) + test('Cipher - full encrypt/decrypt cycle with PBKDF2', async () => { const password = 'MyStr0ng!Password' const data = 'secret seed phrase data' diff --git a/src/services/Entity/Account/Account.js b/src/services/Entity/Account/Account.js index 1eec7042..d4a402ca 100644 --- a/src/services/Entity/Account/Account.js +++ b/src/services/Entity/Account/Account.js @@ -8,7 +8,9 @@ import { import { BTC as BtcHelpers } from '@Helpers' import loadAccountSubRoutines from './loadWorkers' import { LocalStorageService } from '@Storage' -import { DEFAULT_ITERATIONS, LEGACY_ITERATIONS } from '../../Crypto/Cipher/Cipher' +import { CURRENT_ENCRYPTION_VERSION } from '../../Crypto/Cipher/Cipher' + +const getAccountVersion = (account) => account.encryptionVersion || 1 const saveAccount = async (data) => { const { generateEncryptionKey } = await loadAccountSubRoutines() @@ -29,7 +31,7 @@ const saveAccount = async (data) => { const account = { name, salt, - kdfIterations: DEFAULT_ITERATIONS, + encryptionVersion: CURRENT_ENCRYPTION_VERSION, iv: { btcIv, mlTestnetPrivKeyIv, mlMainnetPrivKeyIv }, tag: { btcTag, mlTestnetPrivKeyTag, mlMainnetPrivKeyTag }, seed: { @@ -90,7 +92,7 @@ const checkPasswordValidity = async (id, password) => { const { key } = await generateEncryptionKey({ password, salt: account.salt, - iterations: account.kdfIterations || LEGACY_ITERATIONS, + version: getAccountVersion(account), }) const decrypted = await decryptSeed({ @@ -120,7 +122,7 @@ const unlockHtlsSecret = async ({ accountId, password, hash }) => { const { key } = await generateEncryptionKey({ password, salt: account.salt, - iterations: account.kdfIterations || LEGACY_ITERATIONS, + version: getAccountVersion(account), }) const data = account.htlsSecrets[hash] @@ -150,7 +152,7 @@ const saveProvidedHtlsSecret = async ({ accountId, password, data }) => { password, account.salt, data.secret, - account.kdfIterations || LEGACY_ITERATIONS, + getAccountVersion(account), ) const updatedHtlsSecrets = { @@ -166,7 +168,7 @@ const reEncryptAccount = async (id, password, account, decryptedSeeds) => { const { key: newKey, salt: newSalt } = await generateEncryptionKey({ password, - iterations: DEFAULT_ITERATIONS, + version: CURRENT_ENCRYPTION_VERSION, }) const reEncrypt = async (data) => { @@ -199,7 +201,7 @@ const reEncryptAccount = async (id, password, account, decryptedSeeds) => { const { key: oldKey } = await generateEncryptionKey({ password, salt: account.salt, - iterations: account.kdfIterations || LEGACY_ITERATIONS, + version: getAccountVersion(account), }) for (const [hash, data] of Object.entries(account.htlsSecrets)) { @@ -214,13 +216,18 @@ const reEncryptAccount = async (id, password, account, decryptedSeeds) => { iv: htlsIv, tag: htlsTag, } = await reEncrypt(decryptedSecret) - updatedHtlsSecrets[hash] = { encryptedHtlsSecret, htlsIv, htlsTag, txHash: data.txHash } + updatedHtlsSecrets[hash] = { + encryptedHtlsSecret, + htlsIv, + htlsTag, + txHash: data.txHash, + } } } await updateAccount(id, { salt: newSalt, - kdfIterations: DEFAULT_ITERATIONS, + encryptionVersion: CURRENT_ENCRYPTION_VERSION, iv: { btcIv, mlTestnetPrivKeyIv, mlMainnetPrivKeyIv }, tag: { btcTag, mlTestnetPrivKeyTag, mlMainnetPrivKeyTag }, seed: { @@ -246,12 +253,12 @@ const unlockAccount = async (id, password, { wallets } = {}) => { if (!account.walletsToCreate) updateAccount(id, { walletsToCreate: AppInfo.DEFAULT_WALLETS_TO_CREATE }) - const accountIterations = account.kdfIterations || LEGACY_ITERATIONS + const accountVersion = getAccountVersion(account) const { key } = await generateEncryptionKey({ password, salt: account.salt, - iterations: accountIterations, + version: getAccountVersion(account), }) const seed = await decryptSeed({ @@ -318,13 +325,13 @@ const unlockAccount = async (id, password, { wallets } = {}) => { } } - // Migrate old accounts to stronger KDF in the background - if (accountIterations < DEFAULT_ITERATIONS) { + // Migrate old accounts to current encryption version in the background + if (accountVersion !== CURRENT_ENCRYPTION_VERSION) { reEncryptAccount(id, password, account, { seed, mlTestnetPrivateKey, mlMainnetPrivateKey, - }).catch((e) => console.error('KDF migration failed:', e)) + }).catch((e) => console.error('Encryption migration failed:', e)) } return { diff --git a/src/services/Entity/Account/AccountHelpers.js b/src/services/Entity/Account/AccountHelpers.js index 40fb8494..580f81de 100644 --- a/src/services/Entity/Account/AccountHelpers.js +++ b/src/services/Entity/Account/AccountHelpers.js @@ -53,9 +53,9 @@ const getEncryptedPrivateKeys = async (password, salt, mnemonic) => { } } -const getEncryptedHtlsSecret = async (password, salt, secret, iterations) => { +const getEncryptedHtlsSecret = async (password, salt, secret, version) => { const { generateEncryptionKey, encryptSeed } = await loadAccountSubRoutines() - const { key } = await generateEncryptionKey({ password, salt, iterations }) + const { key } = await generateEncryptionKey({ password, salt, version }) const { encryptedData: encryptedHtlsSecret, From b8f9afd396b6978239888cf0d827eff6c5355579 Mon Sep 17 00:00:00 2001 From: owlsua Date: Thu, 9 Apr 2026 21:54:24 +0200 Subject: [PATCH 8/8] test: add encryption tests --- src/services/Crypto/Cipher/Cipher.test.js | 254 ++++++++++++++++++++++ 1 file changed, 254 insertions(+) diff --git a/src/services/Crypto/Cipher/Cipher.test.js b/src/services/Crypto/Cipher/Cipher.test.js index aa04ce70..9d5bcf04 100644 --- a/src/services/Crypto/Cipher/Cipher.test.js +++ b/src/services/Crypto/Cipher/Cipher.test.js @@ -363,6 +363,260 @@ test('Cipher - full encrypt/decrypt cycle with PBKDF2', async () => { expect(Buffer.from(decrypted).toString()).toBe(data) }) +test('Cipher - encrypt v1, decrypt v1, re-encrypt v2, decrypt v2', async () => { + const password = 'MyStr0ng!Password' + const originalData = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + + // Step 1: encrypt with v1 (10000 iterations) + const { key: keyV1, salt: saltV1 } = await generatePBKDF2Key({ + password, + version: 1, + }) + const { + encryptedData: encV1, + iv: ivV1, + tag: tagV1, + } = await encryptAES({ + data: originalData, + key: keyV1, + }) + + // Step 2: decrypt with v1 + const { key: keyV1Again } = await generatePBKDF2Key({ + password, + salt: saltV1, + version: 1, + }) + const decryptedV1 = await decryptAES({ + data: encV1, + iv: ivV1, + tag: tagV1, + key: keyV1Again, + }) + expect(Buffer.from(decryptedV1).toString()).toBe(originalData) + + // Step 3: re-encrypt with v2 (600000 iterations) + const { key: keyV2, salt: saltV2 } = await generatePBKDF2Key({ + password, + version: 2, + }) + const { + encryptedData: encV2, + iv: ivV2, + tag: tagV2, + } = await encryptAES({ + data: decryptedV1, + key: keyV2, + }) + + // Step 4: decrypt with v2 + const { key: keyV2Again } = await generatePBKDF2Key({ + password, + salt: saltV2, + version: 2, + }) + const decryptedV2 = await decryptAES({ + data: encV2, + iv: ivV2, + tag: tagV2, + key: keyV2Again, + }) + expect(Buffer.from(decryptedV2).toString()).toBe(originalData) +}) + +test('Cipher - encrypt v2, decrypt v2, re-encrypt v1, decrypt v1', async () => { + const password = 'An0ther$ecure' + const originalData = 'zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong' + + // Step 1: encrypt with v2 (600000 iterations) + const { key: keyV2, salt: saltV2 } = await generatePBKDF2Key({ + password, + version: 2, + }) + const { + encryptedData: encV2, + iv: ivV2, + tag: tagV2, + } = await encryptAES({ + data: originalData, + key: keyV2, + }) + + // Step 2: decrypt with v2 + const { key: keyV2Again } = await generatePBKDF2Key({ + password, + salt: saltV2, + version: 2, + }) + const decryptedV2 = await decryptAES({ + data: encV2, + iv: ivV2, + tag: tagV2, + key: keyV2Again, + }) + expect(Buffer.from(decryptedV2).toString()).toBe(originalData) + + // Step 3: re-encrypt with v1 (10000 iterations) + const { key: keyV1, salt: saltV1 } = await generatePBKDF2Key({ + password, + version: 1, + }) + const { + encryptedData: encV1, + iv: ivV1, + tag: tagV1, + } = await encryptAES({ + data: decryptedV2, + key: keyV1, + }) + + // Step 4: decrypt with v1 + const { key: keyV1Again } = await generatePBKDF2Key({ + password, + salt: saltV1, + version: 1, + }) + const decryptedV1 = await decryptAES({ + data: encV1, + iv: ivV1, + tag: tagV1, + key: keyV1Again, + }) + expect(Buffer.from(decryptedV1).toString()).toBe(originalData) +}) + +test('Cipher - multiple re-encryption cycles preserve data', async () => { + const password = 'Cycl3!Test' + const originalData = 'secret mnemonic phrase for multi-cycle test' + const versions = [1, 2, 1, 2, 1] + + let currentData = originalData + + for (const version of versions) { + const { key, salt } = await generatePBKDF2Key({ password, version }) + const { encryptedData, iv, tag } = await encryptAES({ + data: currentData, + key, + }) + + const { key: decryptKey } = await generatePBKDF2Key({ + password, + salt, + version, + }) + const decrypted = await decryptAES({ + data: encryptedData, + iv, + tag, + key: decryptKey, + }) + + currentData = decrypted + expect(Buffer.from(decrypted).toString()).toBe(originalData) + } +}) + +test('Cipher - re-encryption with different passwords per version', async () => { + const originalData = 'important seed data' + + // Encrypt with password1 + v1 + const password1 = 'P@ssword1' + const { key: keyEnc, salt: salt1 } = await generatePBKDF2Key({ + password: password1, + version: 1, + }) + const { + encryptedData: enc1, + iv: iv1, + tag: tag1, + } = await encryptAES({ + data: originalData, + key: keyEnc, + }) + + // Decrypt with password1 + v1 + const { key: keyDec1 } = await generatePBKDF2Key({ + password: password1, + salt: salt1, + version: 1, + }) + const decrypted1 = await decryptAES({ + data: enc1, + iv: iv1, + tag: tag1, + key: keyDec1, + }) + expect(Buffer.from(decrypted1).toString()).toBe(originalData) + + // Re-encrypt with password2 + v2 + const password2 = 'N3wP@ss!' + const { key: keyEnc2, salt: salt2 } = await generatePBKDF2Key({ + password: password2, + version: 2, + }) + const { + encryptedData: enc2, + iv: iv2, + tag: tag2, + } = await encryptAES({ + data: decrypted1, + key: keyEnc2, + }) + + // Decrypt with password2 + v2 + const { key: keyDec2 } = await generatePBKDF2Key({ + password: password2, + salt: salt2, + version: 2, + }) + const decrypted2 = await decryptAES({ + data: enc2, + iv: iv2, + tag: tag2, + key: keyDec2, + }) + expect(Buffer.from(decrypted2).toString()).toBe(originalData) + + // Old password + v2 salt should NOT decrypt + const { key: wrongKey } = await generatePBKDF2Key({ + password: password1, + salt: salt2, + version: 2, + }) + await expect( + decryptAES({ data: enc2, iv: iv2, tag: tag2, key: wrongKey }), + ).rejects.toThrow('Incorrect password') +}) + +test('Cipher - v1 key cannot decrypt v2-encrypted data with same password', async () => { + const password = 'SameP@ss1' + const originalData = 'cross version test' + const salt = await generateSalt(16) + + const { key: keyV2 } = await generatePBKDF2Key({ + password, + salt, + version: 2, + }) + const { encryptedData, iv, tag } = await encryptAES({ + data: originalData, + key: keyV2, + }) + + const { key: keyV1 } = await generatePBKDF2Key({ + password, + salt, + version: 1, + }) + + // v1 key differs from v2 key (different iterations), so decryption must fail + expect(keyV1).not.toStrictEqual(keyV2) + await expect( + decryptAES({ data: encryptedData, iv, tag, key: keyV1 }), + ).rejects.toThrow('Incorrect password') +}) + test('Cipher - full cycle with wrong password fails', async () => { const data = 'secret seed phrase data'