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.

MethodReturnsDigest length
hash.md5(s)string32 hex chars (128-bit)
hash.sha1(s)string40 hex chars (160-bit)
hash.sha256(s)string64 hex chars (256-bit)
hash.sha512(s)string128 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 key may 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 aesEncrypt call, 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.
  • aesDecrypt verifies 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, where CC is the cost.
  • cost is 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 true on a match, false otherwise. Also accepts hashes written in the $2a / $2y variants (the digest is identical to $2b for 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/argon2 uses, so hashes written here are byte-compatible with Node’s. Pass explicit values to tune; omitted trailing arguments keep their default.
  • memoryCost is in KiB and dominates both security and cost. timeCost is the number of passes. parallelism is 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 true on a match, false on 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 bytes
  • data - the data that was signed (raw bytes, not base64)
  • n - RSA modulus (from a JWK n field)
  • e - RSA public exponent (from a JWK e field)

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

CallReturnsPurpose
hash.md5(s) / sha1 / sha256 / sha512stringHex message digest
crypto.hmacSha256(key, msg)stringKeyed hash / signature (hex)
crypto.base64Encode(s)stringEncode to base64
crypto.base64Decode(s)stringDecode from base64
crypto.randomBytes(n)stringn random bytes as 2n hex chars
crypto.aesEncrypt(key, plaintext)stringAES-256-GCM encrypt → base64
crypto.aesDecrypt(key, ciphertext)stringDecrypt; "" on auth failure
crypto.hashPassword(pw [, cost])stringbcrypt hash (60-char $2b$)
crypto.verifyPassword(pw, hash)boolVerify a password against a hash
crypto.rsaSha256Verify(sig, data, n, e)boolRS256 signature verify
jwt.sign(secret, payload)stringHS256 JWT (stdlib)
jwt.verify(secret, token)boolHS256 JWT verify (stdlib)
jwt.verifyRS256(jwk, token)boolRS256 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.