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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 34 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ npm install @haskou/value-objects
```

```typescript
import { Email, PositiveNumber, Color, Hour } from 'value-objects';
import { Email, PositiveNumber, Color, Hour } from '@haskou/value-objects';

// ✅ Automatic validation on construction
const email = new Email('user@example.com'); // Valid
Expand Down Expand Up @@ -88,24 +88,38 @@ your application.

### 🔐 Cryptography
- **`KeyPair`** - Ed25519 key pair generation for signing and verification
- **`PrivateKey`** - Ed25519 private key (PEM format) for signing; also accepted by payload decryption for data addressed to the matching key pair
- **`PublicKey`** - Ed25519 public key (PEM format) for signature verification; also accepted by payload encryption to address data to the matching key pair
- **`PrivateKey`** - Ed25519 private key (PEM format) for signing; also accepted by asymmetric payload decryption for data addressed to the matching key pair
- **`PublicKey`** - Ed25519 public key (PEM format) for signature verification; also accepted by asymmetric payload encryption to address data to the matching key pair
- **`Signature`** - Base64-encoded Ed25519 digital signature
- **`EncryptedPayload`** - Dot-separated Base64 container returned by payload encryption
- **`EncryptedPayload`** - Base container for dot-separated encrypted payload formats
- **`AsymmetricEncryptedPayload`** - Payload encrypted for an Ed25519/X25519 recipient key pair
- **`SymmetricEncryptedPayload`** - Payload encrypted with a symmetric AES-256-GCM key
- **`SymmetricKey`** - 32-byte AES-256-GCM key, randomly generated or deterministically derived from a password and salt with scrypt
- **`EncryptedPrivateKey`** - Password-protected private key encryption using scrypt + AES-256-GCM
- **`EncryptedKeyPair`** - Key pair with encrypted private key

Technical notes:

- Payload encryption is a classical hybrid scheme. It generates an ephemeral
- Asymmetric payload encryption is a classical hybrid scheme. It generates an ephemeral
X25519 key pair, converts the recipient Ed25519 key material to Montgomery
form for X25519 key agreement, derives a shared secret, then derives a
256-bit AES key with SHA-256 and encrypts the payload with AES-256-GCM.
- The encrypted payload format is
`ephemeralPublicKey.iv.cipherText.authTag`, with Base64-encoded fields,
- The asymmetric payload format is
`ephemeralPublicKey.iv.cipherText.tag`, with Base64-encoded fields,
32-byte ephemeral public keys, 12-byte random IVs, and 16-byte
authentication tags. Tampered ciphertext, IVs, tags, or key-agreement data
fail authentication during decryption.
- Symmetric payload encryption uses a 32-byte AES-256-GCM key directly. The
payload format is `v1.aes-256-gcm.iv.cipherText.tag`, with Base64-encoded
IV, ciphertext, and tag fields.
- `SymmetricKey.generate()` creates a random 256-bit key. `SymmetricKey.fromPassword()`
derives the same 256-bit key from the same password, salt, and scrypt
parameters. Encryption itself is still randomized because each payload uses a
fresh 12-byte IV.
- A 32-byte key provides the AES-256 key size. Real security depends on using
either a high-entropy random key or a strong password with a unique,
non-empty salt. AES-GCM also requires that IVs do not repeat for the same key;
the library generates a random 96-bit IV for each encryption.
- Payload encryption is intended for small payloads and is currently capped at
1 MiB before encryption.
- Encrypted private keys use a separate password-based scheme:
Expand Down Expand Up @@ -175,15 +189,26 @@ const sha512 = SHA512Hash.from('hello'); // 9b71d224bd62f3785d96d46ad3ea3d73319b
console.log(md5.toBase64());

// Crypto
const keyPair = KeyPair.generate(); // Ed25519 key pair
const keyPair = await KeyPair.generate(); // Ed25519 key pair
const signature = keyPair.sign('hello world'); // Sign a message
keyPair.isValidSignature('hello world', signature); // true

// Encrypted key pairs
const encrypted = await keyPair.encryptKeyPair('my-password');
const sig = encrypted.sign('message', 'my-password');
const sig = await encrypted.sign('message', 'my-password');
encrypted.isValidSignature('message', sig); // true

// Symmetric payload encryption
const symmetricKey = SymmetricKey.generate();
const symmetricPayload = symmetricKey.encrypt('secret data');
const plaintext = symmetricKey.decrypt(symmetricPayload);
console.log(plaintext.toString()); // 'secret data'

// Deterministic key derivation from a password and explicit salt
const derivedKey = await SymmetricKey.fromPassword('my-password', {
salt: 'stable-application-salt',
});

