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.
Base64: bytes ↔ ASCII
The simplest pair. Encode binary data into an ASCII-safe string; decode back to the original bytes.
crypt.base64encode(data: string): string
crypt.base64decode(data: string): stringstringlocal 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.
crypt.generatebytes(size: number): string
crypt.generatekey(): stringstringlocal 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.
crypt.encrypt(data: string, key: string, iv: string?, mode: string?): (string, string)
crypt.decrypt(data: string, key: string, iv: string, mode: string?): string(string, 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 messageA 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".
crypt.hash
A unified hashing function with a selectable algorithm.
crypt.hash(data: string, algorithm: string): stringstringprint(crypt.hash("hello", "sha256"))
--> 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
print(crypt.hash("hello", "sha1"))
--> aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
print(crypt.hash("hello", "md5"))
--> 5d41402abc4b2a76b9719d911017c592Supported 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.
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-123Two 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.
-- 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")
endGotchas
- The key passed to
crypt.encryptmust 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 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.