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 +}) 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,