mooncred
Mooncredthe guidei

The guide, in order.

Each sheet adds one thing to the last: a token, the ways it is refused, the settings behind a check, keys from JSON, then certificates and the files around them. Every listing is cut from one program, src/examples/guide, and every box under a listing is what that program prints.

$ moon add moonbitstack/mooncred

The keys come from mooncrypt and are passed in; mooncred chooses no algorithm and draws no randomness of its own.

Mooncredjwtii

A token, signed and checked.

The key is anything that signs. @jwt.mac turns a MAC into a signer and a verifier at once, so one HMAC secret both issues and checks. verify is told the algorithm, the time and what the claims must say; the token is not asked.

let secret = b"a shared secret nobody else has"
let key = @jwt.mac(fn() {
  @hmac.Hmac::new(secret[:], fn() { @sha2.Hasher::new() })
})
let claims : Map[String, Json] = {
  "sub": "heke1228",
  "iss": "moonbitstack",
  "aud": "moonapi",
  "exp": 1793235600,
}
let token = @jwt.sign(claims, HS256, key)
println(token)
let got = @jwt.verify(
  token[:],
  HS256,
  key,
  now=At(now),
  issuer="moonbitstack",
  audience=["moonapi"],
)
if got.get("sub") is Some(String(sub)) {
  println("subject: \{sub}")
}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJoZWtlMTIyOCIsImlzcyI6Im1vb25iaXRzdGFjayIsImF1ZCI6Im1vb25hcGkiLCJleHAiOjE3OTMyMzU2MDB9.XEBAfpv40yTEYS1th0JS4017XL1wOqY2zE9u-t3B3Cc
subject: heke1228
Mooncredjwtiii

What a verifier refuses.

A token naming another algorithm than the one the caller expects is refused before its signature is looked at; that closes both alg: none and the HMAC-with-a-public-key confusion. Time is checked unless the caller switches the clock off, and saying so is the only way to read a lapsed token.

// The same bytes, offered as another algorithm.
try @jwt.verify(token[:], HS384, key, now=At(now)) |> ignore catch {
  Algorithm(want~, got~) => println("refused: \{got} is not \{want}")
  _ => println("refused")
}
// Two hours on, the token has lapsed.
try @jwt.verify(token[:], HS256, key, now=At(now + 7200)) |> ignore catch {
  Expired(at~) => println("refused: expired at \{at.to_int64()}")
  _ => println("refused")
}
// Read anyway, with the clock switched off on purpose.
let old = @jwt.verify(token[:], HS256, key, now=Ignored)
println("read with no clock: \{old.length()} claims")
refused: HS256 is not HS384
refused: expired at 1793235600
read with no clock: 4 claims
Mooncredjwtiv

Settings, in three layers.

Everything a verification checks is a setting, given in a Policy assembled once or as an argument to one call. The more specific layer wins:

@jwt.policy<the policy passed<the arguments passed

wins=Base turns the last two around, so the record decides. clash says what happens when one setting arrives twice: Ignore merges quietly, Panic stops, Handle(f) hands both to your function. now is data, not a setting, so it is always an argument.

// Assembled once for a deployment.
let ours = @jwt.Policy::new(
  issuer="moonbitstack",
  audience=["moonapi"],
  leeway=30,
)
// The same record, written as an update of the preset.
let same : @jwt.Policy = {
  ..@jwt.policy,
  issuer: Some("moonbitstack"),
  audience: ["moonapi"],
  leeway: 30,
}
println("same policy: \{ours == same}")
// A hundred seconds past `exp`: outside the policy's leeway.
let late : @jwt.Clock = At(1793235700L)
// This one call allows two minutes; the argument beats the record.
let got = @jwt.verify(token[:], HS256, key, now=late, policy=ours, leeway=120)
println("accepted with leeway 120: \{got.length()} claims")
// `wins=Base` turns that around, and the record's 30 s stands.
try
  @jwt.verify(
    token[:],
    HS256,
    key,
    now=late,
    policy=ours,
    leeway=120,
    wins=Base,
  )
  |> ignore
catch {
  Expired(..) => println("refused with wins=Base: the policy decides")
  _ => println("refused")
}
same policy: true
accepted with leeway 120: 4 claims
refused with wins=Base: the policy decides
Mooncredjwkv

Keys from a JWK set.

jwk reads key material as bytes, not as keys: building a key means choosing an implementation, and the program is what knows which one it links. pick finds the key for an algorithm and an id; a verifier reads kid from the unverified header to know which one to fetch.

