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
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.
- 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...)
- 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
- 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
# 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# 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
- 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)
# 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 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 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)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
# 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!# 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)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>
| 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 |
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)
# Install dev dependencies
pip install -e ".[dev]"
# Run tests
pytest tests/ -v
# With coverage
pytest tests/ --cov=pqage --cov-report=htmlNote: 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.
Apache License 2.0 - see LICENSE for details.
- 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