# pyaegis

[![PyPI version](https://badge.fury.io/py/pyaegis.svg)](https://badge.fury.io/py/pyaegis)
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jedisct1/pyaegis/blob/main/LICENSE)

Python bindings for libaegis - high-performance AEGIS authenticated encryption.

- [pyaegis](#pyaegis)
  - [Overview](#overview)
    - [Supported Variants](#supported-variants)
      - [Authenticated Encryption (AEAD)](#authenticated-encryption-aead)
      - [Message Authentication Codes (MAC)](#message-authentication-codes-mac)
  - [Installation](#installation)
    - [From PyPI](#from-pypi)
    - [From Source](#from-source)
    - [Building a Distribution](#building-a-distribution)
  - [Usage](#usage)
    - [Basic Encryption/Decryption](#basic-encryptiondecryption)
    - [With Additional Authenticated Data (AAD)](#with-additional-authenticated-data-aad)
    - [Detached Tag Mode](#detached-tag-mode)
    - [Pre-allocated Buffers](#pre-allocated-buffers)
    - [Tag Size](#tag-size)
    - [In-Place Encryption/Decryption](#in-place-encryptiondecryption)
    - [Streaming Encryption/Decryption](#streaming-encryptiondecryption)
      - [Streaming Encryption](#streaming-encryption)
      - [Streaming Decryption](#streaming-decryption)
    - [Stream Generation](#stream-generation)
    - [Message Authentication Code (MAC)](#message-authentication-code-mac)
  - [Random Access Files (RAF)](#random-access-files-raf)
    - [Basic Usage](#basic-usage)
    - [File-based Storage](#file-based-storage)
    - [Random Access Operations](#random-access-operations)
    - [Auto-detecting Algorithm](#auto-detecting-algorithm)
    - [Available RAF Classes](#available-raf-classes)
    - [Merkle Tree Commitment](#merkle-tree-commitment)
    - [Storage Backends](#storage-backends)
  - [Error Handling](#error-handling)
  - [Performance](#performance)
  - [Security Considerations](#security-considerations)

## Overview

pyaegis provides Pythonic interfaces to the AEGIS family of authenticated encryption algorithms.

AEGIS is a high-performance authenticated cipher that provides both confidentiality and authenticity guarantees.

### Supported Variants

#### Authenticated Encryption (AEAD)

- AEGIS-128L: 16-byte key, 16-byte nonce
- AEGIS-256: 32-byte key, 32-byte nonce
- AEGIS-128X2: 16-byte key, 16-byte nonce (recommended on most platforms)
- AEGIS-128X4: 16-byte key, 16-byte nonce (recommended on high-end Intel CPUs)
- AEGIS-256X2: 32-byte key, 32-byte nonce
- AEGIS-256X4: 32-byte key, 32-byte nonce (recommended if a 256-bit nonce is required)

#### Message Authentication Codes (MAC)

All AEAD variants have corresponding MAC variants for authentication without encryption:

- AegisMac128L, AegisMac256
- AegisMac128X2, AegisMac128X4
- AegisMac256X2, AegisMac256X4

## Installation

### From PyPI

Using [uv](https://docs.astral.sh/uv/):

```bash
uv pip install pyaegis
```

Or using pip:

```bash
pip install pyaegis
```

### From Source

The package compiles the C library automatically using any installed C compiler:

```bash
# Clone the repository
git clone https://github.com/jedisct1/pyaegis.git
cd pyaegis

# Install with uv (compiles C sources automatically)
uv pip install .

# Or for development
uv pip install -e .
```

Alternatively with pip:

```bash
pip install .
# Or for development
pip install -e .
```

### Building a Distribution

```bash
# With uv
uv run python -m build

# Or with pip
python -m build
```

This creates both source and wheel distributions in the `dist/` directory. The C sources are bundled in the package and compiled during installation.

## Usage

### Basic Encryption/Decryption

```python
from pyaegis import Aegis128L

# Create a cipher instance
cipher = Aegis128L()

# Generate random key and nonce
key = cipher.random_key()
nonce = cipher.random_nonce()

# Encrypt a message
plaintext = b"Hello, World!"
ciphertext = cipher.encrypt(key, nonce, plaintext)

# Decrypt the message
decrypted = cipher.decrypt(key, nonce, ciphertext)
assert decrypted == plaintext
```

### With Additional Authenticated Data (AAD)

```python
from pyaegis import Aegis256

cipher = Aegis256()
key = cipher.random_key()
nonce = cipher.random_nonce()

# AAD is authenticated but not encrypted
associated_data = b"metadata"

ciphertext = cipher.encrypt(key, nonce, b"secret", associated_data=associated_data)
plaintext = cipher.decrypt(key, nonce, ciphertext, associated_data=associated_data)
```

### Detached Tag Mode

```python
from pyaegis import Aegis128L

cipher = Aegis128L()
key = cipher.random_key()
nonce = cipher.random_nonce()

# Encrypt with detached tag
ciphertext, tag = cipher.encrypt_detached(key, nonce, b"secret")

# Decrypt with detached tag
plaintext = cipher.decrypt_detached(key, nonce, ciphertext, tag)
```

### Pre-allocated Buffers

For performance-sensitive applications, you can provide pre-allocated buffers to avoid memory allocation:

```python
from pyaegis import Aegis128L

cipher = Aegis128L()
key = cipher.random_key()
nonce = cipher.random_nonce()
plaintext = b"secret message"

# Pre-allocate output buffer for encryption
output_buffer = bytearray(len(plaintext) + cipher.tag_size)
cipher.encrypt(key, nonce, plaintext, into=output_buffer)

# Pre-allocate output buffer for decryption
ciphertext = bytes(output_buffer)  # Convert to bytes for decrypt input
plaintext_buffer = bytearray(len(ciphertext) - cipher.tag_size)
cipher.decrypt(key, nonce, ciphertext, into=plaintext_buffer)

# Also works with encrypt_detached
ciphertext_buffer = bytearray(len(plaintext))
ciphertext, tag = cipher.encrypt_detached(key, nonce, plaintext, ciphertext_into=ciphertext_buffer)
```

### Tag Size

By default, a 32-byte (256-bit) tag is used for maximum security. You can also use a 16-byte (128-bit) tag:

```python
cipher = Aegis128L(tag_size=16)
```

### In-Place Encryption/Decryption

For performance-critical applications, especially when working with large buffers (>10MB), in-place operations can provide 30-50% performance improvement by reducing memory bandwidth:

```python
from pyaegis import Aegis128X4

cipher = Aegis128X4()
key = cipher.random_key()
nonce = cipher.random_nonce()

# Encrypt in-place
buffer = bytearray(b"secret message")
tag = cipher.encrypt_inplace(key, nonce, buffer)
# buffer now contains ciphertext

# Decrypt in-place
cipher.decrypt_inplace(key, nonce, buffer, tag)
# buffer now contains plaintext again
```

In-place operations work with `bytearray` or `memoryview` objects and overwrite the input buffer directly. If decryption fails, the buffer is zeroed for security.

### Streaming Encryption/Decryption

For processing large data in chunks or when data arrives incrementally, use the streaming encryption/decryption classes. These allow you to encrypt or decrypt data piece by piece without loading everything into memory at once.

#### Streaming Encryption

```python
from pyaegis import AegisStreamEncrypt128L

key = AegisStreamEncrypt128L.random_key()
nonce = AegisStreamEncrypt128L.random_nonce()

# Create a streaming encryption context
with AegisStreamEncrypt128L(key, nonce, associated_data=b"metadata") as enc:
    # Encrypt data in chunks
    ciphertext1 = enc.update(b"first chunk of data")
    ciphertext2 = enc.update(b"second chunk of data")

    # Finalize and get the authentication tag
    tag = enc.final()

# Send ciphertext1 + ciphertext2 + tag to the recipient
```

#### Streaming Decryption

```python
from pyaegis import AegisStreamDecrypt128L, DecryptionError

# Create a streaming decryption context
with AegisStreamDecrypt128L(key, nonce, associated_data=b"metadata") as dec:
    # Decrypt data in chunks
    dec.update(ciphertext1)
    dec.update(ciphertext2)

    # Verify the authentication tag and get all plaintext
    try:
        plaintext = dec.verify(tag)
        # plaintext is only released after successful verification
    except DecryptionError:
        print("Authentication failed!")
```

**Security Note**: The streaming decryption API buffers all plaintext internally and only releases it after successful tag verification. This prevents using unauthenticated data.

**Available Streaming Classes**:

- `AegisStreamEncrypt128L` / `AegisStreamDecrypt128L`
- `AegisStreamEncrypt256` / `AegisStreamDecrypt256`
- `AegisStreamEncrypt128X2` / `AegisStreamDecrypt128X2`
- `AegisStreamEncrypt128X4` / `AegisStreamDecrypt128X4`
- `AegisStreamEncrypt256X2` / `AegisStreamDecrypt256X2`
- `AegisStreamEncrypt256X4` / `AegisStreamDecrypt256X4`

### Stream Generation

Generate a deterministic pseudo-random byte sequence (AEGIS-128L and AEGIS-256 only):

```python
from pyaegis import Aegis128L

key = Aegis128L.random_key()
nonce = Aegis128L.random_nonce()

# Generate 1024 pseudo-random bytes
random_bytes = Aegis128L.stream(key, nonce, 1024)

# With pre-allocated buffer for better performance
buffer = bytearray(1024)
random_bytes = Aegis128L.stream(key, nonce, 1024, into=buffer)
```

### Message Authentication Code (MAC)

Generate and verify authentication tags without encryption:

```python
from pyaegis import AegisMac128L, DecryptionError

key = AegisMac128L.random_key()
nonce = AegisMac128L.random_nonce()

# Generate MAC tag
mac = AegisMac128L(key, nonce)
mac.update(b"message part 1")
mac.update(b"message part 2")
tag = mac.final()

# Verify MAC tag
mac_verify = AegisMac128L(key, nonce)
mac_verify.update(b"message part 1message part 2")
try:
    mac_verify.verify(tag)
    print("Authentication successful!")
except DecryptionError:
    print("Authentication failed!")
```

Important: The same key must NOT be used for both MAC and encryption operations.

## Random Access Files (RAF)

The RAF API provides encrypted file storage with random access capabilities. Files are divided into fixed-size chunks, each independently encrypted, enabling efficient random access without decrypting the entire file.

### Basic Usage

```python
from pyaegis import AegisRaf128L, BytesIOStorage

# Create an in-memory storage backend
storage = BytesIOStorage()
key = AegisRaf128L.random_key()

# Create and write to an encrypted file
with AegisRaf128L(storage, key, create=True) as f:
    f.write(b"Hello, World!")
    f.write(b" More data.")

# Reopen and read
with AegisRaf128L(storage, key) as f:
    print(f.read())  # b'Hello, World! More data.'
```

### File-based Storage

```python
from pyaegis import AegisRaf128L, FileStorage

key = AegisRaf128L.random_key()

# Create encrypted file on disk
with FileStorage("/path/to/file.raf", "w+b") as storage, \
        AegisRaf128L(storage, key, create=True) as f:
    f.write(b"Secret data")

# Read from encrypted file
with FileStorage("/path/to/file.raf", "r+b") as storage, \
        AegisRaf128L(storage, key) as f:
    print(f.read())
```

### Random Access Operations

```python
from pyaegis import AegisRaf128L, BytesIOStorage

storage = BytesIOStorage()
key = AegisRaf128L.random_key()

with AegisRaf128L(storage, key, create=True) as f:
    f.write(b"0123456789")

    # Seek and read
    f.seek(5)
    print(f.read(3))  # b'567'

    # pread - read without changing position
    print(f.pread(3, 0))  # b'012'
    print(f.tell())  # 8 (unchanged)

    # pwrite - write without changing position
    f.pwrite(b"ABC", 3)
    f.seek(0)
    print(f.read())  # b'012ABC6789'
```

### Auto-detecting Algorithm

```python
from pyaegis import raf_open, raf_probe, BytesIOStorage, AegisRaf256

storage = BytesIOStorage()
key = AegisRaf256.random_key()

# Create with AEGIS-256
with AegisRaf256(storage, key, create=True) as f:
    f.write(b"data")

# Probe to see file parameters (without key)
alg_id, chunk_size, file_size = raf_probe(storage)
print(f"Algorithm: {alg_id}, Chunk size: {chunk_size}, Size: {file_size}")

# Auto-detect and open with correct class
with raf_open(storage, key) as f:
    print(f.read())  # b'data'
```

### Available RAF Classes

- `AegisRaf128L`: 16-byte key
- `AegisRaf256`: 32-byte key
- `AegisRaf128X2`, `AegisRaf128X4`: 16-byte key, multi-lane
- `AegisRaf256X2`, `AegisRaf256X4`: 32-byte key, multi-lane

### Merkle Tree Commitment

Individual RAF chunks are already authenticated by their AEAD tags. Optionally, a Merkle hash tree can track whole-file commitment: every write automatically updates a binary hash tree in memory, maintaining a single root hash that represents the current plaintext content of the entire file. This root can be stored externally (e.g. in a database) and later used to detect any modification to the file.

```python
from pyaegis import AegisRaf128L, BytesIOStorage

storage = BytesIOStorage()
key = AegisRaf128L.random_key()

# Create with Merkle tree enabled (uses SHA-256 by default)
with AegisRaf128L(storage, key, create=True, merkle=True) as f:
    f.write(b"important data")
    root = f.root_hash  # 32-byte SHA-256 root hash
    print(f"Root hash: {root.hex()}")

# Later: reopen and verify integrity
with AegisRaf128L(storage, key, merkle=True) as f:
    f.verify_root(root)  # raises RAFAuthenticationError on mismatch
```

For more control, you can call `merkle_rebuild()` and `merkle_verify()` separately:

```python
with AegisRaf128L(storage, key, merkle=True) as f:
    # Rebuild tree from file contents (decrypts every chunk)
    f.merkle_rebuild()

    # Verify all chunks against the in-memory tree
    corrupted = f.merkle_verify()  # None if clean, chunk index if corrupted
```

The `merkle_max_chunks` parameter controls how many chunks the tree can track (default: 16384, covering ~1 GiB at 64 KB chunks). For larger files, pass a higher value:

```python
with AegisRaf128L(storage, key, create=True, merkle=True, merkle_max_chunks=100000) as f:
    ...
```

Custom hash functions can be used by passing a `MerkleHasher` instance instead of `True`:

```python
from pyaegis import SHA256MerkleHasher

# The default hasher (equivalent to merkle=True)
hasher = SHA256MerkleHasher()

# Or implement your own: any object with hash_len, hash_leaf(),
# hash_parent(), hash_empty(), and hash_commitment() methods works.

with AegisRaf128L(storage, key, create=True, merkle=hasher) as f:
    ...
```

Merkle support works with `raf_open()` too:

```python
from pyaegis import raf_open

with raf_open(storage, key, merkle=True) as f:
    f.verify_root(expected_root)
```

### Storage Backends

- `FileStorage`: File-based storage using `os.pread`/`os.pwrite` (Unix only)
- `BytesIOStorage`: In-memory storage for testing

Custom backends can be implemented by following the `RAFStorage` protocol.

## Error Handling

AEAD and streaming operations raise `DecryptionError` on authentication failure:

```python
from pyaegis import Aegis128L, DecryptionError

cipher = Aegis128L()
key = cipher.random_key()
nonce = cipher.random_nonce()

try:
    plaintext = cipher.decrypt(key, nonce, tampered_ciphertext)
except DecryptionError:
    print("Authentication failed - ciphertext was tampered with!")
```

RAF operations use a separate exception hierarchy:

- `RAFAuthenticationError` -- chunk authentication failed (corruption or tampering)
- `RAFIOError` -- I/O failure during read/write
- `RAFConfigError` -- invalid configuration (bad chunk size, Merkle overflow, etc.)

```python
from pyaegis import AegisRaf128L, BytesIOStorage, RAFAuthenticationError

storage = BytesIOStorage()
key = AegisRaf128L.random_key()

with AegisRaf128L(storage, key, create=True) as f:
    f.write(b"data")

try:
    wrong_key = AegisRaf128L.random_key()
    with AegisRaf128L(storage, wrong_key) as f:
        f.read()
except RAFAuthenticationError:
    print("Wrong key or corrupted file!")
```

All exceptions inherit from `AegisError`.

## Performance

The library automatically detects CPU features at runtime and uses the most optimized implementation available:

- AES-NI on Intel/AMD processors
- ARM Crypto Extensions on ARM processors
- AVX2 and AVX-512 for multi-lane variants
- Software fallback for other platforms

Multi-lane variants (X2, X4) provide higher throughput on systems with appropriate SIMD support.

## Security Considerations

- Nonce Uniqueness: Never reuse a nonce with the same key. If you can't maintain a counter, use `random_nonce()` for each message.
- Key Management: Use `random_key()` to generate cryptographically secure keys. Keep keys secret.
- AAD: Additional authenticated data is not encrypted but is protected against tampering.
