atherhubather.hub
Back to Guides
SUNC
9 min read
May 11, 2026

The SUNC crypt library

SUNC's crypt namespace bundles the cryptography primitives a script realistically needs: base64 encoding for transit safety, AES for symmetric encryption, a unified hash function, and helpers to generate keys and random bytes. This guide goes through each, with the format the output actually takes.

Ather
Ather
Lead developer at Atherhub. Writes about Roblox internals, Luau, script engineering, and platform security.Last updated May 11, 2026

Base64: bytes ↔ ASCII

The simplest pair. Encode binary data into an ASCII-safe string; decode back to the original bytes.

luau
crypt.base64encode(data: string): string
crypt.base64decode(data: string): string
Returnsstring
The base64-encoded representation of the input (or the decoded bytes for the inverse). 4 output characters per 3 input bytes.
local s = crypt.base64encode("Hello, world!")
print(s)
--> SGVsbG8sIHdvcmxkIQ==

print(crypt.base64decode(s))
--> Hello, world!

Base64 isn't encryption — it's framing. The main use is sending binary data through transports that mangle non-ASCII (HTTP form fields, configuration files, clipboards). The encoded form is 1.33x the size of the original.

generatebytes and generatekey

Cryptographically random bytes. generatebytes returns a number of bytes you request; generatekey returns a base64-encoded 32-byte key suitable for AES.

luau
crypt.generatebytes(size: number): string
crypt.generatekey(): string
Returnsstring
generatebytes returns size raw bytes as a string. generatekey returns a base64 string of 32 random bytes (44 characters including padding).
local raw = crypt.generatebytes(16)
print(#raw)  --> 16

local key = crypt.generatekey()
print(#key, key)
--> 44  q8VVYz7eXgU7n6h2gVD0kJv...=

The output of generatekey is what you store in a file or hand to crypt.encrypt. The randomness source is the OS's secure RNG on every implementation, so the values are suitable for keys, IVs, and nonces.

encrypt and decrypt: AES symmetric encryption

AES under the hood, with a small ergonomic shell on top.

luau
crypt.encrypt(data: string, key: string, iv: string?, mode: string?): (string, string)
crypt.decrypt(data: string, key: string, iv: string, mode: string?): string
Returns(string, string)
Encryption returns the ciphertext (base64) followed by the IV (base64). Decryption returns the plaintext as a string.
local key = crypt.generatekey()
local plaintext = "secret message"

local ciphertext, iv = crypt.encrypt(plaintext, key)
print(ciphertext, iv)
--> 7vqK...Q==  Z9aGq...A==

print(crypt.decrypt(ciphertext, key, iv))
--> secret message

A few things to remember. The key is the base64 of a 32-byte random value — usually whatever generatekey produced. The IV is generated for you on encrypt unless you pass one; on decrypt, you must hand back the IV you got alongside the ciphertext. The default mode is AES-CBC; some executors also accept "CFB", "OFB", "CTR".

On using this for real secrets
AES with the right mode is a sound choice for keeping data unreadable on disk or in transit. It's not, on its own, enough to defeat someone with the same script — anything your script can decrypt, so can anyone reading your script. Use this for transit confidentiality, not anti-tampering.

crypt.hash

A unified hashing function with a selectable algorithm.

luau
crypt.hash(data: string, algorithm: string): string
Returnsstring
The hash, hex-encoded. Length depends on the algorithm.
print(crypt.hash("hello", "sha256"))
--> 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

print(crypt.hash("hello", "sha1"))
--> aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d

print(crypt.hash("hello", "md5"))
--> 5d41402abc4b2a76b9719d911017c592

Supported algorithms across most implementations: md5, sha1, sha256, sha384, sha512. Use sha256 unless you have a specific reason not to — md5 and sha1 are present for compatibility with old systems, not because they're recommended.

A worked example: encrypted-at-rest settings

Suppose you keep a token in your settings file and you'd rather it not sit in plaintext on disk. The full flow uses four of the primitives above.

luau
local KEY_PATH = "atherhub/key.bin"
local DATA_PATH = "atherhub/secrets.bin"

-- Get or create a long-lived key per device
local function getDeviceKey(): string
    if isfile(KEY_PATH) then return readfile(KEY_PATH) end
    local key = crypt.generatekey()
    writefile(KEY_PATH, key)
    return key
end

local function saveSecret(plaintext: string)
    local key = getDeviceKey()
    local ct, iv = crypt.encrypt(plaintext, key)
    writefile(DATA_PATH, iv .. ":" .. ct)
end

local function loadSecret(): string?
    if not isfile(DATA_PATH) then return nil end
    local raw = readfile(DATA_PATH)
    local iv, ct = string.match(raw, "([^:]+):(.+)")
    if not iv or not ct then return nil end
    return crypt.decrypt(ct, getDeviceKey(), iv)
end

saveSecret("user-token-abc-123")
print(loadSecret())  --> user-token-abc-123

Two design notes. We persist the IV alongside the ciphertext (joined with a separator) because we'll need it back on decrypt. And we keep the encryption key in a separate file so the "data" file isn't a single self-contained payload that decrypts on its own.

A common request: HMAC

SUNC doesn't define an HMAC primitive directly, but you can build one over crypt.hash in a few lines if you need authenticated tags for messages. For most script purposes, AES-CBC plus an upstream integrity check is enough.

luau
-- Naive HMAC-SHA256, sufficient when SUNC's hash is genuine SHA-256.
local function hmacSha256(key: string, message: string): string
    local blockSize = 64
    if #key > blockSize then key = crypt.hash(key, "sha256") end
    key = key .. string.rep("\0", blockSize - #key)
    local outer, inner = "", ""
    for i = 1, blockSize do
        outer = outer .. string.char(string.byte(key, i) ~ 0x5c)
        inner = inner .. string.char(string.byte(key, i) ~ 0x36)
    end
    return crypt.hash(outer .. crypt.hash(inner .. message, "sha256"), "sha256")
end

Gotchas

  • The key passed to crypt.encrypt must be a base64-encoded 32-byte value. Bare strings of arbitrary length will error or silently misbehave depending on the executor.
  • IVs are not optional in real cryptography even if the SUNC signature makes them look optional. Always preserve and transmit the IV; never reuse the same IV with the same key.
  • crypt.hash's output is hex by default, not raw bytes. If you need bytes (for HMAC, for example), decode it.
  • None of this protects you from someone who has the same script. Keys persisted to disk are readable by anyone with filesystem access in the executor sandbox.

Wrap-up

Small library, tight surface. base64encode/decode for transit framing, encrypt/decrypt for symmetric confidentiality, hash for everything you want to fingerprint, generatekey and generatebytes for randomness. That covers about 95% of legitimate cryptographic needs a Roblox script will ever have.

Ather
Written by Ather

Ather is the lead developer behind Atherhub. He's been writing Luau and Roblox tooling for the better part of a decade, with a focus on the messy interface between game-script internals and the platforms that host them. Have feedback on this article? Drop it in the Discord.