Skip to content

Commit 1fa27e3

Browse files
sirtimidclaude
andcommitted
perf: make PBKDF2 iteration count configurable, speed up crypto tests
Add optional `pbkdf2Iterations` parameter to `encryptMnemonic()` and `decryptMnemonic()`, defaulting to the production value of 600,000. Thread the parameter through `keyring-vat` initialize() and unlock(). Tests now pass 1,000 iterations instead of 600,000, reducing combined test time from ~250s to ~1.8s (mnemonic-crypto: 140s → 0.15s, keyring-vat: 110s → 1.6s). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4f6a067 commit 1fa27e3

4 files changed

Lines changed: 173 additions & 164 deletions

File tree

packages/evm-wallet-experiment/src/lib/mnemonic-crypto.test.ts

Lines changed: 138 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -7,160 +7,148 @@ const TEST_MNEMONIC =
77
'test test test test test test test test test test test junk';
88
const TEST_PASSWORD = 'my-secret-password';
99
const TEST_SALT = 'deadbeefdeadbeefdeadbeefdeadbeef';
10+
// Use fast PBKDF2 iterations for testing. Production uses 600,000.
11+
const TEST_PBKDF2_ITERATIONS = 1_000;
1012

1113
describe('mnemonic-crypto', () => {
1214
describe('encryptMnemonic / decryptMnemonic', () => {
13-
// PBKDF2 with 600k iterations is slow under coverage instrumentation.
14-
const KDF_TIMEOUT = 900_000;
15-
16-
it(
17-
'roundtrips with correct password',
18-
() => {
19-
const encrypted = encryptMnemonic({
20-
mnemonic: TEST_MNEMONIC,
21-
password: TEST_PASSWORD,
22-
});
23-
24-
expect(encrypted.encrypted).toBe(true);
25-
expect(encrypted.ciphertext).not.toBe('');
26-
expect(encrypted.nonce).not.toBe('');
27-
expect(encrypted.salt).not.toBe('');
28-
29-
const decrypted = decryptMnemonic({
30-
data: encrypted,
31-
password: TEST_PASSWORD,
32-
});
33-
34-
expect(decrypted).toBe(TEST_MNEMONIC);
35-
},
36-
KDF_TIMEOUT,
37-
);
38-
39-
it(
40-
'roundtrips with caller-provided salt',
41-
() => {
42-
const encrypted = encryptMnemonic({
43-
mnemonic: TEST_MNEMONIC,
44-
password: TEST_PASSWORD,
45-
salt: TEST_SALT,
46-
});
47-
48-
expect(encrypted.salt).toBe(TEST_SALT);
49-
50-
const decrypted = decryptMnemonic({
15+
it('roundtrips with correct password', () => {
16+
const encrypted = encryptMnemonic({
17+
mnemonic: TEST_MNEMONIC,
18+
password: TEST_PASSWORD,
19+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
20+
});
21+
22+
expect(encrypted.encrypted).toBe(true);
23+
expect(encrypted.ciphertext).not.toBe('');
24+
expect(encrypted.nonce).not.toBe('');
25+
expect(encrypted.salt).not.toBe('');
26+
27+
const decrypted = decryptMnemonic({
28+
data: encrypted,
29+
password: TEST_PASSWORD,
30+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
31+
});
32+
33+
expect(decrypted).toBe(TEST_MNEMONIC);
34+
});
35+
36+
it('roundtrips with caller-provided salt', () => {
37+
const encrypted = encryptMnemonic({
38+
mnemonic: TEST_MNEMONIC,
39+
password: TEST_PASSWORD,
40+
salt: TEST_SALT,
41+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
42+
});
43+
44+
expect(encrypted.salt).toBe(TEST_SALT);
45+
46+
const decrypted = decryptMnemonic({
47+
data: encrypted,
48+
password: TEST_PASSWORD,
49+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
50+
});
51+
52+
expect(decrypted).toBe(TEST_MNEMONIC);
53+
});
54+
55+
it('throws on decrypt with wrong password', () => {
56+
const encrypted = encryptMnemonic({
57+
mnemonic: TEST_MNEMONIC,
58+
password: TEST_PASSWORD,
59+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
60+
});
61+
62+
expect(() =>
63+
decryptMnemonic({
5164
data: encrypted,
65+
password: 'wrong-password',
66+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
67+
}),
68+
).toThrow(/invalid.*tag|decrypt/iu);
69+
});
70+
71+
it('produces deterministic output for same password and salt', () => {
72+
const a = encryptMnemonic({
73+
mnemonic: TEST_MNEMONIC,
74+
password: TEST_PASSWORD,
75+
salt: TEST_SALT,
76+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
77+
});
78+
const b = encryptMnemonic({
79+
mnemonic: TEST_MNEMONIC,
80+
password: TEST_PASSWORD,
81+
salt: TEST_SALT,
82+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
83+
});
84+
85+
expect(a).toStrictEqual(b);
86+
});
87+
88+
it('produces different output for different passwords with same salt', () => {
89+
const a = encryptMnemonic({
90+
mnemonic: TEST_MNEMONIC,
91+
password: 'password-a',
92+
salt: TEST_SALT,
93+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
94+
});
95+
const b = encryptMnemonic({
96+
mnemonic: TEST_MNEMONIC,
97+
password: 'password-b',
98+
salt: TEST_SALT,
99+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
100+
});
101+
102+
expect(a.ciphertext).not.toBe(b.ciphertext);
103+
});
104+
105+
it('stores all fields as hex strings', () => {
106+
const encrypted = encryptMnemonic({
107+
mnemonic: TEST_MNEMONIC,
108+
password: TEST_PASSWORD,
109+
salt: TEST_SALT,
110+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
111+
});
112+
113+
expect(encrypted.ciphertext).toMatch(/^[\da-f]+$/u);
114+
expect(encrypted.nonce).toMatch(/^[\da-f]+$/u);
115+
expect(encrypted.salt).toMatch(/^[\da-f]+$/u);
116+
expect(encrypted.nonce).toHaveLength(24); // 12 bytes = 24 hex chars
117+
});
118+
119+
it('handles empty mnemonic', () => {
120+
const encrypted = encryptMnemonic({
121+
mnemonic: '',
122+
password: TEST_PASSWORD,
123+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
124+
});
125+
const decrypted = decryptMnemonic({
126+
data: encrypted,
127+
password: TEST_PASSWORD,
128+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
129+
});
130+
expect(decrypted).toBe('');
131+
});
132+
133+
it('rejects tampered ciphertext', () => {
134+
const encrypted = encryptMnemonic({
135+
mnemonic: TEST_MNEMONIC,
136+
password: TEST_PASSWORD,
137+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
138+
});
139+
140+
const tampered: EncryptedMnemonicData = {
141+
...encrypted,
142+
ciphertext: encrypted.ciphertext.replace(/^.{2}/u, 'ff'),
143+
};
144+
145+
expect(() =>
146+
decryptMnemonic({
147+
data: tampered,
52148
password: TEST_PASSWORD,
53-
});
54-
55-
expect(decrypted).toBe(TEST_MNEMONIC);
56-
},
57-
KDF_TIMEOUT,
58-
);
59-
60-
it(
61-
'throws on decrypt with wrong password',
62-
() => {
63-
const encrypted = encryptMnemonic({
64-
mnemonic: TEST_MNEMONIC,
65-
password: TEST_PASSWORD,
66-
});
67-
68-
expect(() =>
69-
decryptMnemonic({ data: encrypted, password: 'wrong-password' }),
70-
).toThrow(/invalid.*tag|decrypt/iu);
71-
},
72-
KDF_TIMEOUT,
73-
);
74-
75-
it(
76-
'produces deterministic output for same password and salt',
77-
() => {
78-
const a = encryptMnemonic({
79-
mnemonic: TEST_MNEMONIC,
80-
password: TEST_PASSWORD,
81-
salt: TEST_SALT,
82-
});
83-
const b = encryptMnemonic({
84-
mnemonic: TEST_MNEMONIC,
85-
password: TEST_PASSWORD,
86-
salt: TEST_SALT,
87-
});
88-
89-
expect(a).toStrictEqual(b);
90-
},
91-
KDF_TIMEOUT,
92-
);
93-
94-
it(
95-
'produces different output for different passwords with same salt',
96-
() => {
97-
const a = encryptMnemonic({
98-
mnemonic: TEST_MNEMONIC,
99-
password: 'password-a',
100-
salt: TEST_SALT,
101-
});
102-
const b = encryptMnemonic({
103-
mnemonic: TEST_MNEMONIC,
104-
password: 'password-b',
105-
salt: TEST_SALT,
106-
});
107-
108-
expect(a.ciphertext).not.toBe(b.ciphertext);
109-
},
110-
KDF_TIMEOUT,
111-
);
112-
113-
it(
114-
'stores all fields as hex strings',
115-
() => {
116-
const encrypted = encryptMnemonic({
117-
mnemonic: TEST_MNEMONIC,
118-
password: TEST_PASSWORD,
119-
salt: TEST_SALT,
120-
});
121-
122-
expect(encrypted.ciphertext).toMatch(/^[\da-f]+$/u);
123-
expect(encrypted.nonce).toMatch(/^[\da-f]+$/u);
124-
expect(encrypted.salt).toMatch(/^[\da-f]+$/u);
125-
expect(encrypted.nonce).toHaveLength(24); // 12 bytes = 24 hex chars
126-
},
127-
KDF_TIMEOUT,
128-
);
129-
130-
it(
131-
'handles empty mnemonic',
132-
() => {
133-
const encrypted = encryptMnemonic({
134-
mnemonic: '',
135-
password: TEST_PASSWORD,
136-
});
137-
const decrypted = decryptMnemonic({
138-
data: encrypted,
139-
password: TEST_PASSWORD,
140-
});
141-
expect(decrypted).toBe('');
142-
},
143-
KDF_TIMEOUT,
144-
);
145-
146-
it(
147-
'rejects tampered ciphertext',
148-
() => {
149-
const encrypted = encryptMnemonic({
150-
mnemonic: TEST_MNEMONIC,
151-
password: TEST_PASSWORD,
152-
});
153-
154-
const tampered: EncryptedMnemonicData = {
155-
...encrypted,
156-
ciphertext: encrypted.ciphertext.replace(/^.{2}/u, 'ff'),
157-
};
158-
159-
expect(() =>
160-
decryptMnemonic({ data: tampered, password: TEST_PASSWORD }),
161-
).toThrow(/invalid.*tag|decrypt/iu);
162-
},
163-
KDF_TIMEOUT,
164-
);
149+
pbkdf2Iterations: TEST_PBKDF2_ITERATIONS,
150+
}),
151+
).toThrow(/invalid.*tag|decrypt/iu);
152+
});
165153
});
166154
});

