Skip to Content

Crypto

Introduction

import 'crypto';

The crypto module holds the building blocks of secure software: hashing (SHA-256 and SHA-384), message authentication (HMAC), key derivation (HKDF), encryption (ChaCha20-Poly1305 and AES-128-GCM), key agreement (X25519), checking RSA and ECDSA signatures, base64 and hex encoding, and secure random bytes from the operating system.

import 'crypto'; import 'string'; byte[] digest = crypto.sha256(string.toByteArray("abc")); print(crypto.hexEncode(digest)); // ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad byte[] key = crypto.randomBytes(32); byte[] mac = crypto.hmacSha256(key, digest); print(crypto.base64Encode(mac));

Notes

  • Everything works on byte[]. Hash a string by converting it first with string.toByteArray.
  • The hash is part of each function’s name (sha256, sha384), so a wrong algorithm name is a compile error, never a runtime surprise.
  • Never compare secrets with == — it stops at the first differing byte, which leaks where the difference is to anyone who can time it. Compare a MAC, token, or tag with string.bytesEqualConstantTime or string.equalsConstantTime.
  • base64Decode and hexDecode are fallible: text that does not decode raises an error with the code crypto.ErrorCode.INVALID, which catch handles like any other (see Errors).
  • The decoders are strict: no whitespace, exact padding, no trailing bits. Strip newlines yourself before decoding wrapped base64 (such as PEM contents).
  • randomBytes is the operating system’s generator. It cannot be configured or seeded, and the module offers no other randomness.
  • Keys, nonces and curve points have fixed lengths (32 bytes, except the AEAD nonce at 12). Passing a different length stops the program — it is a bug, not bad input.
  • There is no signing here, and none is planned. Signing is the most side-channel-fragile operation in a TLS stack, so this one simply does not contain it. The RSA and ECDSA functions verify only.

crypto.sha256() and crypto.sha384()

byte[] crypto.sha256(byte[] data) byte[] crypto.sha384(byte[] data)

The SHA-256 (32-byte) and SHA-384 (48-byte) digests of data.

import 'crypto'; import 'string'; print(crypto.hexEncode(crypto.sha256(string.toByteArray("")))); // e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

crypto.hmacSha256() and crypto.hmacSha384()

byte[] crypto.hmacSha256(byte[] key, byte[] data) byte[] crypto.hmacSha384(byte[] key, byte[] data)

The HMAC of data under key (RFC 2104): 32 or 48 bytes. Use it to authenticate a message with a shared secret; verify by recomputing and comparing with string.bytesEqualConstantTime.

import 'crypto'; import 'string'; byte[] key = crypto.randomBytes(32); byte[] message = string.toByteArray("amount=25"); byte[] tag = crypto.hmacSha256(key, message); // Later, on receipt: byte[] again = crypto.hmacSha256(key, message); print(string.bytesEqualConstantTime(tag, again)); // true

crypto.hkdfExtractSha256() and crypto.hkdfExpandSha256()

byte[] crypto.hkdfExtractSha256(byte[] salt, byte[] ikm) byte[] crypto.hkdfExpandSha256(byte[] prk, byte[] info, int length)

HKDF (RFC 5869), the standard way to turn one secret into as many independent keys as needed. hkdfExtractSha256 condenses input key material into a pseudorandom key; hkdfExpandSha256 stretches that key into length bytes, bound to a context string info, so different contexts get unrelated keys. Both exist in SHA-384 form as hkdfExtractSha384 and hkdfExpandSha384.

import 'crypto'; import 'string'; byte[] shared = crypto.randomBytes(32); // some negotiated secret byte[] prk = crypto.hkdfExtractSha256(string.toByteArray(""), shared); byte[] encKey = crypto.hkdfExpandSha256(prk, string.toByteArray("enc"), 32); byte[] macKey = crypto.hkdfExpandSha256(prk, string.toByteArray("mac"), 32);

An empty salt means “no salt”, as the RFC defines it. length may not exceed 255 times the hash length (8160 bytes for SHA-256); asking for more stops the program.

crypto.chacha20Poly1305Seal() and crypto.chacha20Poly1305Open()

byte[] crypto.chacha20Poly1305Seal(byte[] key, byte[] nonce, byte[] plaintext, byte[] aad) byte[]! crypto.chacha20Poly1305Open(byte[] key, byte[] nonce, byte[] ciphertext, byte[] aad)

Authenticated encryption (RFC 8439). key is 32 bytes, nonce is 12. Seal returns the ciphertext with a 16-byte authentication tag appended; Open verifies that tag and returns the plaintext, or fails.

aad (“additional authenticated data”) is data that travels in the clear but must not be alterable — a message header, a type byte, a sequence number. Pass an empty array if there is none.

