From 1d815ea71feccd95da12283b8fdd45333914a8d9 Mon Sep 17 00:00:00 2001 From: brusher_ru Date: Wed, 21 Aug 2024 11:32:01 -0300 Subject: [PATCH 1/6] feat: implement aes-ctr-argon2 encryption --- package.json | 5 ++ src/__tests__/aes-ctr-argon2.test.ts | 51 ++++++++++++ src/utils/aes-ctr-argon2.ts | 112 +++++++++++++++++++++++++++ yarn.lock | 25 ++++++ 4 files changed, 193 insertions(+) create mode 100644 src/__tests__/aes-ctr-argon2.test.ts create mode 100644 src/utils/aes-ctr-argon2.ts diff --git a/package.json b/package.json index a961872..288b5a9 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,13 @@ "@uidotdev/usehooks": "^2.4.1", "@zondax/ledger-js": "^0.10.0", "@zondax/ledger-spacemesh": "^0.2.2", + "aes-js": "^3.1.2", "bech32": "^2.0.0", + "crypto-js": "^4.2.0", "detect-browser": "^5.3.0", "eventemitter3": "^5.0.1", "framer-motion": "^8.5.2", + "hash-wasm": "^4.11.0", "install": "^0.13.0", "js-file-download": "^0.4.12", "npm": "^10.8.1", @@ -66,6 +69,8 @@ "@commitlint/config-conventional": "^17.4.2", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^13.4.0", + "@types/aes-js": "^3.1.4", + "@types/crypto-js": "^4.2.2", "@types/jest": "^29.2.6", "@types/react": "^18.0.27", "@types/react-dom": "^18.0.10", diff --git a/src/__tests__/aes-ctr-argon2.test.ts b/src/__tests__/aes-ctr-argon2.test.ts new file mode 100644 index 0000000..0deca1f --- /dev/null +++ b/src/__tests__/aes-ctr-argon2.test.ts @@ -0,0 +1,51 @@ +import { decrypt, encrypt } from '../utils/aes-ctr-argon2'; + +// Polyfill for browser's crypto +// eslint-disable-next-line @typescript-eslint/no-var-requires +const crypto = require('crypto'); + +Object.defineProperty(globalThis, 'crypto', { + value: { + getRandomValues: (arr: Uint8Array) => crypto.randomBytes(arr.length), + }, +}); + +describe('AES-CTR-Argon2', () => { + const ENCRYPTED_FIXTURE = { + kdf: 'ARGON2', + kdfparams: { + salt: '4ae49fea810d755484e6d5fbddc8b6df', + iterations: 64, + memorySize: 65536, + parallelism: 1, + }, + cipher: 'AES-256-CTR', + cipherParams: { iv: '811e60e5e564774b16806a0bdf671e2b' }, + cipherText: 'd0e97cd11c2bb1b1f7102b', + mac: 'd0fcd7eaf6ee6404c02be79f97fc5898fe1cf7fd167d95a20b01c5a950c62778', + } as const; + + it('should encrypt', async () => { + const encryptedMsg = await encrypt('hello world', 'pass@123'); + expect(encryptedMsg).toHaveProperty('kdf'); + expect(encryptedMsg).toHaveProperty('kdfparams'); + expect(encryptedMsg).toHaveProperty('cipher'); + expect(encryptedMsg).toHaveProperty('cipherParams'); + expect(encryptedMsg).toHaveProperty('cipherText'); + expect(encryptedMsg).toHaveProperty('mac'); + }); + it('should decrypt', async () => { + const decrypted = await decrypt(ENCRYPTED_FIXTURE, 'pass@123'); + expect(decrypted).toBe('hello world'); + }); + it('should encrypt and decrypt', async () => { + const encryptedMsg = await encrypt('hello world', 'pass@123'); + const decryptedPlainText = await decrypt(encryptedMsg, 'pass@123'); + expect(decryptedPlainText).toBe('hello world'); + }); + it('should fail on wrong password', async () => { + await expect(() => + decrypt(ENCRYPTED_FIXTURE, 'wrong!Pass') + ).rejects.toThrow(); + }); +}); diff --git a/src/utils/aes-ctr-argon2.ts b/src/utils/aes-ctr-argon2.ts new file mode 100644 index 0000000..f7764d5 --- /dev/null +++ b/src/utils/aes-ctr-argon2.ts @@ -0,0 +1,112 @@ +import * as aes from 'aes-js'; +import * as cryptoJS from 'crypto-js'; +import { argon2id } from 'hash-wasm'; + +import { HexString } from '../types/common'; + +import { fromHexString, toHexString } from './hexString'; + +export const CIPHER = 'AES-256-CTR'; +export const KDF = 'ARGON2'; + +// +const ARGON2_ITERATIONS = 7; +const ARGON2_MEMORY = 2 ** 16; // 64MB +const ARGON2_PARALLELISM = 1; +const ARGON2_HASH_LENGTH = 32; // 256 bits + +// +type EncryptedMessage = { + cipher: typeof CIPHER; + cipherParams: { iv: HexString }; + kdf: typeof KDF; + kdfparams: { + salt: HexString; + iterations: number; + memorySize: number; + parallelism: number; + }; + cipherText: HexString; + mac: HexString; +}; + +// +export const getRandomBytes = (length: number): Uint8Array => + crypto.getRandomValues(new Uint8Array(length)); + +// +export const encrypt = async ( + plaintext: string, + password: string, + iterations = ARGON2_ITERATIONS, + memorySize = ARGON2_MEMORY, + parallelism = ARGON2_PARALLELISM +): Promise => { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const argon2key = await argon2id({ + password, + salt, + iterations, + memorySize, + parallelism, + outputType: 'binary', + hashLength: ARGON2_HASH_LENGTH, + }); + const plainTextBytes = aes.utils.utf8.toBytes(plaintext); + const aesIV = getRandomBytes(16); // 128-bit initial vector (salt) + // eslint-disable-next-line new-cap + const aesCTR = new aes.ModeOfOperation.ctr(argon2key, new aes.Counter(aesIV)); + const cipherTextBytes = aesCTR.encrypt(plainTextBytes); + const cipherTextHex = toHexString(cipherTextBytes); + const argon2keyHex = toHexString(argon2key); + const hmac = cryptoJS.HmacSHA256(plaintext, argon2keyHex); + + return { + kdf: KDF, + kdfparams: { + salt: toHexString(salt), + iterations, + memorySize, + parallelism, + }, + cipher: CIPHER, + cipherParams: { iv: toHexString(aesIV) }, + cipherText: cipherTextHex, + mac: hmac.toString(), + }; +}; + +const decryptHmac = (plaintext: string, key: Uint8Array) => { + try { + return cryptoJS.HmacSHA256(plaintext, toHexString(key)); + } catch (err) { + throw new Error('MAC does not match: wrong password'); + } +}; + +export const decrypt = async ( + encryptedMsg: EncryptedMessage, + password: string +) => { + const salt = fromHexString(encryptedMsg.kdfparams.salt); + const argon2key = await argon2id({ + ...encryptedMsg.kdfparams, + password, + salt, + outputType: 'binary', + hashLength: ARGON2_HASH_LENGTH, + }); + + // eslint-disable-next-line new-cap + const aesCTR = new aes.ModeOfOperation.ctr( + argon2key, + new aes.Counter(fromHexString(encryptedMsg.cipherParams.iv)) + ); + const decryptedBytes = aesCTR.decrypt(fromHexString(encryptedMsg.cipherText)); + const decryptedPlaintext = aes.utils.utf8.fromBytes(decryptedBytes); + const hmac = decryptHmac(decryptedPlaintext, argon2key); + if (hmac.toString() !== encryptedMsg.mac) { + throw new Error('MAC does not match: wrong password'); + } + return decryptedPlaintext; +}; diff --git a/yarn.lock b/yarn.lock index 0e39405..f797ab2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2471,6 +2471,11 @@ "@tufjs/canonical-json" "2.0.0" minimatch "^9.0.4" +"@types/aes-js@^3.1.4": + version "3.1.4" + resolved "https://registry.yarnpkg.com/@types/aes-js/-/aes-js-3.1.4.tgz#90afb62e69b0365a392d2e79579a6f424b772792" + integrity sha512-v3D66IptpUqh+pHKVNRxY8yvp2ESSZXe0rTzsGdzUhEwag7ljVfgCllkWv2YgiYXDhWFBrEywll4A5JToyTNFA== + "@types/aria-query@^5.0.1": version "5.0.4" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" @@ -2516,6 +2521,11 @@ dependencies: "@types/node" "*" +"@types/crypto-js@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.2.2.tgz#771c4a768d94eb5922cc202a3009558204df0cea" + integrity sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ== + "@types/estree@^1.0.0": version "1.0.5" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" @@ -2844,6 +2854,11 @@ acorn@^8.1.0, acorn@^8.4.1, acorn@^8.8.1, acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== +aes-js@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.1.2.tgz#db9aabde85d5caabbfc0d4f2a4446960f627146a" + integrity sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ== + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -3891,6 +3906,11 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" +crypto-js@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" + integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q== + css-box-model@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" @@ -5256,6 +5276,11 @@ hash-base@~3.0: inherits "^2.0.1" safe-buffer "^5.0.1" +hash-wasm@^4.11.0: + version "4.11.0" + resolved "https://registry.yarnpkg.com/hash-wasm/-/hash-wasm-4.11.0.tgz#7d1479b114c82e48498fdb1d2462a687d00386d5" + integrity sha512-HVusNXlVqHe0fzIzdQOGolnFN6mX/fqcrSAOcTBXdvzrXVHwTz11vXeKRmkR5gTuwVpvHZEIyKoePDvuAR+XwQ== + hash.js@^1.0.0, hash.js@^1.0.3: version "1.1.7" resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" From b8160f34a21698a0c3c37d53a03e0d1d3894a3cc Mon Sep 17 00:00:00 2001 From: brusher_ru Date: Wed, 21 Aug 2024 15:36:49 -0300 Subject: [PATCH 2/6] refactor: encrypt/decrypt AES-GCM --- src/utils/aes-gcm.ts | 93 ++++++++++++++++++++++++++++++++++---------- src/utils/wallet.ts | 49 +++-------------------- 2 files changed, 77 insertions(+), 65 deletions(-) diff --git a/src/utils/aes-gcm.ts b/src/utils/aes-gcm.ts index fb85459..c85c85c 100644 --- a/src/utils/aes-gcm.ts +++ b/src/utils/aes-gcm.ts @@ -1,9 +1,36 @@ +import * as aes from 'aes-js'; + +import { HexString } from '../types/common'; + +import { fromHexString, toHexString } from './hexString'; + const { subtle } = crypto; -export const KDF_DKLEN = 256; -export const KDF_ITERATIONS = 120000; +export const CIPHER = 'AES-GCM'; +export const KDF = 'PBKDF2'; -export const pbkdf2Key = async ( +// +const KDF_DKLEN = 256; +const KDF_ITERATIONS = 120000; + +// +export interface GCMEncrypted { + cipher: typeof CIPHER; + cipherParams: { + iv: HexString; + }; + kdf: typeof KDF; + kdfparams: { + dklen: number; + hash: 'SHA-512'; + iterations: number; + salt: HexString; + }; + cipherText: string; +} + +// +const pbkdf2Key = async ( pass: string, salt: Uint8Array, dklen = KDF_DKLEN, @@ -32,7 +59,7 @@ export const pbkdf2Key = async ( return key; }; -export const constructAesGcmIv = async ( +const constructAesGcmIv = async ( key: CryptoKey, input: Uint8Array ): Promise => { @@ -54,27 +81,51 @@ export const constructAesGcmIv = async ( }; export const encrypt = async ( - key: CryptoKey, - iv: Uint8Array, - plaintext: Uint8Array -): Promise => { - const ciphertext = await subtle.encrypt( - { - name: 'AES-GCM', - iv, - tagLength: 128, - }, - key, - plaintext + plaintext: string, + password: string, + dklen = KDF_DKLEN, + iterations = KDF_ITERATIONS +): Promise => { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const key = await pbkdf2Key(password, salt); + const plainTextBytes = aes.utils.utf8.toBytes(plaintext); + const iv = await constructAesGcmIv(key, plainTextBytes); + const cipherText = new Uint8Array( + await subtle.encrypt( + { + name: 'AES-GCM', + iv, + tagLength: 128, + }, + key, + plainTextBytes + ) ); - return new Uint8Array(ciphertext); + + return { + cipher: CIPHER, + cipherText: toHexString(cipherText), + cipherParams: { + iv: toHexString(iv), + }, + kdf: KDF, + kdfparams: { + hash: 'SHA-512', + salt: toHexString(salt), + dklen, + iterations, + }, + }; }; export const decrypt = async ( - key: CryptoKey, - iv: Uint8Array, - ciphertext: Uint8Array + encryptedMessage: GCMEncrypted, + password: string ): Promise => { + const salt = fromHexString(encryptedMessage.kdfparams.salt); + const iv = fromHexString(encryptedMessage.cipherParams.iv); + const cipherText = fromHexString(encryptedMessage.cipherText); + const key = await pbkdf2Key(password, salt); const plaintext = await subtle.decrypt( { name: 'AES-GCM', @@ -82,7 +133,7 @@ export const decrypt = async ( tagLength: 128, }, key, - ciphertext + cipherText ); return new Uint8Array(plaintext); }; diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index 4dd1b0a..3189e31 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -13,18 +13,11 @@ import { WalletSecretsEncrypted, } from '../types/wallet'; -import { - constructAesGcmIv, - decrypt, - encrypt, - KDF_DKLEN, - KDF_ITERATIONS, - pbkdf2Key, -} from './aes-gcm'; +import { decrypt, encrypt } from './aes-gcm'; import { generateAddress } from './bech32'; import Bip32KeyDerivation from './bip32'; import { getISODate } from './datetime'; -import { fromHexString, toHexString } from './hexString'; +import { toHexString } from './hexString'; import { AnySpawnArguments, convertSpawnArgumentsForEncoding, @@ -142,20 +135,8 @@ export const decryptWallet = async ( password: string ): Promise => { const dc = new TextDecoder(); - const key = await pbkdf2Key( - password, - fromHexString(crypto.kdfparams.salt), - crypto.kdfparams.dklen, - crypto.kdfparams.iterations - ); try { - const decryptedRaw = dc.decode( - await decrypt( - key, - fromHexString(crypto.cipherParams.iv), - fromHexString(crypto.cipherText) - ) - ); + const decryptedRaw = dc.decode(await decrypt(crypto, password)); const decrypted = JSON.parse(decryptedRaw) as WalletSecrets; return decrypted; } catch (err) { @@ -166,28 +147,8 @@ export const decryptWallet = async ( export const encryptWallet = async ( secrets: WalletSecrets, password: string -): Promise => { - const ec = new TextEncoder(); - const salt = crypto.getRandomValues(new Uint8Array(16)); - const key = await pbkdf2Key(password, salt); - const plaintext = ec.encode(JSON.stringify(secrets)); - const iv = await constructAesGcmIv(key, plaintext); - const cipherText = await encrypt(key, iv, plaintext); - return { - cipher: 'AES-GCM', - cipherText: toHexString(cipherText), - cipherParams: { - iv: toHexString(iv), - }, - kdf: 'PBKDF2', - kdfparams: { - dklen: KDF_DKLEN, - hash: 'SHA-512', - salt: toHexString(salt), - iterations: KDF_ITERATIONS, - }, - }; -}; +): Promise => + encrypt(JSON.stringify(secrets), password); export const safeKeyForAccount = (acc: AccountWithAddress) => `${acc.address}_${acc.displayName.replace(/\s/g, '_')}`; From f91f0f1411acb8ce8059494e224a432e1d72cd0f Mon Sep 17 00:00:00 2001 From: brusher_ru Date: Thu, 22 Aug 2024 01:11:53 -0300 Subject: [PATCH 3/6] feat: support decrypt for both KDFs, but encrypt only using Argon2 --- src/types/wallet.ts | 19 +++--------- src/utils/aes-ctr-argon2.ts | 20 +++++++++--- src/utils/aes-gcm.ts | 18 +++++++++-- src/utils/wallet.ts | 61 ++++++++++++------------------------- 4 files changed, 55 insertions(+), 63 deletions(-) diff --git a/src/types/wallet.ts b/src/types/wallet.ts index 3a11eda..762a929 100644 --- a/src/types/wallet.ts +++ b/src/types/wallet.ts @@ -1,6 +1,8 @@ +import { Argon2Encrypted } from '../utils/aes-ctr-argon2'; +import { GCMEncrypted } from '../utils/aes-gcm'; import { AnySpawnArguments } from '../utils/templates'; -import { Bech32Address, HexString } from './common'; +import { Bech32Address } from './common'; export type KeyMeta = { displayName: string; @@ -69,20 +71,7 @@ export interface Wallet { crypto: WalletSecrets; } -export interface WalletSecretsEncrypted { - cipher: 'AES-GCM'; - cipherParams: { - iv: HexString; - }; - kdf: 'PBKDF2'; - kdfparams: { - dklen: number; - hash: 'SHA-512'; - iterations: number; - salt: HexString; - }; - cipherText: string; -} +export type WalletSecretsEncrypted = GCMEncrypted | Argon2Encrypted; // Encrypted Wallet representation on the filesystem export interface WalletFile { diff --git a/src/utils/aes-ctr-argon2.ts b/src/utils/aes-ctr-argon2.ts index f7764d5..8e3dfce 100644 --- a/src/utils/aes-ctr-argon2.ts +++ b/src/utils/aes-ctr-argon2.ts @@ -16,7 +16,7 @@ const ARGON2_PARALLELISM = 1; const ARGON2_HASH_LENGTH = 32; // 256 bits // -type EncryptedMessage = { +export type Argon2Encrypted = { cipher: typeof CIPHER; cipherParams: { iv: HexString }; kdf: typeof KDF; @@ -30,6 +30,18 @@ type EncryptedMessage = { mac: HexString; }; +export const isArgon2Encrypted = (data: unknown): data is Argon2Encrypted => + typeof data === 'object' && + data !== null && + 'cipher' in data && + data.cipher === CIPHER && + 'cipherParams' in data && + 'kdf' in data && + data.kdf === KDF && + 'kdfparams' in data && + 'cipherText' in data && + 'mac' in data; + // export const getRandomBytes = (length: number): Uint8Array => crypto.getRandomValues(new Uint8Array(length)); @@ -41,7 +53,7 @@ export const encrypt = async ( iterations = ARGON2_ITERATIONS, memorySize = ARGON2_MEMORY, parallelism = ARGON2_PARALLELISM -): Promise => { +): Promise => { const salt = crypto.getRandomValues(new Uint8Array(16)); const argon2key = await argon2id({ password, @@ -85,9 +97,9 @@ const decryptHmac = (plaintext: string, key: Uint8Array) => { }; export const decrypt = async ( - encryptedMsg: EncryptedMessage, + encryptedMsg: Argon2Encrypted, password: string -) => { +): Promise => { const salt = fromHexString(encryptedMsg.kdfparams.salt); const argon2key = await argon2id({ ...encryptedMsg.kdfparams, diff --git a/src/utils/aes-gcm.ts b/src/utils/aes-gcm.ts index c85c85c..c790418 100644 --- a/src/utils/aes-gcm.ts +++ b/src/utils/aes-gcm.ts @@ -29,6 +29,17 @@ export interface GCMEncrypted { cipherText: string; } +export const isGCMEncrypted = (data: unknown): data is GCMEncrypted => + typeof data === 'object' && + data !== null && + 'cipher' in data && + data.cipher === CIPHER && + 'cipherParams' in data && + 'kdf' in data && + data.kdf === KDF && + 'kdfparams' in data && + 'cipherText' in data; + // const pbkdf2Key = async ( pass: string, @@ -121,12 +132,13 @@ export const encrypt = async ( export const decrypt = async ( encryptedMessage: GCMEncrypted, password: string -): Promise => { +): Promise => { + const dc = new TextDecoder(); const salt = fromHexString(encryptedMessage.kdfparams.salt); const iv = fromHexString(encryptedMessage.cipherParams.iv); const cipherText = fromHexString(encryptedMessage.cipherText); const key = await pbkdf2Key(password, salt); - const plaintext = await subtle.decrypt( + const decryptedBytes = await subtle.decrypt( { name: 'AES-GCM', iv, @@ -135,5 +147,5 @@ export const decrypt = async ( key, cipherText ); - return new Uint8Array(plaintext); + return dc.decode(decryptedBytes); }; diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index 3189e31..8028b0e 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -5,7 +5,6 @@ import { StdTemplateKeys, StdTemplates } from '@spacemesh/sm-codec'; import { Bech32Address } from '../types/common'; import { AccountWithAddress, - Contact, KeyPair, Wallet, WalletMeta, @@ -13,7 +12,12 @@ import { WalletSecretsEncrypted, } from '../types/wallet'; -import { decrypt, encrypt } from './aes-gcm'; +import { + decrypt as decryptArgon2, + encrypt, + isArgon2Encrypted, +} from './aes-ctr-argon2'; +import { decrypt as decryptGCM, isGCMEncrypted } from './aes-gcm'; import { generateAddress } from './bech32'; import Bip32KeyDerivation from './bip32'; import { getISODate } from './datetime'; @@ -73,43 +77,6 @@ export const createWallet = ( return { meta, crypto }; }; -// -// KeyPairs -// - -export const addKeyPair = (wallet: Wallet, keypair: KeyPair): Wallet => ({ - ...wallet, - crypto: { - ...wallet.crypto, - keys: [...wallet.crypto.keys, keypair], - }, -}); - -// TODO: editKeyPair, removeKeyPair -// TODO: addAccount, editAccount, removeAccount - -export const generateNewKeyPair = (wallet: Wallet, name?: string): Wallet => { - const index = wallet.crypto.accounts.length; - return addKeyPair( - wallet, - createKeyPair(name || `Account ${index}`, wallet.crypto.mnemonic, index) - ); -}; - -// -// Contacts -// - -export const addContact = (wallet: Wallet, contact: Contact): Wallet => ({ - ...wallet, - crypto: { - ...wallet.crypto, - contacts: [...wallet.crypto.contacts, contact], - }, -}); - -// TODO: editContact, removeContact - // // Derive Account from KeyPair // @@ -130,13 +97,25 @@ export const computeAddress = ( // // Encryption / decryption // +const decryptAnyWallet = async ( + crypto: WalletSecretsEncrypted, + password: string +): Promise => { + if (isArgon2Encrypted(crypto)) { + return decryptArgon2(crypto, password); + } + if (isGCMEncrypted(crypto)) { + return decryptGCM(crypto, password); + } + throw new Error('Unsupported encryption format'); +}; + export const decryptWallet = async ( crypto: WalletSecretsEncrypted, password: string ): Promise => { - const dc = new TextDecoder(); try { - const decryptedRaw = dc.decode(await decrypt(crypto, password)); + const decryptedRaw = await decryptAnyWallet(crypto, password); const decrypted = JSON.parse(decryptedRaw) as WalletSecrets; return decrypted; } catch (err) { From a4ec14f8ffe4b7ce90b55648d0554e47e1932456 Mon Sep 17 00:00:00 2001 From: brusher_ru Date: Thu, 22 Aug 2024 01:12:51 -0300 Subject: [PATCH 4/6] refactor: avoid decrypting wallet multiple times for some operations --- src/components/CreateKeyPairModal.tsx | 13 +---- src/components/ImportKeyFromLedgerModal.tsx | 16 ++---- src/components/ImportKeyPairModal.tsx | 18 +++--- src/store/useWallet.ts | 63 ++++++++++++++++----- 4 files changed, 63 insertions(+), 47 deletions(-) diff --git a/src/components/CreateKeyPairModal.tsx b/src/components/CreateKeyPairModal.tsx index c97e9be..c1de680 100644 --- a/src/components/CreateKeyPairModal.tsx +++ b/src/components/CreateKeyPairModal.tsx @@ -13,7 +13,6 @@ import { ModalOverlay, Text, } from '@chakra-ui/react'; -import { StdPublicKeys } from '@spacemesh/sm-codec'; import usePassword from '../store/usePassword'; import useWallet from '../store/useWallet'; @@ -36,7 +35,7 @@ function CreateKeyPairModal({ isOpen, onClose, }: CreateKeyPairModalProps): JSX.Element { - const { createKeyPair, createAccount, wallet } = useWallet(); + const { createKeyPair, wallet } = useWallet(); const { withPassword } = usePassword(); const { register, @@ -55,15 +54,7 @@ function CreateKeyPairModal({ async ({ displayName, path, createSingleSig }) => { const success = await withPassword( async (password) => { - const key = await createKeyPair(displayName, path, password); - if (createSingleSig) { - await createAccount( - displayName, - StdPublicKeys.SingleSig, - { PublicKey: key.publicKey }, - password - ); - } + await createKeyPair(displayName, path, password, createSingleSig); return true; }, 'Create a new Key Pair', diff --git a/src/components/ImportKeyFromLedgerModal.tsx b/src/components/ImportKeyFromLedgerModal.tsx index 00529a2..1995ab4 100644 --- a/src/components/ImportKeyFromLedgerModal.tsx +++ b/src/components/ImportKeyFromLedgerModal.tsx @@ -12,7 +12,6 @@ import { ModalOverlay, Text, } from '@chakra-ui/react'; -import { StdPublicKeys } from '@spacemesh/sm-codec'; import useHardwareWallet from '../store/useHardwareWallet'; import usePassword from '../store/usePassword'; @@ -36,7 +35,7 @@ function ImportKeyFromLedgerModal({ isOpen, onClose, }: ImportKeyFromLedgerModalProps): JSX.Element { - const { addForeignKey, createAccount } = useWallet(); + const { addForeignKey } = useWallet(); const { withPassword } = usePassword(); const { checkDeviceConnection, connectedDevice, modalConnect } = useHardwareWallet(); @@ -67,18 +66,11 @@ function ImportKeyFromLedgerModal({ const publicKey = await connectedDevice.actions.getPubKey(path); const success = await withPassword( async (password) => { - const key = await addForeignKey( + await addForeignKey( { displayName, path, publicKey }, - password + password, + createSingleSig ); - if (createSingleSig) { - await createAccount( - displayName, - StdPublicKeys.SingleSig, - { PublicKey: key.publicKey }, - password - ); - } return true; }, 'Import PublicKey from Ledger device', diff --git a/src/components/ImportKeyPairModal.tsx b/src/components/ImportKeyPairModal.tsx index 81a7abd..52131aa 100644 --- a/src/components/ImportKeyPairModal.tsx +++ b/src/components/ImportKeyPairModal.tsx @@ -12,7 +12,6 @@ import { ModalOverlay, Text, } from '@chakra-ui/react'; -import { StdPublicKeys } from '@spacemesh/sm-codec'; import usePassword from '../store/usePassword'; import useWallet from '../store/useWallet'; @@ -38,7 +37,7 @@ function ImportKeyPairModal({ onClose, keys, }: ImportKeyPairModalProps): JSX.Element { - const { importKeyPair, createAccount } = useWallet(); + const { importKeyPair } = useWallet(); const { withPassword } = usePassword(); const { register, @@ -57,15 +56,12 @@ function ImportKeyPairModal({ async ({ displayName, secretKey, createSingleSig }) => { const success = await withPassword( async (password) => { - const key = await importKeyPair(displayName, secretKey, password); - if (createSingleSig) { - await createAccount( - displayName, - StdPublicKeys.SingleSig, - { PublicKey: key.publicKey }, - password - ); - } + await importKeyPair( + displayName, + secretKey, + password, + createSingleSig + ); return true; }, 'Importing the Key Pair', diff --git a/src/store/useWallet.ts b/src/store/useWallet.ts index 7674d10..7f4b16b 100644 --- a/src/store/useWallet.ts +++ b/src/store/useWallet.ts @@ -2,7 +2,7 @@ import fileDownload from 'js-file-download'; import { create } from 'zustand'; import * as bip39 from '@scure/bip39'; -import { StdTemplateKeys } from '@spacemesh/sm-codec'; +import { StdPublicKeys, StdTemplateKeys } from '@spacemesh/sm-codec'; import { HexString } from '../types/common'; import { @@ -64,20 +64,28 @@ type WalletActions = { // Operations with secrets loadWalletWithSecrets: (password: string) => Promise; showMnemonics: (password: string) => Promise; - addKeyPair: (keypair: AnyKey, password: string) => void; + addKeyPair: ( + keypair: AnyKey, + password: string, + withSingleSig?: boolean, + prevWallet?: Wallet | null + ) => void; addForeignKey: ( key: { displayName: string; path: string; publicKey: HexString }, - password: string + password: string, + withSingleSig?: boolean ) => Promise; createKeyPair: ( displayName: string, path: string, - password: string + password: string, + withSingleSig?: boolean ) => Promise; importKeyPair: ( displayName: string, secretKey: HexString, - password: string + password: string, + withSingleSig?: boolean ) => Promise; revealSecretKey: ( publicKey: HexString, @@ -203,12 +211,31 @@ const useWallet = create( const wallet = await get().loadWalletWithSecrets(password); return wallet.crypto.mnemonic; }, - addKeyPair: async (keypair, password) => { - const wallet = await get().loadWalletWithSecrets(password); + addKeyPair: async ( + keypair, + password, + withSingleSig = false, + prevWallet = null + ) => { + const wallet = + prevWallet || (await get().loadWalletWithSecrets(password)); + const accounts = withSingleSig + ? [ + ...wallet.crypto.accounts, + { + displayName: keypair.displayName, + templateAddress: StdPublicKeys.SingleSig, + spawnArguments: { + PublicKey: keypair.publicKey, + }, + }, + ] + : wallet.crypto.accounts; // Preparing secret part of the wallet const newSecrets = { ...wallet.crypto, keys: [...wallet.crypto.keys, keypair], + accounts, }; // Updating App state set({ @@ -223,16 +250,21 @@ const useWallet = create( crypto: await encryptWallet(newSecrets, password), }); }, - addForeignKey: async (key, password) => { + addForeignKey: async (key, password, withSingleSig = false) => { const newKeyPair: ForeignKey = { ...key, created: getISODate(), origin: KeyOrigin.Ledger, }; - await get().addKeyPair(newKeyPair, password); + await get().addKeyPair(newKeyPair, password, withSingleSig); return ensafeKeyPair(newKeyPair); }, - createKeyPair: async (displayName, path, password) => { + createKeyPair: async ( + displayName, + path, + password, + withSingleSig = false + ) => { const wallet = await get().loadWalletWithSecrets(password); // Creating new key pair const seed = await bip39.mnemonicToSeedSync(wallet.crypto.mnemonic); @@ -244,10 +276,15 @@ const useWallet = create( publicKey: toHexString(keys.publicKey), secretKey: toHexString(keys.secretKey), }; - await get().addKeyPair(kp, password); + await get().addKeyPair(kp, password, withSingleSig, wallet); return ensafeKeyPair(kp); }, - importKeyPair: async (displayName, secretKey, password) => { + importKeyPair: async ( + displayName, + secretKey, + password, + withSingleSig = false + ) => { const trimmed = secretKey.replace(/^0x/, ''); const kp: LocalKey = { displayName, @@ -255,7 +292,7 @@ const useWallet = create( publicKey: trimmed.slice(64), secretKey: trimmed, }; - await get().addKeyPair(kp, password); + await get().addKeyPair(kp, password, withSingleSig); return ensafeKeyPair(kp); }, revealSecretKey: async (publicKey, password) => { From f9d65d28c8d3f218a90852c134502e9db64be0e1 Mon Sep 17 00:00:00 2001 From: brusher_ru Date: Thu, 22 Aug 2024 01:44:57 -0300 Subject: [PATCH 5/6] tweak: add "waiting" state for buttons that decrypts wallet (password modal, unlock, import wallet) --- src/components/PasswordAlert.tsx | 14 +++++++++++--- src/screens/UnlockScreen.tsx | 14 ++++++++++++-- src/screens/welcome/ImportScreen.tsx | 14 ++++++++++++-- src/store/usePassword.ts | 25 +++++++++++++++---------- src/utils/promises.ts | 11 +++++++++++ 5 files changed, 61 insertions(+), 17 deletions(-) create mode 100644 src/utils/promises.ts diff --git a/src/components/PasswordAlert.tsx b/src/components/PasswordAlert.tsx index 435e292..a3abe8c 100644 --- a/src/components/PasswordAlert.tsx +++ b/src/components/PasswordAlert.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { Form } from 'react-hook-form'; import { @@ -21,12 +21,19 @@ import usePassword from '../store/usePassword'; import PasswordInput from './PasswordInput'; function PasswordAlert(): JSX.Element { + const [isLoading, setIsLoading] = useState(false); const { form } = usePassword(); const cancelRef = useRef(null); if (!form.register.password || !form.register.remember) { throw new Error('PasswordAlert: password or remember is not registered'); } + const onSubmit = async () => { + setIsLoading(true); + await form.onSubmit(); + setIsLoading(false); + }; + return ( - {form.actionLabel} + {isLoading ? 'Checking password...' : form.actionLabel} diff --git a/src/screens/UnlockScreen.tsx b/src/screens/UnlockScreen.tsx index b64979e..fe0c742 100644 --- a/src/screens/UnlockScreen.tsx +++ b/src/screens/UnlockScreen.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Form, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; @@ -32,8 +33,10 @@ function UnlockScreen(): JSX.Element { reset, formState: { errors }, } = useForm(); + const [isLoading, setIsLoading] = useState(false); const submit = handleSubmit(async (data) => { + setIsLoading(true); const success = await unlockWallet(data.password); if (!success) { setError('password', { type: 'value', message: 'Invalid password' }); @@ -41,6 +44,7 @@ function UnlockScreen(): JSX.Element { } setValue('password', ''); reset(); + setIsLoading(false); navigate('/wallet'); }); @@ -64,8 +68,14 @@ function UnlockScreen(): JSX.Element { {errors.password?.message} - diff --git a/src/screens/welcome/ImportScreen.tsx b/src/screens/welcome/ImportScreen.tsx index afc175e..27255e4 100644 --- a/src/screens/welcome/ImportScreen.tsx +++ b/src/screens/welcome/ImportScreen.tsx @@ -19,6 +19,7 @@ import BackButton from '../../components/BackButton'; import PasswordInput from '../../components/PasswordInput'; import useWallet from '../../store/useWallet'; import { WalletFile } from '../../types/wallet'; +import { postpone } from '../../utils/promises'; type FormValues = { password: string; @@ -38,6 +39,7 @@ function ImportScreen(): JSX.Element { } = useForm(); const { openWallet } = useWallet(); const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); const readFile = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -75,11 +77,18 @@ function ImportScreen(): JSX.Element { setError('root', { type: 'manual', message: 'No wallet file loaded' }); return; } - const success = await openWallet(walletFileContent, password); + setIsLoading(true); + const success = await postpone( + // We need to postpone it for one tick + // to allow component to re-render + () => openWallet(walletFileContent, password), + 1 + ); if (!success) { setError('password', { type: 'value', message: 'Invalid password' }); return; } + setIsLoading(false); navigate('/wallet'); }); @@ -135,8 +144,9 @@ function ImportScreen(): JSX.Element { mt={4} width="100%" onClick={onSubmit} + disabled={isLoading} > - Import wallet + {isLoading ? 'Importing...' : 'Import wallet'} diff --git a/src/store/usePassword.ts b/src/store/usePassword.ts index 31bd1c1..0731887 100644 --- a/src/store/usePassword.ts +++ b/src/store/usePassword.ts @@ -13,6 +13,7 @@ import { useDisclosure } from '@chakra-ui/react'; import { MINUTE } from '../utils/constants'; import { noop } from '../utils/func'; +import { postpone } from '../utils/promises'; const REMEMBER_PASSWORD_TIME = 5 * MINUTE; @@ -123,16 +124,20 @@ const usePassword = (): UsePasswordReturnType => { ); return; } - try { - const res = await passwordCallback(password); - setPassword(password, remember); - _onClose(); - setValue('password', ''); - reset(); - eventEmitter.emit('success', res); - } catch (err) { - setError('password', { message: 'Incorrect password' }); - } + await postpone(async () => { + // We need to postpone it for one tick to allow + // the form to re-render before start checking the password + try { + const res = await passwordCallback(password); + setPassword(password, remember); + _onClose(); + setValue('password', ''); + reset(); + eventEmitter.emit('success', res); + } catch (err) { + setError('password', { message: 'Incorrect password' }); + } + }, 1); }); const onClose = () => { diff --git a/src/utils/promises.ts b/src/utils/promises.ts new file mode 100644 index 0000000..a040682 --- /dev/null +++ b/src/utils/promises.ts @@ -0,0 +1,11 @@ +export const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +export const postpone = (fn: () => T, ms: number): Promise => + new Promise((resolve) => { + setTimeout(async () => { + resolve(await fn()); + }, ms); + }); From 3fb5e8bf7fecdf0a84c65303816312a0bc40424d Mon Sep 17 00:00:00 2001 From: brusher_ru Date: Thu, 22 Aug 2024 01:50:11 -0300 Subject: [PATCH 6/6] feat: re-encrypt imported AES-GCM wallet with Argon2 --- src/store/useWallet.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/store/useWallet.ts b/src/store/useWallet.ts index 7f4b16b..01a419d 100644 --- a/src/store/useWallet.ts +++ b/src/store/useWallet.ts @@ -35,6 +35,7 @@ import { generateMnemonic, } from '../utils/wallet'; import { isLegacySecrets, migrateSecrets } from '../utils/wallet.legacy'; +import { isGCMEncrypted } from '../utils/aes-gcm'; type WalletData = { meta: WalletMeta; @@ -146,9 +147,10 @@ const useWallet = create( crypto: migratedSecrets, }), }); - const secretsToStore = isLegacySecrets(secrets) - ? await encryptWallet(migratedSecrets, password) - : wallet.crypto; + const secretsToStore = + isLegacySecrets(secrets) || isGCMEncrypted(wallet.crypto) + ? await encryptWallet(migratedSecrets, password) + : wallet.crypto; saveToLocalStorage(WALLET_STORE_KEY, { meta: wallet.meta,