Crypto¶
Script API: native.crypto¶
Authenticated encryption without handling key material. Scripts name a key ("invoices": 1-64
characters of letters, digits, -, _, .); the host derives it from the operator's master key
and your tenant name (HKDF-SHA256), so tenants cannot read each other's data even with the same
key name. Needs crypto in the deployment config.
| Function | Result |
|---|---|
encrypt(key, plaintext, aad?) |
binary string: XChaCha20-Poly1305 with a random nonce. 42 bytes larger than the input. |
decrypt(key, ciphertext, aad?) |
plaintext; raises if the key or aad differs or the data was modified. |
is_current(ciphertext) |
true if it was sealed with the operator's active master key. |
hmac_sha256(key, data, key_id?) |
32-byte tag; key_id selects an older master key to verify old tags. |
public_key(key, key_id?), seal(public_key, plaintext), open(key, ciphertext) |
public-key encryption in the age format, see below. |
sha256(data), random(n) |
digest; n random bytes (1-1024). Work without crypto configured. |
hex_encode/decode, base64_encode/decode |
conversions between binary strings and text. |
aad is optional context that must match on decrypt (for example the object key, which stops a
ciphertext being moved to another key). Inputs are capped at max_object_mb.
local b = native.bucket.Out
b.put("invoices/7.json", native.crypto.encrypt("invoices", body, "invoices/7.json"))
local plain = native.crypto.decrypt("invoices", b.get("invoices/7.json"), "invoices/7.json")
Public-key variant (uploads from outside)¶
public_key(key, key_id?) returns an
age public key (age1...) whose private half exists only inside this
tenant's scripts, derived from the master key like the symmetric keys (but unrelated to the
symmetric key of the same name). Hand it to anyone; they encrypt with the stock tool and upload,
for instance through a presign_put URL, and a script reads the object and calls open(key, blob).
seal(public_key, plaintext) does the same encryption from a script.
local pk = native.crypto.public_key("inbox") -- publish this
local url = native.bucket.In.presign_put("drop/report.csv.age") -- give this to the sender
local csv = native.crypto.open("inbox", native.bucket.In.get("drop/report.csv.age"))
open tries every configured master key, so uploads sealed to an older public key still open after
rotation; senders should switch to the new public_key (key_id returns an older one). The
format does not authenticate the sender: anyone with the public key can upload, so treat opened
data as untrusted input.
Deployment: master keys¶
crypto.keys maps a key number (1-255) to the name of an environment variable holding a
32-byte master key, as 64 hex characters or base64 (openssl rand -hex 32). The secret itself never
goes in the config file; inject it from Secrets Manager or similar. crypto.active is the number
used for new encryption. Startup fails if a listed variable is missing or malformed.
Rotation¶
Ciphertext records the number of the master key that sealed it, so after the
operator adds key 2 and makes it active, old data still decrypts and new data uses key 2. To
migrate lazily, re-encrypt whatever is_current reports as false.
To rotate: generate key 2, deploy with both keys and "active": 2, let scripts re-encrypt data
that native.crypto.is_current reports as stale, then drop key 1. Data sealed with a key you remove
can no longer be decrypted, and losing every key loses the data: back them up separately from the data.
Changing the key name is a tenant-side rotation and needs the script to read with the old name and write with the new one.