import 'crypto'; import 'string'; byte[] key = crypto.randomBytes(32); byte[] nonce = crypto.randomBytes(12); byte[] sealed = crypto.chacha20Poly1305Seal(key, nonce, string.toByteArray("attack at dawn"), string.toByteArray("msg-42")); byte[]? plain = crypto.chacha20Poly1305Open(key, nonce, sealed, string.toByteArray("msg-42")) catch e { print("message was forged or corrupted"); }; if (plain != null) { print(string.fromByteArray(plain)); // attack at dawn }

Never reuse a nonce with the same key. Doing so destroys the secrecy of both messages and leaks the authentication key, letting an attacker forge freely. Either keep a per-key counter, or generate 12 fresh random bytes for every message and transmit them next to the ciphertext (they are not secret).

Failure carries crypto.ErrorCode.AUTHENTICATION rather than INVALID: the message was readable and is a forgery, which usually means dropping the connection rather than retrying. Open verifies before it decrypts, so a failed call never yields partial plaintext.

ChaCha20-Poly1305 works on every processor at full safety, which makes it the default choice. AES-128-GCM is also available, but only on processors with AES instructions.

crypto.x25519PublicKey() and crypto.x25519SharedSecret()

byte[] crypto.x25519PublicKey(byte[] privateKey) byte[]! crypto.x25519SharedSecret(byte[] privateKey, byte[] peerPublicKey)

X25519 Diffie-Hellman key agreement (RFC 7748). A private key is 32 random bytes; the required bit-clamping happens inside. Two parties who exchange public keys arrive at the same 32-byte shared secret.

import 'crypto'; // Each side, independently: byte[] alicePrivate = crypto.randomBytes(32); byte[] alicePublic = crypto.x25519PublicKey(alicePrivate); byte[] bobPrivate = crypto.randomBytes(32); byte[] bobPublic = crypto.x25519PublicKey(bobPrivate); // After exchanging the public keys, both compute the same secret: byte[]? aliceSecret = crypto.x25519SharedSecret(alicePrivate, bobPublic) catch e { print("unusable public key from peer"); }; byte[]? bobSecret = crypto.x25519SharedSecret(bobPrivate, alicePublic) catch e { print("unusable public key from peer"); }; if (aliceSecret != null && bobSecret != null) { print(crypto.hexEncode(aliceSecret) == crypto.hexEncode(bobSecret)); // true }

Do not use the shared secret as a key directly. It is not evenly random. Turn it into keys with HKDF:

import 'crypto'; import 'string'; byte[] deriveSessionKey(byte[] shared) { byte[] prk = crypto.hkdfExtractSha256(string.toByteArray(""), shared); return crypto.hkdfExpandSha256(prk, string.toByteArray("session"), 32); }

x25519SharedSecret fails when the peer’s public key has small order — a key chosen to force the shared secret to a value the peer picked, which would make the “agreement” meaningless. Rejecting it is mandatory for TLS 1.3, and this function is fallible so that the check cannot be skipped.

The curve arithmetic is fiat-crypto’s formally verified code, used unmodified.

crypto.base64Encode() and crypto.base64Decode()

String crypto.base64Encode(byte[] data) byte[]! crypto.base64Decode(String s)

Standard base64 (RFC 4648 §4), with padding. Decoding is strict — the standard alphabet only, correct padding, no whitespace — and fails with crypto.ErrorCode.INVALID on anything else.

import 'crypto'; import 'string'; print(crypto.base64Encode(string.toByteArray("foobar"))); // Zm9vYmFy byte[]? decoded = crypto.base64Decode("Zm9vYmFy") catch e { print(e.message); }; if (decoded != null) { print(string.fromByteArray(decoded)); // foobar }

crypto.hexEncode() and crypto.hexDecode()

String crypto.hexEncode(byte[] data) byte[]! crypto.hexDecode(String s)

Hex encoding: two characters per byte. Encoding answers lower-case; decoding accepts either case, and fails with crypto.ErrorCode.INVALID on an odd length or a character that is not a hex digit.

crypto.randomBytes()

byte[] crypto.randomBytes(int n)

n cryptographically secure random bytes from the operating system (getentropy on macOS, getrandom on Linux, BCryptGenRandom on Windows). Suitable for keys, tokens, and nonces. If the system generator fails — which does not happen on a working machine — the program stops; there is no weaker fallback.

crypto.rsaVerifyPkcs1v15() and crypto.rsaVerifyPss()

void! crypto.rsaVerifyPkcs1v15(byte[] modulus, int exponent, String hash, byte[] digest, byte[] signature) void! crypto.rsaVerifyPss(byte[] modulus, int exponent, String hash, byte[] digest, byte[] signature)

Checks an RSA signature over an already-computed digest. hash names the digest that produced it — "sha256" or "sha384" — and modulus is the key’s modulus without a leading zero byte.

They answer nothing and fail instead. A verifier that returned a bool would eventually have a caller who forgot to test it, and that is a whole family of real vulnerabilities. One that raises cannot be ignored:

import 'crypto'; void check(byte[] modulus, byte[] message, byte[] signature) { crypto.rsaVerifyPkcs1v15(modulus, 65537, "sha256", crypto.sha256(message), signature) catch e { print("not signed by that key"); return; }; print("signed"); }

The two failures are told apart, and it matters:

CodeMeans
crypto.ErrorCode.INVALIDThe key or signature cannot be used at all — a modulus under 1024 bits or over 8192, an even modulus, an even exponent or one outside 3…2³¹−1, a digest of the wrong length for the named hash, a signature that is not exactly as long as the modulus, or one not below it.
crypto.ErrorCode.AUTHENTICATIONEverything was well-formed and the signature does not verify.

SHA-1 and MD5 are not accepted as hash names. Chosen-prefix collisions against SHA-1 are practical, so a signature over one means nothing.

rsaVerifyPss expects the profile TLS 1.3 uses: MGF1 with the same hash, and a salt length equal to the hash length.

Both are tested against Google’s Wycheproof collection of known-tricky signatures. Only public values are involved, so neither function needs to guard against timing attacks.

crypto.aes128GcmSeal() and crypto.aes128GcmOpen()

bool crypto.aesGcmAvailable() byte[]! crypto.aes128GcmSeal(byte[] key, byte[] nonce, byte[] plaintext, byte[] aad) byte[]! crypto.aes128GcmOpen(byte[] key, byte[] nonce, byte[] ciphertext, byte[] aad)

AES-128-GCM, the other AEAD TLS 1.3 uses. A 16-byte key, a 12-byte nonce, and the 16-byte tag appended to the ciphertext — the same shape as chacha20Poly1305Seal, so the two are interchangeable to a caller that holds either.

Warning

This cipher exists only where the CPU can run it in constant time, and both functions — seal included, unlike the ChaCha pair — fail when it cannot. Ask crypto.aesGcmAvailable() first if you need to know before committing.

There is no software fallback, and that is the point. A software AES is either a table indexed by key-derived bytes — a cache-timing oracle, and the most exploited implementation flaw the cipher has — or a bitsliced implementation, which is a project of its own. Refusing is the honest third option.

import 'crypto'; import 'string'; void roundTrip() { if (!crypto.aesGcmAvailable()) { print("no AES instructions on this CPU; use ChaCha20-Poly1305"); return; } byte[] key = crypto.randomBytes(16); byte[] nonce = crypto.randomBytes(12); byte[] aad = string.toByteArray("msg-1"); byte[] sealed = crypto.aes128GcmSeal(key, nonce, string.toByteArray("hello"), aad) catch e { return; }; byte[] plain = crypto.aes128GcmOpen(key, nonce, sealed, aad) catch e { print("forged or corrupted"); // e.code == crypto.ErrorCode.AUTHENTICATION return; }; print(string.fromByteArray(plain)); // hello }

A nonce may never repeat under one key — the same rule, and the same consequences, as the ChaCha AEAD above. GCM is if anything less forgiving: repeating a nonce leaks the GHASH authentication key, after which an attacker can forge any message.

Everything is done with the processor’s own AES instructions, and the implementation is tested against Google’s Wycheproof vectors and against OpenSSL on both arm64 and x86-64.

crypto.ecdsaVerify()

void! crypto.ecdsaVerify(String curve, byte[] publicKey, byte[] digest, byte[] r, byte[] s)

The same contract for ECDSA over the two NIST curves the web serves. curve is "p256" or "p384" — named rather than inferred from the key’s length, so a mistake is a loud INVALID rather than a quiet guess. publicKey is the SEC 1 uncompressed point (0x04 ‖ X ‖ Y, the only form certificates carry), and r and s are the signature’s two integers as big-endian bytes — already out of their DER, which is x509.parseEcdsaSignature’s job, because ASN.1 parsing does not belong in C.

import 'crypto'; import 'x509'; void check(byte[] publicKey, byte[] message, byte[] derSignature) { x509.EcdsaSignature sig = x509.parseEcdsaSignature(derSignature) catch e { return; }; crypto.ecdsaVerify("p256", publicKey, crypto.sha256(message), sig.r, sig.s) catch e { print("not signed by that key"); return; }; print("signed"); }

The failure split is the RSA one: a key that is not an uncompressed point on the named curve, or an r or s outside 1…n−1, is INVALID; a well-formed signature that does not verify is AUTHENTICATION. A digest longer than the curve’s order is truncated to its leftmost bytes, which is what FIPS 186-5 says and what lets a SHA-384 signature ride a P-256 key.

The arithmetic underneath is fiat-crypto’s formally verified code, and the function is tested against Google’s Wycheproof vectors for both curves.