radlang — Cryptography & Hashing
This document covers radlang’s built-in cryptographic support: the hash and crypto namespaces. Together they provide message digests, HMAC, base64,
random bytes, AES-256-GCM authenticated encryption, and bcrypt/argon2 password
hashing.
For language fundamentals see the Overview.
Overview
Cryptography in radlang is exposed through two compiler-intrinsic namespaces:
hash— one-way message digests (MD5, SHA-1, SHA-256, SHA-512) and password hashing (bcrypt, argon2).crypto— HMAC, base64, random bytes, AES-256-GCM encryption, and legacy aliases for password hashing.
Both are built into the compiler and dispatched directly to the runtime. Like math and fs, they need no import — the names hash and crypto are
reserved and available everywhere. The jwt namespace (in the stdlib package)
builds on these for JSON Web Token signing and verification:
var digest = hash.sha256("hello")
var token = crypto.randomBytes(16) All algorithms are implemented in self-contained C shipped with the compiler. No external crypto library (OpenSSL/libcrypto) is linked, so binaries have no extra runtime dependency.
Text vs. binary. Every function here takes and returns a radlang
string, and strings are NUL-terminated at the runtime level. Inputs are read up to the first NUL byte, so these functions are intended for text and typical binary-as-base64 workflows, not for hashing/encrypting raw buffers that contain embedded NUL bytes. Base64 and AES outputs are always NUL-free.
The hash namespace
One-way cryptographic digests. Each takes a string and returns its
lowercase hex digest as a string.
| Method | Returns | Digest length |
|---|---|---|
hash.md5(s) | string | 32 hex chars (128-bit) |
hash.sha1(s) | string | 40 hex chars (160-bit) |
hash.sha256(s) | string | 64 hex chars (256-bit) |
hash.sha512(s) | string | 128 hex chars (512-bit) |
fn main() {
sys.output(hash.md5("abc")) // 900150983cd24fb0d6963f7d28e17f72
sys.output(hash.sha1("abc")) // a9993e364706816aba3e25717850c26c9cd0d89d
sys.output(hash.sha256("abc")) // ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
sys.output(hash.sha512("")) // cf83e1357eefb8bd... (128 chars)
} Security note. MD5 and SHA-1 are provided for compatibility and checksums only — they are broken for collision resistance. Use SHA-256 or SHA-512 for anything security-sensitive, and never use a bare digest for passwords (see Password hashing).
The crypto namespace
HMAC
crypto.hmacSha256(key: string, message: string) : string Keyed hash (RFC 2104) using SHA-256. Returns a 64-char lowercase hex MAC. Suitable for message authentication and signing tokens.
fn main() {
string mac = crypto.hmacSha256("key", "The quick brown fox jumps over the lazy dog")
sys.output(mac)
// f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
} To verify an HMAC, recompute it and compare:
fn verifySignature(secret: string, body: string, provided: string) : bool {
return crypto.hmacSha256(secret, body) == provided
} Base64
crypto.base64Encode(s: string) : string
crypto.base64Decode(s: string) : string Standard RFC 4648 alphabet (A–Z a–z 0–9 + /) with = padding. base64Decode skips whitespace and any character outside the alphabet, and treats padding as
optional.
fn main() {
string enc = crypto.base64Encode("hello") // aGVsbG8=
string dec = crypto.base64Decode(enc) // hello
sys.output(enc)
sys.output(dec)
// Round-trips cleanly:
sys.output(crypto.base64Decode(crypto.base64Encode("radlang!"))) // radlang!
} Random bytes
crypto.randomBytes(n: int) : string Returns n cryptographically-random bytes, encoded as 2n lowercase hex
characters. Reads from /dev/urandom. Ideal for tokens, salts, and nonces.
fn main() {
string token = crypto.randomBytes(16) // 32 hex chars, e.g. "9f86d0818...c2b"
sys.output(token)
sys.output(token.length().toString()) // 32
} Encryption (AES-256-GCM)
crypto.aesEncrypt(key: string, plaintext: string) : string
crypto.aesDecrypt(key: string, ciphertext: string) : string Authenticated encryption using AES-256 in GCM mode. GCM provides both confidentiality and integrity — a tampered or wrong-key ciphertext fails to decrypt rather than returning garbage.
How it works
- The
keymay be any string. It is stretched to a 32-byte AES key with SHA-256, so keys of any length are accepted. - A fresh random 12-byte IV is generated on every
aesEncryptcall, so encrypting the same message twice yields different ciphertexts. - The returned string is base64 of
iv (12 bytes) ‖ ciphertext ‖ auth tag (16 bytes). It is safe to store or transmit as text. aesDecryptverifies the 16-byte GCM tag in constant time. On any failure — wrong key, corrupted data, truncated input — it returns the empty string"".
fn main() {
string key = "correct horse battery staple"
string secret = crypto.aesEncrypt(key, "attack at dawn")
sys.output(secret) // base64, different every run
string back = crypto.aesDecrypt(key, secret)
sys.output(back) // attack at dawn
// Wrong key → empty string, never garbage:
string bad = crypto.aesDecrypt("guessed-key", secret)
sys.output(bad == "" ? "auth failed" : bad) // auth failed
} Checking for failure
Because failure yields "", distinguish it from a legitimately empty message
by checking the result:
fn tryDecrypt(key: string, blob: string) : bool {
string plain = crypto.aesDecrypt(key, blob)
if plain == "" {
sys.output("could not decrypt (wrong key or tampered)")
return false
}
sys.output("decrypted: " + plain)
return true
} Caveats.
- The key is derived with a plain
SHA-256(key)— great for high-entropy/random keys. If you must encrypt under a user password, derive a key with a slow KDF first (bcrypt below covers verification; a dedicated PBKDF2/scrypt KDF is not yet built in).- Because
""signals auth failure, an empty plaintext also decrypts to"". If empty messages are meaningful in your protocol, wrap them (e.g. prefix a marker byte before encrypting).- Additional authenticated data (AAD) is not currently exposed.
Password hashing
Password hashing lives under hash.bcrypt and hash.argon2, with per-algorithm
hash and verify methods. Never store a bare hash.sha256 of a password.
bcrypt (hash.bcrypt)
hash.bcrypt(password: string) : string
hash.bcrypt(password: string, cost: int) : string
hash.bcrypt.verify(password: string, hash: string) : bool bcrypt is deliberately slow and salted, defeating brute-force and rainbow-table attacks. It is a sound choice; argon2 is the stronger one, being memory-hard as well as slow.
hash.bcrypt
- Generates a random 16-byte salt on every call, so hashing the same password twice produces different hashes.
- Returns a 60-character bcrypt string in the standard
$2b$format:$2b$CC$followed by the encoded salt and digest, whereCCis the cost. costis the log2 work factor (valid range 4-31, clamped). It defaults to 10. Each +1 doubles the work. Higher is more secure but slower; 10-12 is a common choice.
hash.bcrypt.verify
- Reads the cost and salt out of the stored
hash, recomputes, and compares in constant time. - Returns
trueon a match,falseotherwise. Also accepts hashes written in the$2a/$2yvariants (the digest is identical to$2bfor normal-length passwords).
fn main() {
string stored = hash.bcrypt("hunter2")
sys.output(stored)
// e.g. $2b$10$N9qo8uLOickgx2ZMRZoMy.MH.rB... (60 chars, unique per call)
if hash.bcrypt.verify("hunter2", stored) {
sys.output("welcome back")
} else {
sys.output("invalid credentials")
}
sys.output(hash.bcrypt.verify("wrong", stored) ? "ok" : "rejected") // rejected
} Custom cost:
fn main() {
string h = hash.bcrypt("s3cret", 12)
sys.output(h.startsWith("$2b$12$") ? "cost 12" : "?") // cost 12
sys.output(hash.bcrypt.verify("s3cret", h) ? "ok" : "no") // ok
} argon2 (hash.argon2)
hash.argon2(password: string) : string
hash.argon2(password: string, memoryCost: int) : string
hash.argon2(password: string, memoryCost: int, timeCost: int) : string
hash.argon2(password: string, memoryCost: int, timeCost: int, parallelism: int) : string
hash.argon2.verify(password: string, hash: string) : bool argon2id is the memory-hard alternative to bcrypt: alongside CPU time it forces an attacker to commit a tunable amount of RAM per guess, which is what blunts GPU and ASIC cracking. Prefer it over bcrypt for new systems, and use it when you must interoperate with an existing argon2 deployment.
hash.argon2
- Hashes as argon2id and returns the standard PHC string
$argon2id$v=19$m=M,t=T,p=P$salt$tag. - Generates a fresh random 16-byte salt on every call, so the same password hashes to a different string each time. The tag is 32 bytes.
- Cost parameters default to m=19456 KiB (19 MiB), t=2, p=1 — the OWASP
recommended second-choice profile, and the same defaults
@node-rs/argon2uses, so hashes written here are byte-compatible with Node’s. Pass explicit values to tune; omitted trailing arguments keep their default. memoryCostis in KiB and dominates both security and cost.timeCostis the number of passes.parallelismis the lane count.
hash.argon2.verify
- Accepts argon2id, argon2i, and argon2d hashes — it reads the variant, version, and all three cost parameters back out of the encoded string, so it verifies hashes written by other systems (Node.js, Python, PHP) regardless of the parameters they chose.
- Allocates the full working memory (m KiB), recomputes, and compares in constant time.
- Returns
trueon a match,falseon mismatch or malformed input.
fn main() {
string h = hash.argon2("s3cret")
sys.output(h) // $argon2id$v=19$m=19456,t=2,p=1$<salt>$<tag>
sys.output(hash.argon2.verify("s3cret", h) ? "ok" : "no") // ok
// Tuned: 64 MiB, 3 passes, 4 lanes.
string strong = hash.argon2("s3cret", 65536, 3, 4)
sys.output(hash.argon2.verify("s3cret", strong) ? "ok" : "no") // ok
// A hash produced by another system verifies with its own parameters.
string stored = "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$..."
if hash.argon2.verify("user_password", stored) {
sys.output("verified")
}
} Interoperating with an existing argon2 system. Because the defaults match @node-rs/argon2, a radlang service and a Node service can share one user
table and write hashes each other can verify — no migration step and no
dual-format window. Keep storing argon2 rather than re-hashing to bcrypt:
fn login(string password, string storedHash): bool {
// Verify against whatever format is on the row; never rewrite an argon2
// hash to bcrypt if another service still has to read it.
if storedHash.startsWith("$argon2") {
return hash.argon2.verify(password, storedHash)
}
return hash.bcrypt.verify(password, storedHash)
}
fn register(string password): string {
return hash.argon2(password)
} crypto.verifyPassword does that format detection for you (see Legacy aliases).
Legacy aliases
crypto.hashPassword and crypto.verifyPassword still work and map to hash.bcrypt and hash.bcrypt.verify respectively. crypto.verifyPassword auto-detects argon2 hashes by their $argon2 prefix, so it verifies both
formats — useful when a table holds a mix. There is no crypto alias for
argon2 hashing; call hash.argon2 directly. New code should use the hash.* API.
Caveats.
- bcrypt ignores everything past the first 72 bytes of the password (an inherent property of the algorithm). For very long passphrases, pre-hash with SHA-256 and base64 the result before calling
hash.bcrypt.- The salt is embedded in the returned hash. Store the whole 60-char string, and pass exactly that string back to
hash.bcrypt.verify. You do not manage salts yourself.
RSA signature verification
crypto.rsaSha256Verify(sig: string, data: string, n: string, e: string) : bool Verifies an RSASSA-PKCS1-v1.5 + SHA-256 (RS256) signature. All parameters are base64url-encoded strings:
sig- the signature bytesdata- the data that was signed (raw bytes, not base64)n- RSA modulus (from a JWKnfield)e- RSA public exponent (from a JWKefield)
Returns true if the signature is valid, false otherwise. Uses BearSSL’s br_rsa_i31_pkcs1_vrfy internally. Supports key sizes up to 8192-bit.
This is a low-level primitive. For JWT verification, use jwt.verifyRS256 from the stdlib instead.
JWT (stdlib)
The jwt namespace lives in the stdlib package (import { jwt } from stdlib)
and provides JSON Web Token signing and verification for both HS256 (symmetric)
and RS256 (asymmetric) algorithms.
HS256 (symmetric)
jwt.sign(secret: string, payloadJson: string) : string
jwt.verify(secret: string, token: string) : bool Sign and verify tokens using HMAC-SHA256. Build the payload with json.from:
import { jwt } from stdlib
string token = jwt.sign(secret, json.from({ sub: "u1", exp: jwt.expiresAt(3600) }))
bool valid = jwt.verify(secret, token) RS256 (asymmetric)
jwt.verifyRS256(publicKeyJson: string, token: string) : bool Verify an RS256-signed JWT using a JWK public key. The key JSON must contain n (modulus) and e (exponent) fields in base64url encoding. Returns true only when the signature is valid and the token is not expired.
import { jwt } from stdlib
type JWK { string kty, string n, string e }
string key = json.from(JWK("RSA", "xu0awC...", "AQAB"))
bool valid = jwt.verifyRS256(key, idToken) Use this to verify tokens from external identity providers (Google, Apple, Auth0, etc.) by fetching their JWKS endpoint and extracting the matching key.
Claims
jwt.payload(token: string) : string // decoded payload JSON (no verification)
jwt.claim(token: string, name: string) : string
jwt.claimInt(token: string, name: string) : int
jwt.now() : int // Unix seconds
jwt.expiresAt(ttlSeconds: int) : int // now() + ttl Quick reference
| Call | Returns | Purpose |
|---|---|---|
hash.md5(s) / sha1 / sha256 / sha512 | string | Hex message digest |
crypto.hmacSha256(key, msg) | string | Keyed hash / signature (hex) |
crypto.base64Encode(s) | string | Encode to base64 |
crypto.base64Decode(s) | string | Decode from base64 |
crypto.randomBytes(n) | string | n random bytes as 2n hex chars |
crypto.aesEncrypt(key, plaintext) | string | AES-256-GCM encrypt → base64 |
crypto.aesDecrypt(key, ciphertext) | string | Decrypt; "" on auth failure |
crypto.hashPassword(pw [, cost]) | string | bcrypt hash (60-char $2b$) |
crypto.verifyPassword(pw, hash) | bool | Verify a password against a hash |
crypto.rsaSha256Verify(sig, data, n, e) | bool | RS256 signature verify |
jwt.sign(secret, payload) | string | HS256 JWT (stdlib) |
jwt.verify(secret, token) | bool | HS256 JWT verify (stdlib) |
jwt.verifyRS256(jwk, token) | bool | RS256 JWT verify (stdlib) |
Picking the right tool
- Integrity checksum / dedup key →
hash.sha256. - Signing or verifying a payload with a shared secret →
crypto.hmacSha256. - Encoding binary as text →
crypto.base64Encode/base64Decode. - Tokens, salts, nonces →
crypto.randomBytes. - Encrypting data you need to read back →
crypto.aesEncrypt/aesDecrypt. - Storing user passwords →
crypto.hashPassword/verifyPassword.