Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 69 additions & 6 deletions src/value-objects/crypto/PrivateKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,45 @@ 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$/;

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());
}
Expand Down Expand Up @@ -55,15 +91,42 @@ export class PrivateKey extends Key {
}

public decrypt(encryptedPayload: EncryptedPayload): Buffer {
const [ephPubB64, ivB64, cipherTextB64, tagB64] = encryptedPayload
.valueOf()
.split('.');
const parts = encryptedPayload.valueOf().split('.');
assert(
parts.length === PrivateKey.ENCRYPTED_PAYLOAD_PARTS,
new InvalidFormatError(encryptedPayload.valueOf()),
);

const [ephPubB64, ivB64, cipherTextB64, tagB64] = parts;
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 = Buffer.from(ephPubB64, 'base64');
const iv = Buffer.from(ivB64, 'base64');
const ephemeralPub = PrivateKey.decodeBase64(ephPubB64, encryptedPayload);
const iv = PrivateKey.decodeBase64(ivB64, encryptedPayload);
const cipherText = Buffer.from(cipherTextB64, 'base64');
const tag = Buffer.from(tagB64, 'base64');
const tag = PrivateKey.decodeBase64(tagB64, encryptedPayload);

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()),
);
const x25519Priv = CryptoAdapter.privateKeyToX25519(this.valueOf());

const sharedSecret = CryptoAdapter.x25519SharedSecret(
Expand Down
8 changes: 8 additions & 0 deletions src/value-objects/crypto/PublicKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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$/;

Expand Down Expand Up @@ -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());

Expand Down
36 changes: 36 additions & 0 deletions tests/value-objects/crypto/PrivateKey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -190,6 +200,32 @@ 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 = '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')}`,
);

expect(() => privKey.decrypt(payload)).toThrow(InvalidLengthError);
});
});

describe('inheritance and Key behavior', () => {
Expand Down
7 changes: 7 additions & 0 deletions tests/value-objects/crypto/PublicKey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down