Skip to main content

Simple hybrid post-quantum file and blob encryption for Python.

Project description

qduck

Project Logo

qduck is a small Python encryption toolkit that makes hybrid post-quantum encryption easy to use.

It intentionally exposes only a few primitives:

  1. Generate an X25519 + ML-KEM-768 hybrid keypair.
  2. Client side: derive a 32-byte AES-256 key and a 1120-byte key block for the recipient.
  3. Recipient side: recover the same AES-256 key from the key block.
  4. Encrypt/decrypt in-memory blobs with AES-256-GCM.
  5. Encrypt/decrypt files on disk with a chunked AES-256-GCM qduck file format.

No HTTP. No sessions. No server framework. Callers compose the primitives however they want. HTTP transport and server-framework adapters can live in separate companion packages so this core package stays dependency-light.

Status

qduck is experimental and has not been independently audited. The cryptographic primitives it composes — X25519, ML-KEM-768, HKDF-SHA256, and AES-256-GCM — are standard primitives exposed by the cryptography package, but qduck's specific composition and file format have not undergone third-party security review.

Use at your own risk for production data.

Install

pip install qduck

From a clone:

pip install -e .

With test tools:

pip install -e ".[dev]"

Or from GitHub after tagging:

pip install "qduck @ git+https://github.com/rharold1900/qduck.git@v0.2.4"

qduck requires cryptography>=48.0.0. cryptography 48.0.0 added ML-KEM key encapsulation support for OpenSSL 3.5.0+ and notes that the PyPI wheels expose post-quantum algorithms to wheel users. If your environment builds cryptography from source against an older or unusual crypto backend, ML-KEM may still be unavailable at runtime; in that case qduck raises QDuckError with an upgrade/rebuild message.

Public API

Use the package root:

import qduck

Or import from the facade:

from qduck.api import derive_key, encrypt_blob

Public calls:

qduck.generate_keypair() -> tuple[bytes, bytes]
qduck.save_keypair(public_path: str, private_path: str, force: bool = False) -> None
qduck.load_public_key(path: str) -> bytes
qduck.load_private_key(path: str) -> bytes

qduck.derive_key(public_key: bytes) -> tuple[bytes, bytes]
qduck.recover_key(private_key: bytes, key_block: bytes) -> bytes

qduck.encrypt_blob(data: bytes, aes_key: bytes, aad: bytes | None = None) -> bytes
qduck.decrypt_blob(ciphertext: bytes, aes_key: bytes, aad: bytes | None = None) -> bytes

qduck.encrypt_file(src_path: str,
                   dst_path: str,
                   aes_key: bytes,
                   aad: bytes | None = None,
                   overwrite: bool = False,
                   chunk_size: int = 1024 * 1024,
                   key_block: bytes | None = None) -> None

qduck.decrypt_file(src_path: str,
                   dst_path: str,
                   aes_key: bytes,
                   aad: bytes | None = None,
                   overwrite: bool = False) -> None

qduck.decrypt_file_with_private_key(src_path: str,
                                    dst_path: str,
                                    private_key: bytes,
                                    aad: bytes | None = None,
                                    overwrite: bool = False) -> None

qduck.QDuckError
qduck.DecryptionError
qduck.KeyFormatError

Internally, qduck/api.py is the public facade. qduck/crypto.py is the only module that imports from cryptography.

Key generation

qduck-keygen --public public.key --private private.key

The private key file is created atomically with 0o600 permissions. The public key is created atomically with 0o644 permissions. Existing files are refused unless --force is passed.

Minimal blob usage

import qduck

qduck.save_keypair("public.key", "private.key")

public_key = qduck.load_public_key("public.key")
aes_key, key_block = qduck.derive_key(public_key)

ciphertext = qduck.encrypt_blob(
    b"the contents of my secret message",
    aes_key,
    aad=key_block,
)

private_key = qduck.load_private_key("private.key")
server_aes_key = qduck.recover_key(private_key, key_block)
plaintext = qduck.decrypt_blob(ciphertext, server_aes_key, aad=key_block)

Minimal file usage: external key block

import qduck

public_key = qduck.load_public_key("public.key")
aes_key, key_block = qduck.derive_key(public_key)

qduck.encrypt_file(
    "message.txt",
    "message.txt.qduck",
    aes_key,
    aad=key_block,
)

private_key = qduck.load_private_key("private.key")
server_aes_key = qduck.recover_key(private_key, key_block)
qduck.decrypt_file(
    "message.txt.qduck",
    "message.recovered.txt",
    server_aes_key,
    aad=key_block,
)

Minimal file usage: embedded key block

Use this when you want the encrypted file to carry the KEM handshake bytes inside the qduck header.

import qduck

public_key = qduck.load_public_key("public.key")
aes_key, key_block = qduck.derive_key(public_key)

qduck.encrypt_file(
    "message.txt",
    "message.txt.qduck",
    aes_key,
    aad=key_block,
    key_block=key_block,
)

private_key = qduck.load_private_key("private.key")
qduck.decrypt_file_with_private_key(
    "message.txt.qduck",
    "message.recovered.txt",
    private_key,
    aad=key_block,
)

Embedding the key_block does not embed the private key or plaintext AES key. It only embeds the public KEM ciphertext/ephemeral-public material needed by the private-key holder to recover the AES key.

File format summary

encrypt_file() uses qduck file format v3. It is chunked and does not load the whole file into memory.

The header includes magic bytes, version, flags, total header_len, algorithm IDs, plaintext chunk size, per-file base nonce, and optional embedded key block.

