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-wasip2 component 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:

manifest.json
…
FieldAllowedMeaning
path[a-z0-9][a-z0-9_-]{0,31}, unique in the listThe 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.
bindproject (default) | wasmAs for a signing key — see Binding.
callersigner (default) | predecessorAs for a signing key — see Signer or predecessor.
vaulta NEAR account idOptional, 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 a type is 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 bind or caller value outside its list is refused the same way.
  • A namespace of its own. An encryption key and a signing key declared at the same path are 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

text
…
bindIssued only toThe key belongs toA new version of the code
project (default)a run through a project whose version is a WasmUrl versionthe project's on-chain uuid + the chosen calleropens what an earlier version sealed
wasma direct run from a wasm URL, with no projectthe code's sha256 + the chosen callerhas 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 bind or caller does not match how the code is run refuses the whole run before it starts.
  • A project key belongs to the project's uuid, not its owner/name id: 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:

wit/deps/encryption-keys.wit
…
FunctionAnswers
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#

text
…
  • 41 bytes longer than the plaintext. The first byte is the format marker: it names the ciphertext's format, not a key version.
  • Format 0x01 is 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:

PartWhat to useWhy
the storage keymac(path, vault, name), in hexstorage 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 valueencrypt(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 writesset-raw, get-raw, set-if-absent-raw, set-if-equals-rawthe value is already sealed: the bytes are stored as given
compare-and-swapset-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).

manifest.json
…
Cargo.toml
…

The world imports both interfaces. Copy encryption-keys.wit and storage.wit into wit/deps/:

wit/world.wit
…
src/main.rs
…

Build, then check the imports and the manifest section:

bash
…

Publish the wasm as a URL, deploy it as a project version (bind: "project"), and call the project:

bash
…

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 mac of the name; the operator sees every storage key and every raw value.
  • Never rename a path that 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; use project for 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:

CauseWhat clears it
a wasm32-wasip1 build declares keysbuild 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 listfix manifest.json, publish a new version
keys declared on a GitHub-sourced run, when the manifest reaches the workerpublish the code as a wasm URL
a project key on a run with no projectrun it through its project
a wasm key on a run through a projectrun the wasm URL directly, or declare the key project
a predecessor key on a run that carries no predecessorcall 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 namesdeploy 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 availablefix the manifest, or the vault
a run with no caller accountcall with a payment key, or on chain

Inside a run, a call answers err for:

  • an undeclared path, or a vault argument that is not the key's declared vault;
  • a plaintext, aad or mac data over 262144 bytes, or a ciphertext over 262144 + 41 bytes;
  • decrypt: any failure to open — always exactly decryption 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.