Post-Quantum file encryption & signing tool (ML-KEM + ML-DSA).
Project description
pqcrypt
Post-quantum file encryption and signing from the command line.
Keys live on a microSD card. Everyday encryption needs no card inserted.
✨ Quick start
# Install
pip install pqcrypt
# Generate your encryption key (SD card must be inserted)
pqcrypt init
# Encrypt a file (no SD card needed after caching)
pqcrypt encrypt secret.pdf
# Decrypt (requires SD card)
pqcrypt decrypt secret.pdf.age
What it does
pqcrypt wraps two audited, battle-tested command-line tools behind a
single ergonomic interface:
| Layer | Tool | Algorithm | NIST standard |
|---|---|---|---|
| Encryption | age ≥ 1.3.0 |
HPKE / ML-KEM-768 + X25519 (hybrid) | FIPS 203 |
| Signing | openssl ≥ 3.5 (or 3.x + oqs-provider) |
ML-DSA-65 | FIPS 204 |
Both algorithms are post-quantum: they resist attacks from future large-scale quantum computers. The hybrid encryption scheme (ML-KEM-768 + X25519) additionally retains classical security — if either primitive is ever broken, the other continues to protect your files.
Why microSD?
Your private keys live only on the card.
- Encryption and signature verification work every day without the card.
- Decryption and signing require the card + a passphrase — physical possession and knowledge combined.
- Losing the workstation does not expose the keys. Losing the card without the passphrase does not expose them either.
Security design
┌─────────────────── microSD card ────────────────────┐
│ main.key.age ← age identity │
│ └── passphrase-encrypted by age itself │
│ signing.key.pem ← ML-DSA-65 private key │
│ └── AES-256-CBC PKCS#8, passphrase by openssl │
│ main.pub ← age public recipient (copy)│
│ signing.pub.pem ← ML-DSA public key (copy) │
└─────────────────────────────────────────────────────┘
~/.config/pqcrypt/
main.pub ← cached for offline encryption
signing.pub.pem ← cached for offline verification
Key invariants
-
Cleartext age identity never touches disk.
Twoageprocesses are connected by an OS pipe: the first decrypts the passphrase-protected identity; the second receives it viastdin(age -d -i -). The cleartext key is never written bypqcrypt. -
All writes are atomic.
Output goes to<dst>.part, thenrename()(POSIX atomic on the same filesystem). A crash orCtrl-Cnever leaves a confusing half-written file behind. -
Encrypt-then-sign.
When--signis used, the signature covers the ciphertext, not the plaintext. This means verification works with only the public key, and you can detect tampering before attempting decryption. -
Verify-then-decrypt ordering.
decrypt --verifychecks the signature first and refuses to decrypt if it fails — even before the SD card passphrase is requested. -
Path-traversal protection.
Every tar member is validated before extraction. Absolute paths,..-traversal, and links escaping the destination directory are rejected (CVE-2007-4559mitigation).
Requirements
Both platforms
| Tool | Version | Install |
|---|---|---|
| Python | ≥ 3.10 | system |
age |
≥ 1.3.0 | see below |
openssl |
≥ 3.5, or 3.x + oqs-provider | see below |
age 1.3.0 is the minimum because ML-KEM support (
age1pq1…recipients) was introduced in that release (December 2025).
macOS
brew install age
brew install openssl@3 # ships ML-DSA in 3.5+
If your Homebrew openssl is older than 3.5, also install the oqs-provider.
Ubuntu
# age — use the system package on 24.10+ which ships 1.3;
# on older releases grab the static binary from GitHub releases.
sudo apt install age
# openssl 3.5 landed in Ubuntu 25.04+
sudo apt install openssl
# On older Ubuntu, install oqs-provider:
# https://github.com/open-quantum-safe/oqs-provider/releases
Verify the stack is ready
pqcrypt status
Expected output:
OS: Linux (6.8.0-51-generic)
age: /usr/bin/age (age v1.3.0)
openssl: /usr/bin/openssl (OpenSSL 3.5.0 ...)
SD card: /media/alice/PQKEYS ✓
Encryption key: /media/alice/PQKEYS/main.key.age ✓
Encryption pub: /home/alice/.config/pqcrypt/main.pub ✓
Signing key: /media/alice/PQKEYS/signing.key.pem ✓
Signing pub: /home/alice/.config/pqcrypt/signing.pub.pem ✓
Installation
# Install from PyPI
pip install pqcrypt
# Or install directly from GitHub (latest development version)
pip install git+https://github.com/xvi-xv-xii-ix-xxii-ix-xiv/pqcrypt.git
After installation, the pqcrypt command is globally available.
Getting started
Step 1 — Format the microSD
Format the card as exFAT with the label PQKEYS (readable on both
macOS and Linux without extra drivers).
💡 Recommended: Use the official SD Memory Card Formatter. This free tool from the SD Association ensures optimal performance and compliance with SD specifications, avoiding potential formatting errors that can occur with built-in OS tools.
Alternative methods:
macOS: Disk Utility → Erase → ExFAT, name PQKEYS.
Linux:
# Identify your card first — triple-check before mkfs!
lsblk -f
sudo mkfs.exfat -n PQKEYS /dev/sdX1
Step 2 — Generate your encryption key
Insert the card, then:
pqcrypt init
You will be prompted twice for a passphrase (choose a strong one — a 6-word diceware phrase works well). The tool will:
- Generate a hybrid ML-KEM-768 + X25519 key pair.
- Write the passphrase-encrypted private identity to the card
(
PQKEYS/main.key.age). - Cache the public recipient locally (
~/.config/pqcrypt/main.pub).
Step 3 — Generate your signing key (optional)
pqcrypt init-signing
You will be prompted for a passphrase to encrypt the ML-DSA-65 private key. The public key is cached locally for offline verification.
The signing key can use a different passphrase from the encryption key — this is recommended.
Step 4 — Back up the SD card
# On Linux — create a bit-for-bit image backup
dd if=/dev/sdX of=~/pqkeys-backup.img bs=4M status=progress
# Store a second physical copy in a different location.
Without a backup, losing the card means losing access to everything encrypted with it.
Usage
Encrypt a file
# SD card NOT required — uses the locally cached public recipient
pqcrypt encrypt ~/Documents/passport.pdf
# → ~/Documents/passport.pdf.age
Encrypt a directory
pqcrypt encrypt ~/Projects/client-secrets/
# → ~/Projects/client-secrets.tar.gz.age
Encrypt and sign in one step
# SD card IS required for signing (private signing key)
pqcrypt encrypt ~/Documents/passport.pdf --sign
# → ~/Documents/passport.pdf.age
# → ~/Documents/passport.pdf.age.sig
Decrypt a file
# SD card required — you will be prompted for the encryption passphrase
pqcrypt decrypt ~/Documents/passport.pdf.age
# → ~/Documents/passport.pdf
Decrypt and verify before decrypting
pqcrypt decrypt ~/Documents/passport.pdf.age --verify
# Aborts with exit code 1 if the signature is missing or invalid.
Verify a signature without decrypting
# SD card NOT required — uses the locally cached public signing key
pqcrypt verify ~/Documents/passport.pdf.age
# Exit 0 = valid, exit 2 = invalid
Custom output path
pqcrypt encrypt report.pdf -o /tmp/report_encrypted.pdf.age
pqcrypt decrypt report.pdf.age -o ~/safe/report.pdf
Show file metadata
pqcrypt info passport.pdf.age
# Requires age-inspect (ships with age >= 1.3.0)
Diagnostics
pqcrypt status
File layout reference
| File | Location | Required for |
|---|---|---|
main.key.age |
microSD | Decryption |
signing.key.pem |
microSD | Signing |
main.pub |
~/.config/pqcrypt/ |
Encryption (no card needed) |
signing.pub.pem |
~/.config/pqcrypt/ |
Verification (no card needed) |
| Extension | Contents |
|---|---|
.age |
Single encrypted file |
.tar.gz.age |
Encrypted directory archive |
.sig |
Detached ML-DSA-65 signature |
Configuration via environment variables
| Variable | Default | Description |
|---|---|---|
PQCRYPT_SD_LABEL |
PQKEYS |
Volume label of the microSD card |
PQCRYPT_AGE |
age |
Path to the age binary |
PQCRYPT_AGE_KEYGEN |
age-keygen |
Path to age-keygen |
PQCRYPT_OPENSSL |
openssl |
Path to the openssl binary |
PQCRYPT_SIGN_ALG |
ML-DSA-65 |
ML-DSA variant (ML-DSA-44 / ML-DSA-65 / ML-DSA-87) |
XDG_CONFIG_HOME |
~/.config |
Override the config cache directory |
Example — using a non-default label and a Homebrew openssl on macOS:
export PQCRYPT_SD_LABEL=MYKEYS
export PQCRYPT_OPENSSL=/opt/homebrew/opt/openssl@3/bin/openssl
pqcrypt status
Running the tests
# Clone the repository first
git clone https://github.com/xvi-xv-xii-ix-xxii-ix-xiv/pqcrypt.git
cd pqcrypt
python -m unittest test_pqcrypt -v
The test suite has 53 tests and no external dependencies — it does not
require age or openssl to be installed. Every subprocess call is
replaced by unittest.mock.
Coverage areas:
- SD card locator factory dispatching by OS
- Operation applicability and dispatcher ordering
- Default destination path computation
- Atomic-write invariants (failure, success,
KeyboardInterrupt, existing destination) - Path-traversal protection (absolute paths,
.., symlinks, hardlinks) OpenSSLBackendpre-flight: ML-DSA present / absent / non-hyphenated formSigner: correct arguments, atomic.sigwrite, cleanup on failure, duplicate guardVerifier: True/False return, default.sigpath, missing file errorsSigningKeyManager: cache preference, SD fallback,KeyMissingErrorEncryptCommand --signintegrationDecryptCommand --verifyintegration (abort on bad sig, proceed on good)VerifyCommandexit codesConfigsanity (algorithm names, suffixes, minimum versions)
Algorithm selection rationale
Why ML-KEM-768 + X25519 (hybrid)?
- ML-KEM-768 provides NIST security level 3 (~AES-192) against quantum adversaries.
- X25519 provides classical Diffie-Hellman security.
- The hybrid KEM means an attacker must break both to compromise the key exchange. This protects against harvest-now-decrypt-later attacks (recording ciphertexts today, decrypting them once a quantum computer exists) without abandoning time-tested classical security.
- The
agespecification for hybrid PQ recipients is published at https://age-encryption.org/v1 and the implementation has been publicly reviewed.
Why ML-DSA-65?
- NIST security level 3 — a good balance of security and performance for personal use.
- If you need stronger guarantees (archival, high-value data), set
PQCRYPT_SIGN_ALG=ML-DSA-87(level 5) — no other changes required. -rawinis passed toopenssl pkeyutlbecause ML-DSA performs its own domain-separated hashing; pre-hashing the message externally would undermine that design.
Threat model and limitations
Protected against
- Passive adversary with classical or quantum computer intercepting stored files.
- Attacker who steals only the workstation (no card, no passphrase).
- Attacker who steals only the SD card (no passphrase).
- Maliciously crafted archives attempting path traversal.
- Tampered ciphertexts (with
--sign/--verify).
Not protected against
- Attacker with both the SD card and the passphrase.
- Malware running as the same user at the time of decryption (the cleartext
is in memory / written to disk by your application, not by
pqcrypt). - Side-channel attacks on the CPU (ML-KEM is constant-time in
age's Go implementation; the Python wrapper itself does not perform any cryptographic operations). - Loss of the SD card without a backup — this is an availability risk, not a confidentiality risk.
Backup checklist
After init and init-signing, verify you have:
- Primary microSD card in a safe location.
- At least one physical backup card (bank safe-deposit box, trusted person off-site).
- Passphrases stored in a separate, offline password manager or written and stored securely (not with the card).
- A test decryption: encrypt a small test file, eject the card, reinsert it, decrypt, compare.
- Reminder in your calendar to re-test every 6 months.
Contributing
Pull requests are welcome. Please:
- Run
python -m unittest test_pqcrypt -v— all tests must pass. - Add tests for any new behaviour.
- Keep
pqcrypt.pya single file with no third-party imports — the zero-dependency property is a feature. - Do not weaken the security invariants described above without a detailed justification in the PR description.
License
MIT — see LICENSE.
Acknowledgements
- FiloSottile/age — the encryption engine powering this tool.
- NIST PQC Standardisation — ML-KEM (FIPS 203) and ML-DSA (FIPS 204).
- Open Quantum Safe / oqs-provider — OpenSSL provider for systems not yet on OpenSSL 3.5.
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 pqcrypt-1.0.0.tar.gz.
File metadata
- Download URL: pqcrypt-1.0.0.tar.gz
- Upload date:
- Size: 18.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bdc69753e465fd1b698a8a27f3d4a11c50249d128f6e851b43d3b53b6c2454b4
|
|
| MD5 |
20af17cdc5f145e5230fd61ee69cfb1e
|
|
| BLAKE2b-256 |
d32983e2d60b920cf5e7a6f480bd9d567445394aee4afa5a359c7ace90d4e5e4
|
Provenance
The following attestation bundles were made for pqcrypt-1.0.0.tar.gz:
Publisher:
publish-to-pypi.yml on xvi-xv-xii-ix-xxii-ix-xiv/pqcrypt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pqcrypt-1.0.0.tar.gz -
Subject digest:
bdc69753e465fd1b698a8a27f3d4a11c50249d128f6e851b43d3b53b6c2454b4 - Sigstore transparency entry: 1417568066
- Sigstore integration time:
-
Permalink:
xvi-xv-xii-ix-xxii-ix-xiv/pqcrypt@62e499fa78fc7df1797ceea4e4f9c2cd7e9f0a3f -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/xvi-xv-xii-ix-xxii-ix-xiv
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-to-pypi.yml@62e499fa78fc7df1797ceea4e4f9c2cd7e9f0a3f -
Trigger Event:
release
-
Statement type:
File details
Details for the file pqcrypt-1.0.0-py3-none-any.whl.
File metadata
- Download URL: pqcrypt-1.0.0-py3-none-any.whl
- Upload date:
- Size: 18.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b3a3e3bd285c3942f8dee11324b09aadf8363dc17d22e7ef7d30d65ed3b04aeb
|
|
| MD5 |
d7f1d387b9e901273477a5348099db73
|
|
| BLAKE2b-256 |
99b79ed00dd51533a61b53aea33a35d8f160758057fbd6408ceec83b40e657d6
|
Provenance
The following attestation bundles were made for pqcrypt-1.0.0-py3-none-any.whl:
Publisher:
publish-to-pypi.yml on xvi-xv-xii-ix-xxii-ix-xiv/pqcrypt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pqcrypt-1.0.0-py3-none-any.whl -
Subject digest:
b3a3e3bd285c3942f8dee11324b09aadf8363dc17d22e7ef7d30d65ed3b04aeb - Sigstore transparency entry: 1417568123
- Sigstore integration time:
-
Permalink:
xvi-xv-xii-ix-xxii-ix-xiv/pqcrypt@62e499fa78fc7df1797ceea4e4f9c2cd7e9f0a3f -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/xvi-xv-xii-ix-xxii-ix-xiv
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-to-pypi.yml@62e499fa78fc7df1797ceea4e4f9c2cd7e9f0a3f -
Trigger Event:
release
-
Statement type: