skip to content
QUANTUM NEXUM

← forge

OpenSSL recipes

OpenSSL 3.5.0 (released 2025-04-08, LTS) includes ML-KEM, ML-DSA, and SLH-DSA natively — no provider plugins required. This page covers setup, key generation, signing, KEM operations, certificates, and TLS testing using the native algorithms. Commands that require the OQS provider (older OpenSSL or non-standardized algorithms) are marked explicitly.

Verify your version before running any recipe:

openssl version
openssl list -signature-algorithms | grep -iE "ml-dsa|slh-dsa"
openssl list -kem-algorithms | grep -i "ml-kem"

All three commands should return results against the default provider. If PQ algorithms are absent, see Setup below.

Setup

Most distributions ship OpenSSL 3.0 – 3.3. You need 3.5+ for native PQC. The safest approach is to install 3.5 to a non-system prefix alongside the system OpenSSL, then use a shell alias.

Linux (Ubuntu / Debian)

Ubuntu 22.04 and 24.04 ship OpenSSL 3.0; build 3.5 from source:

sudo apt update
sudo apt install build-essential wget perl

cd /opt
sudo wget https://github.com/openssl/openssl/releases/download/openssl-3.5.0/openssl-3.5.0.tar.gz
sudo tar xzf openssl-3.5.0.tar.gz
sudo chown -R "$USER":"$USER" openssl-3.5.0
cd openssl-3.5.0

./Configure --prefix=/opt/openssl-3.5 --openssldir=/opt/openssl-3.5/ssl
make -j"$(nproc)"
sudo make install

Add a shell alias so that openssl in your session resolves to the new binary:

echo 'alias openssl="LD_LIBRARY_PATH=/opt/openssl-3.5/lib64 /opt/openssl-3.5/bin/openssl"' >> ~/.bashrc
source ~/.bashrc

Build time is roughly 5–10 minutes. The system binary at /usr/bin/openssl is untouched; removing the alias restores the original. To uninstall: sudo rm -rf /opt/openssl-3.5 /opt/openssl-3.5.0.

Linux (RHEL / Rocky / AlmaLinux)

RHEL 9 and Rocky 9 ship OpenSSL 3.0 or 3.2. The build steps are identical to Ubuntu except for the prerequisite install:

sudo dnf groupinstall "Development Tools"
sudo dnf install perl wget

Then follow the download, configure, and alias steps above. If SELinux is enforcing and you intend to use the new binary in a systemd service, restore the file context with restorecon -Rv /opt/openssl-3.5 after installation.

macOS

Homebrew openssl@3 has shipped OpenSSL ≥3.5 since April 2025. Check with brew info openssl@3 and upgrade with brew upgrade openssl@3 if needed. The Homebrew installation does not shadow the system LibreSSL, so use the full path or an alias:

echo 'alias openssl="DYLD_LIBRARY_PATH=$(brew --prefix openssl@3)/lib $(brew --prefix openssl@3)/bin/openssl"' >> ~/.zshrc
source ~/.zshrc

To build from source on macOS instead:

xcode-select --install

cd ~/Downloads
curl -LO https://github.com/openssl/openssl/releases/download/openssl-3.5.0/openssl-3.5.0.tar.gz
tar xzf openssl-3.5.0.tar.gz
cd openssl-3.5.0

# Apple Silicon:
./Configure --prefix=/usr/local/openssl-3.5 --openssldir=/usr/local/openssl-3.5/ssl darwin64-arm64-cc
# Intel:
# ./Configure --prefix=/usr/local/openssl-3.5 --openssldir=/usr/local/openssl-3.5/ssl darwin64-x86_64-cc

make -j"$(sysctl -n hw.ncpu)"
sudo make install

Windows

The easiest path is WSL2 with Ubuntu, then follow the Ubuntu instructions:

wsl --install -d Ubuntu

For native Windows builds: use MSYS2 MinGW64 (pacman -S mingw-w64-x86_64-toolchain perl make) and configure with ./Configure mingw64 --prefix=/c/openssl-3.5. Pre-built Windows binaries may be available from Shining Light Productions but may lag behind the latest release.

Verify PQC is available

