diff --git a/README.md b/README.md index 501c5a6..d8855e2 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ your application. ### 🔤 Basic - **`StringValueObject`** - Strings with length validation +- **`Password`** - Password strings with length and complexity validation - **`NumberValueObject`** - Numbers with math operations - **`Integer`** - Whole numbers - **`PositiveNumber`** - Positive numbers (> 0) @@ -94,7 +95,7 @@ your application. - **`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 +- **`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 @@ -103,28 +104,34 @@ Technical notes: - 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 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. + 256-bit AES key with HKDF-SHA256 and encrypts the payload with AES-256-GCM. +- The current asymmetric payload format is + `v2.x25519-hkdf-sha256-aes-256-gcm.ephemeralPublicKey.iv.cipherText.tag`, + with Base64-encoded key, IV, ciphertext, and tag fields. The previous + `ephemeralPublicKey.iv.cipherText.tag` format still decrypts for backward + compatibility. +- The v2 asymmetric header is authenticated as AES-GCM AAD. HKDF uses + `ephemeralPublicKey || recipientPublicKey` as salt and + `@haskou/value-objects/asymmetric-payload/v2` as domain-separated info. - 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. +- `SymmetricKey.generate()` creates a random 256-bit key. + `SymmetricKey.fromPasswordUsingOwasp()` derives a 256-bit key with the OWASP + scrypt profile used by this package for new password-derived keys. + `SymmetricKey.fromPassword()` keeps its original defaults for backward + compatibility with existing encrypted data. - 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: - `scrypt(N=16384,r=8,p=1)` with a 16-byte salt, then AES-256-GCM with a - 12-byte IV and 16-byte authentication tag. +- New encrypted private keys use `v3.scrypt.N16384.r8.p5` with a 16-byte salt, + then AES-256-GCM with a 12-byte IV and 16-byte authentication tag. The older + v2 scrypt profile and legacy PBKDF2 format still decrypt, and + `needsReEncryption()` marks them for upgrade. The v3 header is authenticated + as AES-GCM AAD. - This is not a post-quantum cryptography scheme. Ed25519 and X25519 are classical elliptic-curve primitives, so neither signatures nor payload key agreement are post-quantum secure. @@ -194,8 +201,9 @@ 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 = await encrypted.sign('message', 'my-password'); +const cryptoPassword = new Password('Secure-password-123!'); +const encrypted = await keyPair.encryptKeyPair(cryptoPassword); +const sig = await encrypted.sign('message', cryptoPassword); encrypted.isValidSignature('message', sig); // true // Symmetric payload encryption @@ -205,9 +213,10 @@ 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', -}); +const derivedKey = await SymmetricKey.fromPasswordUsingOwasp( + cryptoPassword, + { salt: 'stable-application-salt' }, +); // Media const media = new Media('hello world'); diff --git a/TECHNICAL_DOCUMENTATION.md b/TECHNICAL_DOCUMENTATION.md index 28a4180..9693b65 100644 --- a/TECHNICAL_DOCUMENTATION.md +++ b/TECHNICAL_DOCUMENTATION.md @@ -7,6 +7,7 @@ Comprehensive technical documentation for the Value Objects library. - [API Documentation](#api-documentation) - [Base Classes](#base-classes) - [String Value Objects](#string-value-objects) + - [Password Value Objects](#password-value-objects) - [Number Value Objects](#number-value-objects) - [Integer Value Objects](#integer-value-objects) - [PositiveNumber Value Objects](#positivenumber-value-objects) @@ -85,6 +86,29 @@ try { } ``` +### Password Value Objects + +#### Password + +Represents immutable password strings with basic length and complexity validation. A `Password` must be between 12 and 256 characters long and include at least one uppercase letter, one lowercase letter, one number, and one symbol. This value object validates password shape only; it does not hash, store, or encrypt passwords by itself. + +```typescript +class Password extends StringValueObject { + constructor(value: string | StringValueObject); +} +``` + +**Example:** +```typescript +const password = new Password('Secure-password-123!'); + +try { + new Password('weak-password'); // Throws InvalidPasswordError +} catch (error) { + console.error('Invalid password'); +} +``` + ### Number Value Objects #### NumberValueObject @@ -645,7 +669,7 @@ try { ### Cryptography Value Objects -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. +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 expanded with HKDF-SHA256 to derive a 256-bit AES key, and the payload is encrypted with AES-256-GCM. The current asymmetric format authenticates its version and algorithm header as AES-GCM AAD, and uses HKDF salt/info values for domain separation. 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 @@ -707,7 +731,7 @@ console.log(valid); // true // Encrypt a payload for the corresponding private key const encrypted = publicKey.encrypt('sensitive data'); -console.log(encrypted.valueOf()); // 'ephemeralPub.iv.cipherText.tag' (base64, dot-separated) +console.log(encrypted.valueOf()); // 'v2.x25519-hkdf-sha256-aes-256-gcm.ephemeralPub.iv.cipherText.tag' ``` #### Signature @@ -750,7 +774,7 @@ class KeyPair { public isValidSignature(payload: CryptoPayload, signature: Signature): boolean; public encrypt(payload: CryptoPayload): EncryptedPayload; public decrypt(encryptedPayload: EncryptedPayload): Buffer; - public encryptKeyPair(password: string | StringValueObject): Promise; + public encryptKeyPair(password: CryptoPassword): Promise; public toPrimitives(): { publicKey: string; privateKey: string }; } ``` @@ -773,13 +797,14 @@ console.log(decrypted.toString()); // 'secret data' const primitives = keyPair.toPrimitives(); const restored = KeyPair.fromPrimitives(primitives); -// Encrypt the private key with a password -const encryptedKeyPair = await keyPair.encryptKeyPair('strong-password'); +// Encrypt the private key with a validated password +const password = new Password('Secure-password-123!'); +const encryptedKeyPair = await keyPair.encryptKeyPair(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). +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). New encrypted payloads authenticate the `v1.aes-256-gcm` header as AES-GCM AAD; decrypting without AAD remains supported only for payloads produced by older package versions. ```typescript type SymmetricKeyDerivationOptions = { @@ -789,18 +814,28 @@ type SymmetricKeyDerivationOptions = { salt: string | StringValueObject | Buffer; }; +type CryptoPassword = string | StringValueObject | Password; + +type SymmetricKeyCryptOptions = { + aad?: 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, + password: CryptoPassword, options: SymmetricKeyDerivationOptions, ): Promise; + public static fromPasswordUsingOwasp( + password: CryptoPassword, + options: Pick, + ): Promise; constructor(value: string | StringValueObject); public getBuffer(): Buffer; - public encrypt(payload: CryptoPayload): SymmetricEncryptedPayload; - public decrypt(encryptedPayload: EncryptedPayload): Buffer; + public encrypt(payload: CryptoPayload, options?: SymmetricKeyCryptOptions): SymmetricEncryptedPayload; + public decrypt(encryptedPayload: EncryptedPayload, options?: SymmetricKeyCryptOptions): Buffer; } ``` @@ -812,27 +847,28 @@ 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', { +// OWASP-aligned deterministic key derivation from password + explicit salt +const password = new Password('Secure-password-123!'); +const derived = await SymmetricKey.fromPasswordUsingOwasp(password, { salt: 'application-specific-salt', }); // The same password, salt, and scrypt parameters derive the same key -const sameDerived = await SymmetricKey.fromPassword('strong-password', { +const sameDerived = await SymmetricKey.fromPasswordUsingOwasp(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`. +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 password-based derivation; it is not embedded in `SymmetricEncryptedPayload`. `fromPassword()` keeps its original scrypt defaults for backward compatibility. `fromPasswordUsingOwasp()` uses this package's OWASP-aligned scrypt profile for new password-derived keys. #### 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. +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. -The encrypted format is: `v2.scrypt.N16384.r8.p1.salt.iv.tag.cipherText` (base64-encoded, dot-separated). Legacy format: `cipherText.iv.salt.tag`. +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`. -The v2 implementation derives a `SymmetricKey` from the password, salt, and +The v3 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 @@ -842,10 +878,10 @@ private key later. class EncryptedPrivateKey extends ValueObject { public static async create( privateKey: PrivateKey, - password: string | StringValueObject, + password: CryptoPassword, ): Promise; constructor(encryptedPrivateKey: string | StringValueObject); - public decrypt(password: string | StringValueObject): Promise; + public decrypt(password: CryptoPassword): Promise; public needsReEncryption(): boolean; } ``` @@ -853,22 +889,23 @@ class EncryptedPrivateKey extends ValueObject { **Example:** ```typescript // Encrypt a private key -const encrypted = await EncryptedPrivateKey.create(privateKey, 'my-password'); -console.log(encrypted.valueOf()); // 'v2.scrypt.N16384.r8.p1.base64Salt.base64IV.base64Tag.base64CipherText' +const password = new Password('Secure-password-123!'); +const encrypted = await EncryptedPrivateKey.create(privateKey, password); +console.log(encrypted.valueOf()); // 'v3.scrypt.N16384.r8.p5.base64Salt.base64IV.base64Tag.base64CipherText' // Decrypt it back -const decrypted = await encrypted.decrypt('my-password'); +const decrypted = await encrypted.decrypt(password); console.log(decrypted.valueOf()); // Original PEM private key // Check if re-encryption is needed if (encrypted.needsReEncryption()) { // Re-encrypt with stronger parameters - const reEncrypted = await EncryptedPrivateKey.create(decrypted, 'my-password'); + const reEncrypted = await EncryptedPrivateKey.create(decrypted, password); } // Wrong password throws an error try { - await encrypted.decrypt('wrong-password'); // Throws (AES-GCM authentication fails) + await encrypted.decrypt(new Password('Wrong-password-123!')); // Throws (AES-GCM authentication fails) } catch (error) { console.error('Invalid password'); } @@ -883,14 +920,14 @@ class EncryptedKeyPair { public static async encryptKeyPair( publicKey: PublicKey, privateKey: PrivateKey, - password: string | StringValueObject, + password: CryptoPassword, ): Promise; public static fromPrimitives(primitives: PrimitiveOf): EncryptedKeyPair; constructor(publicKey: PublicKey, encryptedPrivateKey: EncryptedPrivateKey); - public sign(payload: CryptoPayload, password: string | StringValueObject): Promise; + public sign(payload: CryptoPayload, password: CryptoPassword): Promise; public isValidSignature(payload: CryptoPayload, signature: Signature): boolean; public encrypt(payload: CryptoPayload): EncryptedPayload; - public decrypt(encryptedPayload: EncryptedPayload, password: string | StringValueObject): Promise; + public decrypt(encryptedPayload: EncryptedPayload, password: CryptoPassword): Promise; public toPrimitives(): { publicKey: string; encryptedPrivateKey: string }; } ``` @@ -899,17 +936,18 @@ class EncryptedKeyPair { ```typescript // Create from a KeyPair const keyPair = await KeyPair.generate(); -const encrypted = await keyPair.encryptKeyPair('password'); +const password = new Password('Secure-password-123!'); +const encrypted = await keyPair.encryptKeyPair(password); // Encrypt a payload (no password needed — uses public key) const cipherPayload = encrypted.encrypt('confidential data'); // Decrypt (requires the password to unlock the private key internally) -const plaintext = await encrypted.decrypt(cipherPayload, 'password'); +const plaintext = await encrypted.decrypt(cipherPayload, password); console.log(plaintext.toString()); // 'confidential data' // Sign (requires the password) -const sig = await encrypted.sign('message', 'password'); +const sig = await encrypted.sign('message', password); // Verify (no password needed) console.log(encrypted.isValidSignature('message', sig)); // true @@ -941,16 +979,25 @@ class SymmetricEncryptedPayload extends EncryptedPayload { `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()`. +Recognized payload shapes: +- Asymmetric v2: `v2.x25519-hkdf-sha256-aes-256-gcm.ephemeralPub.iv.cipherText.tag` +- Asymmetric legacy: `ephemeralPub.iv.cipherText.tag` +- Symmetric v1: `v1.aes-256-gcm.iv.cipherText.tag` + +For current asymmetric v2 and symmetric v1 payloads, the version and algorithm +header is authenticated as AES-GCM AAD. The legacy asymmetric shape and +pre-AAD symmetric payloads still decrypt for backward compatibility. + #### Encrypting and Decrypting Payloads 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 +2. A new ephemeral X25519 key pair is generated for each encryption 3. X25519 produces a shared secret -4. SHA-256 over the shared secret and ephemeral public key derives a 256-bit AES key -5. AES-256-GCM encrypts the payload with a 12-byte random IV and 16-byte authentication tag -6. The output is `ephemeralPub.iv.cipherText.tag` with Base64-encoded fields +4. HKDF-SHA256 over the shared secret derives a 256-bit AES key, using `ephemeralPublicKey || recipientPublicKey` as salt and `@haskou/value-objects/asymmetric-payload/v2` as domain-separated info +5. AES-256-GCM encrypts the payload with a 12-byte random IV, 16-byte authentication tag, and `v2.x25519-hkdf-sha256-aes-256-gcm` as AAD +6. The output is `v2.x25519-hkdf-sha256-aes-256-gcm.ephemeralPub.iv.cipherText.tag` with Base64-encoded key, IV, ciphertext, and tag fields 7. Decryption requires the corresponding Ed25519 private key material converted to X25519 **With KeyPair (plaintext private key available):** @@ -967,13 +1014,14 @@ console.log(decrypted.toString()); // 'sensitive data' **With EncryptedKeyPair (private key protected by password):** ```typescript -const encryptedKeyPair = await keyPair.encryptKeyPair('strong-password'); +const password = new Password('Secure-password-123!'); +const encryptedKeyPair = await keyPair.encryptKeyPair(password); // Encrypt (no password needed) const encrypted = encryptedKeyPair.encrypt('confidential'); // Decrypt (password unlocks the private key internally) -const decrypted = await encryptedKeyPair.decrypt(encrypted, 'strong-password'); +const decrypted = await encryptedKeyPair.decrypt(encrypted, password); console.log(decrypted.toString()); // 'confidential' ``` @@ -989,8 +1037,11 @@ console.log(decrypted.toString()); // 'hello world' **Important:** - Each call to `encrypt()` generates a new ephemeral key, producing different ciphertext even for the same payload. +- Each call to `encrypt()` also generates a fresh 12-byte AES-GCM IV; the 16-byte tag authenticates the ciphertext and current header. +- HKDF uses payload-specific salt and info values so the asymmetric payload key derivation is domain separated from other derivation contexts. - Decrypting with the wrong private key throws an AES-GCM authentication error. -- The asymmetric encrypted format is `ephemeralPub.iv.cipherText.tag` (base64, dot-separated). +- The current asymmetric encrypted format is `v2.x25519-hkdf-sha256-aes-256-gcm.ephemeralPub.iv.cipherText.tag` (base64 data fields, dot-separated). +- The previous `ephemeralPub.iv.cipherText.tag` format still decrypts for backward compatibility. - 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 @@ -1003,7 +1054,7 @@ console.log(decrypted.toString()); // 'hello world' 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 +3. AES-256-GCM encrypts the payload, authenticates `v1.aes-256-gcm` as AAD by default, 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 @@ -1017,7 +1068,8 @@ console.log(decrypted.toString()); // 'secret data' **Password-derived key:** ```typescript -const key = await SymmetricKey.fromPassword('strong-password', { +const password = new Password('Secure-password-123!'); +const key = await SymmetricKey.fromPasswordUsingOwasp(password, { salt: 'application-specific-salt', }); @@ -1026,11 +1078,20 @@ const decrypted = key.decrypt(encrypted); console.log(decrypted.toString()); // 'secret data' ``` +**Custom AAD:** +```typescript +const encrypted = key.encrypt('secret data', { aad: 'orders.v1' }); +const decrypted = key.decrypt(encrypted, { aad: 'orders.v1' }); +``` + **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()`. +- `SymmetricKey.fromPassword()` keeps the original scrypt defaults (`N=16384`, `r=8`, `p=1`) for backward compatibility. Custom scrypt parameters are accepted through the same method. +- `SymmetricKey.fromPasswordUsingOwasp()` uses the package's OWASP-aligned scrypt profile (`N=16384`, `r=8`, `p=5`) for new password-derived symmetric keys. - 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. +- New symmetric payloads authenticate their version and algorithm header as AAD. If a caller supplies custom AAD, the same AAD is required for decryption. +- `decrypt()` falls back to no-AAD decryption only when no custom AAD is supplied, so symmetric payloads created before header AAD support remain readable. - 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. diff --git a/src/errors/InvalidEncryptedPrivateKeyFormatError.ts b/src/errors/InvalidEncryptedPrivateKeyFormatError.ts new file mode 100644 index 0000000..9bdce92 --- /dev/null +++ b/src/errors/InvalidEncryptedPrivateKeyFormatError.ts @@ -0,0 +1,7 @@ +import { DomainError } from './DomainError'; + +export class InvalidEncryptedPrivateKeyFormatError extends DomainError { + constructor(reason = 'Invalid encrypted private key format') { + super(reason); + } +} diff --git a/src/errors/InvalidPasswordError.ts b/src/errors/InvalidPasswordError.ts new file mode 100644 index 0000000..a67b871 --- /dev/null +++ b/src/errors/InvalidPasswordError.ts @@ -0,0 +1,9 @@ +import { DomainError } from './DomainError'; + +export class InvalidPasswordError extends DomainError { + constructor(minLength: number, maxLength: number) { + super( + `Password must be between ${minLength} and ${maxLength} characters long and include at least one uppercase letter, one lowercase letter, one number and one symbol.`, + ); + } +} diff --git a/src/errors/index.ts b/src/errors/index.ts index b473c76..c571036 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -5,6 +5,7 @@ export * from './InvalidDayError'; export * from './InvalidDayFormatError'; export * from './InvalidHourError'; export * from './InvalidEmailError'; +export * from './InvalidEncryptedPrivateKeyFormatError'; export * from './InvalidFormatError'; export * from './InvalidHashError'; export * from './InvalidIntegerError'; @@ -13,6 +14,7 @@ export * from './InvalidLongitudeError'; export * from './InvalidEmailError'; export * from './InvalidMinutesError'; export * from './InvalidNumberError'; +export * from './InvalidPasswordError'; export * from './InvalidPositiveNumberError'; export * from './InvalidStringLengthError'; export * from './InvalidTimestampIntervalError'; diff --git a/src/patterns/Assert.ts b/src/patterns/Assert.ts index f2189a5..343acea 100644 --- a/src/patterns/Assert.ts +++ b/src/patterns/Assert.ts @@ -6,7 +6,7 @@ export type Errors = Error | BaseError | DomainError | string; export function assert( condition: boolean | unknown | undefined, error: string | Errors, -): void { +): asserts condition { if (condition) { return; } diff --git a/src/value-objects/Password.ts b/src/value-objects/Password.ts new file mode 100644 index 0000000..95417a7 --- /dev/null +++ b/src/value-objects/Password.ts @@ -0,0 +1,33 @@ +import { InvalidPasswordError } from '../errors/InvalidPasswordError'; +import { assert } from '../patterns'; +import { NullObject } from './NullObject'; +import { StringValueObject } from './StringValueObject'; + +export class Password extends StringValueObject { + private static readonly MAX_LENGTH = 256; + private static readonly MIN_LENGTH = 12; + + constructor(value: string | StringValueObject) { + super(value); + + if (NullObject.isNullObject(this)) { + return this; + } + + assert( + this.isValidPassword(), + new InvalidPasswordError(Password.MIN_LENGTH, Password.MAX_LENGTH), + ); + } + + private isValidPassword(): boolean { + return ( + this.value.length >= Password.MIN_LENGTH && + this.value.length <= Password.MAX_LENGTH && + /[A-Z]/.test(this.value) && + /[a-z]/.test(this.value) && + /[0-9]/.test(this.value) && + /[^A-Za-z0-9]/.test(this.value) + ); + } +} diff --git a/src/value-objects/crypto/CryptoAdapter.ts b/src/value-objects/crypto/CryptoAdapter.ts index bf16d19..0a74a1e 100644 --- a/src/value-objects/crypto/CryptoAdapter.ts +++ b/src/value-objects/crypto/CryptoAdapter.ts @@ -1,5 +1,6 @@ import { gcm } from '@noble/ciphers/aes.js'; import { ed25519, x25519 } from '@noble/curves/ed25519.js'; +import { hkdf } from '@noble/hashes/hkdf.js'; import { md5 } from '@noble/hashes/legacy.js'; import { sha256, sha512 } from '@noble/hashes/sha2.js'; import { @@ -13,6 +14,9 @@ import { Buffer } from 'buffer'; const privateKeyDerPrefix = hexToBytes('302e020100300506032b657004220420'); const publicKeyDerPrefix = hexToBytes('302a300506032b6570032100'); const gcmTagLength = 16; +const asymmetricPayloadV2Info = Buffer.from( + '@haskou/value-objects/asymmetric-payload/v2', +); type HashAlgorithm = 'md5' | 'sha256' | 'sha512'; @@ -38,8 +42,9 @@ export class CryptoAdapter { iv: Uint8Array, cipherText: Uint8Array, tag: Uint8Array, + aad?: Uint8Array, ): Buffer { - return Buffer.from(gcm(key, iv).decrypt(concatBytes(cipherText, tag))); + return Buffer.from(gcm(key, iv, aad).decrypt(concatBytes(cipherText, tag))); } public static deriveEncryptionKey( @@ -49,12 +54,27 @@ export class CryptoAdapter { return sha256(concatBytes(sharedSecret, ephemeralPublicKey)); } + public static deriveEncryptionKeyWithHkdf( + sharedSecret: Uint8Array, + ephemeralPublicKey: Uint8Array, + recipientPublicKey: Uint8Array, + ): Uint8Array { + return hkdf( + sha256, + sharedSecret, + concatBytes(ephemeralPublicKey, recipientPublicKey), + asymmetricPayloadV2Info, + 32, + ); + } + public static encryptAes256Gcm( key: Uint8Array, iv: Uint8Array, message: Uint8Array, + aad?: Uint8Array, ): { cipherText: Uint8Array; tag: Uint8Array } { - const encrypted = gcm(key, iv).encrypt(message); + const encrypted = gcm(key, iv, aad).encrypt(message); return { cipherText: encrypted.subarray(0, -gcmTagLength), diff --git a/src/value-objects/crypto/EncryptedKeyPair.ts b/src/value-objects/crypto/EncryptedKeyPair.ts index 6c7ef7d..4f9a999 100644 --- a/src/value-objects/crypto/EncryptedKeyPair.ts +++ b/src/value-objects/crypto/EncryptedKeyPair.ts @@ -1,19 +1,19 @@ import type { Buffer } from 'buffer'; import { PrimitiveOf } from '../../interfaces'; -import { StringValueObject } from '../StringValueObject'; import { CryptoPayload } from './CryptoPayload'; import { EncryptedPayload } from './EncryptedPayload'; import { EncryptedPrivateKey } from './EncryptedPrivateKey'; import { PrivateKey } from './PrivateKey'; import { PublicKey } from './PublicKey'; import { Signature } from './Signature'; +import { CryptoPassword } from './SymmetricKey'; export class EncryptedKeyPair { public static async encryptKeyPair( publicKey: PublicKey, privateKey: PrivateKey, - password: string | StringValueObject, + password: CryptoPassword, ): Promise { const encryptedPrivateKey = await EncryptedPrivateKey.create( privateKey, @@ -46,7 +46,7 @@ export class EncryptedKeyPair { public async sign( payload: CryptoPayload, - password: string | StringValueObject, + password: CryptoPassword, ): Promise { const privateKey = await this.encryptedPrivateKey.decrypt(password); @@ -66,7 +66,7 @@ export class EncryptedKeyPair { public async decrypt( encryptedPayload: EncryptedPayload, - password: string | StringValueObject, + password: CryptoPassword, ): Promise { const privateKey = await this.encryptedPrivateKey.decrypt(password); diff --git a/src/value-objects/crypto/EncryptedPayload.ts b/src/value-objects/crypto/EncryptedPayload.ts index 982ab08..5db22cb 100644 --- a/src/value-objects/crypto/EncryptedPayload.ts +++ b/src/value-objects/crypto/EncryptedPayload.ts @@ -10,6 +10,14 @@ export class EncryptedPayload extends ValueObject { return 'asymmetric'; } + if ( + parts.length === 6 && + parts[0] === 'v2' && + parts[1] === 'x25519-hkdf-sha256-aes-256-gcm' + ) { + return 'asymmetric'; + } + if (parts.length === 5 && parts[0] === 'v1' && parts[1] === 'aes-256-gcm') { return 'symmetric'; } diff --git a/src/value-objects/crypto/EncryptedPrivateKey.ts b/src/value-objects/crypto/EncryptedPrivateKey.ts index f9d2ef3..7dbdb0e 100644 --- a/src/value-objects/crypto/EncryptedPrivateKey.ts +++ b/src/value-objects/crypto/EncryptedPrivateKey.ts @@ -1,20 +1,25 @@ +import { InvalidEncryptedPrivateKeyFormatError } from '../../errors/InvalidEncryptedPrivateKeyFormatError'; +import { assert } from '../../patterns'; import { StringValueObject } from '../StringValueObject'; import { ValueObject } from '../ValueObject'; import { EncryptedPrivateKeyLegacy } from './encrypted-private-key/EncryptedPrivateKeyLegacy'; import { EncryptedPrivateKeyV2 } from './encrypted-private-key/EncryptedPrivateKeyV2'; +import { EncryptedPrivateKeyV3 } from './encrypted-private-key/EncryptedPrivateKeyV3'; import { PrivateKey } from './PrivateKey'; +import { CryptoPassword } from './SymmetricKey'; export class EncryptedPrivateKey extends ValueObject { private static readonly versions = [ new EncryptedPrivateKeyLegacy(), new EncryptedPrivateKeyV2(), + new EncryptedPrivateKeyV3(), ]; public static async create( privateKey: PrivateKey, - password: string | StringValueObject, + password: CryptoPassword, ): Promise { - const encryptedPrivateKey = await EncryptedPrivateKeyV2.encrypt( + const encryptedPrivateKey = await EncryptedPrivateKeyV3.encrypt( privateKey, password, ); @@ -26,17 +31,13 @@ export class EncryptedPrivateKey extends ValueObject { super(encryptedPrivateKey?.valueOf()); } - public async decrypt( - password: string | StringValueObject, - ): Promise { + public async decrypt(password: CryptoPassword): Promise { const parts = this.valueOf().split('.'); const version = EncryptedPrivateKey.versions.find((handler) => handler.matches(parts), ); - if (!version) { - throw new Error('Invalid encrypted private key format'); - } + assert(version, new InvalidEncryptedPrivateKeyFormatError()); return version.decrypt(parts, password); } diff --git a/src/value-objects/crypto/KeyPair.ts b/src/value-objects/crypto/KeyPair.ts index ad7299e..4913d4d 100644 --- a/src/value-objects/crypto/KeyPair.ts +++ b/src/value-objects/crypto/KeyPair.ts @@ -1,13 +1,13 @@ import type { Buffer } from 'buffer'; import { PrimitiveOf } from '../../interfaces'; -import { StringValueObject } from '../StringValueObject'; import { CryptoPayload } from './CryptoPayload'; import { EncryptedKeyPair } from './EncryptedKeyPair'; import { EncryptedPayload } from './EncryptedPayload'; import { PrivateKey } from './PrivateKey'; import { PublicKey } from './PublicKey'; import { Signature } from './Signature'; +import { CryptoPassword } from './SymmetricKey'; export class KeyPair { public static generate(): Promise { @@ -30,12 +30,12 @@ export class KeyPair { ) {} public async encryptKeyPair( - password: string | StringValueObject, + password: CryptoPassword, ): Promise { return await EncryptedKeyPair.encryptKeyPair( this.publicKey, this.privateKey, - password.valueOf(), + password, ); } diff --git a/src/value-objects/crypto/PrivateKey.ts b/src/value-objects/crypto/PrivateKey.ts index 3a1c5b0..fd25353 100644 --- a/src/value-objects/crypto/PrivateKey.ts +++ b/src/value-objects/crypto/PrivateKey.ts @@ -12,36 +12,32 @@ import { EncryptedPayload } from './EncryptedPayload'; import { Key } from './Key'; import { PublicKey } from './PublicKey'; import { Signature } from './Signature'; +import { StrictBase64 } from './StrictBase64'; export class PrivateKey extends Key { private static readonly LENGTH = 119; - private static readonly ENCRYPTED_PAYLOAD_PARTS = 4; + private static readonly LEGACY_ENCRYPTED_PAYLOAD_PARTS = 4; + private static readonly OWASP_ENCRYPTED_PAYLOAD_PARTS = 6; 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 PAYLOAD_ALGORITHM = 'x25519-hkdf-sha256-aes-256-gcm'; + + private static readonly PAYLOAD_VERSION = 'v2'; 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 }, + options: { allowEmpty: boolean }, ): void { - assert( - (options.allowEmpty || value.length > 0) && - value.length % 4 === 0 && - PrivateKey.BASE64_PATTERN.test(value), + StrictBase64.ensure( + value, new InvalidFormatError(encryptedPayload.valueOf()), + options, ); } @@ -50,10 +46,10 @@ export class PrivateKey extends Key { encryptedPayload: EncryptedPayload, length: number, ): void { - PrivateKey.ensureIsBase64(value, encryptedPayload); - assert( - PrivateKey.getBase64DecodedLength(value) === length, + StrictBase64.ensureDecodedLength( + value, new InvalidFormatError(encryptedPayload.valueOf()), + length, ); } @@ -61,9 +57,16 @@ export class PrivateKey extends Key { value: string, encryptedPayload: EncryptedPayload, ): Buffer { - PrivateKey.ensureIsBase64(value, encryptedPayload); + return StrictBase64.decode( + value, + new InvalidFormatError(encryptedPayload.valueOf()), + ); + } - return Buffer.from(value, 'base64'); + private static getPayloadAad(): Buffer { + return Buffer.from( + [PrivateKey.PAYLOAD_VERSION, PrivateKey.PAYLOAD_ALGORITHM].join('.'), + ); } public static fromPEM(pem: string | StringValueObject): PrivateKey { @@ -92,32 +95,18 @@ export class PrivateKey extends Key { assert(PrivateKey.PATTERN.test(value), new InvalidFormatError(value)); } - public getPublicKey(): PublicKey { - return PublicKey.fromPEM(CryptoAdapter.getPublicKey(this.valueOf())); - } - - public sign(payload: CryptoPayload): Signature { - const messageBuffer = - payload instanceof Media - ? payload.getBuffer() - : Buffer.from(payload.valueOf()); - const signatureBuffer = CryptoAdapter.sign(messageBuffer, this.valueOf()); - - return Signature.fromBuffer(signatureBuffer); - } - - public decrypt(encryptedPayload: EncryptedPayload): Buffer { - const parts = encryptedPayload.valueOf().split('.'); - assert( - parts.length === PrivateKey.ENCRYPTED_PAYLOAD_PARTS, - new InvalidFormatError(encryptedPayload.valueOf()), - ); - - const [ephPubB64, ivB64, cipherTextB64, tagB64] = parts; + private decryptPayload( + encryptedPayload: EncryptedPayload, + ephPubB64: string, + ivB64: string, + cipherTextB64: string, + tagB64: string, + options: { useHkdf: boolean; aad?: Buffer }, + ): Buffer { PrivateKey.ensureIsBase64(cipherTextB64, encryptedPayload, { allowEmpty: true, }); - const cipherTextLength = PrivateKey.getBase64DecodedLength(cipherTextB64); + const cipherTextLength = StrictBase64.getDecodedLength(cipherTextB64); assert( cipherTextLength <= PrivateKey.MAX_CIPHERTEXT_LENGTH, new InvalidLengthError( @@ -144,7 +133,11 @@ export class PrivateKey extends Key { const ephemeralPub = PrivateKey.decodeBase64(ephPubB64, encryptedPayload); const iv = PrivateKey.decodeBase64(ivB64, encryptedPayload); - const cipherText = Buffer.from(cipherTextB64, 'base64'); + const cipherText = StrictBase64.decode( + cipherTextB64, + new InvalidFormatError(encryptedPayload.valueOf()), + { allowEmpty: true }, + ); const tag = PrivateKey.decodeBase64(tagB64, encryptedPayload); const x25519Priv = CryptoAdapter.privateKeyToX25519(this.valueOf()); @@ -153,11 +146,75 @@ export class PrivateKey extends Key { ephemeralPub, ); - const aesKey = CryptoAdapter.deriveEncryptionKey( - sharedSecret, - ephemeralPub, + const aesKey = options.useHkdf + ? CryptoAdapter.deriveEncryptionKeyWithHkdf( + sharedSecret, + ephemeralPub, + CryptoAdapter.x25519PublicKey(x25519Priv), + ) + : CryptoAdapter.deriveEncryptionKey(sharedSecret, ephemeralPub); + + return CryptoAdapter.decryptAes256Gcm( + aesKey, + iv, + cipherText, + tag, + options.aad, + ); + } + + public getPublicKey(): PublicKey { + return PublicKey.fromPEM(CryptoAdapter.getPublicKey(this.valueOf())); + } + + public sign(payload: CryptoPayload): Signature { + const messageBuffer = + payload instanceof Media + ? payload.getBuffer() + : Buffer.from(payload.valueOf()); + const signatureBuffer = CryptoAdapter.sign(messageBuffer, this.valueOf()); + + return Signature.fromBuffer(signatureBuffer); + } + + public decrypt(encryptedPayload: EncryptedPayload): Buffer { + const parts = encryptedPayload.valueOf().split('.'); + + if (parts.length === PrivateKey.LEGACY_ENCRYPTED_PAYLOAD_PARTS) { + const [ephPubB64, ivB64, cipherTextB64, tagB64] = parts; + + return this.decryptPayload( + encryptedPayload, + ephPubB64, + ivB64, + cipherTextB64, + tagB64, + { useHkdf: false }, + ); + } + + assert( + parts.length === PrivateKey.OWASP_ENCRYPTED_PAYLOAD_PARTS, + new InvalidFormatError(encryptedPayload.valueOf()), ); - return CryptoAdapter.decryptAes256Gcm(aesKey, iv, cipherText, tag); + const [version, algorithm, ephPubB64, ivB64, cipherTextB64, tagB64] = parts; + assert( + version === PrivateKey.PAYLOAD_VERSION && + algorithm === PrivateKey.PAYLOAD_ALGORITHM, + new InvalidFormatError(encryptedPayload.valueOf()), + ); + + return this.decryptPayload( + encryptedPayload, + ephPubB64, + ivB64, + cipherTextB64, + tagB64, + { + aad: PrivateKey.getPayloadAad(), + useHkdf: true, + }, + ); } } diff --git a/src/value-objects/crypto/PublicKey.ts b/src/value-objects/crypto/PublicKey.ts index 341ddcc..4d1f1e8 100644 --- a/src/value-objects/crypto/PublicKey.ts +++ b/src/value-objects/crypto/PublicKey.ts @@ -15,9 +15,19 @@ 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 PAYLOAD_ALGORITHM = 'x25519-hkdf-sha256-aes-256-gcm'; + + private static readonly PAYLOAD_VERSION = 'v2'; + private static readonly PATTERN = /^-----BEGIN PUBLIC KEY-----\n[A-Za-z0-9+/=]+\n-----END PUBLIC KEY-----\n$/; + private static getPayloadAad(): Buffer { + return Buffer.from( + [PublicKey.PAYLOAD_VERSION, PublicKey.PAYLOAD_ALGORITHM].join('.'), + ); + } + public static fromPEM(pem: string | StringValueObject): PublicKey { return new PublicKey(pem.valueOf()); } @@ -81,9 +91,10 @@ export class PublicKey extends Key { x25519Pub, ); - const aesKey = CryptoAdapter.deriveEncryptionKey( + const aesKey = CryptoAdapter.deriveEncryptionKeyWithHkdf( sharedSecret, ephemeralPub, + x25519Pub, ); const iv = CryptoAdapter.randomBytes(12); @@ -91,9 +102,12 @@ export class PublicKey extends Key { aesKey, iv, messageBuffer, + PublicKey.getPayloadAad(), ); const result = [ + PublicKey.PAYLOAD_VERSION, + PublicKey.PAYLOAD_ALGORITHM, Buffer.from(ephemeralPub).toString('base64'), iv.toString('base64'), Buffer.from(cipherText).toString('base64'), diff --git a/src/value-objects/crypto/StrictBase64.ts b/src/value-objects/crypto/StrictBase64.ts new file mode 100644 index 0000000..0dece97 --- /dev/null +++ b/src/value-objects/crypto/StrictBase64.ts @@ -0,0 +1,73 @@ +import { Buffer } from 'buffer'; + +import { assert } from '../../patterns'; + +type StrictBase64Options = { + allowEmpty?: boolean; +}; + +export class StrictBase64 { + private static readonly PATTERN = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + + public static getDecodedLength(value: string): number { + const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + + return (value.length / 4) * 3 - padding; + } + + public static ensure( + value: string, + error: Error, + options: StrictBase64Options = {}, + ): void { + assert( + (options.allowEmpty === true || value.length > 0) && + value.length % 4 === 0 && + StrictBase64.PATTERN.test(value), + error, + ); + } + + public static ensureDecodedLength( + value: string, + error: Error, + length: number, + options: StrictBase64Options = {}, + ): void { + StrictBase64.ensure(value, error, options); + assert(StrictBase64.getDecodedLength(value) === length, error); + } + + public static decode( + value: string, + error: Error, + options: StrictBase64Options = {}, + ): Buffer { + StrictBase64.ensure(value, error, options); + + return Buffer.from(value, 'base64'); + } + + public static decodeFixedLength( + value: string, + error: Error, + length: number, + ): Buffer { + StrictBase64.ensureDecodedLength(value, error, length); + + return Buffer.from(value, 'base64'); + } + + public static decodeCanonicalFixedLength( + value: string, + error: Error, + length: number, + ): Buffer { + const decoded = StrictBase64.decodeFixedLength(value, error, length); + + assert(decoded.toString('base64') === value, error); + + return decoded; + } +} diff --git a/src/value-objects/crypto/SymmetricKey.ts b/src/value-objects/crypto/SymmetricKey.ts index 6a40ca4..229feb3 100644 --- a/src/value-objects/crypto/SymmetricKey.ts +++ b/src/value-objects/crypto/SymmetricKey.ts @@ -5,12 +5,14 @@ import { InvalidLengthError } from '../../errors/InvalidLengthError'; import { assert } from '../../patterns'; import { Media } from '../media'; import { NullObject } from '../NullObject'; +import { Password } from '../Password'; 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 { StrictBase64 } from './StrictBase64'; import { SymmetricEncryptedPayload } from './SymmetricEncryptedPayload'; export type SymmetricKeyDerivationOptions = { @@ -20,6 +22,12 @@ export type SymmetricKeyDerivationOptions = { salt: string | StringValueObject | Buffer; }; +export type CryptoPassword = string | StringValueObject | Password; + +export type SymmetricKeyCryptOptions = { + aad?: string | StringValueObject | Buffer; +}; + export class SymmetricKey extends ValueObject { private static readonly ALGORITHM = 'aes-256-gcm'; private static readonly IV_LENGTH = 12; @@ -29,27 +37,21 @@ export class SymmetricKey extends ValueObject { private static readonly SCRYPT_N = 16384; private static readonly SCRYPT_P = 1; private static readonly SCRYPT_R = 8; + private static readonly OWASP_SCRYPT_N = 16384; + private static readonly OWASP_SCRYPT_P = 5; + private static readonly OWASP_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 }, + options: { allowEmpty: boolean }, ): void { - assert( - (options.allowEmpty || value.length > 0) && - value.length % 4 === 0 && - SymmetricKey.BASE64_PATTERN.test(value), + StrictBase64.ensure( + value, new InvalidFormatError(encryptedPayload.valueOf()), + options, ); } @@ -58,10 +60,10 @@ export class SymmetricKey extends ValueObject { encryptedPayload: EncryptedPayload, length: number, ): void { - SymmetricKey.ensureIsBase64(value, encryptedPayload); - assert( - SymmetricKey.getBase64DecodedLength(value) === length, + StrictBase64.ensureDecodedLength( + value, new InvalidFormatError(encryptedPayload.valueOf()), + length, ); } @@ -81,6 +83,18 @@ export class SymmetricKey extends ValueObject { : Buffer.from(payload.valueOf()); } + private static getAadBytes(aad: string | StringValueObject | Buffer): Buffer { + if (Buffer.isBuffer(aad)) { + return aad; + } + + return Buffer.from(aad.valueOf()); + } + + private static getDefaultAad(version: string, algorithm: string): Buffer { + return Buffer.from([version, algorithm].join('.')); + } + public static fromBase64(key: string | StringValueObject): SymmetricKey { return new SymmetricKey(key.valueOf()); } @@ -101,7 +115,7 @@ export class SymmetricKey extends ValueObject { } public static async fromPassword( - password: string | StringValueObject, + password: CryptoPassword, options: SymmetricKeyDerivationOptions, ): Promise { const salt = SymmetricKey.getSaltBytes(options.salt); @@ -121,6 +135,18 @@ export class SymmetricKey extends ValueObject { return SymmetricKey.fromBuffer(Buffer.from(key)); } + public static async fromPasswordUsingOwasp( + password: CryptoPassword, + options: Pick, + ): Promise { + return SymmetricKey.fromPassword(password, { + N: SymmetricKey.OWASP_SCRYPT_N, + p: SymmetricKey.OWASP_SCRYPT_P, + r: SymmetricKey.OWASP_SCRYPT_R, + salt: options.salt, + }); + } + constructor(value: string | StringValueObject) { super(value?.valueOf()); @@ -132,16 +158,11 @@ export class SymmetricKey extends ValueObject { } private ensureIsValidKey(value: string): void { + StrictBase64.ensure(value, new InvalidFormatError(value)); + const decodedLength = StrictBase64.getDecodedLength(value); 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, - ), + decodedLength === SymmetricKey.KEY_LENGTH, + new InvalidLengthError(decodedLength, SymmetricKey.KEY_LENGTH), ); } @@ -149,7 +170,10 @@ export class SymmetricKey extends ValueObject { return Buffer.from(this.valueOf(), 'base64'); } - public encrypt(payload: CryptoPayload): SymmetricEncryptedPayload { + public encrypt( + payload: CryptoPayload, + options: SymmetricKeyCryptOptions = {}, + ): SymmetricEncryptedPayload { const messageBuffer = SymmetricKey.getPayloadBytes(payload); assert( messageBuffer.length <= SymmetricKey.MAX_PAYLOAD_LENGTH, @@ -160,10 +184,18 @@ export class SymmetricKey extends ValueObject { ); const iv = CryptoAdapter.randomBytes(SymmetricKey.IV_LENGTH); + const aad = + options.aad === undefined + ? SymmetricKey.getDefaultAad( + SymmetricKey.VERSION, + SymmetricKey.ALGORITHM, + ) + : SymmetricKey.getAadBytes(options.aad); const { cipherText, tag } = CryptoAdapter.encryptAes256Gcm( this.getBuffer(), iv, messageBuffer, + aad, ); const result = [ @@ -177,7 +209,10 @@ export class SymmetricKey extends ValueObject { return new SymmetricEncryptedPayload(result); } - public decrypt(encryptedPayload: EncryptedPayload): Buffer { + public decrypt( + encryptedPayload: EncryptedPayload, + options: SymmetricKeyCryptOptions = {}, + ): Buffer { const parts = encryptedPayload.valueOf().split('.'); assert( parts.length === SymmetricKey.PAYLOAD_PARTS, @@ -198,7 +233,7 @@ export class SymmetricKey extends ValueObject { SymmetricKey.ensureIsBase64(cipherTextB64, encryptedPayload, { allowEmpty: true, }); - const cipherTextLength = SymmetricKey.getBase64DecodedLength(cipherTextB64); + const cipherTextLength = StrictBase64.getDecodedLength(cipherTextB64); assert( cipherTextLength <= SymmetricKey.MAX_PAYLOAD_LENGTH, new InvalidLengthError(cipherTextLength, SymmetricKey.MAX_PAYLOAD_LENGTH), @@ -209,11 +244,26 @@ export class SymmetricKey extends ValueObject { SymmetricKey.TAG_LENGTH, ); - return CryptoAdapter.decryptAes256Gcm( - this.getBuffer(), - Buffer.from(ivB64, 'base64'), - Buffer.from(cipherTextB64, 'base64'), - Buffer.from(tagB64, 'base64'), - ); + const key = this.getBuffer(); + const formatError = new InvalidFormatError(encryptedPayload.valueOf()); + const iv = StrictBase64.decode(ivB64, formatError); + const cipherText = StrictBase64.decode(cipherTextB64, formatError, { + allowEmpty: true, + }); + const tag = StrictBase64.decode(tagB64, formatError); + const aad = + options.aad === undefined + ? SymmetricKey.getDefaultAad(version, algorithm) + : SymmetricKey.getAadBytes(options.aad); + + try { + return CryptoAdapter.decryptAes256Gcm(key, iv, cipherText, tag, aad); + } catch (error) { + if (options.aad !== undefined) { + throw error; + } + + return CryptoAdapter.decryptAes256Gcm(key, iv, cipherText, tag); + } } } diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyLegacy.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyLegacy.ts index 3278d02..13ace94 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyLegacy.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyLegacy.ts @@ -1,8 +1,8 @@ import { Buffer } from 'buffer'; -import { StringValueObject } from '../../StringValueObject'; import { CryptoAdapter } from '../CryptoAdapter'; import { PrivateKey } from '../PrivateKey'; +import { CryptoPassword } from '../SymmetricKey'; import { CryptoDerivation } from './CryptoDerivation'; import { EncryptedPrivateKeyVersion } from './EncryptedPrivateKeyVersion'; @@ -19,7 +19,7 @@ export class EncryptedPrivateKeyLegacy extends EncryptedPrivateKeyVersion { public async decrypt( parts: string[], - password: string | StringValueObject, + password: CryptoPassword, ): Promise { const [cipherTextB64, ivB64, saltB64, tagB64] = parts; const cipherText = Buffer.from(cipherTextB64, 'base64'); diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts index 954e047..7bfb246 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts @@ -1,9 +1,10 @@ import { Buffer } from 'buffer'; -import { StringValueObject } from '../../StringValueObject'; +import { InvalidEncryptedPrivateKeyFormatError } from '../../../errors/InvalidEncryptedPrivateKeyFormatError'; +import { assert } from '../../../patterns'; import { PrivateKey } from '../PrivateKey'; import { SymmetricEncryptedPayload } from '../SymmetricEncryptedPayload'; -import { SymmetricKey } from '../SymmetricKey'; +import { CryptoPassword, SymmetricKey } from '../SymmetricKey'; import { CryptoDerivation } from './CryptoDerivation'; import { EncryptedPrivateKeyVersion } from './EncryptedPrivateKeyVersion'; @@ -27,7 +28,7 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { } private static async deriveSymmetricKey( - password: string | StringValueObject, + password: CryptoPassword, salt: Buffer, options: { N: number; p: number; r: number }, ): Promise { @@ -54,7 +55,7 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { public static async encrypt( privateKey: PrivateKey, - password: string | StringValueObject, + password: CryptoPassword, ): Promise { const salt = await CryptoDerivation.randomBytesAsync( EncryptedPrivateKeyV2.SALT_ENTROPY, @@ -96,11 +97,14 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { public async decrypt( parts: string[], - password: string | StringValueObject, + password: CryptoPassword, ): Promise { - if (!EncryptedPrivateKeyV2.hasSupportedScryptParameters(parts)) { - throw new Error('Unsupported encrypted private key parameters'); - } + assert( + EncryptedPrivateKeyV2.hasSupportedScryptParameters(parts), + new InvalidEncryptedPrivateKeyFormatError( + 'Unsupported encrypted private key parameters', + ), + ); const N = parseInt(parts[2].slice(1), 10); const r = parseInt(parts[3].slice(1), 10); @@ -118,4 +122,8 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { return new PrivateKey(decrypted.toString()); } + + public needsReEncryption(): boolean { + return true; + } } diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts new file mode 100644 index 0000000..9786019 --- /dev/null +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts @@ -0,0 +1,151 @@ +import { Buffer } from 'buffer'; + +import { InvalidEncryptedPrivateKeyFormatError } from '../../../errors/InvalidEncryptedPrivateKeyFormatError'; +import { assert } from '../../../patterns'; +import { PrivateKey } from '../PrivateKey'; +import { StrictBase64 } from '../StrictBase64'; +import { SymmetricEncryptedPayload } from '../SymmetricEncryptedPayload'; +import { CryptoPassword, SymmetricKey } from '../SymmetricKey'; +import { CryptoDerivation } from './CryptoDerivation'; +import { EncryptedPrivateKeyVersion } from './EncryptedPrivateKeyVersion'; + +export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { + private static readonly VERSION = 'v3'; + private static readonly KDF = 'scrypt'; + private static readonly SCRYPT_N = 16384; + private static readonly SCRYPT_R = 8; + private static readonly SCRYPT_P = 5; + private static readonly SALT_ENTROPY = 16; + 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 ( + parts[2] === `N${EncryptedPrivateKeyV3.SCRYPT_N}` && + parts[3] === `r${EncryptedPrivateKeyV3.SCRYPT_R}` && + parts[4] === `p${EncryptedPrivateKeyV3.SCRYPT_P}` + ); + } + + private static getHeaderParts(): string[] { + return [ + EncryptedPrivateKeyV3.VERSION, + EncryptedPrivateKeyV3.KDF, + `N${EncryptedPrivateKeyV3.SCRYPT_N}`, + `r${EncryptedPrivateKeyV3.SCRYPT_R}`, + `p${EncryptedPrivateKeyV3.SCRYPT_P}`, + ]; + } + + private static getAad(parts: string[]): string { + return parts.slice(0, 5).join('.'); + } + + private static ensureSaltIsValid(saltB64: string): Buffer { + return StrictBase64.decodeCanonicalFixedLength( + saltB64, + new InvalidEncryptedPrivateKeyFormatError( + 'Invalid encrypted private key salt', + ), + EncryptedPrivateKeyV3.SALT_ENTROPY, + ); + } + + private static async deriveSymmetricKey( + password: CryptoPassword, + 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( + [ + EncryptedPrivateKeyV3.SYMMETRIC_PAYLOAD_VERSION, + EncryptedPrivateKeyV3.CIPHER, + iv, + cipherText, + tag, + ].join('.'), + ); + } + + public static async encrypt( + privateKey: PrivateKey, + password: CryptoPassword, + ): Promise { + const salt = await CryptoDerivation.randomBytesAsync( + EncryptedPrivateKeyV3.SALT_ENTROPY, + ); + const key = await EncryptedPrivateKeyV3.deriveSymmetricKey(password, salt, { + N: EncryptedPrivateKeyV3.SCRYPT_N, + p: EncryptedPrivateKeyV3.SCRYPT_P, + r: EncryptedPrivateKeyV3.SCRYPT_R, + }); + const symmetricPayload = key + .encrypt(privateKey.valueOf(), { + aad: EncryptedPrivateKeyV3.getHeaderParts().join('.'), + }) + .valueOf() + .split('.'); + const iv = symmetricPayload[2]; + const cipherText = symmetricPayload[3]; + const tag = symmetricPayload[4]; + + return [ + ...EncryptedPrivateKeyV3.getHeaderParts(), + salt.toString('base64'), + iv, + tag, + cipherText, + ].join('.'); + } + + public matches(parts: string[]): boolean { + return ( + parts.length === EncryptedPrivateKeyV3.EXPECTED_PARTS && + parts[0] === EncryptedPrivateKeyV3.VERSION && + parts[1] === EncryptedPrivateKeyV3.KDF && + EncryptedPrivateKeyV3.hasSupportedScryptParameters(parts) + ); + } + + public async decrypt( + parts: string[], + password: CryptoPassword, + ): Promise { + assert( + EncryptedPrivateKeyV3.hasSupportedScryptParameters(parts), + new InvalidEncryptedPrivateKeyFormatError( + 'Unsupported encrypted private key parameters', + ), + ); + + const N = parseInt(parts[2].slice(1), 10); + const r = parseInt(parts[3].slice(1), 10); + const p = parseInt(parts[4].slice(1), 10); + const salt = EncryptedPrivateKeyV3.ensureSaltIsValid(parts[5]); + const key = await EncryptedPrivateKeyV3.deriveSymmetricKey(password, salt, { + N, + p, + r, + }); + + const decrypted = key.decrypt( + EncryptedPrivateKeyV3.toSymmetricPayload(parts), + { + aad: EncryptedPrivateKeyV3.getAad(parts), + }, + ); + + return new PrivateKey(decrypted.toString()); + } +} diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyVersion.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyVersion.ts index 0b0d108..3b7bec8 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyVersion.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyVersion.ts @@ -1,11 +1,11 @@ -import { StringValueObject } from '../../StringValueObject'; import { PrivateKey } from '../PrivateKey'; +import { CryptoPassword } from '../SymmetricKey'; export abstract class EncryptedPrivateKeyVersion { public abstract matches(parts: string[]): boolean; public abstract decrypt( parts: string[], - password: string | StringValueObject, + password: CryptoPassword, ): Promise; public needsReEncryption(): boolean { diff --git a/src/value-objects/index.ts b/src/value-objects/index.ts index 9f3d9c5..74ec54c 100644 --- a/src/value-objects/index.ts +++ b/src/value-objects/index.ts @@ -9,6 +9,7 @@ export * from './Email'; export * from './Enum'; export * from './Integer'; export * from './NumberValueObject'; +export * from './Password'; export * from './PositiveNumber'; export * from './StringValueObject'; export * from './NullObject'; diff --git a/src/value-objects/time/DayOfWeek.ts b/src/value-objects/time/DayOfWeek.ts index 38a7a0e..61c504e 100644 --- a/src/value-objects/time/DayOfWeek.ts +++ b/src/value-objects/time/DayOfWeek.ts @@ -1,4 +1,5 @@ import { InvalidDayError } from '../../errors/InvalidDayError'; +import { assert } from '../../patterns'; import { Enum } from '../Enum'; import { Timestamp } from './Timestamp'; @@ -37,9 +38,7 @@ export class DayOfWeek extends Enum { ([, value]) => value === day, ) || []; - if (!eDay) { - throw new InvalidDayError(eDay); - } + assert(eDay, new InvalidDayError(eDay)); return new DayOfWeek(eDay); } diff --git a/tests/value-objects/Password.spec.ts b/tests/value-objects/Password.spec.ts new file mode 100644 index 0000000..f5733f7 --- /dev/null +++ b/tests/value-objects/Password.spec.ts @@ -0,0 +1,71 @@ +import { Password, InvalidPasswordError, NullObject } from '../../src'; + +describe('Password', () => { + describe('constructor', () => { + it('should return a NullValueObject when receiving nullish', () => { + expect(() => new Password(undefined as unknown as string)).not.toThrow(); + expect( + NullObject.isNullObject(new Password(undefined as unknown as string)), + ).toBeTrue(); + expect( + NullObject.isNullObject(new Password(null as unknown as string)), + ).toBeTrue(); + }); + + it('should create a valid password with the required complexity', () => { + const validPassword = 'Valid-password1!'; + + const password = new Password(validPassword); + + expect(password.valueOf()).toBe(validPassword); + }); + + it('should create a valid password with exactly 12 characters', () => { + const exactPassword = 'Valid-pass1!'; + + const password = new Password(exactPassword); + + expect(password.valueOf()).toBe(exactPassword); + }); + + it('should throw InvalidPasswordError when password is shorter than 12 characters', () => { + const shortPassword = 'Short-1'; + + expect(() => new Password(shortPassword)).toThrow(InvalidPasswordError); + }); + + it('should throw InvalidPasswordError when password is longer than 256 characters', () => { + const longPassword = `A${'a'.repeat(254)}1!`; + + expect(() => new Password(longPassword)).toThrow(InvalidPasswordError); + }); + + it('should throw InvalidPasswordError when password has no uppercase letters', () => { + expect(() => new Password('valid-password1!')).toThrow( + InvalidPasswordError, + ); + }); + + it('should throw InvalidPasswordError when password has no lowercase letters', () => { + expect(() => new Password('VALID-PASSWORD1!')).toThrow( + InvalidPasswordError, + ); + }); + + it('should throw InvalidPasswordError when password has no numbers', () => { + expect(() => new Password('Valid-password!')).toThrow( + InvalidPasswordError, + ); + }); + + it('should throw InvalidPasswordError when password has no symbols', () => { + expect(() => new Password('Validpassword1')).toThrow( + InvalidPasswordError, + ); + }); + + it('should throw InvalidPasswordError when password is empty', () => { + expect(() => new Password('')).toThrow(InvalidPasswordError); + }); + }); +}); diff --git a/tests/value-objects/crypto/EncryptedPayload.spec.ts b/tests/value-objects/crypto/EncryptedPayload.spec.ts index 85c955a..d321088 100644 --- a/tests/value-objects/crypto/EncryptedPayload.spec.ts +++ b/tests/value-objects/crypto/EncryptedPayload.spec.ts @@ -27,6 +27,14 @@ describe('EncryptedPayload', () => { expect(payload.getScheme()).toBe('asymmetric'); }); + it('should identify versioned asymmetric payloads', () => { + const payload = new EncryptedPayload( + 'v2.x25519-hkdf-sha256-aes-256-gcm.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'); diff --git a/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts index c05648c..e420a92 100644 --- a/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts +++ b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts @@ -2,19 +2,23 @@ import * as crypto from 'node:crypto'; import { EncryptedPrivateKey, + InvalidEncryptedPrivateKeyFormatError, + Password, PrivateKey, StringValueObject, SymmetricEncryptedPayload, SymmetricKey, } from '../../../src'; +import { CryptoAdapter } from '../../../src/value-objects/crypto/CryptoAdapter'; import { CryptoDerivation } from '../../../src/value-objects/crypto/encrypted-private-key/CryptoDerivation'; import { EncryptedPrivateKeyV2 } from '../../../src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2'; +import { EncryptedPrivateKeyV3 } from '../../../src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3'; import { NullObject } from '../../../src/value-objects/NullObject'; describe('EncryptedPrivateKey', () => { let privatePem: string; let publicPem: string; - const password = 'secure-password-123'; + const password = new Password('Secure-password-123!'); beforeAll(() => { const pair = crypto.generateKeyPairSync('ed25519', { @@ -70,6 +74,7 @@ describe('EncryptedPrivateKey', () => { const parts = encrypted.valueOf().split('.'); expect(parts).toHaveLength(9); + expect(parts.slice(0, 5)).toEqual(['v3', 'scrypt', 'N16384', 'r8', 'p5']); }); it('should produce different ciphertexts each time due to random salt/iv', async () => { @@ -113,7 +118,9 @@ describe('EncryptedPrivateKey', () => { it('should fail to decrypt invalid format', async () => { const invalidEncrypted = new EncryptedPrivateKey('invalid.format'); - await expect(invalidEncrypted.decrypt(password)).toReject(); + await expect(invalidEncrypted.decrypt(password)).rejects.toThrow( + InvalidEncryptedPrivateKeyFormatError, + ); }); it('should reject tampered scrypt parameters before deriving a key', async () => { @@ -125,7 +132,7 @@ describe('EncryptedPrivateKey', () => { const scryptSpy = jest.spyOn(CryptoDerivation, 'scryptAsync'); await expect(tampered.decrypt(password)).rejects.toThrow( - 'Invalid encrypted private key format', + InvalidEncryptedPrivateKeyFormatError, ); expect(scryptSpy).not.toHaveBeenCalled(); @@ -141,7 +148,7 @@ describe('EncryptedPrivateKey', () => { const scryptSpy = jest.spyOn(CryptoDerivation, 'scryptAsync'); await expect(tampered.decrypt(password)).rejects.toThrow( - 'Invalid encrypted private key format', + InvalidEncryptedPrivateKeyFormatError, ); expect(scryptSpy).not.toHaveBeenCalled(); @@ -150,19 +157,19 @@ describe('EncryptedPrivateKey', () => { 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('.'); + const encrypted = await EncryptedPrivateKeyV2.encrypt(privateKey, password); + const parts = encrypted.split('.'); parts[4] = 'p2'; await expect( new EncryptedPrivateKeyV2().decrypt(parts, password), - ).rejects.toThrow('Unsupported encrypted private key parameters'); + ).rejects.toThrow(InvalidEncryptedPrivateKeyFormatError); }); 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 encrypted = await EncryptedPrivateKeyV2.encrypt(privateKey, password); + const parts = encrypted.split('.'); const key = await SymmetricKey.fromPassword(password, { N: 16384, p: 1, @@ -176,6 +183,122 @@ describe('EncryptedPrivateKey', () => { expect(key.decrypt(symmetricPayload).toString()).toBe(privatePem); }); + it('should decrypt v2 encrypted private keys for backward compatibility', async () => { + const privateKey = new PrivateKey(privatePem); + const encrypted = await EncryptedPrivateKeyV2.encrypt(privateKey, password); + const decrypted = await new EncryptedPrivateKey(encrypted).decrypt( + password, + ); + + expect(decrypted.valueOf()).toBe(privatePem); + }); + + it('should decrypt v2 encrypted private keys created without AAD', async () => { + const salt = Buffer.alloc(16, 3); + const iv = Buffer.alloc(12, 4); + const key = await SymmetricKey.fromPassword(password, { + N: 16384, + p: 1, + r: 8, + salt, + }); + const { cipherText, tag } = CryptoAdapter.encryptAes256Gcm( + key.getBuffer(), + iv, + Buffer.from(privatePem), + ); + const encrypted = [ + 'v2', + 'scrypt', + 'N16384', + 'r8', + 'p1', + salt.toString('base64'), + iv.toString('base64'), + Buffer.from(tag).toString('base64'), + Buffer.from(cipherText).toString('base64'), + ].join('.'); + + const decrypted = await new EncryptedPrivateKey(encrypted).decrypt( + password, + ); + + expect(decrypted.valueOf()).toBe(privatePem); + }); + + it('should keep v3 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: 5, + 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, { aad: parts.slice(0, 5).join('.') }) + .toString(), + ).toBe(privatePem); + }); + + it('should reject unsupported v3 parameters inside the v3 decryptor', async () => { + const privateKey = new PrivateKey(privatePem); + const encrypted = await EncryptedPrivateKey.create(privateKey, password); + const parts = encrypted.valueOf().split('.'); + parts[4] = 'p1'; + + await expect( + new EncryptedPrivateKeyV3().decrypt(parts, password), + ).rejects.toThrow(InvalidEncryptedPrivateKeyFormatError); + }); + + it('should reject invalid v3 salt base64 before deriving a key', async () => { + const privateKey = new PrivateKey(privatePem); + const encrypted = await EncryptedPrivateKey.create(privateKey, password); + const parts = encrypted.valueOf().split('.'); + parts[5] = 'not-base64!'; + const scryptSpy = jest.spyOn(CryptoDerivation, 'scryptAsync'); + + await expect( + new EncryptedPrivateKeyV3().decrypt(parts, password), + ).rejects.toThrow(InvalidEncryptedPrivateKeyFormatError); + + expect(scryptSpy).not.toHaveBeenCalled(); + scryptSpy.mockRestore(); + }); + + it('should reject v3 salts that are not exactly 16 bytes before deriving a key', async () => { + const privateKey = new PrivateKey(privatePem); + const encrypted = await EncryptedPrivateKey.create(privateKey, password); + const parts = encrypted.valueOf().split('.'); + parts[5] = Buffer.alloc(15).toString('base64'); + const scryptSpy = jest.spyOn(CryptoDerivation, 'scryptAsync'); + + await expect( + new EncryptedPrivateKeyV3().decrypt(parts, password), + ).rejects.toThrow(InvalidEncryptedPrivateKeyFormatError); + + expect(scryptSpy).not.toHaveBeenCalled(); + scryptSpy.mockRestore(); + }); + + it('should authenticate v3 header fields with AAD', async () => { + const privateKey = new PrivateKey(privatePem); + const encrypted = await EncryptedPrivateKey.create(privateKey, password); + const parts = encrypted.valueOf().split('.'); + parts[1] = 'tampered-kdf'; + + await expect( + new EncryptedPrivateKeyV3().decrypt(parts, password), + ).toReject(); + }); + it('should decrypt to a functional PrivateKey that can sign', async () => { const privateKey = new PrivateKey(privatePem); const encrypted = await EncryptedPrivateKey.create(privateKey, password); @@ -214,6 +337,9 @@ describe('EncryptedPrivateKey', () => { // Test with legacy format const legacyEncrypted = new EncryptedPrivateKey('ciphertext.iv.salt.tag'); expect(legacyEncrypted.needsReEncryption()).toBeTrue(); + + const v2 = await EncryptedPrivateKeyV2.encrypt(privateKey, password); + expect(new EncryptedPrivateKey(v2).needsReEncryption()).toBeTrue(); }); it('should return false for an invalid format when checking re-encryption', () => { @@ -228,7 +354,7 @@ describe('EncryptedPrivateKey', () => { const iv = crypto.randomBytes(12); const key = await new Promise((resolve, reject) => { crypto.pbkdf2( - password, + password.valueOf(), salt, 100000, 32, diff --git a/tests/value-objects/crypto/PrivateKey.spec.ts b/tests/value-objects/crypto/PrivateKey.spec.ts index c058238..5c092af 100644 --- a/tests/value-objects/crypto/PrivateKey.spec.ts +++ b/tests/value-objects/crypto/PrivateKey.spec.ts @@ -11,6 +11,7 @@ import { Signature, StringValueObject, } from '../../../src'; +import { CryptoAdapter } from '../../../src/value-objects/crypto/CryptoAdapter'; import { NullObject } from '../../../src/value-objects/NullObject'; describe('PrivateKey', () => { @@ -180,6 +181,38 @@ describe('PrivateKey', () => { expect(decrypted).toHaveLength(0); }); + it('should decrypt legacy asymmetric payloads', () => { + const privKey = new PrivateKey(privatePem); + const message = Buffer.from('legacy secret'); + const recipientPub = CryptoAdapter.publicKeyToX25519(publicPem); + const ephemeralPriv = CryptoAdapter.x25519RandomPrivateKey(); + const ephemeralPub = CryptoAdapter.x25519PublicKey(ephemeralPriv); + const sharedSecret = CryptoAdapter.x25519SharedSecret( + ephemeralPriv, + recipientPub, + ); + const aesKey = CryptoAdapter.deriveEncryptionKey( + sharedSecret, + ephemeralPub, + ); + const iv = CryptoAdapter.randomBytes(12); + const { cipherText, tag } = CryptoAdapter.encryptAes256Gcm( + aesKey, + iv, + message, + ); + const legacyPayload = new EncryptedPayload( + [ + Buffer.from(ephemeralPub).toString('base64'), + iv.toString('base64'), + Buffer.from(cipherText).toString('base64'), + Buffer.from(tag).toString('base64'), + ].join('.'), + ); + + expect(privKey.decrypt(legacyPayload).toString()).toBe('legacy secret'); + }); + it('should fail to decrypt with the wrong private key', () => { const pubKey = new PublicKey(publicPem); const encrypted = pubKey.encrypt('secret'); @@ -193,6 +226,54 @@ describe('PrivateKey', () => { expect(() => wrongKey.decrypt(encrypted)).toThrow(); }); + it('should fail when the ephemeral key is tampered', () => { + const pubKey = new PublicKey(publicPem); + const privKey = new PrivateKey(privatePem); + const encrypted = pubKey.encrypt('secret'); + const parts = encrypted.valueOf().split('.'); + parts[2] = Buffer.alloc(32, 1).toString('base64'); + + expect(() => + privKey.decrypt(new EncryptedPayload(parts.join('.'))), + ).toThrow(); + }); + + it('should fail when the IV is tampered', () => { + const pubKey = new PublicKey(publicPem); + const privKey = new PrivateKey(privatePem); + const encrypted = pubKey.encrypt('secret'); + const parts = encrypted.valueOf().split('.'); + parts[3] = Buffer.alloc(12, 1).toString('base64'); + + expect(() => + privKey.decrypt(new EncryptedPayload(parts.join('.'))), + ).toThrow(); + }); + + it('should fail when the ciphertext is tampered', () => { + const pubKey = new PublicKey(publicPem); + const privKey = new PrivateKey(privatePem); + const encrypted = pubKey.encrypt('secret'); + const parts = encrypted.valueOf().split('.'); + parts[4] = Buffer.from('tampered').toString('base64'); + + expect(() => + privKey.decrypt(new EncryptedPayload(parts.join('.'))), + ).toThrow(); + }); + + it('should fail when the authentication tag is tampered', () => { + const pubKey = new PublicKey(publicPem); + const privKey = new PrivateKey(privatePem); + const encrypted = pubKey.encrypt('secret'); + const parts = encrypted.valueOf().split('.'); + parts[5] = Buffer.alloc(16, 1).toString('base64'); + + expect(() => + privKey.decrypt(new EncryptedPayload(parts.join('.'))), + ).toThrow(); + }); + it('should handle different payload sizes', () => { const pubKey = new PublicKey(publicPem); const privKey = new PrivateKey(privatePem); @@ -223,6 +304,22 @@ describe('PrivateKey', () => { expect(() => privKey.decrypt(malformed)).toThrow(InvalidFormatError); }); + it('should throw InvalidFormatError for unsupported versioned payload algorithm', () => { + const privKey = new PrivateKey(privatePem); + const malformed = new EncryptedPayload( + [ + 'v2', + 'x25519-sha256-aes-256-gcm', + Buffer.alloc(32).toString('base64'), + Buffer.alloc(12).toString('base64'), + '', + Buffer.alloc(16).toString('base64'), + ].join('.'), + ); + + 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=='); diff --git a/tests/value-objects/crypto/PublicKey.spec.ts b/tests/value-objects/crypto/PublicKey.spec.ts index 09120f2..46ad163 100644 --- a/tests/value-objects/crypto/PublicKey.spec.ts +++ b/tests/value-objects/crypto/PublicKey.spec.ts @@ -136,19 +136,29 @@ describe('PublicKey', () => { expect(encrypted.valueOf()).not.toBe('hello world'); }); - it('should produce a base64-encoded string', () => { + it('should produce a versioned OWASP-compatible payload format', () => { const key = new PublicKey(publicPem); const encrypted = key.encrypt('test data'); + const parts = encrypted.valueOf().split('.'); - expect(() => Buffer.from(encrypted.valueOf(), 'base64')).not.toThrow(); + expect(parts).toHaveLength(6); + expect(parts[0]).toBe('v2'); + expect(parts[1]).toBe('x25519-hkdf-sha256-aes-256-gcm'); + expect(Buffer.from(parts[2], 'base64')).toHaveLength(32); + expect(Buffer.from(parts[3], 'base64')).toHaveLength(12); + expect(Buffer.from(parts[5], 'base64')).toHaveLength(16); }); - it('should produce different ciphertext for the same payload (OAEP padding)', () => { + it('should produce different ciphertext for the same payload', () => { const key = new PublicKey(publicPem); const encrypted1 = key.encrypt('same payload'); const encrypted2 = key.encrypt('same payload'); + const firstParts = encrypted1.valueOf().split('.'); + const secondParts = encrypted2.valueOf().split('.'); expect(encrypted1.isEqual(encrypted2)).toBeFalse(); + expect(firstParts[2]).not.toBe(secondParts[2]); + expect(firstParts[3]).not.toBe(secondParts[3]); }); it('should accept a StringValueObject as payload', () => { diff --git a/tests/value-objects/crypto/SymmetricKey.spec.ts b/tests/value-objects/crypto/SymmetricKey.spec.ts index f2bed83..0995e55 100644 --- a/tests/value-objects/crypto/SymmetricKey.spec.ts +++ b/tests/value-objects/crypto/SymmetricKey.spec.ts @@ -4,10 +4,13 @@ import { InvalidLengthError, Media, NullObject, + Password, StringValueObject, SymmetricEncryptedPayload, SymmetricKey, } from '../../../src'; +import { CryptoAdapter } from '../../../src/value-objects/crypto/CryptoAdapter'; +import { CryptoDerivation } from '../../../src/value-objects/crypto/encrypted-private-key/CryptoDerivation'; describe('SymmetricKey', () => { const keyBytes = Buffer.alloc(32, 7); @@ -116,6 +119,51 @@ describe('SymmetricKey', () => { expect(key.getBuffer()).toHaveLength(32); }); + it('should keep legacy scrypt defaults for fromPassword', async () => { + const derivedKey = Buffer.alloc(32, 9); + const scryptSpy = jest + .spyOn(CryptoDerivation, 'scryptAsync') + .mockResolvedValue(derivedKey); + + const key = await SymmetricKey.fromPassword('password', { + salt: 'stable-salt', + }); + + expect(scryptSpy).toHaveBeenCalledWith( + 'password', + Buffer.from('stable-salt'), + 32, + { N: 16384, p: 1, r: 8 }, + ); + expect(key.getBuffer()).toEqual(derivedKey); + + scryptSpy.mockRestore(); + }); + + it('should derive with OWASP scrypt parameters when requested', async () => { + const derivedKey = Buffer.alloc(32, 10); + const scryptSpy = jest + .spyOn(CryptoDerivation, 'scryptAsync') + .mockResolvedValue(derivedKey); + + const key = await SymmetricKey.fromPasswordUsingOwasp( + new Password('Secure-password-123!'), + { + salt: 'stable-salt', + }, + ); + + expect(scryptSpy).toHaveBeenCalledWith( + 'Secure-password-123!', + Buffer.from('stable-salt'), + 32, + { N: 16384, p: 5, r: 8 }, + ); + expect(key.getBuffer()).toEqual(derivedKey); + + scryptSpy.mockRestore(); + }); + it('should reject empty derivation salts', async () => { await expect( SymmetricKey.fromPassword('password', { salt: '' }), @@ -141,6 +189,9 @@ describe('SymmetricKey', () => { const second = key.encrypt('same payload'); expect(first.isEqual(second)).toBeFalse(); + expect(first.valueOf().split('.')[2]).not.toBe( + second.valueOf().split('.')[2], + ); expect(key.decrypt(first).toString()).toBe('same payload'); expect(key.decrypt(second).toString()).toBe('same payload'); }); @@ -191,13 +242,76 @@ describe('SymmetricKey', () => { expect(() => wrongKey.decrypt(encrypted)).toThrow(); }); + it('should throw when the IV is tampered', () => { + const key = new SymmetricKey(keyBase64); + const encrypted = key.encrypt('secret'); + const parts = encrypted.valueOf().split('.'); + parts[2] = Buffer.alloc(12, 1).toString('base64'); + + expect(() => + key.decrypt(new SymmetricEncryptedPayload(parts.join('.'))), + ).toThrow(); + }); + + it('should throw when the ciphertext is tampered', () => { + const key = new SymmetricKey(keyBase64); + const encrypted = key.encrypt('secret'); + const parts = encrypted.valueOf().split('.'); + parts[3] = Buffer.from('tampered').toString('base64'); + + expect(() => + key.decrypt(new SymmetricEncryptedPayload(parts.join('.'))), + ).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(); + expect(() => + key.decrypt(new SymmetricEncryptedPayload(parts.join('.'))), + ).toThrow(); + }); + + it('should authenticate custom AAD', () => { + const key = new SymmetricKey(keyBase64); + const encrypted = key.encrypt('secret', { aad: 'domain.header' }); + + expect(key.decrypt(encrypted, { aad: 'domain.header' }).toString()).toBe( + 'secret', + ); + expect(() => key.decrypt(encrypted, { aad: 'other.header' })).toThrow(); + }); + + it('should authenticate Buffer AAD', () => { + const key = new SymmetricKey(keyBase64); + const aad = Buffer.from('domain.header'); + const encrypted = key.encrypt('secret', { aad }); + + expect(key.decrypt(encrypted, { aad }).toString()).toBe('secret'); + }); + + it('should decrypt legacy symmetric payloads without AAD', () => { + const key = new SymmetricKey(keyBase64); + const iv = Buffer.alloc(12, 2); + const { cipherText, tag } = CryptoAdapter.encryptAes256Gcm( + key.getBuffer(), + iv, + Buffer.from('legacy secret'), + ); + const legacyPayload = new SymmetricEncryptedPayload( + [ + 'v1', + 'aes-256-gcm', + iv.toString('base64'), + Buffer.from(cipherText).toString('base64'), + Buffer.from(tag).toString('base64'), + ].join('.'), + ); + + expect(key.decrypt(legacyPayload).toString()).toBe('legacy secret'); }); });