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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions src/services/Crypto/Cipher/Cipher.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
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]
if (!config) throw new Error(`Unknown encryption version: ${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)))
Expand All @@ -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(
Expand All @@ -53,7 +58,7 @@ const generatePBKDF2Key = async ({
hash: 'SHA-512',
},
baseKey,
KEYSIZE * 8,
keySize * 8,
)
const key = [...new Uint8Array(derivedBits)]

Expand Down
85 changes: 83 additions & 2 deletions src/services/Crypto/Cipher/Cipher.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -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
})
30 changes: 12 additions & 18 deletions src/services/Entity/Account/Account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,7 +25,7 @@ const saveAccount = async (data) => {
mlTestnetPrivKeyTag,
mlMainnetPrivKeyTag,
btcTag,
} = await getEncryptedPrivateKeys(password, salt, mnemonic)
} = await getEncryptedPrivateKeys(password, undefined, mnemonic)

const account = {
name,
Expand All @@ -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) => {
Expand Down Expand Up @@ -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({
Expand All @@ -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')
Expand Down Expand Up @@ -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')
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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({
Expand Down Expand Up @@ -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: '' },
})
Expand Down
6 changes: 5 additions & 1 deletion src/services/Entity/Account/AccountHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -41,6 +44,7 @@ const getEncryptedPrivateKeys = async (password, salt, mnemonic) => {
} = await encryptData(seed)

return {
salt: usedSalt,
encryptedMlTestnetPrivateKey,
encryptedMlMainnetPrivateKey,
btcEncryptedSeed,
Expand Down
Loading