# Key encapsulation
openssl list -kem-algorithms | grep -i "ml-kem"

{ 2.16.840.1.101.3.4.4.1, id-alg-ml-kem-512, ML-KEM-512, MLKEM512 } @ default
{ 2.16.840.1.101.3.4.4.2, id-alg-ml-kem-768, ML-KEM-768, MLKEM768 } @ default
{ 2.16.840.1.101.3.4.4.3, id-alg-ml-kem-1024, ML-KEM-1024, MLKEM1024 } @ default
X25519MLKEM768 @ default
SecP256r1MLKEM768 @ default

# Digital signatures
openssl list -signature-algorithms | grep -iE "ml-dsa|slh-dsa"

{ 2.16.840.1.101.3.4.3.17, id-ml-dsa-44, ML-DSA-44, MLDSA44 } @ default
{ 2.16.840.1.101.3.4.3.18, id-ml-dsa-65, ML-DSA-65, MLDSA65 } @ default
{ 2.16.840.1.101.3.4.3.19, id-ml-dsa-87, ML-DSA-87, MLDSA87 } @ default
{ 2.16.840.1.101.3.4.3.20, id-slh-dsa-sha2-128s, SLH-DSA-SHA2-128s } @ default
...

Each PQ algorithm appears with its NIST CSOR OID and @ default, indicating it is provided by the built-in default provider — no -provider flags needed.

Quick sanity check — generate a key, encapsulate, and decapsulate:

openssl genpkey -algorithm ML-KEM-768 -out mlkem768_priv.pem
openssl pkey -in mlkem768_priv.pem -pubout -out mlkem768_pub.pem
openssl pkeyutl -encap -inkey mlkem768_pub.pem -pubin \
    -secret shared_secret.bin -out ciphertext.bin
openssl pkeyutl -decap -inkey mlkem768_priv.pem \
    -in ciphertext.bin -secret recovered_secret.bin
diff shared_secret.bin recovered_secret.bin && echo "Secrets match."

Expected sizes: mlkem768_priv.pem ~3,439 B (PEM), mlkem768_pub.pem ~1,686 B (PEM), ciphertext.bin 1,088 B, shared_secret.bin 32 B.

Key generation

ML-KEM (FIPS 203) — key encapsulation

# ML-KEM-768 (recommended default, NIST Level 3)
openssl genpkey -algorithm ML-KEM-768 -out mlkem768_priv.pem
openssl pkey -in mlkem768_priv.pem -pubout -out mlkem768_pub.pem

# ML-KEM-512 (Level 1, constrained environments)
openssl genpkey -algorithm ML-KEM-512 -out mlkem512_priv.pem

# ML-KEM-1024 (Level 5, CNSA 2.0 requirement)
openssl genpkey -algorithm ML-KEM-1024 -out mlkem1024_priv.pem

ML-KEM-768 is the recommended default for general-purpose key exchange. ML-KEM-1024 is required under CNSA 2.0 for National Security Systems.

ML-DSA (FIPS 204) — digital signatures

# ML-DSA-65 (recommended default, NIST Level 3)
openssl genpkey -algorithm ML-DSA-65 -out mldsa65_priv.pem
openssl pkey -in mldsa65_priv.pem -pubout -out mldsa65_pub.pem

# ML-DSA-44 (Level 2, high-volume signing)
openssl genpkey -algorithm ML-DSA-44 -out mldsa44_priv.pem

# ML-DSA-87 (Level 5, CNSA 2.0, root CA use)
openssl genpkey -algorithm ML-DSA-87 -out mldsa87_priv.pem

SLH-DSA (FIPS 205) — hash-based signatures

# SLH-DSA-SHAKE-128f (Level 1, fast variant)
openssl genpkey -algorithm SLH-DSA-SHAKE-128f -out slhdsa128f_priv.pem
openssl pkey -in slhdsa128f_priv.pem -pubout -out slhdsa128f_pub.pem

# SLH-DSA-SHAKE-128s (Level 1, small-signature variant)
openssl genpkey -algorithm SLH-DSA-SHAKE-128s -out slhdsa128s_priv.pem

