From d6b985c708710c1788b6c670505dcdb13bff28ae Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 19 Aug 2026 16:38:20 +0200 Subject: [PATCH 1/2] Replace ethereumjs-wallet with in-package V3 keystore implementation ethereumjs-wallet is deprecated and pulled in the unmaintained browserify crypto stack (ethereum-cryptography@0.1.3, pbkdf2, sha.js, cipher-base, secp256k1@4, elliptic), carrying several critical advisories including pbkdf2 returning static keys for Uint8Array input. Implement V3 encrypt/decrypt directly on @noble/hashes and @noble/ciphers, both already used across sibling SDK packages. The keystore format is unchanged and geth-generated keystores still decrypt; decryption now accepts only aes-128-ctr rather than passing the file's cipher field to an arbitrary cipher constructor. --- .../keystores-drop-ethereumjs-wallet.md | 17 + packages/sdk/keystores/package.json | 3 +- packages/sdk/keystores/src/keystore-base.ts | 19 +- .../sdk/keystores/src/v3-keystore.test.ts | 117 +++++++ packages/sdk/keystores/src/v3-keystore.ts | 176 ++++++++++ yarn.lock | 300 +----------------- 6 files changed, 330 insertions(+), 302 deletions(-) create mode 100644 .changeset/keystores-drop-ethereumjs-wallet.md create mode 100644 packages/sdk/keystores/src/v3-keystore.test.ts create mode 100644 packages/sdk/keystores/src/v3-keystore.ts diff --git a/.changeset/keystores-drop-ethereumjs-wallet.md b/.changeset/keystores-drop-ethereumjs-wallet.md new file mode 100644 index 0000000000..3423238556 --- /dev/null +++ b/.changeset/keystores-drop-ethereumjs-wallet.md @@ -0,0 +1,17 @@ +--- +'@celo/keystores': patch +--- + +Replace the deprecated `ethereumjs-wallet` dependency with an in-package V3 +keystore implementation built on `@noble/hashes` and `@noble/ciphers`. + +`ethereumjs-wallet` pulled in the unmaintained browserify crypto stack +(`ethereum-cryptography@0.1.3`, `pbkdf2`, `sha.js`, `cipher-base`, `secp256k1@4`, +`elliptic`), which carried several critical advisories — among them `pbkdf2` +silently returning static keys for `Uint8Array` input, and hash-rewind flaws in +`sha.js` and `cipher-base`. Those packages are no longer in the dependency tree. + +The keystore format is unchanged: encryption still uses geth's defaults (scrypt +n=262144, r=8, p=1, AES-128-CTR, keccak256 MAC), and geth-generated keystores +still decrypt. Decryption now accepts only the `aes-128-ctr` cipher rather than +passing the file's `cipher` field to an arbitrary cipher constructor. diff --git a/packages/sdk/keystores/package.json b/packages/sdk/keystores/package.json index 04ceefe34e..184e32dea4 100644 --- a/packages/sdk/keystores/package.json +++ b/packages/sdk/keystores/package.json @@ -27,7 +27,8 @@ "dependencies": { "@celo/utils": "^8.0.3", "@celo/wallet-local": "^8.0.1", - "ethereumjs-wallet": "^1.0.1", + "@noble/ciphers": "1.1.3", + "@noble/hashes": "1.3.3", "rimraf": "^4.4.1" }, "devDependencies": { diff --git a/packages/sdk/keystores/src/keystore-base.ts b/packages/sdk/keystores/src/keystore-base.ts index 358cf6056a..a232114079 100644 --- a/packages/sdk/keystores/src/keystore-base.ts +++ b/packages/sdk/keystores/src/keystore-base.ts @@ -3,7 +3,7 @@ import { normalizeAddressWith0x, privateKeyToAddress, } from '@celo/utils/lib/address' -import Wallet from 'ethereumjs-wallet' +import { decryptV3, encryptV3, v3Filename } from './v3-keystore' export enum ErrorMessages { KEYSTORE_ENTRY_EXISTS = 'Existing encrypted keystore for address', @@ -90,12 +90,10 @@ export abstract class KeystoreBase { throw new Error(ErrorMessages.KEYSTORE_ENTRY_EXISTS) } - const key = Buffer.from(privateKey, 'hex') - const wallet = Wallet.fromPrivateKey(key) - const keystore = await wallet.toV3String(passphrase) - const keystoreName = wallet.getV3Filename(Date.now()) + const keystore = await encryptV3(privateKey, passphrase) + const keystoreName = v3Filename(address, Date.now()) - this.persistKeystore(keystoreName, keystore) + this.persistKeystore(keystoreName, JSON.stringify(keystore)) } /** @@ -120,7 +118,7 @@ export abstract class KeystoreBase { async getPrivateKey(address: string, passphrase: string): Promise { const rawKeystore = this.getRawKeystore(await this.getKeystoreName(address)) // TODO do we want to trim leading 0x here? what is the best practice here? - return (await Wallet.fromV3(rawKeystore, passphrase)).getPrivateKeyString() + return decryptV3(rawKeystore, passphrase) } /** @@ -132,10 +130,9 @@ export abstract class KeystoreBase { async changeKeystorePassphrase(address: string, oldPassphrase: string, newPassphrase: string) { const keystoreName = await this.getKeystoreName(address) const rawKeystore = this.getRawKeystore(keystoreName) - const newKeystore = await (await Wallet.fromV3(rawKeystore, oldPassphrase)).toV3String( - newPassphrase - ) - this.persistKeystore(keystoreName, newKeystore) + const privateKey = await decryptV3(rawKeystore, oldPassphrase) + const newKeystore = await encryptV3(privateKey, newPassphrase) + this.persistKeystore(keystoreName, JSON.stringify(newKeystore)) } /** diff --git a/packages/sdk/keystores/src/v3-keystore.test.ts b/packages/sdk/keystores/src/v3-keystore.test.ts new file mode 100644 index 0000000000..44c4c03189 --- /dev/null +++ b/packages/sdk/keystores/src/v3-keystore.test.ts @@ -0,0 +1,117 @@ +import { trimLeading0x } from '@celo/utils/lib/address' +import { + ADDRESS1, + GETH_GEN_KEYSTORE1, + GETH_GEN_KEYSTORE2, + PASSPHRASE1, + PASSPHRASE2, + PK1, + PK2, +} from './test-constants' +import { decryptV3, encryptV3, V3ErrorMessages, v3Filename } from './v3-keystore' + +jest.setTimeout(30000) + +describe('decryptV3', () => { + it('decrypts a geth-generated keystore', async () => { + expect(trimLeading0x(await decryptV3(GETH_GEN_KEYSTORE1, PASSPHRASE1))).toBe(PK1) + expect(trimLeading0x(await decryptV3(GETH_GEN_KEYSTORE2, PASSPHRASE2))).toBe(PK2) + }) + + it('returns the private key 0x-prefixed', async () => { + expect(await decryptV3(GETH_GEN_KEYSTORE1, PASSPHRASE1)).toBe(`0x${PK1}`) + }) + + it('rejects a wrong passphrase', async () => { + await expect(decryptV3(GETH_GEN_KEYSTORE1, `${PASSPHRASE1}!`)).rejects.toThrow( + V3ErrorMessages.WRONG_PASSPHRASE + ) + }) + + it('rejects a non-V3 keystore', async () => { + const v1 = JSON.stringify({ ...JSON.parse(GETH_GEN_KEYSTORE1), version: 1 }) + await expect(decryptV3(v1, PASSPHRASE1)).rejects.toThrow(V3ErrorMessages.NOT_V3) + }) + + it('rejects an unsupported cipher', async () => { + const parsed = JSON.parse(GETH_GEN_KEYSTORE1) + parsed.crypto.cipher = 'aes-128-cbc' + await expect(decryptV3(JSON.stringify(parsed), PASSPHRASE1)).rejects.toThrow( + V3ErrorMessages.UNSUPPORTED_CIPHER + ) + }) + + it('rejects an unsupported key derivation scheme', async () => { + const parsed = JSON.parse(GETH_GEN_KEYSTORE1) + parsed.crypto.kdf = 'bcrypt' + await expect(decryptV3(JSON.stringify(parsed), PASSPHRASE1)).rejects.toThrow( + V3ErrorMessages.UNSUPPORTED_KDF + ) + }) + + it('rejects pbkdf2 with a pseudorandom function other than hmac-sha256', async () => { + const parsed = JSON.parse(GETH_GEN_KEYSTORE1) + parsed.crypto.kdf = 'pbkdf2' + parsed.crypto.kdfparams = { dklen: 32, salt: '00'.repeat(32), c: 1, prf: 'hmac-sha512' } + await expect(decryptV3(JSON.stringify(parsed), PASSPHRASE1)).rejects.toThrow( + V3ErrorMessages.UNSUPPORTED_PBKDF2_PARAMS + ) + }) + + it('detects a tampered ciphertext via the mac', async () => { + const parsed = JSON.parse(GETH_GEN_KEYSTORE1) + parsed.crypto.ciphertext = `${parsed.crypto.ciphertext.slice(0, -2)}00` + await expect(decryptV3(JSON.stringify(parsed), PASSPHRASE1)).rejects.toThrow( + V3ErrorMessages.WRONG_PASSPHRASE + ) + }) +}) + +describe('encryptV3', () => { + it('round-trips a private key', async () => { + const keystore = await encryptV3(PK1, PASSPHRASE1) + expect(trimLeading0x(await decryptV3(JSON.stringify(keystore), PASSPHRASE1))).toBe(PK1) + }) + + it('round-trips a 0x-prefixed private key', async () => { + const keystore = await encryptV3(`0x${PK1}`, PASSPHRASE1) + expect(trimLeading0x(await decryptV3(JSON.stringify(keystore), PASSPHRASE1))).toBe(PK1) + }) + + it('writes geth-compatible parameters', async () => { + const { version, crypto } = await encryptV3(PK1, PASSPHRASE1) + expect(version).toBe(3) + expect(crypto.cipher).toBe('aes-128-ctr') + expect(crypto.kdf).toBe('scrypt') + expect(crypto.kdfparams).toMatchObject({ dklen: 32, n: 262144, r: 8, p: 1 }) + expect(crypto.cipherparams.iv).toHaveLength(32) + expect(crypto.kdfparams.salt).toHaveLength(64) + expect(crypto.mac).toHaveLength(64) + }) + + it('stores the address unprefixed and lowercased', async () => { + const { address } = await encryptV3(PK1, PASSPHRASE1) + expect(address).toBe(trimLeading0x(ADDRESS1).toLowerCase()) + }) + + it('uses a fresh salt and iv for each keystore', async () => { + const [a, b] = await Promise.all([encryptV3(PK1, PASSPHRASE1), encryptV3(PK1, PASSPHRASE1)]) + expect(a.crypto.kdfparams.salt).not.toBe(b.crypto.kdfparams.salt) + expect(a.crypto.cipherparams.iv).not.toBe(b.crypto.cipherparams.iv) + expect(a.crypto.ciphertext).not.toBe(b.crypto.ciphertext) + expect(a.id).not.toBe(b.id) + }) +}) + +describe('v3Filename', () => { + it('builds the geth filename convention', () => { + expect(v3Filename(ADDRESS1, Date.parse('2016-03-15T17:11:33.007Z'))).toBe( + `UTC--2016-03-15T17-11-33.007Z--${trimLeading0x(ADDRESS1).toLowerCase()}` + ) + }) + + it('accepts an address with or without a 0x prefix', () => { + const timestamp = Date.parse('2016-03-15T17:11:33.007Z') + expect(v3Filename(ADDRESS1, timestamp)).toBe(v3Filename(trimLeading0x(ADDRESS1), timestamp)) + }) +}) diff --git a/packages/sdk/keystores/src/v3-keystore.ts b/packages/sdk/keystores/src/v3-keystore.ts new file mode 100644 index 0000000000..3493d3b751 --- /dev/null +++ b/packages/sdk/keystores/src/v3-keystore.ts @@ -0,0 +1,176 @@ +/** + * Encryption and decryption of Web3 Secret Storage V3 keystores. + * See https://github.com/ethereum/wiki/wiki/Web3-Secret-Storage-Definition + * + * Parameter defaults and error messages match those of geth-generated keystores. + */ + +import { randomUUID } from 'node:crypto' +import { + ensureLeading0x, + hexToBuffer, + privateKeyToAddress, + trimLeading0x, +} from '@celo/utils/lib/address' +import { ctr } from '@noble/ciphers/aes' +import { pbkdf2Async } from '@noble/hashes/pbkdf2' +import { scryptAsync } from '@noble/hashes/scrypt' +import { keccak_256 } from '@noble/hashes/sha3' +import { sha256 } from '@noble/hashes/sha256' +import { randomBytes } from '@noble/hashes/utils' + +/** The only cipher used by geth and by the wider V3 ecosystem. */ +const CIPHER = 'aes-128-ctr' +const SCRYPT_N = 262144 +const SCRYPT_R = 8 +const SCRYPT_P = 1 +const DERIVED_KEY_LENGTH = 32 +const SALT_LENGTH = 32 +const IV_LENGTH = 16 + +export enum V3ErrorMessages { + NOT_V3 = 'Not a V3 wallet', + UNSUPPORTED_CIPHER = 'Unsupported cipher', + UNSUPPORTED_KDF = 'Unsupported key derivation scheme', + UNSUPPORTED_PBKDF2_PARAMS = 'Unsupported parameters to PBKDF2', + WRONG_PASSPHRASE = 'Key derivation failed - possibly wrong passphrase', +} + +interface ScryptParams { + dklen: number + salt: string + n: number + r: number + p: number +} + +interface Pbkdf2Params { + dklen: number + salt: string + c: number + prf: string +} + +export interface V3Keystore { + version: number + id: string + address: string + crypto: { + ciphertext: string + cipherparams: { iv: string } + cipher: string + kdf: string + kdfparams: ScryptParams | Pbkdf2Params + mac: string + } +} + +/** + * Derives the symmetric key that protects a keystore's ciphertext. + * The first half encrypts the private key, the second half authenticates it. + */ +async function deriveKey( + kdf: string, + kdfparams: ScryptParams | Pbkdf2Params, + passphrase: string +): Promise { + const password = Buffer.from(passphrase, 'utf8') + const salt = hexToBuffer(kdfparams.salt) + + if (kdf === 'scrypt') { + const { n, r, p } = kdfparams as ScryptParams + return scryptAsync(password, salt, { N: n, r, p, dkLen: kdfparams.dklen }) + } + if (kdf === 'pbkdf2') { + const { c, prf } = kdfparams as Pbkdf2Params + if (prf !== 'hmac-sha256') { + throw new Error(V3ErrorMessages.UNSUPPORTED_PBKDF2_PARAMS) + } + return pbkdf2Async(sha256, password, salt, { c, dkLen: kdfparams.dklen }) + } + throw new Error(V3ErrorMessages.UNSUPPORTED_KDF) +} + +/** + * The V3 message authentication code, over the second half of the derived key + * and the ciphertext. + */ +function macOf(derivedKey: Uint8Array, ciphertext: Uint8Array): string { + return Buffer.from( + keccak_256(Buffer.concat([Buffer.from(derivedKey.subarray(16, 32)), Buffer.from(ciphertext)])) + ).toString('hex') +} + +/** + * Decrypts a V3 keystore + * @param keystore Serialized V3 keystore + * @param passphrase Secret phrase the private key was encrypted with + * @returns Private key as a 0x-prefixed hex string + */ +export async function decryptV3(keystore: string, passphrase: string): Promise { + const json: V3Keystore = JSON.parse(keystore) + if (json.version !== 3) { + throw new Error(V3ErrorMessages.NOT_V3) + } + if (json.crypto.cipher !== CIPHER) { + throw new Error(`${V3ErrorMessages.UNSUPPORTED_CIPHER}: ${json.crypto.cipher}`) + } + + const derivedKey = await deriveKey(json.crypto.kdf, json.crypto.kdfparams, passphrase) + const ciphertext = hexToBuffer(json.crypto.ciphertext) + if (macOf(derivedKey, ciphertext) !== json.crypto.mac) { + throw new Error(V3ErrorMessages.WRONG_PASSPHRASE) + } + + const privateKey = ctr( + derivedKey.subarray(0, 16), + hexToBuffer(json.crypto.cipherparams.iv) + ).decrypt(ciphertext) + return ensureLeading0x(Buffer.from(privateKey).toString('hex')) +} + +/** + * Encrypts a private key into a V3 keystore, using geth's default parameters + * @param privateKey Private key to encrypt, with or without a 0x prefix + * @param passphrase Secret phrase to encrypt the private key with + */ +export async function encryptV3(privateKey: string, passphrase: string): Promise { + const salt = randomBytes(SALT_LENGTH) + const iv = randomBytes(IV_LENGTH) + const kdfparams: ScryptParams = { + dklen: DERIVED_KEY_LENGTH, + salt: Buffer.from(salt).toString('hex'), + n: SCRYPT_N, + r: SCRYPT_R, + p: SCRYPT_P, + } + + const derivedKey = await deriveKey('scrypt', kdfparams, passphrase) + const ciphertext = ctr(derivedKey.subarray(0, 16), iv).encrypt(hexToBuffer(privateKey)) + + return { + version: 3, + id: randomUUID(), + // The V3 spec omits the address, but geth and its ecosystem always write it. + address: trimLeading0x(privateKeyToAddress(privateKey)).toLowerCase(), + crypto: { + ciphertext: Buffer.from(ciphertext).toString('hex'), + cipherparams: { iv: Buffer.from(iv).toString('hex') }, + cipher: CIPHER, + kdf: 'scrypt', + kdfparams, + mac: macOf(derivedKey, ciphertext), + }, + } +} + +/** + * Builds the conventional geth filename for a V3 keystore entry, + * e.g. UTC--2016-03-15T17-11-33.007Z--
+ * @param address Account address of the keystore entry + * @param timestamp Creation time in milliseconds since the epoch + */ +export function v3Filename(address: string, timestamp: number): string { + const isoTime = new Date(timestamp).toJSON().replace(/:/g, '-') + return `UTC--${isoTime}--${trimLeading0x(address).toLowerCase()}` +} diff --git a/yarn.lock b/yarn.lock index 56f60be457..b81d6bbe5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2002,8 +2002,9 @@ __metadata: "@celo/typescript": "workspace:^" "@celo/utils": "npm:^8.0.3" "@celo/wallet-local": "npm:^8.0.1" + "@noble/ciphers": "npm:1.1.3" + "@noble/hashes": "npm:1.3.3" "@types/rimraf": "npm:3.0.2" - ethereumjs-wallet: "npm:^1.0.1" rimraf: "npm:^4.4.1" languageName: unknown linkType: soft @@ -6568,15 +6569,6 @@ __metadata: languageName: node linkType: hard -"@types/pbkdf2@npm:^3.0.0": - version: 3.1.0 - resolution: "@types/pbkdf2@npm:3.1.0" - dependencies: - "@types/node": "npm:*" - checksum: d15024b1957c21cf3b8887329d9bd8dfde754cf13a09d76ae25f1391cfc62bb8b8d7b760773c5dbaa748172fba8b3e0c3dbe962af6ccbd69b76df12a48dfba40 - languageName: node - linkType: hard - "@types/prompts@npm:^1.1.1": version: 1.2.0 resolution: "@types/prompts@npm:1.2.0" @@ -6620,7 +6612,7 @@ __metadata: languageName: node linkType: hard -"@types/secp256k1@npm:^4.0.0, @types/secp256k1@npm:^4.0.1": +"@types/secp256k1@npm:^4.0.0": version: 4.0.3 resolution: "@types/secp256k1@npm:4.0.3" dependencies: @@ -6981,13 +6973,6 @@ __metadata: languageName: node linkType: hard -"aes-js@npm:^3.1.2": - version: 3.1.2 - resolution: "aes-js@npm:3.1.2" - checksum: b65916767034a51375a3ac5aad62af452d89a386c1ae7b607bb9145d0bb8b8823bf2f3eba85bdfa52d61c65d5aed90ba90f677b8c826bfa1a8b7ae2fa3b54d91 - languageName: node - linkType: hard - "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -7391,15 +7376,6 @@ __metadata: languageName: node linkType: hard -"base-x@npm:^3.0.2": - version: 3.0.9 - resolution: "base-x@npm:3.0.9" - dependencies: - safe-buffer: "npm:^5.0.1" - checksum: 957101d6fd09e1903e846fd8f69fd7e5e3e50254383e61ab667c725866bec54e5ece5ba49ce385128ae48f9ec93a26567d1d5ebb91f4d56ef4a9cc0d5a5481e8 - languageName: node - linkType: hard - "base64-js@npm:^1.0.2, base64-js@npm:^1.3.0, base64-js@npm:^1.3.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" @@ -7487,13 +7463,6 @@ __metadata: languageName: node linkType: hard -"blakejs@npm:^1.1.0": - version: 1.2.1 - resolution: "blakejs@npm:1.2.1" - checksum: 0638b1bd058b21892633929c43005aa6a4cc4b2ac5b338a146c3c076622f1b360795bd7a4d1f077c9b01863ed2df0c1504a81c5b520d164179120434847e6cd7 - languageName: node - linkType: hard - "bn.js@npm:^4.11.9": version: 4.12.0 resolution: "bn.js@npm:4.12.0" @@ -7501,7 +7470,7 @@ __metadata: languageName: node linkType: hard -"bn.js@npm:^5.1.2, bn.js@npm:^5.2.0, bn.js@npm:^5.2.1": +"bn.js@npm:^5.2.1": version: 5.2.1 resolution: "bn.js@npm:5.2.1" checksum: 7a7e8764d7a6e9708b8b9841b2b3d6019cc154d2fc23716d0efecfe1e16921b7533c6f7361fb05471eab47986c4aa310c270f88e3507172104632ac8df2cfd84 @@ -7559,20 +7528,6 @@ __metadata: languageName: node linkType: hard -"browserify-aes@npm:^1.2.0": - version: 1.2.0 - resolution: "browserify-aes@npm:1.2.0" - dependencies: - buffer-xor: "npm:^1.0.3" - cipher-base: "npm:^1.0.0" - create-hash: "npm:^1.1.0" - evp_bytestokey: "npm:^1.0.3" - inherits: "npm:^2.0.1" - safe-buffer: "npm:^5.0.1" - checksum: 2813058f74e083a00450b11ea9d5d1f072de7bf0133f5d122d4ff7b849bece56d52b9c51ad0db0fad21c0bc4e8272fd5196114bbe7b94a9b7feb0f9fbb33a3bf - languageName: node - linkType: hard - "browserslist@npm:^4.21.3": version: 4.21.5 resolution: "browserslist@npm:4.21.5" @@ -7596,26 +7551,6 @@ __metadata: languageName: node linkType: hard -"bs58@npm:^4.0.0": - version: 4.0.1 - resolution: "bs58@npm:4.0.1" - dependencies: - base-x: "npm:^3.0.2" - checksum: b3c5365bb9e0c561e1a82f1a2d809a1a692059fae016be233a6127ad2f50a6b986467c3a50669ce4c18929dcccb297c5909314dd347a25a68c21b68eb3e95ac2 - languageName: node - linkType: hard - -"bs58check@npm:^2.1.2": - version: 2.1.2 - resolution: "bs58check@npm:2.1.2" - dependencies: - bs58: "npm:^4.0.0" - create-hash: "npm:^1.1.0" - safe-buffer: "npm:^5.1.2" - checksum: 43bdf08a5dd04581b78f040bc4169480e17008da482ffe2a6507327bbc4fc5c28de0501f7faf22901cfe57fbca79cbb202ca529003fedb4cb8dccd265b38e54d - languageName: node - linkType: hard - "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -7670,13 +7605,6 @@ __metadata: languageName: node linkType: hard -"buffer-xor@npm:^1.0.3": - version: 1.0.3 - resolution: "buffer-xor@npm:1.0.3" - checksum: 4a63d48b5117c7eda896d81cd3582d9707329b07c97a14b0ece2edc6e64220ea7ea17c94b295e8c2cb7b9f8291e2b079f9096be8ac14be238420a43e06ec66e2 - languageName: node - linkType: hard - "buffer@npm:4.9.2": version: 4.9.2 resolution: "buffer@npm:4.9.2" @@ -8067,16 +7995,6 @@ __metadata: languageName: node linkType: hard -"cipher-base@npm:^1.0.0, cipher-base@npm:^1.0.1, cipher-base@npm:^1.0.3": - version: 1.0.4 - resolution: "cipher-base@npm:1.0.4" - dependencies: - inherits: "npm:^2.0.1" - safe-buffer: "npm:^5.0.1" - checksum: 3d5d6652ca499c3f7c5d7fdc2932a357ec1e5aa84f2ad766d850efd42e89753c97b795c3a104a8e7ae35b4e293f5363926913de3bf8181af37067d9d541ca0db - languageName: node - linkType: hard - "cjs-module-lexer@npm:^1.0.0": version: 1.2.3 resolution: "cjs-module-lexer@npm:1.2.3" @@ -8407,33 +8325,6 @@ __metadata: languageName: node linkType: hard -"create-hash@npm:^1.1.0, create-hash@npm:^1.1.2, create-hash@npm:^1.2.0": - version: 1.2.0 - resolution: "create-hash@npm:1.2.0" - dependencies: - cipher-base: "npm:^1.0.1" - inherits: "npm:^2.0.1" - md5.js: "npm:^1.3.4" - ripemd160: "npm:^2.0.1" - sha.js: "npm:^2.4.0" - checksum: 3cfef32043b47a8999602af9bcd74966db6971dd3eb828d1a479f3a44d7f58e38c1caf34aa21a01941cc8d9e1a841738a732f200f00ea155f8a8835133d2e7bc - languageName: node - linkType: hard - -"create-hmac@npm:^1.1.4, create-hmac@npm:^1.1.7": - version: 1.1.7 - resolution: "create-hmac@npm:1.1.7" - dependencies: - cipher-base: "npm:^1.0.3" - create-hash: "npm:^1.1.0" - inherits: "npm:^2.0.1" - ripemd160: "npm:^2.0.0" - safe-buffer: "npm:^5.0.1" - sha.js: "npm:^2.4.8" - checksum: 2b26769f87e99ef72150bf99d1439d69272b2e510e23a2b8daf4e93e2412f4842504237d726044fa797cb20ee0ec8bee78d414b11f2d7ca93299185c93df0dae - languageName: node - linkType: hard - "create-jest@npm:^29.7.0": version: 29.7.0 resolution: "create-jest@npm:29.7.0" @@ -9080,58 +8971,6 @@ __metadata: languageName: node linkType: hard -"ethereum-cryptography@npm:^0.1.3": - version: 0.1.3 - resolution: "ethereum-cryptography@npm:0.1.3" - dependencies: - "@types/pbkdf2": "npm:^3.0.0" - "@types/secp256k1": "npm:^4.0.1" - blakejs: "npm:^1.1.0" - browserify-aes: "npm:^1.2.0" - bs58check: "npm:^2.1.2" - create-hash: "npm:^1.2.0" - create-hmac: "npm:^1.1.7" - hash.js: "npm:^1.1.7" - keccak: "npm:^3.0.0" - pbkdf2: "npm:^3.0.17" - randombytes: "npm:^2.1.0" - safe-buffer: "npm:^5.1.2" - scrypt-js: "npm:^3.0.0" - secp256k1: "npm:^4.0.1" - setimmediate: "npm:^1.0.5" - checksum: 975e476782746acd97d5b37366801ae622a52fb31e5d83f600804be230a61ef7b9d289dcecd9c308fb441967caf3a6e3768dd7c8add6441fcc60c398175d5a96 - languageName: node - linkType: hard - -"ethereumjs-util@npm:^7.1.2": - version: 7.1.5 - resolution: "ethereumjs-util@npm:7.1.5" - dependencies: - "@types/bn.js": "npm:^5.1.0" - bn.js: "npm:^5.1.2" - create-hash: "npm:^1.1.2" - ethereum-cryptography: "npm:^0.1.3" - rlp: "npm:^2.2.4" - checksum: f28fc1ebb8f35bf9e418f76f51be737d94d603b912c3e014c4e87cd45ccd1b10bdfef764c8f152574b57e9faa260a18773cbc110f9e0a754d6b3730699e54dc9 - languageName: node - linkType: hard - -"ethereumjs-wallet@npm:^1.0.1": - version: 1.0.2 - resolution: "ethereumjs-wallet@npm:1.0.2" - dependencies: - aes-js: "npm:^3.1.2" - bs58check: "npm:^2.1.2" - ethereum-cryptography: "npm:^0.1.3" - ethereumjs-util: "npm:^7.1.2" - randombytes: "npm:^2.1.0" - scrypt-js: "npm:^3.0.1" - utf8: "npm:^3.0.0" - uuid: "npm:^8.3.2" - checksum: 0a9ad0ac0930627c15e6fa6115dde8d1dd81160e57fbdf81ef0bcd1e0edd750fe9e8b00992651e694cabefee29eef2ad651dce8b24ae5253861927d0e19b6c90 - languageName: node - linkType: hard - "ethers@npm:5.7.2": version: 5.7.2 resolution: "ethers@npm:5.7.2" @@ -9205,17 +9044,6 @@ __metadata: languageName: node linkType: hard -"evp_bytestokey@npm:^1.0.3": - version: 1.0.3 - resolution: "evp_bytestokey@npm:1.0.3" - dependencies: - md5.js: "npm:^1.3.4" - node-gyp: "npm:latest" - safe-buffer: "npm:^5.1.1" - checksum: ad4e1577f1a6b721c7800dcc7c733fe01f6c310732bb5bf2240245c2a5b45a38518b91d8be2c610611623160b9d1c0e91f1ce96d639f8b53e8894625cf20fa45 - languageName: node - linkType: hard - "execa@npm:^5.0.0": version: 5.1.1 resolution: "execa@npm:5.1.1" @@ -10077,18 +9905,7 @@ __metadata: languageName: node linkType: hard -"hash-base@npm:^3.0.0": - version: 3.1.0 - resolution: "hash-base@npm:3.1.0" - dependencies: - inherits: "npm:^2.0.4" - readable-stream: "npm:^3.6.0" - safe-buffer: "npm:^5.2.0" - checksum: 26b7e97ac3de13cb23fc3145e7e3450b0530274a9562144fc2bf5c1e2983afd0e09ed7cc3b20974ba66039fad316db463da80eb452e7373e780cbee9a0d2f2dc - languageName: node - linkType: hard - -"hash.js@npm:1.1.7, hash.js@npm:^1.0.0, hash.js@npm:^1.0.3, hash.js@npm:^1.1.7": +"hash.js@npm:1.1.7, hash.js@npm:^1.0.0, hash.js@npm:^1.0.3": version: 1.1.7 resolution: "hash.js@npm:1.1.7" dependencies: @@ -10373,7 +10190,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521 @@ -11677,18 +11494,6 @@ __metadata: languageName: node linkType: hard -"keccak@npm:^3.0.0": - version: 3.0.3 - resolution: "keccak@npm:3.0.3" - dependencies: - node-addon-api: "npm:^2.0.0" - node-gyp: "npm:latest" - node-gyp-build: "npm:^4.2.0" - readable-stream: "npm:^3.6.0" - checksum: 30c652c39e935132eb92300eca974fad1f4ec4aed4c6e2f21d774b06001d07e24117dd46ef1494272f5674f7f11d5e7a8ee50c7bf8d87bb3895aa60607c4aabc - languageName: node - linkType: hard - "keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -12227,17 +12032,6 @@ __metadata: languageName: node linkType: hard -"md5.js@npm:^1.3.4": - version: 1.3.5 - resolution: "md5.js@npm:1.3.5" - dependencies: - hash-base: "npm:^3.0.0" - inherits: "npm:^2.0.1" - safe-buffer: "npm:^5.1.2" - checksum: 098494d885684bcc4f92294b18ba61b7bd353c23147fbc4688c75b45cb8590f5a95fd4584d742415dcc52487f7a1ef6ea611cfa1543b0dc4492fe026357f3f0c - languageName: node - linkType: hard - "mdurl@npm:^2.0.0": version: 2.0.0 resolution: "mdurl@npm:2.0.0" @@ -13598,19 +13392,6 @@ __metadata: languageName: node linkType: hard -"pbkdf2@npm:^3.0.17": - version: 3.1.2 - resolution: "pbkdf2@npm:3.1.2" - dependencies: - create-hash: "npm:^1.1.2" - create-hmac: "npm:^1.1.4" - ripemd160: "npm:^2.0.1" - safe-buffer: "npm:^5.0.1" - sha.js: "npm:^2.4.8" - checksum: 40bdf30df1c9bb1ae41ec50c11e480cf0d36484b7c7933bf55e4451d1d0e3f09589df70935c56e7fccc5702779a0d7b842d012be8c08a187b44eb24d55bb9460 - languageName: node - linkType: hard - "picocolors@npm:^1.0.0": version: 1.0.0 resolution: "picocolors@npm:1.0.0" @@ -14028,15 +13809,6 @@ __metadata: languageName: node linkType: hard -"randombytes@npm:^2.1.0": - version: 2.1.0 - resolution: "randombytes@npm:2.1.0" - dependencies: - safe-buffer: "npm:^5.1.0" - checksum: 4efd1ad3d88db77c2d16588dc54c2b52fd2461e70fe5724611f38d283857094fe09040fa2c9776366803c3152cf133171b452ef717592b65631ce5dc3a2bdafc - languageName: node - linkType: hard - "rc@npm:^1.2.7": version: 1.2.8 resolution: "rc@npm:1.2.8" @@ -14373,27 +14145,6 @@ __metadata: languageName: node linkType: hard -"ripemd160@npm:^2.0.0, ripemd160@npm:^2.0.1": - version: 2.0.2 - resolution: "ripemd160@npm:2.0.2" - dependencies: - hash-base: "npm:^3.0.0" - inherits: "npm:^2.0.1" - checksum: 006accc40578ee2beae382757c4ce2908a826b27e2b079efdcd2959ee544ddf210b7b5d7d5e80467807604244e7388427330f5c6d4cd61e6edaddc5773ccc393 - languageName: node - linkType: hard - -"rlp@npm:^2.2.4": - version: 2.2.7 - resolution: "rlp@npm:2.2.7" - dependencies: - bn.js: "npm:^5.2.0" - bin: - rlp: bin/rlp - checksum: cf1919a2dc99f336191b3363b76299db567c192b7ee3c6f5c722728c34f65577883c9c88eeb7a1bfcbc26693c8a4f1fb0662e79ee86f0c98dd258d6987303498 - languageName: node - linkType: hard - "rollup@npm:^4.34.9": version: 4.62.0 resolution: "rollup@npm:4.62.0" @@ -14518,7 +14269,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451 @@ -14571,7 +14322,7 @@ __metadata: languageName: node linkType: hard -"scrypt-js@npm:3.0.1, scrypt-js@npm:^3.0.0, scrypt-js@npm:^3.0.1": +"scrypt-js@npm:3.0.1": version: 3.0.1 resolution: "scrypt-js@npm:3.0.1" checksum: 2f8aa72b7f76a6f9c446bbec5670f80d47497bccce98474203d89b5667717223eeb04a50492ae685ed7adc5a060fc2d8f9fd988f8f7ebdaf3341967f3aeff116 @@ -14590,18 +14341,6 @@ __metadata: languageName: node linkType: hard -"secp256k1@npm:^4.0.1": - version: 4.0.3 - resolution: "secp256k1@npm:4.0.3" - dependencies: - elliptic: "npm:^6.5.4" - node-addon-api: "npm:^2.0.0" - node-gyp: "npm:latest" - node-gyp-build: "npm:^4.2.0" - checksum: 8b45820cd90fd2f95cc8fdb9bf8a71e572de09f2311911ae461a951ffa9e30c99186a129d0f1afeb380dd67eca0c10493f8a7513c39063fda015e99995088e3b - languageName: node - linkType: hard - "semver@npm:^6.0.0, semver@npm:^6.3.0": version: 6.3.1 resolution: "semver@npm:6.3.1" @@ -14690,25 +14429,6 @@ __metadata: languageName: node linkType: hard -"setimmediate@npm:^1.0.5": - version: 1.0.5 - resolution: "setimmediate@npm:1.0.5" - checksum: 76e3f5d7f4b581b6100ff819761f04a984fa3f3990e72a6554b57188ded53efce2d3d6c0932c10f810b7c59414f85e2ab3c11521877d1dea1ce0b56dc906f485 - languageName: node - linkType: hard - -"sha.js@npm:^2.4.0, sha.js@npm:^2.4.8": - version: 2.4.11 - resolution: "sha.js@npm:2.4.11" - dependencies: - inherits: "npm:^2.0.1" - safe-buffer: "npm:^5.0.1" - bin: - sha.js: ./bin.js - checksum: d833bfa3e0a67579a6ce6e1bc95571f05246e0a441dd8c76e3057972f2a3e098465687a4369b07e83a0375a88703577f71b5b2e966809e67ebc340dbedb478c7 - languageName: node - linkType: hard - "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -15932,7 +15652,7 @@ __metadata: languageName: node linkType: hard -"utf8@npm:3.0.0, utf8@npm:^3.0.0": +"utf8@npm:3.0.0": version: 3.0.0 resolution: "utf8@npm:3.0.0" checksum: 31d19c4faacbb65b09ebc1c21c32b20bdb0919c6f6773cee5001b99bb83f8e503e7233c08fc71ebb34f7cfebd95cec3243b81d90176097aa2f286cccb4ce866e @@ -15975,7 +15695,7 @@ __metadata: languageName: node linkType: hard -"uuid@npm:^8.3.0, uuid@npm:^8.3.2": +"uuid@npm:^8.3.0": version: 8.3.2 resolution: "uuid@npm:8.3.2" bin: From 1434bfda52c505a5c8c0ce1b73a992029284512c Mon Sep 17 00:00:00 2001 From: Paul Lange Date: Wed, 19 Aug 2026 16:46:07 +0200 Subject: [PATCH 2/2] Pin the pbkdf2 key derivation path with a known-answer test The V3 spec allows pbkdf2 as well as scrypt, but keystores in the wild almost always use scrypt, so the pbkdf2 branch shipped with only its rejection path covered. Add the Web3 Secret Storage pbkdf2 test vector, verified against ethereumjs-wallet before use. --- packages/sdk/keystores/src/test-constants.ts | 6 ++++++ packages/sdk/keystores/src/v3-keystore.test.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/sdk/keystores/src/test-constants.ts b/packages/sdk/keystores/src/test-constants.ts index 677769d575..df86288155 100644 --- a/packages/sdk/keystores/src/test-constants.ts +++ b/packages/sdk/keystores/src/test-constants.ts @@ -11,3 +11,9 @@ export const PK2 = 'bb6f3fa4a83b7b06e72e580a3b09df5dd6fb4fa745ee2b0d865413ad6299 export const KEYSTORE_NAME2 = 'PK2 keystore name' export const GETH_GEN_KEYSTORE2 = `{"address":"b81a82696018fd9d8b43431966b60c31bdcdc2e8","blspublickey":"b9f862e2ced58bb2eef8ffde7020189ab2bb050603630eceec9b80c1636d98f8c3b9bd517d673937a0551c3a0698a00086bda4db1f0d859912a91988775ae388886013e7eb254d195871f9ced6643e288755da0b483ebe6dda448fea2eb75481","crypto":{"cipher":"aes-128-ctr","ciphertext":"6f3cd02b2d3d81b2bbf76743396c9c3c1685ddc6cfafbba34195ab03476831d3","cipherparams":{"iv":"af1f9853e0ff20ee5d495cf7d9461e1c"},"kdf":"scrypt","kdfparams":{"dklen":32,"n":262144,"p":1,"r":8,"salt":"cf0446914e5d214f2a312c08ef24e7e3dd15e948d2ca67d59b3bb97903a96147"},"mac":"58348f6d843d28b3ac8cf40542d10da198016f28f132c32389ab56a945c858e1"},"id":"b224dac6-c089-4b47-8557-e04ae60b3506","version":3}` export const ADDRESS2 = normalizeAddressWith0x(privateKeyToAddress(PK2)) + +// The pbkdf2 test vector from the Web3 Secret Storage Definition. Keystores in +// the wild almost always use scrypt, so this pins the pbkdf2 derivation path. +export const PBKDF2_PASSPHRASE = 'testpassword' +export const PBKDF2_PK = '7a28b5ba57c53603b0b07b56bba752f7784bf506fa95edc395f5cf6c7514fe9d' +export const PBKDF2_KEYSTORE = `{"crypto":{"cipher":"aes-128-ctr","cipherparams":{"iv":"6087dab2f9fdbbfaddc31a909735c1e6"},"ciphertext":"5318b4d5bcd28de64ee5559e671353e16f075ecae9f99c7a79a38af5f869aa46","kdf":"pbkdf2","kdfparams":{"c":262144,"dklen":32,"prf":"hmac-sha256","salt":"ae3cd4e7013836a3df6bd7241b12db061dbe2c6785853cce422d148a624ce0bd"},"mac":"517ead924a9d0dc3124507e3393d175ce3ff7c1e96529c6c555ce9e51205e9b2"},"id":"3198bc9c-6672-5ab3-d995-4942343ae5b6","version":3}` diff --git a/packages/sdk/keystores/src/v3-keystore.test.ts b/packages/sdk/keystores/src/v3-keystore.test.ts index 44c4c03189..7d6804728e 100644 --- a/packages/sdk/keystores/src/v3-keystore.test.ts +++ b/packages/sdk/keystores/src/v3-keystore.test.ts @@ -5,6 +5,9 @@ import { GETH_GEN_KEYSTORE2, PASSPHRASE1, PASSPHRASE2, + PBKDF2_KEYSTORE, + PBKDF2_PASSPHRASE, + PBKDF2_PK, PK1, PK2, } from './test-constants' @@ -22,6 +25,16 @@ describe('decryptV3', () => { expect(await decryptV3(GETH_GEN_KEYSTORE1, PASSPHRASE1)).toBe(`0x${PK1}`) }) + it('decrypts a pbkdf2 keystore', async () => { + expect(trimLeading0x(await decryptV3(PBKDF2_KEYSTORE, PBKDF2_PASSPHRASE))).toBe(PBKDF2_PK) + }) + + it('rejects a wrong passphrase for a pbkdf2 keystore', async () => { + await expect(decryptV3(PBKDF2_KEYSTORE, `${PBKDF2_PASSPHRASE}!`)).rejects.toThrow( + V3ErrorMessages.WRONG_PASSPHRASE + ) + }) + it('rejects a wrong passphrase', async () => { await expect(decryptV3(GETH_GEN_KEYSTORE1, `${PASSPHRASE1}!`)).rejects.toThrow( V3ErrorMessages.WRONG_PASSPHRASE