Development

Testing Post-Quantum Cryptography in Your Blockchain CI/CD Pipeline

A practical guide to integrating post-quantum cryptography tests into blockchain CI/CD pipelines: how to run ML-DSA Known Answer Tests, write integration tests for hybrid signature schemes, benchmark signing performance in GitHub Actions, and structure regression tests when changing crypto libraries in Python, Go, and Rust.

QuanChain Research
August 30, 2026
14 min read
Share
Testing Post-Quantum Cryptography in Your Blockchain CI/CD Pipeline

Switching your blockchain's cryptographic primitives is one of the highest-risk code changes you will make. A bug in signature verification that passes unit tests but fails on real transactions could freeze funds, reject valid blocks, or silently accept invalid ones. Post-quantum signature schemes are newer, less battle-tested, and implemented in libraries that have fewer production years behind them than OpenSSL or libsecp256k1. Your CI/CD pipeline needs to catch problems before they reach mainnet.

This guide covers the specific tests you need to add when integrating ML-DSA (CRYSTALS-Dilithium) or hybrid ECDSA + ML-DSA signing into a blockchain project. It covers Known Answer Tests from NIST, integration tests for signing and verification pipelines, performance benchmarks suitable for GitHub Actions, and regression tests for library upgrades. Code examples are provided in Python, Go, and Rust since those are the three most common languages for blockchain infrastructure.

Why Standard Unit Tests Are Not Enough

Standard unit tests for post-quantum cryptography catch API-level bugs but miss the most dangerous failure modes: incorrect algorithm implementations that produce plausible-looking but cryptographically wrong output. NIST Known Answer Tests (KATs) verify that your library produces exactly the correct byte-level output for official test vectors, providing a ground-truth check that no amount of round-trip testing can substitute.

A round-trip test generates a keypair, signs a message, and verifies the signature. If it passes, you know the sign/verify pair is internally consistent. But it does not tell you:

  • Whether your library produces the same output as the NIST reference implementation for the same input seed
  • Whether your serialization of keys and signatures is compatible with other implementations you will interoperate with
  • Whether edge cases (all-zero messages, maximum-length messages, boundary parameter values) are handled correctly
  • Whether a library upgrade changed the output format in a breaking way

NIST provides Known Answer Test vectors for ML-DSA (FIPS 204) that specify the exact output bytes expected for given input seeds. These vectors are the authoritative reference for what a correct implementation produces. Running them in CI is not optional if you care about interoperability.

Quick Win

Download the NIST ML-DSA KAT files from the official NIST post-quantum project page and commit them directly into your test fixtures directory. Treating the KAT files as versioned test assets (not fetched at test time from an external URL) means your CI tests are reproducible and do not break if the NIST server is temporarily unreachable.

Python: Running ML-DSA Known Answer Tests

NIST KAT files for ML-DSA have a specific format: each test case lists a seed, an expected public key, an expected private key, a message, and an expected signature. Here is a parser and test runner:

import oqs
import pytest
from pathlib import Path

def parse_kat_file(path: str) -> list:
    """Parse a NIST KAT file into a list of test case dicts."""
    cases = []
    current = {}
    with open(path) as f:
        for line in f:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            if line.startswith("count = "):
                if current:
                    cases.append(current)
                current = {"count": int(line.split(" = ")[1])}
            elif " = " in line:
                key, val = line.split(" = ", 1)
                current[key.strip()] = bytes.fromhex(val.strip())
    if current:
        cases.append(current)
    return cases

KAT_DIR = Path("tests/fixtures/kat")

@pytest.mark.parametrize("algorithm,kat_file", [
    ("ML-DSA-44", "ML-DSA-44.rsp"),
    ("ML-DSA-65", "ML-DSA-65.rsp"),
    ("ML-DSA-87", "ML-DSA-87.rsp"),
])
def test_ml_dsa_kat(algorithm: str, kat_file: str):
    """Verify ML-DSA produces NIST-correct output for known test vectors."""
    cases = parse_kat_file(KAT_DIR / kat_file)
    # Test first 10 cases in CI for speed; run all in nightly
    for case in cases[:10]:
        with oqs.Signature(algorithm) as verifier:
            valid = verifier.verify(case["msg"], case["sm"], case["pk"])
            assert valid, f"KAT verification failed for {algorithm} case {case['count']}"