let set = @jwk.Set::parse(
  (
    #|{"keys":[{"kty":"OKP","crv":"Ed25519","kid":"2026-09","alg":"EdDSA",
    #|"d":"nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A",
    #|"x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}]}
  ),
)
guard set.pick(alg="EdDSA", id="2026-09") is Some(jwk) else { return }
guard jwk.material() is Okp(x~, secret=Some(seed), ..) else { return }
let signer = @ed25519.PrivateKey::new(seed[:])
let token = @jwt.sign({ "sub": "heke1228" }, EdDSA, signer, extra={
  "kid": "2026-09",
})
// A verifier reads `kid` first, fetches that key, then verifies.
guard @jwt.header(token[:]).get("kid") is Some(String(kid)) else { return }
guard set.by_id(kid) is Some(found) else { return }
guard found.material() is Okp(x=public, ..) else { return }
println("kid \{kid}, same key: \{public == x}")
let got = @jwt.verify(
  token[:],
  EdDSA,
  @ed25519.PublicKey::new(public[:]),
  now=At(now),
)
println("EdDSA verified: \{got.length()} claim")
kid 2026-09, same key: true
EdDSA verified: 1 claim
Mooncredtokenvi

A signed string for a cookie.

When the reader is your own server, a JWT is more than the job needs. token signs a value with a timestamp and an HMAC; the salt keeps a session cookie from passing as a password-reset link, max_age bounds its life, and rotate moves to a new key while the old one still reads.

let keys = @token.Keys::new(b"the key that signs session cookies")
let cookie = @token.sign(
  b"user=heke1228",
  keys~,
  salt="session",
  at=Some(now),
)
println(cookie)
let value = @token.verify(
  cookie[:],
  keys~,
  salt="session",
  max_age=Some(3600L),
  now~,
)
println("value: \{value.length()} bytes")
// A cookie signed for one purpose does not pass for another.
try @token.verify(cookie[:], keys~, salt="reset", now~) |> ignore catch {
  Signature => println("refused under salt \"reset\"")
  _ => println("refused")
}
// A new key signs from now on; cookies under the old one still read.
let next = keys.rotate(b"next quarter's session key")
let kept = @token.verify(cookie[:], keys=next, salt="session", now~)
println("after rotation: \{kept.length()} bytes")
dXNlcj1oZWtlMTIyOA.AAAAAGrijIA.TH9WhyXWbCafkk-MDH0K5PDDFyUhjeHiwgID1G6-HLc
value: 13 bytes
refused under salt "reset"
after rotation: 13 bytes
Mooncredx509vii

A certificate chain.

The certificates below were written by openssl: a test authority and a server certificate it issued for leaf.example.com. verify builds the path to a trust anchor, checks each signature, the validity period, the constraints, and the name the server was asked for.

let anchors = @x509.Roots::pem(authority)
let chain = [@x509.pem(good)]
let path = @x509.verify(chain[:], anchors, now~, host="leaf.example.com")
for cert in path {
  println(cert.subject.cn().unwrap_or("?"))
}
try
  @x509.verify(chain[:], anchors, now~, host="other.example.com") |> ignore
catch {
  Host(name) => println("refused: not issued for \{name}")
  _ => println("refused")
}
good.example.com
Mooncred Test CA
refused: not issued for other.example.com

One certificate, read.

parse reads a certificate down to its extensions; its fields are plain data.

let cert = @x509.parse(@x509.pem(good))
println("subject  \{cert.subject.cn().unwrap_or("?")}")
println("issuer   \{cert.issuer.cn().unwrap_or("?")}")
for name in cert.exts.san {
  if name is Dns(host) {
    println("dns      \{host}")
  }
}
println("serves leaf.example.com: \{cert.matches("leaf.example.com")}")
println("valid now: \{cert.valid_at(now)}")
let ca = @x509.parse(@x509.pem(authority))
println("signed by the CA: \{cert.signed_by(ca.spki)}")
subject  good.example.com
issuer   Mooncred Test CA
dns      leaf.example.com
serves leaf.example.com: true
valid now: true
signed by the CA: true
Mooncredx509 · csrviii

A certificate and a request.

For tests and local services, self_signed writes a P-256 certificate. What a real authority wants is a request: csr writes one signed by the key it asks a certificate for, and reads it back to check that signature.

// RFC 6979's P-256 test key; a real one comes from a random source.
let key = @ecdsa.PrivateKey::new(
  @base16.decode(
    "C9AFA9D845BA75166B5C215767B1D6934E50C3DB36E89B127B8A622B120F6721",
  ),
)
let der = @x509.self_signed(
  key, b"\x01", "moonbitstack.example", "261029000000Z", "271029000000Z",
)
let cert = @x509.parse(der)
println(
  "self-signed \{cert.subject.cn().unwrap_or("?")}: \{cert.signed_by(cert.spki)}",
)
// What a CA would ask for instead: a request, signed by the same key.
let pem = @csr.self_signed_pem(
  Ec(key),
  subject=@x509.common_name("moonbitstack.example"),
)
let request = @csr.Request::pem(pem)
println("request verified: \{request.verified()}")
println("asks for this key: \{request.asks_for(Ec(key.public()))}")
self-signed moonbitstack.example: true
request verified: true
asks for this key: true
Mooncredpbe · pkcs12ix

