pq-age 0.1.0
age-compatible post-quantum encryption with hybrid ML-KEM-1024 + X25519
Requires Python: >=3.12
Keywords: encryption, post-quantum, kyber, ml-kem, x25519, cryptography, security, age, age-encryption

<p align="center">
  <img src="assets/logo.svg" alt="pq-age logo" width="180" height="180">
</p>

<h1 align="center">pq-age</h1>

<p align="center">
  <strong>age-compatible post-quantum encryption for Python</strong><br>
  Hybrid ML-KEM-1024 + X25519 with full age v1 format interoperability
</p>

<p align="center">
  <code>age v1 format · ML-KEM-1024 + X25519 hybrid · SSH Ed25519 keys · scrypt · ChaCha20-Poly1305</code>
</p>

<p align="center">
  <a href="https://github.com/pqdude/pq-age/actions/workflows/ci.yml"><img src="https://github.com/pqdude/pq-age/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
  <a href="https://codecov.io/gh/pqdude/pq-age"><img src="https://codecov.io/gh/pqdude/pq-age/branch/main/graph/badge.svg" alt="Coverage"></a>
  <a href="https://github.com/pqdude/pq-age/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License"></a>
  <a href="https://pypi.org/project/pq-age/"><img src="https://img.shields.io/pypi/v/pq-age.svg" alt="PyPI"></a>
</p>

<p align="center">
  <a href="#-features">Features</a> •
  <a href="#-installation">Install</a> •
  <a href="#-usage">Usage</a> •
  <a href="#-interoperability">Interop</a> •
  <a href="SECURITY.md">Security</a>
</p>

---

## What is pq-age?

