From 2869e9014138114d502d2e13b65e222a7027770b Mon Sep 17 00:00:00 2001 From: owlsua Date: Thu, 2 Jul 2026 22:30:34 +0200 Subject: [PATCH 1/2] feat(crypto): add AES-256 encryption (V3) and harden hexToBytes Introduce a version-aware key size so V3 derives a 256-bit key (AES-256-GCM) while V1/V2 keep their 128-bit keys for backward-compatible decryption of already-stored data. Existing accounts are migrated to V3 on unlock via the existing reEncryptAccount flow. Also guard hexToBytes against null/empty input to avoid a TypeError on malformed data. Adds migration round-trip tests (v1->v2, v2->v3, v1->v3). --- src/services/Crypto/Cipher/Cipher.js | 21 +++--- src/services/Crypto/Cipher/Cipher.test.js | 85 ++++++++++++++++++++++- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/src/services/Crypto/Cipher/Cipher.js b/src/services/Crypto/Cipher/Cipher.js index 8f3a6fb6..4e9b2229 100644 --- a/src/services/Crypto/Cipher/Cipher.js +++ b/src/services/Crypto/Cipher/Cipher.js @@ -1,8 +1,11 @@ -const V1 = { iterations: 10000 } +// keySize is in bytes: 16 => AES-128, 32 => AES-256. +// V1/V2 must keep keySize 16 so already-stored data stays decryptable. +const V1 = { iterations: 10000, keySize: 16 } const V2 = { ...V1, iterations: 600000 } +const V3 = { ...V2, keySize: 32 } -const CURRENT_ENCRYPTION_VERSION = 2 -const ENCRYPTION_VERSIONS = { 1: V1, 2: V2 } +const CURRENT_ENCRYPTION_VERSION = 3 +const ENCRYPTION_VERSIONS = { 1: V1, 2: V2, 3: V3 } const getVersionConfig = (version) => { const config = ENCRYPTION_VERSIONS[version] @@ -10,12 +13,14 @@ const getVersionConfig = (version) => { return config } -const KEYSIZE = 16 const IVSIZE = 12 const SALTSIZE = 16 -const hexToBytes = (hexString) => - Uint8Array.from(hexString.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))) +const hexToBytes = (hexString) => { + const pairs = hexString?.match(/.{1,2}/g) + if (!pairs) return new Uint8Array() + return Uint8Array.from(pairs.map((byte) => parseInt(byte, 16))) +} const binaryStringToBytes = (str) => new Uint8Array([...str].map((c) => c.charCodeAt(0))) @@ -35,7 +40,7 @@ const generatePBKDF2Key = async ({ salt, version = CURRENT_ENCRYPTION_VERSION, }) => { - const { iterations } = getVersionConfig(version) + const { iterations, keySize } = getVersionConfig(version) const currentSalt = salt || (await generateSalt(SALTSIZE)) const encoder = new TextEncoder() const baseKey = await crypto.subtle.importKey( @@ -53,7 +58,7 @@ const generatePBKDF2Key = async ({ hash: 'SHA-512', }, baseKey, - KEYSIZE * 8, + keySize * 8, ) const key = [...new Uint8Array(derivedBits)] diff --git a/src/services/Crypto/Cipher/Cipher.test.js b/src/services/Crypto/Cipher/Cipher.test.js index 9d5bcf04..54398014 100644 --- a/src/services/Crypto/Cipher/Cipher.test.js +++ b/src/services/Crypto/Cipher/Cipher.test.js @@ -72,7 +72,7 @@ test('Cipher - generatePBKDF2Key returns correct key length', async () => { }) expect(Array.isArray(key)).toBe(true) - expect(key.length).toBe(16) + expect(key.length).toBe(32) key.forEach((byte) => { expect(byte).toBeGreaterThanOrEqual(0) expect(byte).toBeLessThanOrEqual(255) @@ -335,9 +335,13 @@ test('Cipher - decryptAES tampered tag throws', async () => { }) test('Cipher - constants are correct', () => { - expect(CURRENT_ENCRYPTION_VERSION).toBe(2) + expect(CURRENT_ENCRYPTION_VERSION).toBe(3) expect(getVersionConfig(1).iterations).toBe(10000) expect(getVersionConfig(2).iterations).toBe(600000) + expect(getVersionConfig(3).iterations).toBe(600000) + expect(getVersionConfig(1).keySize).toBe(16) + expect(getVersionConfig(2).keySize).toBe(16) + expect(getVersionConfig(3).keySize).toBe(32) expect(IVSIZE).toBe(12) }) @@ -637,3 +641,80 @@ test('Cipher - full cycle with wrong password fails', async () => { }), ).rejects.toThrow('Incorrect password') }) + +// Mirrors the migration flow in reEncryptAccount: decrypt with the old +// version, then re-encrypt with a fresh salt under the new version. +const migrateRoundTrip = async (password, data, fromVersion, toVersion) => { + const { key: fromKey, salt: fromSalt } = await generatePBKDF2Key({ + password, + version: fromVersion, + }) + const enc = await encryptAES({ data, key: fromKey }) + + const { key: fromKeyAgain } = await generatePBKDF2Key({ + password, + salt: fromSalt, + version: fromVersion, + }) + const decrypted = await decryptAES({ + data: enc.encryptedData, + iv: enc.iv, + tag: enc.tag, + key: fromKeyAgain, + }) + expect(Buffer.from(decrypted).toString()).toBe(data) + + const { key: toKey, salt: toSalt } = await generatePBKDF2Key({ + password, + version: toVersion, + }) + const reEnc = await encryptAES({ data: decrypted, key: toKey }) + + const { key: toKeyAgain } = await generatePBKDF2Key({ + password, + salt: toSalt, + version: toVersion, + }) + const finalDecrypted = await decryptAES({ + data: reEnc.encryptedData, + iv: reEnc.iv, + tag: reEnc.tag, + key: toKeyAgain, + }) + expect(Buffer.from(finalDecrypted).toString()).toBe(data) + + return { fromKey, toKey } +} + +test('Cipher - migration v1 -> v2 preserves data (AES-128)', async () => { + const { fromKey, toKey } = await migrateRoundTrip( + 'MyStr0ng!Pass', + 'seed phrase v1 to v2', + 1, + 2, + ) + expect(fromKey.length).toBe(16) + expect(toKey.length).toBe(16) +}) + +test('Cipher - migration v2 -> v3 upgrades to AES-256 and preserves data', async () => { + const { fromKey, toKey } = await migrateRoundTrip( + 'MyStr0ng!Pass', + 'seed phrase v2 to v3', + 2, + 3, + ) + expect(fromKey.length).toBe(16) // AES-128 + expect(toKey.length).toBe(32) // AES-256 +}) + +test('Cipher - migration v1 -> v3 upgrades to AES-256 and preserves data', async () => { + const { fromKey, toKey } = await migrateRoundTrip( + 'MyStr0ng!Pass', + 'seed phrase v1 to v3', + 1, + 3, + ) + expect(fromKey.length).toBe(16) // AES-128 + expect(toKey.length).toBe(32) // AES-256 +}) From 7af1783dd2f59b090053e08f8149ee103a31be1f Mon Sep 17 00:00:00 2001 From: owlsua Date: Thu, 2 Jul 2026 22:31:01 +0200 Subject: [PATCH 2/2] refactor(account): dedupe account loading and derive salt once Reuse getAccount() instead of repeating loadAccounts()+get(), collapse the duplicate loadAccountSubRoutines() call in reEncryptAccount, and reuse the already-computed accountVersion in unlockAccount. saveAccount no longer derives a key just to obtain a salt: getEncryptedPrivateKeys now generates the salt when none is passed and returns it, removing one expensive PBKDF2 derivation per account creation. Align the unlockAccount rejection shape with its success shape (addresses, btcPrivateKeys) so consumers can destructure consistently on error. --- src/services/Entity/Account/Account.js | 30 ++++++++----------- src/services/Entity/Account/AccountHelpers.js | 6 +++- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/services/Entity/Account/Account.js b/src/services/Entity/Account/Account.js index d4a402ca..2a5e9088 100644 --- a/src/services/Entity/Account/Account.js +++ b/src/services/Entity/Account/Account.js @@ -13,10 +13,9 @@ import { CURRENT_ENCRYPTION_VERSION } from '../../Crypto/Cipher/Cipher' const getAccountVersion = (account) => account.encryptionVersion || 1 const saveAccount = async (data) => { - const { generateEncryptionKey } = await loadAccountSubRoutines() const { name, password, mnemonic, walletType, walletsToCreate } = data - const { salt } = await generateEncryptionKey({ password }) const { + salt, encryptedMlTestnetPrivateKey, encryptedMlMainnetPrivateKey, btcEncryptedSeed, @@ -26,7 +25,7 @@ const saveAccount = async (data) => { mlTestnetPrivKeyTag, mlMainnetPrivKeyTag, btcTag, - } = await getEncryptedPrivateKeys(password, salt, mnemonic) + } = await getEncryptedPrivateKeys(password, undefined, mnemonic) const account = { name, @@ -50,8 +49,7 @@ const saveAccount = async (data) => { const getAccount = async (id) => { const accounts = await IndexedDB.loadAccounts() - const account = await IndexedDB.get(accounts, id) - return account + return IndexedDB.get(accounts, id) } const updateAccount = async (id, updates) => { @@ -85,8 +83,7 @@ const restoreAccountFromJSON = async (json) => { const checkPasswordValidity = async (id, password) => { const { generateEncryptionKey, decryptSeed } = await loadAccountSubRoutines() try { - const accounts = await IndexedDB.loadAccounts() - const account = await IndexedDB.get(accounts, id) + const account = await getAccount(id) if (!account?.salt || !account?.seed?.btcEncryptedSeed) return false const { key } = await generateEncryptionKey({ @@ -111,8 +108,7 @@ const checkPasswordValidity = async (id, password) => { const unlockHtlsSecret = async ({ accountId, password, hash }) => { const { generateEncryptionKey, decryptSeed } = await loadAccountSubRoutines() - const accounts = await IndexedDB.loadAccounts() - const account = await IndexedDB.get(accounts, accountId) + 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') @@ -142,8 +138,7 @@ const unlockHtlsSecret = async ({ accountId, password, hash }) => { } const saveProvidedHtlsSecret = async ({ accountId, password, data }) => { - const accounts = await IndexedDB.loadAccounts() - const account = await IndexedDB.get(accounts, accountId) + 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') @@ -164,7 +159,8 @@ const saveProvidedHtlsSecret = async ({ accountId, password, data }) => { } const reEncryptAccount = async (id, password, account, decryptedSeeds) => { - const { generateEncryptionKey, encryptSeed } = await loadAccountSubRoutines() + const { generateEncryptionKey, encryptSeed, decryptSeed } = + await loadAccountSubRoutines() const { key: newKey, salt: newSalt } = await generateEncryptionKey({ password, @@ -197,7 +193,6 @@ const reEncryptAccount = async (id, password, account, decryptedSeeds) => { // Re-encrypt HTLS secrets if any const updatedHtlsSecrets = {} if (account.htlsSecrets) { - const { decryptSeed } = await loadAccountSubRoutines() const { key: oldKey } = await generateEncryptionKey({ password, salt: account.salt, @@ -246,8 +241,7 @@ const unlockAccount = async (id, password, { wallets } = {}) => { const addresses = {} try { - const accounts = await IndexedDB.loadAccounts() - const account = await IndexedDB.get(accounts, id) + const account = await getAccount(id) const walletsToCreate = AppInfo.DEFAULT_WALLETS_TO_CREATE if (!account.walletsToCreate) @@ -258,7 +252,7 @@ const unlockAccount = async (id, password, { wallets } = {}) => { const { key } = await generateEncryptionKey({ password, salt: account.salt, - version: getAccountVersion(account), + version: accountVersion, }) const seed = await decryptSeed({ @@ -343,8 +337,8 @@ const unlockAccount = async (id, password, { wallets } = {}) => { } catch (e) { console.error(e) return Promise.reject({ - address: '', - btcPrivateKeys: '', + addresses: {}, + btcPrivateKeys: { btcHDWallet: null, btcAddressData: null }, name: '', mlPrivKeys: { mlMainnetPrivateKey: '', mlTestnetPrivateKey: '' }, }) diff --git a/src/services/Entity/Account/AccountHelpers.js b/src/services/Entity/Account/AccountHelpers.js index 580f81de..9fa13d3c 100644 --- a/src/services/Entity/Account/AccountHelpers.js +++ b/src/services/Entity/Account/AccountHelpers.js @@ -5,7 +5,10 @@ import loadAccountSubRoutines from './loadWorkers' const getEncryptedPrivateKeys = async (password, salt, mnemonic) => { const { generateSeed, generateEncryptionKey, encryptSeed } = await loadAccountSubRoutines() - const { key } = await generateEncryptionKey({ password, salt }) + const { key, salt: usedSalt } = await generateEncryptionKey({ + password, + salt, + }) const seed = await generateSeed(mnemonic) const encryptData = async (data) => { @@ -41,6 +44,7 @@ const getEncryptedPrivateKeys = async (password, salt, mnemonic) => { } = await encryptData(seed) return { + salt: usedSalt, encryptedMlTestnetPrivateKey, encryptedMlMainnetPrivateKey, btcEncryptedSeed,