Crypto Arrives: Hashing, HMAC, and the Primitives That Unlock Everything

Crypto Arrives: Hashing, HMAC, and the Primitives That Unlock Everything

February 11, 2026·
Luma Core Team

Two tools down, one to go. Yesterday we shipped binary buffers. Today, Luma gets a crypto module — hashing, HMAC, secure random generation, encoding, and the byte utilities that tie them together.

These aren’t exotic features. They’re the primitives that show up the moment a program touches authentication, data integrity, API signatures, or tokens. Without them, you can’t verify a webhook, hash a password, generate a session token, or authenticate to a database. With them, all of that becomes straightforward.

What’s in the box

Six hash algorithms

hash: [byte] = crypto.md5("hello")
hash: [byte] = crypto.sha1("hello")
hash: [byte] = crypto.sha224("hello")
hash: [byte] = crypto.sha256("hello")
hash: [byte] = crypto.sha384("hello")
hash: [byte] = crypto.sha512("hello")

Every hash function accepts both str and [byte] arguments. The compiler dispatches to the correct implementation based on the argument type — no explicit .to_bytes() needed.

Every hash function returns [byte]. If you want a hex string, chain .to_hex():

checksum: str = crypto.sha256(content).to_hex()

Raw bytes stay accessible for protocols that need them. Hex is just a display choice.

Four HMAC variants

HMAC combines a hash with a secret key to produce an authentication code. Unlike plain hashing, HMAC proves the message was created by someone who knows the key.

mac: [byte] = crypto.hmac_sha256(key, payload)

Four variants: hmac_md5, hmac_sha1, hmac_sha256, hmac_sha512. The data argument is duck-typed — str or [byte], same as the hash functions.

// Verify a webhook signature
secret: [byte] = "webhook_secret".to_bytes()
expected: str = "sha256=" + crypto.hmac_sha256(secret, payload).to_hex()
if crypto.equal(expected.to_bytes(), received_signature.to_bytes()) {
    print("Signature valid")
}

Secure random bytes

token: [byte] = crypto.rand(32)
session_id: str = token.to_hex()

Cryptographically secure, from the operating system’s random source. Never fails. Use it for encryption keys, nonces, salts, session tokens — anything that must be unpredictable.

XOR and constant-time comparison

result: [byte] = crypto.xor(a, b)
equal: bool = crypto.equal(a, b)

crypto.xor() is byte-wise XOR of two equal-length slices. Fundamental to MySQL authentication, stream ciphers, and key derivation.

crypto.equal() compares two byte slices in constant time. Normal == leaks information through timing differences — how long it takes reveals where the bytes diverge. crypto.equal() always takes the same amount of time, preventing timing attacks. Use it whenever you compare secret values: HMAC signatures, password hashes, API keys.

Encoding as type methods

Encoding isn’t crypto — it’s reversible transformation for display and transport. So encoding methods live as type methods on [byte] and str, not under the crypto namespace.

Hex

hex_str: str = data.to_hex()              // [byte] -> "deadbeef"
bytes: [byte] = "deadbeef".from_hex()     // "deadbeef" -> [byte]

Base64

encoded: str = data.to_base64()           // standard base64 with padding
decoded: [byte] = encoded.from_base64()

URL-safe Base64

encoded: str = data.to_base64url()        // no +, /, or = characters
decoded: [byte] = encoded.from_base64url()

All from_* methods panic on invalid input by default. For user-facing input where failure is expected, use the optional return type:

result: ?[byte] = user_input.from_hex()
if result == nil {
    print("Invalid hex")
}

Argument-type dispatch

One of Luma’s design principles: the compiler figures out what you mean. Hash functions accept both strings and byte slices:

// These produce identical results
hash1: [byte] = crypto.md5("Hello")
hash2: [byte] = crypto.md5(bytes_data)

The compiler sees the argument type and selects the correct Go implementation. No overloading syntax, no generics, no runtime reflection. Just type-aware dispatch at compile time.

What this looks like in practice

MySQL authentication

The reason we built all of this. MySQL’s mysql_native_password algorithm requires SHA1 hashing and XOR:

// SHA1(password) XOR SHA1(nonce + SHA1(SHA1(password)))
sha1_hash: [byte] = crypto.sha1(password)
double_hash: [byte] = crypto.sha1(sha1_hash)

combined: buffer = buffer()
combined.write(nonce)
combined.write(double_hash)
scramble: [byte] = crypto.sha1(combined.to_bytes())

auth_response: [byte] = crypto.xor(sha1_hash, scramble)

Notice how buffers and crypto work together naturally. The buffer builds the concatenation, crypto does the hashing and XOR. Each tool does one thing well.

MySQL 8.0’s caching_sha2_password works the same way — just replace sha1 with sha256.

Session tokens

token: str = crypto.rand(32).to_hex()
// "a3f8b2c1d4e5f6071829304a5b6c7d8e..."

One line. Cryptographically secure, hex-encoded, ready to store.

Password hashing with salt

salt: [byte] = crypto.rand(16)
salted: buffer = buffer()
salted.write(salt)
salted.write(password)
hash: [byte] = crypto.sha256(salted.to_bytes())
stored: str = salt.to_hex() + ":" + hash.to_hex()

The buffer builds the salted input. The hash function processes it. The hex encoding makes it storable. Three features, working together.

File checksums

content: str = file("data.csv").read()
print("MD5:    ${crypto.md5(content).to_hex()}")
print("SHA256: ${crypto.sha256(content).to_hex()}")

Why we included MD5

MD5 is cryptographically broken — you shouldn’t use it for security purposes. But it’s everywhere: file checksums, content fingerprinting, cache keys, legacy API compatibility, etag generation. Excluding it forces developers to work around the language or reach for external tools. We include it and trust developers to make informed choices.

Feature-flagged, as always

Like buffers, the crypto runtime is only included when you use it. A program that never touches crypto.* gets no crypto imports, no crypto functions, no extra binary size. The encoding functions (to_hex, from_hex, to_base64, etc.) have their own separate flag — you can use hex encoding without pulling in the full crypto module.

The toolbox so far

With buffers and crypto done, two of the three low-level primitives from our database roadmap are complete:

Primitive Status What it enables
Binary buffers Done Packet construction and parsing
Crypto Done Authentication, integrity, tokens
TCP networking Next Socket connections to databases and services

TCP is the last piece. Once it lands, we have everything needed to write a MySQL driver in pure Luma — opening a socket, performing the handshake, authenticating with caching_sha2_password, sending queries, and parsing results. All in regular .luma files that anyone can read and understand.

What we shipped

  • 6 hash functions: MD5, SHA1, SHA224, SHA256, SHA384, SHA512
  • 4 HMAC functions: HMAC-MD5, HMAC-SHA1, HMAC-SHA256, HMAC-SHA512
  • Secure random: crypto.rand(n) — OS-level cryptographic randomness
  • XOR: crypto.xor(a, b) — byte-wise XOR for protocol operations
  • Constant-time compare: crypto.equal(a, b) — timing-safe comparison
  • Hex encoding: .to_hex() on [byte], .from_hex() on str
  • Base64 encoding: .to_base64() / .from_base64() — standard with padding
  • URL-safe Base64: .to_base64url() / .from_base64url() — no padding, URL-safe characters
  • Optional decode variants: .from_hex(), .from_base64(), .from_base64url() return nil instead of panicking when used with optional types

32 new .lcore runtime files. Zero external dependencies. Everything compiles from Go’s standard library.

The toolbox grows. One more tool to go.

Last updated on