Keys at rest, and containers.

A private key on disk is kept behind a passphrase: PKCS#8 encrypted with PBES2, the form openssl writes. Salt and IV are arguments, because this library draws no randomness; in production they come from a random source such as moonrand.

// Salt and IV are arguments: fresh random bytes in production.
let pem = @x509.PrivateKey::Ec(key).encrypt_pem(
  passphrase="correct horse",
  salt=b"0123456789abcdef",
  iv=b"fedcba9876543210",
)
println(pem.split("\n").next().unwrap_or(""))
let back = @x509.PrivateKey::pem_encrypted(pem, passphrase="correct horse")
println(
  "same key: \{back.public() is Ec(public) && public.bytes() == key.public().bytes()}",
)
try
  @x509.PrivateKey::pem_encrypted(pem, passphrase="battery staple") |> ignore
catch {
  _ => println("wrong passphrase: refused")
}
-----BEGIN ENCRYPTED PRIVATE KEY-----
same key: true
wrong passphrase: refused

A PKCS#12 container.

A key and its chain travel together in one file, under the same passphrase and a MAC over the whole.

let pfx = @pkcs12.build(
  certs=[der],
  key=Some(@x509.PrivateKey::Ec(key).pkcs8()),
  passphrase="correct horse",
  salt=b"0123456789abcdef",
  iv=b"fedcba9876543210",
  mac_salt=b"mac salt",
)
let read = @pkcs12.parse(pfx)
println("MAC checks out: \{read.verify(passphrase="correct horse")}")
let inside = read.contents(passphrase="correct horse")
println(
  "\{inside.certs.length()} certificate, key inside: \{inside.key is Some(_)}",
)
MAC checks out: true
1 certificate, key inside: true
Mooncredrevokex

Revocation.

revoke reads a revocation list or an OCSP answer and judges a certificate by it. Fetching either is the caller's business: nothing here opens a socket. A list is only trusted when its issuer signed it and it has not aged past its next update.

let list = @revoke.Crl::pem(crl)
let ca = @x509.parse(@x509.pem(authority))
println("list signed by the CA: \{list.signed_by(ca.spki)}")
println("list still fresh: \{list.fresh(now)}")
for name in ["good", "revoked"] {
  let cert = @x509.parse(
    @x509.pem(if name == "good" { good } else { revoked }),
  )
  match list.status(cert) {
    Good => println("\{name}: good")
    Revoked(_) => println("\{name}: revoked")
    Unknown => println("\{name}: unknown")
  }
}
list signed by the CA: true
list still fresh: true
good: good
revoked: revoked
Mooncredjwe · cosexi

JWE and COSE.

A JWT is signed, not hidden. When the claims must stay private, jwe encrypts them: here directly under a shared key with AES-GCM, the IV again an argument.

let shared = b"a 32-byte key for A256GCM, here."
let jwe = @jwe.encrypt(
  b"card ending 4242",
  key=Shared(shared),
  alg=Dir,
  enc=A256Gcm,
  iv=b"twelve bytes",
)
println(jwe)
let opened = @jwe.decrypt(jwe[:], key=Shared(shared), alg=Dir, enc=A256Gcm)
println("opened: \{opened == b"card ending 4242"}")
eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..dHdlbHZlIGJ5dGVz.ei65CyO_XY2BQcdXOYzFkA.79HmCUv3Pctq4OGRlubD7Q
opened: true

A CBOR Web Token.

COSE is the same idea written in CBOR, for devices that count bytes. A CWT carries the claims a JWT would, signed here with Ed25519.

// RFC 8032's first Ed25519 test key.
let signer = @ed25519.PrivateKey::new(
  @base16.decode(
    "9D61B19DEFFD5A60BA844AF492EC2CC44449C5697B326919703BAC031CAE7F60",
  ),
)
let claims = @cose.Claims::new(
  iss="moonbitstack",
  sub="heke1228",
  exp=1793235600L,
)
let token = @cose.signed(claims, alg=EdDsa, signer)
println("a CWT of \{token.length()} bytes")
let back = @cose.verified(token, alg=EdDsa, signer.public(), now=At(now))
println("issuer: \{back.iss.unwrap_or("?")}")
a CWT of 106 bytes
issuer: moonbitstack
Mooncredrootsxii

Trust anchors.

To check a public server, the anchors are the Mozilla CA store, built from curl's extract. Build it once and keep it; pass it to @x509.verify in place of the test authority above.

let mozilla = @roots.mozilla()
println("\{mozilla.length()} anchors, Mozilla store of \{@roots.version}")
118 anchors, Mozilla store of 2026-10-20

Every type and function used here, and the ones that were not, are in the reference.