Note that full KAT determinism requires controlling the random number generator, which the liboqs Python bindings do not directly expose. The verification-only test (checking that the provided signature verifies against the provided public key and message) is what most projects can run without modifying liboqs internals. If you need to test signature generation determinism, you need to use the liboqs C API directly or a language binding that exposes RNG seeding.

Go: Integration Tests for Hybrid Signing

Go is common for blockchain node implementations. The open-quantum-safe/liboqs-go package wraps the same liboqs C library:

package crypto_test

import (
    "crypto/sha256"
    "testing"

    oqs "github.com/open-quantum-safe/liboqs-go/oqs"
)

func TestMLDSARoundTrip(t *testing.T) {
    algos := []string{"ML-DSA-44", "ML-DSA-65", "ML-DSA-87"}
    for _, alg := range algos {
        t.Run(alg, func(t *testing.T) {
            signer := oqs.Signature{}
            defer signer.Clean()
            if err := signer.Init(alg, nil); err != nil {
                t.Fatalf("Init failed: %v", err)
            }
            pubKey, err := signer.GenerateKeyPair()
            if err != nil {
                t.Fatalf("KeyGen failed: %v", err)
            }
            msg := sha256.Sum256([]byte("test transaction payload"))
            sig, err := signer.Sign(msg[:])
            if err != nil {
                t.Fatalf("Sign failed: %v", err)
            }
            verifier := oqs.Signature{}
            defer verifier.Clean()
            if err := verifier.Init(alg, nil); err != nil {
                t.Fatalf("Verifier init failed: %v", err)
            }
            valid, err := verifier.Verify(msg[:], sig, pubKey)
            if err != nil || !valid {
                t.Errorf("Verification failed for %s: err=%v valid=%v", alg, err, valid)
            }
        })
    }
}

For integration tests that exercise the full transaction pipeline (serialize, broadcast to a test node, verify in the mempool), run a local devnet in your GitHub Actions workflow using the same Docker image you use for local development. This catches serialization bugs that unit tests miss.

Quick Win

Add a TestSignatureSchemeVersionCompatibility test that signs a fixed message with your current library version, serializes the signature to a hex string, commits that hex string as a fixture, and in every subsequent CI run verifies that the same hex string still verifies correctly. This catches silent breaking changes in library upgrades that change the signature format without changing the API.

Rust: ML-DSA Tests with the pqcrypto Crate

For Rust-based blockchain nodes (Solana, Substrate, or custom implementations), the pqcrypto crate provides ML-DSA bindings:

use pqcrypto_dilithium::dilithium2::{keypair, sign, open};
use pqcrypto_traits::sign::SignedMessage;
use sha2::{Sha256, Digest};

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_ml_dsa_44_round_trip() {
        let (pk, sk) = keypair();
        let message = Sha256::digest(b"test transaction payload");
        let signed = sign(&message, &sk);
        let verified = open(&signed, &pk);
        assert!(verified.is_ok(), "ML-DSA-44 verification failed");
        assert_eq!(verified.unwrap().as_slice(), message.as_slice());
    }

    #[test]
    fn test_signature_sizes() {
        use pqcrypto_traits::sign::{PublicKey, SecretKey};
        let (pk, sk) = keypair();
        let message = b"size test";
        let signed = sign(message, &sk);
        assert_eq!(pk.as_bytes().len(), 1312, "ML-DSA-44 pubkey must be 1312 bytes");
        assert_eq!(sk.as_bytes().len(), 2528, "ML-DSA-44 privkey must be 2528 bytes");
        let sig_len = signed.as_bytes().len() - message.len();
        assert_eq!(sig_len, 2420, "ML-DSA-44 signature must be 2420 bytes");
    }

    #[test]
    fn test_wrong_pubkey_rejects() {
        let (pk1, sk) = keypair();
        let (pk2, _) = keypair();
        let message = b"cross-key test";
        let signed = sign(message, &sk);
        assert!(open(&signed, &pk2).is_err(), "Wrong pubkey must not verify");
        assert!(open(&signed, &pk1).is_ok(), "Correct pubkey must verify");
    }
}

The size assertion test is important: if a library upgrade changes the parameter set (for example, by aliasing "dilithium2" to a different security level), it will fail immediately with a clear error message rather than producing signatures that interoperate incorrectly with the rest of your network.

Benchmarking in GitHub Actions

Signature performance is a CI concern, not just a development-time concern. A library upgrade that doubles signing time will reduce your node's transaction throughput in production. Add a benchmark job to your pipeline that fails if signing performance degrades beyond a threshold:

import oqs
import time
import sys

ALGORITHMS = {
    "ML-DSA-44": {"sign_min_ops_per_sec": 5000, "verify_min_ops_per_sec": 8000},
    "ML-DSA-65": {"sign_min_ops_per_sec": 3500, "verify_min_ops_per_sec": 6000},
}

ITERATIONS = 500
message = b"benchmark payload " * 10
failures = []

for alg, thresholds in ALGORITHMS.items():
    with oqs.Signature(alg) as s:
        pub = s.generate_keypair()
        priv = s.export_secret_key()

    with oqs.Signature(alg, secret_key=priv) as signer:
        t0 = time.perf_counter()
        for _ in range(ITERATIONS):
            sig = signer.sign(message)
        sign_ops = ITERATIONS / (time.perf_counter() - t0)

    with oqs.Signature(alg) as verifier:
        t0 = time.perf_counter()
        for _ in range(ITERATIONS):
            verifier.verify(message, sig, pub)
        verify_ops = ITERATIONS / (time.perf_counter() - t0)

    if sign_ops < thresholds["sign_min_ops_per_sec"]:
        failures.append(
            f"{alg} sign: {sign_ops:.0f} ops/s < {thresholds['sign_min_ops_per_sec']}"
        )
    if verify_ops < thresholds["verify_min_ops_per_sec"]:
        failures.append(
            f"{alg} verify: {verify_ops:.0f} ops/s < {thresholds['verify_min_ops_per_sec']}"
        )

    print(f"{alg}: sign={sign_ops:.0f} ops/s, verify={verify_ops:.0f} ops/s")

if failures:
    print("PERFORMANCE REGRESSIONS DETECTED:")
    for f in failures:
        print(f"  {f}")
    sys.exit(1)

GitHub Actions runners have variable performance, so set your thresholds conservatively (roughly 60% of the expected performance on your fastest development machine). The goal is to catch regressions by a factor of 2 or more, not to enforce exact performance numbers.

Structuring Your Test Fixtures

Organize your PQC test assets in a way that makes the relationship between tests and official sources clear:

  • tests/fixtures/kat/ML-DSA-44.rsp through ML-DSA-87.rsp: NIST KAT files, committed verbatim, never modified
  • tests/fixtures/regression/: Signed message files generated by your implementation on the current canonical version, used for regression detection across library upgrades
  • tests/fixtures/edge-cases/: Custom test vectors for boundary conditions: empty messages, maximum-length messages, messages with all-zero bytes

Add a test that checks the SHA-256 hash of each KAT file against a pinned value. This prevents accidental modification of the reference files (for example, a text editor converting line endings) from silently causing KAT tests to pass against wrong vectors.

For more context on what ML-DSA is and how it relates to CRYSTALS-Dilithium, see CRYSTALS-Dilithium explained and ML-DSA vs CRYSTALS-Dilithium: what changed. For the hands-on liboqs setup covered in this guide, the full installation walkthrough is in the liboqs Python tutorial.

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