# Higher security levels
openssl genpkey -algorithm SLH-DSA-SHA2-192f -out slhdsa192f_priv.pem
openssl genpkey -algorithm SLH-DSA-SHA2-256f -out slhdsa256f_priv.pem

SLH-DSA security rests entirely on hash-function collision resistance — no lattice assumptions. Use f variants where signing speed matters; use s variants where signature size matters. Signing is significantly slower than ML-DSA (hundreds of ops/s vs thousands).

Batch key generation

#!/bin/bash
set -euo pipefail

mkdir -p pqc_keys
cd pqc_keys

for algo in ML-KEM-512 ML-KEM-768 ML-KEM-1024; do
    openssl genpkey -algorithm "${algo}" -out "${algo,,}.key"
    openssl pkey -in "${algo,,}.key" -pubout -out "${algo,,}.pub"
done

for algo in ML-DSA-44 ML-DSA-65 ML-DSA-87; do
    openssl genpkey -algorithm "${algo}" -out "${algo,,}.key"
    openssl pkey -in "${algo,,}.key" -pubout -out "${algo,,}.pub"
done

echo "Generated keys:"
ls -la

Sign & verify

Sign a file with ML-DSA

# Sign any file directly
openssl pkeyutl -sign -inkey mldsa65_priv.pem \
    -in message.txt -out signature.bin

# Signature size: ~3,309 B for ML-DSA-65
wc -c signature.bin

ML-DSA operates in "pure" mode: the algorithm internally hashes the message, so you pass the raw message directly to pkeyutl -sign. The -rawin flag is not needed and should not be used. Per RFC 9881 §8.3, HashML-DSA (a pre-hash variant) MUST NOT be used in X.509/PKIX contexts; use pure ML-DSA for certificate and protocol work. For very large messages that do not fit in memory, use ExternalMu-ML-DSA rather than HashML-DSA.

Verify a signature

openssl pkeyutl -verify -inkey mldsa65_pub.pem -pubin \
    -in message.txt -sigfile signature.bin

Output is Signature Verified Successfully on success, or a non-zero exit with an error message on failure.

Sign with SLH-DSA

openssl pkeyutl -sign -inkey slhdsa128f_priv.pem \
    -in message.txt -out signature_slh.bin

# Signature is ~17,088 B for SLH-DSA-SHAKE-128f
wc -c signature_slh.bin

openssl pkeyutl -verify -inkey slhdsa128f_pub.pem -pubin \
    -in message.txt -sigfile signature_slh.bin

Inspect key details

# Private key info
openssl pkey -in mldsa65_priv.pem -text -noout

# Public key info
openssl pkey -in mldsa65_pub.pem -pubin -text -noout

# Key fingerprint (algorithm-agnostic)
openssl pkey -in mldsa65_pub.pem -pubin -outform DER | sha256sum | cut -d' ' -f1

KEM operations

Encapsulate (create shared secret)

# Generate shared secret + ciphertext using the recipient's public key
openssl pkeyutl -encap -inkey mlkem768_pub.pem -pubin \
    -secret shared_secret.bin -out ciphertext.bin

# Inspect (ciphertext is 1,088 B for ML-KEM-768; shared secret is 32 B)
wc -c ciphertext.bin shared_secret.bin
xxd shared_secret.bin

Decapsulate (recover shared secret)

openssl pkeyutl -decap -inkey mlkem768_priv.pem \
    -in ciphertext.bin -secret recovered_secret.bin

diff shared_secret.bin recovered_secret.bin && echo "Secrets match."

Full key exchange walkthrough

# Alice (receiver) generates a keypair and shares the public key
openssl genpkey -algorithm ML-KEM-768 -out alice_priv.pem
openssl pkey -in alice_priv.pem -pubout -out alice_pub.pem
# Alice sends alice_pub.pem to Bob

# Bob encapsulates against Alice's public key
openssl pkeyutl -encap -inkey alice_pub.pem -pubin \
    -secret bob_secret.bin -out ciphertext.bin
# Bob sends ciphertext.bin to Alice; keeps bob_secret.bin

# Alice decapsulates
openssl pkeyutl -decap -inkey alice_priv.pem \
    -in ciphertext.bin -secret alice_secret.bin