pq-age is a Python implementation of the [age encryption format](https://age-encryption.org/) with an additional **hybrid post-quantum recipient type** (`mlkem1024-x25519-v1`). It is fully interoperable with:

- [age](https://github.com/FiloSottile/age) (Go reference implementation)
- [rage](https://github.com/str4d/rage) (Rust implementation)
- Any other age-compatible tool

**Key differentiator:** pq-age adds a hybrid ML-KEM-1024 + X25519 recipient that provides defense-in-depth against both classical and quantum attacks. Both algorithms must be broken to compromise the encryption.

## 🔐 Features

### age-Compatible
- **age v1 format**: Files encrypted with pq-age (using X25519, SSH, or scrypt) can be decrypted by age/rage
- **SSH Ed25519 keys**: Encrypt to `~/.ssh/id_ed25519.pub`, decrypt with `~/.ssh/id_ed25519`
- **scrypt passwords**: Password-based encryption compatible with `age -p`
- **X25519 recipients**: Standard age public keys (`age1...`)

### Post-Quantum Extension
- **Hybrid ML-KEM-1024 + X25519**: New recipient type for quantum resistance
- **Defense-in-depth**: Both classical and PQ algorithms must be broken
- **NIST Level-5**: ML-KEM-1024 provides equivalent security to AES-256

### Security
- **STREAM cipher**: ChaCha20-Poly1305 with authenticated streaming (age standard)
- **Header MAC**: HMAC-SHA256 for header integrity
- **Constant-time comparisons**: Uses `hmac.compare_digest` where possible
- **Memory wiping**: Best-effort in Python; native extension uses mlock + zeroize
- **scrypt**: Password-based encryption uses age-compatible scrypt

## 📦 Installation

### Quick Install

```bash
# Clone and install
git clone https://github.com/pqdude/pq-age.git
cd pq-age

# Install liboqs (required for ML-KEM)
./scripts/install-liboqs.sh

# Create venv and install
python3.12 -m venv .venv
source .venv/bin/activate
pip install -e .

# Verify installation
pqage --version
```

### Production Installation

```bash
# Install with native extension (REQUIRED for production)
pip install pq-age[native]
```

The native extension provides:
- `mlock()` - prevents secrets from being swapped to disk
- Guaranteed memory zeroization via Rust `zeroize` crate
- Constant-time comparisons via `subtle` crate

### Dependencies

- **Python** >= 3.12
- **liboqs** >= 0.15.0 (for ML-KEM-1024)
- **pynacl** >= 1.5.0
- **liboqs-python** == 0.14.0 (pinned for security)
- **bech32** >= 1.2.0 (for standard age key format)

## 🚀 Usage

### Generate Hybrid Keypair

```bash
# Generate new identity (outputs to stdout)
pqage-keygen

# Save to file
pqage-keygen -o ~/.pqage/identity.txt

# Output:
# Public key: age1pq<base64...>
# Identity saved to: ~/.pqage/identity.txt
```

### Encrypt Files

```bash
# Encrypt with hybrid post-quantum key
pqage -r "age1pq<public-key>" -o secret.age plaintext.txt

# Encrypt to SSH key (age-compatible)
pqage -R ~/.ssh/id_ed25519.pub -o secret.age plaintext.txt

# Encrypt with password (age-compatible)
pqage -p -o secret.age plaintext.txt

# Multiple recipients (any can decrypt)
pqage -r "age1pq<alice>" -r "age1pq<bob>" -R ~/.ssh/carol.pub -o secret.age file.txt

# ASCII-armored output
pqage -a -r "age1pq<key>" -o secret.age.asc plaintext.txt
```

### Decrypt Files

```bash
# Decrypt with hybrid identity
pqage -d -i ~/.pqage/identity.txt -o plaintext.txt secret.age

# Decrypt with SSH key
pqage -d -i ~/.ssh/id_ed25519 -o plaintext.txt secret.age

# Decrypt with password
pqage -d -o plaintext.txt secret.age
# (prompts for password)
```

### CLI Reference

```
pqage [OPTIONS] [INPUT]

Encryption (default):
  -e, --encrypt    Encrypt mode (default)
  -r RECIPIENT     Recipient public key (age1pq... or age1...)
  -R PATH          SSH public key file for recipient
  -p               Encrypt with passphrase (scrypt)
  -o PATH          Output file (default: stdout)
  -a               ASCII-armored output
  -f               Overwrite existing output file

Decryption:
  -d, --decrypt    Decrypt mode
  -i PATH          Identity file (pq-age or SSH private key)
  -o PATH          Output file (default: stdout)

Other:
  -v, --verbose    Verbose output
  --version        Show version

pqage-keygen [OPTIONS]
  -o PATH          Output identity file (default: stdout)
  -f               Overwrite existing file
```

## 🔄 Interoperability

### With age/rage (Classical Recipients)

```bash
# Encrypt with pq-age, decrypt with age (SSH recipient)
pqage -R ~/.ssh/id_ed25519.pub -o secret.age plaintext.txt
age -d -i ~/.ssh/id_ed25519 -o plaintext.txt secret.age  # Works!

# Encrypt with age, decrypt with pq-age (password)
age -p -o secret.age plaintext.txt
pqage -d -o plaintext.txt secret.age  # Works!
```

### Hybrid Recipients (pq-age only)

```bash
# Encrypt with hybrid PQ recipient
pqage -r "age1pq<hybrid-key>" -o secret.age plaintext.txt

# Decrypt requires pq-age
pqage -d -i ~/.pqage/identity.txt -o plaintext.txt secret.age
# age/rage cannot decrypt (unknown recipient type)
```

## 📁 File Format

pq-age uses the standard age v1 format with an additional recipient type:

```
age-encryption.org/v1
-> mlkem1024-x25519-v1 <fingerprint-b64> <mlkem-ct-b64> <x25519-eph-b64>
<wrapped-file-key-b64>
-> X25519 <ephemeral-share-b64>
<wrapped-file-key-b64>
-> ssh-ed25519 <key-hash-b64> <ephemeral-share-b64>
<wrapped-file-key-b64>
-> scrypt <salt-b64> <log2-N>
<wrapped-file-key-b64>
--- <header-mac-b64>
<STREAM-encrypted-payload>
```

### Recipient Types

| Type | Description | Interop |
|------|-------------|---------|
| `X25519` | Classical age recipient | age, rage, pq-age |
| `ssh-ed25519` | SSH Ed25519 key | age, rage, pq-age |
| `scrypt` | Password-based | age, rage, pq-age |
| `mlkem1024-x25519-v1` | Hybrid post-quantum | **pq-age only** |

## 🏗️ Architecture

```
pqage/
├── age_format.py       # age v1 format parser/writer
├── age_file_ops.py     # High-level encrypt/decrypt
├── age_cli.py          # CLI interface
└── crypto/
    ├── age_stream.py   # age STREAM cipher
    ├── age_recipients.py   # Recipient implementations
    ├── ssh.py          # SSH key parsing
    ├── keys.py         # Key generation (SecureKeyBundle)
    ├── kdf.py          # HKDF-SHA256 key derivation
    ├── x25519.py       # X25519 helpers (clamping, ephemeral)
    ├── kem.py          # Hybrid ML-KEM-1024 + X25519
    └── utils.py        # Security utilities (secure_wipe)
```

## 🧪 Testing

```bash
# Install dev dependencies
pip install -e ".[dev]"

# Run tests
pytest tests/ -v

# With coverage
pytest tests/ --cov=pqage --cov-report=html
```

## ⚠️ Security Considerations

> **Note:** This is a hobby project, not audited production software.

1. **Not standardized**: The `mlkem1024-x25519-v1` recipient type is a custom extension - only pq-age can decrypt it
2. **Post-quantum only with hybrid**: Standard X25519/SSH/scrypt recipients provide classical security only
3. **Best-effort memory wiping**: Python cannot guarantee memory is wiped; native extension helps but isn't magic
4. **Protect your keys**: Use `chmod 600` on identity files

For more details, see [SECURITY.md](SECURITY.md).

## 📄 License

Apache License 2.0 - see [LICENSE](LICENSE) for details.

## Acknowledgments

- [age](https://github.com/FiloSottile/age) by Filippo Valsorda - Original format specification and reference implementation
- [rage](https://github.com/str4d/rage) by str4d - Rust implementation (used for interoperability testing)
- [Open Quantum Safe](https://openquantumsafe.org/) - liboqs ML-KEM-1024 implementation
- [PyNaCl](https://pynacl.readthedocs.io/) - X25519 and ChaCha20-Poly1305 via libsodium
- [C2SP age spec](https://c2sp.org/age) - Formal protocol specification