diff --git a/README.md b/README.md index 22b9724..501c5a6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ npm install @haskou/value-objects ``` ```typescript -import { Email, PositiveNumber, Color, Hour } from 'value-objects'; +import { Email, PositiveNumber, Color, Hour } from '@haskou/value-objects'; // ✅ Automatic validation on construction const email = new Email('user@example.com'); // Valid @@ -88,24 +88,38 @@ your application. ### 🔐 Cryptography - **`KeyPair`** - Ed25519 key pair generation for signing and verification -- **`PrivateKey`** - Ed25519 private key (PEM format) for signing; also accepted by payload decryption for data addressed to the matching key pair -- **`PublicKey`** - Ed25519 public key (PEM format) for signature verification; also accepted by payload encryption to address data to the matching key pair +- **`PrivateKey`** - Ed25519 private key (PEM format) for signing; also accepted by asymmetric payload decryption for data addressed to the matching key pair +- **`PublicKey`** - Ed25519 public key (PEM format) for signature verification; also accepted by asymmetric payload encryption to address data to the matching key pair - **`Signature`** - Base64-encoded Ed25519 digital signature -- **`EncryptedPayload`** - Dot-separated Base64 container returned by payload encryption +- **`EncryptedPayload`** - Base container for dot-separated encrypted payload formats +- **`AsymmetricEncryptedPayload`** - Payload encrypted for an Ed25519/X25519 recipient key pair +- **`SymmetricEncryptedPayload`** - Payload encrypted with a symmetric AES-256-GCM key +- **`SymmetricKey`** - 32-byte AES-256-GCM key, randomly generated or deterministically derived from a password and salt with scrypt - **`EncryptedPrivateKey`** - Password-protected private key encryption using scrypt + AES-256-GCM - **`EncryptedKeyPair`** - Key pair with encrypted private key Technical notes: -- Payload encryption is a classical hybrid scheme. It generates an ephemeral +- Asymmetric payload encryption is a classical hybrid scheme. It generates an ephemeral X25519 key pair, converts the recipient Ed25519 key material to Montgomery form for X25519 key agreement, derives a shared secret, then derives a 256-bit AES key with SHA-256 and encrypts the payload with AES-256-GCM. -- The encrypted payload format is - `ephemeralPublicKey.iv.cipherText.authTag`, with Base64-encoded fields, +- The asymmetric payload format is + `ephemeralPublicKey.iv.cipherText.tag`, with Base64-encoded fields, 32-byte ephemeral public keys, 12-byte random IVs, and 16-byte authentication tags. Tampered ciphertext, IVs, tags, or key-agreement data fail authentication during decryption. +- Symmetric payload encryption uses a 32-byte AES-256-GCM key directly. The + payload format is `v1.aes-256-gcm.iv.cipherText.tag`, with Base64-encoded + IV, ciphertext, and tag fields. +- `SymmetricKey.generate()` creates a random 256-bit key. `SymmetricKey.fromPassword()` + derives the same 256-bit key from the same password, salt, and scrypt + parameters. Encryption itself is still randomized because each payload uses a + fresh 12-byte IV. +- A 32-byte key provides the AES-256 key size. Real security depends on using + either a high-entropy random key or a strong password with a unique, + non-empty salt. AES-GCM also requires that IVs do not repeat for the same key; + the library generates a random 96-bit IV for each encryption. - Payload encryption is intended for small payloads and is currently capped at 1 MiB before encryption. - Encrypted private keys use a separate password-based scheme: @@ -175,15 +189,26 @@ const sha512 = SHA512Hash.from('hello'); // 9b71d224bd62f3785d96d46ad3ea3d73319b console.log(md5.toBase64()); // Crypto -const keyPair = KeyPair.generate(); // Ed25519 key pair +const keyPair = await KeyPair.generate(); // Ed25519 key pair const signature = keyPair.sign('hello world'); // Sign a message keyPair.isValidSignature('hello world', signature); // true // Encrypted key pairs const encrypted = await keyPair.encryptKeyPair('my-password'); -const sig = encrypted.sign('message', 'my-password'); +const sig = await encrypted.sign('message', 'my-password'); encrypted.isValidSignature('message', sig); // true +// Symmetric payload encryption +const symmetricKey = SymmetricKey.generate(); +const symmetricPayload = symmetricKey.encrypt('secret data'); +const plaintext = symmetricKey.decrypt(symmetricPayload); +console.log(plaintext.toString()); // 'secret data' + +// Deterministic key derivation from a password and explicit salt +const derivedKey = await SymmetricKey.fromPassword('my-password', { + salt: 'stable-application-salt', +}); + // Media const media = new Media('hello world'); console.log(media.getSize()); // 11 diff --git a/TECHNICAL_DOCUMENTATION.md b/TECHNICAL_DOCUMENTATION.md index 2956e42..28a4180 100644 --- a/TECHNICAL_DOCUMENTATION.md +++ b/TECHNICAL_DOCUMENTATION.md @@ -17,6 +17,7 @@ Comprehensive technical documentation for the Value Objects library. - [Hash Value Objects](#hash-value-objects) - [Cryptography Value Objects](#cryptography-value-objects) - [Encrypting and Decrypting Payloads](#encrypting-and-decrypting-payloads) + - [Symmetric Payload Encryption](#symmetric-payload-encryption) - [Media Value Objects](#media-value-objects) - [Collection Utilities](#collection-utilities) - [Hour Value Objects](#hour-value-objects) @@ -644,7 +645,7 @@ try { ### Cryptography Value Objects -Cryptographic key value objects use **Ed25519** elliptic curve keys in PEM format for signing and verification. Payload encryption uses a library-specific hybrid public-key encryption scheme: Ed25519 key material is converted to X25519 (Montgomery form via `@noble/curves`), an ephemeral X25519 shared secret is combined with the ephemeral public key and hashed with SHA-256 to derive a 256-bit AES key, and the payload is encrypted with AES-256-GCM. This format is not HPKE, is not post-quantum, and is not documented here as an independently audited protocol. +Cryptographic key value objects use **Ed25519** elliptic curve keys in PEM format for signing and verification. Asymmetric payload encryption uses a library-specific hybrid public-key encryption scheme: Ed25519 key material is converted to X25519 (Montgomery form via `@noble/curves`), an ephemeral X25519 shared secret is combined with the ephemeral public key and hashed with SHA-256 to derive a 256-bit AES key, and the payload is encrypted with AES-256-GCM. Symmetric payload encryption uses a caller-held 32-byte AES-256-GCM key directly. These formats are not HPKE, are not post-quantum cryptography schemes, and are not documented here as independently audited protocols. #### Key @@ -656,7 +657,7 @@ abstract class Key extends ValueObject {} #### PrivateKey -Represents an immutable Ed25519 private key in PEM (PKCS8) format. Used for signing messages; also accepted by payload decryption for data addressed to the matching key pair. +Represents an immutable Ed25519 private key in PEM (PKCS8) format. Used for signing messages; also accepted by asymmetric payload decryption for data addressed to the matching key pair. ```typescript class PrivateKey extends Key { @@ -684,14 +685,14 @@ console.log(decrypted.toString()); // Original plaintext #### PublicKey -Represents an immutable Ed25519 public key in PEM (SPKI) format. Used for verifying signatures; also accepted by payload encryption to address data to the matching key pair. +Represents an immutable Ed25519 public key in PEM (SPKI) format. Used for verifying signatures; also accepted by asymmetric payload encryption to address data to the matching key pair. ```typescript class PublicKey extends Key { public static fromPEM(pem: string | StringValueObject): PublicKey; constructor(value: string | StringValueObject); public isValidSignature(payload: CryptoPayload, signature: Signature): boolean; - public encrypt(payload: CryptoPayload): EncryptedPayload; + public encrypt(payload: CryptoPayload): AsymmetricEncryptedPayload; } ``` @@ -776,12 +777,67 @@ const restored = KeyPair.fromPrimitives(primitives); const encryptedKeyPair = await keyPair.encryptKeyPair('strong-password'); ``` +#### SymmetricKey + +Represents an immutable 32-byte AES-256-GCM key encoded as Base64. Keys can be generated randomly, loaded from a 32-byte buffer, loaded from Base64, or deterministically derived from a password plus an explicit salt with scrypt (`N=16384`, `r=8`, `p=1` by default). + +```typescript +type SymmetricKeyDerivationOptions = { + N?: number; + p?: number; + r?: number; + salt: string | StringValueObject | Buffer; +}; + +class SymmetricKey extends ValueObject { + public static fromBase64(key: string | StringValueObject): SymmetricKey; + public static fromBuffer(key: Buffer): SymmetricKey; + public static generate(): SymmetricKey; + public static fromPassword( + password: string | StringValueObject, + options: SymmetricKeyDerivationOptions, + ): Promise; + constructor(value: string | StringValueObject); + public getBuffer(): Buffer; + public encrypt(payload: CryptoPayload): SymmetricEncryptedPayload; + public decrypt(encryptedPayload: EncryptedPayload): Buffer; +} +``` + +**Example:** +```typescript +// Random symmetric key +const key = SymmetricKey.generate(); +const encrypted = key.encrypt('confidential data'); +const decrypted = key.decrypt(encrypted); +console.log(decrypted.toString()); // 'confidential data' + +// Deterministic key derivation from password + explicit salt +const derived = await SymmetricKey.fromPassword('strong-password', { + salt: 'application-specific-salt', +}); + +// The same password, salt, and scrypt parameters derive the same key +const sameDerived = await SymmetricKey.fromPassword('strong-password', { + salt: 'application-specific-salt', +}); +console.log(derived.isEqual(sameDerived)); // true +``` + +The derived key is deterministic, but encryption is not: `encrypt()` generates a fresh 12-byte AES-GCM IV for each payload. Callers must store or reproduce the salt used for `fromPassword()`; it is not embedded in `SymmetricEncryptedPayload`. + #### EncryptedPrivateKey Represents an immutable password-protected private key container. New encrypted private keys use scrypt (N=16384, r=8, p=1), a 16-byte salt, a 32-byte derived key, and AES-256-GCM with a 12-byte IV and 16-byte authentication tag. The class also supports the legacy 4-part format, which decrypts with PBKDF2-SHA256 using 100000 iterations and AES-256-GCM. The encrypted format is: `v2.scrypt.N16384.r8.p1.salt.iv.tag.cipherText` (base64-encoded, dot-separated). Legacy format: `cipherText.iv.salt.tag`. +The v2 implementation derives a `SymmetricKey` from the password, salt, and +scrypt parameters, then reuses the same AES-256-GCM payload primitive used by +`SymmetricKey`. `EncryptedPrivateKey` keeps its own serialized container because +it must persist the KDF name, KDF parameters, and salt needed to decrypt the +private key later. + ```typescript class EncryptedPrivateKey extends ValueObject { public static async create( @@ -865,17 +921,29 @@ const restored = EncryptedKeyPair.fromPrimitives(primitives); #### EncryptedPayload -Represents an immutable dot-separated Base64 container returned by the library-specific payload encryption scheme. The format is `ephemeralPub.iv.cipherText.tag`. +Represents an immutable dot-separated encrypted payload container. It is the base type accepted by decryptors and can classify the known payload shape as `asymmetric`, `symmetric`, or `unknown`. ```typescript -class EncryptedPayload extends ValueObject {} +type EncryptedPayloadScheme = 'asymmetric' | 'symmetric' | 'unknown'; + +class EncryptedPayload extends ValueObject { + public getScheme(): EncryptedPayloadScheme; +} + +class AsymmetricEncryptedPayload extends EncryptedPayload { + public getScheme(): EncryptedPayloadScheme; +} + +class SymmetricEncryptedPayload extends EncryptedPayload { + public getScheme(): EncryptedPayloadScheme; +} ``` -Created by `PublicKey.encrypt()`, `KeyPair.encrypt()`, or `EncryptedKeyPair.encrypt()`. Decrypted by the corresponding `decrypt()` method. +`AsymmetricEncryptedPayload` is created by `PublicKey.encrypt()`, `KeyPair.encrypt()`, or `EncryptedKeyPair.encrypt()` and decrypted by the corresponding private-key decryptor. `SymmetricEncryptedPayload` is created by `SymmetricKey.encrypt()` and decrypted by `SymmetricKey.decrypt()`. #### Encrypting and Decrypting Payloads -The library uses a classical hybrid public-key encryption approach: +The public-key payload API uses a classical hybrid encryption approach: 1. The Ed25519 public key is converted to X25519 (Montgomery form) 2. An ephemeral X25519 key pair is generated @@ -922,12 +990,50 @@ console.log(decrypted.toString()); // 'hello world' **Important:** - Each call to `encrypt()` generates a new ephemeral key, producing different ciphertext even for the same payload. - Decrypting with the wrong private key throws an AES-GCM authentication error. -- The encrypted format is `ephemeralPub.iv.cipherText.tag` (base64, dot-separated). -- Payload encryption is capped at 1 MiB before encryption. -- Payload encryption does not authenticate the sender. AES-GCM authenticates +- The asymmetric encrypted format is `ephemeralPub.iv.cipherText.tag` (base64, dot-separated). +- Asymmetric payload encryption is capped at 1 MiB before encryption. +- Asymmetric payload encryption does not authenticate the sender. AES-GCM authenticates ciphertext integrity for the derived key, but the payload does not include a sender key or signature. -- This payload encryption format is library-specific, not HPKE, and is not post-quantum. +- This public-key payload encryption format is library-specific, not HPKE, and is not post-quantum. + +#### Symmetric Payload Encryption + +`SymmetricKey` encrypts and decrypts payloads with AES-256-GCM using the key bytes held by the value object. + +1. A `SymmetricKey` contains exactly 32 bytes (256 bits) encoded as Base64 +2. `encrypt()` generates a fresh 12-byte random IV +3. AES-256-GCM encrypts the payload and produces a 16-byte authentication tag +4. The output is `v1.aes-256-gcm.iv.cipherText.tag` with Base64-encoded IV, ciphertext, and tag fields +5. `decrypt()` validates the version, algorithm, IV length, tag length, Base64 fields, and 1 MiB ciphertext limit before decrypting + +**Random key:** +```typescript +const key = SymmetricKey.generate(); +const encrypted = key.encrypt('secret data'); +const decrypted = key.decrypt(encrypted); +console.log(decrypted.toString()); // 'secret data' +``` + +**Password-derived key:** +```typescript +const key = await SymmetricKey.fromPassword('strong-password', { + salt: 'application-specific-salt', +}); + +const encrypted = key.encrypt(Buffer.from('secret data')); +const decrypted = key.decrypt(encrypted); +console.log(decrypted.toString()); // 'secret data' +``` + +**Important:** +- A 32-byte key is the AES-256 key size. With a uniformly random key, brute-force key search is not practical with current classical computing. +- Password-derived keys are only as strong as the password and salt policy. Use a unique, non-empty salt per derivation context; callers must store or reproduce that salt because it is not included in `SymmetricEncryptedPayload`. +- The default password KDF is scrypt with `N=16384`, `r=8`, `p=1`, and a 32-byte output. Custom scrypt parameters are accepted through `SymmetricKey.fromPassword()`. +- The same password, salt, and scrypt parameters derive the same key. The encrypted payload remains randomized because every encryption uses a fresh 96-bit IV. +- AES-GCM requires IV uniqueness for a given key. The library generates a random IV for each encryption; avoid manually reusing serialized payload internals as new encryption inputs. +- Symmetric payload encryption provides confidentiality and ciphertext integrity for holders of the same key. It does not identify which holder encrypted the payload. It is not a post-quantum cryptography scheme; AES-256 is considered to retain a large security margin against Grover-style quadratic speedups, but the KDF and password entropy still matter. +- Symmetric payload encryption is capped at 1 MiB before encryption. #### CryptoPayload diff --git a/src/value-objects/crypto/AsymmetricEncryptedPayload.ts b/src/value-objects/crypto/AsymmetricEncryptedPayload.ts new file mode 100644 index 0000000..aa7ac88 --- /dev/null +++ b/src/value-objects/crypto/AsymmetricEncryptedPayload.ts @@ -0,0 +1,7 @@ +import { EncryptedPayload, EncryptedPayloadScheme } from './EncryptedPayload'; + +export class AsymmetricEncryptedPayload extends EncryptedPayload { + public getScheme(): EncryptedPayloadScheme { + return 'asymmetric'; + } +} diff --git a/src/value-objects/crypto/EncryptedPayload.ts b/src/value-objects/crypto/EncryptedPayload.ts index 9433b4a..982ab08 100644 --- a/src/value-objects/crypto/EncryptedPayload.ts +++ b/src/value-objects/crypto/EncryptedPayload.ts @@ -1,3 +1,19 @@ import { ValueObject } from '../ValueObject'; -export class EncryptedPayload extends ValueObject {} +export type EncryptedPayloadScheme = 'asymmetric' | 'symmetric' | 'unknown'; + +export class EncryptedPayload extends ValueObject { + public getScheme(): EncryptedPayloadScheme { + const parts = this.valueOf().split('.'); + + if (parts.length === 4) { + return 'asymmetric'; + } + + if (parts.length === 5 && parts[0] === 'v1' && parts[1] === 'aes-256-gcm') { + return 'symmetric'; + } + + return 'unknown'; + } +} diff --git a/src/value-objects/crypto/PublicKey.ts b/src/value-objects/crypto/PublicKey.ts index 2af7388..341ddcc 100644 --- a/src/value-objects/crypto/PublicKey.ts +++ b/src/value-objects/crypto/PublicKey.ts @@ -6,9 +6,9 @@ import { assert } from '../../patterns'; import { Media } from '../media'; import { NullObject } from '../NullObject'; import { StringValueObject } from '../StringValueObject'; +import { AsymmetricEncryptedPayload } from './AsymmetricEncryptedPayload'; import { CryptoAdapter } from './CryptoAdapter'; import { CryptoPayload } from './CryptoPayload'; -import { EncryptedPayload } from './EncryptedPayload'; import { Key } from './Key'; import { Signature } from './Signature'; @@ -58,7 +58,7 @@ export class PublicKey extends Key { return valid; } - public encrypt(payload: CryptoPayload): EncryptedPayload { + public encrypt(payload: CryptoPayload): AsymmetricEncryptedPayload { const messageBuffer = payload instanceof Media ? payload.getBuffer() @@ -100,6 +100,6 @@ export class PublicKey extends Key { Buffer.from(tag).toString('base64'), ].join('.'); - return new EncryptedPayload(result); + return new AsymmetricEncryptedPayload(result); } } diff --git a/src/value-objects/crypto/SymmetricEncryptedPayload.ts b/src/value-objects/crypto/SymmetricEncryptedPayload.ts new file mode 100644 index 0000000..b2498ef --- /dev/null +++ b/src/value-objects/crypto/SymmetricEncryptedPayload.ts @@ -0,0 +1,7 @@ +import { EncryptedPayload, EncryptedPayloadScheme } from './EncryptedPayload'; + +export class SymmetricEncryptedPayload extends EncryptedPayload { + public getScheme(): EncryptedPayloadScheme { + return 'symmetric'; + } +} diff --git a/src/value-objects/crypto/SymmetricKey.ts b/src/value-objects/crypto/SymmetricKey.ts new file mode 100644 index 0000000..6a40ca4 --- /dev/null +++ b/src/value-objects/crypto/SymmetricKey.ts @@ -0,0 +1,219 @@ +import { Buffer } from 'buffer'; + +import { InvalidFormatError } from '../../errors/InvalidFormatError'; +import { InvalidLengthError } from '../../errors/InvalidLengthError'; +import { assert } from '../../patterns'; +import { Media } from '../media'; +import { NullObject } from '../NullObject'; +import { StringValueObject } from '../StringValueObject'; +import { ValueObject } from '../ValueObject'; +import { CryptoAdapter } from './CryptoAdapter'; +import { CryptoPayload } from './CryptoPayload'; +import { CryptoDerivation } from './encrypted-private-key/CryptoDerivation'; +import { EncryptedPayload } from './EncryptedPayload'; +import { SymmetricEncryptedPayload } from './SymmetricEncryptedPayload'; + +export type SymmetricKeyDerivationOptions = { + N?: number; + p?: number; + r?: number; + salt: string | StringValueObject | Buffer; +}; + +export class SymmetricKey extends ValueObject { + private static readonly ALGORITHM = 'aes-256-gcm'; + private static readonly IV_LENGTH = 12; + private static readonly KEY_LENGTH = 32; + private static readonly MAX_PAYLOAD_LENGTH = 1024 * 1024; + private static readonly PAYLOAD_PARTS = 5; + private static readonly SCRYPT_N = 16384; + private static readonly SCRYPT_P = 1; + private static readonly SCRYPT_R = 8; + private static readonly TAG_LENGTH = 16; + private static readonly VERSION = 'v1'; + private static readonly BASE64_PATTERN = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + + 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 && + SymmetricKey.BASE64_PATTERN.test(value), + new InvalidFormatError(encryptedPayload.valueOf()), + ); + } + + private static ensureBase64DecodedLength( + value: string, + encryptedPayload: EncryptedPayload, + length: number, + ): void { + SymmetricKey.ensureIsBase64(value, encryptedPayload); + assert( + SymmetricKey.getBase64DecodedLength(value) === length, + new InvalidFormatError(encryptedPayload.valueOf()), + ); + } + + private static getSaltBytes( + salt: string | StringValueObject | Buffer, + ): Buffer { + if (Buffer.isBuffer(salt)) { + return salt; + } + + return Buffer.from(salt.valueOf()); + } + + private static getPayloadBytes(payload: CryptoPayload): Buffer { + return payload instanceof Media + ? payload.getBuffer() + : Buffer.from(payload.valueOf()); + } + + public static fromBase64(key: string | StringValueObject): SymmetricKey { + return new SymmetricKey(key.valueOf()); + } + + public static fromBuffer(key: Buffer): SymmetricKey { + assert( + key.length === SymmetricKey.KEY_LENGTH, + new InvalidLengthError(key.length, SymmetricKey.KEY_LENGTH), + ); + + return new SymmetricKey(key.toString('base64')); + } + + public static generate(): SymmetricKey { + return SymmetricKey.fromBuffer( + CryptoAdapter.randomBytes(SymmetricKey.KEY_LENGTH), + ); + } + + public static async fromPassword( + password: string | StringValueObject, + options: SymmetricKeyDerivationOptions, + ): Promise { + const salt = SymmetricKey.getSaltBytes(options.salt); + assert(salt.length > 0, new InvalidLengthError(salt.length, 1)); + + const key = await CryptoDerivation.scryptAsync( + password.valueOf(), + salt, + SymmetricKey.KEY_LENGTH, + { + N: options.N ?? SymmetricKey.SCRYPT_N, + p: options.p ?? SymmetricKey.SCRYPT_P, + r: options.r ?? SymmetricKey.SCRYPT_R, + }, + ); + + return SymmetricKey.fromBuffer(Buffer.from(key)); + } + + constructor(value: string | StringValueObject) { + super(value?.valueOf()); + + if (NullObject.isNullObject(this)) { + return this; + } + + this.ensureIsValidKey(this.value); + } + + private ensureIsValidKey(value: string): void { + assert( + value.length % 4 === 0 && SymmetricKey.BASE64_PATTERN.test(value), + new InvalidFormatError(value), + ); + assert( + SymmetricKey.getBase64DecodedLength(value) === SymmetricKey.KEY_LENGTH, + new InvalidLengthError( + SymmetricKey.getBase64DecodedLength(value), + SymmetricKey.KEY_LENGTH, + ), + ); + } + + public getBuffer(): Buffer { + return Buffer.from(this.valueOf(), 'base64'); + } + + public encrypt(payload: CryptoPayload): SymmetricEncryptedPayload { + const messageBuffer = SymmetricKey.getPayloadBytes(payload); + assert( + messageBuffer.length <= SymmetricKey.MAX_PAYLOAD_LENGTH, + new InvalidLengthError( + messageBuffer.length, + SymmetricKey.MAX_PAYLOAD_LENGTH, + ), + ); + + const iv = CryptoAdapter.randomBytes(SymmetricKey.IV_LENGTH); + const { cipherText, tag } = CryptoAdapter.encryptAes256Gcm( + this.getBuffer(), + iv, + messageBuffer, + ); + + const result = [ + SymmetricKey.VERSION, + SymmetricKey.ALGORITHM, + iv.toString('base64'), + Buffer.from(cipherText).toString('base64'), + Buffer.from(tag).toString('base64'), + ].join('.'); + + return new SymmetricEncryptedPayload(result); + } + + public decrypt(encryptedPayload: EncryptedPayload): Buffer { + const parts = encryptedPayload.valueOf().split('.'); + assert( + parts.length === SymmetricKey.PAYLOAD_PARTS, + new InvalidFormatError(encryptedPayload.valueOf()), + ); + + const [version, algorithm, ivB64, cipherTextB64, tagB64] = parts; + assert( + version === SymmetricKey.VERSION && algorithm === SymmetricKey.ALGORITHM, + new InvalidFormatError(encryptedPayload.valueOf()), + ); + + SymmetricKey.ensureBase64DecodedLength( + ivB64, + encryptedPayload, + SymmetricKey.IV_LENGTH, + ); + SymmetricKey.ensureIsBase64(cipherTextB64, encryptedPayload, { + allowEmpty: true, + }); + const cipherTextLength = SymmetricKey.getBase64DecodedLength(cipherTextB64); + assert( + cipherTextLength <= SymmetricKey.MAX_PAYLOAD_LENGTH, + new InvalidLengthError(cipherTextLength, SymmetricKey.MAX_PAYLOAD_LENGTH), + ); + SymmetricKey.ensureBase64DecodedLength( + tagB64, + encryptedPayload, + SymmetricKey.TAG_LENGTH, + ); + + return CryptoAdapter.decryptAes256Gcm( + this.getBuffer(), + Buffer.from(ivB64, 'base64'), + Buffer.from(cipherTextB64, 'base64'), + Buffer.from(tagB64, 'base64'), + ); + } +} diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts index 4203970..954e047 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts @@ -1,8 +1,9 @@ import { Buffer } from 'buffer'; import { StringValueObject } from '../../StringValueObject'; -import { CryptoAdapter } from '../CryptoAdapter'; import { PrivateKey } from '../PrivateKey'; +import { SymmetricEncryptedPayload } from '../SymmetricEncryptedPayload'; +import { SymmetricKey } from '../SymmetricKey'; import { CryptoDerivation } from './CryptoDerivation'; import { EncryptedPrivateKeyVersion } from './EncryptedPrivateKeyVersion'; @@ -13,10 +14,9 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { private static readonly SCRYPT_R = 8; private static readonly SCRYPT_P = 1; private static readonly SALT_ENTROPY = 16; - private static readonly IV_ENTROPY = 12; - private static readonly KEY_LENGTH = 32; private static readonly CIPHER = 'aes-256-gcm'; private static readonly EXPECTED_PARTS = 9; + private static readonly SYMMETRIC_PAYLOAD_VERSION = 'v1'; private static hasSupportedScryptParameters(parts: string[]): boolean { return ( @@ -26,6 +26,32 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { ); } + private static async deriveSymmetricKey( + password: string | StringValueObject, + salt: Buffer, + options: { N: number; p: number; r: number }, + ): Promise { + return SymmetricKey.fromPassword(password, { ...options, salt }); + } + + private static toSymmetricPayload( + parts: string[], + ): SymmetricEncryptedPayload { + const iv = parts[6]; + const tag = parts[7]; + const cipherText = parts[8]; + + return new SymmetricEncryptedPayload( + [ + EncryptedPrivateKeyV2.SYMMETRIC_PAYLOAD_VERSION, + EncryptedPrivateKeyV2.CIPHER, + iv, + cipherText, + tag, + ].join('.'), + ); + } + public static async encrypt( privateKey: PrivateKey, password: string | StringValueObject, @@ -33,24 +59,18 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { const salt = await CryptoDerivation.randomBytesAsync( EncryptedPrivateKeyV2.SALT_ENTROPY, ); - const key = await CryptoDerivation.scryptAsync( - password.valueOf(), - salt, - EncryptedPrivateKeyV2.KEY_LENGTH, - { - N: EncryptedPrivateKeyV2.SCRYPT_N, - p: EncryptedPrivateKeyV2.SCRYPT_P, - r: EncryptedPrivateKeyV2.SCRYPT_R, - }, - ); - const iv = await CryptoDerivation.randomBytesAsync( - EncryptedPrivateKeyV2.IV_ENTROPY, - ); - const { cipherText, tag } = CryptoAdapter.encryptAes256Gcm( - key, - iv, - Buffer.from(privateKey.valueOf()), - ); + const key = await EncryptedPrivateKeyV2.deriveSymmetricKey(password, salt, { + N: EncryptedPrivateKeyV2.SCRYPT_N, + p: EncryptedPrivateKeyV2.SCRYPT_P, + r: EncryptedPrivateKeyV2.SCRYPT_R, + }); + const symmetricPayload = key + .encrypt(privateKey.valueOf()) + .valueOf() + .split('.'); + const iv = symmetricPayload[2]; + const cipherText = symmetricPayload[3]; + const tag = symmetricPayload[4]; return [ EncryptedPrivateKeyV2.VERSION, @@ -59,9 +79,9 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { `r${EncryptedPrivateKeyV2.SCRYPT_R}`, `p${EncryptedPrivateKeyV2.SCRYPT_P}`, salt.toString('base64'), - iv.toString('base64'), - Buffer.from(tag).toString('base64'), - Buffer.from(cipherText).toString('base64'), + iv, + tag, + cipherText, ].join('.'); } @@ -86,19 +106,16 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { const r = parseInt(parts[3].slice(1), 10); const p = parseInt(parts[4].slice(1), 10); const salt = Buffer.from(parts[5], 'base64'); - const iv = Buffer.from(parts[6], 'base64'); - const tag = Buffer.from(parts[7], 'base64'); - const cipherText = Buffer.from(parts[8], 'base64'); + const key = await EncryptedPrivateKeyV2.deriveSymmetricKey(password, salt, { + N, + p, + r, + }); - const key = await CryptoDerivation.scryptAsync( - password.valueOf(), - salt, - EncryptedPrivateKeyV2.KEY_LENGTH, - { N, p, r }, + const decrypted = key.decrypt( + EncryptedPrivateKeyV2.toSymmetricPayload(parts), ); - const decrypted = CryptoAdapter.decryptAes256Gcm(key, iv, cipherText, tag); - return new PrivateKey(decrypted.toString()); } } diff --git a/src/value-objects/crypto/index.ts b/src/value-objects/crypto/index.ts index 31d591a..7e98cf6 100644 --- a/src/value-objects/crypto/index.ts +++ b/src/value-objects/crypto/index.ts @@ -1,3 +1,4 @@ +export * from './AsymmetricEncryptedPayload'; export * from './EncryptedKeyPair'; export * from './EncryptedPayload'; export * from './EncryptedPrivateKey'; @@ -6,3 +7,5 @@ export * from './KeyPair'; export * from './PrivateKey'; export * from './PublicKey'; export * from './Signature'; +export * from './SymmetricEncryptedPayload'; +export * from './SymmetricKey'; diff --git a/tests/value-objects/crypto/CryptoDerivation.spec.ts b/tests/value-objects/crypto/CryptoDerivation.spec.ts index 0956537..2c2cd72 100644 --- a/tests/value-objects/crypto/CryptoDerivation.spec.ts +++ b/tests/value-objects/crypto/CryptoDerivation.spec.ts @@ -17,6 +17,35 @@ describe('CryptoDerivation', () => { expect(key).toHaveLength(32); }); + it('should derive a key using pbkdf2Async with sha512', async () => { + const salt = crypto.randomBytes(16); + const key = await CryptoDerivation.pbkdf2Async( + 'secure-password', + salt, + 100000, + 32, + 'sha512', + ); + + expect(key).toBeInstanceOf(Buffer); + expect(key).toHaveLength(32); + }); + + it('should derive a key using pbkdf2Async fallback when injected crypto module has no pbkdf2', async () => { + const salt = crypto.randomBytes(16); + const key = await CryptoDerivation.pbkdf2Async( + 'secure-password', + salt, + 100000, + 32, + 'sha256', + {}, + ); + + expect(key).toBeInstanceOf(Buffer); + expect(key).toHaveLength(32); + }); + it('should derive a key using scryptAsync', async () => { const salt = crypto.randomBytes(16); const key = await CryptoDerivation.scryptAsync( @@ -61,6 +90,36 @@ describe('CryptoDerivation', () => { ).rejects.toThrow('Mock scrypt error'); }); + it('should derive a key using scryptAsync with injected crypto module', async () => { + const salt = Buffer.from('aabbccddeeff00112233445566778899', 'hex'); + const expectedKey = Buffer.from('0123456789abcdef0123456789abcdef', 'hex'); + const mockCrypto = { + ...crypto, + scrypt: jest.fn((password, saltArg, keylen, options, callback) => { + expect(password).toBe('secure-password'); + expect(saltArg).toEqual(salt); + expect(keylen).toBe(32); + expect(options).toEqual({ N: 16384, r: 8, p: 1 }); + callback(null, expectedKey); + }), + } as unknown as typeof crypto; + + const key = await CryptoDerivation.scryptAsync( + 'secure-password', + salt, + 32, + { + N: 16384, + r: 8, + p: 1, + }, + mockCrypto, + ); + + expect(key).toBe(expectedKey); + expect(mockCrypto.scrypt).toHaveBeenCalled(); + }); + it('should generate random bytes using randomBytesAsync', async () => { const bytes = await CryptoDerivation.randomBytesAsync(16); @@ -98,6 +157,30 @@ describe('CryptoDerivation', () => { expect(mockCrypto.pbkdf2).toHaveBeenCalled(); }); + it('should reject pbkdf2Async when injected crypto module fails', async () => { + const salt = Buffer.from('aabbccddeeff00112233445566778899', 'hex'); + const mockError = new Error('Mock pbkdf2 error'); + const mockCrypto = { + ...crypto, + pbkdf2: jest.fn( + (password, saltArg, iterations, keyLength, algorithm, callback) => { + callback(mockError, Buffer.alloc(0)); + }, + ), + } as unknown as typeof crypto; + + await expect( + CryptoDerivation.pbkdf2Async( + 'secure-password', + salt, + 100000, + 32, + 'sha256', + mockCrypto, + ), + ).rejects.toThrow('Mock pbkdf2 error'); + }); + it('should generate random bytes using randomBytesAsync with injected crypto module', async () => { const expectedBytes = Buffer.alloc(16, 0xab); const mockCrypto = { @@ -113,4 +196,18 @@ describe('CryptoDerivation', () => { expect(bytes).toEqual(expectedBytes); expect(mockCrypto.randomBytes).toHaveBeenCalled(); }); + + it('should reject randomBytesAsync when injected crypto module fails', async () => { + const mockError = new Error('Mock randomBytes error'); + const mockCrypto = { + ...crypto, + randomBytes: jest.fn((size, callback) => { + callback(mockError, Buffer.alloc(0)); + }), + } as unknown as typeof crypto; + + await expect( + CryptoDerivation.randomBytesAsync(16, mockCrypto), + ).rejects.toThrow('Mock randomBytes error'); + }); }); diff --git a/tests/value-objects/crypto/EncryptedPayload.spec.ts b/tests/value-objects/crypto/EncryptedPayload.spec.ts index ce239b8..85c955a 100644 --- a/tests/value-objects/crypto/EncryptedPayload.spec.ts +++ b/tests/value-objects/crypto/EncryptedPayload.spec.ts @@ -1,4 +1,10 @@ -import { EncryptedPayload, NullObject, StringValueObject } from '../../../src'; +import { + AsymmetricEncryptedPayload, + EncryptedPayload, + NullObject, + StringValueObject, + SymmetricEncryptedPayload, +} from '../../../src'; describe('EncryptedPayload', () => { describe('constructor', () => { @@ -14,6 +20,42 @@ describe('EncryptedPayload', () => { }); }); + describe('getScheme', () => { + it('should identify legacy asymmetric payloads', () => { + const payload = new EncryptedPayload('eph.iv.cipher.tag'); + + expect(payload.getScheme()).toBe('asymmetric'); + }); + + it('should identify symmetric payloads', () => { + const payload = new EncryptedPayload('v1.aes-256-gcm.iv.cipher.tag'); + + expect(payload.getScheme()).toBe('symmetric'); + }); + + it('should return unknown for unsupported payload formats', () => { + const payload = new EncryptedPayload('some-data'); + + expect(payload.getScheme()).toBe('unknown'); + }); + + it('should return unknown for incomplete symmetric payload formats', () => { + const payload = new EncryptedPayload('v1.aes-256-gcm'); + + expect(payload.getScheme()).toBe('unknown'); + }); + + it('should allow subclasses to expose their fixed scheme', () => { + const asymmetric = new AsymmetricEncryptedPayload('some-data'); + const symmetric = new SymmetricEncryptedPayload('some-data'); + + expect(asymmetric).toBeInstanceOf(EncryptedPayload); + expect(symmetric).toBeInstanceOf(EncryptedPayload); + expect(asymmetric.getScheme()).toBe('asymmetric'); + expect(symmetric.getScheme()).toBe('symmetric'); + }); + }); + describe('inheritance and ValueObject behavior', () => { it('should inherit from ValueObject', () => { const payload = new EncryptedPayload('data'); diff --git a/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts index e22cc8a..c05648c 100644 --- a/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts +++ b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts @@ -4,8 +4,11 @@ import { EncryptedPrivateKey, PrivateKey, StringValueObject, + SymmetricEncryptedPayload, + SymmetricKey, } from '../../../src'; import { CryptoDerivation } from '../../../src/value-objects/crypto/encrypted-private-key/CryptoDerivation'; +import { EncryptedPrivateKeyV2 } from '../../../src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2'; import { NullObject } from '../../../src/value-objects/NullObject'; describe('EncryptedPrivateKey', () => { @@ -145,6 +148,34 @@ describe('EncryptedPrivateKey', () => { scryptSpy.mockRestore(); }); + it('should reject unsupported v2 parameters inside the v2 decryptor', async () => { + const privateKey = new PrivateKey(privatePem); + const encrypted = await EncryptedPrivateKey.create(privateKey, password); + const parts = encrypted.valueOf().split('.'); + parts[4] = 'p2'; + + await expect( + new EncryptedPrivateKeyV2().decrypt(parts, password), + ).rejects.toThrow('Unsupported encrypted private key parameters'); + }); + + it('should keep v2 AES-GCM fields compatible with SymmetricKey', async () => { + const privateKey = new PrivateKey(privatePem); + const encrypted = await EncryptedPrivateKey.create(privateKey, password); + const parts = encrypted.valueOf().split('.'); + const key = await SymmetricKey.fromPassword(password, { + N: 16384, + p: 1, + r: 8, + salt: Buffer.from(parts[5], 'base64'), + }); + const symmetricPayload = new SymmetricEncryptedPayload( + ['v1', 'aes-256-gcm', parts[6], parts[8], parts[7]].join('.'), + ); + + expect(key.decrypt(symmetricPayload).toString()).toBe(privatePem); + }); + it('should decrypt to a functional PrivateKey that can sign', async () => { const privateKey = new PrivateKey(privatePem); const encrypted = await EncryptedPrivateKey.create(privateKey, password); diff --git a/tests/value-objects/crypto/PublicKey.spec.ts b/tests/value-objects/crypto/PublicKey.spec.ts index 03e7bcb..09120f2 100644 --- a/tests/value-objects/crypto/PublicKey.spec.ts +++ b/tests/value-objects/crypto/PublicKey.spec.ts @@ -1,6 +1,7 @@ import * as crypto from 'node:crypto'; import { + AsymmetricEncryptedPayload, EncryptedPayload, InvalidFormatError, InvalidLengthError, @@ -129,7 +130,9 @@ describe('PublicKey', () => { const key = new PublicKey(publicPem); const encrypted = key.encrypt('hello world'); + expect(encrypted).toBeInstanceOf(AsymmetricEncryptedPayload); expect(encrypted).toBeInstanceOf(EncryptedPayload); + expect(encrypted.getScheme()).toBe('asymmetric'); expect(encrypted.valueOf()).not.toBe('hello world'); }); diff --git a/tests/value-objects/crypto/SymmetricKey.spec.ts b/tests/value-objects/crypto/SymmetricKey.spec.ts new file mode 100644 index 0000000..f2bed83 --- /dev/null +++ b/tests/value-objects/crypto/SymmetricKey.spec.ts @@ -0,0 +1,306 @@ +import { + EncryptedPayload, + InvalidFormatError, + InvalidLengthError, + Media, + NullObject, + StringValueObject, + SymmetricEncryptedPayload, + SymmetricKey, +} from '../../../src'; + +describe('SymmetricKey', () => { + const keyBytes = Buffer.alloc(32, 7); + const keyBase64 = keyBytes.toString('base64'); + + describe('constructor', () => { + it('should return a NullValueObject when receiving nullish', () => { + expect(() => new SymmetricKey(undefined as unknown as string)).not.toThrow(); + expect( + NullObject.isNullObject( + new SymmetricKey(undefined as unknown as string), + ), + ).toBeTrue(); + expect( + NullObject.isNullObject(new SymmetricKey(null as unknown as string)), + ).toBeTrue(); + }); + + it('should store a base64-encoded 32-byte key', () => { + const key = new SymmetricKey(keyBase64); + + expect(key.valueOf()).toBe(keyBase64); + expect(key.getBuffer()).toEqual(keyBytes); + }); + + it('should throw InvalidFormatError for non-base64 keys', () => { + expect(() => new SymmetricKey('*'.repeat(44))).toThrow( + InvalidFormatError, + ); + }); + + it('should throw InvalidLengthError for wrong decoded key length', () => { + expect(() => new SymmetricKey(Buffer.alloc(31).toString('base64'))).toThrow( + InvalidLengthError, + ); + }); + }); + + describe('factories', () => { + it('should create a key from base64', () => { + const key = SymmetricKey.fromBase64(keyBase64); + + expect(key).toBeInstanceOf(SymmetricKey); + expect(key.getBuffer()).toEqual(keyBytes); + }); + + it('should create a key from a StringValueObject wrapping base64', () => { + const key = SymmetricKey.fromBase64(new StringValueObject(keyBase64)); + + expect(key.getBuffer()).toEqual(keyBytes); + }); + + it('should create a key from a 32-byte Buffer', () => { + const key = SymmetricKey.fromBuffer(keyBytes); + + expect(key.valueOf()).toBe(keyBase64); + }); + + it('should throw InvalidLengthError for non-32-byte Buffers', () => { + expect(() => SymmetricKey.fromBuffer(Buffer.alloc(33))).toThrow( + InvalidLengthError, + ); + }); + + it('should generate random 32-byte keys', () => { + const key = SymmetricKey.generate(); + + expect(key).toBeInstanceOf(SymmetricKey); + expect(key.getBuffer()).toHaveLength(32); + }); + + it('should derive deterministic keys from the same password and salt', async () => { + const first = await SymmetricKey.fromPassword('password', { + salt: 'stable-salt', + }); + const second = await SymmetricKey.fromPassword('password', { + salt: 'stable-salt', + }); + + expect(first.isEqual(second)).toBeTrue(); + expect(first.getBuffer()).toHaveLength(32); + }); + + it('should derive different keys when the salt changes', async () => { + const first = await SymmetricKey.fromPassword('password', { + salt: 'first-salt', + }); + const second = await SymmetricKey.fromPassword('password', { + salt: 'second-salt', + }); + + expect(first.isEqual(second)).toBeFalse(); + }); + + it('should accept custom scrypt options and Buffer salt', async () => { + const key = await SymmetricKey.fromPassword( + new StringValueObject('password'), + { + N: 16, + p: 1, + r: 1, + salt: Buffer.from('buffer-salt'), + }, + ); + + expect(key.getBuffer()).toHaveLength(32); + }); + + it('should reject empty derivation salts', async () => { + await expect( + SymmetricKey.fromPassword('password', { salt: '' }), + ).rejects.toThrow(InvalidLengthError); + }); + }); + + describe('encrypt and decrypt', () => { + it('should encrypt and decrypt string payloads', () => { + const key = new SymmetricKey(keyBase64); + const encrypted = key.encrypt('secret message'); + const decrypted = key.decrypt(encrypted); + + expect(encrypted).toBeInstanceOf(SymmetricEncryptedPayload); + expect(encrypted).toBeInstanceOf(EncryptedPayload); + expect(encrypted.getScheme()).toBe('symmetric'); + expect(decrypted.toString()).toBe('secret message'); + }); + + it('should encrypt the same payload differently because the IV is random', () => { + const key = new SymmetricKey(keyBase64); + const first = key.encrypt('same payload'); + const second = key.encrypt('same payload'); + + expect(first.isEqual(second)).toBeFalse(); + expect(key.decrypt(first).toString()).toBe('same payload'); + expect(key.decrypt(second).toString()).toBe('same payload'); + }); + + it('should accept StringValueObject payloads', () => { + const key = new SymmetricKey(keyBase64); + const encrypted = key.encrypt(new StringValueObject('vo-payload')); + + expect(key.decrypt(encrypted).toString()).toBe('vo-payload'); + }); + + it('should accept Buffer payloads', () => { + const key = new SymmetricKey(keyBase64); + const payload = Buffer.from([0, 1, 2, 255]); + const encrypted = key.encrypt(payload); + + expect(key.decrypt(encrypted)).toEqual(payload); + }); + + it('should accept raw Media bytes', () => { + const key = new SymmetricKey(keyBase64); + const payload = Buffer.from([0xff, 0xfe, 0xfd, 0x00, 0x80]); + const encrypted = key.encrypt(new Media(payload)); + + expect(key.decrypt(encrypted)).toEqual(payload); + }); + + it('should encrypt and decrypt empty payloads', () => { + const key = new SymmetricKey(keyBase64); + const encrypted = key.encrypt(''); + + expect(key.decrypt(encrypted)).toHaveLength(0); + }); + + it('should throw InvalidLengthError for oversized payloads', () => { + const key = new SymmetricKey(keyBase64); + + expect(() => key.encrypt(Buffer.alloc(1024 * 1024 + 1))).toThrow( + InvalidLengthError, + ); + }); + + it('should throw when decrypting with the wrong key', () => { + const key = new SymmetricKey(keyBase64); + const wrongKey = SymmetricKey.fromBuffer(Buffer.alloc(32, 8)); + const encrypted = key.encrypt('secret'); + + expect(() => wrongKey.decrypt(encrypted)).toThrow(); + }); + + it('should throw when the authentication tag is tampered', () => { + const key = new SymmetricKey(keyBase64); + const encrypted = key.encrypt('secret'); + const parts = encrypted.valueOf().split('.'); + parts[4] = Buffer.alloc(16, 1).toString('base64'); + + expect(() => key.decrypt(new SymmetricEncryptedPayload(parts.join('.')))).toThrow(); + }); + }); + + describe('encrypted payload validation', () => { + it('should throw InvalidFormatError for malformed payload part count', () => { + const key = new SymmetricKey(keyBase64); + + expect(() => key.decrypt(new EncryptedPayload('v1.aes-256-gcm.iv'))).toThrow( + InvalidFormatError, + ); + }); + + it('should throw InvalidFormatError for unsupported payload scheme', () => { + const key = new SymmetricKey(keyBase64); + const payload = [ + 'v2', + 'aes-256-gcm', + Buffer.alloc(12).toString('base64'), + '', + Buffer.alloc(16).toString('base64'), + ].join('.'); + + expect(() => key.decrypt(new EncryptedPayload(payload))).toThrow( + InvalidFormatError, + ); + }); + + it('should throw InvalidFormatError for malformed IV base64', () => { + const key = new SymmetricKey(keyBase64); + const payload = [ + 'v1', + 'aes-256-gcm', + '*', + '', + Buffer.alloc(16).toString('base64'), + ].join('.'); + + expect(() => key.decrypt(new EncryptedPayload(payload))).toThrow( + InvalidFormatError, + ); + }); + + it('should throw InvalidFormatError for wrong IV length', () => { + const key = new SymmetricKey(keyBase64); + const payload = [ + 'v1', + 'aes-256-gcm', + Buffer.alloc(11).toString('base64'), + '', + Buffer.alloc(16).toString('base64'), + ].join('.'); + + expect(() => key.decrypt(new EncryptedPayload(payload))).toThrow( + InvalidFormatError, + ); + }); + + it('should throw InvalidFormatError for malformed ciphertext base64', () => { + const key = new SymmetricKey(keyBase64); + const payload = [ + 'v1', + 'aes-256-gcm', + Buffer.alloc(12).toString('base64'), + '*', + Buffer.alloc(16).toString('base64'), + ].join('.'); + + expect(() => key.decrypt(new EncryptedPayload(payload))).toThrow( + InvalidFormatError, + ); + }); + + it('should throw InvalidLengthError for oversized ciphertext', () => { + const key = new SymmetricKey(keyBase64); + const oversizedCipher = 'A'.repeat( + Math.ceil((1024 * 1024 + 1) / 3) * 4, + ); + const payload = [ + 'v1', + 'aes-256-gcm', + Buffer.alloc(12).toString('base64'), + oversizedCipher, + Buffer.alloc(16).toString('base64'), + ].join('.'); + + expect(() => key.decrypt(new EncryptedPayload(payload))).toThrow( + InvalidLengthError, + ); + }); + + it('should throw InvalidFormatError for wrong tag length', () => { + const key = new SymmetricKey(keyBase64); + const payload = [ + 'v1', + 'aes-256-gcm', + Buffer.alloc(12).toString('base64'), + '', + Buffer.alloc(15).toString('base64'), + ].join('.'); + + expect(() => key.decrypt(new EncryptedPayload(payload))).toThrow( + InvalidFormatError, + ); + }); + }); +}); diff --git a/tests/value-objects/media/Media.spec.ts b/tests/value-objects/media/Media.spec.ts index e7b81f3..c03a68b 100644 --- a/tests/value-objects/media/Media.spec.ts +++ b/tests/value-objects/media/Media.spec.ts @@ -87,6 +87,13 @@ describe('Media', () => { expect(media.isEqual(Buffer.from([0xfe]))).toBeFalse(); }); + it('should delegate non-media and non-buffer comparisons to ValueObject', () => { + const media = new Media(testContent); + + expect(media.isEqual(testContent)).toBeTrue(); + expect(media.isEqual('different')).toBeFalse(); + }); + it('should keep distinct binary payloads in unique collections', () => { const medias = UniqueObjectArray.fromArray([ new Media(Buffer.from([0xff])),