Skip to content

Commit d1c3311

Browse files
committed
fix(crypto): 🐛 Validate v3 private key salt
1 parent 3258e06 commit d1c3311

3 files changed

Lines changed: 54 additions & 2 deletions

File tree

TECHNICAL_DOCUMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -864,7 +864,7 @@ The derived key is deterministic, but encryption is not: `encrypt()` generates a
864864

865865
#### EncryptedPrivateKey
866866

867-
Represents an immutable password-protected private key container. New encrypted private keys use v3 with scrypt (N=16384, r=8, p=5), a 16-byte salt, a 32-byte derived key, and AES-256-GCM with a 12-byte IV and 16-byte authentication tag. The v3 `v3.scrypt.N16384.r8.p5` header is authenticated as AES-GCM AAD. The class also supports v2 (`N=16384`, `r=8`, `p=1`) and the legacy 4-part format, which decrypts with PBKDF2-SHA256 using 100000 iterations and AES-256-GCM.
867+
Represents an immutable password-protected private key container. New encrypted private keys use v3 with scrypt (N=16384, r=8, p=5), a strict Base64-encoded 16-byte salt, a 32-byte derived key, and AES-256-GCM with a 12-byte IV and 16-byte authentication tag. The v3 `v3.scrypt.N16384.r8.p5` header is authenticated as AES-GCM AAD. The class also supports v2 (`N=16384`, `r=8`, `p=1`) and the legacy 4-part format, which decrypts with PBKDF2-SHA256 using 100000 iterations and AES-256-GCM.
868868

869869
The current encrypted format is: `v3.scrypt.N16384.r8.p5.salt.iv.tag.cipherText` (base64-encoded, dot-separated). v2 format: `v2.scrypt.N16384.r8.p1.salt.iv.tag.cipherText`. Legacy format: `cipherText.iv.salt.tag`.
870870

src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Buffer } from 'buffer';
22

3+
import { assert } from '../../../patterns';
34
import { PrivateKey } from '../PrivateKey';
45
import { SymmetricEncryptedPayload } from '../SymmetricEncryptedPayload';
56
import { CryptoPassword, SymmetricKey } from '../SymmetricKey';
@@ -16,6 +17,8 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion {
1617
private static readonly CIPHER = 'aes-256-gcm';
1718
private static readonly EXPECTED_PARTS = 9;
1819
private static readonly SYMMETRIC_PAYLOAD_VERSION = 'v1';
20+
private static readonly BASE64_PATTERN =
21+
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
1922

2023
private static hasSupportedScryptParameters(parts: string[]): boolean {
2124
return (
@@ -39,6 +42,25 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion {
3942
return parts.slice(0, 5).join('.');
4043
}
4144

45+
private static ensureSaltIsValid(saltB64: string): Buffer {
46+
assert(
47+
saltB64.length > 0 &&
48+
saltB64.length % 4 === 0 &&
49+
EncryptedPrivateKeyV3.BASE64_PATTERN.test(saltB64),
50+
new Error('Invalid encrypted private key salt'),
51+
);
52+
53+
const salt = Buffer.from(saltB64, 'base64');
54+
55+
assert(
56+
salt.length === EncryptedPrivateKeyV3.SALT_ENTROPY &&
57+
salt.toString('base64') === saltB64,
58+
new Error('Invalid encrypted private key salt'),
59+
);
60+
61+
return salt;
62+
}
63+
4264
private static async deriveSymmetricKey(
4365
password: CryptoPassword,
4466
salt: Buffer,
@@ -116,7 +138,7 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion {
116138
const N = parseInt(parts[2].slice(1), 10);
117139
const r = parseInt(parts[3].slice(1), 10);
118140
const p = parseInt(parts[4].slice(1), 10);
119-
const salt = Buffer.from(parts[5], 'base64');
141+
const salt = EncryptedPrivateKeyV3.ensureSaltIsValid(parts[5]);
120142
const key = await EncryptedPrivateKeyV3.deriveSymmetricKey(password, salt, {
121143
N,
122144
p,

tests/value-objects/crypto/EncryptedPrivateKey.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,36 @@ describe('EncryptedPrivateKey', () => {
255255
).rejects.toThrow('Unsupported encrypted private key parameters');
256256
});
257257

258+
it('should reject invalid v3 salt base64 before deriving a key', async () => {
259+
const privateKey = new PrivateKey(privatePem);
260+
const encrypted = await EncryptedPrivateKey.create(privateKey, password);
261+
const parts = encrypted.valueOf().split('.');
262+
parts[5] = 'not-base64!';
263+
const scryptSpy = jest.spyOn(CryptoDerivation, 'scryptAsync');
264+
265+
await expect(
266+
new EncryptedPrivateKeyV3().decrypt(parts, password),
267+
).rejects.toThrow('Invalid encrypted private key salt');
268+
269+
expect(scryptSpy).not.toHaveBeenCalled();
270+
scryptSpy.mockRestore();
271+
});
272+
273+
it('should reject v3 salts that are not exactly 16 bytes before deriving a key', async () => {
274+
const privateKey = new PrivateKey(privatePem);
275+
const encrypted = await EncryptedPrivateKey.create(privateKey, password);
276+
const parts = encrypted.valueOf().split('.');
277+
parts[5] = Buffer.alloc(15).toString('base64');
278+
const scryptSpy = jest.spyOn(CryptoDerivation, 'scryptAsync');
279+
280+
await expect(
281+
new EncryptedPrivateKeyV3().decrypt(parts, password),
282+
).rejects.toThrow('Invalid encrypted private key salt');
283+
284+
expect(scryptSpy).not.toHaveBeenCalled();
285+
scryptSpy.mockRestore();
286+
});
287+
258288
it('should authenticate v3 header fields with AAD', async () => {
259289
const privateKey = new PrivateKey(privatePem);
260290
const encrypted = await EncryptedPrivateKey.create(privateKey, password);

0 commit comments

Comments
 (0)