Each chunk stores a 4-byte ciphertext-length/final-flag prefix plus ciphertext || AES-GCM tag. The per-chunk nonce is recomputed as:

8-byte random base nonce || 4-byte chunk counter

The nonce is not stored per chunk. Every chunk authenticates its header bytes, chunk counter, final-chunk flag, and caller-provided AAD. This catches header tampering, chunk payload corruption, chunk reordering, missing final chunks, and trailing bytes after the final chunk.

Outputs are written via a sibling temp file plus atomic rename. Encrypted output is created with 0o644; decrypted output is created with 0o600. Existing destinations are refused unless overwrite=True is passed.

Default chunk size is 1 MiB. Supported chunk sizes are 4 KiB through 64 MiB.

Examples

python examples/roundtrip.py
python examples/simple_blob.py
python examples/file_roundtrip.py
python examples/simple_file.py

qduck-keygen --force --public public.key --private private.key
echo "the contents of my secret message" > message.txt
python examples/client.py
python examples/server.py

python examples/folder_roundtrip.py ./source ./encrypted ./decrypted

The client/server and folder examples are caller code showing how to compose qduck primitives. They are not extra library APIs.

Tests

pip install -e ".[dev]"
pytest tests/

Important test groups include blob roundtrip, file roundtrip, folder roundtrip, tamper failure, chunked file corruption checks, and embedded-key-block decrypt convenience API.

Sizes

Item Size
Public key 1216 bytes
Private key 96 bytes
Key block 1120 bytes
AES key 32 bytes
AES-GCM nonce 12 bytes
AES-GCM tag 16 bytes
qduck v3 fixed file header 28 bytes

The public key is:

ML-KEM-768 public key (1184) || X25519 public key (32)

The private key is:

ML-KEM-768 private seed (64) || X25519 private key (32)

The key block is:

ML-KEM-768 ciphertext (1088) || X25519 ephemeral public key (32)

For blob encryption, output is:

nonce (12) || ciphertext || tag (16)

For file encryption, see qduck/file_format.py for the exact v3 framing.

Why hybrid?

A hybrid construction keeps both a classical and a post-quantum component. The final AES-256 key is derived from both X25519 and ML-KEM-768 shared secrets. If ML-KEM has an undiscovered flaw, X25519 still contributes protection. If future quantum computers break X25519, ML-KEM still contributes protection.

Security notes

  • AES-GCM requires nonce uniqueness per AES key. encrypt_blob() automatically generates a fresh random 12-byte nonce per call. encrypt_file() uses a fresh per-file base nonce plus a per-chunk counter.
  • Public key authenticity is the caller's responsibility. qduck performs key exchange but does not authenticate public keys. An attacker who can substitute the recipient's public key with their own can read traffic. Authenticate public keys out of band before passing them to derive_key().
  • qduck does not provide transport security, identity, sessions, replay protection, certificate validation, password-based encryption, or private-key management.
  • Reusing one derived AES key for many blobs/files is possible, but your application must manage session lifetime, process boundaries, replay behavior, and nonce-risk assumptions. A conservative pattern is to derive a fresh AES key per file or per short-lived session.
  • aad=key_block is recommended when the key block is part of the application protocol. It cryptographically binds ciphertexts to the handshake bytes that produced the AES key.

Packaging / release checklist

Before publishing to PyPI:

# Remove macOS and build artifacts first
find . -name ".DS_Store" -delete
find . -name "__MACOSX" -type d -prune -exec rm -rf {} +
rm -rf build dist *.egg-info .pytest_cache
find . -name "__pycache__" -type d -prune -exec rm -rf {} +

# Build, check, test
python -m pip install --upgrade build twine
python -m build
python -m twine check dist/*
pytest tests/

Then publish first to TestPyPI if desired, then to PyPI.

License

Copyright 2026 Rick Harold

Licensed under the Apache License, Version 2.0. See LICENSE for the full text and NOTICE for attribution requirements.

In short: you may use, modify, and redistribute qduck for any purpose, including commercial use, but you must retain the copyright notice, the license text, and the NOTICE file in redistributions.

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

qduck-0.2.4.tar.gz (983.0 kB view details)

Uploaded Source

Built Distribution

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

qduck-0.2.4-py3-none-any.whl (25.3 kB view details)

Uploaded Python 3

File details

Details for the file qduck-0.2.4.tar.gz.

File metadata

  • Download URL: qduck-0.2.4.tar.gz
  • Upload date:
  • Size: 983.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for qduck-0.2.4.tar.gz
Algorithm Hash digest
SHA256 2c72339fbe155591786c6ebbe120906a224c138d31b904c7343287d123d7ead4
MD5 7551bdc43c60e3db7ceedcbec59114d3
BLAKE2b-256 ae4a8c3cd5265f956ad9ded953e6a6319c13fde688794d4b525a33c718747fa6

See more details on using hashes here.

File details

Details for the file qduck-0.2.4-py3-none-any.whl.

File metadata

  • Download URL: qduck-0.2.4-py3-none-any.whl
  • Upload date:
  • Size: 25.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for qduck-0.2.4-py3-none-any.whl
Algorithm Hash digest
SHA256 e6d44aaf5f63b40f3f19c85bf7298f7233025e4ce34d401e0665593c42d07059
MD5 ed821a1e67237f1db1d57135d5379af2
BLAKE2b-256 9f8d637b523c9c4bdb97da4553338e1521925ee7f9161ca5e0cd70c03992d6bc

See more details on using hashes here.

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