The ML-DSA library provides a clean, template-based C++ API for key generation, signing, and verification. All functionality is contained in the dldsa:: namespace with zero heap allocation and no exceptions.
namespace dldsa {
// Parameter sets
struct ML_DSA_44 { /* compile-time parameters */ };
struct ML_DSA_65 { /* compile-time parameters */ };
struct ML_DSA_87 { /* compile-time parameters */ };
// Strongly-typed key and signature objects
template<typename Params>
struct PublicKey {
uint8_t data[Params::PK_SIZE];
const uint8_t* bytes() const;
static constexpr size_t size();
};
template<typename Params>
struct SecretKey {
uint8_t data[Params::SK_SIZE];
const uint8_t* bytes() const;
static constexpr size_t size();
};
template<typename Params>
struct Signature {
uint8_t data[Params::SIG_SIZE];
size_t len; // actual signature length after signing
const uint8_t* bytes() const;
size_t size() const;
};
// Result of signing operation
struct SignResult {
int code; // 0 = success, -1 = error, -3 = rejection loop limit
size_t siglen; // actual signature length (valid when code == 0)
};
// Main API (typed overloads)
template<typename Params>
struct MLDSA {
static int keygen(PublicKey<Params>& pk, SecretKey<Params>& sk,
RngFunc rng, void* rng_ctx);
static SignResult sign(Signature<Params>& sig,
const uint8_t* msg, size_t msglen,
const uint8_t* ctx, size_t ctxlen,
const SecretKey<Params>& sk,
RngFunc rng, void* rng_ctx);
static int verify(const Signature<Params>& sig,
const uint8_t* msg, size_t msglen,
const uint8_t* ctx, size_t ctxlen,
const PublicKey<Params>& pk);
};
// Type aliases
using MLDSA44 = MLDSA<ML_DSA_44>;
using MLDSA65 = MLDSA<ML_DSA_65>;
using MLDSA87 = MLDSA<ML_DSA_87>;
}The raw-pointer API is still available for interoperability and legacy code, but new code should use the typed API above. The raw-pointer functions are marked with [[deprecated]] and accept uint8_t* buffers directly:
// Deprecated overloads (don't use in new code)
int keygen(uint8_t* pk, uint8_t* sk, RngFunc rng, void* rng_ctx);
int sign(uint8_t* sig, size_t* siglen, ...);
int verify(const uint8_t* sig, size_t siglen, ...);This header contains the primary cryptographic interface. All operations are template-based on a parameter set struct.
Three parameter sets are provided, corresponding to NIST security levels:
ML_DSA_44 — NIST Security Level 2 (~128-bit quantum-safe)
static constexpr int K = 4, L = 4, Eta = 2, Tau = 39;
static constexpr size_t PK_SIZE = 1312; // bytes
static constexpr size_t SK_SIZE = 2560; // bytes
static constexpr size_t SIG_SIZE = 2420; // bytesML_DSA_65 — NIST Security Level 3 (~192-bit quantum-safe)
static constexpr int K = 6, L = 5, Eta = 4, Tau = 49;
static constexpr size_t PK_SIZE = 1952; // bytes
static constexpr size_t SK_SIZE = 4032; // bytes
static constexpr size_t SIG_SIZE = 3309; // bytesML_DSA_87 — NIST Security Level 5 (~256-bit quantum-safe)
static constexpr int K = 8, L = 7, Eta = 2, Tau = 60;
static constexpr size_t PK_SIZE = 2592; // bytes
static constexpr size_t SK_SIZE = 4896; // bytes
static constexpr size_t SIG_SIZE = 4627; // bytesHeader: dldsa/rng.hpp
using RngFunc = void (*)(uint8_t* out, size_t len, void* ctx);The RNG is a function pointer that:
- Fills exactly
lenbytes intooutwith cryptographically secure random data - Receives an opaque context pointer
ctxfor state management - Must not fail silently; invalid implementations may compromise security
Example (Linux with getrandom):
#include <sys/random.h>
void os_rng(uint8_t* out, size_t len, void* ctx) {
(void)ctx; // unused
getrandom(out, len, 0); // fills 'len' bytes into 'out'
}
// Usage
MLDSA44::keygen(pk, sk, os_rng, nullptr);Example (Custom state-based RNG):
struct RngState {
uint64_t seed;
// ... your RNG state
};
void my_rng(uint8_t* out, size_t len, void* ctx) {
RngState* state = static_cast<RngState*>(ctx);
// ... fill 'out' using your RNG algorithm
}
RngState rng_state{0xdeadbeefULL};
MLDSA44::keygen(pk, sk, my_rng, &rng_state);Typed API (Preferred):
template<typename Params>
static int MLDSA<Params>::keygen(PublicKey<Params>& pk,
SecretKey<Params>& sk,
RngFunc rng, void* rng_ctx);Parameters:
pk— Output public key objectsk— Output secret key objectrng— Function pointer to random number generatorrng_ctx— Opaque context passed to RNG (may benullptr)
Return Value:
0— Success;pkandskare now populated-1— Invalid argument (null pointer or invalid RNG)
Security Notes:
- The RNG seed should be generated from a system entropy source (e.g.,
/dev/urandom, hardware RNG) - Each call to
keygenmust use a fresh, independent seed - The secret key should be stored securely and protected from unauthorized access
Example (Typed):
#include <iostream>
using namespace dldsa;
PublicKey44 pk;
SecretKey44 sk;
int result = MLDSA44::keygen(pk, sk, os_rng, nullptr);
if (result == 0) {
// Key pair generated successfully
std::cout << "Public key: " << pk.size() << " bytes\n";
}Raw-Pointer API (Deprecated):
[[deprecated("Use the typed overload")]]
static int MLDSA<Params>::keygen(uint8_t* pk, uint8_t* sk,
RngFunc rng, void* rng_ctx);Parameters:
pk— Output buffer for public key (must be exactlyParams::PK_SIZEbytes)sk— Output buffer for secret key (must be exactlyParams::SK_SIZEbytes)rng— Function pointer to random number generatorrng_ctx— Opaque context passed to RNG (may benullptr)
Example (Deprecated):
uint8_t pk[MLDSA44::PK_SIZE];
uint8_t sk[MLDSA44::SK_SIZE];
int result = MLDSA44::keygen(pk, sk, os_rng, nullptr);Typed API (Preferred):
template<typename Params>
static SignResult MLDSA<Params>::sign(Signature<Params>& sig,
const uint8_t* msg, size_t msglen,
const uint8_t* ctx, size_t ctxlen,
const SecretKey<Params>& sk,
RngFunc rng, void* rng_ctx);Parameters:
sig— Output signature object (.lenfield set on success)msg— Message to sign (pointer to first byte, may benullptrifmsglen == 0)msglen— Message length in bytes (may be 0)ctx— Context string (may benullptrfor empty context)ctxlen— Context string length (0–255 bytes); ignored ifctxisnullptrsk— Secret key (fromkeygen)rng— Optional RNG for hedged signing (may benullptrfor deterministic)rng_ctx— Opaque context for RNG
Return Value:
Returns SignResult:
code == 0— Success;sig.lenis the actual signature lengthcode == -1— Invalid argument (null signature, message, or key)code == -3— Signature generation failed (rejection loop limit exceeded; extremely rare ~2^-256)
Notes:
- If
rng == nullptr, signing is deterministic: same message + context → same signature - If
rng != nullptr, signing includes randomness (hedged signing) for additional robustness - Context strings allow domain separation; different contexts produce different signatures
- The context length is limited to 255 bytes per FIPS 204
Example (Deterministic, Typed):
#include <iostream>
using namespace dldsa;
uint8_t msg[] = "Hello, post-quantum world!";
Signature44 sig;
SecretKey44 sk; // from keygen
auto result = MLDSA44::sign(
sig,
msg, sizeof(msg),
nullptr, 0, // no context
sk,
nullptr, nullptr // deterministic (no RNG)
);
if (result.code == 0) {
std::cout << "Signature length: " << result.siglen << " bytes\n";
}Example (With Context, Typed):
using namespace dldsa;
const uint8_t ctx[] = "my-application-v1";
Signature44 sig;
SecretKey44 sk;
auto result = MLDSA44::sign(
sig,
msg, sizeof(msg),
ctx, sizeof(ctx) - 1, // context string
sk,
nullptr, nullptr
);Example (Hedged Signing, Typed):
Signature44 sig;
SecretKey44 sk;
auto result = MLDSA44::sign(
sig,
msg, sizeof(msg),
nullptr, 0,
sk,
os_rng, nullptr // with RNG for randomized signing
);Raw-Pointer API (Deprecated):
[[deprecated("Use the typed overload")]]
static int MLDSA<Params>::sign(uint8_t* sig, size_t* siglen,
const uint8_t* msg, size_t msglen,
const uint8_t* ctx, size_t ctxlen,
const uint8_t* sk,
RngFunc rng, void* rng_ctx);Parameters:
sig— Output buffer for signature (must beParams::SIG_SIZEbytes)siglen— Pointer tosize_t; on success, set to actual signature lengthmsg— Message to signmsglen— Message lengthctx— Context string (may benullptr)ctxlen— Context length (0–255)sk— Secret key buffer (Params::SK_SIZEbytes)rng— Optional RNGrng_ctx— RNG context
Return Value:
0— Success-1— Invalid argument-3— Rejection loop limit exceeded
Example (Deprecated):
uint8_t sig[MLDSA44::SIG_SIZE];
size_t siglen = 0;
int result = MLDSA44::sign(
sig, &siglen,
msg, sizeof(msg),
nullptr, 0,
sk,
nullptr, nullptr
);Typed API (Preferred):
template<typename Params>
static int MLDSA<Params>::verify(const Signature<Params>& sig,
const uint8_t* msg, size_t msglen,
const uint8_t* ctx, size_t ctxlen,
const PublicKey<Params>& pk);Parameters:
sig— Signature object (with.lenfield set by a previoussign()call)msg— Message that was signedmsglen— Message length in bytesctx— Context string used during signing (may benullptr)ctxlen— Context string length (0–255); ignored ifctxisnullptrpk— Public key (from correspondingkeygen)
Return Value:
0— Success; signature is valid-1— Invalid argument (null pointer)-2— Verification failed; signature does not match
Notes:
- The context string must match exactly the context used during signing (case-sensitive)
- The signature length in
sig.lenmust match the expected size; otherwise, return-2 - Verification is deterministic and can be done by anyone
- Does not use fuzzy matching; signature bits must match exactly
Example (Typed):
#include <iostream>
using namespace dldsa;
uint8_t msg[] = "Hello, post-quantum world!";
const uint8_t ctx[] = "my-application-v1";
Signature44 sig; // from sign()
PublicKey44 pk; // from keygen()
int result = MLDSA44::verify(
sig,
msg, sizeof(msg),
ctx, sizeof(ctx) - 1,
pk
);
if (result == 0) {
std::cout << "✓ Signature is valid!\n";
} else if (result == -2) {
std::cout << "✗ Signature is invalid!\n";
} else {
std::cerr << "Error: " << result << '\n';
}Raw-Pointer API (Deprecated):
[[deprecated("Use the typed overload")]]
static int MLDSA<Params>::verify(const uint8_t* sig, size_t siglen,
const uint8_t* msg, size_t msglen,
const uint8_t* ctx, size_t ctxlen,
const uint8_t* pk);Parameters:
sig— Signature buffer (must be exactlyParams::SIG_SIZEbytes)siglen— Signature length (must equalParams::SIG_SIZE)msg— Message that was signedmsglen— Message lengthctx— Context string (may benullptr)ctxlen— Context length (0–255)pk— Public key buffer (Params::PK_SIZEbytes)
Example (Deprecated):
int result = MLDSA44::verify(
sig, MLDSA44::SIG_SIZE,
msg, sizeof(msg),
ctx, sizeof(ctx) - 1,
pk
);The library provides strongly-typed, stack-allocated wrappers for keys and signatures:
template<typename Params>
struct PublicKey {
static constexpr size_t SIZE = Params::PK_SIZE;
uint8_t data[SIZE];
uint8_t* bytes(); // Access raw bytes
const uint8_t* bytes() const;
static constexpr size_t size(); // Returns SIZE
};Convenience aliases: PublicKey44, PublicKey65, PublicKey87
template<typename Params>
struct SecretKey {
static constexpr size_t SIZE = Params::SK_SIZE;
uint8_t data[SIZE];
uint8_t* bytes(); // Access raw bytes
const uint8_t* bytes() const;
static constexpr size_t size(); // Returns SIZE
};Convenience aliases: SecretKey44, SecretKey65, SecretKey87
template<typename Params>
struct Signature {
static constexpr size_t MAX_SIZE = Params::SIG_SIZE;
uint8_t data[MAX_SIZE];
size_t len; // Actual signature length (0 until sign() completes)
uint8_t* bytes(); // Access raw bytes
const uint8_t* bytes() const;
size_t size() const; // Returns len
};Note: The len field is automatically set by sign() on success.
Convenience aliases: Signature44, Signature65, Signature87
struct SignResult {
int code; // 0 = success, -1 = invalid arg, -3 = rejection limit exceeded
size_t siglen; // Actual signature length (valid only when code == 0)
};Returned by the typed sign() overload to communicate both success/failure and the signature length.
Core types and constants used throughout the library:
// Global constants
static constexpr int32_t Q = 8380417; // Modulus
static constexpr int N = 256; // Polynomial degree
static constexpr int D = 13; // Rounding bit count
static constexpr size_t SEED_BYTES = 32; // Seed size (ρ, K, ξ)
static constexpr size_t TR_BYTES = 64; // Public key hash
static constexpr size_t RND_BYTES = 32; // Hedged signing randomness
static constexpr size_t MU_BYTES = 64; // Message hash
// Polynomial type
struct Poly {
int32_t coeffs[256]; // 256 coefficients in range [-(q-1), q-1]
};
// Polynomial vector type
template<int Size>
struct PolyVec {
Poly vec[Size]; // Vector of 'Size' polynomials
};Header: dldsa/poly.hpp
These are internal helper functions, exposed for advanced use and testing:
void poly_add(Poly& a, const Poly& b); // a += b
void poly_sub(Poly& a, const Poly& b); // a -= b
void poly_ntt(Poly& a); // In-place forward NTT
void poly_invntt_tomont(Poly& a); // In-place inverse NTT + Montgomery
void poly_pointwise_montgomery(Poly& c,
const Poly& a,
const Poly& b); // c = a * b (NTT domain)
void poly_reduce(Poly& a); // Barrett reduction
void poly_caddq(Poly& a); // Conditional add q
void poly_freeze(Poly& a); // Full reduction to [0, q)
void poly_shiftl(Poly& a); // Multiply by 2^D
bool poly_chknorm(const Poly& a, int32_t bound); // Check |a[i]| < boundThese functions are not part of the main API and are subject to change. Use them only if you are implementing custom extensions or testing.
All main functions return int with the following meanings:
| Code | Meaning | Context |
|---|---|---|
0 |
Success | All operations |
-1 |
Invalid argument | Null pointer, bad buffer size, out-of-range context length |
-2 |
Verification failed | verify() only; signature does not match |
-3 |
Rejection loop limit exceeded | sign() only; extremely rare; indicates implementation issue |
Note: For sign() and verify(), passing nullptr for optional RNG/context is valid and returns 0 on success, not an error.
Choosing the right parameter set depends on your security requirements:
| Aspect | ML-DSA-44 | ML-DSA-65 | ML-DSA-87 |
|---|---|---|---|
| Security | NIST L2 | NIST L3 | NIST L5 |
| Quantum Safety | ~128 bits | ~192 bits | ~256 bits |
| Key Size | 1.3 KB | 1.9 KB | 2.6 KB (public) |
| Signature Size | 2.4 KB | 3.3 KB | 4.6 KB |
| Speed | Fastest | Medium | Slowest |
| Use Case | Cost-sensitive IoT | General-purpose | High-security apps |
- All public API is in the
dldsa::namespace - Type names use
PascalCase(e.g.,Poly,PolyVec,MLDSA) - Function names use
snake_case(e.g.,keygen,poly_add) - Constants use
UPPER_CASE(e.g.,Q,N,SEED_BYTES) - Template parameters use
PascalCase(e.g.,Params,Size)
[ρ (32 bytes)] [T₁ packed (K × 320 bytes)]
Where K is the parameter set's module dimension.
[ρ (32)] [K (32)] [tr (64)] [s₁ packed (L × 96 or 128)] [s₂ packed (K × 96 or 128)] [t₀ packed (K × 416)]
[c̃ (32 or 48 or 64)] [z packed (L × 576 or 640)] [h (ω + L bytes)]
Where ω is the rejection threshold (80, 55, or 75 depending on parameter set).
With default settings (no on-demand matrix):
keygen: ~50 KBsign: ~60 KBverify: ~70 KB (includes full matrix storage)
With DLDSA_ONDEMAND_MATRIX=ON:
- Reduces matrix storage overhead
verify: ~30 KB (no full matrix in memory)- Computation slightly slower due to matrix regeneration
See QUICKSTART.md for practical code examples and usage patterns.
This header provides serialization and deserialization of keys in OpenSSL 3.5+ compatible formats. Keys can be exported to DER (binary) or PEM (text) formats for interoperability with OpenSSL tooling and other implementations.
Public Keys:
SubjectPublicKeyInfoDER (binary)SubjectPublicKeyInfoPEM (text,-----BEGIN PUBLIC KEY-----)
Private Keys:
- PKCS#8 seed-only DER/PEM — compact format matching OpenSSL's
genpkeydefault output - PKCS#8 expanded-key DER/PEM — full secret key bytes (when seed is not available)
Each parameter set has a unique OID (Object Identifier) per draft-ietf-lamps-dilithium-certificates:
| Parameter Set | OID | DER Hex |
|---|---|---|
| ML-DSA-44 | 2.16.840.1.101.3.4.3.17 | 06 09 60 86 48 01 65 03 04 03 11 |
| ML-DSA-65 | 2.16.840.1.101.3.4.3.18 | 06 09 60 86 48 01 65 03 04 03 12 |
| ML-DSA-87 | 2.16.840.1.101.3.4.3.19 | 06 09 60 86 48 01 65 03 04 03 13 |
template<typename Params>
struct OsslIO {
// Compile-time size constants
static constexpr size_t PK_DER_SIZE;
static constexpr size_t SK_SEED_DER_SIZE; // 54 bytes (all param sets)
static constexpr size_t SK_EXP_DER_SIZE;
static constexpr size_t PK_PEM_SIZE;
static constexpr size_t SK_SEED_PEM_SIZE;
static constexpr size_t SK_EXP_PEM_SIZE;
// Write (serialize)
static int pk_to_der(uint8_t* buf, size_t buflen,
const PublicKey<Params>& pk);
static int pk_to_pem(char* buf, size_t buflen,
const PublicKey<Params>& pk);
static int sk_to_der(uint8_t* buf, size_t buflen,
const uint8_t seed[SEED_BYTES]);
static int sk_to_pem(char* buf, size_t buflen,
const uint8_t seed[SEED_BYTES]);
static int sk_expanded_to_der(uint8_t* buf, size_t buflen,
const SecretKey<Params>& sk);
static int sk_expanded_to_pem(char* buf, size_t buflen,
const SecretKey<Params>& sk);
// Read (deserialize)
static int pk_from_der(PublicKey<Params>& pk,
const uint8_t* der, size_t derlen);
static int pk_from_pem(PublicKey<Params>& pk,
const char* pem, size_t pemlen);
static int sk_from_der(PublicKey<Params>& pk,
SecretKey<Params>& sk,
const uint8_t* der, size_t derlen);
static int sk_from_pem(PublicKey<Params>& pk,
SecretKey<Params>& sk,
const char* pem, size_t pemlen);
};
// Convenience aliases
using OsslIO44 = OsslIO<ML_DSA_44>;
using OsslIO65 = OsslIO<ML_DSA_65>;
using OsslIO87 = OsslIO<ML_DSA_87>;To DER (binary):
using namespace dldsa;
PublicKey44 pk; // from keygen()
uint8_t der[OsslIO44::PK_DER_SIZE];
int bytes_written = OsslIO44::pk_to_der(der, sizeof(der), pk);
if (bytes_written < 0) {
// Error: invalid input
}
// bytes_written == OsslIO44::PK_DER_SIZETo PEM (text):
char pem[OsslIO44::PK_PEM_SIZE];
int chars_written = OsslIO44::pk_to_pem(pem, sizeof(pem), pk);
if (chars_written < 0) {
// Error
}
// pem is now null-terminated and contains:
// -----BEGIN PUBLIC KEY-----
// [base64-encoded DER]
// -----END PUBLIC KEY-----Seed-only (recommended):
When you have the original seed (ξ), use the seed-only format — this matches OpenSSL's default output and is the most compact:
uint8_t seed[SEED_BYTES]; // from RNG during keygen
uint8_t der[OsslIO44::SK_SEED_DER_SIZE]; // exactly 54 bytes
int bytes = OsslIO44::sk_to_der(der, sizeof(der), seed);
if (bytes != static_cast<int>(OsslIO44::SK_SEED_DER_SIZE)) {
// Error
}Expanded key (alternative):
If the seed is not available (e.g., only the SecretKey<Params> object exists):
SecretKey44 sk; // from keygen()
uint8_t der[OsslIO44::SK_EXP_DER_SIZE];
int bytes = OsslIO44::sk_expanded_to_der(der, sizeof(der), sk);
if (bytes != static_cast<int>(OsslIO44::SK_EXP_DER_SIZE)) {
// Error
}To PEM:
char pem[OsslIO44::SK_SEED_PEM_SIZE];
OsslIO44::sk_to_pem(pem, sizeof(pem), seed);
// Contains: -----BEGIN PRIVATE KEY-----PublicKey44 pk;
// From DER
int ret = OsslIO44::pk_from_der(pk, der, derlen);
if (ret != 0) {
// Error: -1 = invalid input, wrong OID, wrong size
}
// From PEM
ret = OsslIO44::pk_from_pem(pk, pem_string, pem_length);
if (ret != 0) {
// Error
}
// pk.data now contains the public key bytesPublicKey44 pk;
SecretKey44 sk;
// From DER — automatically handles seed-only, expanded, or both formats
int ret = OsslIO44::sk_from_der(pk, sk, der, derlen);
if (ret != 0) {
// Error: -1 = invalid input, wrong OID, wrong version
}
// If the input was seed-only:
// - pk is populated (keygen replayed with the seed)
// - sk is populated
// If the input was expanded-only:
// - sk is populated
// - pk left unchanged (cannot derive from expanded key alone)
// From PEM
ret = OsslIO44::sk_from_pem(pk, sk, pem_string, pem_length);Important: When reading a seed-only private key, the library internally replays keygen with the recovered seed. This produces both the public and secret keys without requiring the seed to be stored separately.
All functions return:
0or positive: Success; for write functions, returns number of bytes/chars written-1: Invalid input (null pointer, buffer too small, wrong OID, malformed DER/PEM)
Generate a key with OpenSSL and load it:
# Generate ML-DSA-44 key
openssl genpkey -algorithm ML-DSA-44 -out priv.pem
# Extract public key
openssl pkey -in priv.pem -pubout -out pub.pemThen load in your C++ application:
#include <fstream>
#include <iostream>
#include "dldsa/ossl_io.hpp"
using namespace dldsa;
// Read PEM file
std::ifstream f("priv.pem");
char pem[OsslIO44::SK_SEED_PEM_SIZE];
f.read(pem, sizeof(pem));
std::streamsize pem_len = f.gcount();
// Load key
PublicKey44 pk;
SecretKey44 sk;
int ret = OsslIO44::sk_from_pem(pk, sk, pem, static_cast<size_t>(pem_len));
if (ret != 0) {
std::cerr << "Failed to load private key\n";
return 1;
}
// Ready to sign
Signature44 sig;
MLDSA44::sign(sig, msg, msglen, nullptr, 0, sk, nullptr, nullptr);- Zero heap allocation: All buffers are caller-provided on the stack
- No exceptions: All errors return
-1 - Buffer sizes: Compile-time constants; use
sizeofor the constant directly - Maximum sizes (stack):
- PEM public key: ~2 KB
- PEM private key: ~1 KB (seed-only), ~10 KB (expanded)
- DER is binary, typically 40–70% smaller than PEM
Include the headers:
#include "dldsa/sign.hpp" // Core signing API
#include "dldsa/ossl_io.hpp" // Optional: OpenSSL-compatible I/OLink against the library:
cmake -B build -S .
cmake --build build
g++ myapp.cpp -o myapp -I./include -L./build -ldldsaOr use CMake in your project:
add_subdirectory(path/to/dl-dsa)
add_executable(myapp myapp.cpp)
target_link_libraries(myapp dldsa)