# Crypto Utility Usage Guide

This document provides examples for using the ClaudeTools encryption utilities.

## Overview

The crypto utilities provide secure encryption and decryption functions for sensitive data such as:
- User credentials
- API keys and tokens
- Passwords
- OAuth secrets
- Database connection strings

## Features

- **AES-256 symmetric encryption** via Fernet (AES-128-CBC + HMAC)
- **Authenticated encryption** to prevent tampering
- **Random IV** for each encryption (same plaintext produces different ciphertexts)
- **Base64 encoding** for safe storage in databases and config files
- **Proper error handling** for invalid keys or corrupted data
- **Type safety** with type hints

## Setup

### 1. Generate an Encryption Key

```python
from api.utils.crypto import generate_encryption_key

# Generate a new key (only do this once during initial setup)
key = generate_encryption_key()
print(f"ENCRYPTION_KEY={key}")
```

### 2. Add to Environment

Add the generated key to your `.env` file:

```bash
ENCRYPTION_KEY=a59976f06d88049f7e3c2b1a8d4e5f6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2
```

**Security Notes:**
- Never commit the `.env` file to version control
- Use different keys for development, staging, and production
- Store production keys in a secure secrets manager
- Never rotate keys without migrating existing encrypted data

## Basic Usage

### Encrypting Data

```python
from api.utils.crypto import encrypt_string

# Encrypt sensitive data
api_key = "sk-1234567890abcdef"
encrypted_api_key = encrypt_string(api_key)

# Store encrypted value in database
user.encrypted_api_key = encrypted_api_key
db.commit()
```

### Decrypting Data

```python
from api.utils.crypto import decrypt_string

# Retrieve encrypted value from database
encrypted_value = user.encrypted_api_key

# Decrypt it
api_key = decrypt_string(encrypted_value)

# Use the decrypted value
response = requests.get(api_url, headers={"Authorization": f"Bearer {api_key}"})
```

### Error Handling with Default Values

```python
from api.utils.crypto import decrypt_string

# Return a default value if decryption fails
api_key = decrypt_string(user.encrypted_api_key, default="")

if not api_key:
    print("Unable to decrypt API key - may need to re-authenticate")
```

## Advanced Examples

### Database Model with Encrypted Field

```python
from sqlalchemy import Column, String, Integer
from sqlalchemy.orm import declarative_base
from api.utils.crypto import encrypt_string, decrypt_string

Base = declarative_base()

class UserCredential(Base):
    __tablename__ = "user_credentials"

    id = Column(Integer, primary_key=True)
    service_name = Column(String(100), nullable=False)
    username = Column(String(100), nullable=False)
    encrypted_password = Column(String(500), nullable=False)

    def set_password(self, password: str):
        """Encrypt and store the password."""
        self.encrypted_password = encrypt_string(password)

    def get_password(self) -> str:
        """Decrypt and return the password."""
        return decrypt_string(self.encrypted_password)

# Usage
credential = UserCredential(
    service_name="GitHub",
    username="user@example.com"
)
credential.set_password("my_secure_password_123")

# Later, retrieve the password
password = credential.get_password()
```

### API Endpoint Example

```python
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from api.utils.crypto import encrypt_string, decrypt_string
from api.database import get_db

router = APIRouter()

@router.post("/credentials")
async def create_credential(
    service: str,
    username: str,
    password: str,
    db: Session = Depends(get_db)
):
    """Store encrypted credentials."""
    try:
        # Encrypt the password before storing
        encrypted_password = encrypt_string(password)

        credential = UserCredential(
            service_name=service,
            username=username,
            encrypted_password=encrypted_password
        )

        db.add(credential)
        db.commit()

        return {"message": "Credentials stored securely"}

    except Exception as e:
        raise HTTPException(status_code=500, detail="Failed to encrypt credentials")

@router.get("/credentials/{service}")
async def get_credential(service: str, db: Session = Depends(get_db)):
    """Retrieve and decrypt credentials."""
    credential = db.query(UserCredential).filter_by(service_name=service).first()

    if not credential:
        raise HTTPException(status_code=404, detail="Credentials not found")

    try:
        # Decrypt the password
        password = decrypt_string(credential.encrypted_password)

        return {
            "service": credential.service_name,
            "username": credential.username,
            "password": password  # In production, consider not returning plaintext
        }

    except ValueError:
        raise HTTPException(status_code=500, detail="Failed to decrypt credentials")
```

### Batch Encryption

```python
from api.utils.crypto import encrypt_string

def encrypt_user_secrets(user_data: dict) -> dict:
    """Encrypt all sensitive fields in user data."""
    encrypted_data = user_data.copy()

    # List of fields to encrypt
    sensitive_fields = ['password', 'api_key', 'oauth_token', 'secret_key']

    for field in sensitive_fields:
        if field in encrypted_data and encrypted_data[field]:
            encrypted_data[f'encrypted_{field}'] = encrypt_string(encrypted_data[field])
            del encrypted_data[field]  # Remove plaintext

    return encrypted_data

# Usage
user_data = {
    "username": "john_doe",
    "email": "john@example.com",
    "password": "super_secret_password",
    "api_key": "sk-1234567890"
}

encrypted_user = encrypt_user_secrets(user_data)
# Result: { "username": "john_doe", "email": "john@example.com",
#           "encrypted_password": "gAAAAAB...", "encrypted_api_key": "gAAAAAB..." }
```

## Security Best Practices

### DO:
- Use the encryption for passwords, API keys, tokens, and sensitive credentials
- Store encrypted values in database fields with adequate length (500+ chars)
- Use VARCHAR or TEXT fields for encrypted data
- Validate encryption key exists and is correctly formatted
- Log encryption/decryption failures without logging sensitive data
- Use `default` parameter for graceful degradation

