mooncrypt
Guide

From a digest to a key file

Ten sections, each one runnable on its own. The line under every listing is what that listing printed when it was run against this checkout.

Install

The library is a set of packages, one per algorithm. Add the module once, then import the packages you call. A package that is not imported is not linked, so the ones you never touch cost nothing.

shell
moon add moonbitstack/mooncrypt

Every listing below is a body for fn main in a new project that has this module added. The output line underneath is the printed result, not an illustration of one.

A digest

hash takes a BytesView, which is a slice. msg[:] turns a Bytes into one without copying it.

main.mbt
let msg = b"the quick brown fox"
let digest = @sha2.hash(msg[:])
println(@base16.encode(digest))
prints 9ecb36561341d18eb65484e833efea61edc74b84cf5e6ae1b81c63533e25fc8f

A message that does not fit in memory is written in pieces. This is the same digest, computed by SHA-512 instead, and the two halves never have to meet.

main.mbt
let hasher = @sha2.Hasher::new(kind=Sha512)
hasher.write(b"the quick "[:])
hasher.write(b"brown fox"[:])
println(@base16.encode(hasher.finish()))
prints d9d380f29b97ad6a1d92e987d83fa5a02653301e1006dd2bcd51afa59a9147e9caedaf89521abc0f0b682adcd47fb512b8343c834a32f326fe9bef00542ce887

Every hash package has the same shape — Hasher::new, write, finish — because they all answer the @spec.Hash trait.

A keyed digest

HMAC takes a factory rather than a digest, because it runs the hash twice with the key folded in. The factory is what lets one HMAC serve every digest in the library.

main.mbt
let sha256 = fn() -> &@spec.Hash { @sha2.Hasher::new() }
let tag = @hmac.mac(b"shared secret"[:], msg[:], sha256)
println(@base16.encode(tag))
prints 1bc9dec719333af59c72c731bca522da4afc8e8855361ce45a307a238d7d1710

KMAC is the same idea built on SHA-3's sponge, and it takes the output length as an argument, so one call produces a tag of any size.

main.mbt
let kmac = @kmac.mac(b"shared secret"[:], msg[:], kind=Kmac256, len=32)
println(@base16.encode(kmac))
prints e1c27b1167639bbf3f4af364f51b2ed967e5d26219e272c366f4505cb2e20d72

Comparing a tag is a place where == is wrong: it stops at the first difference, and the time it took says how much of the tag was right. @spec.eq reads every byte.

Deriving a key

HKDF turns one shared secret into several keys. info is what separates them: the same input under a different label is a different key.

main.mbt
let key = @hkdf.key(
  b"input keying material"[:],
  sha256,
  salt=b"salt"[:],
  info=b"client write key"[:],
  len=32,
)
println(@base16.encode(key))
prints 9bf7b38cbf3c65f62e8330bf4db8e8ee97f6709d460684f8bb506a4e8ae1ef43

A password is not a key. It needs a function that is deliberately slow and deliberately hungry, and scrypt is one: this setting spends 16 MiB, and @scrypt.sensitive spends 1 GiB.

main.mbt
let stretched = @scrypt.key(
  b"correct horse"[:],
  b"battery staple"[:],
  params=@scrypt.Params::new(n=1024, r=8, p=1),
  len=32,
)
println(@base16.encode(stretched))
prints 7744b8247539ed550e742991f10ca581c84d0d54af393bcbaeb3aa999d4f9cea

Encrypting with a tag

AEAD does encryption and authentication in one pass. seal returns the ciphertext with the tag on the end of it, and open returns the plaintext or raises.

main.mbt
let boxed = @chachapoly.ChaChaPoly::new(key[:])
let nonce = Bytes::make(12, b'\x00')
let sealed_box = boxed.seal(nonce=nonce[:], plain=msg[:])
println(@base16.encode(sealed_box))
println(boxed.open(nonce=nonce[:], cipher=sealed_box[:]) == msg)
prints 96918f97f8fbe1276216d26702cb17350150ba878430aa41df60c80c46044ed01fc72f true

