crypto

tape
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.

tape
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 []u8 slices 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 Csprng duplicates its future output: keep one per user.

Types and constants

tape
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; }
ConstantValue
SHA256_LEN, SHA384_LEN, SHA512_LEN32, 48, 64digest sizes
MAX_HASH_LEN64a buffer that fits any digest
AEAD_TAG_LEN, AEAD_NONCE_LEN16, 12every AEAD here
X25519_LEN32scalars, points and secrets
P256_SEED_LEN, P256_PRIVATE_LEN, P256_PUBLIC_LEN40, 32, 65P-256 key exchange

Hashes

tape
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.

tape
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

tape
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.

tape
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

tape
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).

tape
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

tape
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 bytes

The 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

tape
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 CryptoError

An 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.

tape
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:

FunctionReturns
aead_key(alg: Aead, key: []const u8) -> AeadKeyeverything 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) -> AeadKeythe same on a chosen AES path
aead_key_len(alg: Aead) -> i6416 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 CryptoErrorchecks the tag and, when it matches, decrypts msg in place; AuthFailed otherwise
aes_key(key: []const u8) -> AesKeyan expanded AES key: 16 bytes for AES-128, 32 for AES-256
aes_key_with(impl: AesImpl, key: []const u8) -> AesKeythe 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() -> AesImplthe 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.

tape
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.

tape
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 again

Two 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.

tape
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

tape
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.

tape
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

FunctionDoes
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 CryptoErrorthe 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.

tape
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

FunctionDoes
ecdsa_verify(curve: Curve, public_key: []const u8, hash: []const u8, sig: []const u8) -> void or CryptoErrorchecks a DER-encoded signature
ecdsa_verify_raw(curve: Curve, public_key: []const u8, hash: []const u8, r: []const u8, s: []const u8) -> void or CryptoErrorthe 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.

tape
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

FunctionDoes
rsa_pkcs1v15_verify(alg: HashAlg, n: []const u8, e: []const u8, hash: []const u8, sig: []const u8) -> void or CryptoErrorRSASSA-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 CryptoErrorRSASSA-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, or e is even, below 3 or not below 2^32.
  • BadSignature: sig is 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.

tape
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

tape
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:

tape
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

tape
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: