← crypto · API reference · source
crypto is the primitives: SHA-256 and HMAC, base64 both ways, Ed25519 and ECDSA-P256 signatures, X25519 Diffie-Hellman, HKDF, AES-256-GCM, HPKE, and random bytes.
It is not a protocol. There is no TLS here, no password hashing (no argon2, no bcrypt), no key store and no certificate handling — those are decisions a library should not make for you. It gives you the pieces and expects you to know which one you want.
⚠ Read this before anything else: every argument and every answer is BASE64 TEXT. Keys, messages, nonces, ciphertexts, digests from the _b64 doors — all of them. The signatures say text -> text and cannot tell you that, which is why the section below exists and why it is the first one.
use crypto::*;
fn main() {
Hashing plain text, and the bridge every other call needs.
digest = crypto::sha256("hello");
encoded = crypto::base64_encode("hello");
println("sha256 : {digest}");
println("base64 : {encoded}");
println("rtrip : {crypto::base64_decode(encoded) == "hello"}");
assert(len(digest) == 64, "sha256 answers 64 lowercase HEX characters");
assert(encoded == "aGVsbG8=", "base64_encode is the bridge to every other call");
assert(crypto::base64_decode(encoded) == "hello", "…and it round-trips");
Every section below is a function, and main calls each one. A section main does not call still COMPILES — so a renamed API is caught, and the assertions that check the example is RIGHT never run.
sign_the_base64_never_the_message();
two_hashing_doors();
failure_is_a_value_never_a_halt();
a_length_you_did_not_ask_for();
}
That prints:
sha256 : 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
base64 : aGVsbG8=
rtrip : true
The one that bites: sign the BASE64 of your message, never the message
ed25519_sign(secret, message) wants the message as base64_encode(msg). Handing it the message text type-checks, runs, and signs whatever bytes that text happens to decode to as base64.
And it stays silent, which is the whole problem: verifying with the SAME wrong spelling succeeds, so your own round-trip test passes. Only a peer who spelled it correctly ever sees the mismatch — in production, against someone else's implementation, long after.
The same rule runs through the package. The Ed25519 secret key is the RFC 8032 32-byte SEED in base64, not a 64-byte expanded key; the public key comes from ed25519_public_key; nonces, AAD and plaintext for AES-GCM are base64; HKDF's salt, IKM and info are base64.
fn sign_the_base64_never_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 seed derives the key");
Correct: the message carried as base64.
msg = crypto::base64_encode("test");
assert(msg == "dGVzdA==", "base64_encode is the bridge");
sig = crypto::ed25519_sign(seed, msg);
assert(crypto::ed25519_verify(pk, msg, sig), "and it verifies");
The trap: "test" is itself four valid base64 characters, so passing it raw signs the three bytes it decodes to instead of the four bytes of "test".
wrong = crypto::ed25519_sign(seed, "test");
assert(crypto::ed25519_verify(pk, "test", wrong),
"the same wrong spelling VERIFIES — which is why nothing tells you");
assert(!crypto::ed25519_verify(pk, msg, wrong),
"…and a peer who spelled it right sees a signature that does not match");
println("signing : base64_encode(msg), always — the wrong spelling self-verifies");
}
Two hashing doors, and why there are two
sha256(text) takes loft text and answers 64 lowercase HEX characters. sha256_b64(b64) takes the BASE64 of a byte string and answers the BASE64 of the same 32-byte digest. Both hash the same bytes the same way; they differ in how the input arrives and how the output is spelled, and comparing one spelling to the other can never match.
Reach for sha256_b64 whenever what you are hashing is not valid UTF-8 — a ciphertext, a canonical CBOR record, a compressed blob. Such a byte string cannot ride as text at all, so sha256 cannot even see it. bytes_to_base64 / base64_to_bytes are the bridge to a vector<u8>.
fn two_hashing_doors() {
hex = crypto::sha256("hello");
b64 = crypto::sha256_b64(crypto::base64_encode("hello"));
assert(len(hex) == 64, "the hex spelling is 64 characters");
assert(len(crypto::base64_to_bytes(b64)) == 32, "the base64 one decodes to 32 bytes");
assert(hex != b64, "two spellings of ONE digest, and they are not equal");
A byte that is not valid UTF-8 can only go through the base64 door.
raw = crypto::bytes_to_base64([255 as u8]);
assert(raw == "/w==", "raw bytes cross as base64");
assert(len(crypto::sha256_b64(raw)) == 44, "…and hash fine there");
println("hashing : sha256 -> hex, sha256_b64 -> base64, never comparable");
}
Failure is a VALUE, never a halt
Nothing here panics on malformed input. A producer answers "" and a verifier answers false — so an unchecked "" is a signature you never made, and a program that stores it has stored nothing while reporting success.
Check the result of every producer before you use it. That is the whole error model; there is no exception to catch and no status code to read.
fn failure_is_a_value_never_a_halt() {
msg = crypto::base64_encode("test");
assert(crypto::ed25519_sign("not-base64!!", msg) == "",
"a malformed key answers the empty text, not an error");
assert(!crypto::ed25519_verify("nope", msg, "also-nope"),
"…and a malformed verify answers false");
seed = "TM0Imyj/ltqdtsNG7BFOD1uKMZ81q6Yk2oz27U+4pvs=";
good = crypto::ed25519_sign(seed, msg);
assert(good != "", "the check that matters: a real signature is not empty");
println("errors : '' and false — an unchecked '' is a signature you never made");
}
A length you did not ask for
random_bytes(16) answers 16 bytes of randomness as base64, so the text you get is 24 characters long. Measuring the answer instead of asking for what you wanted is how a key ends up the wrong size — decode it, or count what you requested.
fn a_length_you_did_not_ask_for() {
r = crypto::random_bytes(16);
assert(len(r) == 24, "16 bytes of base64 is 24 characters, got {len(r)}");
assert(len(crypto::base64_to_bytes(r)) == 16, "…and 16 bytes once decoded");
assert(crypto::random_bytes(16) != r, "two draws differ");
println("random : random_bytes(16) is 24 characters of base64, 16 bytes decoded");
}
Where to go next
README.md— the whole surface: ECDSA-P256 (deterministic, flat JOSE encodings), X25519, HKDF's three doors, AES-256-GCM and HPKE.tests/—@CRY-001…@CRY-006, six contracts a signature cannot state, each a running test: the two hashing doors, the signing spelling above, ES256's determinism, why a Diffie-Hellman output is NOT a key, where AES-GCM's tag lives, and the three things an HPKE recipient needs.cborfor a canonical record to hash or sign, andpluginabifor a protocol built on both.