rbx-cryptography

Post quantum

Agree on keys with ML-KEM and sign with ML-DSA, which stay safe against quantum computers.

A large enough quantum computer could break X25519 and Ed25519. ML-KEM and ML-DSA are the replacements NIST standardized in FIPS 203 and FIPS 204, and they stay safe against one. They're bigger and slower than X25519 and Ed25519, so use them when your data needs to stay secret or trusted for many years.

Both live under Cryptography.Verification, and each has three security levels. A higher level is safer, slower and bigger.

ML-KEM

ML-KEM agrees on a 32 byte shared secret, like key exchange, but in one direction: one side makes a key pair, and the other side uses its public key to make a secret and a ciphertext. Only the secret key can get the secret back out of the ciphertext.

local MlKEM = Cryptography.Verification.MlKEM
local CSPRNG = Cryptography.Utilities.CSPRNG

-- the receiver
local PublicKey: buffer, SecretKey: buffer = MlKEM.MLKEM_768.GenerateKeys()

-- the sender, with the receiver's public key
local Ciphertext: buffer, SharedSecret: buffer = MlKEM.MLKEM_768.Encapsulate(CSPRNG.RandomBytes(32), PublicKey)

-- the receiver, with the ciphertext
local Recovered: buffer = MlKEM.MLKEM_768.Decapsulate(Ciphertext, SecretKey)
print(MlKEM.SecretsEqual(SharedSecret, Recovered)) -- true
  • GenerateKeys makes a key pair from the library's random number generator. KeyGen(D, Z) does the same from two 32 byte seeds you give it.
  • Encapsulate takes 32 random bytes and the public key, and gives back the ciphertext to send and the shared secret to keep.
  • Decapsulate gives back the same shared secret. If the ciphertext was changed, it gives back a different secret, never an error, so the two sides end up with different keys and nothing they encrypt will decrypt.
  • SecretsEqual compares two secrets in constant time.
LevelPublic keySecret keyCiphertext
MLKEM_512800 bytes1,632 bytes768 bytes
MLKEM_7681,184 bytes2,400 bytes1,088 bytes
MLKEM_10241,568 bytes3,168 bytes1,568 bytes

The shared secret is 32 bytes at every level. ML-KEM-768 is the usual choice.

Checking a stored key

ValidateDecapsulationKey checks that a secret key wasn't damaged or changed. It doesn't need to run on every call, only once when you load a key from storage or get it from somewhere else:

if not MlKEM.MLKEM_768.ValidateDecapsulationKey(SecretKey) then
	error("the stored key is damaged")
end

ML-DSA

ML-DSA signs messages, like Ed25519. You give it a buffer to write the signature into:

local MlDSA = Cryptography.Verification.MlDSA
local CSPRNG = Cryptography.Utilities.CSPRNG

local PublicKey: buffer, SecretKey: buffer = MlDSA.ML_DSA_65.GenerateKeys()

local Context: buffer = buffer.create(0)
local Signature: buffer = buffer.create(MlDSA.ML_DSA_65.SigByteLen)

local Signed: boolean = MlDSA.ML_DSA_65.Sign(Message, CSPRNG.RandomBytes(32), SecretKey, Context, Signature)
local Valid: boolean = MlDSA.ML_DSA_65.Verify(Message, PublicKey, Context, Signature)
  • The 32 random bytes make each signature different. Pass 32 zero bytes, buffer.create(32), to make the same signature every time for the same message.
  • The context is up to 255 bytes that tie a signature to one use, like buffer.fromstring("save file"). A signature only checks out with the same context it was signed with. Pass an empty buffer if you don't need one.
  • Sign writes the signature into Signature, and gives false if it couldn't, such as when the context is longer than 255 bytes.
LevelPublic keySecret keySignature
ML_DSA_441,312 bytes2,560 bytes2,420 bytes
ML_DSA_651,952 bytes4,032 bytes3,309 bytes
ML_DSA_872,592 bytes4,896 bytes4,627 bytes

Each level has its sizes as fields: PubKeyByteLen, SecKeyByteLen and SigByteLen.

Next: Random numbers

On this page