### DON'T:
- Don't encrypt non-sensitive data (names, emails, public info)
- Don't log decrypted values
- Don't commit encryption keys to version control
- Don't reuse encryption keys across environments
- Don't rotate keys without a migration plan
- Don't encrypt large files (use this for credentials only)

## Error Handling

```python
from api.utils.crypto import decrypt_string

try:
    password = decrypt_string(encrypted_value)
except ValueError as e:
    # Handle invalid ciphertext or wrong key
    logger.error(f"Decryption failed: {e}")
    # Prompt user to re-enter credentials

# Alternative: Use default value
password = decrypt_string(encrypted_value, default=None)
if password is None:
    # Handle failed decryption
    request_user_credentials()
```

## Testing

```python
import pytest
from api.utils.crypto import encrypt_string, decrypt_string

def test_encryption_roundtrip():
    """Test that encryption and decryption work correctly."""
    original = "my_secret_password"
    encrypted = encrypt_string(original)
    decrypted = decrypt_string(encrypted)

    assert decrypted == original
    assert encrypted != original
    assert len(encrypted) > len(original)

def test_encryption_randomness():
    """Test that same input produces different ciphertexts."""
    original = "test_password"
    encrypted1 = encrypt_string(original)
    encrypted2 = encrypt_string(original)

    # Different ciphertexts
    assert encrypted1 != encrypted2

    # But both decrypt to same value
    assert decrypt_string(encrypted1) == original
    assert decrypt_string(encrypted2) == original

def test_invalid_ciphertext():
    """Test error handling for invalid data."""
    with pytest.raises(ValueError):
        decrypt_string("not_valid_ciphertext")

def test_type_validation():
    """Test type checking."""
    with pytest.raises(TypeError):
        encrypt_string(12345)  # Not a string

    with pytest.raises(TypeError):
        decrypt_string(12345)  # Not a string
```

## Troubleshooting

### "Invalid encryption key" Error

**Cause:** The `ENCRYPTION_KEY` environment variable is missing or incorrectly formatted.

**Solution:**
1. Generate a new key: `python -c "from api.utils.crypto import generate_encryption_key; print(generate_encryption_key())"`
2. Add to `.env`: `ENCRYPTION_KEY=<generated_key>`
3. Ensure the key is exactly 64 hex characters (32 bytes)

### "Failed to decrypt data" Error

**Cause:** One of the following:
- Data was encrypted with a different key
- Data was corrupted
- Data was tampered with

**Solution:**
1. Verify you're using the correct encryption key
2. Check if encryption key was rotated without migrating data
3. For corrupted data, request user to re-enter credentials

### "Encryption key must be 32 bytes" Error

**Cause:** The encryption key is not the correct length.

**Solution:**
Ensure your `ENCRYPTION_KEY` is exactly 64 hex characters (representing 32 bytes):
```bash
# Correct format (64 characters)
ENCRYPTION_KEY=a59976f06d88049f7e3c2b1a8d4e5f6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2

# Incorrect format (too short)
ENCRYPTION_KEY=abc123
```

## Performance Considerations

- Encryption/decryption is fast (~microseconds per operation)
- Suitable for real-time API requests
- For bulk operations, consider batching in background tasks
- Encrypted data is ~33% larger than original (due to base64 + auth tag)
- Plan database field sizes accordingly (recommend 500+ chars for encrypted fields)

## Migration and Key Rotation

If you need to rotate encryption keys:

1. Generate a new key
2. Create a migration script:

```python
from api.utils.crypto import decrypt_string, encrypt_string
import os

def migrate_encrypted_data(old_key: str, new_key: str):
    """Migrate data from old key to new key."""
    # Temporarily set old key
    os.environ['ENCRYPTION_KEY'] = old_key
    from api.utils.crypto import decrypt_string as old_decrypt

    # Get all encrypted records
    credentials = db.query(UserCredential).all()

    for cred in credentials:
        # Decrypt with old key
        old_password = old_decrypt(cred.encrypted_password)

        # Re-encrypt with new key
        os.environ['ENCRYPTION_KEY'] = new_key
        from api.utils.crypto import encrypt_string as new_encrypt

        cred.encrypted_password = new_encrypt(old_password)

    db.commit()
```

3. Run migration in a maintenance window
4. Update environment variable
5. Verify all data decrypts correctly
6. Securely delete old key

## API Reference

### `encrypt_string(plaintext: str) -> str`

Encrypts a string using Fernet symmetric encryption.

**Parameters:**
- `plaintext` (str): The string to encrypt

**Returns:**
- str: Base64-encoded encrypted string

**Raises:**
- `ValueError`: If the encryption key is invalid
- `TypeError`: If plaintext is not a string

### `decrypt_string(ciphertext: str, default: Optional[str] = None) -> str`

Decrypts a Fernet-encrypted string back to plaintext.

**Parameters:**
- `ciphertext` (str): Base64-encoded encrypted string from `encrypt_string()`
- `default` (Optional[str]): Optional default value to return if decryption fails

**Returns:**
- str: Decrypted plaintext string

**Raises:**
- `ValueError`: If ciphertext is invalid or decryption fails (when `default=None`)
- `TypeError`: If ciphertext is not a string

### `generate_encryption_key() -> str`

Generates a new random encryption key.

**Returns:**
- str: 64-character hex string representing a 32-byte key

**Usage:**
Only use during initial setup or key rotation. Never rotate keys without migrating existing encrypted data.
