Metadata-Version: 2.4
Name: ecl_crypto_utility
Version: 0.1.0
Summary: Production-grade, framework-agnostic Python cryptographic utility package
Author-email: ECL <engineering@ecl.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security :: Cryptography
Requires-Python: >=3.10
Requires-Dist: argon2-cffi>=23.1
Requires-Dist: cryptography>=43.0
Requires-Dist: ecl-logging-utility
Provides-Extra: all
Requires-Dist: boto3>=1.34; extra == 'all'
Requires-Dist: google-cloud-kms>=2.21; extra == 'all'
Requires-Dist: hvac>=2.1; extra == 'all'
Provides-Extra: aws
Requires-Dist: boto3>=1.34; extra == 'aws'
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Provides-Extra: gcp
Requires-Dist: google-cloud-kms>=2.21; extra == 'gcp'
Provides-Extra: vault
Requires-Dist: hvac>=2.1; extra == 'vault'
Description-Content-Type: text/markdown

# ecl_crypto_utility

Production-grade, framework-agnostic Python cryptographic utility package.

[![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Coverage](https://img.shields.io/badge/coverage-95%25%2B-brightgreen.svg)]()
[![PyPI](https://img.shields.io/pypi/v/ecl_crypto_utility.svg)]()

## Table of Contents

- [Installation](#installation)
- [Quick Start](#quick-start)
- [Auto-Configuration](#auto-configuration)
- [Symmetric Encryption](#symmetric-encryption)
- [Asymmetric Operations](#asymmetric-operations)
- [Hashing](#hashing)
- [Key Derivation](#key-derivation)
- [Token Generation](#token-generation)
- [Envelope Encryption](#envelope-encryption)
- [KMS Backends](#kms-backends)
- [DEK Caching](#dek-caching)
- [Audit Logging](#audit-logging)
- [Exceptions](#exceptions)
- [Security Policy](#security-policy)
- [License](#license)

---

## Installation

```bash
# Core package
pip install ecl_crypto_utility

# With AWS KMS support
pip install "ecl_crypto_utility[aws]"

# With GCP Cloud KMS support
pip install "ecl_crypto_utility[gcp]"

# With HashiCorp Vault support
pip install "ecl_crypto_utility[vault]"

# All KMS backends
pip install "ecl_crypto_utility[all]"

# Development dependencies
pip install "ecl_crypto_utility[dev]"
```

---

## Quick Start

Set two environment variables and you're ready:

```bash
export ECL_CRYPTO_KMS_BACKEND=local       # or: aws, gcp, vault
export ECL_CRYPTO_KMS_KEY_ID=my-app-key
```

```python
from ecl_crypto_utility import encrypt, decrypt

ciphertext = encrypt(plaintext=b"Hello, World!")
plaintext = decrypt(encrypted_data=ciphertext)
assert plaintext == b"Hello, World!"
```

The package reads ENV vars on first call, auto-configures the KMS backend and DEK cache, and caches the singleton for all subsequent calls. No manual backend or encryptor setup needed.

For direct symmetric encryption (you manage the key yourself):

```python
from ecl_crypto_utility.symmetric import generate_key, encrypt, decrypt

key = generate_key()
ciphertext = encrypt(plaintext=b"Hello, World!", key=key)
plaintext = decrypt(encrypted_data=ciphertext, key=key)
```

---

## Auto-Configuration

The top-level `encrypt()` and `decrypt()` use envelope encryption under the hood, configured entirely via environment variables.

### Required Variables

| Variable | Description |
|----------|-------------|
| `ECL_CRYPTO_KMS_BACKEND` | Backend to use: `aws`, `gcp`, `vault`, or `local` |
| `ECL_CRYPTO_KMS_KEY_ID` | KMS key identifier (ARN, alias, key name, etc.) |

### AWS Backend Variables

All optional — uses the standard boto3 credential chain by default.

| Variable | Description |
|----------|-------------|
| `ECL_CRYPTO_AWS_REGION` | AWS region (e.g., `us-east-1`) |
| `ECL_CRYPTO_AWS_PROFILE` | AWS credential profile name |
| `ECL_CRYPTO_AWS_ENDPOINT_URL` | Custom endpoint (e.g., `http://localhost:4566` for LocalStack) |

### GCP Backend Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `ECL_CRYPTO_GCP_PROJECT_ID` | Yes | GCP project ID |
| `ECL_CRYPTO_GCP_LOCATION_ID` | Yes | Cloud KMS location (e.g., `us-east1`) |
| `ECL_CRYPTO_GCP_KEY_RING_ID` | Yes | Key ring name |

### Vault Backend Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `ECL_CRYPTO_VAULT_URL` | Yes | Vault server URL |
| `ECL_CRYPTO_VAULT_TOKEN` | No | Auth token (falls back to env-based auth) |
| `ECL_CRYPTO_VAULT_MOUNT_POINT` | No | Transit engine mount (default: `transit`) |
| `ECL_CRYPTO_VAULT_NAMESPACE` | No | Vault namespace (enterprise) |

### Local Backend Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `ECL_CRYPTO_LOCAL_MASTER_KEY` | No | Hex-encoded 32-byte master key. Auto-generated if absent. |

### Cache Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `ECL_CRYPTO_DEK_CACHE_ENABLED` | `true` | Enable/disable DEK caching |
| `ECL_CRYPTO_DEK_CACHE_MAX_SIZE` | `100` | Max cached entries |
| `ECL_CRYPTO_DEK_CACHE_TTL_SECONDS` | `300` | TTL per entry (seconds) |

### Example `.env` Files

**AWS:**
```bash
ECL_CRYPTO_KMS_BACKEND=aws
ECL_CRYPTO_KMS_KEY_ID=arn:aws:kms:us-east-1:123456789012:key/abcd-1234
ECL_CRYPTO_AWS_REGION=us-east-1
```

**GCP:**
```bash
ECL_CRYPTO_KMS_BACKEND=gcp
ECL_CRYPTO_KMS_KEY_ID=my-crypto-key
ECL_CRYPTO_GCP_PROJECT_ID=my-project
ECL_CRYPTO_GCP_LOCATION_ID=us-east1
ECL_CRYPTO_GCP_KEY_RING_ID=my-keyring
```

**Vault:**
```bash
ECL_CRYPTO_KMS_BACKEND=vault
ECL_CRYPTO_KMS_KEY_ID=my-transit-key
ECL_CRYPTO_VAULT_URL=https://vault.example.com:8200
ECL_CRYPTO_VAULT_TOKEN=hvs.my-vault-token
```

**Local (development):**
```bash
ECL_CRYPTO_KMS_BACKEND=local
ECL_CRYPTO_KMS_KEY_ID=dev-key
```

---

## Symmetric Encryption

AES-256-GCM authenticated encryption. A 12-byte nonce is generated internally for every encryption call -- never accepted as input, eliminating nonce reuse. The output is a single Base64-encoded string containing the nonce, ciphertext, and authentication tag.

### API

| Function | Signature | Returns |
|----------|-----------|---------|
| `generate_key` | `generate_key() -> bytes` | 32-byte AES-256 key |
| `encrypt` | `encrypt(plaintext: bytes, key: bytes, associated_data: bytes \| None = None) -> str` | Base64 string |
| `decrypt` | `decrypt(encrypted_data: str, key: bytes, associated_data: bytes \| None = None) -> bytes` | Plaintext bytes |

### Usage

```python
from ecl_crypto_utility import generate_key, encrypt, decrypt

key = generate_key()
ciphertext = encrypt(plaintext=b"hello, world", key=key)
plaintext = decrypt(encrypted_data=ciphertext, key=key)
```

**With Associated Data (AAD)** -- authenticated but not encrypted context binding:

```python
context = b"user_id=42"
ciphertext = encrypt(plaintext=b"payment details", key=key, associated_data=context)
plaintext = decrypt(encrypted_data=ciphertext, key=key, associated_data=context)
```

Decrypting with a different or missing `associated_data` raises `DecryptionError`.

### Wire Format

```
nonce (12 bytes) || ciphertext (variable) || authentication tag (16 bytes)
```

### Exceptions

| Exception | When |
|-----------|------|
| `InvalidParameterError` | Key is not exactly 32 bytes |
| `EncryptionError` | AES-256-GCM encryption fails |
| `DecryptionError` | Wrong key, corrupted data, or AAD mismatch |

### Security Notes

- Nonce generated via `os.urandom()` for every call -- no caller-supplied nonces.
- Only 256-bit (32-byte) keys accepted.
- AES-GCM 128-bit auth tag detects any tampering.
- AAD is authenticated but transmitted in the clear -- do not place secrets in it.

---

## Asymmetric Operations

RSA-4096-OAEP encryption and ECDSA SECP384R1 digital signatures.

### RSA API

| Function | Signature | Returns |
|----------|-----------|---------|
| `generate_rsa_key_pair` | `generate_rsa_key_pair() -> tuple[bytes, bytes]` | (private_pem, public_pem) |
| `rsa_encrypt` | `rsa_encrypt(plaintext: bytes, public_key_pem: bytes) -> str` | Base64 string |
| `rsa_decrypt` | `rsa_decrypt(encrypted_data: str, private_key_pem: bytes) -> bytes` | Plaintext bytes |

### ECDSA API

| Function | Signature | Returns |
|----------|-----------|---------|
| `generate_ecdsa_key_pair` | `generate_ecdsa_key_pair() -> tuple[bytes, bytes]` | (private_pem, public_pem) |
| `sign` | `sign(data: bytes, private_key_pem: bytes) -> str` | Base64 signature |
| `verify` | `verify(data: bytes, signature: str, public_key_pem: bytes) -> bool` | `True`; **raises on failure** |

### Usage

```python
from ecl_crypto_utility import generate_rsa_key_pair, rsa_encrypt, rsa_decrypt

private_key, public_key = generate_rsa_key_pair()
ciphertext = rsa_encrypt(plaintext=b"secret message", public_key_pem=public_key)
plaintext = rsa_decrypt(encrypted_data=ciphertext, private_key_pem=private_key)
```

```python
from ecl_crypto_utility import generate_ecdsa_key_pair, sign, verify

private_key, public_key = generate_ecdsa_key_pair()
signature = sign(data=b"important document", private_key_pem=private_key)
verify(data=b"important document", signature=signature, public_key_pem=public_key)
```

### Algorithm Details

| Parameter | RSA | ECDSA |
|-----------|-----|-------|
| Key size | 4096 bits | SECP384R1 (P-384) |
| Hash | SHA-256 | SHA-384 |
| Padding/Scheme | OAEP (MGF1-SHA-256) | ECDSA |
| Max plaintext | ~446 bytes | N/A (signing only) |
| Key format | PEM (PKCS8 / SubjectPublicKeyInfo) | PEM (PKCS8 / SubjectPublicKeyInfo) |

### Exceptions

| Exception | When |
|-----------|------|
| `KeyGenerationError` | Key pair generation fails |
| `EncryptionError` | RSA-OAEP encryption fails (e.g., plaintext too large) |
| `DecryptionError` | RSA-OAEP decryption fails (wrong key, corrupted data) |
| `SigningError` | ECDSA signing fails |
| `VerificationError` | Signature is invalid |

### Security Notes

- `verify()` raises `VerificationError` on invalid signatures -- it never returns `False`.
- RSA-OAEP is probabilistic (same plaintext produces different ciphertexts).
- Private keys are serialized without encryption -- protect them at rest.
- RSA key pairs are for encryption; ECDSA key pairs are for signing. Do not interchange them.

---

## Hashing

Three categories of hashing for different purposes:

1. **Password hashing** (Argon2id) -- memory-hard, for storing user passwords
2. **Integrity hashing** (SHA-256) -- fast, for verifying data has not been tampered with
3. **HMAC** (HMAC-SHA256) -- keyed hash for authenticating data

### API

| Function | Signature | Returns |
|----------|-----------|---------|
| `hash_password` | `hash_password(password: str) -> str` | Argon2-encoded string |
| `verify_password` | `verify_password(password: str, hash_string: str) -> bool` | `True`; **raises on mismatch** |
| `hash_data` | `hash_data(data: bytes) -> str` | Hex SHA-256 digest |
| `verify_data_integrity` | `verify_data_integrity(data: bytes, expected_hash: str) -> bool` | `True`; **raises on mismatch** |
| `hmac_sign` | `hmac_sign(data: bytes, key: bytes) -> str` | Hex HMAC-SHA256 |
| `hmac_verify` | `hmac_verify(data: bytes, key: bytes, expected_hmac: str) -> bool` | `True`; **raises on mismatch** |

### Usage

```python
from ecl_crypto_utility import hash_password, verify_password
from ecl_crypto_utility.exceptions import HashingError

hashed = hash_password(password="my_secure_password")
try:
    verify_password(password="my_secure_password", hash_string=hashed)
    print("Password matches")
except HashingError:
    print("Password does not match")
```

```python
from ecl_crypto_utility import hash_data, verify_data_integrity

digest = hash_data(data=b"important document")
verify_data_integrity(data=b"important document", expected_hash=digest)
```

```python
from ecl_crypto_utility import generate_key, hmac_sign, hmac_verify

key = generate_key()
mac = hmac_sign(data=b"api request payload", key=key)
hmac_verify(data=b"api request payload", key=key, expected_hmac=mac)
```

### Argon2id Parameters

| Parameter | Value |
|-----------|-------|
| Algorithm | Argon2id |
| Time cost | 3 iterations |
| Memory cost | 65536 KB (64 MB) |
| Parallelism | 4 threads |
| Hash length | 32 bytes |

### Security Notes

- All verify functions raise `HashingError` on mismatch -- they never return `False`.
- `verify_data_integrity()` and `hmac_verify()` use `hmac.compare_digest()` for constant-time comparison (prevents timing attacks).
- Argon2id is intentionally slow (64 MB memory, 3 iterations) -- do not use for high-throughput integrity checks.
- SHA-256 is not suitable for passwords. Never use `hash_data()` for password storage.
- HMAC requires a secret key. If the key leaks, an attacker can forge valid HMACs.
- Salt is embedded in the Argon2 output -- no separate salt management needed.

---

## Key Derivation

HKDF-SHA256 (from key material) and PBKDF2-HMAC-SHA256 (from passphrases).

### API

| Function | Signature | Returns |
|----------|-----------|---------|
| `derive_key_hkdf` | `derive_key_hkdf(input_key_material: bytes, length: int = 32, salt: bytes \| None = None, info: bytes \| None = None) -> bytes` | Derived key bytes |
| `derive_key_pbkdf2` | `derive_key_pbkdf2(passphrase: str, length: int = 32, salt: bytes \| None = None, iterations: int = 600000) -> tuple[bytes, bytes]` | (derived_key, salt) |

### Usage

```python
from ecl_crypto_utility import generate_key, derive_key_hkdf

master_secret = generate_key()
derived = derive_key_hkdf(
    input_key_material=master_secret,
    length=32,
    salt=b"static-application-salt",
    info=b"encryption-key-v1",
)
```

```python
from ecl_crypto_utility import derive_key_pbkdf2

derived_key, salt = derive_key_pbkdf2(passphrase="user-chosen-password")
# IMPORTANT: Store the salt alongside the derived key.
# To re-derive later:
rederived_key, _ = derive_key_pbkdf2(passphrase="user-chosen-password", salt=salt)
assert rederived_key == derived_key
```

### When to Use Which

| Scenario | Function |
|----------|----------|
| Deriving from a Diffie-Hellman shared secret | `derive_key_hkdf` |
| Deriving multiple keys from one master secret (different `info` values) | `derive_key_hkdf` |
| Deriving from a user-entered passphrase | `derive_key_pbkdf2` |

### Algorithm Details

| | HKDF | PBKDF2 |
|-|------|--------|
| Algorithm | HKDF-SHA256 | PBKDF2-HMAC-SHA256 |
| Default output | 32 bytes | 32 bytes |
| Default salt | Random 32 bytes | Random 32 bytes |
| Default iterations | N/A | 600,000 |

### Security Notes

- **PBKDF2 salt management**: The returned salt must be stored alongside the derived key. Without it, the key cannot be re-derived.
- **HKDF with `salt=None`**: Random salt is generated internally and cannot be retrieved. Only use this when the derived key will be stored, not re-derived.
- **HKDF is not for passwords**: It is fast and not memory-hard. For passwords, use `derive_key_pbkdf2` or `hash_password`.
- **Iteration count**: 600,000 follows OWASP recommendations. Increase for stronger brute-force resistance.

---

## Token Generation

Cryptographically secure random tokens for API keys, session IDs, CSRF tokens, password reset tokens, etc.

### API

| Function | Signature | Returns |
|----------|-----------|---------|
| `generate_token_bytes` | `generate_token_bytes(length: int = 32) -> bytes` | Raw bytes |
| `generate_token_hex` | `generate_token_hex(length: int = 32) -> str` | Hex string (`length * 2` chars) |
| `generate_token_urlsafe` | `generate_token_urlsafe(length: int = 32) -> str` | URL-safe Base64 string |

### Usage

```python
from ecl_crypto_utility import generate_token_hex, generate_token_urlsafe

api_key = generate_token_hex(length=32)       # 64-char hex string
reset_token = generate_token_urlsafe(length=32)  # ~43-char URL-safe string
```

### Output Format

| Function | Input `length=32` | Output Length | Character Set |
|----------|--------------------|---------------|---------------|
| `generate_token_bytes` | 32 bytes | 32 bytes | Raw (0x00-0xFF) |
| `generate_token_hex` | 32 bytes | 64 characters | `0-9`, `a-f` |
| `generate_token_urlsafe` | 32 bytes | ~43 characters | `A-Z`, `a-z`, `0-9`, `-`, `_` |

### Security Notes

- Uses `os.urandom()` / `secrets` -- cryptographically secure.
- Default 32 bytes (256 bits) provides strong brute-force resistance. Do not reduce below 16 bytes.
- Tokens are random values, not ciphertexts. Treat them as secrets.

---

## Envelope Encryption

Two-layer encryption: data is encrypted locally with a random DEK (AES-256-GCM), and the DEK is wrapped by a KMS backend. The result is a single opaque Base64-encoded string.

### API

```python
class EnvelopeEncryptor(kms_backend: KMSBackend, key_id: str, dek_cache: DEKCache | None = None)
```

| Method | Signature | Returns |
|--------|-----------|---------|
| `encrypt` | `encrypt(plaintext: bytes) -> str` | Base64 envelope string |
| `decrypt` | `decrypt(envelope_data: str) -> bytes` | Plaintext bytes |

### Usage

```python
from ecl_crypto_utility import EnvelopeEncryptor, LocalKMSBackend

backend = LocalKMSBackend()
encryptor = EnvelopeEncryptor(kms_backend=backend, key_id="dev-key")

envelope = encryptor.encrypt(plaintext=b"sensitive customer data")
plaintext = encryptor.decrypt(envelope_data=envelope)
```

**With AWS KMS:**

```python
from ecl_crypto_utility import EnvelopeEncryptor
from ecl_crypto_utility.kms.aws import AWSKMSBackend

backend = AWSKMSBackend(region_name="us-east-1")
encryptor = EnvelopeEncryptor(
    kms_backend=backend,
    key_id="arn:aws:kms:us-east-1:123456789012:key/abcd-1234",
)
envelope = encryptor.encrypt(plaintext=b"production data")
```

**With DEK Caching:**

```python
from ecl_crypto_utility import DEKCache, EnvelopeEncryptor, LocalKMSBackend

cache = DEKCache(max_size=100, ttl_seconds=300)
encryptor = EnvelopeEncryptor(kms_backend=LocalKMSBackend(), key_id="dev-key", dek_cache=cache)

envelope = encryptor.encrypt(plaintext=b"data")
result = encryptor.decrypt(envelope_data=envelope)  # KMS call, DEK cached
result = encryptor.decrypt(envelope_data=envelope)  # Cache hit, no KMS call
```

### Encryption Flow

1. Generate a new 256-bit DEK.
2. Encrypt the plaintext with the DEK (AES-256-GCM).
3. Wrap the DEK via the KMS backend.
4. Build a JSON envelope and Base64-encode it.
5. Emit an audit log entry.

### Decryption Flow

1. Base64-decode and parse the envelope JSON.
2. Validate the envelope version.
3. Check the DEK cache (if provided).
4. On cache miss, unwrap the DEK via the KMS backend.
5. Cache the DEK (if cache provided).
6. Decrypt the data with the DEK.
7. Emit an audit log entry.

### Envelope Format

The Base64 string decodes to JSON:

```json
{
    "version": 1,
    "encrypted_dek": "<base64>",
    "key_id": "<KMS key identifier>",
    "encrypted_data": "<base64 AES-256-GCM ciphertext>",
    "algorithm": "AES-256-GCM",
    "timestamp": "<ISO 8601 UTC>"
}
```

### Security Notes

- Each `encrypt()` generates a unique DEK -- no reuse.
- Plaintext DEK never leaves memory during encryption.
- Envelope version is validated during decryption.
- All KMS operations are audit-logged.
- `LocalKMSBackend` is for development only.

---

## KMS Backends

All backends implement the `KMSBackend` abstract interface:

| Method | Returns | Description |
|--------|---------|-------------|
| `encrypt_key(key_material, key_id)` | Base64 string | Wrap a DEK |
| `decrypt_key(encrypted_key, key_id)` | Plaintext bytes | Unwrap a DEK |
| `generate_data_key(key_id)` | (plaintext_key, encrypted_key_base64) | Generate and wrap a new DEK |

### LocalKMSBackend (Development Only)

```python
from ecl_crypto_utility import LocalKMSBackend

backend = LocalKMSBackend()                          # Auto-generated master key
backend = LocalKMSBackend(master_key=my_32_byte_key)  # Explicit key
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `master_key` | `bytes \| None` | `None` | 32-byte key. Random if None. |

Emits `WARNING` logs on every operation.

### AWSKMSBackend

```bash
pip install "ecl_crypto_utility[aws]"
```

```python
from ecl_crypto_utility.kms.aws import AWSKMSBackend

backend = AWSKMSBackend(region_name="us-east-1")
backend = AWSKMSBackend(region_name="us-east-1", endpoint_url="http://localhost:4566")  # LocalStack
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `region_name` | `str \| None` | `None` | AWS region |
| `profile_name` | `str \| None` | `None` | AWS credential profile |
| `endpoint_url` | `str \| None` | `None` | Custom endpoint (e.g., LocalStack) |

### GCPKMSBackend

```bash
pip install "ecl_crypto_utility[gcp]"
```

```python
from ecl_crypto_utility.kms.gcp import GCPKMSBackend

backend = GCPKMSBackend(project_id="my-project", location_id="us-east1", key_ring_id="my-keyring")
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `project_id` | `str` | required | GCP project ID |
| `location_id` | `str` | required | Cloud KMS location |
| `key_ring_id` | `str` | required | Key ring name |

The `key_id` in methods is the crypto key name within the key ring.

### VaultKMSBackend

```bash
pip install "ecl_crypto_utility[vault]"
```

```python
from ecl_crypto_utility.kms.vault import VaultKMSBackend

backend = VaultKMSBackend(vault_url="https://vault.example.com:8200", token="hvs.my-token")
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `vault_url` | `str` | required | Vault server URL |
| `token` | `str \| None` | `None` | Auth token |
| `mount_point` | `str` | `"transit"` | Transit engine mount |
| `namespace` | `str \| None` | `None` | Vault namespace (enterprise) |

### Creating a Custom Backend

```python
from ecl_crypto_utility.kms.base import KMSBackend
from ecl_crypto_utility.symmetric import generate_key


class MyCustomKMSBackend(KMSBackend):
    def encrypt_key(self, key_material: bytes, key_id: str) -> str: ...
    def decrypt_key(self, encrypted_key: str, key_id: str) -> bytes: ...

    def generate_data_key(self, key_id: str) -> tuple[bytes, str]:
        plaintext_key = generate_key()
        encrypted_key = self.encrypt_key(key_material=plaintext_key, key_id=key_id)
        return (plaintext_key, encrypted_key)
```

### Exception Mapping

| Provider Exception | Mapped To |
|--------------------|-----------|
| `NotFoundException` / `NotFound` / `InvalidPath` | `KMSKeyNotFoundError` |
| `AccessDeniedException` / `PermissionDenied` / `Forbidden` | `KMSAuthenticationError` |
| `DependencyTimeoutException` / `ServiceUnavailable` / `VaultDown` | `KMSUnavailableError` |
| Missing SDK (boto3, google-cloud-kms, hvac) | `UnsupportedBackendError` |

---

## DEK Caching

In-memory LRU cache for decrypted DEKs to reduce KMS call latency.

### API

```python
class DEKCache(max_size: int = 100, ttl_seconds: int = 300)
```

| Method/Property | Signature | Description |
|-----------------|-----------|-------------|
| `get` | `get(encrypted_dek: str) -> bytes \| None` | Retrieve cached DEK or None |
| `put` | `put(encrypted_dek: str, plaintext_dek: bytes) -> None` | Cache a DEK; LRU eviction if full |
| `invalidate` | `invalidate(encrypted_dek: str) -> None` | Remove a specific entry |
| `clear` | `clear() -> None` | Remove all entries |
| `size` | `@property -> int` | Current entry count |

### Behavior

- **LRU eviction**: When at `max_size`, the least recently used entry is evicted. `get()` moves entries to most-recently-used.
- **TTL expiration**: Entries expire after `ttl_seconds`. Expired entries are cleaned up lazily on `get()`.
- **Thread-safe**: All operations protected by `threading.Lock`.

### Tuning

| Parameter | Too Small | Too Large | Default |
|-----------|-----------|-----------|---------|
| `max_size` | Frequent KMS calls | More DEKs in memory | 100 |
| `ttl_seconds` | Frequent KMS calls | Longer key exposure | 300 (5 min) |

### Security Notes

- Plaintext DEKs in process memory. Minimize exposure with shorter `ttl_seconds`.
- No disk persistence. Cleared on process exit.
- Call `cache.clear()` on key rotation.
- DEK values are never logged.

---

## Audit Logging

Structured JSON audit trail for all KMS operations via a dedicated Python logger named `ecl_crypto_utility.audit`.

### API

```python
from ecl_crypto_utility import log_kms_operation

log_kms_operation(
    operation="encrypt_dek",      # "encrypt_dek" | "decrypt_dek" | "generate_dek"
    key_id="my-key-id",
    backend_type="AWSKMSBackend",
    success=True,
    metadata={"request_id": "abc-123"},  # optional
)
```

`EnvelopeEncryptor` calls this automatically on every `encrypt()` and `decrypt()`.

### Log Entry Format

```json
{
    "operation": "encrypt_dek",
    "key_id": "arn:aws:kms:us-east-1:123:key/abc",
    "backend": "AWSKMSBackend",
    "success": true,
    "timestamp": "2026-04-08T12:00:00+00:00"
}
```

Success logs at `INFO`, failure at `ERROR` (with `metadata.error`).

### Attaching Custom Handlers

```python
import logging

audit_logger = logging.getLogger("ecl_crypto_utility.audit")
audit_logger.setLevel(logging.INFO)
audit_logger.addHandler(logging.FileHandler("audit.log"))

# Prevent propagation to root logger
audit_logger.propagate = False
```

### Security Notes

- Audit entries include key IDs, never plaintext key material.
- All timestamps are UTC (ISO 8601).
- Do not disable audit logging in production.
- Avoid placing sensitive data in the `metadata` field.

---

## Exceptions

All exceptions inherit from `ECLCryptoError`. Every exception accepts `message` and optional `cause` (chains via `__cause__`).

### Hierarchy

```
ECLCryptoError
├── EncryptionError
├── DecryptionError
├── SigningError
├── VerificationError
├── HashingError
├── KeyDerivationError
├── KeyGenerationError
├── TokenGenerationError
├── KMSError
│   ├── KMSUnavailableError
│   ├── KMSAuthenticationError
│   └── KMSKeyNotFoundError
├── EnvelopeError
├── DEKCacheError
├── InvalidParameterError
└── UnsupportedBackendError
```

### Which Exceptions to Catch

| Scenario | Catch |
|----------|-------|
| Any crypto operation | `ECLCryptoError` |
| Symmetric encrypt/decrypt | `InvalidParameterError`, `EncryptionError`, `DecryptionError` |
| RSA encrypt/decrypt | `EncryptionError`, `DecryptionError` |
| ECDSA sign/verify | `SigningError`, `VerificationError` |
| Password/data hashing | `HashingError` |
| Key derivation | `KeyDerivationError` |
| Token generation | `TokenGenerationError` |
| Envelope encryption | `EnvelopeError`, `KMSError` (and subclasses) |
| KMS backend instantiation | `UnsupportedBackendError` |
| KMS retryable errors | `KMSUnavailableError` (retryable); `KMSAuthenticationError`, `KMSKeyNotFoundError` (not retryable) |

### Usage

```python
from ecl_crypto_utility import decrypt
from ecl_crypto_utility.exceptions import DecryptionError, InvalidParameterError

try:
    plaintext = decrypt(encrypted_data=ciphertext, key=key)
except InvalidParameterError as exc:
    print(f"Invalid key: {exc}")
except DecryptionError as exc:
    print(f"Decryption failed: {exc}")
    if exc.cause:
        print(f"Root cause: {exc.cause}")
```

### Security Notes

- Exception messages may contain operational details. Do not expose them to end users.
- `KMSUnavailableError` is retryable (with exponential backoff). Other KMS errors are configuration issues.

---

## Security Policy

Security vulnerabilities should be reported via email to **security@ecl.com** (placeholder). Please do **not** report security issues via public GitHub issues.

This package follows a private disclosure process. After a vulnerability is confirmed, we will:

1. Acknowledge the report within 48 hours.
2. Provide an estimated timeline for a fix.
3. Release a patched version and publish a security advisory.

---

## License

MIT License. See [LICENSE](LICENSE) for details.
