Elvelt

Standard Library

Post-quantum crypto (`mpl_pqc` / `empl_pqc.h`)

Post-quantum crypto (mpl_pqc / empl_pqc.h)

The empl_pqc.h header (395 lines, MPL_Standard/System/empl_pqc.h) exposes post-quantum cryptographic primitives via OpenSSL 3.5+. It supports the NIST-standardized families:

  • ML-KEM (Module-Lattice-Based Key-Encapsulation Mechanism, FIPS 203) — formerly Kyber
  • ML-DSA (Module-Lattice-Based Digital Signature Algorithm, FIPS 204) — formerly Dilithium
  • SLH-DSA (Stateless Hash-Based Digital Signature Algorithm, FIPS 205) — formerly SPHINCS+

All implementations delegate to OpenSSL EVP APIs (EVP_PKEY_*, EVP_SIGNATURE_fetch, EVP_PKEY_CTX_*). On older OpenSSL (< 3.5) the header compiles, but NID lookups return 0 and callers must check __mpl_pqc_primitive_available() first.

Header guard and includes

// empl_pqc.h:24
#ifndef MPL_PQC_H
#define MPL_PQC_H

#include <string>
#include <stdexcept>
#include <cstddef>
#include <openssl/evp.h>
#include <openssl/opensslv.h>
#include <openssl/core_names.h>

Plus compatibility shims for OpenSSL < 3.2 (empl_pqc.h:39-82) that emulate EVP_PKEY_sign_message_init and EVP_PKEY_verify_message_init (introduced in OpenSSL 3.2) using EVP_PKEY_sign_init + EVP_PKEY_CTX_set_params. On OpenSSL ≥ 3.2 these come from <openssl/evp.h> directly.

Namespace

All public functions and types are in namespace mpl_pqc (lines 84-377). C-style __mpl_pqc_* aliases (lines 379-393) are provided for the JS interop surface in empl.h.

namespace mpl_pqc {
    // types
    struct kem_keypair { std::string publicKey; std::string privateKey; };
    struct sig_keypair { std::string publicKey; std::string privateKey; };
    struct kem_encap_result { std::string ciphertext; std::string sharedSecret; };

    // NID lookups
    inline int kem_nid(const std::string& canonical);
    inline int sig_nid(const std::string& canonical);

    // availability check
    inline bool primitive_available(const std::string& canonical);

    // KEM operations
    inline kem_keypair kem_keygen(const std::string& canonical);
    inline kem_encap_result kem_encapsulate(const std::string& canonical, const std::string& rawPublic);
    inline std::string kem_decapsulate(const std::string& canonical, const std::string& rawPrivate, const std::string& ciphertext);

    // KEM key loaders
    inline EVP_PKEY* kem_resolve_public(const std::string& canonical, const std::string& rawPublic);
    inline EVP_PKEY* kem_resolve_private(const std::string& canonical, const std::string& rawPrivate);

    // Signature operations
    inline sig_keypair sig_keygen(const std::string& canonical);
    inline std::string sig_sign(const std::string& canonical, const std::string& rawPrivate, const std::string& data);
    inline bool sig_verify(const std::string& canonical, const std::string& rawPublic, const std::string& signature, const std::string& data);

    // Signature key loaders
    inline EVP_PKEY* sig_resolve_public(const std::string& canonical, const std::string& rawPublic);
    inline EVP_PKEY* sig_resolve_private(const std::string& canonical, const std::string& rawPrivate);
}

Supported algorithms

ML-KEM (KEM / key exchange)

Canonical name NID macro Security level Public key Ciphertext Shared secret
ML-KEM-512 NID_ML_KEM_512 1 (≈ AES-128) 800 B 768 B 32 B
ML-KEM-768 NID_ML_KEM_768 3 (≈ AES-192) 1184 B 1088 B 32 B
ML-KEM-1024 NID_ML_KEM_1024 5 (≈ AES-256) 1568 B 1568 B 32 B

ML-DSA (signature)

Canonical name NID macro Security level Public key Signature
ML-DSA-44 EVP_PKEY_ML_DSA_44 2 (≈ AES-128) 1312 B 2420 B
ML-DSA-65 EVP_PKEY_ML_DSA_65 3 (≈ AES-192) 1952 B 3309 B
ML-DSA-87 EVP_PKEY_ML_DSA_87 5 (≈ AES-256) 2592 B 4627 B

SLH-DSA (signature, hash-based)

