crypto
import crypto from "crypto";crypto has the primitives TLS 1.3 and X.509 need, written in tape:
hashes, MACs, key derivation, authenticated encryption, key exchange,
signature verification and a random generator for keys and nonces.
var digest: [32]u8;
crypto.sha256_digest("abc", digest[0..32]);
let key: crypto.AeadKey = crypto.aead_key(crypto.Aead.ChaCha20Poly1305, key_bytes);
key.seal(nonce, aad, msg, tag); // encrypts msg in place
key.open(nonce, aad, msg, tag) or { // checks, then decrypts
io.println("{err}"); // authentication failed
};Every function that touches a secret (a key, plaintext, a MAC, a private
scalar) is @constant_time: the compiler checks that no branch, loop bound
or memory address in it depends on the secret, so its running time and
cache footprint don’t give the secret away (see the language reference,
Constant-time functions). Signature verification works on public data and
is ordinary code.
The module is pure and works in every profile, t0 included: it allocates
nothing and reaches no OS. You hand it buffers; state lives in small structs
you own. Randomness is a parameter: seed the generator from
os.random_bytes.
Conventions
- Buffers are yours. Outputs are
[]u8slices you provide; a function writes only as many bytes as its result has (a SHA-256 digest writes 32). - Wrong sizes trap. A key, nonce or output of the wrong length is a
programming mistake, not a run-time condition: the call stops the program
with a message such as
crypto.chacha20: key must be 32 bytes. The sizes are the constants below. - Errors are for outcomes. Only things an attacker or a peer controls
come back as
CryptoError: a message that fails authentication, a signature that doesn’t verify, a bad public key. - In place. Encryption and decryption work on the buffer you pass; passing the same slice as input and output is the normal use.
- States are values. Copying a hash state forks it (hash a common
prefix once, finish two copies). Copying a
Csprngduplicates its future output: keep one per user.
Types and constants
pub enum CryptoError : u8 {
AuthFailed = 1: "authentication failed";
BadSignature = 2: "signature does not verify";
BadKey = 3: "invalid public key";
LowOrderPoint = 4: "key exchange produced the all-zero secret";
UnsupportedKeySize = 5: "unsupported key size";
}
pub enum HashAlg : u8 { Sha256 = 1; Sha384 = 2; Sha512 = 3; }
pub enum Aead : u8 { Aes128Gcm = 1; Aes256Gcm = 2; ChaCha20Poly1305 = 3; }
pub enum Curve : u8 { P256 = 1; P384 = 2; }
pub enum AesImpl : u8 { Bitsliced = 1; Hardware = 2; }| Constant | Value | |
|---|---|---|
SHA256_LEN, SHA384_LEN, SHA512_LEN | 32, 48, 64 | digest sizes |
MAX_HASH_LEN | 64 | a buffer that fits any digest |
AEAD_TAG_LEN, AEAD_NONCE_LEN | 16, 12 | every AEAD here |
X25519_LEN | 32 | scalars, points and secrets |
P256_SEED_LEN, P256_PRIVATE_LEN, P256_PUBLIC_LEN | 40, 32, 65 | P-256 key exchange |
Hashes
pub fn sha256() -> Sha256
pub fn Sha256.update(*self, data: []const u8)
pub fn Sha256.finish(*self, out: []u8) // writes 32 bytes
pub fn sha256_digest(data: []const u8, out: []u8)
pub fn sha384() -> Sha512
pub fn sha512() -> Sha512
pub fn Sha512.update(*self, data: []const u8)
pub fn Sha512.finish(*self, out: []u8) // writes 48 (SHA-384) or 64 bytes
pub fn sha384_digest(data: []const u8, out: []u8)
pub fn sha512_digest(data: []const u8, out: []u8)SHA-256, SHA-384 and SHA-512 (FIPS 180-4), streaming or in one call.
SHA-384 is SHA-512’s state with another start value, so sha384() returns
a Sha512.
var h: crypto.Sha256 = crypto.sha256();
h.update("hello, ");
let prefix: crypto.Sha256 = h; // a fork: hashes "hello, " so far
h.update("world");
var out: [32]u8;
h.finish(out[0..32]);finish pads the message and writes the digest; the state is then spent,
and another update or finish on it traps (crypto: hash already finished). Copy the state first to keep hashing. out must hold the
digest; it may be longer.
Choosing the hash at run time
pub fn hash_len(alg: HashAlg) -> i64
pub fn hasher(alg: HashAlg) -> Hasher
pub fn Hasher.update(*self, data: []const u8)
pub fn Hasher.finish(*self, out: []u8) // writes hash_len(alg) bytes
pub fn digest(alg: HashAlg, data: []const u8, out: []u8)A Hasher is any of the three, picked by a HashAlg value, for code (like
a TLS transcript) whose hash is decided by negotiation.
var t: crypto.Hasher = crypto.hasher(crypto.HashAlg.Sha384);
t.update(client_hello);
var out: [64]u8;
t.finish(out[0..crypto.hash_len(crypto.HashAlg.Sha384)]);HMAC and HKDF
pub fn hmac(alg: HashAlg, key: []const u8) -> Hmac
pub fn Hmac.update(*self, data: []const u8)
pub fn Hmac.finish(*self, out: []u8) // writes hash_len(alg) bytes
pub fn hmac_digest(alg: HashAlg, key: []const u8, data: []const u8, out: []u8)
pub fn hkdf_extract(alg: HashAlg, salt: []const u8, ikm: []const u8, prk: []u8)
pub fn hkdf_expand(alg: HashAlg, prk: []const u8, info: []const u8, out: []u8)HMAC (RFC 2104) over any of the hashes; a key longer than the hash’s block
(64 bytes for SHA-256, 128 for SHA-384/512) is hashed first. An Hmac is
keyed once and can be copied to MAC several messages under the same key.
To check a MAC you received, compare with ct_eq,
never with bytes.eq.
HKDF (RFC 5869): hkdf_extract writes hash_len(alg) bytes of
pseudorandom key into prk (an empty salt means hash_len zero bytes);
hkdf_expand fills all of out, up to 255 hash lengths (more traps).
var prk: [32]u8;
crypto.hkdf_extract(crypto.HashAlg.Sha256, salt, shared_secret, prk[0..32]);
var keys: [64]u8;
crypto.hkdf_expand(crypto.HashAlg.Sha256, prk[0..32], "my protocol v1", keys[0..64]);TLS’s HKDF-Expand-Label is TLS framing around hkdf_expand and belongs
to the TLS module.
ChaCha20 and Poly1305
pub fn chacha20_xor(key: []const u8, nonce: []const u8, counter: u32, buf: []u8)
pub fn chacha20_block(key: []const u8, nonce: []const u8, counter: u32, out: []u8) // 64 bytes
pub fn poly1305(key: []const u8) -> Poly1305 // a 32-byte one-time key
pub fn Poly1305.update(*self, data: []const u8)
pub fn Poly1305.finish(*self, tag: []u8) // writes 16 bytesThe raw RFC 8439 building blocks; for encryption use the AEAD below, which
combines them correctly. chacha20_xor XORs the keystream (32-byte key,
12-byte nonce, block counter starting at counter) into buf; running it
again decrypts. A message that would run the counter past 2^32 - 1 traps.
A Poly1305 key must never be used for two messages; a finished Poly1305
traps on reuse.
AES and AEADs
pub fn aead_key_len(alg: Aead) -> i64 // 16, 32, 32
pub fn aead_key(alg: Aead, key: []const u8) -> AeadKey
pub fn AeadKey.seal(*const self, nonce: []const u8, aad: []const u8, msg: []u8, tag: []u8)
pub fn AeadKey.open(*const self, nonce: []const u8, aad: []const u8, msg: []u8, tag: []const u8) -> void or CryptoErrorAn AeadKey holds everything one key needs, computed once. seal
encrypts msg in place and writes the 16-byte tag, which also covers
aad (data sent in the clear but authenticated). open checks the tag in
constant time and only then decrypts msg in place; on AuthFailed the
buffer still holds the ciphertext, so nothing unauthenticated reaches you.
Nonces are 12 bytes and must never repeat under one key.
let k: crypto.AeadKey = crypto.aead_key(crypto.Aead.ChaCha20Poly1305, key[0..32]);
var tag: [16]u8;
k.seal(nonce[0..12], header, body, tag[0..16]);
// ... send header, body, tag; on the other side:
k.open(nonce[0..12], header, body, tag[0..16]) or { return; };ChaCha20-Poly1305 follows RFC 8439; AES-GCM follows NIST SP 800-38D and has its own section below, with the AES block functions and the choice between the two AES paths.
AES and AES-GCM
AES-128 and AES-256, and AES-GCM with 96-bit nonces and 128-bit tags (the AEADs TLS 1.3 uses), behind one key type per job:
| Function | Returns |
|---|---|
aead_key(alg: Aead, key: []const u8) -> AeadKey | everything the AEAD needs for this key, computed once; key is aead_key_len(alg) bytes |
aead_key_with(alg: Aead, impl: AesImpl, key: []const u8) -> AeadKey | the same on a chosen AES path |
aead_key_len(alg: Aead) -> i64 | 16 for Aes128Gcm, 32 for Aes256Gcm and ChaCha20Poly1305 |
AeadKey.seal(*const self, nonce, aad, msg: []u8, tag: []u8) | encrypts msg in place and writes the 16-byte tag |
AeadKey.open(*const self, nonce, aad, msg: []u8, tag) -> void or CryptoError | checks the tag and, when it matches, decrypts msg in place; AuthFailed otherwise |
aes_key(key: []const u8) -> AesKey | an expanded AES key: 16 bytes for AES-128, 32 for AES-256 |
aes_key_with(impl: AesImpl, key: []const u8) -> AesKey | the same on a chosen AES path |
AesKey.encrypt_block(*const self, block: []u8) | encrypts one 16-byte block in place |
AesKey.decrypt_block(*const self, block: []u8) | decrypts one 16-byte block in place: the inverse of encrypt_block |
aes_impl() -> AesImpl | the AES path this processor gets |
AEAD_NONCE_LEN (12) and AEAD_TAG_LEN (16) are the only sizes: another
nonce or tag length traps, as does a key of the wrong length. seal and
open work in place; nothing is allocated. open compares the tag in
constant time, and when it fails it leaves msg as it was, so no
unauthenticated plaintext is ever released.
import crypto from "crypto";
import io from "io";
pub fn main() -> i32 {
var key: [16]u8; // from a key exchange in real use
var nonce: [12]u8; // never the same twice under one key
let k: crypto.AeadKey = crypto.aead_key(crypto.Aead.Aes128Gcm, key[0..16]);
var msg: [5]u8 = [0x68, 0x65, 0x6c, 0x6c, 0x6f];
var tag: [16]u8;
k.seal(nonce[0..12], "header", msg[0..5], tag[0..16]); // msg is now ciphertext
k.open(nonce[0..12], "header", msg[0..5], tag[0..16]) or {
io.println("rejected: {err}"); // "authentication failed"
return 1;
};
io.println("{msg[0]}"); // 104: the plaintext is back
return 0;
}A nonce must never repeat under one key: GCM with a repeated nonce reveals the XOR of the two plaintexts and lets an attacker forge tags. TLS derives each record’s nonce from a sequence number for this reason. One nonce covers up to 2^36 - 32 bytes of message.
aes_key, encrypt_block and decrypt_block exist for tests and for
building other modes, such as AES key wrap (RFC 3394), whose unwrap needs
the inverse cipher; there is no ECB, CBC, CTR or key-wrap API. Both block
functions are constant time on either path, and a key made by aes_key
does both: there is no separate decryption key.
let k: crypto.AesKey = crypto.aes_key(kek[0..16]);
var b: [16]u8; // one block, encrypted and back
k.encrypt_block(b[0..16]);
k.decrypt_block(b[0..16]); // b is all zeros againTwo paths, the same bytes
AES and GCM’s hash run one of two ways, both constant time:
AesImpl.Hardware: the processor’s AES and carry-less-multiply instructions (AES-NI and PCLMULQDQ on x86-64, the ARMv8 AES and PMULL instructions on arm64). Several times faster.AesImpl.Bitsliced: integer code only, for processors without them.
aes_impl() returns Hardware when the processor has both instruction
sets (@cpu_has(aes) and @cpu_has(clmul)), else Bitsliced. It asks the
processor and nothing else, so every key a program makes takes the same
path. aes_key and aead_key use it; a key remembers its path
(k.aes.impl, ak.impl) for its whole life.
The _with forms choose the path: a test can run both, and a program can
insist on the software one. Asking for Hardware on a processor without the
instructions traps (crypto: hardware AES not available). Both paths give
identical ciphertexts and tags for every input, so the choice shows only in
the speed.
let soft: crypto.AeadKey = crypto.aead_key_with(crypto.Aead.Aes256Gcm,
crypto.AesImpl.Bitsliced, key32[0..32]);
io.println("this processor: {crypto.aes_impl().desc()}");X25519
pub fn x25519(scalar: []const u8, point: []const u8, out: []u8) -> void or CryptoError
pub fn x25519_public(scalar: []const u8, out: []u8)Diffie-Hellman over Curve25519 (RFC 7748). Each side picks 32 random bytes
as its private scalar, publishes x25519_public(scalar), and computes the
shared secret with x25519(scalar, their_public). The scalar is clamped
on a copy; your bytes aren’t changed.
var mine: [32]u8;
os.random_bytes(mine[0..32]) or { return; };
var my_public: [32]u8;
crypto.x25519_public(mine[0..32], my_public[0..32]);
// ... exchange public keys ...
var secret: [32]u8;
crypto.x25519(mine[0..32], their_public, secret[0..32]) or {
// LowOrderPoint: the peer sent a point that forces an all-zero secret
return;
};x25519 returns LowOrderPoint when the shared secret is all zeros, which
only a malicious or broken peer causes (RFC 8446 requires refusing it).
P-256 and signatures
P-256 key exchange
| Function | Does |
|---|---|
p256_keypair(seed: []const u8, private_out: []u8, public_out: []u8) | a key pair from P256_SEED_LEN (40) random bytes: the private scalar (32 bytes) and the public point (65 bytes) |
p256_ecdh(private: []const u8, peer: []const u8, out: []u8) -> void or CryptoError | the shared secret: the x coordinate of private * peer, 32 bytes |
p256_keypair turns the seed into a scalar as FIPS 186-5 Appendix A.2.1
does (the seed as a number, modulo n - 1, plus 1), so any 40 random bytes
give a valid key with no retry loop. The public key is a SEC 1
uncompressed point, 04 || x || y, which is also what p256_ecdh takes
from the peer.
p256_ecdh returns CryptoError.BadKey unless the peer’s point is 65
bytes, starts with 04, has both coordinates below the field prime and
lies on the curve; compressed points are refused. Both functions are
@constant_time: the private scalar decides no branch and no memory
address. Wrong buffer sizes are a broken contract and trap.
import os from "os";
var seed_a: [40]u8;
var seed_b: [40]u8;
os.random_bytes(seed_a[0..40]) or { return; };
os.random_bytes(seed_b[0..40]) or { return; };
var priv_a: [32]u8;
var pub_a: [65]u8;
var priv_b: [32]u8;
var pub_b: [65]u8;
crypto.p256_keypair(seed_a[0..40], priv_a[0..32], pub_a[0..65]);
crypto.p256_keypair(seed_b[0..40], priv_b[0..32], pub_b[0..65]);
var za: [32]u8;
var zb: [32]u8;
crypto.p256_ecdh(priv_a[0..32], pub_b[0..65], za[0..32]) or { return; };
crypto.p256_ecdh(priv_b[0..32], pub_a[0..65], zb[0..32]) or { return; };
// za and zb hold the same 32 bytes; wipe the private keys when done
crypto.wipe(priv_a[0..32]);ECDSA verification
| Function | Does |
|---|---|
ecdsa_verify(curve: Curve, public_key: []const u8, hash: []const u8, sig: []const u8) -> void or CryptoError | checks a DER-encoded signature |
ecdsa_verify_raw(curve: Curve, public_key: []const u8, hash: []const u8, r: []const u8, s: []const u8) -> void or CryptoError | the same with r and s given as big-endian numbers |
curve is Curve.P256 or Curve.P384; public_key is the uncompressed
point (65 or 97 bytes) and hash the digest of the signed message, made
with the hash the signature names (ECDSA-with-SHA256, -SHA384, -SHA512). A
hash longer than the curve’s order is cut to its leftmost bits, as FIPS
186-5 says.
The errors: BadKey when the key is not a point on the curve (compressed
points included), BadSignature when the signature does not verify. The
DER form is parsed strictly: SEQUENCE { INTEGER r, INTEGER s } with
minimal lengths, no negative or zero-padded integers, nothing after the
sequence, and 0 < r, s < n. Anything else is BadSignature, so a
signature has exactly one accepted encoding.
var h: [32]u8;
crypto.sha256_digest(message, h[0..32]);
crypto.ecdsa_verify(crypto.Curve.P256, key, h[0..32], sig) or {
io.println("rejected: {err}"); // "signature does not verify"
return;
};ecdsa_verify_raw suits signatures carried as a fixed-length r || s
(32 + 32 or 48 + 48 bytes): pass the two halves. Leading zero bytes are
allowed there.
Verification works on public data only and is ordinary code, not
@constant_time.
RSA verification
| Function | Does |
|---|---|
rsa_pkcs1v15_verify(alg: HashAlg, n: []const u8, e: []const u8, hash: []const u8, sig: []const u8) -> void or CryptoError | RSASSA-PKCS1-v1_5 (RFC 8017 §8.2) |
rsa_pss_verify(alg: HashAlg, n: []const u8, e: []const u8, hash: []const u8, sig: []const u8) -> void or CryptoError | RSASSA-PSS (RFC 8017 §8.1) with MGF1 over alg and a salt as long as the hash |
n and e are the key’s modulus and public exponent as big-endian bytes,
the way a certificate holds them (a leading zero byte is fine). hash is
the message digest under alg and must be exactly hash_len(alg) bytes
(a different length traps).
UnsupportedKeySize: the modulus is not 2048 to 4096 bits long.BadKey: the modulus is even, oreis even, below 3 or not below 2^32.BadSignature:sigis not exactly as long as the modulus, is not below it, or its padding is not the expected one.
PKCS#1 v1.5 builds the whole expected encoding (00 01 FF..FF 00, the
DigestInfo with its explicit NULL parameter, the hash) and compares it
byte for byte; it never parses the decrypted block, which is what lets
forged signatures through elsewhere. PSS accepts exactly the parameters
TLS 1.3’s rsa_pss_rsae_* schemes and their certificates use: MGF1 over
the same hash, salt length equal to the hash length.
var h: [48]u8;
crypto.sha384_digest(tbs, h[0..48]);
crypto.rsa_pss_verify(crypto.HashAlg.Sha384, modulus, exponent, h[0..48], sig) or {
io.println("rejected: {err}");
return;
};CSPRNG
pub fn csprng(seed: []const u8) -> Csprng // seed: at least 32 bytes
pub fn Csprng.fill(*self, dst: []u8)
pub fn Csprng.reseed(*self, entropy: []const u8)A cryptographically secure generator: ChaCha20 under a 32-byte key with
fast key erasure. Every fill replaces the key with fresh keystream before
handing out bytes, so someone who later reads the generator’s state learns
nothing about what it produced before.
crypto reaches no OS, so you seed it. In a hosted program, take the seed
from os.random_bytes:
var seed: [32]u8;
os.random_bytes(seed[0..32]) or { return; };
var rng: crypto.Csprng = crypto.csprng(seed[0..32]);
crypto.wipe(seed[0..32]);
var nonce: [12]u8;
rng.fill(nonce[0..12]);For a handful of keys you can also call os.random_bytes directly. A fixed
seed gives a fixed stream, which makes tests repeatable. reseed mixes more
entropy into the key (SHA-256(key || entropy)). A seed shorter than 32
bytes traps.
Constant-time helpers
pub fn ct_eq(a: []const u8, b: []const u8) -> bool
pub fn wipe(buf: []u8)ct_eq compares every byte whatever the contents (different lengths are
unequal at once: lengths aren’t secret). Use it for MACs, tags and anything
an attacker could otherwise probe a byte at a time. wipe zeroes a buffer,
and the compiler keeps those stores even when nothing reads the buffer
again: wipe keys and secrets when you are done with them.
Notes
- The constant-time guarantee is for native code. The VM computes the same
results but its timing is not constant; build programs that handle
secrets in production with
tape build. - Not here: SHA-1, MD5, password hashing (PBKDF2, scrypt, Argon2), Ed25519, signing with RSA or ECDSA.
Last modified: