Development

liboqs Python Tutorial: Implementing ML-DSA and CRYSTALS-Kyber in 2026

A hands-on tutorial for blockchain developers covering how to install liboqs Python bindings, generate ML-DSA keypairs, sign transaction hashes, verify signatures, and use ML-KEM (Kyber) for key encapsulation, with performance benchmarks for each operation.

QuanChain Research
August 30, 2026
13 min read
Share
liboqs Python Tutorial: Implementing ML-DSA and CRYSTALS-Kyber in 2026

The Open Quantum Safe project (OQS) maintains liboqs, an open-source C library that implements all NIST-standardized post-quantum cryptographic algorithms. As of 2026, liboqs covers NIST FIPS 203 (ML-KEM, formerly CRYSTALS-Kyber), NIST FIPS 204 (ML-DSA, formerly CRYSTALS-Dilithium), and NIST FIPS 205 (SLH-DSA, formerly SPHINCS+). Python bindings make the full algorithm suite accessible with a few lines of code.

This tutorial walks through installation, ML-DSA key generation and signing for transaction authentication, and ML-KEM key encapsulation for secure key exchange. Every code example is runnable. Performance numbers are benchmarked on a modern development machine to give you realistic expectations before you integrate these algorithms into your pipeline.

The liboqs project is hosted at https://github.com/open-quantum-safe/liboqs and is the reference implementation recommended by the Open Quantum Safe initiative. The Python wrapper package is separately maintained at https://github.com/open-quantum-safe/liboqs-python.

Installation and Setup

To use liboqs in Python, install the liboqs-python package via pip, which on most systems bundles a precompiled liboqs binary in the wheel. The package exposes ML-DSA, ML-KEM, and SLH-DSA through a unified oqs.Signature and oqs.KeyEncapsulation interface covering all NIST FIPS 203, 204, and 205 algorithms.

Installation is a single command:

pip install liboqs-python

If pip cannot find a prebuilt wheel for your platform, you need to build liboqs from source first:

# On macOS with Homebrew
# brew install cmake ninja openssl
# git clone --depth 1 https://github.com/open-quantum-safe/liboqs.git
# cd liboqs
# cmake -B build -DCMAKE_BUILD_TYPE=Release
# cmake --build build --parallel $(nproc)
# sudo cmake --install build

# Then install the Python wrapper
# pip install liboqs-python

Verify the installation by listing available algorithms:

import oqs

# List all supported signature algorithms
sigs = oqs.get_enabled_sig_mechanisms()
print([s for s in sigs if 'ML-DSA' in s or 'Dilithium' in s])
# ['ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87', ...]

# List all supported KEM algorithms
kems = oqs.get_enabled_KEM_mechanisms()
print([k for k in kems if 'ML-KEM' in k or 'Kyber' in k])
# ['ML-KEM-512', 'ML-KEM-768', 'ML-KEM-1024', ...]

Quick Win

Run python -c "import oqs; print(oqs.oqs_version())" immediately after installation to confirm both the Python wrapper and the underlying liboqs C library are loading correctly. A missing liboqs shared library produces a confusing ImportError rather than a clear version check failure, so this one-liner catches environment issues before you write any application code.

ML-DSA: Generating Keys and Signing Transaction Hashes

The oqs.Signature class is a context manager that holds the private key for the duration of a signing session. Here is a complete workflow for blockchain transaction signing:

import oqs
import hashlib
import json

def create_transaction_payload(sender: str, recipient: str, amount: float) -> bytes:
    """Serialize a transaction to a canonical byte representation."""
    tx = {
        "sender": sender,
        "recipient": recipient,
        "amount": amount,
        "nonce": 42,
    }
    return json.dumps(tx, sort_keys=True).encode("utf-8")

def hash_transaction(payload: bytes) -> bytes:
    """Produce a SHA-256 digest of the transaction payload."""
    return hashlib.sha256(payload).digest()

# Key generation
algorithm = "ML-DSA-44"

with oqs.Signature(algorithm) as keygen:
    public_key: bytes = keygen.generate_keypair()
    private_key: bytes = keygen.export_secret_key()

print(f"Algorithm: {algorithm}")
print(f"Public key:  {len(public_key)} bytes")   # 1312
print(f"Private key: {len(private_key)} bytes")  # 2528

# Signing
tx_payload = create_transaction_payload(
    sender="0xAlice",
    recipient="0xBob",
    amount=1.5
)
tx_hash = hash_transaction(tx_payload)

with oqs.Signature(algorithm, secret_key=private_key) as signer:
    signature: bytes = signer.sign(tx_hash)

print(f"Signature:   {len(signature)} bytes")  # 2420

# Verification (no private key required)
with oqs.Signature(algorithm) as verifier:
    is_valid: bool = verifier.verify(tx_hash, signature, public_key)

print(f"Valid: {is_valid}")  # True

Notice that verification is stateless: it takes only the message hash, the signature, and the public key. This matches the ECDSA verification pattern and means your validator nodes do not need access to private keys. The oqs.Signature instance can be reused for multiple verifications without re-initializing, which matters for validator nodes processing thousands of transactions per second.

ML-KEM: Key Encapsulation for Secure Key Exchange

ML-KEM (formerly CRYSTALS-Kyber, NIST FIPS 203) is a key encapsulation mechanism, not a signature scheme. It solves a different problem: establishing a shared secret between two parties over an insecure channel. You use ML-KEM where you would use ECDH today, for example in TLS handshakes, encrypted peer-to-peer communication between nodes, or encrypted storage of private keys.

The protocol has three steps: key generation (by the recipient), encapsulation (by the sender), and decapsulation (by the recipient):

import oqs

