neyuki 0.6.3

A small Lua-like scripting language implemented in Rust
local table = require("@neyuki/table")

local crypto = {}

-- Every primitive here is native (src/vm/libs/crypto.rs, on the RustCrypto and
-- dalek crates); this module validates arguments and fills in defaults.
--
-- Binary values are strings in an `encoding`: "hex", "base64" or
-- "base64url". Digests default to hex, everything else (keys, ciphertexts,
-- signatures, random bytes) to base64. Asymmetric keys are PEM strings.

const ENCODINGS = { hex = true, base64 = true, base64url = true }

function checkString(s: string, name: string)
    if (s == nil) then error(name .. " must be provided") end
    if (type(s) != "string") then error(name .. " must be a string") end
end

function checkOptionalString(s: string?, name: string)
    if (s != nil) then checkString(s, name) end
end

function checkOptionalNumber(n: number?, name: string)
    if (n != nil and type(n) != "number") then error(name .. " must be a number") end
end

function checkOptionalTable(t: {any}?, name: string)
    if (t != nil and type(t) != "table") then error(name .. " must be a table") end
end

function checkEncoding(encoding: string, name: string)
    checkString(encoding, name)
    if (ENCODINGS[encoding] == nil) then
        error(name .. " must be hex, base64 or base64url")
    end
end

-- Reads `options[key]` with a default, checking its type against the
-- default's type when both are present.
function option(options: {[string]: any}, key: string, default: any): any
    local value = options[key]
    if (value == nil) then return default end
    if (default != nil and type(value) != type(default)) then
        error(key .. " must be a " .. type(default))
    end
    return value
end

function encodingOption(options: {[string]: any}, default: string): string
    local encoding = option(options, "encoding", default)
    checkEncoding(encoding, "encoding")
    return encoding
end

-- HASHING ------------------------------------------------------------------

-- Digest of the UTF-8 bytes of `s`. Algorithms: sha256 (default), sha224,
-- sha384, sha512, sha3-256, sha3-512, blake2b, blake2s, sha1 and md5 (the
-- last two only for interoperability; do not use them for anything new).
function crypto.hash(s: string, algorithm: string?, encoding: string?): string
    checkString(s, "s")
    checkOptionalString(algorithm, "algorithm")
    if (encoding != nil) then checkEncoding(encoding, "encoding") end
    return __crypto_hash(algorithm ?? "sha256", s, encoding ?? "hex")
end

-- Keyed digest (HMAC) of `s`. Takes the same algorithms as `hash` except
-- blake2b and blake2s.
function crypto.hmac(s: string, key: string, algorithm: string?, encoding: string?): string
    checkString(s, "s")
    checkString(key, "key")
    checkOptionalString(algorithm, "algorithm")
    if (encoding != nil) then checkEncoding(encoding, "encoding") end
    return __crypto_hmac(algorithm ?? "sha256", key, s, encoding ?? "hex")
end

-- Compares two strings in constant time. Use it for MACs, tokens and
-- password reset codes, where `==` would leak how much of a guess matched.
function crypto.equals(a: string, b: string): boolean
    checkString(a, "a")
    checkString(b, "b")
    return __crypto_equals(a, b)
end

-- KEY DERIVATION -------------------------------------------------------------

-- Stretches a password into a key with PBKDF2. `options` may hold:
--   algorithm   the HMAC digest (default "sha256")
--   iterations  the work factor (default 600000, per OWASP for sha256)
--   length      key length in bytes (default 32)
--   encoding    of the result (default "base64")
-- Prefer `hashPassword` for storing passwords; pbkdf2 is for deriving keys
-- and for matching systems that already use it.
function crypto.pbkdf2(password: string, salt: string, options: {[string]: any}?): string
    checkString(password, "password")
    checkString(salt, "salt")
    checkOptionalTable(options, "options")
    options = options ?? {}
    return __crypto_pbkdf2(
        option(options, "algorithm", "sha256"),
        password,
        salt,
        option(options, "iterations", 600000),
        option(options, "length", 32),
        encodingOption(options, "base64")
    )
end

-- Derives a key from key material that is already random (a shared secret,
-- a master key) with HKDF. `options` may hold `algorithm` (default
-- "sha256"), `salt`, `info` (what the key is for, so different purposes get
-- different keys), `length` (default 32) and `encoding` (default "base64"),
-- which applies to both `key` and the result.
function crypto.hkdf(key: string, options: {[string]: any}?): string
    checkString(key, "key")
    checkOptionalTable(options, "options")
    options = options ?? {}
    return __crypto_hkdf(
        option(options, "algorithm", "sha256"),
        key,
        option(options, "salt", ""),
        option(options, "info", ""),
        option(options, "length", 32),
        encodingOption(options, "base64")
    )
