From 043e66bb1c19b7ea4f6d76b26b2b627f0504eb7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hask=C5=8D?= <102477627+haskou@users.noreply.github.com> Date: Wed, 20 May 2026 16:19:23 +0200 Subject: [PATCH 1/2] fix(crypto): validate encrypted payload before decrypt --- src/value-objects/crypto/PrivateKey.ts | 58 ++++++++++++++++--- tests/value-objects/crypto/PrivateKey.spec.ts | 24 ++++++++ 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/src/value-objects/crypto/PrivateKey.ts b/src/value-objects/crypto/PrivateKey.ts index f993cb1..c70b135 100644 --- a/src/value-objects/crypto/PrivateKey.ts +++ b/src/value-objects/crypto/PrivateKey.ts @@ -14,6 +14,14 @@ import { Signature } from './Signature'; export class PrivateKey extends Key { private static readonly LENGTH = 119; + private static readonly ENCRYPTED_PAYLOAD_PARTS = 4; + private static readonly EPHEMERAL_PUBLIC_KEY_LENGTH = 32; + private static readonly IV_LENGTH = 12; + private static readonly TAG_LENGTH = 16; + private static readonly MAX_CIPHERTEXT_LENGTH = 1024 * 1024; + private static readonly BASE64_PATTERN = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + private static readonly PATTERN = /^-----BEGIN PRIVATE KEY-----\n[A-Za-z0-9+/=]+\n-----END PRIVATE KEY-----\n$/; @@ -55,14 +63,48 @@ export class PrivateKey extends Key { } public decrypt(encryptedPayload: EncryptedPayload): Buffer { - const [ephPubB64, ivB64, cipherTextB64, tagB64] = encryptedPayload - .valueOf() - .split('.'); - - const ephemeralPub = Buffer.from(ephPubB64, 'base64'); - const iv = Buffer.from(ivB64, 'base64'); - const cipherText = Buffer.from(cipherTextB64, 'base64'); - const tag = Buffer.from(tagB64, 'base64'); + const decodeBase64 = (value: string): Buffer => { + assert( + value.length > 0 && + value.length % 4 === 0 && + PrivateKey.BASE64_PATTERN.test(value), + new InvalidFormatError(encryptedPayload.valueOf()), + ); + + return Buffer.from(value, 'base64'); + }; + + const parts = encryptedPayload.valueOf().split('.'); + assert( + parts.length === PrivateKey.ENCRYPTED_PAYLOAD_PARTS, + new InvalidFormatError(encryptedPayload.valueOf()), + ); + + const [ephPubB64, ivB64, cipherTextB64, tagB64] = parts; + const ephemeralPub = decodeBase64(ephPubB64); + const iv = decodeBase64(ivB64); + const cipherText = decodeBase64(cipherTextB64); + const tag = decodeBase64(tagB64); + + assert( + ephemeralPub.length === PrivateKey.EPHEMERAL_PUBLIC_KEY_LENGTH, + new InvalidFormatError(encryptedPayload.valueOf()), + ); + assert( + iv.length === PrivateKey.IV_LENGTH, + new InvalidFormatError(encryptedPayload.valueOf()), + ); + assert( + tag.length === PrivateKey.TAG_LENGTH, + new InvalidFormatError(encryptedPayload.valueOf()), + ); + assert( + cipherText.length <= PrivateKey.MAX_CIPHERTEXT_LENGTH, + new InvalidLengthError( + cipherText.length, + PrivateKey.MAX_CIPHERTEXT_LENGTH, + ), + ); const x25519Priv = CryptoAdapter.privateKeyToX25519(this.valueOf()); diff --git a/tests/value-objects/crypto/PrivateKey.spec.ts b/tests/value-objects/crypto/PrivateKey.spec.ts index 5d04e49..d0b3337 100644 --- a/tests/value-objects/crypto/PrivateKey.spec.ts +++ b/tests/value-objects/crypto/PrivateKey.spec.ts @@ -190,6 +190,30 @@ describe('PrivateKey', () => { expect(decrypted.toString()).toBe('vo-payload'); }); + + it('should throw InvalidFormatError for malformed encrypted payload format', () => { + const privKey = new PrivateKey(privatePem); + const malformed = new EncryptedPayload('only-one-field'); + + expect(() => privKey.decrypt(malformed)).toThrow(InvalidFormatError); + }); + + it('should throw InvalidFormatError for invalid field lengths', () => { + const privKey = new PrivateKey(privatePem); + const malformed = new EncryptedPayload('YQ==.YQ==.YQ==.YQ=='); + + expect(() => privKey.decrypt(malformed)).toThrow(InvalidFormatError); + }); + + it('should throw InvalidLengthError for oversized ciphertext', () => { + const privKey = new PrivateKey(privatePem); + const oversizedCipher = Buffer.alloc(1024 * 1024 + 1).toString('base64'); + const payload = new EncryptedPayload( + `${Buffer.alloc(32).toString('base64')}.${Buffer.alloc(12).toString('base64')}.${oversizedCipher}.${Buffer.alloc(16).toString('base64')}`, + ); + + expect(() => privKey.decrypt(payload)).toThrow(InvalidLengthError); + }); }); describe('inheritance and Key behavior', () => { From 824104682be6a9d11de65b683a93f5de15c9ccad Mon Sep 17 00:00:00 2001 From: Hasko Date: Fri, 22 May 2026 17:13:48 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(crypto):=20=F0=9F=90=9B=20Harden=20encr?= =?UTF-8?q?ypted=20payload=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/value-objects/crypto/PrivateKey.ts | 67 ++++++++++++------- src/value-objects/crypto/PublicKey.ts | 8 +++ tests/value-objects/crypto/PrivateKey.spec.ts | 14 +++- tests/value-objects/crypto/PublicKey.spec.ts | 7 ++ 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/src/value-objects/crypto/PrivateKey.ts b/src/value-objects/crypto/PrivateKey.ts index c70b135..748c491 100644 --- a/src/value-objects/crypto/PrivateKey.ts +++ b/src/value-objects/crypto/PrivateKey.ts @@ -25,6 +25,34 @@ export class PrivateKey extends Key { private static readonly PATTERN = /^-----BEGIN PRIVATE KEY-----\n[A-Za-z0-9+/=]+\n-----END PRIVATE KEY-----\n$/; + private static getBase64DecodedLength(value: string): number { + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + + return (value.length / 4) * 3 - padding; + } + + private static ensureIsBase64( + value: string, + encryptedPayload: EncryptedPayload, + options: { allowEmpty: boolean } = { allowEmpty: false }, + ): void { + assert( + (options.allowEmpty || value.length > 0) && + value.length % 4 === 0 && + PrivateKey.BASE64_PATTERN.test(value), + new InvalidFormatError(encryptedPayload.valueOf()), + ); + } + + private static decodeBase64( + value: string, + encryptedPayload: EncryptedPayload, + ): Buffer { + PrivateKey.ensureIsBase64(value, encryptedPayload); + + return Buffer.from(value, 'base64'); + } + public static fromPEM(pem: string | StringValueObject): PrivateKey { return new PrivateKey(pem.valueOf()); } @@ -63,17 +91,6 @@ export class PrivateKey extends Key { } public decrypt(encryptedPayload: EncryptedPayload): Buffer { - const decodeBase64 = (value: string): Buffer => { - assert( - value.length > 0 && - value.length % 4 === 0 && - PrivateKey.BASE64_PATTERN.test(value), - new InvalidFormatError(encryptedPayload.valueOf()), - ); - - return Buffer.from(value, 'base64'); - }; - const parts = encryptedPayload.valueOf().split('.'); assert( parts.length === PrivateKey.ENCRYPTED_PAYLOAD_PARTS, @@ -81,10 +98,22 @@ export class PrivateKey extends Key { ); const [ephPubB64, ivB64, cipherTextB64, tagB64] = parts; - const ephemeralPub = decodeBase64(ephPubB64); - const iv = decodeBase64(ivB64); - const cipherText = decodeBase64(cipherTextB64); - const tag = decodeBase64(tagB64); + PrivateKey.ensureIsBase64(cipherTextB64, encryptedPayload, { + allowEmpty: true, + }); + const cipherTextLength = PrivateKey.getBase64DecodedLength(cipherTextB64); + assert( + cipherTextLength <= PrivateKey.MAX_CIPHERTEXT_LENGTH, + new InvalidLengthError( + cipherTextLength, + PrivateKey.MAX_CIPHERTEXT_LENGTH, + ), + ); + + const ephemeralPub = PrivateKey.decodeBase64(ephPubB64, encryptedPayload); + const iv = PrivateKey.decodeBase64(ivB64, encryptedPayload); + const cipherText = Buffer.from(cipherTextB64, 'base64'); + const tag = PrivateKey.decodeBase64(tagB64, encryptedPayload); assert( ephemeralPub.length === PrivateKey.EPHEMERAL_PUBLIC_KEY_LENGTH, @@ -98,14 +127,6 @@ export class PrivateKey extends Key { tag.length === PrivateKey.TAG_LENGTH, new InvalidFormatError(encryptedPayload.valueOf()), ); - assert( - cipherText.length <= PrivateKey.MAX_CIPHERTEXT_LENGTH, - new InvalidLengthError( - cipherText.length, - PrivateKey.MAX_CIPHERTEXT_LENGTH, - ), - ); - const x25519Priv = CryptoAdapter.privateKeyToX25519(this.valueOf()); const sharedSecret = CryptoAdapter.x25519SharedSecret( diff --git a/src/value-objects/crypto/PublicKey.ts b/src/value-objects/crypto/PublicKey.ts index fadd35a..961a132 100644 --- a/src/value-objects/crypto/PublicKey.ts +++ b/src/value-objects/crypto/PublicKey.ts @@ -13,6 +13,7 @@ import { Signature } from './Signature'; export class PublicKey extends Key { private static readonly LENGTH = 113; + private static readonly MAX_PAYLOAD_LENGTH = 1024 * 1024; private static readonly PATTERN = /^-----BEGIN PUBLIC KEY-----\n[A-Za-z0-9+/=]+\n-----END PUBLIC KEY-----\n$/; @@ -55,6 +56,13 @@ export class PublicKey extends Key { public encrypt(payload: CryptoPayload): EncryptedPayload { const messageBuffer = Buffer.from(payload.valueOf()); + assert( + messageBuffer.length <= PublicKey.MAX_PAYLOAD_LENGTH, + new InvalidLengthError( + messageBuffer.length, + PublicKey.MAX_PAYLOAD_LENGTH, + ), + ); const x25519Pub = CryptoAdapter.publicKeyToX25519(this.valueOf()); diff --git a/tests/value-objects/crypto/PrivateKey.spec.ts b/tests/value-objects/crypto/PrivateKey.spec.ts index d0b3337..4630d13 100644 --- a/tests/value-objects/crypto/PrivateKey.spec.ts +++ b/tests/value-objects/crypto/PrivateKey.spec.ts @@ -155,6 +155,16 @@ describe('PrivateKey', () => { expect(decrypted.toString()).toBe('secret message'); }); + it('should decrypt an empty payload', () => { + const pubKey = new PublicKey(publicPem); + const privKey = new PrivateKey(privatePem); + const encrypted = pubKey.encrypt(''); + const decrypted = privKey.decrypt(encrypted); + + expect(decrypted).toBeInstanceOf(Buffer); + expect(decrypted).toHaveLength(0); + }); + it('should fail to decrypt with the wrong private key', () => { const pubKey = new PublicKey(publicPem); const encrypted = pubKey.encrypt('secret'); @@ -207,7 +217,9 @@ describe('PrivateKey', () => { it('should throw InvalidLengthError for oversized ciphertext', () => { const privKey = new PrivateKey(privatePem); - const oversizedCipher = Buffer.alloc(1024 * 1024 + 1).toString('base64'); + const oversizedCipher = 'A'.repeat( + Math.ceil((1024 * 1024 + 1) / 3) * 4, + ); const payload = new EncryptedPayload( `${Buffer.alloc(32).toString('base64')}.${Buffer.alloc(12).toString('base64')}.${oversizedCipher}.${Buffer.alloc(16).toString('base64')}`, ); diff --git a/tests/value-objects/crypto/PublicKey.spec.ts b/tests/value-objects/crypto/PublicKey.spec.ts index c8ff12f..18a669b 100644 --- a/tests/value-objects/crypto/PublicKey.spec.ts +++ b/tests/value-objects/crypto/PublicKey.spec.ts @@ -146,6 +146,13 @@ describe('PublicKey', () => { expect(encrypted).toBeInstanceOf(EncryptedPayload); }); + it('should throw InvalidLengthError for oversized payloads', () => { + const key = new PublicKey(publicPem); + const payload = Buffer.alloc(1024 * 1024 + 1); + + expect(() => key.encrypt(payload)).toThrow(InvalidLengthError); + }); + it('should produce output that can be decrypted by the corresponding PrivateKey', () => { const pubKey = new PublicKey(publicPem); const privKey = new PrivateKey(privatePem);