From f6fe8e452019eb8b9977d16e57ca8e1384b95b22 Mon Sep 17 00:00:00 2001 From: Hasko Date: Wed, 10 Jun 2026 16:54:39 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(crypto):=20=E2=9C=A8=20Add=20OWASP-com?= =?UTF-8?q?patible=20crypto=20formats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 +++-- TECHNICAL_DOCUMENTATION.md | 41 +++--- src/value-objects/crypto/CryptoAdapter.ts | 18 +++ src/value-objects/crypto/EncryptedPayload.ts | 8 ++ .../crypto/EncryptedPrivateKey.ts | 4 +- src/value-objects/crypto/PrivateKey.ts | 100 +++++++++++---- src/value-objects/crypto/PublicKey.ts | 9 +- src/value-objects/crypto/SymmetricKey.ts | 15 +++ .../EncryptedPrivateKeyV2.ts | 4 + .../EncryptedPrivateKeyV3.ts | 121 ++++++++++++++++++ .../crypto/EncryptedPayload.spec.ts | 8 ++ .../crypto/EncryptedPrivateKey.spec.ts | 49 ++++++- tests/value-objects/crypto/PrivateKey.spec.ts | 49 +++++++ tests/value-objects/crypto/PublicKey.spec.ts | 12 +- .../value-objects/crypto/SymmetricKey.spec.ts | 43 +++++++ 15 files changed, 447 insertions(+), 64 deletions(-) create mode 100644 src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts diff --git a/README.md b/README.md index 501c5a6..3eb96b8 100644 --- a/README.md +++ b/README.md @@ -103,28 +103,30 @@ 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. - 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. - 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. @@ -205,7 +207,7 @@ 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', { +const derivedKey = await SymmetricKey.fromPasswordUsingOwasp('my-password', { salt: 'stable-application-salt', }); diff --git a/TECHNICAL_DOCUMENTATION.md b/TECHNICAL_DOCUMENTATION.md index 28a4180..a41d902 100644 --- a/TECHNICAL_DOCUMENTATION.md +++ b/TECHNICAL_DOCUMENTATION.md @@ -645,7 +645,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. 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 +707,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 @@ -797,6 +797,10 @@ class SymmetricKey extends ValueObject { password: string | StringValueObject, options: SymmetricKeyDerivationOptions, ): Promise; + public static fromPasswordUsingOwasp( + password: string | StringValueObject, + options: Pick, + ): Promise; constructor(value: string | StringValueObject); public getBuffer(): Buffer; public encrypt(payload: CryptoPayload): SymmetricEncryptedPayload; @@ -812,27 +816,27 @@ 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-compatible deterministic key derivation from password + explicit salt +const derived = await SymmetricKey.fromPasswordUsingOwasp('strong-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('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`. +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-compatible 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 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 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 @@ -854,7 +858,7 @@ class EncryptedPrivateKey extends ValueObject { ```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' +console.log(encrypted.valueOf()); // 'v3.scrypt.N16384.r8.p5.base64Salt.base64IV.base64Tag.base64CipherText' // Decrypt it back const decrypted = await encrypted.decrypt('my-password'); @@ -941,6 +945,11 @@ 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` + #### Encrypting and Decrypting Payloads The public-key payload API uses a classical hybrid encryption approach: @@ -948,9 +957,9 @@ 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 3. X25519 produces a shared secret -4. SHA-256 over the shared secret and ephemeral public key derives a 256-bit AES key +4. HKDF-SHA256 over the shared secret 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 +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):** @@ -990,7 +999,8 @@ 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 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 @@ -1017,7 +1027,7 @@ console.log(decrypted.toString()); // 'secret data' **Password-derived key:** ```typescript -const key = await SymmetricKey.fromPassword('strong-password', { +const key = await SymmetricKey.fromPasswordUsingOwasp('strong-password', { salt: 'application-specific-salt', }); @@ -1029,7 +1039,8 @@ 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()`. +- `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-compatible 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. - 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. diff --git a/src/value-objects/crypto/CryptoAdapter.ts b/src/value-objects/crypto/CryptoAdapter.ts index bf16d19..479c2a0 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'; @@ -49,6 +53,20 @@ 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, 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..49d23e7 100644 --- a/src/value-objects/crypto/EncryptedPrivateKey.ts +++ b/src/value-objects/crypto/EncryptedPrivateKey.ts @@ -2,19 +2,21 @@ 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'; export class EncryptedPrivateKey extends ValueObject { private static readonly versions = [ new EncryptedPrivateKeyLegacy(), new EncryptedPrivateKeyV2(), + new EncryptedPrivateKeyV3(), ]; public static async create( privateKey: PrivateKey, password: string | StringValueObject, ): Promise { - const encryptedPrivateKey = await EncryptedPrivateKeyV2.encrypt( + const encryptedPrivateKey = await EncryptedPrivateKeyV3.encrypt( privateKey, password, ); diff --git a/src/value-objects/crypto/PrivateKey.ts b/src/value-objects/crypto/PrivateKey.ts index 3a1c5b0..001098f 100644 --- a/src/value-objects/crypto/PrivateKey.ts +++ b/src/value-objects/crypto/PrivateKey.ts @@ -15,11 +15,16 @@ import { Signature } from './Signature'; 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 PAYLOAD_ALGORITHM = 'x25519-hkdf-sha256-aes-256-gcm'; + + private static readonly PAYLOAD_VERSION = 'v2'; + private static readonly BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; @@ -92,28 +97,14 @@ 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, + useHkdf: boolean, + ): Buffer { PrivateKey.ensureIsBase64(cipherTextB64, encryptedPayload, { allowEmpty: true, }); @@ -153,11 +144,66 @@ export class PrivateKey extends Key { ephemeralPub, ); - const aesKey = CryptoAdapter.deriveEncryptionKey( - sharedSecret, - ephemeralPub, - ); + const aesKey = useHkdf + ? CryptoAdapter.deriveEncryptionKeyWithHkdf( + sharedSecret, + ephemeralPub, + CryptoAdapter.x25519PublicKey(x25519Priv), + ) + : CryptoAdapter.deriveEncryptionKey(sharedSecret, ephemeralPub); return CryptoAdapter.decryptAes256Gcm(aesKey, iv, cipherText, tag); } + + 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, + false, + ); + } + + assert( + parts.length === PrivateKey.OWASP_ENCRYPTED_PAYLOAD_PARTS, + new InvalidFormatError(encryptedPayload.valueOf()), + ); + + 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, + true, + ); + } } diff --git a/src/value-objects/crypto/PublicKey.ts b/src/value-objects/crypto/PublicKey.ts index 341ddcc..4422e9d 100644 --- a/src/value-objects/crypto/PublicKey.ts +++ b/src/value-objects/crypto/PublicKey.ts @@ -15,6 +15,10 @@ 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$/; @@ -81,9 +85,10 @@ export class PublicKey extends Key { x25519Pub, ); - const aesKey = CryptoAdapter.deriveEncryptionKey( + const aesKey = CryptoAdapter.deriveEncryptionKeyWithHkdf( sharedSecret, ephemeralPub, + x25519Pub, ); const iv = CryptoAdapter.randomBytes(12); @@ -94,6 +99,8 @@ export class PublicKey extends Key { ); 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/SymmetricKey.ts b/src/value-objects/crypto/SymmetricKey.ts index 6a40ca4..db349c8 100644 --- a/src/value-objects/crypto/SymmetricKey.ts +++ b/src/value-objects/crypto/SymmetricKey.ts @@ -29,6 +29,9 @@ 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 = @@ -121,6 +124,18 @@ export class SymmetricKey extends ValueObject { return SymmetricKey.fromBuffer(Buffer.from(key)); } + public static async fromPasswordUsingOwasp( + password: string | StringValueObject, + 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()); diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts index 954e047..e4da8b5 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts @@ -118,4 +118,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..8006f14 --- /dev/null +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts @@ -0,0 +1,121 @@ +import { Buffer } from 'buffer'; + +import { StringValueObject } from '../../StringValueObject'; +import { PrivateKey } from '../PrivateKey'; +import { SymmetricEncryptedPayload } from '../SymmetricEncryptedPayload'; +import { 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 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( + [ + EncryptedPrivateKeyV3.SYMMETRIC_PAYLOAD_VERSION, + EncryptedPrivateKeyV3.CIPHER, + iv, + cipherText, + tag, + ].join('.'), + ); + } + + public static async encrypt( + privateKey: PrivateKey, + password: string | StringValueObject, + ): 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()) + .valueOf() + .split('.'); + const iv = symmetricPayload[2]; + const cipherText = symmetricPayload[3]; + const tag = symmetricPayload[4]; + + return [ + EncryptedPrivateKeyV3.VERSION, + EncryptedPrivateKeyV3.KDF, + `N${EncryptedPrivateKeyV3.SCRYPT_N}`, + `r${EncryptedPrivateKeyV3.SCRYPT_R}`, + `p${EncryptedPrivateKeyV3.SCRYPT_P}`, + 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: string | StringValueObject, + ): Promise { + if (!EncryptedPrivateKeyV3.hasSupportedScryptParameters(parts)) { + throw new Error('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 = Buffer.from(parts[5], 'base64'); + const key = await EncryptedPrivateKeyV3.deriveSymmetricKey(password, salt, { + N, + p, + r, + }); + + const decrypted = key.decrypt( + EncryptedPrivateKeyV3.toSymmetricPayload(parts), + ); + + return new PrivateKey(decrypted.toString()); + } +} 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..51c1ed9 100644 --- a/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts +++ b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts @@ -9,6 +9,7 @@ import { } 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 { EncryptedPrivateKeyV3 } from '../../../src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3'; import { NullObject } from '../../../src/value-objects/NullObject'; describe('EncryptedPrivateKey', () => { @@ -70,6 +71,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 () => { @@ -150,8 +152,8 @@ 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( @@ -160,12 +162,39 @@ describe('EncryptedPrivateKey', () => { }); it('should keep v2 AES-GCM fields compatible with SymmetricKey', async () => { + const privateKey = new PrivateKey(privatePem); + const encrypted = await EncryptedPrivateKeyV2.encrypt(privateKey, password); + const parts = encrypted.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 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 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: 1, + p: 5, r: 8, salt: Buffer.from(parts[5], 'base64'), }); @@ -176,6 +205,17 @@ describe('EncryptedPrivateKey', () => { expect(key.decrypt(symmetricPayload).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('Unsupported encrypted private key parameters'); + }); + 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 +254,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', () => { diff --git a/tests/value-objects/crypto/PrivateKey.spec.ts b/tests/value-objects/crypto/PrivateKey.spec.ts index c058238..f04d13f 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'); @@ -223,6 +256,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..25e3167 100644 --- a/tests/value-objects/crypto/PublicKey.spec.ts +++ b/tests/value-objects/crypto/PublicKey.spec.ts @@ -136,14 +136,20 @@ 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'); diff --git a/tests/value-objects/crypto/SymmetricKey.spec.ts b/tests/value-objects/crypto/SymmetricKey.spec.ts index f2bed83..aef3a30 100644 --- a/tests/value-objects/crypto/SymmetricKey.spec.ts +++ b/tests/value-objects/crypto/SymmetricKey.spec.ts @@ -8,6 +8,7 @@ import { SymmetricEncryptedPayload, SymmetricKey, } from '../../../src'; +import { CryptoDerivation } from '../../../src/value-objects/crypto/encrypted-private-key/CryptoDerivation'; describe('SymmetricKey', () => { const keyBytes = Buffer.alloc(32, 7); @@ -116,6 +117,48 @@ 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('password', { + salt: 'stable-salt', + }); + + expect(scryptSpy).toHaveBeenCalledWith( + 'password', + 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: '' }), From 3258e065674b5205fd6fb45b4f04cd7c9dd69ed0 Mon Sep 17 00:00:00 2001 From: Hasko Date: Wed, 10 Jun 2026 17:25:31 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat(crypto):=20=E2=9C=A8=20Harden=20AES-GC?= =?UTF-8?q?M=20payload=20authentication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 21 ++-- TECHNICAL_DOCUMENTATION.md | 112 +++++++++++++----- src/errors/InvalidPasswordError.ts | 9 ++ src/errors/index.ts | 1 + src/value-objects/Password.ts | 28 +++++ src/value-objects/crypto/CryptoAdapter.ts | 6 +- src/value-objects/crypto/EncryptedKeyPair.ts | 8 +- .../crypto/EncryptedPrivateKey.ts | 7 +- src/value-objects/crypto/KeyPair.ts | 6 +- src/value-objects/crypto/PrivateKey.ts | 25 +++- src/value-objects/crypto/PublicKey.ts | 7 ++ src/value-objects/crypto/SymmetricKey.ts | 65 ++++++++-- .../EncryptedPrivateKeyLegacy.ts | 4 +- .../EncryptedPrivateKeyV2.ts | 9 +- .../EncryptedPrivateKeyV3.ts | 36 ++++-- .../EncryptedPrivateKeyVersion.ts | 4 +- src/value-objects/index.ts | 1 + tests/value-objects/Password.spec.ts | 61 ++++++++++ .../crypto/EncryptedPrivateKey.spec.ts | 56 ++++++++- tests/value-objects/crypto/PrivateKey.spec.ts | 48 ++++++++ tests/value-objects/crypto/PublicKey.spec.ts | 4 + .../value-objects/crypto/SymmetricKey.spec.ts | 81 ++++++++++++- 22 files changed, 505 insertions(+), 94 deletions(-) create mode 100644 src/errors/InvalidPasswordError.ts create mode 100644 src/value-objects/Password.ts create mode 100644 tests/value-objects/Password.spec.ts diff --git a/README.md b/README.md index 3eb96b8..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 @@ -109,6 +110,9 @@ Technical notes: 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. @@ -126,7 +130,8 @@ Technical notes: - 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. + `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. @@ -196,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 @@ -207,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.fromPasswordUsingOwasp('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 a41d902..bad3b7c 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 expanded with HKDF-SHA256 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 @@ -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,22 +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: string | StringValueObject, + 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; } ``` @@ -817,12 +848,13 @@ const decrypted = key.decrypt(encrypted); console.log(decrypted.toString()); // 'confidential data' // OWASP-compatible deterministic key derivation from password + explicit salt -const derived = await SymmetricKey.fromPasswordUsingOwasp('strong-password', { +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.fromPasswordUsingOwasp('strong-password', { +const sameDerived = await SymmetricKey.fromPasswordUsingOwasp(password, { salt: 'application-specific-salt', }); console.log(derived.isEqual(sameDerived)); // true @@ -832,7 +864,7 @@ The derived key is deterministic, but encryption is not: `encrypt()` generates a #### EncryptedPrivateKey -Represents an immutable password-protected private key container. New encrypted private keys use v3 with scrypt (N=16384, r=8, p=5), a 16-byte salt, a 32-byte derived key, and AES-256-GCM with a 12-byte IV and 16-byte authentication tag. The 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. +Represents an immutable password-protected private key container. New encrypted private keys use v3 with scrypt (N=16384, r=8, p=5), a 16-byte salt, a 32-byte derived key, and AES-256-GCM with a 12-byte IV and 16-byte authentication tag. The v3 `v3.scrypt.N16384.r8.p5` header is authenticated as AES-GCM AAD. The class also supports v2 (`N=16384`, `r=8`, `p=1`) and the legacy 4-part format, which decrypts with PBKDF2-SHA256 using 100000 iterations and AES-256-GCM. 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`. @@ -846,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; } ``` @@ -857,22 +889,23 @@ class EncryptedPrivateKey extends ValueObject { **Example:** ```typescript // Encrypt a private key -const encrypted = await EncryptedPrivateKey.create(privateKey, 'my-password'); +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'); } @@ -887,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 }; } ``` @@ -903,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 @@ -950,15 +984,19 @@ Recognized payload shapes: - 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. HKDF-SHA256 over the shared secret derives a 256-bit AES key -5. AES-256-GCM encrypts the payload with a 12-byte random IV and 16-byte authentication tag +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 @@ -976,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' ``` @@ -998,6 +1037,8 @@ 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 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. @@ -1013,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 @@ -1027,7 +1068,8 @@ console.log(decrypted.toString()); // 'secret data' **Password-derived key:** ```typescript -const key = await SymmetricKey.fromPasswordUsingOwasp('strong-password', { +const password = new Password('Secure-password-123!'); +const key = await SymmetricKey.fromPasswordUsingOwasp(password, { salt: 'application-specific-salt', }); @@ -1036,12 +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`. - `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-compatible 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/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..d48b92d 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -13,6 +13,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/value-objects/Password.ts b/src/value-objects/Password.ts new file mode 100644 index 0000000..34e93bf --- /dev/null +++ b/src/value-objects/Password.ts @@ -0,0 +1,28 @@ +import { InvalidPasswordError } from '../errors/InvalidPasswordError'; +import { assert } from '../patterns'; +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); + + 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 479c2a0..0a74a1e 100644 --- a/src/value-objects/crypto/CryptoAdapter.ts +++ b/src/value-objects/crypto/CryptoAdapter.ts @@ -42,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( @@ -71,8 +72,9 @@ export class CryptoAdapter { 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/EncryptedPrivateKey.ts b/src/value-objects/crypto/EncryptedPrivateKey.ts index 49d23e7..cf0cb75 100644 --- a/src/value-objects/crypto/EncryptedPrivateKey.ts +++ b/src/value-objects/crypto/EncryptedPrivateKey.ts @@ -4,6 +4,7 @@ import { EncryptedPrivateKeyLegacy } from './encrypted-private-key/EncryptedPriv 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 = [ @@ -14,7 +15,7 @@ export class EncryptedPrivateKey extends ValueObject { public static async create( privateKey: PrivateKey, - password: string | StringValueObject, + password: CryptoPassword, ): Promise { const encryptedPrivateKey = await EncryptedPrivateKeyV3.encrypt( privateKey, @@ -28,9 +29,7 @@ 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), 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 001098f..c626d3c 100644 --- a/src/value-objects/crypto/PrivateKey.ts +++ b/src/value-objects/crypto/PrivateKey.ts @@ -71,6 +71,12 @@ export class PrivateKey extends Key { 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 { return new PrivateKey(pem.valueOf()); } @@ -103,7 +109,7 @@ export class PrivateKey extends Key { ivB64: string, cipherTextB64: string, tagB64: string, - useHkdf: boolean, + options: { useHkdf: boolean; aad?: Buffer }, ): Buffer { PrivateKey.ensureIsBase64(cipherTextB64, encryptedPayload, { allowEmpty: true, @@ -144,7 +150,7 @@ export class PrivateKey extends Key { ephemeralPub, ); - const aesKey = useHkdf + const aesKey = options.useHkdf ? CryptoAdapter.deriveEncryptionKeyWithHkdf( sharedSecret, ephemeralPub, @@ -152,7 +158,13 @@ export class PrivateKey extends Key { ) : CryptoAdapter.deriveEncryptionKey(sharedSecret, ephemeralPub); - return CryptoAdapter.decryptAes256Gcm(aesKey, iv, cipherText, tag); + return CryptoAdapter.decryptAes256Gcm( + aesKey, + iv, + cipherText, + tag, + options.aad, + ); } public getPublicKey(): PublicKey { @@ -181,7 +193,7 @@ export class PrivateKey extends Key { ivB64, cipherTextB64, tagB64, - false, + { useHkdf: false }, ); } @@ -203,7 +215,10 @@ export class PrivateKey extends Key { ivB64, cipherTextB64, tagB64, - true, + { + aad: PrivateKey.getPayloadAad(), + useHkdf: true, + }, ); } } diff --git a/src/value-objects/crypto/PublicKey.ts b/src/value-objects/crypto/PublicKey.ts index 4422e9d..4d1f1e8 100644 --- a/src/value-objects/crypto/PublicKey.ts +++ b/src/value-objects/crypto/PublicKey.ts @@ -22,6 +22,12 @@ export class PublicKey extends Key { 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()); } @@ -96,6 +102,7 @@ export class PublicKey extends Key { aesKey, iv, messageBuffer, + PublicKey.getPayloadAad(), ); const result = [ diff --git a/src/value-objects/crypto/SymmetricKey.ts b/src/value-objects/crypto/SymmetricKey.ts index db349c8..07dad05 100644 --- a/src/value-objects/crypto/SymmetricKey.ts +++ b/src/value-objects/crypto/SymmetricKey.ts @@ -5,6 +5,7 @@ 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'; @@ -20,6 +21,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; @@ -84,6 +91,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()); } @@ -104,7 +123,7 @@ export class SymmetricKey extends ValueObject { } public static async fromPassword( - password: string | StringValueObject, + password: CryptoPassword, options: SymmetricKeyDerivationOptions, ): Promise { const salt = SymmetricKey.getSaltBytes(options.salt); @@ -125,7 +144,7 @@ export class SymmetricKey extends ValueObject { } public static async fromPasswordUsingOwasp( - password: string | StringValueObject, + password: CryptoPassword, options: Pick, ): Promise { return SymmetricKey.fromPassword(password, { @@ -164,7 +183,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, @@ -175,10 +197,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 = [ @@ -192,7 +222,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, @@ -224,11 +257,23 @@ 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 iv = Buffer.from(ivB64, 'base64'); + const cipherText = Buffer.from(cipherTextB64, 'base64'); + const tag = Buffer.from(tagB64, 'base64'); + 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 e4da8b5..405ceed 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts @@ -1,9 +1,8 @@ import { Buffer } from 'buffer'; -import { StringValueObject } from '../../StringValueObject'; 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 +26,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 +53,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,7 +95,7 @@ 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'); diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts index 8006f14..c337639 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts @@ -1,9 +1,8 @@ import { Buffer } from 'buffer'; -import { StringValueObject } from '../../StringValueObject'; 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'; @@ -26,8 +25,22 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { ); } + 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 async deriveSymmetricKey( - password: string | StringValueObject, + password: CryptoPassword, salt: Buffer, options: { N: number; p: number; r: number }, ): Promise { @@ -54,7 +67,7 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { public static async encrypt( privateKey: PrivateKey, - password: string | StringValueObject, + password: CryptoPassword, ): Promise { const salt = await CryptoDerivation.randomBytesAsync( EncryptedPrivateKeyV3.SALT_ENTROPY, @@ -65,7 +78,9 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { r: EncryptedPrivateKeyV3.SCRYPT_R, }); const symmetricPayload = key - .encrypt(privateKey.valueOf()) + .encrypt(privateKey.valueOf(), { + aad: EncryptedPrivateKeyV3.getHeaderParts().join('.'), + }) .valueOf() .split('.'); const iv = symmetricPayload[2]; @@ -73,11 +88,7 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { const tag = symmetricPayload[4]; return [ - EncryptedPrivateKeyV3.VERSION, - EncryptedPrivateKeyV3.KDF, - `N${EncryptedPrivateKeyV3.SCRYPT_N}`, - `r${EncryptedPrivateKeyV3.SCRYPT_R}`, - `p${EncryptedPrivateKeyV3.SCRYPT_P}`, + ...EncryptedPrivateKeyV3.getHeaderParts(), salt.toString('base64'), iv, tag, @@ -96,7 +107,7 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { public async decrypt( parts: string[], - password: string | StringValueObject, + password: CryptoPassword, ): Promise { if (!EncryptedPrivateKeyV3.hasSupportedScryptParameters(parts)) { throw new Error('Unsupported encrypted private key parameters'); @@ -114,6 +125,9 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { 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/tests/value-objects/Password.spec.ts b/tests/value-objects/Password.spec.ts new file mode 100644 index 0000000..8da8bad --- /dev/null +++ b/tests/value-objects/Password.spec.ts @@ -0,0 +1,61 @@ +import { Password, InvalidPasswordError } from '../../src'; + +describe('Password', () => { + describe('constructor', () => { + 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/EncryptedPrivateKey.spec.ts b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts index 51c1ed9..9ca4f75 100644 --- a/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts +++ b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts @@ -2,11 +2,13 @@ import * as crypto from 'node:crypto'; import { EncryptedPrivateKey, + 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'; @@ -15,7 +17,7 @@ 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', { @@ -188,6 +190,39 @@ describe('EncryptedPrivateKey', () => { 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); @@ -202,7 +237,11 @@ describe('EncryptedPrivateKey', () => { ['v1', 'aes-256-gcm', parts[6], parts[8], parts[7]].join('.'), ); - expect(key.decrypt(symmetricPayload).toString()).toBe(privatePem); + expect( + key + .decrypt(symmetricPayload, { aad: parts.slice(0, 5).join('.') }) + .toString(), + ).toBe(privatePem); }); it('should reject unsupported v3 parameters inside the v3 decryptor', async () => { @@ -216,6 +255,17 @@ describe('EncryptedPrivateKey', () => { ).rejects.toThrow('Unsupported encrypted private key parameters'); }); + 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); @@ -271,7 +321,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 f04d13f..5c092af 100644 --- a/tests/value-objects/crypto/PrivateKey.spec.ts +++ b/tests/value-objects/crypto/PrivateKey.spec.ts @@ -226,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); diff --git a/tests/value-objects/crypto/PublicKey.spec.ts b/tests/value-objects/crypto/PublicKey.spec.ts index 25e3167..46ad163 100644 --- a/tests/value-objects/crypto/PublicKey.spec.ts +++ b/tests/value-objects/crypto/PublicKey.spec.ts @@ -153,8 +153,12 @@ describe('PublicKey', () => { 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 aef3a30..0995e55 100644 --- a/tests/value-objects/crypto/SymmetricKey.spec.ts +++ b/tests/value-objects/crypto/SymmetricKey.spec.ts @@ -4,10 +4,12 @@ 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', () => { @@ -144,12 +146,15 @@ describe('SymmetricKey', () => { .spyOn(CryptoDerivation, 'scryptAsync') .mockResolvedValue(derivedKey); - const key = await SymmetricKey.fromPasswordUsingOwasp('password', { - salt: 'stable-salt', - }); + const key = await SymmetricKey.fromPasswordUsingOwasp( + new Password('Secure-password-123!'), + { + salt: 'stable-salt', + }, + ); expect(scryptSpy).toHaveBeenCalledWith( - 'password', + 'Secure-password-123!', Buffer.from('stable-salt'), 32, { N: 16384, p: 5, r: 8 }, @@ -184,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'); }); @@ -234,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'); }); }); From d1c331178acf3d758ce1fdae50441ce28847515f Mon Sep 17 00:00:00 2001 From: Hasko Date: Wed, 10 Jun 2026 17:36:08 +0200 Subject: [PATCH 3/6] =?UTF-8?q?fix(crypto):=20=F0=9F=90=9B=20Validate=20v3?= =?UTF-8?q?=20private=20key=20salt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TECHNICAL_DOCUMENTATION.md | 2 +- .../EncryptedPrivateKeyV3.ts | 24 ++++++++++++++- .../crypto/EncryptedPrivateKey.spec.ts | 30 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/TECHNICAL_DOCUMENTATION.md b/TECHNICAL_DOCUMENTATION.md index bad3b7c..6c3f475 100644 --- a/TECHNICAL_DOCUMENTATION.md +++ b/TECHNICAL_DOCUMENTATION.md @@ -864,7 +864,7 @@ The derived key is deterministic, but encryption is not: `encrypt()` generates a #### EncryptedPrivateKey -Represents an immutable password-protected private key container. New encrypted private keys use v3 with scrypt (N=16384, r=8, p=5), a 16-byte salt, a 32-byte derived key, and AES-256-GCM with a 12-byte IV and 16-byte authentication tag. The v3 `v3.scrypt.N16384.r8.p5` header is authenticated as AES-GCM AAD. The class also supports v2 (`N=16384`, `r=8`, `p=1`) and the legacy 4-part format, which decrypts with PBKDF2-SHA256 using 100000 iterations and AES-256-GCM. +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 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`. diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts index c337639..9a06954 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts @@ -1,5 +1,6 @@ import { Buffer } from 'buffer'; +import { assert } from '../../../patterns'; import { PrivateKey } from '../PrivateKey'; import { SymmetricEncryptedPayload } from '../SymmetricEncryptedPayload'; import { CryptoPassword, SymmetricKey } from '../SymmetricKey'; @@ -16,6 +17,8 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { private static readonly CIPHER = 'aes-256-gcm'; private static readonly EXPECTED_PARTS = 9; private static readonly SYMMETRIC_PAYLOAD_VERSION = 'v1'; + private static readonly BASE64_PATTERN = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; private static hasSupportedScryptParameters(parts: string[]): boolean { return ( @@ -39,6 +42,25 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { return parts.slice(0, 5).join('.'); } + private static ensureSaltIsValid(saltB64: string): Buffer { + assert( + saltB64.length > 0 && + saltB64.length % 4 === 0 && + EncryptedPrivateKeyV3.BASE64_PATTERN.test(saltB64), + new Error('Invalid encrypted private key salt'), + ); + + const salt = Buffer.from(saltB64, 'base64'); + + assert( + salt.length === EncryptedPrivateKeyV3.SALT_ENTROPY && + salt.toString('base64') === saltB64, + new Error('Invalid encrypted private key salt'), + ); + + return salt; + } + private static async deriveSymmetricKey( password: CryptoPassword, salt: Buffer, @@ -116,7 +138,7 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { 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 = Buffer.from(parts[5], 'base64'); + const salt = EncryptedPrivateKeyV3.ensureSaltIsValid(parts[5]); const key = await EncryptedPrivateKeyV3.deriveSymmetricKey(password, salt, { N, p, diff --git a/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts index 9ca4f75..ee17671 100644 --- a/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts +++ b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts @@ -255,6 +255,36 @@ describe('EncryptedPrivateKey', () => { ).rejects.toThrow('Unsupported encrypted private key parameters'); }); + 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('Invalid encrypted private key salt'); + + 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('Invalid encrypted private key salt'); + + 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); From 44ff890f0b1f2e6ae7096e687e53dbff0aa95899 Mon Sep 17 00:00:00 2001 From: Hasko Date: Wed, 10 Jun 2026 17:47:30 +0200 Subject: [PATCH 4/6] =?UTF-8?q?fix(crypto):=20=F0=9F=90=9B=20Use=20strict?= =?UTF-8?q?=20Base64=20and=20domain=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TECHNICAL_DOCUMENTATION.md | 6 +- .../InvalidEncryptedPrivateKeyFormatError.ts | 7 ++ src/errors/index.ts | 1 + .../crypto/EncryptedPrivateKey.ts | 3 +- src/value-objects/crypto/PrivateKey.ts | 40 +++++----- src/value-objects/crypto/StrictBase64.ts | 73 +++++++++++++++++++ src/value-objects/crypto/SymmetricKey.ts | 48 +++++------- .../EncryptedPrivateKeyV2.ts | 5 +- .../EncryptedPrivateKeyV3.ts | 30 +++----- .../crypto/EncryptedPrivateKey.spec.ts | 17 +++-- 10 files changed, 148 insertions(+), 82 deletions(-) create mode 100644 src/errors/InvalidEncryptedPrivateKeyFormatError.ts create mode 100644 src/value-objects/crypto/StrictBase64.ts diff --git a/TECHNICAL_DOCUMENTATION.md b/TECHNICAL_DOCUMENTATION.md index 6c3f475..9693b65 100644 --- a/TECHNICAL_DOCUMENTATION.md +++ b/TECHNICAL_DOCUMENTATION.md @@ -847,7 +847,7 @@ const encrypted = key.encrypt('confidential data'); const decrypted = key.decrypt(encrypted); console.log(decrypted.toString()); // 'confidential data' -// OWASP-compatible deterministic key derivation from password + explicit salt +// 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', @@ -860,7 +860,7 @@ const sameDerived = await SymmetricKey.fromPasswordUsingOwasp(password, { 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 password-based derivation; it is not embedded in `SymmetricEncryptedPayload`. `fromPassword()` keeps its original scrypt defaults for backward compatibility. `fromPasswordUsingOwasp()` uses this package's OWASP-compatible scrypt profile for new password-derived keys. +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 @@ -1088,7 +1088,7 @@ const decrypted = key.decrypt(encrypted, { aad: 'orders.v1' }); - 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`. - `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-compatible scrypt profile (`N=16384`, `r=8`, `p=5`) for new password-derived symmetric keys. +- `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. 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/index.ts b/src/errors/index.ts index d48b92d..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'; diff --git a/src/value-objects/crypto/EncryptedPrivateKey.ts b/src/value-objects/crypto/EncryptedPrivateKey.ts index cf0cb75..c0217a3 100644 --- a/src/value-objects/crypto/EncryptedPrivateKey.ts +++ b/src/value-objects/crypto/EncryptedPrivateKey.ts @@ -1,3 +1,4 @@ +import { InvalidEncryptedPrivateKeyFormatError } from '../../errors/InvalidEncryptedPrivateKeyFormatError'; import { StringValueObject } from '../StringValueObject'; import { ValueObject } from '../ValueObject'; import { EncryptedPrivateKeyLegacy } from './encrypted-private-key/EncryptedPrivateKeyLegacy'; @@ -36,7 +37,7 @@ export class EncryptedPrivateKey extends ValueObject { ); if (!version) { - throw new Error('Invalid encrypted private key format'); + throw new InvalidEncryptedPrivateKeyFormatError(); } return version.decrypt(parts, password); diff --git a/src/value-objects/crypto/PrivateKey.ts b/src/value-objects/crypto/PrivateKey.ts index c626d3c..fd25353 100644 --- a/src/value-objects/crypto/PrivateKey.ts +++ b/src/value-objects/crypto/PrivateKey.ts @@ -12,6 +12,7 @@ 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; @@ -25,28 +26,18 @@ export class PrivateKey extends Key { private static readonly PAYLOAD_VERSION = 'v2'; - private static readonly BASE64_PATTERN = - /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; - private static readonly PATTERN = /^-----BEGIN PRIVATE KEY-----\n[A-Za-z0-9+/=]+\n-----END PRIVATE KEY-----\n$/; - private static getBase64DecodedLength(value: string): number { - const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; - - return (value.length / 4) * 3 - padding; - } - private static ensureIsBase64( value: string, encryptedPayload: EncryptedPayload, - options: { allowEmpty: boolean } = { allowEmpty: false }, + 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, ); } @@ -55,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, ); } @@ -66,9 +57,10 @@ export class PrivateKey extends Key { value: string, encryptedPayload: EncryptedPayload, ): Buffer { - PrivateKey.ensureIsBase64(value, encryptedPayload); - - return Buffer.from(value, 'base64'); + return StrictBase64.decode( + value, + new InvalidFormatError(encryptedPayload.valueOf()), + ); } private static getPayloadAad(): Buffer { @@ -114,7 +106,7 @@ export class PrivateKey extends Key { PrivateKey.ensureIsBase64(cipherTextB64, encryptedPayload, { allowEmpty: true, }); - const cipherTextLength = PrivateKey.getBase64DecodedLength(cipherTextB64); + const cipherTextLength = StrictBase64.getDecodedLength(cipherTextB64); assert( cipherTextLength <= PrivateKey.MAX_CIPHERTEXT_LENGTH, new InvalidLengthError( @@ -141,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()); 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 07dad05..229feb3 100644 --- a/src/value-objects/crypto/SymmetricKey.ts +++ b/src/value-objects/crypto/SymmetricKey.ts @@ -12,6 +12,7 @@ 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 = { @@ -41,25 +42,16 @@ export class SymmetricKey extends ValueObject { 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, ); } @@ -68,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, ); } @@ -166,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), ); } @@ -246,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), @@ -258,9 +245,12 @@ export class SymmetricKey extends ValueObject { ); const key = this.getBuffer(); - const iv = Buffer.from(ivB64, 'base64'); - const cipherText = Buffer.from(cipherTextB64, 'base64'); - const tag = Buffer.from(tagB64, 'base64'); + 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) diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts index 405ceed..69735b3 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts @@ -1,5 +1,6 @@ import { Buffer } from 'buffer'; +import { InvalidEncryptedPrivateKeyFormatError } from '../../../errors/InvalidEncryptedPrivateKeyFormatError'; import { PrivateKey } from '../PrivateKey'; import { SymmetricEncryptedPayload } from '../SymmetricEncryptedPayload'; import { CryptoPassword, SymmetricKey } from '../SymmetricKey'; @@ -98,7 +99,9 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { password: CryptoPassword, ): Promise { if (!EncryptedPrivateKeyV2.hasSupportedScryptParameters(parts)) { - throw new Error('Unsupported encrypted private key parameters'); + throw new InvalidEncryptedPrivateKeyFormatError( + 'Unsupported encrypted private key parameters', + ); } const N = parseInt(parts[2].slice(1), 10); diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts index 9a06954..bde45a8 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts @@ -1,7 +1,8 @@ import { Buffer } from 'buffer'; -import { assert } from '../../../patterns'; +import { InvalidEncryptedPrivateKeyFormatError } from '../../../errors/InvalidEncryptedPrivateKeyFormatError'; import { PrivateKey } from '../PrivateKey'; +import { StrictBase64 } from '../StrictBase64'; import { SymmetricEncryptedPayload } from '../SymmetricEncryptedPayload'; import { CryptoPassword, SymmetricKey } from '../SymmetricKey'; import { CryptoDerivation } from './CryptoDerivation'; @@ -17,8 +18,6 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { private static readonly CIPHER = 'aes-256-gcm'; private static readonly EXPECTED_PARTS = 9; private static readonly SYMMETRIC_PAYLOAD_VERSION = 'v1'; - private static readonly BASE64_PATTERN = - /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; private static hasSupportedScryptParameters(parts: string[]): boolean { return ( @@ -43,22 +42,13 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { } private static ensureSaltIsValid(saltB64: string): Buffer { - assert( - saltB64.length > 0 && - saltB64.length % 4 === 0 && - EncryptedPrivateKeyV3.BASE64_PATTERN.test(saltB64), - new Error('Invalid encrypted private key salt'), - ); - - const salt = Buffer.from(saltB64, 'base64'); - - assert( - salt.length === EncryptedPrivateKeyV3.SALT_ENTROPY && - salt.toString('base64') === saltB64, - new Error('Invalid encrypted private key salt'), + return StrictBase64.decodeCanonicalFixedLength( + saltB64, + new InvalidEncryptedPrivateKeyFormatError( + 'Invalid encrypted private key salt', + ), + EncryptedPrivateKeyV3.SALT_ENTROPY, ); - - return salt; } private static async deriveSymmetricKey( @@ -132,7 +122,9 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { password: CryptoPassword, ): Promise { if (!EncryptedPrivateKeyV3.hasSupportedScryptParameters(parts)) { - throw new Error('Unsupported encrypted private key parameters'); + throw new InvalidEncryptedPrivateKeyFormatError( + 'Unsupported encrypted private key parameters', + ); } const N = parseInt(parts[2].slice(1), 10); diff --git a/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts index ee17671..e420a92 100644 --- a/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts +++ b/tests/value-objects/crypto/EncryptedPrivateKey.spec.ts @@ -2,6 +2,7 @@ import * as crypto from 'node:crypto'; import { EncryptedPrivateKey, + InvalidEncryptedPrivateKeyFormatError, Password, PrivateKey, StringValueObject, @@ -117,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 () => { @@ -129,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(); @@ -145,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(); @@ -160,7 +163,7 @@ describe('EncryptedPrivateKey', () => { 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 () => { @@ -252,7 +255,7 @@ describe('EncryptedPrivateKey', () => { await expect( new EncryptedPrivateKeyV3().decrypt(parts, password), - ).rejects.toThrow('Unsupported encrypted private key parameters'); + ).rejects.toThrow(InvalidEncryptedPrivateKeyFormatError); }); it('should reject invalid v3 salt base64 before deriving a key', async () => { @@ -264,7 +267,7 @@ describe('EncryptedPrivateKey', () => { await expect( new EncryptedPrivateKeyV3().decrypt(parts, password), - ).rejects.toThrow('Invalid encrypted private key salt'); + ).rejects.toThrow(InvalidEncryptedPrivateKeyFormatError); expect(scryptSpy).not.toHaveBeenCalled(); scryptSpy.mockRestore(); @@ -279,7 +282,7 @@ describe('EncryptedPrivateKey', () => { await expect( new EncryptedPrivateKeyV3().decrypt(parts, password), - ).rejects.toThrow('Invalid encrypted private key salt'); + ).rejects.toThrow(InvalidEncryptedPrivateKeyFormatError); expect(scryptSpy).not.toHaveBeenCalled(); scryptSpy.mockRestore(); From 267dc9b8478ec26e094783668dc197684e9a6531 Mon Sep 17 00:00:00 2001 From: Hasko Date: Wed, 10 Jun 2026 17:54:13 +0200 Subject: [PATCH 5/6] =?UTF-8?q?refactor(core):=20=E2=99=BB=EF=B8=8F=20Use?= =?UTF-8?q?=20assert=20for=20guard=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/patterns/Assert.ts | 2 +- src/value-objects/crypto/EncryptedPrivateKey.ts | 5 ++--- .../encrypted-private-key/EncryptedPrivateKeyV2.ts | 10 ++++++---- .../encrypted-private-key/EncryptedPrivateKeyV3.ts | 10 ++++++---- src/value-objects/time/DayOfWeek.ts | 5 ++--- 5 files changed, 17 insertions(+), 15 deletions(-) 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/crypto/EncryptedPrivateKey.ts b/src/value-objects/crypto/EncryptedPrivateKey.ts index c0217a3..7dbdb0e 100644 --- a/src/value-objects/crypto/EncryptedPrivateKey.ts +++ b/src/value-objects/crypto/EncryptedPrivateKey.ts @@ -1,4 +1,5 @@ import { InvalidEncryptedPrivateKeyFormatError } from '../../errors/InvalidEncryptedPrivateKeyFormatError'; +import { assert } from '../../patterns'; import { StringValueObject } from '../StringValueObject'; import { ValueObject } from '../ValueObject'; import { EncryptedPrivateKeyLegacy } from './encrypted-private-key/EncryptedPrivateKeyLegacy'; @@ -36,9 +37,7 @@ export class EncryptedPrivateKey extends ValueObject { handler.matches(parts), ); - if (!version) { - throw new InvalidEncryptedPrivateKeyFormatError(); - } + assert(version, new InvalidEncryptedPrivateKeyFormatError()); return version.decrypt(parts, password); } diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts index 69735b3..7bfb246 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV2.ts @@ -1,6 +1,7 @@ import { Buffer } from 'buffer'; import { InvalidEncryptedPrivateKeyFormatError } from '../../../errors/InvalidEncryptedPrivateKeyFormatError'; +import { assert } from '../../../patterns'; import { PrivateKey } from '../PrivateKey'; import { SymmetricEncryptedPayload } from '../SymmetricEncryptedPayload'; import { CryptoPassword, SymmetricKey } from '../SymmetricKey'; @@ -98,11 +99,12 @@ export class EncryptedPrivateKeyV2 extends EncryptedPrivateKeyVersion { parts: string[], password: CryptoPassword, ): Promise { - if (!EncryptedPrivateKeyV2.hasSupportedScryptParameters(parts)) { - throw new InvalidEncryptedPrivateKeyFormatError( + 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); diff --git a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts index bde45a8..9786019 100644 --- a/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts +++ b/src/value-objects/crypto/encrypted-private-key/EncryptedPrivateKeyV3.ts @@ -1,6 +1,7 @@ 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'; @@ -121,11 +122,12 @@ export class EncryptedPrivateKeyV3 extends EncryptedPrivateKeyVersion { parts: string[], password: CryptoPassword, ): Promise { - if (!EncryptedPrivateKeyV3.hasSupportedScryptParameters(parts)) { - throw new InvalidEncryptedPrivateKeyFormatError( + 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); 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); } From e92bfd8315e118eb8d32e82baaa61240b542e870 Mon Sep 17 00:00:00 2001 From: Hasko Date: Wed, 10 Jun 2026 18:08:56 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix(password):=20=F0=9F=90=9B=20Preserve=20?= =?UTF-8?q?null-object=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/value-objects/Password.ts | 5 +++++ tests/value-objects/Password.spec.ts | 12 +++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/value-objects/Password.ts b/src/value-objects/Password.ts index 34e93bf..95417a7 100644 --- a/src/value-objects/Password.ts +++ b/src/value-objects/Password.ts @@ -1,5 +1,6 @@ import { InvalidPasswordError } from '../errors/InvalidPasswordError'; import { assert } from '../patterns'; +import { NullObject } from './NullObject'; import { StringValueObject } from './StringValueObject'; export class Password extends StringValueObject { @@ -9,6 +10,10 @@ export class Password extends StringValueObject { constructor(value: string | StringValueObject) { super(value); + if (NullObject.isNullObject(this)) { + return this; + } + assert( this.isValidPassword(), new InvalidPasswordError(Password.MIN_LENGTH, Password.MAX_LENGTH), diff --git a/tests/value-objects/Password.spec.ts b/tests/value-objects/Password.spec.ts index 8da8bad..f5733f7 100644 --- a/tests/value-objects/Password.spec.ts +++ b/tests/value-objects/Password.spec.ts @@ -1,7 +1,17 @@ -import { Password, InvalidPasswordError } from '../../src'; +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!';