A nonce is unique per key, not secret. Reusing one with the same key is the mistake this construction cannot survive, which is why the nonce is an argument and not something the library invents.

Take one byte off the end and the message is gone — not shortened, gone.

main.mbt
let cut = sealed_box[0:sealed_box.length() - 1]
let opened = boxed.open(nonce=nonce[:], cipher=cut) catch { _ => b"" }
println(opened.length())
prints 0

There is no partial result and no flag to check afterwards: open either authenticates the whole ciphertext or it raises.

Signing

An Ed25519 private key is its 32-byte seed; the public key is computed from it. Signing is deterministic, so the same key and message always produce the same signature.

main.mbt
let seed = @base16.decode_lossy(
  "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
)
let sk = @ed25519.PrivateKey::new(seed[:])
let sig = sk.sign(msg[:])
println(@base16.encode(sk.public().bytes()))
println(sk.public().verify(msg[:], sig[:]))
prints d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a true

ECDSA has the same shape over a different curve. Its nonce comes from RFC 6979 rather than from a random source, so it is deterministic too — and a repeated nonce, which is what breaks ECDSA in the field, cannot happen.

main.mbt
let scalar = @base16.decode_lossy(
  "c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721",
)
let ec = @ecdsa.PrivateKey::new(scalar[:])
let ec_sig = ec.sign(msg[:])
println(@base16.encode(ec.public().bytes()))
println(ec.public().verify(msg[:], ec_sig[:]))
prints 0460fed4ba255a9d31c961eb74c6356d68c049b8923b61fa6ce669622e60f29fb67903fe1008b8bc99a41ae9e95628bc64f2f1b20c2d7e9f5177a3c294d4462299 true

Both implement @spec.Verifier, so a function written against the trait verifies either without knowing which it has.

Agreeing on a secret

Two private scalars, two public points, one shared secret. The arguments are scalars here for brevity; public is what does the multiplication.

main.mbt
let alice = Bytes::make(32, b'\x11')
let bob = Bytes::make(32, b'\x22')
let secret = @ecdh.shared(alice[:], @ecdh.public(bob[:])[:])
println(@base16.encode(secret))
prints ccfc261f58193c98ca4ad4a53bbac6f0ee29bc4d48438090446908622ca79af6

shared refuses a peer key that is not a point on the curve, and one of low order, before any arithmetic touches it. Bob calling the same function with Alice's public key gets the same bytes.

Reading a key file

PEM is a label, base64, and two rules around it; DER is what the base64 decodes to. armor writes one and dearmor reads one, refusing a body that is not base64 rather than skipping the damage.

main.mbt
let pem = @key.armor("EC PRIVATE KEY", Bytes::make(48, b'\x42'))
let (label, der) = @key.dearmor(pem[:])
println(label)
println(der.length())
prints EC PRIVATE KEY 48

From there, parse_pkcs8, parse_spki, parse_sec1 and the PKCS#1 pair take the DER apart into numbers, and refuse a structure whose lengths do not add up.

Where randomness comes from

From the caller. No algorithm here reaches for an entropy source: a seed, a salt and a nonce are arguments, and the library never invents one. That is why every example on this page runs without a caveat, and why every test in the suite gives the same answer twice.

A fresh key takes a source instead: every private key type has generate(source), and X25519, X448 and ECDH have a generate(source) for their scalars. The source is any moonrand Source. Curve scalars are drawn as FIPS 186-5 A.2.2 draws them, from the same bytes as Go's ecdsa.GenerateKey.

If you need entropy

@system.source from moonbitstack/moonrand/system is the platform's. When the platform has none, the call raises Platform rather than falling back to a seeded generator; a test hands over a Replay of fixed bytes instead.

Where to go next

The API reference lists every public type and function, grouped by package. The showcase runs three of these ideas in the browser: the avalanche of a one-bit change, a tag that refuses a touched ciphertext, and a one-time password counting down.

The source is at moonbitstack/mooncrypt. moon run src/examples/tour prints one line from every family if you would rather read the output than the page.