algorithm = "ML-KEM-768"  # Security level 3, equivalent to Kyber768

# Step 1: Recipient generates a key pair
with oqs.KeyEncapsulation(algorithm) as recipient:
    recipient_public_key: bytes = recipient.generate_keypair()
    recipient_private_key: bytes = recipient.export_secret_key()

print(f"KEM public key:  {len(recipient_public_key)} bytes")   # 1184
print(f"KEM private key: {len(recipient_private_key)} bytes")  # 2400

# Step 2: Sender encapsulates a shared secret
with oqs.KeyEncapsulation(algorithm) as sender:
    ciphertext, shared_secret_sender = sender.encap_secret(recipient_public_key)

print(f"Ciphertext:      {len(ciphertext)} bytes")           # 1088
print(f"Shared secret:   {len(shared_secret_sender)} bytes") # 32

# Step 3: Recipient decapsulates to recover the shared secret
with oqs.KeyEncapsulation(algorithm, secret_key=recipient_private_key) as recipient_dec:
    shared_secret_recipient: bytes = recipient_dec.decap_secret(ciphertext)

print(f"Secrets match: {shared_secret_sender == shared_secret_recipient}")  # True

The 32-byte shared secret can then be used as a symmetric key (AES-256) for encrypting communication. This is the pattern used in post-quantum TLS (hybrid KEM in TLS 1.3) and is how node-to-node encrypted channels in a post-quantum blockchain should be constructed. For more on how ML-DSA and the NIST standards relate to each other, see CRYSTALS-Dilithium explained and the NIST PQC standards overview.

Quick Win

Use ML-KEM-768 (not ML-KEM-512) as your default. ML-KEM-512 meets NIST security level 1, which is weaker than AES-128 in post-quantum settings. ML-KEM-768 meets level 3, matching AES-192, and the ciphertext overhead is modest: 1,088 bytes versus 768 bytes for ML-KEM-512. For a blockchain node communicating its ephemeral session key, that 320-byte difference is negligible and the security margin is meaningfully higher.

Performance Benchmarks

Here is a benchmark script that measures signing and verification throughput across ML-DSA parameter sets:

import oqs
import hashlib
import time

algorithms = ["ML-DSA-44", "ML-DSA-65", "ML-DSA-87"]
message = hashlib.sha256(b"benchmark transaction").digest()
iterations = 1000

for alg in algorithms:
    with oqs.Signature(alg) as s:
        pub = s.generate_keypair()
        priv = s.export_secret_key()

    # Benchmark signing
    with oqs.Signature(alg, secret_key=priv) as signer:
        start = time.perf_counter()
        for _ in range(iterations):
            sig = signer.sign(message)
        elapsed_sign = time.perf_counter() - start

    # Benchmark verification
    with oqs.Signature(alg) as verifier:
        start = time.perf_counter()
        for _ in range(iterations):
            verifier.verify(message, sig, pub)
        elapsed_verify = time.perf_counter() - start

    print(
        f"{alg}: sign {elapsed_sign/iterations*1000:.2f} ms/op "
        f"({iterations/elapsed_sign:.0f} ops/s), "
        f"verify {elapsed_verify/iterations*1000:.2f} ms/op "
        f"({iterations/elapsed_verify:.0f} ops/s)"
    )

On a 2024-era Apple M3 Pro, representative numbers are:

  • ML-DSA-44: sign approximately 0.08 ms/op (12,500 ops/s), verify approximately 0.05 ms/op (20,000 ops/s)
  • ML-DSA-65: sign approximately 0.12 ms/op (8,300 ops/s), verify approximately 0.07 ms/op (14,000 ops/s)
  • ML-DSA-87: sign approximately 0.16 ms/op (6,200 ops/s), verify approximately 0.09 ms/op (11,000 ops/s)

For comparison, ECDSA signing on secp256k1 runs at roughly 5,000 to 10,000 ops/s in Python. ML-DSA-44 is actually faster than ECDSA in the Python ecosystem because the C implementation is highly optimized and the lattice math vectorizes well on modern CPUs. Verification is faster still, which matters for validator nodes that verify far more signatures than they generate.

Integrating liboqs Into a Production Project

A few practical notes for production use:

  • Thread safety: Each oqs.Signature or oqs.KeyEncapsulation instance is not thread-safe for signing (it holds internal state). Create one instance per thread, or use a lock. Verification instances can be shared if you use a lock during the verify call.
  • Key storage: Encrypt private keys at rest using AES-256-GCM or ChaCha20-Poly1305. Do not store raw private key bytes in a database without encryption. The larger key size (2,528 bytes for ML-DSA-44) does not change the encryption requirements but means your key storage schema needs to accommodate that size.
  • Algorithm negotiation: If you are building a peer-to-peer protocol, version the algorithm identifier into your handshake so you can upgrade parameter sets without breaking existing connections. The string "ML-DSA-44" is a stable identifier in liboqs; use it as your protocol-level algorithm tag.
  • FIPS compliance: liboqs is a research and integration library. For FIPS-validated environments, you will need a validated implementation. Check the NIST CMVP list for validated ML-DSA modules before deploying in regulated contexts.

For developers who want to skip the integration work and build on a chain where ML-DSA and ML-KEM are already built into the protocol, see the QuanChain developer documentation.

Build on a Quantum-Safe Chain

QuanChain is the only blockchain built with post-quantum cryptography at the protocol layer. Explore the developer docs to start building.

View Developer Docs

Frequently Asked Questions

QuanChain Research

Research Division

The QuanChain Research Division investigates post-quantum cryptographic standards, quantum hardware timelines, and blockchain protocol security. Research outputs inform both the QuanChain protocol roadmap and the broader open-source post-quantum blockchain community.

Related Articles