# Both hold the same 32-byte shared secret
diff bob_secret.bin alice_secret.bin && echo "Key exchange successful."

Certificates

Self-signed certificate

openssl req -x509 -new -newkey ML-DSA-65 \
    -keyout pqc_key.pem -out pqc_cert.pem \
    -days 365 -noenc \
    -subj "/C=US/O=Example Org/CN=example.com"

openssl x509 -in pqc_cert.pem -text -noout
openssl verify -CAfile pqc_cert.pem pqc_cert.pem

Certificate Signing Request

openssl genpkey -algorithm ML-DSA-65 -out server_key.pem

openssl req -new -key server_key.pem -out server.csr \
    -subj "/C=US/O=Example Org/CN=server.example.com"

openssl req -in server.csr -text -noout

# Self-sign (for testing only)
openssl x509 -req -in server.csr -signkey server_key.pem \
    -out server_cert.pem -days 365

CA and signed end-entity certificate

# Root CA key and self-signed certificate (ML-DSA-87 for highest assurance)
openssl genpkey -algorithm ML-DSA-87 -out ca_key.pem
openssl req -x509 -new -key ca_key.pem -out ca_cert.pem \
    -days 3650 -subj "/C=US/O=Example Org/CN=Example Root CA"

# End-entity key and CSR
openssl genpkey -algorithm ML-DSA-65 -out server_key.pem
openssl req -new -key server_key.pem -out server.csr \
    -subj "/C=US/O=Example Org/CN=server.example.com"

# Sign with CA
openssl x509 -req -in server.csr -CA ca_cert.pem -CAkey ca_key.pem \
    -CAcreateserial -out server_cert.pem -days 365

# Verify chain
openssl verify -CAfile ca_cert.pem server_cert.pem

Convert between PEM and DER

# PEM to DER
openssl pkey -in mlkem768_priv.pem -outform DER -out mlkem768_priv.der
openssl pkey -in mlkem768_pub.pem -pubin -outform DER -out mlkem768_pub.der
openssl x509 -in pqc_cert.pem -outform DER -out pqc_cert.der

# DER to PEM
openssl pkey -in mlkem768_priv.der -inform DER -out mlkem768_priv.pem

Extract raw key bytes

To extract the raw key material from a SubjectPublicKeyInfo (SPKI) DER blob, use openssl asn1parse rather than a hardcoded byte offset. The header size differs by algorithm (the Ed25519 offset of 12 bytes is not applicable to ML-KEM or ML-DSA keys):

# Find the offset of the BIT STRING payload
openssl pkey -in mlkem768_pub.pem -pubin -outform DER | openssl asn1parse -inform DER

# Then extract from the reported offset, e.g. offset 22 for ML-KEM-768:
openssl pkey -in mlkem768_pub.pem -pubin -outform DER | \
    openssl asn1parse -inform DER -strparse 22 -out mlkem768_raw_pub.bin -noout

If you need a quick fingerprint without raw extraction, hashing the full DER is algorithm-agnostic and sufficient for most comparison purposes:

openssl pkey -in mlkem768_pub.pem -pubin -outform DER | sha256sum | cut -d' ' -f1

Composite signatures

Composite signatures (pairing a classical algorithm with a PQ algorithm in a single certificate) are being specified in draft-ietf-lamps-pq-composite-sigs. OpenSSL 3.5 has no support for composite signatures; do not use fabricated algorithm strings such as COMPOSITE:ECDSA-P384+ML-DSA-65 in scripts or tooling.

TLS testing

OpenSSL 3.5 supports the hybrid TLS 1.3 named groups X25519MLKEM768 and SecP256r1MLKEM768 natively. The standard approach for local testing is a loopback server/client pair.

Local loopback test

# Generate a self-signed ML-DSA-65 server certificate
openssl req -x509 -new -newkey ML-DSA-65 \
    -keyout server_key.pem -out server_cert.pem \
    -days 365 -noenc -subj "/CN=localhost"

# Start a TLS 1.3 server on port 4433 advertising PQ key exchange
openssl s_server -accept 4433 \
    -cert server_cert.pem -key server_key.pem \
    -groups X25519MLKEM768 -www

