Skip to main content

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.

Python Version PyPI Version License: MIT CI No dependencies Platforms


✨ 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

  1. Cleartext age identity never touches disk.
    Two age processes are connected by an OS pipe: the first decrypts the passphrase-protected identity; the second receives it via stdin (age -d -i -). The cleartext key is never written by pqcrypt.

  2. All writes are atomic.
    Output goes to <dst>.part, then rename() (POSIX atomic on the same filesystem). A crash or Ctrl-C never leaves a confusing half-written file behind.

  3. Encrypt-then-sign.
    When --sign is 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.

  4. Verify-then-decrypt ordering.
    decrypt --verify checks the signature first and refuses to decrypt if it fails — even before the SD card passphrase is requested.

  5. Path-traversal protection.
    Every tar member is validated before extraction. Absolute paths, ..-traversal, and links escaping the destination directory are rejected (CVE-2007-4559 mitigation).


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)
  • OpenSSLBackend pre-flight: ML-DSA present / absent / non-hyphenated form
  • Signer: correct arguments, atomic .sig write, cleanup on failure, duplicate guard
  • Verifier: True/False return, default .sig path, missing file errors
  • SigningKeyManager: cache preference, SD fallback, KeyMissingError
  • EncryptCommand --sign integration
  • DecryptCommand --verify integration (abort on bad sig, proceed on good)
  • VerifyCommand exit codes
  • Config sanity (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 age specification 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.
  • -rawin is passed to openssl pkeyutl because 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:

  1. Run python -m unittest test_pqcrypt -v — all tests must pass.
  2. Add tests for any new behaviour.
  3. Keep pqcrypt.py a single file with no third-party imports — the zero-dependency property is a feature.
  4. Do not weaken the security invariants described above without a detailed justification in the PR description.

License

MIT — see LICENSE.


Acknowledgements

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pqcrypt-1.0.0.tar.gz (18.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pqcrypt-1.0.0-py3-none-any.whl (18.4 kB view details)

Uploaded Python 3

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

Hashes for pqcrypt-1.0.0.tar.gz
Algorithm Hash digest
SHA256 bdc69753e465fd1b698a8a27f3d4a11c50249d128f6e851b43d3b53b6c2454b4
MD5 20af17cdc5f145e5230fd61ee69cfb1e
BLAKE2b-256 d32983e2d60b920cf5e7a6f480bd9d567445394aee4afa5a359c7ace90d4e5e4

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for pqcrypt-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b3a3e3bd285c3942f8dee11324b09aadf8363dc17d22e7ef7d30d65ed3b04aeb
MD5 d7f1d387b9e901273477a5348099db73
BLAKE2b-256 99b79ed00dd51533a61b53aea33a35d8f160758057fbd6408ceec83b40e657d6

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page