Canonical name NID macro Security level Public key Signature
SLH-DSA-SHA2-128f EVP_PKEY_SLH_DSA_SHA2_128F 1 32 B 17 KB (fast)
SLH-DSA-SHA2-128s EVP_PKEY_SLH_DSA_SHA2_128S 1 32 B 7.8 KB (small)
SLH-DSA-SHA2-192f EVP_PKEY_SLH_DSA_SHA2_192F 3 48 B ~35 KB
SLH-DSA-SHA2-192s EVP_PKEY_SLH_DSA_SHA2_192S 3 48 B ~16 KB
SLH-DSA-SHA2-256f EVP_PKEY_SLH_DSA_SHA2_256F 5 64 B ~49 KB
SLH-DSA-SHA2-256s EVP_PKEY_SLH_DSA_SHA2_256S 5 64 B ~25 KB
SLH-DSA-SHAKE-128f EVP_PKEY_SLH_DSA_SHAKE_128F 1 32 B 17 KB
SLH-DSA-SHAKE-128s EVP_PKEY_SLH_DSA_SHAKE_128S 1 32 B 7.8 KB
SLH-DSA-SHAKE-192f EVP_PKEY_SLH_DSA_SHAKE_192F 3 48 B ~35 KB
SLH-DSA-SHAKE-192s EVP_PKEY_SLH_DSA_SHAKE_192S 3 48 B ~16 KB
SLH-DSA-SHAKE-256f EVP_PKEY_SLH_DSA_SHAKE_256F 5 64 B ~49 KB
SLH-DSA-SHAKE-256s EVP_PKEY_SLH_DSA_SHAKE_256S 5 64 B ~25 KB

f (fast) vs s (small): f variants have larger signatures but faster signing. s variants have smaller signatures but slower signing.

API reference

kem_nid(canonical)

// empl_pqc.h:88
inline int kem_nid(const std::string& canonical);

Returns the OpenSSL NID for the given ML-KEM canonical name, or 0 if the algorithm is not available on this OpenSSL build.

int nid = mpl_pqc::kem_nid("ML-KEM-768");
if (nid == 0) { /* OpenSSL too old */ }

sig_nid(canonical)

// empl_pqc.h:98
inline int sig_nid(const std::string& canonical);

Returns the OpenSSL NID for the given ML-DSA / SLH-DSA canonical name, or 0 if unavailable.

primitive_available(canonical)

// empl_pqc.h:122
inline bool primitive_available(const std::string& canonical);

Returns true if the algorithm is available on this OpenSSL build (i.e. both the NID is defined AND OpenSSL can instantiate a EVP_PKEY_CTX for it).

kem_keygen(canonical)

// empl_pqc.h:142
inline kem_keypair kem_keygen(const std::string& canonical);

Generates a fresh ML-KEM keypair. Throws std::runtime_error on failure (algorithm unavailable or OpenSSL error).

auto kp = mpl_pqc::kem_keygen("ML-KEM-768");
// kp.publicKey  = 1184-byte raw key
// kp.privateKey = 2400-byte raw key

kem_encapsulate(canonical, rawPublic)

// empl_pqc.h:243
inline kem_encap_result kem_encapsulate(const std::string& canonical, const std::string& rawPublic);

Encapsulates a shared secret against the given public key. Returns the ciphertext to send to the holder of the private key, and the shared secret.

auto enc = mpl_pqc::kem_encapsulate("ML-KEM-768", kp.publicKey);
// enc.ciphertext   = 1088 bytes
// enc.sharedSecret = 32 bytes

kem_decapsulate(canonical, rawPrivate, ciphertext)

// empl_pqc.h:268
inline std::string kem_decapsulate(const std::string& canonical, const std::string& rawPrivate, const std::string& ciphertext);

Decapsulates the ciphertext using the private key. Returns the shared secret. Throws on failure.

auto ss = mpl_pqc::kem_decapsulate("ML-KEM-768", kp.privateKey, enc.ciphertext);
// ss = 32-byte shared secret (matches enc.sharedSecret)

sig_keygen(canonical)

// empl_pqc.h:294
inline sig_keypair sig_keygen(const std::string& canonical);

Generates a fresh ML-DSA / SLH-DSA signature keypair. Throws on failure.

sig_sign(canonical, rawPrivate, data)

// empl_pqc.h:326
inline std::string sig_sign(const std::string& canonical, const std::string& rawPrivate, const std::string& data);

One-shot sign of data with the private key. Returns the signature bytes. Throws on failure.

Implementation note. OpenSSL 3.5 ML-DSA / SLH-DSA only expose the one-shot sign dispatch (EVP_PKEY_sign_message_init + EVP_PKEY_sign). They do NOT implement streaming (sign_message_update/final) or the legacy sign_init. empl_pqc.h uses the one-shot API.

sig_verify(canonical, rawPublic, signature, data)

// empl_pqc.h:353
inline bool sig_verify(const std::string& canonical, const std::string& rawPublic, const std::string& signature, const std::string& data);