# In a second terminal, connect with a PQ key share
openssl s_client -connect localhost:4433 \
    -groups X25519MLKEM768 -brief

A successful PQ handshake reports Negotiated TLS1.3 group: X25519MLKEM768. The TLS key share extension will be ~1,124 bytes rather than the 32-byte classical X25519 share — you can observe this with -tlsextdebug.

Test against a remote server

# Connect with hybrid PQ key exchange, prefer X25519MLKEM768
openssl s_client -connect server.example.com:443 \
    -groups X25519MLKEM768:X25519:P-256 -brief

# Confirm group negotiated
openssl s_client -connect server.example.com:443 \
    -groups X25519MLKEM768 &1 | grep -E "group|Cipher"

If the server does not support PQ groups, the handshake falls back to the next group in the list. A "key share len=32" in -tlsextdebug output indicates classical-only negotiation; a larger value (e.g. 1,124 bytes) indicates hybrid PQ negotiation succeeded.

Test with SecP256r1MLKEM768 (P-256 hybrid)

openssl s_client -connect server.example.com:443 \
    -groups SecP256r1MLKEM768 -brief

SecP256r1MLKEM768 pairs a NIST-approved P-256 key exchange with ML-KEM-768. Use this group when the deployment requires a NIST-approved classical algorithm in the hybrid.

Verify list of supported TLS groups

openssl list -tls-groups

Troubleshooting

Error Cause Fix
error:0308010C:...unsupported on ML-KEM or ML-DSA OpenSSL version < 3.5 — native PQC not available Build or install OpenSSL 3.5+. See Setup. If you cannot upgrade, use the OQS provider with OpenSSL 3.0–3.4 (see below).
verify error:num=20:unable to get local issuer certificate Missing CA certificate in trust store Pass -CAfile /path/to/ca.pem to s_client or openssl verify.
tlsv1 alert handshake failure with PQ groups Remote server does not support the requested key exchange group Include classical fallback groups: -groups X25519MLKEM768:X25519:P-256. Verify the server's OpenSSL version.
PQ algorithms listed under oqsprovider, not default You are running OpenSSL 3.0–3.4 with the OQS provider loaded OQS algorithm names are lowercase (e.g. mlkem768, not ML-KEM-768). Pass -provider oqsprovider -provider default on every command. OQS provider algorithms are not identical to the FIPS 203/204/205 finalised specs in all cases; prefer upgrading to 3.5+.
SELinux denial running OpenSSL from /opt/openssl-3.5 File security context not set for non-system path restorecon -Rv /opt/openssl-3.5
DYLD_LIBRARY_PATH ignored on macOS with SIP System Integrity Protection strips the variable for system binaries Use the full Homebrew path in the alias as shown in Setup, or build from source to /usr/local/openssl-3.5.

OQS provider (legacy / older OpenSSL)

If you must use OpenSSL 3.0–3.4 (e.g. an enterprise distribution where upgrading the system binary is not feasible), the OQS provider adds experimental PQC support. Note that OQS algorithm names differ from the FIPS 203/204/205 names used in 3.5+, and not all finalised algorithm parameters may be present.

# Build and install oqs-provider
git clone https://github.com/open-quantum-safe/oqs-provider.git
cd oqs-provider
mkdir build && cd build
cmake -DOPENSSL_ROOT_DIR=/usr -DCMAKE_INSTALL_PREFIX=/usr ..
make -j"$(nproc)"
sudo make install

Add the provider to openssl.cnf:

[openssl_init]
providers = provider_sect

[provider_sect]
default = default_sect
oqsprovider = oqsprovider_sect

[default_sect]
activate = 1

[oqsprovider_sect]
activate = 1
module = /usr/lib/ossl-modules/oqsprovider.so

With the OQS provider active, pass -provider oqsprovider -provider default and use lowercase algorithm names:

openssl genpkey -provider oqsprovider -provider default \
    -algorithm mlkem768 -out mlkem768_priv.pem

Any command on this page that does not mention the OQS provider requires OpenSSL 3.5+ and the native default provider.