A sufficiently large quantum computer would break RSA, X25519/X448 and ECDSA/ECDH on every curve on the Public Key Cryptography page: none of that math resists Shor's algorithm. It does not need to exist today to be a problem today, an adversary can record encrypted traffic now and decrypt it once such a machine exists, a strategy usually called "harvest now, decrypt later". Anything that must stay confidential for years, not days, should already be moving to post-quantum key exchange.
NIST standardized three schemes, all implemented here, and all built on nothing but SHAKE (see Hashing & Message Authentication), so their security rests only on the hash being sound: no lattice or number theory assumption to break.
| Scheme | Purpose | Standard | Unit |
| ML-KEM | Key encapsulation, replaces Diffie-Hellman | FIPS 203 | sgcCrypto_MLKEM |
| ML-DSA | Signatures, lattice based, the default choice | FIPS 204 | sgcCrypto_MLDSA |
| SLH-DSA | Signatures, hash based only, conservative fallback | FIPS 205 | sgcCrypto_SLHDSA |
ML-KEM and the hybrid construction (recommended)
ML-KEM replaces a Diffie-Hellman exchange: the sender encapsulates a shared secret to the recipient's public key, the recipient decapsulates it with the private key, and both end up with the same 32 bytes. It comes in three parameter sets, larger meaning a bigger security margin and bigger keys.
procedure sgcMLKEM_GenerateKeyPair(aParams: TsgcMLKEMParams; out aPublicKey, aPrivateKey: TBytes);
procedure sgcMLKEM_Encapsulate(aParams: TsgcMLKEMParams; const aPublicKey: TBytes; out aCiphertext, aSharedSecret: TBytes);
function sgcMLKEM_Decapsulate(aParams: TsgcMLKEMParams; const aPrivateKey, aCiphertext: TBytes): TBytes;
var
oPub, oPriv, oCiphertext, oSecretA, oSecretB: TBytes;
begin
sgcMLKEM_GenerateKeyPair(mlkem768, oPub, oPriv);
sgcMLKEM_Encapsulate(mlkem768, oPub, oCiphertext, oSecretA); { sender }
oSecretB := sgcMLKEM_Decapsulate(mlkem768, oPriv, oCiphertext); { recipient }
{ oSecretA = oSecretB }
end;
FIPS 203 decapsulation never fails.
A corrupt or malicious ciphertext does not raise an error, it silently returns a different secret, derived from an implicit rejection value baked into the private key. Returning an error instead would hand an attacker a decryption oracle to probe the private key with; the standard avoids that by design. Whatever the derived secret protects has to detect the mismatch on its own, for example an AEAD tag failing to verify downstream.
What to actually deploy today is the hybrid construction: X25519 combined with ML-KEM-768, so the shared secret depends on both. It stays safe if ML-KEM is ever broken by a cryptanalytic advance, and it stays safe if a quantum computer breaks X25519; an attacker needs both to fail at once.
procedure sgcHybrid_GenerateKeyPair(out aPublicKey, aPrivateKey: TBytes);
procedure sgcHybrid_Encapsulate(const aPublicKey: TBytes; out aCiphertext, aSharedSecret: TBytes);
function sgcHybrid_Decapsulate(const aPrivateKey, aCiphertext: TBytes; out aSharedSecret: TBytes): Boolean;
X-Wing and the TLS 1.3 hybrid groups
sgcCrypto_MLKEM_Hybrid holds three hybrid families. The sgcHybrid_ functions above are the original sgcCrypto format and are kept for compatibility. X-Wing is the standardised general purpose hybrid KEM, X25519 combined with ML-KEM-768, and it is the one to use in new code outside TLS. The third family builds the TLS 1.3 hybrid key shares and nothing else.
X-Wing (draft-connolly-cfrg-xwing-kem-10) stores one secret only: the private key is a 32-byte seed, and every operation expands it into the ML-KEM-768 and X25519 material it needs. The public key is 1216 bytes, the ciphertext 1120 bytes and the shared secret 32 bytes. Encapsulation runs the FIPS 203 encapsulation key check and raises when the public key fails it.
procedure sgcXWing_GenerateKeyPair(out aPublicKey, aPrivateKey: TBytes);
procedure sgcXWing_Encapsulate(const aPublicKey: TBytes; out aCiphertext, aSharedSecret: TBytes);
function sgcXWing_Decapsulate(const aPrivateKey, aCiphertext: TBytes; out aSharedSecret: TBytes): Boolean;
var
oPub, oPriv, oCiphertext, oSecretA, oSecretB: TBytes;
begin
sgcXWing_GenerateKeyPair(oPub, oPriv);
sgcXWing_Encapsulate(oPub, oCiphertext, oSecretA); { sender }
if sgcXWing_Decapsulate(oPriv, oCiphertext, oSecretB) then
{ oSecretA = oSecretB };
end;
The TLS 1.3 hybrid key shares of RFC 10024 are X25519MLKEM768, SecP256r1MLKEM768 and SecP384r1MLKEM1024, IANA codepoints 0x11EC, 0x11EB and 0x11ED. These are not KEMs you can use on their own. The shared secret is a plain concatenation of the two halves, which is safe only because TLS feeds it into its key schedule together with the handshake transcript, so use them to build a TLS key share and nothing else. The Native TLS engine offers X25519MLKEM768 first by default, so a client or a server using it already negotiates a post-quantum key exchange with no extra code.
The hybrid groups exist only in TLS 1.3. The native engine also speaks TLS 1.2, and a connection that falls back to it because the peer cannot do TLS 1.3 uses a classic group, X25519, secp256r1 or secp384r1, with no post-quantum protection. Set TLSOptions.Version on a client or SSLOptions.Version on a server to tls1_3, and list only hybrid groups in NativeTLS_Options.Groups, when the key exchange must always be post-quantum.
The Post-Quantum TLS demo, Demos\18.Cryptography\08.PostQuantum_TLS, has a TLS version selector with the choices TLS 1.3 and TLS 1.2, TLS 1.3 only and TLS 1.2 only, and a cipher suite selector, and its status line shows the negotiated version, group and cipher suite. The server demo can sign with an ML-DSA-44 certificate, which serves TLS 1.3 clients only, because TLS 1.2 has no ML-DSA signature scheme.
type
TsgcTLSHybridGroup = (thgX25519MLKEM768, thgSecP256r1MLKEM768, thgSecP384r1MLKEM1024);
function sgcTLSHybrid_GroupID(aGroup: TsgcTLSHybridGroup): Word;
function sgcTLSHybrid_GroupName(aGroup: TsgcTLSHybridGroup): string;
function sgcTLSHybrid_Available(aGroup: TsgcTLSHybridGroup): Boolean;
procedure sgcTLSHybrid_ClientKeyShare(aGroup: TsgcTLSHybridGroup; out aKeyShare, aPrivateState: TBytes);
function sgcTLSHybrid_ServerKeyShare(aGroup: TsgcTLSHybridGroup; const aClientKeyShare: TBytes;
out aKeyShare, aSharedSecret: TBytes): Boolean;
function sgcTLSHybrid_ClientSharedSecret(aGroup: TsgcTLSHybridGroup;
const aPrivateState, aServerKeyShare: TBytes; out aSharedSecret: TBytes): Boolean;
The client keeps aPrivateState secret, passes it unchanged to sgcTLSHybrid_ClientSharedSecret and wipes it afterwards; its layout is not part of the API. The two Boolean functions return False, with every output empty, on any invalid peer input: a key share of the wrong length, an ML-KEM encapsulation key that fails the FIPS 203 check, a point that is not on the curve, or an all-zero X25519 result. RFC 10024 requires the connection to be aborted with an illegal_parameter alert in each of those cases.
Everything built on X25519, which means the sgcHybrid_ functions, X-Wing and the X25519MLKEM768 group, is compiled out of an SGC_CRYPTO_FIPS build, see FIPS Mode. The two SecP groups stay, because P-256, P-384 and ML-KEM are all approved algorithms. Call sgcTLSHybrid_Available before offering or accepting a group rather than assuming it is there.
ML-DSA (recommended for signatures)
Signing draws a masking vector and tries to build a signature, restarting when the attempt would leak information about the key; the expected number of attempts is small but not fixed, so signing takes a variable amount of time. That is inherent to the scheme and is not a side channel on the key. Signatures are large: 2420 bytes for ML-DSA-44 against 64 for Ed25519, and public keys 1312 bytes against 32, so budget for the difference before adopting it in a size sensitive protocol.
procedure sgcMLDSA_GenerateKeyPair(aParams: TsgcMLDSAParams; out aPublicKey, aPrivateKey: TBytes);
function sgcMLDSA_Sign(aParams: TsgcMLDSAParams; const aPrivateKey, aMessage, aContext: TBytes): TBytes;
function sgcMLDSA_Verify(aParams: TsgcMLDSAParams; const aPublicKey, aMessage, aSignature, aContext: TBytes): Boolean;
var
oPub, oPriv, oSignature, oEmpty: TBytes;
begin
sgcMLDSA_GenerateKeyPair(mldsa44, oPub, oPriv);
oSignature := sgcMLDSA_Sign(mldsa44, oPriv, oMessage, oEmpty);
if sgcMLDSA_Verify(mldsa44, oPub, oMessage, oSignature, oEmpty) then
{ valid };
end;
SLH-DSA (conservative fallback)
SLH-DSA rests on nothing but the hash function: no lattice problem to break, ever, at any point in the future. That guarantee is expensive. A signature runs from 7856 bytes (the smallest, SLH-DSA-SHAKE-128s) to 49856 bytes (the largest, SLH-DSA-SHAKE-256f), and signing an s (small) parameter set walks hundreds of thousands of hash operations and can take tens of seconds; the f (fast) sets sign in around one to seven seconds at roughly twice the signature size. Verification is quick for every parameter set, which suits a sign-rarely, verify-often use case such as firmware or code signing better than an interactive protocol.
procedure sgcSLHDSA_GenerateKeyPair(aParams: TsgcSLHDSAParams; out aPublicKey, aPrivateKey: TBytes);
function sgcSLHDSA_Sign(aParams: TsgcSLHDSAParams; const aPrivateKey, aMessage, aContext: TBytes;
aDeterministic: Boolean = True): TBytes;
function sgcSLHDSA_Verify(aParams: TsgcSLHDSAParams; const aPublicKey, aMessage, aSignature, aContext: TBytes): Boolean;
Key formats: SubjectPublicKeyInfo, PKCS#8 and PEM
The raw byte strings the FIPS algorithms consume are not a storage format, and no other implementation will read them. To write a key to disk, put it in a certificate or hand it to another library, export it in the standard containers: an X.509 SubjectPublicKeyInfo for a public key, a PKCS#8 OneAsymmetricKey for a private key, and PEM armour (RFC 7468) around either, labelled PUBLIC KEY and PRIVATE KEY. The three schemes expose the same eight functions, each following the RFC that defines its encoding.
| Scheme | Container RFC | DER functions | PEM functions |
| ML-DSA | RFC 9881 | sgcMLDSA_ExportSubjectPublicKeyInfo, sgcMLDSA_ImportSubjectPublicKeyInfo, sgcMLDSA_ExportPrivateKeyInfo, sgcMLDSA_ImportPrivateKeyInfo | sgcMLDSA_ExportPublicKeyPEM, sgcMLDSA_ImportPublicKeyPEM, sgcMLDSA_ExportPrivateKeyPEM, sgcMLDSA_ImportPrivateKeyPEM |
| ML-KEM | RFC 9935 | sgcMLKEM_ExportSubjectPublicKeyInfo, sgcMLKEM_ImportSubjectPublicKeyInfo, sgcMLKEM_ExportPrivateKeyInfo, sgcMLKEM_ImportPrivateKeyInfo | sgcMLKEM_ExportPublicKeyPEM, sgcMLKEM_ImportPublicKeyPEM, sgcMLKEM_ExportPrivateKeyPEM, sgcMLKEM_ImportPrivateKeyPEM |
| SLH-DSA | RFC 9909 | sgcSLHDSA_ExportSubjectPublicKeyInfo, sgcSLHDSA_ImportSubjectPublicKeyInfo, sgcSLHDSA_ExportPrivateKeyInfo, sgcSLHDSA_ImportPrivateKeyInfo | sgcSLHDSA_ExportPublicKeyPEM, sgcSLHDSA_ImportPublicKeyPEM, sgcSLHDSA_ExportPrivateKeyPEM, sgcSLHDSA_ImportPrivateKeyPEM |
The algorithm identifier carries the NIST object identifier with the parameters absent, so the parameter set travels with the key and import returns it. sgcMLDSA_OID, sgcMLKEM_OID and sgcSLHDSA_OID give the dotted string, and the matching ParamsFromOID functions map one back.
Three private key forms for ML-DSA and ML-KEM.
The export functions take an aFormat parameter of type TsgcPQCPrivateKeyFormat, declared in sgcCrypto_DER. pqkfSeed, the default, writes the seed alone, 32 bytes for ML-DSA and the 64-byte d || z for ML-KEM; this is the form both RFCs recommend and it is by far the smallest. pqkfExpanded writes the expanded FIPS private key. pqkfBoth writes both and raises when the expanded key is not the expansion of the seed, which is what the RFCs call a malformed key.
A seed cannot be recovered from an expanded key afterwards, so ask for it at generation time with sgcMLDSA_GenerateKeyPairAndSeed or sgcMLKEM_GenerateKeyPairAndSeed when you intend to store the recommended form. SLH-DSA has a single private key form, SK.seed || SK.prf || PK.seed || PK.root, so its export takes no format parameter.
function sgcMLDSA_ExportPrivateKeyPEM(aParams: TsgcMLDSAParams; const aSeed, aPrivateKey: TBytes;
aFormat: TsgcPQCPrivateKeyFormat = pqkfSeed): string;
function sgcMLDSA_ImportPrivateKeyPEM(const aPEM: string; out aParams: TsgcMLDSAParams;
out aSeed, aPrivateKey, aPublicKey: TBytes): Boolean;
var
vParams: TsgcMLDSAParams;
oSeed, oPub, oPriv: TBytes;
vPublicPEM, vPrivatePEM: string;
begin
sgcMLDSA_GenerateKeyPairAndSeed(mldsa65, oSeed, oPub, oPriv);
vPublicPEM := sgcMLDSA_ExportPublicKeyPEM(mldsa65, oPub);
vPrivatePEM := sgcMLDSA_ExportPrivateKeyPEM(mldsa65, oSeed, oPriv, pqkfSeed);
{ reading it back: the parameter set comes from the key itself }
if sgcMLDSA_ImportPrivateKeyPEM(vPrivatePEM, vParams, oSeed, oPriv, oPub) then
{ vParams = mldsa65, and oPriv and oPub are the expansion of oSeed };
end;
Import validates, it does not trust.
Every import function returns False rather than raising, and it returns False on malformed DER, on trailing data, on an unknown object identifier, on parameters that are present when they must be absent, and on a key whose length does not match the parameter set. Beyond the structure, an ML-KEM public key must pass the FIPS 203 modulus check, an expanded ML-KEM private key must pass the hash check and the pairwise consistency test, and an expanded ML-DSA private key is rebuilt into its public key so that the stored t0 and tr must both agree with it. An embedded public key that does not match the private key is refused. When a private key holds a seed, all three outputs come back filled, so a caller never has to expand it separately.
sgcSLHDSA_ImportPrivateKeyInfo and sgcSLHDSA_ImportPrivateKeyPEM take an extra aVerify parameter, True by default, that recomputes PK.root from the private seed and refuses a key whose stored root differs. That rebuild costs as much as a key generation, which is seconds for the s parameter sets, so pass False only for keys your own application generated or already trusts. The object identifier, the lengths and the structure are still checked when it is False.
These are the containers the rest of the library reads. A PKCS#8 ML-DSA private key is what the JWT client signs with, a SubjectPublicKeyInfo is what the JWT server validates with, and both are what Certificates & PKI puts into a certificate or a certificate signing request.
Choosing a parameter set
Every size call (sgcMLKEM_PublicKeySize, sgcMLDSA_SignatureSize, sgcSLHDSA_SignatureSize and their PrivateKeySize counterparts) takes the same parameter enum as the operation itself, so a protocol can size its buffers before doing any cryptography:
| Parameter set | Public key | Private key | Ciphertext / Signature |
| ML-KEM-512 | 800 | 1632 | 768 |
| ML-KEM-768 | 1184 | 2400 | 1088 |
| ML-KEM-1024 | 1568 | 3168 | 1568 |
| ML-DSA-44 | 1312 | 2560 | 2420 |
| ML-DSA-65 | 1952 | 4032 | 3309 |
| ML-DSA-87 | 2592 | 4896 | 4627 |
| SLH-DSA-SHAKE-128s | 32 | 64 | 7856 |
| SLH-DSA-SHAKE-128f | 32 | 64 | 17088 |
| SLH-DSA-SHAKE-192s | 48 | 96 | 16224 |
| SLH-DSA-SHAKE-192f | 48 | 96 | 35664 |
| SLH-DSA-SHAKE-256s | 64 | 128 | 29792 |
| SLH-DSA-SHAKE-256f | 64 | 128 | 49856 |
All sizes are in bytes. The ML-KEM shared secret is always 32 bytes regardless of parameter set (sgcMLKEM_SharedSecretSize).