Verifies a signature. Returns true if valid. Throws std::runtime_error on context-initialization failure.

Key-loader functions

The kem_resolve_* and sig_resolve_* functions convert a raw public/private key blob into an EVP_PKEY* suitable for use with the OpenSSL EVP API directly. Callers must EVP_PKEY_free() the returned pointer.

EVP_PKEY* pkey = mpl_pqc::kem_resolve_public("ML-KEM-768", rawPub);
// ... use pkey ...
EVP_PKEY_free(pkey);

These are useful for advanced use cases (e.g. integrating with custom OpenSSL pipelines).

C-style aliases

For JS interop via empl.h, the header provides __mpl_pqc_* aliases:

// empl_pqc.h:381
using __mpl_pqc_kem_keypair      = mpl_pqc::kem_keypair;
using __mpl_pqc_sig_keypair      = mpl_pqc::sig_keypair;
using __mpl_pqc_kem_encap_result = mpl_pqc::kem_encap_result;

inline int                       __mpl_pqc_kem_nid(const std::string& s);
inline int                       __mpl_pqc_sig_nid(const std::string& s);
inline bool                      __mpl_pqc_primitive_available(const std::string& s);
inline __mpl_pqc_kem_keypair     __mpl_pqc_kem_keygen(const std::string& s);
inline __mpl_pqc_sig_keypair     __mpl_pqc_sig_keygen(const std::string& s);
inline __mpl_pqc_kem_encap_result __mpl_pqc_kem_encapsulate(const std::string& s, const std::string& p);
inline std::string               __mpl_pqc_kem_decapsulate(const std::string& s, const std::string& k, const std::string& c);
inline std::string               __mpl_pqc_sig_sign(const std::string& s, const std::string& k, const std::string& d);
inline bool                      __mpl_pqc_sig_verify(const std::string& s, const std::string& p, const std::string& g, const std::string& d);

Usage example

#include "empl_pqc.h"

// Check availability
if (!mpl_pqc::primitive_available("ML-KEM-768")) {
    std::cerr << "ML-KEM-768 not available; need OpenSSL 3.5+\n";
    return 1;
}

// Alice generates a keypair
auto aliceKp = mpl_pqc::kem_keygen("ML-KEM-768");

// Bob encapsulates a shared secret against Alice's public key
auto enc = mpl_pqc::kem_encapsulate("ML-KEM-768", aliceKp.publicKey);
// Bob sends enc.ciphertext to Alice

// Alice decapsulates to recover the shared secret
auto aliceSs = mpl_pqc::kem_decapsulate("ML-KEM-768", aliceKp.privateKey, enc.ciphertext);

// aliceSs == enc.sharedSecret (both 32 bytes)
assert(aliceSs == enc.sharedSecret);

Security considerations

  • Algorithm selection. For new applications, prefer ML-KEM-768 + ML-DSA-65 (NIST security level 3, ≈ AES-192). Use ML-KEM-1024 + ML-DSA-87 only if 256-bit security is required.
  • Side-channel resistance. OpenSSL's ML-KEM / ML-DSA implementations are constant-time but not formally verified against all known side-channel attacks. For high-assurance use, consider explicit constant-time wrappers.
  • Hybrid deployment. PQC is still relatively new. Many deployments use hybrid schemes (e.g. X25519 + ML-KEM-768) to hedge against algorithmic breakthroughs. The C++ backend does NOT currently provide hybrid helpers — callers must compose them manually.
  • Random seed. OpenSSL's keygen uses its internal CSPRNG (RAND_bytes), which is cryptographically secure. Do NOT seed OpenSSL from std::mt19937_64 (used by mpl_crypto_random_uuid in empl_runtime.h).
  • Key serialization. The raw byte format is the canonical ML-KEM/ML-DSA/SLH-DSA byte string. There is no PEM/DER wrapper — convert to PEM via OpenSSL if needed for interchange with non-OpenSSL tools.

Cross-references

Header Provides
empl_pqc.h (this) PQC primitives (KEM, signatures)
empl_runtime.h Non-cryptographic randomness (mpl_crypto_random_uuid, mpl_crypto_random_bytes), OS info, ISO 8601
empl_core.h mpl_value (variant type), mpl_object (boxed objects), mpl_promise<T> (Promise semantics), mpl_throw_*_error
empl.h JS adapter region, mpl_box_value, callable object model, SubtleCrypto implementation

See also

  • cpp-impl.md — C++ stdlib reference
  • modules.md — full stdlib module list
  • runtime.md — runtime utilities
  • headers.md — header hierarchy
  • NIST FIPS 203, 204, 205 — the standards documents
  • OpenSSL EVP documentation — underlying API