Cryptography Functions
Hash functions (SHA-256, SHA-512, MD5, HMAC), password hashing with Argon2, X25519 key exchange, Ed25519 signatures, TOTP codes, and Base64 encoding.
Crypto Class
All cryptographic functions are available as static methods on the Crypto class. Standalone function aliases are also provided for convenience.
Hash Functions
Not for password storage. SHA-256, SHA-512, and MD5 are general-purpose hashes — they are fast by design, which is exactly the wrong property for a password store (an attacker brute-forces them just as fast). For passwords, use Crypto.argon2_hash. For verifying tokens / MACs in constant time, use secure_compare.
Crypto.sha256(data)
Compute SHA-256 hash of a string. Also available as sha256(). Use for file checksums, ETags, content addressing — not for password hashing (use argon2_hash).
Parameters
data : String - The data to hash
Returns
String - 64-character hex string (32 bytes)
hash = Crypto.sha256("hello")
# "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
Crypto.sha512(data)
Compute SHA-512 hash of a string. Also available as sha512(). Same caveats as SHA-256 — not for password hashing (use argon2_hash).
Parameters
data : String - The data to hash
Returns
String - 128-character hex string (64 bytes)
hash = Crypto.sha512("hello")
# 128-character hex string
Crypto.md5(data)
Compute MD5 hash of a string. Also available as md5(). Cryptographically broken — collisions can be constructed cheaply. Use only for non-security checksums (e.g. content fingerprinting where adversarial collisions don't matter). Never use for passwords or signatures.
Parameters
data : String - The data to hash
Returns
String - 32-character hex string (16 bytes)
hash = Crypto.md5("hello")
# "5d41402abc4b2a76b9719d911017c592"
Crypto.hmac(message, key)
Compute HMAC-SHA256 message authentication code. Also available as hmac().
Parameters
message : String - The message to authenticatekey : String - The secret keyReturns
String - 64-character hex string (32 bytes)
mac = Crypto.hmac("message", "secret_key")
# Use for API signature verification, webhook validation, etc.
Crypto.secure_compare(a, b)
Constant-time string equality. Also available as secure_compare(). Use whenever comparing two values where one comes from an untrusted source and timing-leak of the comparison would help an attacker — verifying an HMAC, a webhook signature, a CSRF token, a session-derived MAC. A naïve a == b short-circuits at the first differing byte and leaks the prefix length to a timing attacker.
Parameters
a : Stringb : StringReturns
Bool - true only when both strings have the same length and bytes. Length is not secret; equal-length inputs run in time proportional only to the length, not the position of any differing byte.
expected = Crypto.hmac(payload, getenv("WEBHOOK_SECRET"))
if secure_compare(expected, request_signature)
# Trust the request
end
Secure Random
Cryptographically secure random values drawn from the operating system entropy source. All three take a byte count, not a character count, and reject anything outside 1..=1024.
Never use Math.random for secrets. It is a general-purpose PRNG, not a CSPRNG — its output is predictable to anyone who observes enough of it. Session tokens, password-reset links, OAuth state and API keys must all come from the functions below.
Crypto.random_hex(n)
Random bytes as lowercase hex. n is a byte count, so the result is 2n characters — Crypto.random_hex(32) returns a 64-character string, matching openssl rand -hex 32.
Parameters
n : Int — number of random bytesReturns
String# Generate a value for SOLI_ENCRYPTION_KEY or JWT_SECRET
Crypto.random_hex(32); # 64 hex characters
Crypto.random_bytes(n)
Raw random bytes, for composing with another encoding.
Returns
Array — n integers in 0..=255Base64.urlsafe_encode(Crypto.random_bytes(32))
Hex.encode(Crypto.random_bytes(16))
Crypto.random_token(n = 32)
Random bytes as unpadded URL-safe Base64. The default 32 bytes gives 256 bits of entropy in 43 URL-safe characters, so the value needs no escaping when it travels in a URL. This is the right primitive for OAuth state, PKCE verifiers, authorization codes and refresh tokens.
Returns
Stringstate = Crypto.random_token()
session_set("oauth_state", state)
# Store only the digest, compare in constant time later
token = Crypto.random_token()
user.reset_token_digest = Crypto.sha256(token)
Tamper-Evidence (Hash Chains & Merkle Trees)
Two building blocks for verifiable, append-only data — audit logs, provenance trails, and hash-chained ledgers.
Crypto.canonical_json(value)
Serializes a value to canonical JSON — object keys sorted lexicographically, recursively — so the same logical content always produces the same bytes, and therefore the same hash. Ordinary .to_json preserves insertion order, which is not stable enough to hash.
Parameters
value : Any - A JSON-shaped value. Opaque values (functions, instances, …) raise.
Returns
String - Deterministic JSON text.Crypto.canonical_json({ "b": 1, "a": 2 }) # {"a":2,"b":1}
Crypto.canonical_json({ "a": 2, "b": 1 }) # {"a":2,"b":1} (same bytes)
record = { "amount": 100, "to": "alice" }
hash = Crypto.sha256(Crypto.canonical_json(record))
Crypto.merkle_root(hashes)
Computes the Merkle root of an array of hex leaf hashes — a single hash proving the whole set. Nodes combine pairwise as sha256(left ‖ right); an odd node pairs with itself. Empty → hash of the empty string; a single leaf is its own root. The root changes if any leaf changes or is reordered.
Parameters
hashes : Array<String> - Hex hash strings (e.g. each record's sha256).
Returns
String - 64-character hex Merkle root.leaves = records.map(fn(r) Crypto.sha256(Crypto.canonical_json(r)))
root = Crypto.merkle_root(leaves)
# Publish `root`; re-derive it later to detect tampering.
Crypto.ledger_hash(prev_hash, seq, data)
The leaf hash of a hash-chained ledger record — a one-call shorthand for Crypto.sha256(prev_hash + ":" + str(seq) + ":" + Crypto.canonical_json(data)). A single definition, so the write path and the verifier share the exact same formula and can't drift apart.
Parameters
prev_hash : String - the previous record's hash (64 zeros for the genesis record)seq : Int - the record's monotonic sequence numberdata : Hash - the record's user fieldsReturns
String - 64-character hex hash.prev = "0000000000000000000000000000000000000000000000000000000000000000"
hash = Crypto.ledger_hash(prev, 0, { "amount": 100, "to": "alice" })
Tamper-evident ledgers. Chain records so each commits to the one before it — any later edit or deletion breaks the chain and is detectable by recomputing it. See the blog post Tamper-Evident Audit Logs in Soli for a complete, verifiable example.
Password Hashing
Crypto.argon2_hash(password)
Hash a password using Argon2id (recommended algorithm). Also available as argon2_hash() and password_hash().
Parameters
password : String - The plain text password to hash
Returns
String - The Argon2id hash string
hash = Crypto.argon2_hash("secretpassword")
# $argon2id$v=19$m=19456,t=2,p=1$...
Crypto.argon2_verify(password, hash)
Verify a password against an Argon2id hash. Also available as argon2_verify() and password_verify().
Parameters
password : String - The plain text password to verifyhash : String - The stored hash to verify againstReturns
Bool - true if password matches, false otherwise
if Crypto.argon2_verify(user_input, stored_hash)
println("Password correct!")
else
println("Invalid password")
end
Symmetric Encryption (AES-256-GCM)
Crypto.encrypt(plaintext, key?)
Authenticated encryption with AES-256-GCM. Returns base64 of nonce ‖ ciphertext+tag. The key defaults to the SOLI_ENCRYPTION_KEY environment variable; any string works (it's hashed to a 32-byte key), but use a long, high-entropy secret.
Crypto.decrypt(ciphertext, key?)
Reverses Crypto.encrypt. Throws if the key is wrong or the data was tampered with (GCM authentication).
let key = Crypto.random_hex(32)
let token = Crypto.encrypt("secret value", key)
Crypto.decrypt(token, key) # => "secret value"
For model fields, prefer the encrypts DSL, which encrypts on save and decrypts on load automatically. Note: GCM uses a random nonce, so ciphertext is non-deterministic — you can't match encrypted values in a query.
X25519 Key Exchange
Crypto.x25519_keypair()
Generate an X25519 key pair for Diffie-Hellman key exchange. Also available as x25519_keypair().
Returns
Hash - { "private": String, "public": String } (hex-encoded, 64 chars each)
keypair = Crypto.x25519_keypair
println(keypair["public"]) # Hex-encoded public key
println(keypair["private"]) # Hex-encoded private key
Crypto.x25519_public_key(private_key)
Derive the public key from a private key. Also available as x25519_public_key().
Parameters
private_key : String - Hex-encoded private key
Returns
String - Hex-encoded public key
keypair = Crypto.x25519_keypair
derived_public = Crypto.x25519_public_key(keypair["private"])
# derived_public == keypair["public"]
Ed25519 Signatures
Crypto.ed25519_keypair()
Generate an Ed25519 signing key pair for digital signatures. Also available as ed25519_keypair().
Returns
Hash - { "private": String, "public": String } (hex-encoded, 64 chars each)
keypair = Crypto.ed25519_keypair
# Use keypair["private"] to sign messages
# Share keypair["public"] for verification
ID Generation
Four ID generators: UUID v4 / v7 (RFC 4122), ULID, and NanoID. Use uuid_v7() or ulid() for time-sortable DB primary keys, uuid_v4() when you specifically don't want the timestamp leaking into the ID, and nanoid() for compact URL-safe random IDs with custom length / alphabet.
UUID.v4()
Generate a random (version 4) UUID with 122 bits of OS-CSPRNG randomness. Also available as uuid_v4().
Returns
String — 36-character hyphenated UUID
id = uuid_v4()
# "c74d395d-5b75-41c3-b873-ca597e1ccaac"
token = UUID.v4() # equivalent, via the UUID class
UUID.v7()
Generate a time-ordered (version 7) UUID. The high 48 bits are a Unix millisecond timestamp; the remaining bits are random. UUIDs minted in different milliseconds sort by creation time as strings — ideal for B-tree primary keys. Also available as uuid_v7().
Returns
String — 36-character hyphenated UUID
id = uuid_v7()
# "019e6ef2-ba58-7460-ad04-55e291a8c28b"
# Lexicographic order == creation order.
record_id = UUID.v7()
ULID.generate()
Generate a ULID — a 128-bit identifier encoded as 26 Crockford Base32 chars (high 48 bits are a millisecond timestamp, low 80 bits are random). Sorts by creation time as a string, no dashes, shorter than a UUID. Also available as ulid(), and ULID.new() as an alias.
Returns
String — 26-character Crockford Base32 ULID
id = ulid()
# "01KSQG6MAN1B4S05KRV29NWZPT"
record_id = ULID.generate()
NanoID.generate(size?, alphabet?)
Generate a NanoID — a short, URL-safe, cryptographically random ID. Defaults to 21 characters from the 64-char URL-safe alphabet (A-Z a-z 0-9 _ -), giving ~126 bits of entropy — comparable to a v4 UUID. Also available as nanoid(), and NanoID.new(...) as an alias.
Parameters
size : Int? — Length of the generated ID (1–1024). Default: 21.alphabet : String? — Character set to draw from (1–255 chars). Default: URL-safe 64-char alphabet.Returns
String — size-character random ID
id = nanoid()
# "XBwf0cjEQmwsx8YSQRCws"
short = nanoid(10)
# "hBbpwcRh4j"
# Human-friendly short codes (Crockford-style, no 0/O/1/I/L)
code = nanoid(8, "23456789ABCDEFGHJKMNPQRSTVWXYZ")
# "K7M3PQ2N"
slug = NanoID.generate(12)
Sizing tip. 21 chars at the default alphabet ≈ 126 bits of entropy. Drop to ~12 chars only when per-table uniqueness is enough; never go below 10 for anything security-sensitive.
TOTP (Time-based One-Time Password)
RFC 6238 compliant TOTP generation and verification. Compatible with Google Authenticator, Authy, and other authenticator apps.
Crypto.totp_generate(secret, time?, period?)
Generate a TOTP code (6-digit time-based one-time password). Uses HMAC-SHA1 per RFC 6238.
Parameters
secret : String - Base32-encoded secret keytime : Int? - Optional Unix timestamp (defaults to current time)period : Int? - Optional time window in seconds (defaults to 30)Returns
String - 6-digit TOTP code
# Generate code for current time
code = Crypto.totp_generate("JBSWY3DPEHPK3PXP")
# Generate code for specific time
code = Crypto.totp_generate("JBSWY3DPEHPK3PXP", 1704067200, 30)
# Use RFC 6238 test vector
code = Crypto.totp_generate("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 59, 30)
# Returns "287082"
Crypto.totp_verify(secret, code, time?, period?)
Verify a TOTP code against a secret. Accepts the current code and the previous/next code (1 step window) to handle clock drift.
Parameters
secret : String - Base32-encoded secret keycode : String - 6-digit TOTP code to verifytime : Int? - Optional Unix timestamp (defaults to current time)period : Int? - Optional time window in seconds (defaults to 30)Returns
Bool - true if code is valid, false otherwise
secret = "JBSWY3DPEHPK3PXP"
user_code = request.body["code"]
if Crypto.totp_verify(secret, user_code)
println("Authentication successful!")
else
println("Invalid code")
end
Crypto.totp_uri(secret, account_name?, issuer?, period?)
Generate an otpauth:// URI for easy TOTP setup in authenticator apps. This URI can be encoded into a QR code.
Parameters
secret : String - Base32-encoded secret keyaccount_name : String? - Optional account name (e.g., email)issuer : String? - Optional service name (e.g., "MyApp")period : Int? - Optional time window in seconds (defaults to 30)Returns
String - otpauth:// URI
secret = "JBSWY3DPEHPK3PXP"
uri = Crypto.totp_uri(secret, "user@example.com", "MyApp", 30)
# Returns: otpauth://totp/MyApp:user%40example.com?secret=...&algorithm=SHA1&digits=6&period=30
# Use with QR code library
qr_data = QRCode.encode(uri)
# Display qr_data to user for scanning
XML Digital Signature Primitives
Low-level building blocks for RSA signatures and XML-DSig / SAML / WS-Security: 2048-bit modular exponentiation, PKCS#1 v1.5 padding, and exclusive XML canonicalization. Octet inputs are hex strings (an optional 0x prefix is allowed) or arrays of byte-valued Ints; results are returned as hex strings.
Primitives, not a turn-key signer. These give you m^e mod n, the PKCS#1 padding frame, and a canonical byte stream — you compose the digest, the DigestInfo, and reference resolution yourself. Prefer a vetted library where one exists for your platform.
Crypto.modexp(base, exp, modulus)
Compute base^exp mod modulus over big-endian octet strings. The result is left-padded with zero octets to the modulus width (k = ceil(bits(modulus)/8)), matching the RSA convention where a signature or ciphertext is always k octets wide — so the output drops straight into the PKCS#1 helpers.
Parameters
base, exp, modulus : String | Array - big-endian octets (hex or byte array)
Returns
String - big-endian hex, k octets wide. Errors if the modulus is zero.
# 4^13 mod 497 = 445 (0x01bd); modulus 0x01f1 is 2 octets, so output is 2 octets
Crypto.modexp("04", "0d", "01f1") # => "01bd"
Crypto.pkcs1_pad(data, key_size, block_type?)
Apply PKCS#1 v1.5 padding (RFC 8017): EM = 0x00 || BT || PS || 0x00 || data, producing an encoded message exactly key_size octets long. block_type defaults to 1 (signature padding — PS is all 0xFF); pass 2 for encryption padding (PS is random non-zero octets). data must be at most key_size - 11 octets.
Parameters
data : String | Array - octets to padkey_size : Int - target octet length of the encoded messageblock_type : Int? - 1 (signatures, default) or 2 (encryption)
Returns
String - the key_size-octet encoded message as hex
Crypto.pkcs1_unpad(encoded_message)
Strip PKCS#1 v1.5 padding, returning the embedded data. Validates the 0x00 || BT prefix, the minimum 8-octet padding string, and (for block type 1) that every padding octet is 0xFF.
Returns
String - the recovered data octets as hex. Errors on malformed padding.
# RSA sign/verify round-trip: sign a digest with the private exponent d,
# verify by raising the signature to the public exponent e.
digest = Crypto.sha256(canonical_xml) # 32-byte hex digest
em = Crypto.pkcs1_pad(digest, key_octets) # block type 1
signature = Crypto.modexp(em, rsa_d, rsa_n) # sign
recovered = Crypto.pkcs1_unpad(Crypto.modexp(signature, rsa_e, rsa_n))
assert(recovered == digest)
Xml.c14n_exclusive(xml, inclusive_prefixes_or_options?)
Canonicalize an XML document with the W3C Exclusive XML Canonicalization 1.0 algorithm (http://www.w3.org/2001/10/xml-exc-c14n#) — the canonical form signed by XML-DSig. Empty elements expand to start/end pairs, attributes and namespace declarations are sorted, the XML declaration and comments are removed, and (the "exclusive" part) namespace declarations that are not visibly utilized are dropped rather than inherited from ancestors. DOCTYPE declarations are rejected (XXE defense).
Parameters
xml : String - the XML document to canonicalize2nd arg :
String | Array | Hash? - either the InclusiveNamespaces PrefixList directly (e.g. "ds saml", #default = default namespace), or an options hash: inclusive_prefixes, id (canonicalize only the subtree whose Id attribute matches — resolves Reference URI="#..."), enveloped_signature (Bool — drop any descendant <ds:Signature>).
Returns
String - the canonical UTF-8 serialization
Xml.c14n_exclusive("<?xml version=\"1.0\"?><doc b='2' a='1'/>")
# => "<doc a=\"1\" b=\"2\"></doc>"
# An ancestor namespace the signed element doesn't use (n2) is dropped:
xml = "<n0:r xmlns:n0=\"http://a\" xmlns:n2=\"http://c\"><n1:e xmlns:n1=\"http://b\">x</n1:e></n0:r>"
Xml.c14n_exclusive(xml)
# => "<n0:r xmlns:n0=\"http://a\"><n1:e xmlns:n1=\"http://b\">x</n1:e></n0:r>"
# Canonicalize the SAML-referenced element with its signature stripped:
Xml.c14n_exclusive(saml_response, {"id": "_assertion1", "enveloped_signature": true})
Comments are omitted (the no-comments form).
Xml.get_element_by_id(xml, id)
Return the element whose Id/ID/id attribute equals id, serialized as a standalone XML fragment with the inherited ancestor namespaces injected onto its root (so it re-parses and re-canonicalizes identically to the in-context subtree). Errors if no such element exists.
Returns
String - the element subtree as standalone XMLXml.get_elements_by_tag(xml, local_name)
Return every element with the given local name (namespace prefix ignored), each as a standalone XML fragment. Used to locate elements with no Id, such as <ds:SignedInfo>.
Returns
Array<String>X509.public_key(cert)
Parse an X.509 certificate and extract its RSA public key. Accepts PEM (-----BEGIN CERTIFICATE-----), bare base64 (as found in a SAML metadata <ds:X509Certificate>), hex, or a raw DER byte array.
Returns
Hash - { "algorithm": "RSA", "n": hex, "e": hex, "bits": Int }. The n/e hex strings drop straight into Crypto.modexp.key = X509.public_key(idp_metadata_cert)
em = Crypto.modexp(signature, key["e"], key["n"]) # RSA verify step
X509.fingerprint(cert, algorithm?)
Certificate fingerprint (hash of the DER bytes) as hex. algorithm is "sha256" (default) or "sha1". Fingerprints the whole certificate; for TLS pinning that survives renewal, pin the key with X509.spki_pin instead.
Returns
String - hex digestX509.spki_pin(cert)
The public-key pin for TLS certificate pinning: base64(SHA-256(SubjectPublicKeyInfo)), returned as "sha256/<base64>" — the exact form an Android Network Security Config <pin-set> or any HPKP-style pinner expects.
It pins the key, not the certificate, which is what lets a pinned client survive a certificate renewal: as long as the renewal reuses the key, the pin is unchanged. Pin the certificate (or its fingerprint) instead and the client breaks on every ~90-day rotation.
pin = X509.spki_pin(File.read("cert.pem"))
# => "sha256/UKm/R6MKhCiukXKhnWjBQSRBSWRwGQBLCCa/8w27Dxs="
Pinning is a footgun; treat it as one. A wrong or lost pin bricks the installed app with no server-side fix. Always ship a backup pin (a second, offline key), and for a public web app already on HSTS + Certificate Transparency, weigh whether the one threat it closes — a rogue or compromised CA — is worth the operational risk. Browsers removed HPKP for this reason. Soli gives you the pin string but does not wire pinning into the shells by default; that is a deliberate per-deployment decision.
Returns
String - "sha256/<base64>"Deflate.deflate(data) / Deflate.inflate(data)
Raw DEFLATE (RFC 1951, no zlib/gzip wrapper) — the compression used by the SAML 2.0 HTTP-Redirect binding. Byte conventions mirror Base64: deflate takes a String (its UTF-8 bytes) or byte array and returns a byte array; inflate returns a String when the result is valid UTF-8, else a byte array. Designed to pipe through Base64.
# Inbound: decode a SAMLRequest from a redirect URL
xml = Deflate.inflate(Base64.decode(params["SAMLRequest"]))
# Outbound: encode one
param = Base64.encode(Deflate.deflate(authn_request_xml))
RsaKey.private_from_pem(pem)
Parse an RSA private key — PKCS#8 (-----BEGIN PRIVATE KEY-----) or PKCS#1 (-----BEGIN RSA PRIVATE KEY-----) PEM — so a Service Provider can sign. (The verification side, X509.public_key, only yields (n, e).)
Returns
Hash - { "algorithm": "RSA", "n": hex, "e": hex, "d": hex, "bits": Int }. Sign with the private exponent: Crypto.modexp(padded, key["d"], key["n"]).Hex.encode(data) / Hex.decode(hex)
Bridges the hex world (Crypto.modexp / sha256 / pkcs1_* all speak hex) and the byte/base64 world (Base64, and XML-DSig's base64 DigestValue / SignatureValue). encode takes a String/byte-array → hex; decode takes a hex string (optional 0x prefix) → byte array.
# hex digest -> base64 DigestValue
digest_value = Base64.encode(Hex.decode(Crypto.sha256(canonical_xml)))
# incoming base64 -> hex (to compare against a Crypto.* result)
Hex.encode(Base64.decode(incoming_b64))
Putting it together: signing an envelope
key = RsaKey.private_from_pem(sp_private_key_pem)
# 1. Digest the referenced element (enveloped transform; no Signature yet)
ref_canon = Xml.c14n_exclusive(doc, {"id": "_obj1", "enveloped_signature": true})
digest_b64 = Base64.encode(Hex.decode(Crypto.sha256(ref_canon)))
# 2. Build SignedInfo (exc-c14n, rsa-sha256, Reference w/ enveloped+exc-c14n
# transforms and DigestValue=digest_b64), then sign its canonical form:
si_hash = Crypto.sha256(Xml.c14n_exclusive(signed_info))
em = Crypto.pkcs1_pad("3031300d060960864801650304020105000420" + si_hash, key["bits"] / 8)
sig_b64 = Base64.encode(Hex.decode(Crypto.modexp(em, key["d"], key["n"])))
# 3. Assemble <ds:Signature> and envelope it into the document.
# (Cross-checked: a signxml verifier accepts Soli-produced signatures.)
Putting it together: verifying a SAML signature
# 1. Verify the Reference digest (enveloped transform + by-id exclusive c14n)
canonical = Xml.c14n_exclusive(saml, {"id": assertion_id, "enveloped_signature": true})
if Crypto.sha256(canonical) != digest_value_hex { return false }
# 2. Verify the signature over SignedInfo
signed_info = Xml.get_elements_by_tag(saml, "SignedInfo")[0]
si_hash = Crypto.sha256(Xml.c14n_exclusive(signed_info))
key = X509.public_key(idp_cert)
recovered = Crypto.pkcs1_unpad(Crypto.modexp(signature_hex, key["e"], key["n"]))
# DigestInfo = SHA-256 DER prefix + the SignedInfo hash
recovered == "3031300d060960864801650304020105000420" + si_hash