end

-- PASSWORDS ----------------------------------------------------------------

-- Hashes a password for storage, salted, into a self-describing string that
-- `verifyPassword` checks later. `options` may hold:
--   algorithm    "argon2id" (default) or "bcrypt"
--   cost         argon2 iterations (default 2) or bcrypt log2 rounds (default 12)
--   memory       argon2 memory in KiB (default 19456)
--   parallelism  argon2 lanes (default 1)
-- bcrypt only reads the first 72 bytes of a password, so longer ones are
-- rejected rather than silently truncated.
function crypto.hashPassword(password: string, options: {[string]: any}?): string
    checkString(password, "password")
    checkOptionalTable(options, "options")
    options = options ?? {}
    local algorithm = option(options, "algorithm", "argon2id")
    local defaultCost = 2
    if (algorithm == "bcrypt") then defaultCost = 12 end
    return __crypto_password_hash(
        algorithm,
        password,
        option(options, "cost", defaultCost),
        option(options, "memory", 19456),
        option(options, "parallelism", 1)
    )
end

-- Checks a password against a hash from `hashPassword` (or any other
-- argon2 / bcrypt implementation). Returns false on a mismatch and errors
-- only when `hash` is not a recognisable hash.
function crypto.verifyPassword(password: string, hash: string): boolean
    checkString(password, "password")
    checkString(hash, "hash")
    return __crypto_password_verify(password, hash)
end

-- SYMMETRIC ENCRYPTION -------------------------------------------------------

-- Makes a random key for `encrypt`. Ciphers: aes-256-gcm (default),
-- aes-128-gcm, chacha20-poly1305 and xchacha20-poly1305. `options` may hold
-- the key's `encoding` (default "base64"); pass the same encoding to
-- `encrypt` and `decrypt`.
function crypto.generateKey(algorithm: string?, options: {[string]: any}?): string
    checkOptionalString(algorithm, "algorithm")
    checkOptionalTable(options, "options")
    return __crypto_generate_key(algorithm ?? "aes-256-gcm", encodingOption(options ?? {}, "base64"))
end

-- Encrypts and authenticates `s` with a key from `generateKey` (or
-- `pbkdf2` / `hkdf`). A fresh random nonce is generated every call and
-- stored with the ciphertext, so the same input never encrypts the same
-- way twice. `options` may hold:
--   algorithm  the cipher (default "aes-256-gcm")
--   aad        extra data that is authenticated but not encrypted, such as
--              a record id; `decrypt` must be given the same value
--   encoding   of the key and ciphertext (default "base64")
function crypto.encrypt(s: string, key: string, options: {[string]: any}?): string
    checkString(s, "s")
    checkString(key, "key")
    checkOptionalTable(options, "options")
    options = options ?? {}
    return __crypto_encrypt(
        option(options, "algorithm", "aes-256-gcm"),
        key,
        s,
        option(options, "aad", ""),
        encodingOption(options, "base64")
    )
end

-- Reverses `encrypt`, taking the same options. Errors if the key is wrong,
-- the data was tampered with or the `aad` differs.
function crypto.decrypt(s: string, key: string, options: {[string]: any}?): string
    checkString(s, "s")
    checkString(key, "key")
    checkOptionalTable(options, "options")
    options = options ?? {}
    return __crypto_decrypt(
        option(options, "algorithm", "aes-256-gcm"),
        key,
        s,
        option(options, "aad", ""),
        encodingOption(options, "base64")
    )
end

-- ASYMMETRIC KEYS ------------------------------------------------------------

-- Generates a key pair as `{ publicKey, privateKey }` PEM strings (PKCS#8
-- and SPKI, so OpenSSL and other languages read them). Algorithms:
--   ed25519  (default) signing; small, fast keys
--   x25519   key agreement with `sharedSecret`
--   rsa      signing and `publicEncrypt`; `options.bits` defaults to 2048
function crypto.generateKeyPair(algorithm: string?, options: {[string]: any}?): KeyPair
    checkOptionalString(algorithm, "algorithm")
    checkOptionalTable(options, "options")
    local privateKey, publicKey = __crypto_generate_keypair(
        algorithm ?? "ed25519",
        option(options ?? {}, "bits", 2048)
    )
    return table.freeze({ publicKey = publicKey, privateKey = privateKey })
end

-- The public half of a private key PEM.
function crypto.publicKey(privateKey: string): string
    checkString(privateKey, "privateKey")
    return __crypto_public_key(privateKey)
end

