age-compatible post-quantum encryption with hybrid ML-KEM-1024 + X25519
Project description
pq-age
age-compatible post-quantum encryption for Python
Hybrid ML-KEM-1024 + X25519 with full age v1 format interoperability
age v1 format · ML-KEM-1024 + X25519 hybrid · SSH Ed25519 keys · scrypt · ChaCha20-Poly1305
Features • Install • Usage • Interop • Security
What is pq-age?
pq-age is a Python implementation of the age encryption format with an additional hybrid post-quantum recipient type (mlkem1024-x25519-v1). It is fully interoperable with:
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_digestwhere possible - Memory wiping: Best-effort in Python; native extension uses mlock + zeroize
- scrypt: Password-based encryption uses age-compatible scrypt
📦 Installation
Quick Install
# 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
# 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
zeroizecrate - Constant-time comparisons via
subtlecrate
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
# 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
# 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
# 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)
# 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)
# 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
# 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.
- Not standardized: The
mlkem1024-x25519-v1recipient type is a custom extension - only pq-age can decrypt it - Post-quantum only with hybrid: Standard X25519/SSH/scrypt recipients provide classical security only
- Best-effort memory wiping: Python cannot guarantee memory is wiped; native extension helps but isn't magic
- Protect your keys: Use
chmod 600on identity files
For more details, see SECURITY.md.
📄 License
Apache License 2.0 - see LICENSE for details.
Acknowledgments
- age by Filippo Valsorda - Original format specification and reference implementation
- rage by str4d - Rust implementation (used for interoperability testing)
- Open Quantum Safe - liboqs ML-KEM-1024 implementation
- PyNaCl - X25519 and ChaCha20-Poly1305 via libsodium
- C2SP age spec - Formal protocol specification
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pq_age-0.1.0.tar.gz.
File metadata
- Download URL: pq_age-0.1.0.tar.gz
- Upload date:
- Size: 86.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3c81fe2a1b88b9f193bc39861fc87188d93e4b97b1c594767a95510e37643307
|
|
| MD5 |
b5c16ec18f5928e6e3ff9c14deb64e62
|
|
| BLAKE2b-256 |
b7aefad44cfecac767eb16de794886c62b9a29182d97266de9c970d18bd09c8c
|
File details
Details for the file pq_age-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pq_age-0.1.0-py3-none-any.whl
- Upload date:
- Size: 56.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
74a367a170bd104befb3619338a795e9907c07ea876a3387827a432ed227a37b
|
|
| MD5 |
29869ec7fc8eeb64d67a2a311c4e1e7b
|
|
| BLAKE2b-256 |
fae1c8107fb48440c9676483297e5f6ccb12a23b2a07a2908d8a5f01454fceba
|