Cryptography in std
std/crypto is the largest part of kola's standard library: more than forty modules covering SHA-2 and SHA-3, HMAC, HKDF, PBKDF2 and Argon2, AES-GCM and ChaCha20-Poly1305, X25519, Ed25519, P-256 and P-384, RSA, the NIST post-quantum schemes, X.509 parsing and issuing, and the TLS 1.3 key schedule. All of it is embedded in the compiler binary and reachable with one use line.
This chapter is a map and a set of rules rather than an API listing; exact signatures are in each module's header comment (std/crypto/<name>.kl), and kola help std-index lists every public item. One fact shapes everything below. A kola function declares its effects in its signature, and every function in std/crypto declares !{} (pure computation) or !{alloc(r)} (it also allocates memory); none of them performs io, kola's effect for touching files, sockets or the clock. No capability can reach a hash function, so a cryptographic bug in this tree cannot also be an exfiltration bug: the code has nothing to talk to. It is also why every example here is a plain test with no Sys.
What is in the tree
Hashes
A cryptographic hash turns any input into a short fixed-size digest, such that nobody can find two inputs with the same digest or an input for a given digest. Hashes identify content and are the building block for most of what follows.
sha256,sha384,sha512: FIPS 180-4. Each has a one-shotdigest(&msg)returning a fixed array;sha1andsha256also have incrementalinit/update/finishfor a message you never hold whole.keccakandfips202: SHA-3 and SHAKE.keccakis the convenient face (sha3_256,sha3_512,shake256_32);fips202is the absorb/squeeze machinery underneath, which the post-quantum modules drive directly.blake2sandblake2b: RFC 7693.blake2sserves WireGuard (an unkeyed hash and a keyed 128-bit MAC);blake2bserves Argon2, which needs a runtime-chosen output length.sha1: for interoperability only. Its collision resistance is broken; it is here because Git object ids, old TLS transcripts and HMAC-SHA-1 are still real.md5: cryptographically broken, with practical chosen-prefix collisions. It exists for wire formats that require it, such as PostgreSQL's legacymd5password authentication. Never use it for integrity or anything you get to design.
MACs and key derivation
A MAC (message authentication code) is a short tag computed from a message and a secret key; whoever knows the key can check that the message was not altered. A key derivation function (KDF) stretches or splits one secret into the keys a protocol needs; a password-based KDF is deliberately slow, so guessing passwords is expensive.
hmac: RFC 2104 over SHA-1, SHA-256, SHA-384, SHA-512 and BLAKE2s. Everyhmac_*has ahmac_*_verifytwin that compares the tag in constant time. The module allocates nothing; the inner concatenation is streamed through the hash's incremental state.hkdf: RFC 5869 extract and expand.hkdf_expand_*writes into a caller-owned&mut [u8], so a runtime-length output needs no region.pbkdf2andargon2: password-based derivation. Argon2 shipsargon2d,argon2iandargon2id(RFC 9106); prefer it unless a protocol fixes PBKDF2.poly1305: a one-time MAC (RFC 8439). Use the ChaCha20-Poly1305 AEAD, which derives a fresh key per nonce for you.scram: the client half of SCRAM-SHA-256 (RFC 5802 and 7677), the SASL mechanism of PostgreSQL, MongoDB, Kafka and others. The client nonce is a parameter, so the whole exchange is a pure function and testable against the RFC vector.
Ciphers and AEAD
A cipher encrypts data with a key. In practice you almost always want authenticated encryption, which also detects tampering, rather than a bare cipher. Each encryption also takes a nonce, a value that must never repeat under the same key.
aes: key schedule and single-block AES-128 and AES-256,ct fnthroughout.aes_ctradds counter mode andaes_xtsthe sector-oriented XTS mode for block storage.aes_gcm: the AEAD seal and open pair at both key sizes, each with an_rkvariant that takes a precomputed key schedule and GHASH key so a long-lived session does not re-expand the key per record.chacha20: the stream cipher, the RFC 8439 AEAD (chacha20poly1305_seal/_open) and XChaCha20-Poly1305 with its 24-byte nonce.salsa20is its predecessor, kept for formats that require it.
Curves, key exchange, signatures
Public-key cryptography uses a key pair: a private key you keep and a public key you publish. Key exchange (X25519, ECDH) lets two parties derive a shared secret from each other's public keys; a signature (Ed25519, ECDSA, RSA) proves that the holder of a private key approved a message.
x25519: RFC 7748,ctend to end.x25519(&Secret[[u8; 32]], pub u)returns anOptionthat isNonefor a small-order result;pubkey_genis infallible.ed25519: RFC 8032public_key,signand cofactoredverify. The seed arrives boxed as&Secret[[u8; 32]].sign_inandverify_intake a caller-owned scratchVec, for services that want no steady-state allocation.p256andp384: point arithmetic,ecdhandpubkey_gen, allct.ecdsa256:verifyandsign, where signing uses an RFC 6979 deterministic nonce and so needs noRng.ecdsa384verifies only. Both verify paths are deliberately notct, because every value they touch is public.rsa: PKCS#1 v1.5 sign and verify, PSS sign and verify, OAEP encrypt and decrypt, over themontengine.
Post-quantum
A large quantum computer would break today's elliptic-curve and RSA schemes. The post-quantum schemes standardized by NIST in 2024 are designed to resist one.
mlkem (ML-KEM-768) with mlkem512 and mlkem1024 beside it, and mldsa (ML-DSA-65). These are the FIPS 203 and FIPS 204 forms: keygen/encaps/decaps for the KEM, keygen_internal/sign_internal/verify_internal for the signature. Both are tested against published intermediate-value traces, not just final outputs, so a defect in the middle of the computation cannot hide behind a plausible answer.
Certificates and TLS 1.3
An X.509 certificate binds a public key to a name (such as a host name) and is signed by an issuer; a chain of them leads back to an issuer you already trust.
x509: parse a DER certificate into a region-scopedCert[r], thenverify_chain,check_validity,hostname_matchesandip_matches. Supported signature algorithms are ECDSA with SHA-256, sha256WithRSAEncryption and Ed25519.x509_build: issue certificates withbuild_self_signed_rootandbuild_leaf, plus individual extension writers.build_leaftakes the subject's public coordinates, so the subject's private key never enters the module.pkcs10: parse a certificate signing request and check its self-signature.tls13_keysandtls13_record: the RFC 8446 key schedule (HKDF-Expand-Label, Derive-Secret, the transcript hash, each traffic secret) and record-layer seal and open for both cipher suites. This is the arithmetic of TLS; the handshake state machine is a package.
Engines
fe25519/sc25519, fe256/sc256, fe384/sc384 and mont are field and scalar arithmetic and fixed-width Montgomery exponentiation. You can import them, but they are layers with sharp contracts, not protocol interfaces. Build on the scheme modules; if you need an engine directly, the thing you are building probably belongs in std.
Rules for using it safely
Constant time is proven, not promised
If a function's running time depends on a secret, an attacker who can time it, even over a network, can learn the secret bit by bit. An early-exit byte comparison is the classic case: it returns sooner the earlier the first wrong byte is. Constant-time code takes the same time and touches the same memory addresses whatever the secret values are. A ct fn is checked for this: it treats every parameter as secret and refuses anything that would make its timing depend on one: branching, indexing, division, trapping arithmetic, or calling a non-ct function. In this tree that covers aes, x25519, the p256/p384 point arithmetic and Ed25519's scalar ladder. The mechanism, including the branchless select idiom and pub parameters, is in Constant time (ct).
For comparing tags, digests and authenticators, use hmac::ct_eq(a, b) (or Secret::eq for boxed secrets). The language's == is not constant-time.
An untrusted length is not a precondition
You can mark a parameter untrusted to declare that its bytes come from an attacker: a socket, a file, stdin. The checker then refuses any contract that depends on it. The reason is that a contract is a claim about the caller, and wire bytes have no caller. Contracts are always enforced, so a req packet.len >= 32 on a UDP payload means one crafted 20-byte packet traps the process, which on a freestanding router halts the device. Nobody writing that req is being careless; stating a precondition is a normal reflex. It just isn't the caller's to state.
// A frame header off the wire: a 2-byte length, then the body.
pub fn frame_len(untrusted packet: &[u8]) -> u16 !{}
req packet.len >= 2
{
(widen[u16](packet[0]) << 8) | widen[u16](packet[1])
}error[card §8]: `req` depends on untrusted input `packet`
--> u.kl:2:9
|
2 | req packet.len >= 2
| ^^^^^^^^^^^^^^^
= help: this `req` depends on untrusted input `packet`; return `Result`/`Option` and check at runtime, or remove the `untrusted` marker if this param is not attacker-controlledThe repair keeps the check and makes the refusal a value instead of a trap:
pub fn frame_len(untrusted packet: &[u8]) -> Option[u16] !{} {
if packet.len < 2 { return None; }
Some((widen[u16](packet[0]) << 8) | widen[u16](packet[1]))
}
test short_packet_is_refused_not_fatal {
let short: [u8; 1] = [0x01u8];
assert frame_len(&short) == None;
let full: [u8; 3] = [0x01u8, 0x2cu8, 0xffu8];
assert frame_len(&full) == Some(300u16);
}std follows the rule itself. The TLS record openers are declared open_record_aes128(key, static_iv, seq, untrusted body: &[u8], inner_out: &mut [u8]) -> bool, and a malformed record fails into the same false that a bad authentication tag returns. RFC 8446 treats the two as equally fatal, so callers handle them the same way anyway.
Secret[T]: where the box is used
Secret[T] is a builtin, opaque, linear box. Linear means the compiler insists every box is used up exactly once on every path, so a key cannot be silently forgotten in memory. It cannot be turned into a string (putting one in an f"..." is a compile error), it must be consumed on every path, and it is read only through Secret::expose, a single place a grep can find. Secret::wipe(s) consumes the box and overwrites its bytes. That is harder than it sounds: an optimizing C compiler sees that zeroed memory is never read again and deletes the loop as a dead store, leaving the key in memory. wipe uses a volatile write, which the compiler must keep. The mechanics are covered in Constant time (ct).
Every entry point that takes a long-lived key takes the box: ed25519::public_key and sign, x25519::x25519 and pubkey_gen, p256::ecdh, p256::pubkey_gen and p384::ecdh, ecdsa256::sign, the ML-KEM keygen and decaps, ML-DSA's sign_internal, and x509_build's signing key. The AEAD functions keep plain arrays on purpose (aes_gcm's key: [u8; 16], chacha20's key: [u8; 32], HKDF's PRKs), because a record key is used once per packet and a box would add an expose per record. A caller holding a boxed key therefore exposes it once at the AEAD boundary: expose late, use immediately, and let the exposed copy die in the narrowest scope you can arrange.
A secret whose length is known only at run time goes in Secret[Vec[u8]]. Secret::wrap(v) moves the Vec in, expose borrows its bytes, and wipe scrubs the buffer's whole capacity. To process a stream of such secrets without growing the heap, Secret::wipe_into(s) scrubs the box and hands back the same buffer, empty, for reuse. What you cannot box is a reference: Secret[&r [u8]] is refused, because scrubbing the pointer would leave the plaintext behind it. Bytes you were only lent, such as a request buffer, are scrubbed where they live with Secret::wipe_bytes. And a value that must be rendered, such as a bearer token on the wire, is not a case for Secret at all; for those the discipline is a constant-time compare and wipe_bytes at the end of its life.
Randomness: the line is predictability
There are two kinds of random numbers. A pseudo-random generator computes a long sequence from a small seed: fast and reproducible, but anyone who learns its internal state can compute every later value. The operating system's entropy source (getrandom on Linux) gathers unpredictability from the hardware and is what keys must come from. Rng::weak_u64(&mut rng) -> u64 is the first kind, a xorshift64* generator seeded once from 8 bytes of OS entropy (or from --seed). Two facts about it are easy to miss. Every draw a program makes descends from those 64 bits, so a whole set of keys minted from it carries 64 bits of entropy in total. And its state can be recovered from a single output: one observed value predicts every value after it. So a protocol nonce, a ClientHello.random or a request id drawn from it hands an observer the private key you draw next.
The test is not how long a value lives but whether anyone gains by predicting it. Ask two questions of every draw: would an adversary who sees this value learn anything, and would a second installation drawing the same sequence weaken it? The first catches keys, tokens and ephemerals. The second catches values that are not secret but must be unique per installation, such as a PBKDF2 salt or a database id. Rng::weak_u64 is for what passes both: jitter, shuffling, load spreading and test material.
Everything else comes from the OS on every call, with its provenance in the type. Entropy::draw[[u8; N]](&mut rng) -> Result[Entropy[[u8; N]], (IoErr, OsErr)] !{io} is the mint, and Entropy::expose(&e) is the only way to read it. Entropy records where the bytes came from; Secret says not to read them carelessly, and since Secret::wrap is public it claims nothing about origin. The two compose, which is what a key wants: Secret::wrap(Entropy::expose(&e)). For a runtime-length buffer, rng.fill_os(&mut buf) uses the same source. rng.split() is not a second source: the child stream is seeded from one parent draw and adds no entropy, so a worker that needs key material calls Entropy::draw itself.
These calls are io, and a freestanding build refuses them because there is no OS to ask. Under --seed or kola test they deliberately fall back to the seeded stream so that runs reproduce exactly; keys made that way are test keys. Entropy::of(x) wraps a fixed array for an RFC test vector; kola check accepts it anywhere, but kola audit refuses it on any path that does not start in a test item.
use std::crypto::ed25519;
use std::stdio;
// A key someone keeps: drawn as Entropy, then boxed as a Secret.
fn main(mut sys: Sys) -> u8 !{io} {
let seed = match Entropy::draw[[u8; 32]](&mut sys.rng) {
Ok(e) => e,
Err(_) => { return 1; }
};
let sk = Secret::wrap(Entropy::expose(&seed));
let public = ed25519::public_key(&sk);
Secret::wipe(sk);
let _ = stdio::write_all(&sys.stdout, &public);
0
}kola run keygen.kl prints a different public key each time; kola run --seed 7 keygen.kl prints the same one every time, which is what makes a failing key-handling test reproducible. Two reports check the other direction: kola report secrets finds a compile-time constant reaching a Secret-typed position (a credential baked into the binary), and kola report wipe-windows checks, site by site, that each Secret::wipe_bytes scrub is bounded.
Test against bytes computed outside kola
Every module is checked against known-answer tests, published input and output pairs from the standard that defines it: FIPS 180-4 for SHA-2, RFC 4231 for HMAC, RFC 5869 for HKDF, RFC 8032 for Ed25519, NIST CAVP for P-256, NIST ACVP for the post-quantum schemes. The expected bytes are computed outside kola, by openssl or an independent transcription of the specification, and written down before the kola code.
The reason is that a round-trip test cannot see a canonicalization defect. Imagine a DER writer that always sets a BIT STRING's unused-bits byte to zero. That is right for a signature and wrong for a named bit list, so every keyUsage extension it produces is non-canonical: 03 02 00 80 where other issuers emit 03 02 07 80. The certificates still verify, the module's own parser reads them back, and a test that issues, parses and verifies stays green, because a writer and a reader that share a misunderstanding agree with each other. Only expected bytes from an independent encoder catch it.
Three worked examples
All three are test blocks with no capability, because nothing in std::crypto asks for one. Each passes under kola test on both executors.
Hash, then MAC, then derive
The chain most protocols start from, checked against three standards' own vectors.
use std::crypto::sha256;
use std::crypto::hmac;
use std::crypto::hkdf;
test hash_mac_kdf_chain {
// FIPS 180-4: SHA-256("abc")
assert sha256::digest(&b"abc") == [
0xbau8, 0x78u8, 0x16u8, 0xbfu8, 0x8fu8, 0x01u8, 0xcfu8, 0xeau8,
0x41u8, 0x41u8, 0x40u8, 0xdeu8, 0x5du8, 0xaeu8, 0x22u8, 0x23u8,
0xb0u8, 0x03u8, 0x61u8, 0xa3u8, 0x96u8, 0x17u8, 0x7au8, 0x9cu8,
0xb4u8, 0x10u8, 0xffu8, 0x61u8, 0xf2u8, 0x00u8, 0x15u8, 0xadu8,
];
// RFC 4231 case 1: key = 20 x 0x0b, msg = "Hi There"
let key: [u8; 20] = [0x0bu8; 20];
assert hmac::hmac_sha256(&key, &b"Hi There") == [
0xb0u8, 0x34u8, 0x4cu8, 0x61u8, 0xd8u8, 0xdbu8, 0x38u8, 0x53u8,
0x5cu8, 0xa8u8, 0xafu8, 0xceu8, 0xafu8, 0x0bu8, 0xf1u8, 0x2bu8,
0x88u8, 0x1du8, 0xc2u8, 0x00u8, 0xc9u8, 0x83u8, 0x3du8, 0xa7u8,
0x26u8, 0xe9u8, 0x37u8, 0x6cu8, 0x2eu8, 0x32u8, 0xcfu8, 0xf7u8,
];
// RFC 5869 A.1: extract a PRK, then expand into a caller-owned buffer
let ikm: [u8; 22] = [0x0bu8; 22];
let salt = b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c";
let prk = hkdf::hkdf_extract_sha256(&salt, &ikm);
let mut okm: [u8; 16] = [0u8; 16];
hkdf::hkdf_expand_sha256(prk, &b"\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9", &mut okm);
assert okm == [
0x3cu8, 0xb2u8, 0x5fu8, 0x25u8, 0xfau8, 0xacu8, 0xd5u8, 0x7au8,
0x90u8, 0x43u8, 0x4fu8, 0x64u8, 0xd0u8, 0x36u8, 0x2fu8, 0x2au8,
];
}AEAD: seal, open, and a flipped byte
The output buffer is yours, and its length is a contract (req out.len == plaintext.len): an AEAD here resizes nothing. open returns a bool instead of handing back unauthenticated plaintext and hoping you check; on a bad tag the destination is left zeroed.
use std::crypto::chacha20;
test aead_seal_open {
let key: [u8; 32] = [0x42u8; 32];
let nonce: [u8; 12] = [0x24u8; 12];
let mut ct: [u8; 14] = [0u8; 14];
let tag = chacha20::chacha20poly1305_seal(key, nonce, &b"hdr", &b"attack at dawn", &mut ct);
// Expected bytes computed outside kola (Python `cryptography`),
// not by decrypting our own ciphertext.
assert ct == [
0x85u8, 0x73u8, 0xf1u8, 0x6fu8, 0x88u8, 0xb5u8, 0x78u8, 0xc9u8,
0xf1u8, 0x4bu8, 0xecu8, 0xeau8, 0x93u8, 0x79u8,
];
assert tag == [
0x94u8, 0x26u8, 0x14u8, 0x4au8, 0xb6u8, 0xd8u8, 0x74u8, 0x02u8,
0x22u8, 0x4cu8, 0x7fu8, 0x43u8, 0x04u8, 0xa8u8, 0x12u8, 0xf4u8,
];
let mut pt: [u8; 14] = [0u8; 14];
let ok = chacha20::chacha20poly1305_open(key, nonce, &b"hdr", &ct, tag, &mut pt);
assert ok;
assert pt == b"attack at dawn";
// Flip one ciphertext byte: open returns false and writes no plaintext.
let mut bad = ct;
bad[0] = bad[0] ^ 0x01u8;
let mut pt2: [u8; 14] = [0u8; 14];
let ok2 = chacha20::chacha20poly1305_open(key, nonce, &b"hdr", &bad, tag, &mut pt2);
assert !ok2;
}Ed25519: derive, sign, verify, and reject a forgery
RFC 8032's second test vector, end to end. There is no Rng here: Ed25519 derives its nonce from the seed and the message, so signing is a pure function. The seed is a long-lived key, so public_key and sign take it as &Secret[[u8; 32]], and the test consumes the box with Secret::wipe. A test vector is the one place where a constant legitimately becomes a secret.
use std::crypto::ed25519;
test rfc8032_test2_sign_and_verify {
let seed = Secret::wrap([
0x4cu8, 0xcdu8, 0x08u8, 0x9bu8, 0x28u8, 0xffu8, 0x96u8, 0xdau8,
0x9du8, 0xb6u8, 0xc3u8, 0x46u8, 0xecu8, 0x11u8, 0x4eu8, 0x0fu8,
0x5bu8, 0x8au8, 0x31u8, 0x9fu8, 0x35u8, 0xabu8, 0xa6u8, 0x24u8,
0xdau8, 0x8cu8, 0xf6u8, 0xedu8, 0x4fu8, 0xb8u8, 0xa6u8, 0xfbu8,
]);
let public = ed25519::public_key(&seed);
assert public == [
0x3du8, 0x40u8, 0x17u8, 0xc3u8, 0xe8u8, 0x43u8, 0x89u8, 0x5au8,
0x92u8, 0xb7u8, 0x0au8, 0xa7u8, 0x4du8, 0x1bu8, 0x7eu8, 0xbcu8,
0x9cu8, 0x98u8, 0x2cu8, 0xcfu8, 0x2eu8, 0xc4u8, 0x96u8, 0x8cu8,
0xc0u8, 0xcdu8, 0x55u8, 0xf1u8, 0x2au8, 0xf4u8, 0x66u8, 0x0cu8,
];
let msg: [u8; 1] = [0x72u8];
let sig = ed25519::sign(&seed, public, &msg);
Secret::wipe(seed); // linear: consumed and scrubbed
assert sig == [
0x92u8, 0xa0u8, 0x09u8, 0xa9u8, 0xf0u8, 0xd4u8, 0xcau8, 0xb8u8,
0x72u8, 0x0eu8, 0x82u8, 0x0bu8, 0x5fu8, 0x64u8, 0x25u8, 0x40u8,
0xa2u8, 0xb2u8, 0x7bu8, 0x54u8, 0x16u8, 0x50u8, 0x3fu8, 0x8fu8,
0xb3u8, 0x76u8, 0x22u8, 0x23u8, 0xebu8, 0xdbu8, 0x69u8, 0xdau8,
0x08u8, 0x5au8, 0xc1u8, 0xe4u8, 0x3eu8, 0x15u8, 0x99u8, 0x6eu8,
0x45u8, 0x8fu8, 0x36u8, 0x13u8, 0xd0u8, 0xf1u8, 0x1du8, 0x8cu8,
0x38u8, 0x7bu8, 0x2eu8, 0xaeu8, 0xb4u8, 0x30u8, 0x2au8, 0xeeu8,
0xb0u8, 0x0du8, 0x29u8, 0x16u8, 0x12u8, 0xbbu8, 0x0cu8, 0x00u8,
];
assert ed25519::verify(public, &msg, sig);
let mut forged = msg;
forged[0] = 0x73u8;
assert !ed25519::verify(public, &forged, sig);
}What is not here, and where it lives
TLS itself is a package, not std. std has the key schedule, the record layer and the handshake wire codec (
std::encoding::tls13); the handshake state machine, trust store and connection object live in a TLS pack. The same goes for HPKE, Shamir sharing, DNSSEC and consensus protocols: anything with a protocol state machine is a pack.DER and PEM are encodings, not cryptography:
std::encoding::derandstd::encoding::pem, covered in Serialization and encodings.x509andx509_buildsit incryptobecause they verify and produce signatures.std::hashis not cryptographic. It has FNV-1a and CRC-32C for hash maps and checksums; to authenticate something, usehmac.There is no random-number generator inside
std::crypto. Randomness is authority, so it arrives as theRngcapability and, with its provenance, asEntropy. A crypto function that needs a nonce takes it as a parameter, which keeps the whole tree pure and testable; std's own signature schemes need none.