-- Signs `s` with an ed25519 or RSA private key. `options` may hold
-- `encoding` of the signature (default "base64") and, for RSA, `hash`
-- (default "sha256") and `padding` ("pss", the default, or "pkcs1" for
-- older systems).
function crypto.sign(s: string, privateKey: string, options: {[string]: any}?): string
    checkString(s, "s")
    checkString(privateKey, "privateKey")
    checkOptionalTable(options, "options")
    options = options ?? {}
    return __crypto_sign(
        privateKey,
        s,
        option(options, "hash", "sha256"),
        option(options, "padding", "pss"),
        encodingOption(options, "base64")
    )
end

-- Checks a signature from `sign`, taking the same options. Returns false
-- for a bad or malformed signature. Accepts a private key in place of the
-- public one.
function crypto.verify(s: string, signature: string, publicKey: string, options: {[string]: any}?): boolean
    checkString(s, "s")
    checkString(signature, "signature")
    checkString(publicKey, "publicKey")
    checkOptionalTable(options, "options")
    options = options ?? {}
    return __crypto_verify(
        publicKey,
        s,
        signature,
        option(options, "hash", "sha256"),
        option(options, "padding", "pss"),
        encodingOption(options, "base64")
    )
end

-- Encrypts a short message (at most the key size minus overhead, 190 bytes
-- for a 2048-bit key) to an RSA public key with OAEP. To send anything
-- larger, encrypt it with `encrypt` and wrap only that key here. `options`
-- may hold `hash` (default "sha256") and `encoding` (default "base64").
function crypto.publicEncrypt(s: string, publicKey: string, options: {[string]: any}?): string
    checkString(s, "s")
    checkString(publicKey, "publicKey")
    checkOptionalTable(options, "options")
    options = options ?? {}
    return __crypto_public_encrypt(
        publicKey,
        s,
        option(options, "hash", "sha256"),
        encodingOption(options, "base64")
    )
end

-- Reverses `publicEncrypt` with the matching private key. RSA private-key
-- operations here are not constant-time (RUSTSEC-2023-0071): don't decrypt
-- or sign with RSA in a service that answers requests an attacker can time.
-- Prefer ed25519 and x25519 there.
function crypto.privateDecrypt(s: string, privateKey: string, options: {[string]: any}?): string
    checkString(s, "s")
    checkString(privateKey, "privateKey")
    checkOptionalTable(options, "options")
    options = options ?? {}
    return __crypto_private_decrypt(
        privateKey,
        s,
        option(options, "hash", "sha256"),
        encodingOption(options, "base64")
    )
end

-- X25519 key agreement: both sides get the same secret from their own
-- private key and the other's public key. Run it through `hkdf` (with an
-- `info` naming the purpose) before using it as an encryption key.
function crypto.sharedSecret(privateKey: string, publicKey: string, options: {[string]: any}?): string
    checkString(privateKey, "privateKey")
    checkString(publicKey, "publicKey")
    checkOptionalTable(options, "options")
    return __crypto_shared_secret(privateKey, publicKey, encodingOption(options ?? {}, "base64"))
end

-- RANDOMNESS -----------------------------------------------------------------

-- `n` bytes from the operating system's secure generator (default encoding
-- "base64"). Use this, not `math.random`, for tokens, salts and keys.
function crypto.randomBytes(n: number, encoding: string?): string
    if (type(n) != "number") then error("n must be a number") end
    if (encoding != nil) then checkEncoding(encoding, "encoding") end
    return __crypto_random_bytes(n, encoding ?? "base64")
end

-- A secure random integer, following `math.random`: `randomInt(n)` is in
-- [1, n] and `randomInt(min, max)` in [min, max].
function crypto.randomInt(min: number, max: number?): number
    if (type(min) != "number") then error("min must be a number") end
    if (max == nil) then
        if (min < 1) then error("n must be greater than or equal to 1") end
        return __crypto_random_int(1, min)
    end
    if (type(max) != "number") then error("max must be a number") end
    if (min > max) then error("min must be less than or equal to max") end
    return __crypto_random_int(min, max)
end

-- A random UUID (version 4), like "1b4e28ba-2fa1-4d3b-9c0e-3f6a1e2d5b7c".
function crypto.uuid(): string
    return __crypto_uuid()
end

-- ENCODING -------------------------------------------------------------------

-- Encodes the bytes of `s` as "hex", "base64" or "base64url".
function crypto.encode(s: string, encoding: string): string
    checkString(s, "s")
    checkEncoding(encoding, "encoding")
    return __crypto_encode(encoding, s)
end

-- Reverses `encode`. The decoded bytes must be text (UTF-8); decoded keys
-- and ciphertexts should stay encoded instead.
function crypto.decode(s: string, encoding: string): string
    checkString(s, "s")
    checkEncoding(encoding, "encoding")
    return __crypto_decode(encoding, s)
end

return table.freeze(crypto)