// Media
const media = new Media('hello world');
console.log(media.getSize()); // 11
Expand Down
130 changes: 118 additions & 12 deletions TECHNICAL_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Comprehensive technical documentation for the Value Objects library.
- [Hash Value Objects](#hash-value-objects)
- [Cryptography Value Objects](#cryptography-value-objects)
- [Encrypting and Decrypting Payloads](#encrypting-and-decrypting-payloads)
- [Symmetric Payload Encryption](#symmetric-payload-encryption)
- [Media Value Objects](#media-value-objects)
- [Collection Utilities](#collection-utilities)
- [Hour Value Objects](#hour-value-objects)
Expand Down Expand Up @@ -644,7 +645,7 @@ try {

### Cryptography Value Objects

Cryptographic key value objects use **Ed25519** elliptic curve keys in PEM format for signing and verification. Payload encryption uses a library-specific hybrid public-key encryption scheme: Ed25519 key material is converted to X25519 (Montgomery form via `@noble/curves`), an ephemeral X25519 shared secret is combined with the ephemeral public key and hashed with SHA-256 to derive a 256-bit AES key, and the payload is encrypted with AES-256-GCM. This format is not HPKE, is not post-quantum, and is not documented here as an independently audited protocol.
Cryptographic key value objects use **Ed25519** elliptic curve keys in PEM format for signing and verification. Asymmetric payload encryption uses a library-specific hybrid public-key encryption scheme: Ed25519 key material is converted to X25519 (Montgomery form via `@noble/curves`), an ephemeral X25519 shared secret is combined with the ephemeral public key and hashed with SHA-256 to derive a 256-bit AES key, and the payload is encrypted with AES-256-GCM. Symmetric payload encryption uses a caller-held 32-byte AES-256-GCM key directly. These formats are not HPKE, are not post-quantum cryptography schemes, and are not documented here as independently audited protocols.

#### Key

Expand All @@ -656,7 +657,7 @@ abstract class Key extends ValueObject<string> {}

#### PrivateKey

Represents an immutable Ed25519 private key in PEM (PKCS8) format. Used for signing messages; also accepted by payload decryption for data addressed to the matching key pair.
Represents an immutable Ed25519 private key in PEM (PKCS8) format. Used for signing messages; also accepted by asymmetric payload decryption for data addressed to the matching key pair.

```typescript
class PrivateKey extends Key {
Expand Down Expand Up @@ -684,14 +685,14 @@ console.log(decrypted.toString()); // Original plaintext

#### PublicKey

Represents an immutable Ed25519 public key in PEM (SPKI) format. Used for verifying signatures; also accepted by payload encryption to address data to the matching key pair.
Represents an immutable Ed25519 public key in PEM (SPKI) format. Used for verifying signatures; also accepted by asymmetric payload encryption to address data to the matching key pair.

```typescript
class PublicKey extends Key {
public static fromPEM(pem: string | StringValueObject): PublicKey;
constructor(value: string | StringValueObject);
public isValidSignature(payload: CryptoPayload, signature: Signature): boolean;
public encrypt(payload: CryptoPayload): EncryptedPayload;
public encrypt(payload: CryptoPayload): AsymmetricEncryptedPayload;
}
```

Expand Down Expand Up @@ -776,12 +777,67 @@ const restored = KeyPair.fromPrimitives(primitives);
const encryptedKeyPair = await keyPair.encryptKeyPair('strong-password');
```

#### SymmetricKey

Represents an immutable 32-byte AES-256-GCM key encoded as Base64. Keys can be generated randomly, loaded from a 32-byte buffer, loaded from Base64, or deterministically derived from a password plus an explicit salt with scrypt (`N=16384`, `r=8`, `p=1` by default).

```typescript
type SymmetricKeyDerivationOptions = {
N?: number;
p?: number;
r?: number;
salt: string | StringValueObject | Buffer;
};

class SymmetricKey extends ValueObject<string> {
public static fromBase64(key: string | StringValueObject): SymmetricKey;
public static fromBuffer(key: Buffer): SymmetricKey;
public static generate(): SymmetricKey;
public static fromPassword(
password: string | StringValueObject,
options: SymmetricKeyDerivationOptions,
): Promise<SymmetricKey>;
constructor(value: string | StringValueObject);
public getBuffer(): Buffer;
public encrypt(payload: CryptoPayload): SymmetricEncryptedPayload;
public decrypt(encryptedPayload: EncryptedPayload): Buffer;
}
```

**Example:**
```typescript
// Random symmetric key
const key = SymmetricKey.generate();
const encrypted = key.encrypt('confidential data');
const decrypted = key.decrypt(encrypted);
console.log(decrypted.toString()); // 'confidential data'

// Deterministic key derivation from password + explicit salt
const derived = await SymmetricKey.fromPassword('strong-password', {
salt: 'application-specific-salt',
});

// The same password, salt, and scrypt parameters derive the same key
const sameDerived = await SymmetricKey.fromPassword('strong-password', {
salt: 'application-specific-salt',
});
console.log(derived.isEqual(sameDerived)); // true
```

The derived key is deterministic, but encryption is not: `encrypt()` generates a fresh 12-byte AES-GCM IV for each payload. Callers must store or reproduce the salt used for `fromPassword()`; it is not embedded in `SymmetricEncryptedPayload`.

#### EncryptedPrivateKey

Represents an immutable password-protected private key container. New encrypted private keys use scrypt (N=16384, r=8, p=1), a 16-byte salt, a 32-byte derived key, and AES-256-GCM with a 12-byte IV and 16-byte authentication tag. The class also supports the legacy 4-part format, which decrypts with PBKDF2-SHA256 using 100000 iterations and AES-256-GCM.

The encrypted format is: `v2.scrypt.N16384.r8.p1.salt.iv.tag.cipherText` (base64-encoded, dot-separated). Legacy format: `cipherText.iv.salt.tag`.

The v2 implementation derives a `SymmetricKey` from the password, salt, and
scrypt parameters, then reuses the same AES-256-GCM payload primitive used by
`SymmetricKey`. `EncryptedPrivateKey` keeps its own serialized container because
it must persist the KDF name, KDF parameters, and salt needed to decrypt the
private key later.

```typescript
class EncryptedPrivateKey extends ValueObject<string> {
public static async create(
Expand Down Expand Up @@ -865,17 +921,29 @@ const restored = EncryptedKeyPair.fromPrimitives(primitives);

#### EncryptedPayload

Represents an immutable dot-separated Base64 container returned by the library-specific payload encryption scheme. The format is `ephemeralPub.iv.cipherText.tag`.
Represents an immutable dot-separated encrypted payload container. It is the base type accepted by decryptors and can classify the known payload shape as `asymmetric`, `symmetric`, or `unknown`.

```typescript
class EncryptedPayload extends ValueObject<string> {}
type EncryptedPayloadScheme = 'asymmetric' | 'symmetric' | 'unknown';

class EncryptedPayload extends ValueObject<string> {
public getScheme(): EncryptedPayloadScheme;
}

class AsymmetricEncryptedPayload extends EncryptedPayload {
public getScheme(): EncryptedPayloadScheme;
}

class SymmetricEncryptedPayload extends EncryptedPayload {
public getScheme(): EncryptedPayloadScheme;
}
```

Created by `PublicKey.encrypt()`, `KeyPair.encrypt()`, or `EncryptedKeyPair.encrypt()`. Decrypted by the corresponding `decrypt()` method.
`AsymmetricEncryptedPayload` is created by `PublicKey.encrypt()`, `KeyPair.encrypt()`, or `EncryptedKeyPair.encrypt()` and decrypted by the corresponding private-key decryptor. `SymmetricEncryptedPayload` is created by `SymmetricKey.encrypt()` and decrypted by `SymmetricKey.decrypt()`.

#### Encrypting and Decrypting Payloads

The library uses a classical hybrid public-key encryption approach:
The public-key payload API uses a classical hybrid encryption approach:

1. The Ed25519 public key is converted to X25519 (Montgomery form)
2. An ephemeral X25519 key pair is generated
Expand Down Expand Up @@ -922,12 +990,50 @@ console.log(decrypted.toString()); // 'hello world'
**Important:**
- Each call to `encrypt()` generates a new ephemeral key, producing different ciphertext even for the same payload.
- Decrypting with the wrong private key throws an AES-GCM authentication error.
- The encrypted format is `ephemeralPub.iv.cipherText.tag` (base64, dot-separated).
- Payload encryption is capped at 1 MiB before encryption.
- Payload encryption does not authenticate the sender. AES-GCM authenticates
- The asymmetric encrypted format is `ephemeralPub.iv.cipherText.tag` (base64, dot-separated).
- Asymmetric payload encryption is capped at 1 MiB before encryption.
- Asymmetric payload encryption does not authenticate the sender. AES-GCM authenticates
ciphertext integrity for the derived key, but the payload does not include a
sender key or signature.
- This payload encryption format is library-specific, not HPKE, and is not post-quantum.
- This public-key payload encryption format is library-specific, not HPKE, and is not post-quantum.

#### Symmetric Payload Encryption

`SymmetricKey` encrypts and decrypts payloads with AES-256-GCM using the key bytes held by the value object.

1. A `SymmetricKey` contains exactly 32 bytes (256 bits) encoded as Base64
2. `encrypt()` generates a fresh 12-byte random IV
3. AES-256-GCM encrypts the payload and produces a 16-byte authentication tag
4. The output is `v1.aes-256-gcm.iv.cipherText.tag` with Base64-encoded IV, ciphertext, and tag fields
5. `decrypt()` validates the version, algorithm, IV length, tag length, Base64 fields, and 1 MiB ciphertext limit before decrypting

**Random key:**
```typescript
const key = SymmetricKey.generate();
const encrypted = key.encrypt('secret data');
const decrypted = key.decrypt(encrypted);
console.log(decrypted.toString()); // 'secret data'
```

**Password-derived key:**
```typescript
const key = await SymmetricKey.fromPassword('strong-password', {
salt: 'application-specific-salt',
});

const encrypted = key.encrypt(Buffer.from('secret data'));
const decrypted = key.decrypt(encrypted);
console.log(decrypted.toString()); // 'secret data'
```

**Important:**
- A 32-byte key is the AES-256 key size. With a uniformly random key, brute-force key search is not practical with current classical computing.
- Password-derived keys are only as strong as the password and salt policy. Use a unique, non-empty salt per derivation context; callers must store or reproduce that salt because it is not included in `SymmetricEncryptedPayload`.
- The default password KDF is scrypt with `N=16384`, `r=8`, `p=1`, and a 32-byte output. Custom scrypt parameters are accepted through `SymmetricKey.fromPassword()`.
- The same password, salt, and scrypt parameters derive the same key. The encrypted payload remains randomized because every encryption uses a fresh 96-bit IV.
- AES-GCM requires IV uniqueness for a given key. The library generates a random IV for each encryption; avoid manually reusing serialized payload internals as new encryption inputs.
- Symmetric payload encryption provides confidentiality and ciphertext integrity for holders of the same key. It does not identify which holder encrypted the payload. It is not a post-quantum cryptography scheme; AES-256 is considered to retain a large security margin against Grover-style quadratic speedups, but the KDF and password entropy still matter.
- Symmetric payload encryption is capped at 1 MiB before encryption.

#### CryptoPayload

Expand Down
7 changes: 7 additions & 0 deletions src/value-objects/crypto/AsymmetricEncryptedPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { EncryptedPayload, EncryptedPayloadScheme } from './EncryptedPayload';

export class AsymmetricEncryptedPayload extends EncryptedPayload {
public getScheme(): EncryptedPayloadScheme {
return 'asymmetric';
}
}
18 changes: 17 additions & 1 deletion src/value-objects/crypto/EncryptedPayload.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
import { ValueObject } from '../ValueObject';

export class EncryptedPayload extends ValueObject<string> {}
export type EncryptedPayloadScheme = 'asymmetric' | 'symmetric' | 'unknown';

export class EncryptedPayload extends ValueObject<string> {
public getScheme(): EncryptedPayloadScheme {
const parts = this.valueOf().split('.');

if (parts.length === 4) {
return 'asymmetric';
}

if (parts.length === 5 && parts[0] === 'v1' && parts[1] === 'aes-256-gcm') {
return 'symmetric';
}

return 'unknown';
}
}
6 changes: 3 additions & 3 deletions src/value-objects/crypto/PublicKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import { assert } from '../../patterns';
import { Media } from '../media';
import { NullObject } from '../NullObject';
import { StringValueObject } from '../StringValueObject';
import { AsymmetricEncryptedPayload } from './AsymmetricEncryptedPayload';
import { CryptoAdapter } from './CryptoAdapter';
import { CryptoPayload } from './CryptoPayload';
import { EncryptedPayload } from './EncryptedPayload';
import { Key } from './Key';
import { Signature } from './Signature';

Expand Down Expand Up @@ -58,7 +58,7 @@ export class PublicKey extends Key {
return valid;
}

public encrypt(payload: CryptoPayload): EncryptedPayload {
public encrypt(payload: CryptoPayload): AsymmetricEncryptedPayload {
const messageBuffer =
payload instanceof Media
? payload.getBuffer()
Expand Down Expand Up @@ -100,6 +100,6 @@ export class PublicKey extends Key {
Buffer.from(tag).toString('base64'),
].join('.');

return new EncryptedPayload(result);
return new AsymmetricEncryptedPayload(result);
}
}
7 changes: 7 additions & 0 deletions src/value-objects/crypto/SymmetricEncryptedPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { EncryptedPayload, EncryptedPayloadScheme } from './EncryptedPayload';

export class SymmetricEncryptedPayload extends EncryptedPayload {
public getScheme(): EncryptedPayloadScheme {
return 'symmetric';
}
}
Loading
Loading