crypto guide

← 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