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.