packages/evm-wallet-experiment/src/lib/mnemonic-crypto.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { keccak_256 as keccak256 } from '@noble/hashes/sha3';
55

66
const harden = globalThis.harden ?? (<T>(value: T): T => value);
77

8-
const PBKDF2_ITERATIONS = 600_000;
8+
const DEFAULT_PBKDF2_ITERATIONS = 600_000;
99

1010
/**
1111
* Encrypted mnemonic envelope persisted in baggage.
@@ -81,24 +81,27 @@ function deriveNonce(salt: Uint8Array, key: Uint8Array): Uint8Array {
8181
* @param options.mnemonic - The mnemonic to encrypt.
8282
* @param options.password - The password for key derivation.
8383
* @param options.salt - Optional hex-encoded salt (16 bytes). If omitted, derived from password.
84+
* @param options.pbkdf2Iterations - Optional PBKDF2 iteration count. Defaults to 600,000.
8485
* @returns The encrypted mnemonic envelope.
8586
*/
8687
export function encryptMnemonic({
8788
mnemonic,
8889
password,
8990
salt: saltEncoded,
91+
pbkdf2Iterations = DEFAULT_PBKDF2_ITERATIONS,
9092
}: {
9193
mnemonic: string;
9294
password: string;
9395
salt?: string;
96+
pbkdf2Iterations?: number;
9497
}): EncryptedMnemonicData {
9598
const passwordBytes = new TextEncoder().encode(password);
9699
const salt = saltEncoded
97100
? hexToBytes(saltEncoded)
98101
: deriveSalt(passwordBytes);
99102

100103
const key = pbkdf2(sha256, passwordBytes, salt, {
101-
c: PBKDF2_ITERATIONS,
104+
c: pbkdf2Iterations,
102105
dkLen: 32,
103106
});
104107

@@ -121,22 +124,25 @@ export function encryptMnemonic({
121124
* @param options - Decryption options.
122125
* @param options.data - The encrypted mnemonic envelope.
123126
* @param options.password - The password used during encryption.
127+
* @param options.pbkdf2Iterations - Optional PBKDF2 iteration count. Must match the value used during encryption.
124128
* @returns The decrypted mnemonic string.
125129
*/
126130
export function decryptMnemonic({
127131
data,
128132
password,
133+
pbkdf2Iterations = DEFAULT_PBKDF2_ITERATIONS,
129134
}: {
130135
data: EncryptedMnemonicData;
131136
password: string;
137+
pbkdf2Iterations?: number;
132138
}): string {
133139
const passwordBytes = new TextEncoder().encode(password);
134140
const salt = hexToBytes(data.salt);
135141
const nonce = hexToBytes(data.nonce);
136142
const ciphertext = hexToBytes(data.ciphertext);
137143

138144
const key = pbkdf2(sha256, passwordBytes, salt, {
139-
c: PBKDF2_ITERATIONS,
145+
c: pbkdf2Iterations,
140146
dkLen: 32,
141147
});
142148

0 commit comments

Comments
 (0)