Documentation
Guides and reference for OutLayer verifiable compute and agent custody.
Docs navigation
Encryption Keys
Symmetric keys a WASI module seals data with, through the outlayer:encryption-keys host interface. Any project can declare them. They are derived and issued exactly like signing keys; with the raw functions of storage they make records that only the module can open.
What an encryption key is#
A 256-bit key the module encrypts with that no one ever sees. The keystore derives it inside the TEE from its master, for this project (or this exact build) and this caller. The module names the key by path; the host encrypts, decrypts or authenticates and hands back the result, never the key.
- The key is derived from what the run is — how its code is run, and its caller — never from anything the module says at runtime.
- It is derived in the same request that decrypts the run's secrets and any signing keys, and handed to the worker for that one run. It lives in that run's memory only — never in the environment, stdin or a log — and is dropped with it.
- Only a
wasm32-wasip2component can use it. See WASI Preview 1 vs Preview 2. - Symmetric 256-bit keys are considered adequate against quantum attacks.
What it is for
- Keep records in storage that the storage's operator cannot read — see Sealed storage.
- Hand data out sealed — a token, a cursor, a state blob — that only a later run of the same project and caller can open.
- Name records by a keyed hash (
mac) so that their names are hidden too.
Declaring keys in the manifest#
Keys are declared in encryption_keys of the project manifest — the outlayer.manifest custom section of the wasm, covered by its sha256:
…
| Field | Allowed | Meaning |
|---|---|---|
path | [a-z0-9][a-z0-9_-]{0,31}, unique in the list | The key's name and an input of its derivation. The same path gives the same key for as long as its binding holds. Renaming a path loses the key, and with it everything the key sealed. Pick paths once. |
bind | project (default) | wasm | As for a signing key — see Binding. |
caller | signer (default) | predecessor | As for a signing key — see Signer or predecessor. |
vault | a NEAR account id | Optional, bind: "project" only: derive from that vault's master instead of the default one. The vault must belong to the project's owner, as for a signing key. |
- No
type. An encryption key is 32 bytes, and which algorithm uses them is the platform's choice. A declaration that names atypeis refused as an unknown field. - At most 3 encryption keys, counted apart from signing keys: a manifest may declare 3 of each. A key with an unknown field is refused, not read with the field dropped; a
bindorcallervalue outside its list is refused the same way. - A namespace of its own. An encryption key and a signing key declared at the same
pathare two unrelated secrets.
Binding and caller#
The rules are the signing keys', key for key — Binding, Signer or predecessor, and what is trusted and checked. The keystore derives
…
| bind | Issued only to | The key belongs to | A new version of the code |
|---|---|---|---|
project (default) | a run through a project whose version is a WasmUrl version | the project's on-chain uuid + the chosen caller | opens what an earlier version sealed |
wasm | a direct run from a wasm URL, with no project | the code's sha256 + the chosen caller | has a new key, and cannot open what the old build sealed |
- A GitHub-sourced run never gets keys. Publish the code as a wasm URL.
- One key whose
bindorcallerdoes not match how the code is run refuses the whole run before it starts. - A
projectkey belongs to the project's uuid, not itsowner/nameid: a project deleted and created again under the same name has new keys, and cannot open what the old one sealed.
The host interface#
worker/wit/deps/encryption-keys.wit — copy it into your crate as wit/deps/encryption-keys.wit:
…
| Function | Answers |
|---|---|
encrypt(path, vault, plaintext, aad) | ok: plaintext sealed under the key, bound to aad — see Ciphertext format. err: the reason |
decrypt(path, vault, ciphertext, aad) | ok: the plaintext of what encrypt sealed under the same path, vault and aad. Any failure to open — an unknown format marker, a truncated or tampered ciphertext, another key, another aad — is exactly err("decryption failed"), and which of these it was is not said |
mac(path, vault, data) | ok: HMAC-SHA256 of data, 32 bytes, under a subkey derived from the key for this purpose alone — never the key encrypt uses. Deterministic: the same data under the same key is the same tag in every run that holds the key |
vault names the key's declared vault exactly: none for a key declared without one, some("<vault>") for a key declared with that vault. Any other combination, an undeclared path, and an input over the limit are an err carrying the reason, never a trap. A module that imports the interface and declares no key gets an err for every path.
Limits: at most 262144 bytes (256 KiB) of plaintext, of aad and of mac data. A ciphertext may be longer by its 41 bytes of overhead, so everything encrypt returns opens again.
Ciphertext format#
…
- 41 bytes longer than the plaintext. The first byte is the format marker: it names the ciphertext's format, not a key version.
- Format
0x01is XChaCha20-Poly1305 with a fresh random 24-byte nonce from the host's CSPRNG, so two calls on the same input never return the same bytes. The algorithm is the platform's choice: a later format would get another marker, over the same key. - The key is never handed to the module or to anyone outside the keystore and the run, so a ciphertext opens only through
decrypt, in a run that holds the same key.
Sealed storage#
The raw storage functions — set-raw, get-raw, set-if-absent-raw, set-if-equals-raw — store bytes as given, in the run's storage cell (per account, per project, like the encrypted functions), with no keystore on the path. The storage's operator can read a raw record's key name and bytes, so the module encrypts first. The recipe:
| Part | What to use | Why |
|---|---|---|
| the storage key | mac(path, vault, name), in hex | storage keys are visible to the operator; the same name always maps to the same tag, and the tag does not say which name it is |
| the value | encrypt(path, vault, value, aad = name) | a ciphertext copied onto another record by whoever holds the storage fails to decrypt instead of being read as that record's data |
| reads and writes | set-raw, get-raw, set-if-absent-raw, set-if-equals-raw | the value is already sealed: the bytes are stored as given |
| compare-and-swap | set-if-equals-raw(key, expected, new) | compares the stored ciphertext bytes: pass the ciphertext you read, not a plaintext |
A ciphertext is never empty, so an empty get-raw value means no record. The Rust SDK provides helpers for this pattern; the module below uses the host functions directly.
Pair the key's caller with storage_account. A module that seals records under a caller: "predecessor" key declares "storage_account": "predecessor" in the manifest as well, so the key and the cell belong to the same account. With the default signer cell, records sealed for a relaying contract land in the cell of whichever account signed each transaction, and a call signed by another account finds none of them.
A minimal Rust module: sealed notes
Stores notes per caller under the key notes: put and append write, and get reads — over HTTPS only, where the caller is the payment key's owner (see Security rules).
…
…
The world imports both interfaces. Copy encryption-keys.wit and storage.wit into wit/deps/:
…
…
Build, then check the imports and the manifest section:
…
Publish the wasm as a URL, deploy it as a project version (bind: "project"), and call the project:
…
The same caller calling any later version of the project reads the same notes.
Security rules#
- Never hand back a plaintext to whoever asks. With
caller: "signer", any contract the signer transacts with can start a run under the signer's key with input of its own choosing. An on-chain answer is public besides. A module decides from what it has checked who may read what it opens. - Always pass
aad: the record's name. Without it, a ciphertext moved from one record to another still opens. - Keep names out of raw storage keys. Use
macof the name; the operator sees every storage key and every raw value. - Never rename a
paththat has sealed data: the new path is a new key, and nothing sealed under the old one opens again. - With
bind: "wasm", a new build is a new key. Data sealed by one build does not survive an upgrade; useprojectfor data that must.
Refusals#
Refused before the code runs: every cause in the signing keys' list except the type and secp256k1 rows — an encryption key has no type, and a type member is itself refused as an unknown field:
| Cause | What clears it |
|---|---|
a wasm32-wasip1 build declares keys | build for wasm32-wasip2 |
more than 3 encryption keys, a bad path or one declared twice, an unknown field (type included), a bind or caller outside its list | fix manifest.json, publish a new version |
| keys declared on a GitHub-sourced run, when the manifest reaches the worker | publish the code as a wasm URL |
a project key on a run with no project | run it through its project |
a wasm key on a run through a project | run the wasm URL directly, or declare the key project |
a predecessor key on a run that carries no predecessor | call through a contract, or declare the key signer |
| a project run whose running code is not a WasmUrl version of that project, or whose project does not exist or is not owned by the account its id names | deploy the wasm URL as a version of that project |
vault on a wasm key; a vault not named directly under the project's owner, not the owner's, or not available | fix the manifest, or the vault |
| a run with no caller account | call with a payment key, or on chain |
Inside a run, a call answers err for:
- an undeclared
path, or avaultargument that is not the key's declared vault; - a plaintext,
aadormacdata over 262144 bytes, or a ciphertext over 262144 + 41 bytes; decrypt: any failure to open — always exactlydecryption failed.
Example: signing-key-probe#
wasi-examples/signing-key-probe has three encryption builds — encryption (project keys alpha and beta, beside a signing key alpha), encryption-wasm (a wasm key) and encryption-vault (a vault key) — with every host function and every refusal as an operation. Its decrypt hands back whatever it opens to whoever called: that is what a probe is for, and exactly what a production module must not do.
- src/encryption.rs — the encryption operations and attacks
- README.md — builds, operations, attacks
- CONNECTOR_MANIFEST.md — the full manifest reference, section
encryption_keys