← crypto · API reference · version 0.3.14
12 file(s), 1642 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
sha256 fn | 116 | — | 27 29 33 35 40 42 … (20 total) |
sha256_b64 fn | 125 | CRY-001 | 20 8 9 12 41 43 … (7 total) |
hmac_sha256 fn | 130 | — | 81 88 94 100 110 118 … (8 total) |
base64_encode fn | 135 | — | 127 128 133 134 139 140 … (28 total) |
base64_decode fn | 141 | — | 146 152 158 159 160 161 … (13 total) |
base64url_encode fn | 146 | — | 169 175 176 177 178 185 |
ed25519_public_key fn | 159 | — | 27 52 100 101 14 70 |
ed25519_sign fn | 166 | CRY-002 | 35 60 103 104 21 22 … (9 total) |
ed25519_verify fn | 173 | CRY-002 | 44 69 80 92 106 107 … (10 total) |
ecdsa_p256_keygen fn | 190 | — | 90 106 107 41 105 121 |
ecdsa_p256_public_key fn | 196 | CRY-003 | 37 83 93 136 137 27 … (8 total) |
ecdsa_p256_sign fn | 203 | CRY-003 | 45 63 97 139 140 28 … (10 total) |
ecdsa_p256_verify fn | 209 | — | 53 71 100 119 127 142 … (11 total) |
x25519_dh fn | 221 | CRY-004 | 394 403 420 26 34 45 … (12 total) |
hkdf_sha256 fn | 232 | CRY-004 | 70 78 87 26 27 146 … (8 total) |
hkdf_extract fn | 242 | CRY-004 | 363 156 160 |
hkdf_expand fn | 249 | — | 375 159 160 |
bytes_to_base64 fn | 261 | — | 11 12 15 16 17 52 |
base64_to_bytes fn | 267 | CRY-001 | 91 94 98 16 30 31 … (19 total) |
aes256gcm_seal fn | 281 | CRY-005 | 466 106 146 155 156 24 … (10 total) |
aes256gcm_open fn | 289 | CRY-005 | 502 116 127 136 148 184 … (9 total) |
random_bytes fn | 297 | — | 478 142 143 11 12 13 … (26 total) |
HpkeSealed struct | 456 | — | 463 465 467 477 480 487 … (7 total) |
enc const | 457 | — | 465 467 479 480 481 488 … (22 total) |
ciphertext const | 458 | — | 465 467 480 489 62 76 … (14 total) |
hpke_seal_base fn | 477 | CRY-006 | 88 230 238 |
hpke_seal_base_deterministic fn | 487 | — | 60 |
hpke_open_base fn | 497 | CRY-006 | 67 75 81 93 101 234 … (8 total) |
hpke_key_schedule_base_for_test fn | 507 | — | 53 |
hpke_encap_deterministic_for_test fn | 513 | — | 46 |
src/crypto.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// --- Crypto ---
// Cryptographic primitives: SHA-256 and HMAC, base64, Ed25519 and ES256
// signatures, X25519 key agreement, HKDF, AES-256-GCM, a CSPRNG, and HPKE base
// mode built from them.
//
// Bytes cross the API as standard base64 `text`. Input that is not base64 is
// refused the way each function refuses anything malformed — "" or false, never
// a result computed from other bytes than the ones sent.
// --- Internal natives ---
//
// Each `#native "n_*"` symbol is supplied by the `native/` cdylib via a
// `#[loft_native]` bridge; `loft-ffi-build` scans these annotations to emit
// the cdylib's register list, so the bindings can never drift.
//
// `#pure` marks every native whose value is its only effect — all but the two
// that draw entropy (`ecdsa_p256_keygen`, `random_bytes`). loft then computes
// a call made twice on unchanged arguments once: a payload validated by
// decoding it and then decoded to use it is decoded once.
fn sha256_native(data: text) -> text;
#native "n_sha256"
#pure
fn sha256_b64_native(data_b64: text) -> text;
#native "n_sha256_b64"
#pure
fn hmac_sha256_native(key: text, data: text) -> text;
#native "n_hmac_sha256"
#pure
fn base64_encode_native(data: text) -> text;
#native "n_base64_encode"
#pure
fn base64_decode_native(data: text) -> text;
#native "n_base64_decode"
#pure
fn base64url_encode_native(data: text) -> text;
#native "n_base64url_encode"
#pure
fn ed25519_public_key_native(secret_key_b64: text) -> text;
#native "n_ed25519_public_key"
#pure
fn ed25519_sign_native(secret_key_b64: text, message_b64: text) -> text;
#native "n_ed25519_sign"
#pure
fn ed25519_verify_native(public_key_b64: text, message_b64: text, signature_b64: text) -> boolean;
#native "n_ed25519_verify"
#pure
fn ecdsa_p256_keygen_native() -> text;
#native "n_ecdsa_p256_keygen"
fn ecdsa_p256_public_key_native(secret_key_b64: text) -> text;
#native "n_ecdsa_p256_public_key"
#pure
fn ecdsa_p256_sign_native(secret_key_b64: text, message_b64: text) -> text;
#native "n_ecdsa_p256_sign"
#pure
fn ecdsa_p256_verify_native(public_key_b64: text, message_b64: text, signature_b64: text) -> boolean;
#native "n_ecdsa_p256_verify"
#pure
fn x25519_dh_native(secret_key_b64: text, public_key_b64: text) -> text;
#native "n_x25519_dh"
#pure
fn hkdf_sha256_native(salt_b64: text, ikm_b64: text, info_b64: text, length: integer) -> text;
#native "n_hkdf_sha256"
#pure
fn hkdf_extract_native(salt_b64: text, ikm_b64: text) -> text;
#native "n_hkdf_extract"
#pure
fn hkdf_expand_native(prk_b64: text, info_b64: text, length: integer) -> text;
#native "n_hkdf_expand"
#pure
fn bytes_to_base64_native(bytes: vector<u8>) -> text;
#native "n_bytes_to_base64"
#pure
fn base64_to_bytes_native(b64: text) -> vector<u8>;
#native "n_base64_to_bytes"
#pure
fn bytes_concat_b64_native(a_b64: text, b_b64: text) -> text;
#native "n_bytes_concat_b64"
#pure
fn aes256gcm_seal_native(key_b64: text, nonce_b64: text, aad_b64: text, plaintext_b64: text) -> text;
#native "n_aes256gcm_seal"
#pure
fn aes256gcm_open_native(key_b64: text, nonce_b64: text, aad_b64: text, ciphertext_b64: text) -> text;
#native "n_aes256gcm_open"
#pure
fn random_bytes_native(length: integer) -> text;
#native "n_random_bytes"
// --- Public API ---
// SHA-256 hash of a string. Returns 64-char hex string.
pub fn sha256(data: text) -> text {
sha256_native(data)
}
// SHA-256 of the RAW BYTES decoded from base64 `data_b64`. Returns base64 of the
// 32-byte digest, or "" when `data_b64` is not base64. Use this to hash arbitrary
// byte strings (ciphertext, canonical CBOR) that are not valid UTF-8 and so cannot
// be passed to `sha256` as text.
// Example: @CRY-001 — the two hashing doors: text in / hex out vs base64 both ways.
pub fn sha256_b64(data_b64: text) -> text {
sha256_b64_native(data_b64)
}
// HMAC-SHA256 of data with key. Returns 64-char hex string.
pub fn hmac_sha256(key: text, data: text) -> text {
hmac_sha256_native(key, data)
}
// Base64 encode a string.
pub fn base64_encode(data: text) -> text {
base64_encode_native(data)
}
// Base64 decode to text. "" when `data` is not base64, or when its bytes are not
// UTF-8 — those are not text; `base64_to_bytes` reads them.
pub fn base64_decode(data: text) -> text {
base64_decode_native(data)
}
// Base64url encode (JWT-safe, no padding).
pub fn base64url_encode(data: text) -> text {
base64url_encode_native(data)
}
// --- Ed25519 signatures (RFC 8032) ---
//
// All key / message / signature bytes are standard base64 `text`. A secret
// key is the RFC 8032 32-byte seed (base64); the public key (32B) and
// signature (64B) are the canonical encodings. Malformed input never halts:
// `ed25519_public_key` / `ed25519_sign` return "" and `ed25519_verify`
// returns false.
// Derive the 32-byte public key (base64) from a 32-byte secret seed (base64).
pub fn ed25519_public_key(secret_key_b64: text) -> text {
ed25519_public_key_native(secret_key_b64)
}
// Sign the base64 `message` bytes with the 32-byte secret seed (base64).
// Returns the 64-byte signature, base64.
// Example: @CRY-002 — sign `base64_encode(msg)`; the bare text signs other bytes, silently.
pub fn ed25519_sign(secret_key_b64: text, message_b64: text) -> text {
ed25519_sign_native(secret_key_b64, message_b64)
}
// Verify a signature over the base64 `message` bytes under a public key.
// Returns true iff the signature is valid.
// Example: @CRY-002 — why a wrongly-spelled message still verifies against itself.
pub fn ed25519_verify(public_key_b64: text, message_b64: text, signature_b64: text) -> boolean {
ed25519_verify_native(public_key_b64, message_b64, signature_b64)
}
// --- ES256 signatures (ECDSA P-256 / SHA-256, JOSE `alg: ES256`) ---
//
// The signature RFC 8555 ACME requires for account keys and CSRs — Let's Encrypt
// rejects Ed25519 account keys, so ES256 cannot be replaced by the ed25519 path.
// All bytes are standard base64 `text`. A secret key is the 32-byte P-256 scalar
// `d`; a public key is the 64-byte affine point `x || y` (the JWK coordinates,
// no 0x04 SEC1 prefix); a signature is the 64-byte raw `r || s` (the JOSE form —
// the DER encoding is for the X.509/PKCS#10 layer, never for a JWS signature).
// Signing is deterministic (RFC 6979), so the same key + message always signs to
// the same bytes; only `keygen` draws entropy. Malformed input never halts:
// `ecdsa_p256_keygen` / `_public_key` / `_sign` return "" and `_verify` false.
// Generate a fresh 32-byte P-256 secret scalar (base64) from the OS CSPRNG.
pub fn ecdsa_p256_keygen() -> text {
ecdsa_p256_keygen_native()
}
// Derive the 64-byte public key `x || y` (base64) from a 32-byte secret scalar (base64).
// Example: @CRY-003 — the three JOSE lengths (32 / 64 / 64) a caller should check.
pub fn ecdsa_p256_public_key(secret_key_b64: text) -> text {
ecdsa_p256_public_key_native(secret_key_b64)
}
// Sign the base64 `message` bytes with the 32-byte secret scalar (base64).
// Returns the 64-byte `r || s` signature (base64), deterministic per RFC 6979.
// Example: @CRY-003 — signing twice answers identical bytes, and `r || s` is not DER.
pub fn ecdsa_p256_sign(secret_key_b64: text, message_b64: text) -> text {
ecdsa_p256_sign_native(secret_key_b64, message_b64)
}
// Verify an ES256 signature (64-byte `r || s`) over the base64 `message` bytes
// under a 64-byte `x || y` public key. Returns true iff the signature is valid.
pub fn ecdsa_p256_verify(public_key_b64: text, message_b64: text, signature_b64: text) -> boolean {
ecdsa_p256_verify_native(public_key_b64, message_b64, signature_b64)
}
// --- X25519 key agreement (RFC 7748) ---
//
// A secret key is a 32-byte X25519 scalar (base64); a public key is the
// 32-byte u-coordinate (base64). Returns the raw 32-byte shared secret
// (base64), or "" if either input is the wrong length. Run the result
// through `hkdf_sha256` before using it as a key (RFC 7748 §6.1).
// Example: @CRY-004 — the DH output is keying material, not a key; and how to get a
// public key out of a secret scalar (X25519 has no separate keygen).
pub fn x25519_dh(secret_key_b64: text, public_key_b64: text) -> text {
x25519_dh_native(secret_key_b64, public_key_b64)
}
// --- HKDF-SHA256 key derivation (RFC 5869) ---
//
// Derive `length` bytes of output keying material from `ikm`, `salt`, and
// `info` (each base64). An empty base64 `salt` selects the all-zero salt.
// Returns the base64 OKM, or "" if `length` exceeds 255*32 or is <= 0, or an
// argument is not base64.
// Example: @CRY-004 — `info` is what separates two keys from one shared secret.
pub fn hkdf_sha256(salt_b64: text, ikm_b64: text, info_b64: text, length: integer) -> text {
hkdf_sha256_native(salt_b64, ikm_b64, info_b64, length)
}
// HKDF-Extract (RFC 5869 §2.2): the 32-byte PRK (base64) HMAC-SHA256(salt, ikm).
// An empty base64 `salt` selects the all-zero salt; "" when an argument is not
// base64. Use the Extract/Expand
// halves directly (rather than `hkdf_sha256`) when a PRK is reused for several
// Expands or when an Extract output is itself the value you want (e.g. HPKE).
// Example: @CRY-004 — the split halves compose to exactly `hkdf_sha256`.
pub fn hkdf_extract(salt_b64: text, ikm_b64: text) -> text {
hkdf_extract_native(salt_b64, ikm_b64)
}
// HKDF-Expand (RFC 5869 §2.3) from an existing 32-byte PRK. Returns `length`
// bytes of OKM (base64), or "" on a short PRK, length > 255*32, length <= 0, or
// an argument that is not base64.
pub fn hkdf_expand(prk_b64: text, info_b64: text, length: integer) -> text {
hkdf_expand_native(prk_b64, info_b64, length)
}
// --- Raw bytes <-> base64 ---
//
// loft `text` is UTF-8-validated, so a non-UTF-8 byte string cannot ride as
// `text` (`text_from_bytes` returns ""). These two inverse helpers move raw
// bytes across the base64 boundary instead. The vector `base64_to_bytes`
// answers is an ordinary one: read it, append to it, pass it on.
// Standard base64 of a raw byte vector. Empty vector -> "".
pub fn bytes_to_base64(bytes: vector<u8>) -> text {
bytes_to_base64_native(bytes)
}
// Raw bytes from standard base64. Input that is not base64, or empty, -> empty vector.
// Example: @CRY-001 — reading a decoded byte vector, and hashing non-UTF-8 bytes.
pub fn base64_to_bytes(b64: text) -> vector<u8> {
base64_to_bytes_native(b64)
}
// --- AES-256-GCM authenticated encryption ---
//
// All byte arguments are base64. The key is 32 bytes and the nonce is 12
// bytes (the standard 96-bit GCM IV). A nonce MUST NOT be reused under one
// key — derive a fresh one per message (e.g. random_bytes(12)).
// Encrypt + authenticate `plaintext` under `key`/`nonce`, binding `aad`.
// Returns base64 of ciphertext WITH the 16-byte tag appended, or "" on bad
// key/nonce length or an argument that is not base64.
// Example: @CRY-005 — the tag rides in the blob, and a fresh nonce per message.
pub fn aes256gcm_seal(key_b64: text, nonce_b64: text, aad_b64: text, plaintext_b64: text) -> text {
aes256gcm_seal_native(key_b64, nonce_b64, aad_b64, plaintext_b64)
}
// Verify + decrypt `ciphertext` (ciphertext||tag, base64) under `key`/`nonce`,
// binding `aad`. Returns the base64 plaintext, or "" on ANY authentication
// failure — an empty result means the ciphertext/tag/aad did not verify.
// Example: @CRY-005 — "" is a failure signal, and an authentic empty plaintext gives it too.
pub fn aes256gcm_open(key_b64: text, nonce_b64: text, aad_b64: text, ciphertext_b64: text) -> text {
aes256gcm_open_native(key_b64, nonce_b64, aad_b64, ciphertext_b64)
}
// --- CSPRNG ---
// Return `length` cryptographically secure random bytes (base64) from the OS
// RNG. Returns "" for length <= 0. Use for keys (32), nonces (12), salts.
pub fn random_bytes(length: integer) -> text {
random_bytes_native(length)
}
// ====================================================================
// HPKE — Hybrid Public Key Encryption, base mode (RFC 9180)
// ====================================================================
//
// Single-shot Seal/Open for the ciphersuite
// DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / AES-256-GCM
// kem_id = 0x0020, kdf_id = 0x0001, aead_id = 0x0002, mode = 0x00 (base).
//
// This is a PURE-LOFT composition over the native primitives above (X25519,
// HKDF Extract/Expand, AES-256-GCM), so it runs identically on every backend
// the primitives reach. Correctness is gated on an RFC 9180 known-answer test
// vector (see tests/hpke.loft).
//
// Byte composition stays entirely in base64 `text`: every byte chunk (labels,
// suite ids, keys, DH outputs) is a base64 string, and `bytes_concat_b64`
// concatenates the underlying raw bytes. ASCII labels are base64-encoded with
// the existing `base64_encode` (ASCII is valid UTF-8), so every intermediate is
// the base64 text the primitives take and answer.
// base64(bytes(a) || bytes(b)) — concatenate two base64 byte strings.
fn concat(a_b64: text, b_b64: text) -> text {
bytes_concat_b64_native(a_b64, b_b64)
}
// An ASCII label as a base64 byte string (ASCII is valid UTF-8, so this is the
// plain text base64 encoder).
fn label_b64(s: text) -> text {
base64_encode_native(s)
}
// I2OSP(n, 2) as base64: the 2-byte big-endian encoding of `n` (RFC 8017).
// Only the small fixed set HPKE needs (suite-id fields and the L lengths 32/12)
// is required here, so each is a precomputed base64 constant — no `vector<u8>`.
fn i2osp_2_b64(n: integer) -> text {
if n == 0x0020 { return "ACA="; } // 0x00 0x20 (kem_id, and L = 32)
if n == 0x0001 { return "AAE="; } // 0x00 0x01 (kdf_id)
if n == 0x0002 { return "AAI="; } // 0x00 0x02 (aead_id)
if n == 12 { return "AAw="; } // 0x00 0x0c (L = 12, base nonce)
""
}
// The main HPKE suite_id (base64): "HPKE" || I2OSP(kem,2) || I2OSP(kdf,2) || I2OSP(aead,2).
fn hpke_suite_id_b64() -> text {
s = concat(label_b64("HPKE"), i2osp_2_b64(0x0020)); // + kem_id
s = concat(s, i2osp_2_b64(0x0001)); // + kdf_id
concat(s, i2osp_2_b64(0x0002)) // + aead_id
}
// The DHKEM suite_id (base64): "KEM" || I2OSP(kem_id,2).
fn kem_suite_id_b64() -> text {
concat(label_b64("KEM"), i2osp_2_b64(0x0020))
}
// LabeledExtract(salt, label, ikm) [RFC 9180 §4]:
// labeled_ikm = "HPKE-v1" || suite_id || label || ikm
// return HKDF-Extract(salt, labeled_ikm)
// All byte args are base64. Returns the 32-byte PRK (base64).
fn labeled_extract(suite_id_b64: text, salt_b64: text, label: text, ikm_b64: text) -> text {
li = concat(label_b64("HPKE-v1"), suite_id_b64);
li = concat(li, label_b64(label));
li = concat(li, ikm_b64);
hkdf_extract(salt_b64, li)
}
// LabeledExpand(prk, label, info, L) [RFC 9180 §4]:
// labeled_info = I2OSP(L,2) || "HPKE-v1" || suite_id || label || info
// return HKDF-Expand(prk, labeled_info, L)
// Returns L bytes (base64).
fn labeled_expand(suite_id_b64: text, prk_b64: text, label: text, info_b64: text, length: integer) -> text {
li = concat(i2osp_2_b64(length), label_b64("HPKE-v1"));
li = concat(li, suite_id_b64);
li = concat(li, label_b64(label));
li = concat(li, info_b64);
hkdf_expand(prk_b64, li, length)
}
// DHKEM ExtractAndExpand(dh, kem_context) [RFC 9180 §4.1], KEM suite_id:
// eae_prk = LabeledExtract("", "eae_prk", dh)
// shared_secret = LabeledExpand(eae_prk, "shared_secret", kem_context, Nsecret=32)
// Returns the 32-byte shared secret (base64).
fn dhkem_extract_and_expand(dh_b64: text, kem_context_b64: text) -> text {
ks = kem_suite_id_b64();
eae_prk = labeled_extract(ks, "", "eae_prk", dh_b64);
labeled_expand(ks, eae_prk, "shared_secret", kem_context_b64, 32)
}
// The X25519 base point (base64): 0x09 followed by 31 zero bytes (RFC 7748).
// `x25519_dh(sk, base_point)` yields sk's public key.
const X25519_BASE_POINT = "CQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
// The X25519 public key for secret scalar `sk_b64`: X25519(sk, base_point).
fn x25519_public_key(sk_b64: text) -> text {
x25519_dh(sk_b64, X25519_BASE_POINT)
}
// DHKEM Decap(enc, skR) [RFC 9180 §4.1]:
// dh = DH(skR, enc); pkRm = pk(skR)
// kem_context = enc || pkRm
// return ExtractAndExpand(dh, kem_context)
// Returns the 32-byte shared secret (base64), or "" if enc/skR are invalid.
fn dhkem_decap(enc_b64: text, sk_r_b64: text) -> text {
dh_b64 = x25519_dh(sk_r_b64, enc_b64);
if dh_b64 == "" { return ""; }
pk_r_b64 = x25519_public_key(sk_r_b64);
kem_context = concat(enc_b64, pk_r_b64);
dhkem_extract_and_expand(dh_b64, kem_context)
}
// DHKEM Encap(pkR, skE) — the deterministic variant [RFC 9180 §4.1]. Takes a
// fixed ephemeral secret `sk_e_b64` (the KAT path); `hpke_seal_base` generates
// a random one.
// pkEm = pk(skE); dh = DH(skE, pkR); enc = pkEm
// kem_context = enc || pkR
// return (ExtractAndExpand(dh, kem_context), enc)
// Returns (shared_secret_b64, enc_b64); ("","") on invalid input.
fn dhkem_encap_deterministic(pk_r_b64: text, sk_e_b64: text) -> (text, text) {
enc_b64 = x25519_public_key(sk_e_b64);
if enc_b64 == "" { return ("", ""); }
dh_b64 = x25519_dh(sk_e_b64, pk_r_b64);
if dh_b64 == "" { return ("", ""); }
kem_context = concat(enc_b64, pk_r_b64);
shared_secret = dhkem_extract_and_expand(dh_b64, kem_context);
(shared_secret, enc_b64)
}
// KeySchedule for base mode (RFC 9180 §5.1), MAIN suite_id. Returns the AEAD
// key (32 bytes) and base nonce (12 bytes) as (key_b64, nonce_b64).
// psk_id_hash = LabeledExtract("", "psk_id_hash", "")
// info_hash = LabeledExtract("", "info_hash", info)
// key_schedule_context = 0x00 || psk_id_hash || info_hash
// secret = LabeledExtract(shared_secret, "secret", "")
// key = LabeledExpand(secret, "key", key_schedule_context, Nk=32)
// base_nonce = LabeledExpand(secret, "base_nonce", key_schedule_context, Nn=12)
fn key_schedule_base(shared_secret_b64: text, info_b64: text) -> (text, text) {
s = hpke_suite_id_b64();
psk_id_hash = labeled_extract(s, "", "psk_id_hash", "");
info_hash = labeled_extract(s, "", "info_hash", info_b64);
// key_schedule_context = mode (0x00) || psk_id_hash || info_hash.
// The single 0x00 mode byte as base64 is "AA==".
ks_context = concat("AA==", psk_id_hash);
ks_context = concat(ks_context, info_hash);
// secret = LabeledExtract(shared_secret, "secret", "") — shared_secret is the salt.
secret = labeled_extract(s, shared_secret_b64, "secret", "");
key = labeled_expand(s, secret, "key", ks_context, 32);
base_nonce = labeled_expand(s, secret, "base_nonce", ks_context, 12);
(key, base_nonce)
}
// The result of `hpke_seal_base`: the KEM-encapsulated key `enc` and the AEAD
// `ciphertext` (ciphertext||16-byte-tag), both base64.
pub struct HpkeSealed {
pub const enc: text,
pub const ciphertext: text,
}
// Internal: seal with an already-encapsulated (shared_secret, enc). seq = 0,
// so the AEAD nonce is exactly base_nonce (RFC 9180 §5.2).
fn seal_with_context(shared_secret_b64: text, enc_b64: text, info_b64: text, aad_b64: text, pt_b64: text) -> HpkeSealed {
(key, base_nonce) = key_schedule_base(shared_secret_b64, info_b64);
if key == "" || base_nonce == "" { return HpkeSealed { enc: "", ciphertext: "" }; }
ct = aes256gcm_seal(key, base_nonce, aad_b64, pt_b64);
HpkeSealed { enc: enc_b64, ciphertext: ct }
}
// --- Public HPKE API ---
// HPKE SealBase (RFC 9180 §6.1): encrypt `pt` to recipient public key `pk_r`,
// binding `info` (key-schedule context) and `aad` (per-message AAD). Generates
// a fresh ephemeral key, so each call's output differs. All args/fields base64.
// Returns HpkeSealed { enc, ciphertext }; both "" on failure.
// Example: @CRY-006 — `enc` is fresh per call and must travel with the ciphertext.
pub fn hpke_seal_base(pk_r_b64: text, info_b64: text, aad_b64: text, pt_b64: text) -> HpkeSealed {
sk_e_b64 = random_bytes(32);
(shared_secret, enc) = dhkem_encap_deterministic(pk_r_b64, sk_e_b64);
if shared_secret == "" { return HpkeSealed { enc: "", ciphertext: "" }; }
seal_with_context(shared_secret, enc, info_b64, aad_b64, pt_b64)
}
// Deterministic SealBase — TEST ONLY. Uses the caller-supplied ephemeral
// secret `sk_e_b64` instead of a random one, so the output is reproducible
// against an RFC 9180 known-answer vector. Never use in production.
pub fn hpke_seal_base_deterministic(pk_r_b64: text, sk_e_b64: text, info_b64: text, aad_b64: text, pt_b64: text) -> HpkeSealed {
(shared_secret, enc) = dhkem_encap_deterministic(pk_r_b64, sk_e_b64);
if shared_secret == "" { return HpkeSealed { enc: "", ciphertext: "" }; }
seal_with_context(shared_secret, enc, info_b64, aad_b64, pt_b64)
}
// HPKE OpenBase (RFC 9180 §6.1): decrypt `ct` for recipient secret key `sk_r`
// using the sender's `enc`, binding `info` and `aad`. All args base64. Returns
// the base64 plaintext, or "" on ANY failure (bad enc/key or AEAD auth failure).
// Example: @CRY-006 — `info` AND `aad` must both match the sender's, or open gives "".
pub fn hpke_open_base(sk_r_b64: text, enc_b64: text, info_b64: text, aad_b64: text, ct_b64: text) -> text {
shared_secret = dhkem_decap(enc_b64, sk_r_b64);
if shared_secret == "" { return ""; }
(key, base_nonce) = key_schedule_base(shared_secret, info_b64);
if key == "" || base_nonce == "" { return ""; }
aes256gcm_open(key, base_nonce, aad_b64, ct_b64)
}
// Expose the key-schedule outputs for KAT assertions — TEST ONLY. Given a
// shared secret and base64 `info`, returns (key_b64, base_nonce_b64).
pub fn hpke_key_schedule_base_for_test(shared_secret_b64: text, info_b64: text) -> (text, text) {
key_schedule_base(shared_secret_b64, info_b64)
}
// Expose deterministic Encap for KAT assertions — TEST ONLY. Returns
// (shared_secret_b64, enc_b64).
pub fn hpke_encap_deterministic_for_test(pk_r_b64: text, sk_e_b64: text) -> (text, text) {
dhkem_encap_deterministic(pk_r_b64, sk_e_b64)
}
tests/aead_kex.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Known-answer tests for the asymmetric / KDF / AEAD primitives. Every
// vector is the official published one, base64-encoded, so a KAT only passes
// if the native impl computes the real RFC/NIST/Wycheproof value:
//
// X25519 — RFC 7748 §6.1 (Alice/Bob example).
// HKDF-SHA256 — RFC 5869 Appendix A, Test Case 1 and Test Case 3.
// AES-256-GCM — Project Wycheproof aes_gcm_test.json tcId 91 (keySize 256,
// ivSize 96, tagSize 128, result "valid").
use crypto::*;
// ── X25519 ECDH — RFC 7748 §6.1 ──────────────────────────────────────
//
// alice_priv = 77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a
// alice_pub = 8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a
// bob_priv = 5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb
// bob_pub = de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f
// shared K = 4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742
fn test_x25519_rfc7748_alice_derives_shared() {
alice_priv = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
bob_pub = "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08=";
shared = crypto::x25519_dh(alice_priv, bob_pub);
assert(shared == "Sl2dW6TOLeFyjjv0gDUPJeB+IclH0Z4zdvCbPB4WF0I=",
"x25519 RFC7748 Alice shared secret: {shared}");
}
fn test_x25519_rfc7748_bob_derives_same_shared() {
bob_priv = "XasIfmJKikt54X+Lg4AO5m87sSkmGLb9HC+LJ/+I4Os=";
alice_pub = "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=";
shared = crypto::x25519_dh(bob_priv, alice_pub);
assert(shared == "Sl2dW6TOLeFyjjv0gDUPJeB+IclH0Z4zdvCbPB4WF0I=",
"x25519 RFC7748 Bob shared secret: {shared}");
}
fn test_x25519_both_sides_agree() {
// The defining ECDH property: dh(a, B) == dh(b, A).
alice_priv = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
alice_pub = "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=";
bob_priv = "XasIfmJKikt54X+Lg4AO5m87sSkmGLb9HC+LJ/+I4Os=";
bob_pub = "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08=";
a = crypto::x25519_dh(alice_priv, bob_pub);
b = crypto::x25519_dh(bob_priv, alice_pub);
assert(a == b, "x25519 both sides must agree: {a} vs {b}");
}
fn test_x25519_malformed_input_is_safe() {
// Wrong-length secret → "" rather than a halt.
empty = crypto::x25519_dh("dG9vc2hvcnQ=", "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08=");
assert(empty == "", "x25519_dh on a short secret returns empty");
}
// ── HKDF-SHA256 — RFC 5869 Appendix A ────────────────────────────────
//
// IKM = 0b * 22 ; L = 42
// Case 1: salt=000102030405060708090a0b0c info=f0f1f2f3f4f5f6f7f8f9
// OKM = 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db0
// 2d56ecc4c5bf34007208d5b887185865
// Case 3: salt=(empty) info=(empty)
// OKM = 8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec345
// 4e5f3c738d2d9d201395faa4b61a96c8
fn test_hkdf_sha256_rfc5869_case1() {
salt = "AAECAwQFBgcICQoLDA==";
ikm = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCw==";
info = "8PHy8/T19vf4+Q==";
okm = crypto::hkdf_sha256(salt, ikm, info, 42);
assert(okm == "PLJfJfqs1XqQQ09k0DYvKi0tCpDPGlpMXbAtVuzExb80AHII1biHGFhl",
"hkdf RFC5869 case1 OKM: {okm}");
}
fn test_hkdf_sha256_rfc5869_case3_empty_salt_info() {
ikm = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCw==";
// Empty base64 salt selects the RFC 5869 §2.2 all-zero salt; empty info.
okm = crypto::hkdf_sha256("", ikm, "", 42);
assert(okm == "jaTndaVjwY9xX4AqBjxaMbihH1xe4Yeew0VOXzxzjS2dIBOV+qS2GpbI",
"hkdf RFC5869 case3 OKM: {okm}");
}
fn test_hkdf_sha256_excessive_length_is_safe() {
salt = "AAECAwQFBgcICQoLDA==";
ikm = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCw==";
// 255 * 32 = 8160 is the HKDF-SHA256 ceiling; 8161 must return "".
out = crypto::hkdf_sha256(salt, ikm, "", 8161);
assert(out == "", "hkdf over the 255*HashLen ceiling returns empty");
}
// ── AES-256-GCM — Wycheproof tcId 91 ─────────────────────────────────
//
// key = 92ace3e348cd821092cd921aa3546374299ab46209691bc28b8752d17f123c20
// iv = 00112233445566778899aabb
// aad = 00000000ffffffff
// msg = 00010203040506070809
// ct = e27abdd2d2a53d2f136b
// tag = 9a4a2579529301bcfb71c78d4060f52c
fn test_aes256gcm_seal_matches_wycheproof_tc91() {
key = "kqzj40jNghCSzZIao1RjdCmatGIJaRvCi4dS0X8SPCA=";
nonce = "ABEiM0RVZneImaq7";
aad = "AAAAAP////8=";
plaintext = "AAECAwQFBgcICQ==";
// Expected = base64(ciphertext || 16-byte tag).
sealed = crypto::aes256gcm_seal(key, nonce, aad, plaintext);
assert(sealed == "4nq90tKlPS8Ta5pKJXlSkwG8+3HHjUBg9Sw=",
"aes256gcm seal must match Wycheproof tc91: {sealed}");
}
fn test_aes256gcm_open_round_trips_wycheproof_tc91() {
key = "kqzj40jNghCSzZIao1RjdCmatGIJaRvCi4dS0X8SPCA=";
nonce = "ABEiM0RVZneImaq7";
aad = "AAAAAP////8=";
ct_and_tag = "4nq90tKlPS8Ta5pKJXlSkwG8+3HHjUBg9Sw=";
plaintext = crypto::aes256gcm_open(key, nonce, aad, ct_and_tag);
assert(plaintext == "AAECAwQFBgcICQ==",
"aes256gcm open must recover the Wycheproof tc91 plaintext: {plaintext}");
}
fn test_aes256gcm_open_rejects_tampered_tag() {
key = "kqzj40jNghCSzZIao1RjdCmatGIJaRvCi4dS0X8SPCA=";
nonce = "ABEiM0RVZneImaq7";
aad = "AAAAAP////8=";
// The tc91 blob with the final tag byte 0x2c flipped to 0x2d.
tampered = "4nq90tKlPS8Ta5pKJXlSkwG8+3HHjUBg9S0=";
out = crypto::aes256gcm_open(key, nonce, aad, tampered);
assert(out == "", "aes256gcm open must reject a tampered tag (empty result)");
}
fn test_aes256gcm_open_rejects_wrong_aad() {
key = "kqzj40jNghCSzZIao1RjdCmatGIJaRvCi4dS0X8SPCA=";
nonce = "ABEiM0RVZneImaq7";
ct_and_tag = "4nq90tKlPS8Ta5pKJXlSkwG8+3HHjUBg9Sw=";
// Authentic ciphertext, but AAD altered (last byte ff -> fe) → must fail.
out = crypto::aes256gcm_open(key, nonce, "AAAAAP////4=", ct_and_tag);
assert(out == "", "aes256gcm open must reject a changed AAD");
}
fn test_aes256gcm_seal_open_round_trip() {
// A fresh key + nonce from the CSPRNG, then seal/open arbitrary data.
key = crypto::random_bytes(32);
nonce = crypto::random_bytes(12);
aad = "Y29udGV4dA=="; // "context"
plaintext = "aGVsbG8gd29ybGQ="; // "hello world"
sealed = crypto::aes256gcm_seal(key, nonce, aad, plaintext);
assert(sealed != "", "aes256gcm seal with a random key/nonce must succeed");
opened = crypto::aes256gcm_open(key, nonce, aad, sealed);
assert(opened == plaintext, "aes256gcm round-trip must recover the plaintext: {opened}");
}
fn test_aes256gcm_malformed_input_is_safe() {
// Wrong-length key → "" rather than a halt.
nonce = "ABEiM0RVZneImaq7";
out = crypto::aes256gcm_seal("dG9vc2hvcnQ=", nonce, "", "AAECAwQFBgcICQ==");
assert(out == "", "aes256gcm_seal on a short key returns empty");
}
tests/crypto.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Tests for the `crypto` library. Each test pins a known-correct
// vector (computed offline via Python's `hashlib` / `hmac` / `base64`
// stdlib) so a regression in the native impl surfaces immediately.
// SHA-256 vectors target the FIPS 180-4 padding seam (55-, 56-,
// 64-byte boundaries). HMAC-SHA-256 covers empty inputs and the
// >64-byte key path that triggers HMAC's hash-the-key fallback.
// Base64 covers all three padding shapes (0/1/2 leftover bytes) per
// RFC 4648 §10, and base64url verifies the `+`/`/` → `-`/`_`
// substitution via a UTF-8 input that produces a `/` in standard
// base64.
use crypto::*;
// Helper: build a string of N copies of a single character.
fn repeat(c: text, n: integer) -> text {
out = "";
for _ in 0..n { out += c; }
out
}
// ── SHA-256 ───────────────────────────────────────────────────────
fn test_sha256_hello() {
hash = crypto::sha256("hello");
assert(hash == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"sha256(hello)={hash}");
}
fn test_sha256_empty() {
hash = crypto::sha256("");
assert(hash == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"sha256(empty)");
}
fn test_sha256_abc() {
// The canonical FIPS 180-4 example.
hash = crypto::sha256("abc");
assert(hash == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"sha256(abc)={hash}");
}
fn test_sha256_55_byte_boundary() {
// SHA-256's block is 64 bytes; the padding format reserves 9 bytes
// (1 byte 0x80 + 8 byte length). 55 bytes fits in a single block
// with the padding; 56 forces a second block. This exercises the
// single-block path right at the edge.
hash = crypto::sha256(repeat("a", 55));
assert(hash == "9f4390f8d30c2dd92ec9f095b65e2b9ae9b0a925a5258e241c9f1e910f734318",
"sha256(55*a)={hash}");
}
fn test_sha256_56_byte_boundary() {
// 56-byte input forces the second padding block. A bug in the
// padding seam typically shows here.
hash = crypto::sha256(repeat("a", 56));
assert(hash == "b35439a4ac6f0948b6d6f9e3c6af0f5f590ce20f1bde7090ef7970686ec6738a",
"sha256(56*a)={hash}");
}
fn test_sha256_64_byte_boundary() {
// Exactly one block — also forces a second padding block.
hash = crypto::sha256(repeat("a", 64));
assert(hash == "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb",
"sha256(64*a)={hash}");
}
fn test_sha256_multi_block() {
// 100 bytes — two-block compress, the common production path.
hash = crypto::sha256(repeat("a", 100));
assert(hash == "2816597888e4a0d3a36b82b83316ab32680eb8f00f8cd3b904d681246d285a0e",
"sha256(100*a)={hash}");
}
// ── HMAC-SHA-256 ──────────────────────────────────────────────────
fn test_hmac_sha256_quick_brown_fox() {
// The canonical test vector.
mac = crypto::hmac_sha256("key", "The quick brown fox jumps over the lazy dog");
assert(mac == "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8",
"hmac(key,fox)={mac}");
}
fn test_hmac_sha256_empty_empty() {
// Empty key + empty data — well-defined fixed-point.
mac = crypto::hmac_sha256("", "");
assert(mac == "b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad",
"hmac(empty,empty)={mac}");
}
fn test_hmac_sha256_empty_data() {
mac = crypto::hmac_sha256("key", "");
assert(mac == "5d5d139563c95b5967b9bd9a8c9b233a9dedb45072794cd232dc1b74832607d0",
"hmac(key,empty)={mac}");
}
fn test_hmac_sha256_empty_key() {
mac = crypto::hmac_sha256("", "Hello");
assert(mac == "b4d6720e5dbca77f5108a6e2e9c4d0a77a732f64244bdfdf01a3ede8ff933e00",
"hmac(empty,Hello)={mac}");
}
fn test_hmac_sha256_long_key() {
// HMAC's spec: if key longer than block size (64 bytes), hash it
// first. A 100-byte key exercises that hash-the-key branch — a
// common impl bug is to use the raw long key directly, which gives
// wrong output that won't match any HMAC verifier.
mac = crypto::hmac_sha256(repeat("a", 100), "data");
assert(mac == "e14de9a494162761a795f27ecd019c4ce8fe3ea948c960e0fd251c78af6f1ccc",
"hmac(100*a,data)={mac}");
}
fn test_hmac_sha256_long_data() {
// Long data exercises the streaming/multi-block path of the inner
// SHA-256.
mac = crypto::hmac_sha256("sec", repeat("a", 200));
assert(mac == "ed741c0af595e1ea3055cd781fe68f378d0d4830b3e2f54201fa83bf4b7e93c3",
"hmac(sec,200*a)={mac}");
}
// ── Base64 (RFC 4648 §10 padding cases) ───────────────────────────
fn test_base64_padding_0() {
// 3-byte aligned input: no padding.
assert(crypto::base64_encode("foo") == "Zm9v", "b64(foo)");
assert(crypto::base64_encode("foobar") == "Zm9vYmFy", "b64(foobar)");
}
fn test_base64_padding_1() {
// 2-byte leftover: single `=`.
assert(crypto::base64_encode("fo") == "Zm8=", "b64(fo)");
assert(crypto::base64_encode("fooba") == "Zm9vYmE=", "b64(fooba)");
}
fn test_base64_padding_2() {
// 1-byte leftover: double `==`.
assert(crypto::base64_encode("f") == "Zg==", "b64(f)");
assert(crypto::base64_encode("foob") == "Zm9vYg==", "b64(foob)");
}
fn test_base64_empty() {
// Empty input → empty output (no padding).
assert(crypto::base64_encode("") == "", "b64(empty)");
assert(crypto::base64_decode("") == "", "b64dec(empty)");
}
fn test_base64_roundtrip_hello() {
encoded = crypto::base64_encode("hello world");
assert(encoded == "aGVsbG8gd29ybGQ=", "b64 encode");
decoded = crypto::base64_decode(encoded);
assert(decoded == "hello world", "b64 decode");
}
fn test_base64_decode_all_paddings() {
// Round-trip each padding shape through decode.
assert(crypto::base64_decode("Zg==") == "f", "dec(Zg==)");
assert(crypto::base64_decode("Zm8=") == "fo", "dec(Zm8=)");
assert(crypto::base64_decode("Zm9v") == "foo", "dec(Zm9v)");
assert(crypto::base64_decode("Zm9vYg==") == "foob", "dec(Zm9vYg==)");
assert(crypto::base64_decode("Zm9vYmE=") == "fooba", "dec(Zm9vYmE=)");
assert(crypto::base64_decode("Zm9vYmFy") == "foobar", "dec(Zm9vYmFy)");
}
// ── Base64url (RFC 4648 §5: `+/` → `-_`, no padding) ──────────────
fn test_base64url_hello() {
encoded = crypto::base64url_encode("hello world");
assert(encoded == "aGVsbG8gd29ybGQ", "b64url(hello world)");
}
fn test_base64url_no_padding() {
// base64url strips padding; verify each padding shape.
assert(crypto::base64url_encode("f") == "Zg", "b64url(f) — no ==");
assert(crypto::base64url_encode("fo") == "Zm8", "b64url(fo) — no =");
assert(crypto::base64url_encode("foo") == "Zm9v", "b64url(foo)");
assert(crypto::base64url_encode("foob") == "Zm9vYg", "b64url(foob) — no ==");
}
fn test_base64url_substitutes_slash() {
// The UTF-8 sequence "ÿÿÿ" (= 0xc3 0xbf × 3) encodes to "w7/Dv8O/"
// in standard base64 — note the `/` characters at positions 2, 7.
// base64url must substitute `/` → `_` and strip padding.
encoded = crypto::base64url_encode("ÿÿÿ");
assert(encoded == "w7_Dv8O_", "b64url(ÿÿÿ) substitutes / → _");
}
tests/crypto_simple.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
use crypto::(sha256, hmac_sha256, base64_encode, base64_decode);
fn main() {
digest = sha256("hello");
assert(digest == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", "sha256");
enc = base64_encode("hello world");
assert(enc == "aGVsbG8gd29ybGQ=", "b64 encode");
dec = base64_decode(enc);
assert(dec == "hello world", "b64 decode");
mac = hmac_sha256("key", "The quick brown fox jumps over the lazy dog");
assert(mac == "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8", "hmac");
}
tests/ed25519.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Ed25519 (RFC 8032) known-answer tests. The vectors are the official
// RFC 8032 §7.1 TEST 1 (empty message) and TEST 2 (1-byte message 0x72),
// base64-encoded. A KAT only validates if the expected values are the real
// RFC ones, so the hex was taken verbatim from RFC 8032 and base64-encoded:
//
// TEST 1 sk = 9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60
// pk = d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a
// msg = (empty)
// sig = e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155
// 5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b
//
// TEST 2 sk = 4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb
// pk = 3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c
// msg = 72
// sig = 92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da
// 085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00
use crypto::*;
// ── RFC 8032 TEST 1 (empty message) ──────────────────────────────────
fn test_ed25519_rfc8032_test1_public_key() {
sk = "nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=";
pk = crypto::ed25519_public_key(sk);
assert(pk == "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=",
"ed25519 RFC8032 test1 public key: {pk}");
}
fn test_ed25519_rfc8032_test1_sign() {
sk = "nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=";
msg = ""; // empty message
sig = crypto::ed25519_sign(sk, msg);
assert(sig == "5VZDAMNgrHKQhuLMgG6CioSHfx645dl02HPgZSJJAVVfuIIVkKM7rMYeOXAc+bRr0lv18FlbviRlUUFDjnoQCw==",
"ed25519 RFC8032 test1 signature: {sig}");
}
fn test_ed25519_rfc8032_test1_verify() {
pk = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=";
msg = "";
sig = "5VZDAMNgrHKQhuLMgG6CioSHfx645dl02HPgZSJJAVVfuIIVkKM7rMYeOXAc+bRr0lv18FlbviRlUUFDjnoQCw==";
ok = crypto::ed25519_verify(pk, msg, sig);
assert(ok, "ed25519 RFC8032 test1 verify must accept the valid signature");
}
// ── RFC 8032 TEST 2 (1-byte message) ─────────────────────────────────
fn test_ed25519_rfc8032_test2_public_key() {
sk = "TM0Imyj/ltqdtsNG7BFOD1uKMZ81q6Yk2oz27U+4pvs=";
pk = crypto::ed25519_public_key(sk);
assert(pk == "PUAXw+hDiVqStwqnTRt+vJyYLM8uxJaMwM1V8Sr0Zgw=",
"ed25519 RFC8032 test2 public key: {pk}");
}
fn test_ed25519_rfc8032_test2_sign() {
sk = "TM0Imyj/ltqdtsNG7BFOD1uKMZ81q6Yk2oz27U+4pvs=";
msg = "cg=="; // 0x72
sig = crypto::ed25519_sign(sk, msg);
assert(sig == "kqAJqfDUyrhyDoILX2QlQKKye1QWUD+Ps3YiI+vbadoIWsHkPhWZbkWPNhPQ8R2MOHsurrQwKu6wDSkWErsMAA==",
"ed25519 RFC8032 test2 signature: {sig}");
}
fn test_ed25519_rfc8032_test2_verify() {
pk = "PUAXw+hDiVqStwqnTRt+vJyYLM8uxJaMwM1V8Sr0Zgw=";
msg = "cg==";
sig = "kqAJqfDUyrhyDoILX2QlQKKye1QWUD+Ps3YiI+vbadoIWsHkPhWZbkWPNhPQ8R2MOHsurrQwKu6wDSkWErsMAA==";
ok = crypto::ed25519_verify(pk, msg, sig);
assert(ok, "ed25519 RFC8032 test2 verify must accept the valid signature");
}
// ── Tamper rejection: a flipped signature byte must fail ──────────────
fn test_ed25519_verify_rejects_tampered_signature() {
pk = "PUAXw+hDiVqStwqnTRt+vJyYLM8uxJaMwM1V8Sr0Zgw=";
msg = "cg==";
// Test 2 signature with the final byte 0x00 flipped to 0x01.
tampered = "kqAJqfDUyrhyDoILX2QlQKKye1QWUD+Ps3YiI+vbadoIWsHkPhWZbkWPNhPQ8R2MOHsurrQwKu6wDSkWErsMAQ==";
ok = crypto::ed25519_verify(pk, msg, tampered);
assert(!ok, "ed25519 verify must reject a tampered signature");
}
// ── Wrong-message rejection ───────────────────────────────────────────
fn test_ed25519_verify_rejects_wrong_message() {
pk = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=";
// Test 1's signature is over the EMPTY message; verifying it over a
// non-empty message must fail.
wrong_msg = "cg==";
sig = "5VZDAMNgrHKQhuLMgG6CioSHfx645dl02HPgZSJJAVVfuIIVkKM7rMYeOXAc+bRr0lv18FlbviRlUUFDjnoQCw==";
ok = crypto::ed25519_verify(pk, wrong_msg, sig);
assert(!ok, "ed25519 verify must reject a signature over a different message");
}
// ── Malformed input is loft-safe (no panic) ───────────────────────────
fn test_ed25519_malformed_input_is_safe() {
// Wrong-length secret → empty string, not a halt.
empty_pk = crypto::ed25519_public_key("dG9vc2hvcnQ="); // "tooshort", 8 bytes
assert(empty_pk == "", "ed25519_public_key on a short seed returns empty");
empty_sig = crypto::ed25519_sign("dG9vc2hvcnQ=", "cg==");
assert(empty_sig == "", "ed25519_sign on a short seed returns empty");
bad = crypto::ed25519_verify("dG9vc2hvcnQ=", "cg==", "dG9vc2hvcnQ=");
assert(!bad, "ed25519_verify on malformed key/sig returns false");
}
tests/es256.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// ES256 (ECDSA P-256 / SHA-256) known-answer tests. The vectors are the
// official RFC 6979 Appendix A.2.5 (ECDSA, 256 bits, SHA-256), base64-encoded.
// A KAT only validates if the expected values are the real RFC ones, so the
// hex was taken verbatim from RFC 6979 §A.2.5 and base64-encoded:
//
// x (private key) = C9AFA9D845BA75166B5C215767B1D6934E50C3DB36E89B127B8A622B120F6721
// Ux (public key) = 60FED4BA255A9D31C961EB74C6356D68C049B8923B61FA6CE669622E60F29FB6
// Uy = 7903FE1008B8BC99A41AE9E95628BC64F2F1B20C2D7E9F5177A3C294D4462299
//
// with SHA-256, message = "sample":
// r = EFD48B2AACB6A8FD1140DD9CD45E81D69D2C877B56AAF991C34D0EA84EAF3716
// s = F7CB1C942D657C41D436C7A1B6E29F65F3E900DBB9AFF4064DC4AB2F843ACDA8
// with SHA-256, message = "test":
// r = F1ABB023518351CD71D881567B1EA663ED3EFCF6C5132B354F28D3B0B7D38367
// s = 019F4113742A2B14BD25926B49C649155F267E60D3814B4C0CC84250E46F0083
//
// The public key is the 64-byte `x || y`; a signature is the 64-byte raw
// `r || s` (the JOSE form ACME wants, not DER). Because signing is
// deterministic (RFC 6979), the signature bytes are a fixed golden — this
// pins the whole ES256 stack across the interpret == native == wasm gate.
//
// All KAT values are bound to LOCALS before use: a loft-function-returned or
// bare-literal `text` handed straight into a native call as a TEMPORARY hits a
// native store-lifetime bug (the arg's pointer arrives null while its length
// survives). Binding first is the ed25519 tests' convention too.
use crypto::*;
// ── RFC 6979 A.2.5, message "sample" ─────────────────────────────────
fn test_es256_rfc6979_public_key() {
sk = "ya+p2EW6dRZrXCFXZ7HWk05Qw9s26JsSe4piKxIPZyE=";
expected = "YP7UuiVanTHJYet0xjVtaMBJuJI7Yfps5mliLmDyn7Z5A/4QCLi8maQa6elWKLxk8vGyDC1+n1F3o8KU1EYimQ==";
pk = crypto::ecdsa_p256_public_key(sk);
assert(pk == expected, "es256 RFC6979 A.2.5 public key: {pk}");
}
fn test_es256_rfc6979_sign_sample() {
sk = "ya+p2EW6dRZrXCFXZ7HWk05Qw9s26JsSe4piKxIPZyE=";
msg = "c2FtcGxl"; // "sample"
expected = "79SLKqy2qP0RQN2c1F6B1p0sh3tWqvmRw00OqE6vNxb3yxyULWV8QdQ2x6G24p9l8+kA27mv9AZNxKsvhDrNqA==";
sig = crypto::ecdsa_p256_sign(sk, msg);
assert(sig == expected, "es256 RFC6979 A.2.5 'sample' signature: {sig}");
}
fn test_es256_rfc6979_verify_sample() {
pk = "YP7UuiVanTHJYet0xjVtaMBJuJI7Yfps5mliLmDyn7Z5A/4QCLi8maQa6elWKLxk8vGyDC1+n1F3o8KU1EYimQ==";
msg = "c2FtcGxl";
sig = "79SLKqy2qP0RQN2c1F6B1p0sh3tWqvmRw00OqE6vNxb3yxyULWV8QdQ2x6G24p9l8+kA27mv9AZNxKsvhDrNqA==";
ok = crypto::ecdsa_p256_verify(pk, msg, sig);
assert(ok, "es256 verify must accept the RFC6979 'sample' signature");
}
// ── RFC 6979 A.2.5, message "test" ───────────────────────────────────
fn test_es256_rfc6979_sign_test() {
sk = "ya+p2EW6dRZrXCFXZ7HWk05Qw9s26JsSe4piKxIPZyE=";
msg = "dGVzdA=="; // "test"
expected = "8auwI1GDUc1x2IFWex6mY+0+/PbFEys1TyjTsLfTg2cBn0ETdCorFL0lkmtJxkkVXyZ+YNOBS0wMyEJQ5G8Agw==";
sig = crypto::ecdsa_p256_sign(sk, msg);
assert(sig == expected, "es256 RFC6979 A.2.5 'test' signature: {sig}");
}
fn test_es256_rfc6979_verify_test() {
pk = "YP7UuiVanTHJYet0xjVtaMBJuJI7Yfps5mliLmDyn7Z5A/4QCLi8maQa6elWKLxk8vGyDC1+n1F3o8KU1EYimQ==";
msg = "dGVzdA==";
sig = "8auwI1GDUc1x2IFWex6mY+0+/PbFEys1TyjTsLfTg2cBn0ETdCorFL0lkmtJxkkVXyZ+YNOBS0wMyEJQ5G8Agw==";
ok = crypto::ecdsa_p256_verify(pk, msg, sig);
assert(ok, "es256 verify must accept the RFC6979 'test' signature");
}
// ── d = 1 → the NIST P-256 generator point (independently checkable) ──
//
// The public key for the scalar d = 1 is the base point G, whose affine
// coordinates are the standard SEC 2 / NIST P-256 generator (Gx || Gy).
fn test_es256_scalar_one_is_generator() {
d1 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE="; // 31 zero bytes || 0x01
expected = "axfR8uEsQkf4vOblY6RA8ncDfYEt6zOg9KE5RdiYwpZP40Li/hp/m47n60p8D54WK84zV2sxXs7LtkBoN79R9Q==";
pk = crypto::ecdsa_p256_public_key(d1);
assert(pk == expected, "es256 public key for d=1 is the P-256 generator G: {pk}");
}
// ── keygen round-trip (keygen is random → structural, not byte-KAT) ───
fn test_es256_keygen_round_trip() {
sk = crypto::ecdsa_p256_keygen();
sk_bytes = crypto::base64_to_bytes(sk);
assert(len(sk_bytes) == 32, "es256 keygen yields a 32-byte scalar");
pk = crypto::ecdsa_p256_public_key(sk);
pk_bytes = crypto::base64_to_bytes(pk);
assert(len(pk_bytes) == 64, "es256 public key is a 64-byte x||y point");
msg = "YWNtZSBuZXdPcmRlcg=="; // "acme newOrder"
sig = crypto::ecdsa_p256_sign(sk, msg);
sig_bytes = crypto::base64_to_bytes(sig);
assert(len(sig_bytes) == 64, "es256 signature is a 64-byte r||s");
ok = crypto::ecdsa_p256_verify(pk, msg, sig);
assert(ok, "a fresh es256 signature verifies");
}
fn test_es256_keygen_two_calls_differ() {
// Two independent P-256 keygens colliding is ~2^-256.
a = crypto::ecdsa_p256_keygen();
b = crypto::ecdsa_p256_keygen();
assert(a != b, "es256 keygen draws a fresh scalar each call");
}
// ── Tamper / wrong-message rejection ──────────────────────────────────
fn test_es256_verify_rejects_tampered_signature() {
pk = "YP7UuiVanTHJYet0xjVtaMBJuJI7Yfps5mliLmDyn7Z5A/4QCLi8maQa6elWKLxk8vGyDC1+n1F3o8KU1EYimQ==";
msg = "dGVzdA==";
// The 'test' signature with its leading `r` byte flipped (first char 8 → A:
// decoded byte 0xF1 → 0x01), a genuinely different byte string.
tampered = "AauwI1GDUc1x2IFWex6mY+0+/PbFEys1TyjTsLfTg2cBn0ETdCorFL0lkmtJxkkVXyZ+YNOBS0wMyEJQ5G8Agw==";
ok = crypto::ecdsa_p256_verify(pk, msg, tampered);
assert(!ok, "es256 verify must reject a tampered signature");
}
fn test_es256_verify_rejects_wrong_message() {
pk = "YP7UuiVanTHJYet0xjVtaMBJuJI7Yfps5mliLmDyn7Z5A/4QCLi8maQa6elWKLxk8vGyDC1+n1F3o8KU1EYimQ==";
wrong_msg = "dGVzdA=="; // the 'sample' signature verified over "test" must fail
sig = "79SLKqy2qP0RQN2c1F6B1p0sh3tWqvmRw00OqE6vNxb3yxyULWV8QdQ2x6G24p9l8+kA27mv9AZNxKsvhDrNqA==";
ok = crypto::ecdsa_p256_verify(pk, wrong_msg, sig);
assert(!ok, "es256 verify must reject a signature over a different message");
}
// ── Malformed input is loft-safe (no panic) ───────────────────────────
fn test_es256_malformed_input_is_safe() {
short = "dG9vc2hvcnQ="; // "tooshort" = 8 bytes
msg = "dGVzdA==";
empty_pk = crypto::ecdsa_p256_public_key(short);
assert(empty_pk == "", "ecdsa_p256_public_key on a short secret returns empty");
empty_sig = crypto::ecdsa_p256_sign(short, msg);
assert(empty_sig == "", "ecdsa_p256_sign on a short secret returns empty");
bad = crypto::ecdsa_p256_verify(short, msg, short);
assert(!bad, "ecdsa_p256_verify on malformed key/sig returns false");
}
tests/es256_parity.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// ES256 four-target parity driver (the crypto_simple.loft convention: a `fn main`
// that `loft test` runs on interpret + --native, and that you also BUILD to the
// two wasm targets to prove the deterministic bytes are identical there):
//
// loft tests/es256_parity.loft # interpret
// loft tests/es256_parity.loft --native
// loft tests/es256_parity.loft --native-wasm # then: wasmtime tests/.loft/es256_parity.wasm
// loft tests/es256_parity.loft --html # then: serve tests/.loft/ + headless chromium
//
// The deterministic ops (public_key / sign / verify over the RFC 6979 A.2.5
// vectors) must be byte-identical on all four; keygen is random, so it is only
// checked structurally (a fresh key must sign-and-verify, and be 44 base64 chars
// = a 32-byte scalar). Values are bound to LOCALS before each native call — a
// bare literal handed straight in as a temporary hits a native store-lifetime
// bug (arg pointer arrives null).
use crypto::*;
fn main() {
sk = "ya+p2EW6dRZrXCFXZ7HWk05Qw9s26JsSe4piKxIPZyE=";
exp_pk = "YP7UuiVanTHJYet0xjVtaMBJuJI7Yfps5mliLmDyn7Z5A/4QCLi8maQa6elWKLxk8vGyDC1+n1F3o8KU1EYimQ==";
exp_sig_sample = "79SLKqy2qP0RQN2c1F6B1p0sh3tWqvmRw00OqE6vNxb3yxyULWV8QdQ2x6G24p9l8+kA27mv9AZNxKsvhDrNqA==";
exp_sig_test = "8auwI1GDUc1x2IFWex6mY+0+/PbFEys1TyjTsLfTg2cBn0ETdCorFL0lkmtJxkkVXyZ+YNOBS0wMyEJQ5G8Agw==";
pk = crypto::ecdsa_p256_public_key(sk);
sig_sample = crypto::ecdsa_p256_sign(sk, "c2FtcGxl"); // "sample"
sig_test = crypto::ecdsa_p256_sign(sk, "dGVzdA=="); // "test"
ok_sample = crypto::ecdsa_p256_verify(pk, "c2FtcGxl", sig_sample);
ok_test = crypto::ecdsa_p256_verify(pk, "dGVzdA==", sig_test);
assert(pk == exp_pk, "es256 parity: public key");
assert(sig_sample == exp_sig_sample, "es256 parity: 'sample' signature");
assert(sig_test == exp_sig_test, "es256 parity: 'test' signature");
assert(ok_sample, "es256 parity: 'sample' verifies");
assert(ok_test, "es256 parity: 'test' verifies");
// keygen round-trip: random, so structural — a fresh key must verify, and a
// 32-byte P-256 scalar is 44 base64 chars.
fresh = crypto::ecdsa_p256_keygen();
fresh_pk = crypto::ecdsa_p256_public_key(fresh);
fresh_sig = crypto::ecdsa_p256_sign(fresh, "cGluZw=="); // "ping"
fresh_ok = crypto::ecdsa_p256_verify(fresh_pk, "cGluZw==", fresh_sig);
fresh_b64len = len(fresh);
assert(fresh_ok, "es256 parity: a fresh keygen key signs and verifies");
assert(fresh_b64len == 44, "es256 parity: keygen scalar is 44 base64 chars (32 bytes)");
// Printed so a --native-wasm / --html build can be diffed against interpret.
println("PARITY pk={pk}");
println("PARITY sig_sample={sig_sample}");
println("PARITY sig_test={sig_test}");
println("PARITY verify={ok_sample}/{ok_test} keygen_ok={fresh_ok} keygen_b64len={fresh_b64len}");
}
tests/hpke.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// HPKE base-mode known-answer tests (RFC 9180).
//
// Vector source: the official CFRG test-vectors.json
// (https://github.com/cfrg/draft-irtf-cfrg-hpke, the file RFC 9180 was
// published from), the single entry with
// mode = 0 (base), kem_id = 32 (DHKEM(X25519, HKDF-SHA256)),
// kdf_id = 1 (HKDF-SHA256), aead_id = 2 (AES-256-GCM).
// (RFC 9180 Appendix A.1's printed vector is the same KEM/KDF but AES-128-GCM;
// the AES-256-GCM X25519 vector lives only in test-vectors.json.)
//
// Every field below is that vector's value, base64-encoded. The first
// encryption in the vector is sequence 0, so the AEAD nonce is exactly the
// base nonce.
//
// info = 4f6465206f6e2061204772656369616e2055726e
// skEm = 179d4b53b6365c45b600c4163b61d95cbc2f4d9e36f1695558dce265ab8bab11
// skRm = 497b4502664cfea5d5af0b39934dac72242a74f8480451e1aee7d6a53320333d
// pkRm = 430f4b9859665145a6b1ba274024487bd66f03a2dd577d7753c68d7d7d00c00c
// enc = 6c93e09869df3402d7bf231bf540fadd35cd56be14f97178f0954db94b7fc256
// shared = 3101c54c3a4f87439eaac080699ed9bbcc726ffe44e860c0424ccb7e3e2ead7b
// key = f50b0609186798729ed0564b36ef2ef8044f1f9d05636874d1f46c819c7a669f
// nonce = 151d9929e2449747889bc923
// aad = 436f756e742d30
// pt = 4265617574792069732074727574682c20747275746820626561757479
// ct = e5d84cd531cfb583096e7cfa9641bd3079cf3a91cda813c52deb5f512be9931980a41de125a925cdad859d5b7a
use crypto::*;
const SK_E = "F51LU7Y2XEW2AMQWO2HZXLwvTZ428WlVWNziZauLqxE=";
const SK_R = "SXtFAmZM/qXVrws5k02sciQqdPhIBFHhrufWpTMgMz0=";
const PK_R = "Qw9LmFlmUUWmsbonQCRIe9ZvA6LdV313U8aNfX0AwAw=";
const ENC = "bJPgmGnfNALXvyMb9UD63TXNVr4U+XF48JVNuUt/wlY=";
const SHARED = "MQHFTDpPh0OeqsCAaZ7Zu8xyb/5E6GDAQkzLfj4urXs=";
const KEY = "9QsGCRhnmHKe0FZLNu8u+ARPH50FY2h00fRsgZx6Zp8=";
const NONCE = "FR2ZKeJEl0eIm8kj";
const INFO = "T2RlIG9uIGEgR3JlY2lhbiBVcm4=";
const AAD = "Q291bnQtMA==";
const PT = "QmVhdXR5IGlzIHRydXRoLCB0cnV0aCBiZWF1dHk=";
const CT = "5dhM1THPtYMJbnz6lkG9MHnPOpHNqBPFLetfUSvpkxmApB3hJaklza2FnVt6";
// Deterministic Encap(pkR, skEm) must yield the vector's enc AND shared_secret.
fn test_hpke_encap_matches_vector() {
(shared, enc) = crypto::hpke_encap_deterministic_for_test(PK_R, SK_E);
assert(enc == ENC, "HPKE Encap enc must match vector: got {enc}");
assert(shared == SHARED, "HPKE Encap shared_secret must match vector: got {shared}");
}
// KeySchedule(shared_secret, info) must yield the vector's key and base_nonce.
fn test_hpke_key_schedule_matches_vector() {
(key, nonce) = crypto::hpke_key_schedule_base_for_test(SHARED, INFO);
assert(key == KEY, "HPKE key must match vector: got {key}");
assert(nonce == NONCE, "HPKE base_nonce must match vector: got {nonce}");
}
// SealBase (deterministic) must produce the vector's enc and ciphertext.
fn test_hpke_seal_base_matches_vector() {
sealed = crypto::hpke_seal_base_deterministic(PK_R, SK_E, INFO, AAD, PT);
assert(sealed.enc == ENC, "HPKE seal enc must match vector: got {sealed.enc}");
assert(sealed.ciphertext == CT, "HPKE seal ciphertext must match vector: got {sealed.ciphertext}");
}
// OpenBase must recover the vector's plaintext from enc + ciphertext.
fn test_hpke_open_base_recovers_plaintext() {
pt = crypto::hpke_open_base(SK_R, ENC, INFO, AAD, CT);
assert(pt == PT, "HPKE open must recover the vector plaintext: got {pt}");
}
// A tampered ciphertext must fail authentication and open to "".
fn test_hpke_open_base_rejects_tampered_ciphertext() {
// The vector ct with its final tag byte flipped (last base64 char changed).
tampered = "5dhM1THPtYMJbnz6lkG9MHnPOpHNqBPFLetfUSvpkxmApB3hJaklza2FnVt5";
out = crypto::hpke_open_base(SK_R, ENC, INFO, AAD, tampered);
assert(out == "", "HPKE open must reject a tampered ciphertext (empty result): got {out}");
}
// Wrong AAD must fail authentication and open to "".
fn test_hpke_open_base_rejects_wrong_aad() {
out = crypto::hpke_open_base(SK_R, ENC, INFO, "d3JvbmctYWFk", CT);
assert(out == "", "HPKE open must reject a changed AAD (empty result): got {out}");
}
// Randomised seal -> open round trip: a fresh ephemeral key each call, recovered
// by the matching recipient secret. Exercises the non-deterministic path.
fn test_hpke_seal_open_round_trip() {
sealed = crypto::hpke_seal_base(PK_R, INFO, AAD, PT);
assert(sealed.enc != "", "randomised HPKE seal must produce an enc");
assert(sealed.ciphertext != "", "randomised HPKE seal must produce a ciphertext");
// A fresh ephemeral key means a different enc/ct than the deterministic KAT.
assert(sealed.enc != ENC, "randomised seal enc should differ from the fixed-skE vector");
recovered = crypto::hpke_open_base(SK_R, sealed.enc, INFO, AAD, sealed.ciphertext);
assert(recovered == PT, "randomised HPKE round trip must recover the plaintext: got {recovered}");
}
// A non-recipient secret key must NOT recover the plaintext.
fn test_hpke_open_base_wrong_recipient_fails() {
// skEm is a valid X25519 scalar but not the recipient — decap yields a
// different shared secret, so the AEAD tag fails.
out = crypto::hpke_open_base(SK_E, ENC, INFO, AAD, CT);
assert(out == "", "HPKE open with the wrong recipient key must fail: got {out}");
}
tests/malformed_base64.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// Input that is not base64 is refused by every door that takes base64 — "" or false,
// never a result computed from other bytes than the ones sent. A lenient decoder read
// each stray character as `A`: `base64_to_bytes("!!!!")` answered [0,0,0], and a typo in
// a message signed, hashed and sealed bytes the caller never sent.
use crypto;
fn main() {
bad = "aG k=";
key = crypto::random_bytes(32);
nonce = crypto::random_bytes(12);
seed = crypto::random_bytes(32);
pk = crypto::ed25519_public_key(seed);
assert(len(crypto::base64_to_bytes("!!!!")) == 0, "base64_to_bytes refuses");
assert(crypto::base64_decode("!!!") == "", "base64_decode refuses");
assert(crypto::base64_decode("/w==") == "", "bytes that are not UTF-8 are no text");
assert(crypto::base64_decode("aGk=") == "hi", "valid input still decodes");
assert(crypto::sha256_b64(bad) == "", "sha256_b64 refuses rather than hash other bytes");
assert(crypto::ed25519_sign(seed, bad) == "", "ed25519_sign refuses the message");
good_sig = crypto::ed25519_sign(seed, crypto::base64_encode("m"));
assert(!crypto::ed25519_verify(pk, bad, good_sig), "ed25519_verify refuses the message");
assert(crypto::aes256gcm_seal(key, nonce, bad, crypto::base64_encode("p")) == "", "seal refuses the aad");
assert(crypto::aes256gcm_seal(key, nonce, "", bad) == "", "seal refuses the plaintext");
assert(crypto::hkdf_sha256("", bad, "", 32) == "", "hkdf refuses the ikm");
assert(crypto::hkdf_sha256("", crypto::base64_encode("ikm"), "", 32) != "", "valid hkdf still answers");
// The vector base64_to_bytes answers is an ordinary one: appending to it is sound.
d = crypto::base64_to_bytes(crypto::base64_encode("ab"));
noise = crypto::base64_to_bytes(crypto::random_bytes(16));
d += [99 as u8];
assert(len(d) == 3 && d[0] == 97 && d[2] == 99 && len(noise) == 16, "append onto a decoded vector");
println("malformed base64: refused everywhere");
}
tests/random.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Tests for the OS CSPRNG. A randomness source has no fixed known-answer
// vector, so these check the testable contract: the output length is exactly
// as requested, two independent draws differ, and length 0 is empty.
//
// Length is checked on the BASE64 STRING, not the decoded bytes: standard
// base64 with padding encodes n raw bytes as exactly ceil(n/3)*4 characters,
// which is deterministic. Decoding back to bytes is avoided because the
// library's `base64_decode` is UTF-8-lossy (random non-UTF-8 octets become a
// 3-byte replacement char), so a decoded length would not equal n.
use crypto::*;
fn test_random_bytes_length_32() {
out = crypto::random_bytes(32);
assert(out != "", "random_bytes(32) must be non-empty");
// 32 bytes → ceil(32/3)=11 base64 groups → 44 chars.
n = out.len();
assert(n == 44, "random_bytes(32) must encode to 44 base64 chars, got {n}");
}
fn test_random_bytes_length_12() {
out = crypto::random_bytes(12);
// 12 bytes → ceil(12/3)=4 base64 groups → 16 chars.
n = out.len();
assert(n == 16, "random_bytes(12) must encode to 16 base64 chars, got {n}");
}
fn test_random_bytes_length_1() {
out = crypto::random_bytes(1);
// 1 byte → 1 base64 group with two pad chars → 4 chars.
n = out.len();
assert(n == 4, "random_bytes(1) must encode to 4 base64 chars, got {n}");
}
fn test_random_bytes_two_calls_differ() {
// Two 32-byte CSPRNG draws colliding has probability ~2^-256.
a = crypto::random_bytes(32);
b = crypto::random_bytes(32);
assert(a != b, "two random_bytes(32) draws must differ");
}
fn test_random_bytes_zero_is_empty() {
out = crypto::random_bytes(0);
assert(out == "", "random_bytes(0) must be empty");
}
fn test_random_bytes_negative_is_empty() {
out = crypto::random_bytes(-5);
assert(out == "", "random_bytes(-5) must be empty");
}
tests/sha256_bytes.loft
// Raw-byte <-> base64 bridge KATs: sha256_b64, bytes_to_base64, base64_to_bytes,
// bytes_concat_b64 — the ztcbor signing / content_hash path. MUST give the
// identical result on interpret / --native / --native-wasm / --html: this file is
// the parity check for the four byte bridges the wasm gate depends on.
use crypto::*;
fn main() {
// sha256_b64 (Python hashlib vectors) — raw / non-UTF-8 byte hashing.
assert(sha256_b64("") == "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", "empty");
assert(sha256_b64("/w==") == "qBAK5qoZQNC2Y7sxzUZhQuu9vVGHExuS2TgYmHgy64k=", "0xff");
// bytes_to_base64 — vector<u8> arg crossing the store boundary.
assert(bytes_to_base64([255 as u8]) == "/w==", "bytes_to_base64 0xff");
assert(sha256_b64(bytes_to_base64([255 as u8])) == "qBAK5qoZQNC2Y7sxzUZhQuu9vVGHExuS2TgYmHgy64k=", "via bytes_to_base64");
// base64_to_bytes — vector<u8> return crossing the store boundary; inverse of
// bytes_to_base64 for any byte string (incl. non-UTF-8 and empty).
assert(bytes_to_base64(base64_to_bytes("/w==")) == "/w==", "round-trip 0xff");
assert(bytes_to_base64(base64_to_bytes("")) == "", "round-trip empty");
assert(bytes_to_base64(base64_to_bytes("AAECAwT/")) == "AAECAwT/", "round-trip multi-byte");
assert(len(base64_to_bytes("AAECAwT/")) == 6, "decoded length");
// (bytes_concat_b64 is internal — exercised by the HPKE tests, not public API.)
println("crypto: byte-bridge KATs pass");
}
tests/worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for the `crypto` library (@PLN141). Each `test_*` below is a REAL,
// run-every-CI demonstration of a function whose correct use is not obvious from its
// signature alone; the function documents itself by CITING the tag with
// `// Example: @CRY-00N`, and `check_doc_drift.sh examples` fails if a citation goes
// dangling (no test carries the tag). These are teaching examples, distinct from the
// known-answer suites in crypto.loft / ed25519.loft / es256.loft / aead_kex.loft /
// hpke.loft, which pin the RFC vectors instead.
//
// Why this library owes them: every parameter and return here is `text`. The type
// checker cannot tell a message from the base64 of a message, a hex digest from a
// base64 one, a 32-byte seed from a 64-byte key, or a "" that means "it failed" from a
// "" that means "it was empty". So every example below is a contract a caller can get
// wrong while the program type-checks perfectly.
//
// Every value is bound to a LOCAL before it reaches a native call: a bare literal or a
// function result handed straight through as a TEMPORARY hits a native store-lifetime
// bug (the argument's pointer arrives null while its length survives). That is the
// convention in ed25519.loft and es256.loft too.
use crypto::*;
// @CRY-001 — two hashing doors, and why there are two. `sha256` takes loft `text` and
// answers a 64-character lowercase HEX digest; `sha256_b64` takes the BASE64 of a raw
// byte string and answers the BASE64 of the same 32-byte digest. Both hash bytes the
// same way — they differ only in how the input arrives and how the output is spelled,
// and nothing in `text -> text` says which one you are holding. Reach for `sha256_b64`
// whenever what you are hashing is not valid UTF-8 (a ciphertext, a canonical CBOR
// record): such a byte string cannot ride as `text` at all, so `sha256` cannot see it.
// `bytes_to_base64` / `base64_to_bytes` are the bridge to a `vector<u8>` — READ the
// decoded vector (index it, iterate it, pass it on); never make it the left side of
// `+=`, which corrupts after a later store allocation.
fn test_hash_text_versus_bytes() {
// The same input through both doors.
text_in = "hello";
hex = crypto::sha256(text_in);
assert(hex == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
"sha256 answers HEX: {hex}");
bytes_in = crypto::base64_encode(text_in);
b64 = crypto::sha256_b64(bytes_in);
assert(b64 == "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=",
"sha256_b64 answers BASE64 of the same 32 bytes: {b64}");
// The difference a caller trips over: 64 hex characters against a 32-byte digest.
// Comparing one spelling to the other can never match.
digest_bytes = crypto::base64_to_bytes(b64);
assert(len(hex) == 64, "the hex digest is 64 characters");
assert(len(digest_bytes) == 32, "the base64 digest decodes to 32 bytes");
assert(hex != b64, "two spellings of one digest are not equal");
// A byte string that is NOT valid UTF-8 (here the single byte 0xff) can only be
// hashed through the base64 door: `text` is UTF-8-validated, so it cannot carry it.
raw = crypto::bytes_to_base64([255 as u8]);
assert(raw == "/w==", "raw bytes cross the boundary as base64: {raw}");
raw_digest = crypto::sha256_b64(raw);
assert(raw_digest == "qBAK5qoZQNC2Y7sxzUZhQuu9vVGHExuS2TgYmHgy64k=",
"sha256 of the byte 0xff: {raw_digest}");
}
// @CRY-002 — sign the BASE64 of your message, never the message. Every Ed25519
// argument is base64 of bytes: the secret key is the RFC 8032 32-byte SEED (not a
// 64-byte expanded key), the public key comes from `ed25519_public_key`, and the
// message goes in as `base64_encode(msg)`. Handing the message text straight in
// type-checks and signs whatever bytes that text happens to decode to as base64 — and
// it stays silent, because verifying with the SAME wrong spelling still succeeds. Only
// a peer that spelled it correctly ever sees the mismatch. Malformed input never
// halts: both producers answer "" and `ed25519_verify` answers false, so an unchecked
// "" is a signature you never made.
fn test_ed25519_sign_the_base64_of_the_message() {
seed = "TM0Imyj/ltqdtsNG7BFOD1uKMZ81q6Yk2oz27U+4pvs="; // RFC 8032 §7.1 TEST 2 seed
pk = crypto::ed25519_public_key(seed);
assert(pk == "PUAXw+hDiVqStwqnTRt+vJyYLM8uxJaMwM1V8Sr0Zgw=",
"the 32-byte seed derives the public key: {pk}");
// Correct: the message "test" carried as base64.
msg = crypto::base64_encode("test");
assert(msg == "dGVzdA==", "base64_encode is the bridge: {msg}");
sig = crypto::ed25519_sign(seed, msg);
ok = crypto::ed25519_verify(pk, msg, sig);
assert(ok, "the signature verifies over the message bytes");
// The trap: "test" is itself valid base64 (four characters), so passing it as the
// message signs the three bytes b5 eb 2d instead of the four bytes of "test".
wrong = "test";
sig_wrong = crypto::ed25519_sign(seed, wrong);
assert(sig_wrong != "", "the wrong call does not fail — it signs different bytes");
assert(sig_wrong != sig, "so it is a different signature: {sig_wrong}");
self_ok = crypto::ed25519_verify(pk, wrong, sig_wrong);
assert(self_ok, "and it verifies against itself — invisible inside one program");
peer_ok = crypto::ed25519_verify(pk, msg, sig_wrong);
assert(!peer_ok, "only a peer that spelled the message correctly sees the mismatch");
// Soft failure: a wrong-length seed answers "" rather than halting.
short = "dG9vc2hvcnQ=";
none = crypto::ed25519_sign(short, msg);
assert(none == "", "a bad key answers an empty signature — test for it");
}
// @CRY-003 — ES256 is DETERMINISTIC, and its encodings are the flat JOSE ones. Signing
// follows RFC 6979, so one key and one message always answer the same bytes: two
// signatures differing is a bug, not fresh randomness, and `ecdsa_p256_keygen` is the
// only call here that draws entropy. Nothing in `text -> text` says which encoding it
// wants, and ES256 wants the JOSE forms throughout: the secret key is the bare 32-byte
// scalar `d`, the public key is the 64-byte affine point `x || y` with NO 0x04 SEC1
// prefix, and a signature is the 64-byte raw `r || s` — not DER, which belongs to the
// X.509 / PKCS#10 layer. Feed a DER signature or an 0x04-prefixed key and you get
// `false` / "" with nothing naming the reason, so check the three lengths.
fn test_es256_deterministic_and_jose_shaped() {
sk = crypto::ecdsa_p256_keygen();
sk_bytes = crypto::base64_to_bytes(sk);
assert(len(sk_bytes) == 32, "the secret key is the bare 32-byte scalar d");
pk = crypto::ecdsa_p256_public_key(sk);
pk_bytes = crypto::base64_to_bytes(pk);
assert(len(pk_bytes) == 64, "the public key is 64 bytes x||y — no 0x04 prefix");
msg = crypto::base64_encode("acme newOrder");
sig = crypto::ecdsa_p256_sign(sk, msg);
sig_bytes = crypto::base64_to_bytes(sig);
assert(len(sig_bytes) == 64, "the signature is 64 bytes r||s — not DER");
ok = crypto::ecdsa_p256_verify(pk, msg, sig);
assert(ok, "a fresh ES256 signature verifies");
// Deterministic: the same key and message sign to the identical bytes every time.
again = crypto::ecdsa_p256_sign(sk, msg);
assert(again == sig, "RFC 6979 — signing twice must answer the identical bytes");
// Only keygen draws entropy.
other = crypto::ecdsa_p256_keygen();
assert(other != sk, "keygen draws a fresh scalar per call");
}
// @CRY-004 — a Diffie-Hellman output is NOT a key. `x25519_dh(mine, theirs)` answers
// the raw 32-byte shared point and both sides compute the same one, but RFC 7748 §6.1
// says never use it directly: run it through `hkdf_sha256` so the key depends on a salt
// and a context `info`. That `info` is what separates two keys derived from ONE shared
// secret — a send key from a receive key, one protocol version from the next — and it
// is the argument callers leave empty. Use the split `hkdf_extract` / `hkdf_expand`
// when one PRK feeds several Expands, or when the PRK is itself the value you want
// (HPKE does both); the one-shot is exactly those two composed. An EMPTY base64 salt
// does not mean "no salt": it selects the RFC 5869 all-zero salt.
fn test_x25519_then_hkdf_makes_the_key() {
// RFC 7748 §6.1, the Alice / Bob example.
alice_sk = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
alice_pk = "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo=";
bob_sk = "XasIfmJKikt54X+Lg4AO5m87sSkmGLb9HC+LJ/+I4Os=";
bob_pk = "3p7bfXt9wbTTW2HC7OQ1Nz+DQ8hbeGdNrfx+FG+IK08=";
mine = crypto::x25519_dh(alice_sk, bob_pk);
theirs = crypto::x25519_dh(bob_sk, alice_pk);
assert(mine == theirs, "both sides derive the same shared point");
// The DH output is input keying material, never the key itself.
salt = crypto::random_bytes(32);
info = crypto::base64_encode("loft session key v1");
key = crypto::hkdf_sha256(salt, mine, info, 32);
key_bytes = crypto::base64_to_bytes(key);
assert(len(key_bytes) == 32, "HKDF answers the 32-byte AEAD key");
assert(key != mine, "the key is derived from the shared point, never equal to it");
// A different `info` over the SAME shared secret is a different key: this is how one
// handshake yields several keys without a second exchange.
other_info = crypto::base64_encode("loft session key v2");
other = crypto::hkdf_sha256(salt, mine, other_info, 32);
assert(other != key, "info separates two keys from one shared secret");
// The one-shot IS Extract then Expand — reach for the halves when a PRK is reused.
prk = crypto::hkdf_extract(salt, mine);
prk_bytes = crypto::base64_to_bytes(prk);
assert(len(prk_bytes) == 32, "Extract answers the 32-byte PRK");
composed = crypto::hkdf_expand(prk, info, 32);
assert(composed == key, "hkdf_sha256 is hkdf_extract followed by hkdf_expand");
}
// @CRY-005 — AES-256-GCM: the tag rides inside the ciphertext, and "" from `open` means
// AUTHENTICATION FAILED. `aes256gcm_seal` answers base64 of the ciphertext WITH its
// 16-byte tag appended, so the sealed blob is always 16 bytes longer than the plaintext
// and `open` takes that whole blob — there is no separate tag argument to carry. The
// key is 32 bytes and the nonce is 12; a nonce MUST NOT repeat under one key, so draw a
// fresh `random_bytes(12)` per message and send it alongside. The `aad` is
// authenticated but not encrypted, and must match EXACTLY on open. The trap: `open`
// answers "" for every failure — bad key, wrong nonce, altered aad, tampered tag — and
// "" is also what an authentic EMPTY plaintext answers. Treat "" as failure and carry
// emptiness some other way.
fn test_aes256gcm_seal_open_and_the_empty_answer() {
key = crypto::random_bytes(32);
nonce = crypto::random_bytes(12);
aad = crypto::base64_encode("v1|user-42");
pt = crypto::base64_encode("attack at dawn");
sealed = crypto::aes256gcm_seal(key, nonce, aad, pt);
// ciphertext || 16-byte tag — one blob, nothing separate to keep track of.
pt_bytes = crypto::base64_to_bytes(pt);
sealed_bytes = crypto::base64_to_bytes(sealed);
assert(len(sealed_bytes) == len(pt_bytes) + 16,
"sealed is the plaintext plus a 16-byte tag");
opened = crypto::aes256gcm_open(key, nonce, aad, sealed);
assert(opened == pt, "open recovers the plaintext");
// The aad is authenticated: change it and open refuses.
other_aad = crypto::base64_encode("v1|user-43");
denied = crypto::aes256gcm_open(key, nonce, other_aad, sealed);
assert(denied == "", "a changed aad fails authentication");
// A fresh nonce per message: the SAME plaintext seals to a different blob, which is
// what makes reusing one nonce under one key detectable as the mistake it is.
nonce2 = crypto::random_bytes(12);
sealed2 = crypto::aes256gcm_seal(key, nonce2, aad, pt);
assert(sealed2 != sealed, "a fresh nonce gives a fresh ciphertext for one plaintext");
opened2 = crypto::aes256gcm_open(key, nonce2, aad, sealed2);
assert(opened2 == pt, "and its own nonce opens it");
// The ambiguity: an authentic EMPTY plaintext opens to "" as well — the very answer a
// forgery gets. "" is a failure signal, never data.
nonce3 = crypto::random_bytes(12);
empty_sealed = crypto::aes256gcm_seal(key, nonce3, aad, "");
empty_bytes = crypto::base64_to_bytes(empty_sealed);
assert(len(empty_bytes) == 16, "sealing nothing yields just the tag");
empty_opened = crypto::aes256gcm_open(key, nonce3, aad, empty_sealed);
assert(empty_opened == "", "an authentic empty plaintext is indistinguishable from a failure");
}
// @CRY-006 — HPKE seals to a public key alone, but the recipient needs THREE things
// back: `enc`, the `ciphertext`, and the very same `info` and `aad` the sender used.
// `hpke_seal_base` draws a fresh ephemeral key on every call, so `enc` differs each
// time and MUST travel with the ciphertext — it cannot be cached, and it is not
// derivable from the recipient key. `info` binds the key schedule (a protocol or
// version context) while `aad` binds this one message; both are authenticated, and
// either one differing makes `hpke_open_base` answer "" with nothing saying which.
// X25519 has no separate keygen, so a recipient public key is the secret scalar taken
// against the RFC 7748 base point — the recipe below. The `_deterministic` /
// `_for_test` entry points exist only to pin the RFC 9180 vectors: they take the
// ephemeral secret from the caller, so never reach for them in real code.
fn test_hpke_transmit_enc_and_match_the_context() {
// The CFRG RFC 9180 vector's recipient key pair.
sk_r = "SXtFAmZM/qXVrws5k02sciQqdPhIBFHhrufWpTMgMz0=";
// Where a public key comes from: X25519 of the secret against the base point
// (0x09 followed by 31 zero bytes).
base_point = "CQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
pk_r = crypto::x25519_dh(sk_r, base_point);
assert(pk_r == "Qw9LmFlmUUWmsbonQCRIe9ZvA6LdV313U8aNfX0AwAw=",
"the secret scalar against the base point is the public key: {pk_r}");
info = crypto::base64_encode("loft/hpke demo v1");
aad = crypto::base64_encode("message 1");
pt = crypto::base64_encode("Beauty is truth, truth beauty");
sealed = crypto::hpke_seal_base(pk_r, info, aad, pt);
assert(sealed.enc != "", "seal answers the encapsulated key");
assert(sealed.ciphertext != "", "and the ciphertext");
// Opening needs both halves, plus the identical info and aad.
opened = crypto::hpke_open_base(sk_r, sealed.enc, info, aad, sealed.ciphertext);
assert(opened == pt, "the recipient recovers the plaintext");
// A fresh ephemeral key per call: `enc` differs every time, so it cannot be
// recomputed later — it travels with the ciphertext or the message is lost.
again = crypto::hpke_seal_base(pk_r, info, aad, pt);
assert(again.enc != sealed.enc, "each seal encapsulates a fresh ephemeral key");
assert(again.ciphertext != sealed.ciphertext, "so one plaintext seals differently");
// Mismatch either bound context and open answers "" — the same "" for both.
wrong_info = crypto::base64_encode("loft/hpke demo v2");
bad_info = crypto::hpke_open_base(sk_r, sealed.enc, wrong_info, aad, sealed.ciphertext);
assert(bad_info == "", "a different info fails the key schedule");
wrong_aad = crypto::base64_encode("message 2");
bad_aad = crypto::hpke_open_base(sk_r, sealed.enc, info, wrong_aad, sealed.ciphertext);
assert(bad_aad == "", "a different aad fails the AEAD");
}