Documentation
Guides and reference for OutLayer verifiable compute and agent custody.
Docs navigation
Signing Keys
ed25519 and secp256k1 keys a WASI module signs with, through the outlayer:signing-keys host interface. Any project can declare them. For signing with the custody wallet's keys (EVM, Solana, NEP-413 over the wallet API) see Agent Custody. To seal data rather than sign it, see Encryption Keys: declared and issued by the same rules, in a namespace of their own.
What a signing key is#
A key the module signs 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 and the host signs; the module gets back a public key or a signature, 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.
- The keystore derives it in the same request that decrypts the run's secrets and hands it 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. The master never leaves the keystore.
- Only a
wasm32-wasip2component can use it: a WASI P1 module that declares keys is refused. See WASI Preview 1 vs Preview 2.
What it is for
- Sign what the module returns — a record, a result, a receipt — so anyone can check it against a published public key without trusting the transport.
- Give each caller a stable ed25519 identity per project — a NEAR implicit account that can sign NEP-413 messages.
- Sign for EVM with a secp256k1 key — an EVM address, signatures
ecrecoveraccepts. See EVM. - Let anyone check once that a public key is this project's, derived in the TEE — see Prove a key is the project's.
Declaring keys in the manifest#
Keys are declared in signing_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; another path is another key. Renaming a path loses the key, any address made from its public key, and every signature checked against it. Pick paths once. |
type | ed25519 | secp256k1 | The key's algorithm — see Key types — and an input of its derivation (signing-key:v1:{type}:{project|wasm}:…), so one secret never serves two algorithms. A path is declared once whatever its type: the same path under the other type would be another key. |
bind | project (default) | wasm | How the code must be run to get the key, and what the key belongs to besides the caller. See Binding. There is no repository binding. |
caller | signer (default) | predecessor | Which account of the run the key belongs to. See Signer or predecessor. |
vault | a NEAR account id | Optional, bind: "project" only: derive from that vault's master instead of the default one. |
At most 3 keys. A key with an unknown field is refused, not read with the field dropped: a misspelled vault would derive from the default master, a misspelled bind would bind to the project, a misspelled caller to the signer. A type, bind or caller value outside its list is refused the same way.
vault must belong to the project's owner: it is a direct sub-account of the owner (vault.alice.near for alice.near/app), and its contract's parent is that owner. A vault not named directly under the owner is refused on the two names alone, before any chain read. A vault that is missing, unreadable, not the owner's, unfunded or not loadable refuses the run; the default master is never used in its place. A wasm key cannot name a vault: code has no owner to own one. Vaults themselves: MPC Vaults.
Key types#
| type | public-key | sign: input | sign: output | sign-nep413 |
|---|---|---|---|---|
ed25519 | 32 bytes — in hex, a NEAR implicit account; also a Solana address | the raw message bytes, at most 65536 — no prehash, no prefix; sign a digest to cover more | 64 bytes, RFC 8032 | yes |
secp256k1 | 64 bytes x ‖ y: the uncompressed SEC1 point without its 0x04 prefix, as NEAR's secp256k1: public keys carry it. The EVM address is the last 20 bytes of keccak256 of these 64 bytes | exactly a 32-byte prehash, signed as it is — no further hashing; any other length is an err | 65 bytes r ‖ s ‖ v: ECDSA with an RFC 6979 nonce (one key and one prehash, one signature), r and s big-endian, s low (in the lower half of the group order), v the recovery id 0 or 1 — NEAR's secp256k1 signature and the input ecrecover takes. EVM wants v + 27 | err |
The keystore hands the worker 32 bytes for either type: an RFC 8032 seed, or the secp256k1 secret scalar (big-endian). A scalar that is zero or not below the group order — probability about 2-128 — is not a key, and the run is refused rather than use it.
Binding: how keys are issued#
Keys are issued strictly by how the code is run:
| bind | Issued only to | The key belongs to | A new version of the code | The same key for |
|---|---|---|---|---|
project (default) | a run through a project whose version is a WasmUrl version | the project's on-chain uuid + the chosen caller | keeps the key | that caller, running any version of that project |
wasm | a direct run from a wasm URL, with no project | the code's sha256 + the chosen caller | gets new keys | that caller, running that exact binary directly |
- A GitHub-sourced run never gets keys — neither a project version built from a repository nor a repository run directly. Publish the code as a wasm URL instead.
- One key whose
bindorcallerdoes not match how the code is run refuses the whole run before it starts. - One run never holds both kinds. The manifest is part of the wasm, so a binary that declares
projectkeys runs only through a project, and one that declareswasmkeys runs only directly: declare the onebindthat matches how the module will be run.
project
The key belongs to the project's on-chain uuid, minted once at create_project, never to its owner/name id. So it survives code upgrades — every later version signs with the same key, and whoever publishes versions of the project decides what they sign — and it survives a transfer of the project: the id changes, the uuid stays, and the keys stay with it. A project deleted and created again under the same name is another project with another uuid, and so other keys. Another project, or another caller, gets another key. See Projects.
wasm
The key belongs to the code, not to a deployer: anyone who runs the exact binary directly gets keys for their own callers. A new build is a new key.
Signer or predecessor#
The caller is part of every key, and caller says which account of the run it is. The platform sets both accounts from the job; neither the code nor the input can.
| caller | Account | On chain | Over HTTPS |
|---|---|---|---|
signer (default) | NEAR_USER_ACCOUNT_ID | the transaction's signer | the payment key's owner |
predecessor | NEAR_PREDECESSOR_ID | the account that called the contract: the DAO or wallet contract when one relayed the call, the signer when none did | the signer |
signer gives the person who signed the transaction one key across everything they do. The price: any contract the signer ever transacts with can start a run under the signer's key with input of its own — an ft_transfer_call, a DAO proposal, a wallet contract's callback all execute as the signer. So a module must never treat its input as the signer's intent.
predecessor gives the key to whoever actually called — the DAO or wallet contract itself when one relayed the call. The key is that contract's, not the signer's; on a payment through ft_transfer_call it binds to the token contract. A run that carries no predecessor is refused before it starts.
Both are legitimate; the module author chooses per key. A signer key and a predecessor key for one account are two keys. A run with no caller account is refused: the worker's placeholder for a missing account is refused by name.
The host interface#
worker/wit/deps/signing-keys.wit — copy it into your crate as wit/signing-keys.wit:
…
| Function | Answers |
|---|---|
public-key(path, vault) | ok: the public key — 32 bytes (ed25519) or 64 bytes x ‖ y (secp256k1); err: the reason |
sign(path, vault, message) | ok: ed25519 — 64 bytes over the raw message, at most 65536 bytes; secp256k1 — 65 bytes r ‖ s ‖ v over exactly a 32-byte prehash. err: the reason. See Key types |
sign-nep413(path, vault, message, recipient, nonce, callback-url) | ok: nep413-signature {account-id, public-key, signature} from an ed25519 key; err: the reason. See NEP-413 |
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, a key of a type the call does not serve, and a message the key's type does not take 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.
A minimal Rust module
Signs the record it is given with the key records. The outlayer SDK crate can sit beside it for storage and env; keep the generated bindings in their own module, as below, so the two outlayer names never meet.
…
…
…
Build, then check the import and the manifest section:
…
Publish the wasm as a URL. With bind: "project", deploy that URL as a project version and call the project; the same caller calling any later version gets the same public_key. With bind: "wasm", run the URL directly with no project.
A signature verifies anywhere, for example:
…
NEP-413 (NEAR signMessage)#
sign-nep413(path, vault, message, recipient, nonce, callback-url) signs a NEP-413 message with the declared ed25519 key at path. The host builds the signed bytes itself: sha256(borsh(2^31 + 413) ‖ borsh(payload)), the payload {message: string, nonce: [u8; 32], recipient: string, callback_url: option<string>} in that order, and signs the 32-byte hash with ed25519. Anything that verifies a wallet's NEP-413 signature verifies this one.
| nep413-signature | Value |
|---|---|
account-id | the lowercase hex of the 32-byte public key (64 characters): the key's NEAR implicit account |
public-key | ed25519: + base58 of the 32-byte public key |
signature | the 64-byte ed25519 signature, standard base64 with padding |
That is the shape a NEAR wallet's signMessage answers (accountId, publicKey, signature). An err: a key that is not ed25519, a nonce that is not exactly 32 bytes, a message over 65536 bytes, a recipient or callback-url over 2048 bytes. The nonce is the verifier's: it chooses it, and refuses one it has seen before.
The implicit account needs no registration. A NEP-413 signature verifies against the account hex(public key). A verifier that also looks the key up among the account's access keys over RPC finds it only once the implicit account has been funded; checking account-id == hex(public key) holds regardless.
A module that proves its key
The module fixes message and recipient; the verifier chooses only the nonce. Same Cargo.toml as the minimal module.
…
…
Verify the answer anywhere:
…
The same signature built in the guest from sign — for when the payload must be seen — and a Python verifier are sign_nep413 in the probe's src/main.rs and its README.
EVM (secp256k1)#
A secp256k1 key's EVM address is 0x and the last 20 bytes of keccak256 of its 64-byte public key. sign takes the 32-byte prehash the module computed and answers r ‖ s ‖ v with v 0 or 1; add 27 for EVM. Below, an EIP-191 personal_sign over a message the module composed itself:
…
…
Recover the signer anywhere (message, signature and address from the module's output):
…
The module hashes what it signs. Never hand sign a digest from the input — see Security rules.
Security rules#
- Never sign caller-supplied bytes or digests verbatim. An ed25519 key's public key is a real NEAR implicit account (and a Solana address); a secp256k1 key's is an EVM address. A signature over bytes the caller chose is a signature over whatever those bytes are — a transaction that empties the account, an authorization, a message the account never meant. Sign only messages the module composes itself, from fields it has parsed and checked, under a fixed prefix or structure of its own, and compute the digest in the module from those bytes; refuse an input that asks for a signature over raw bytes or a digest.
- Never sign a caller-supplied digest with a secp256k1 key. It signs 32 bytes as they are, so a caller-chosen digest is the hash of any EVM transaction, EIP-712 permit or
personal_signmessage the caller likes. - A caller-chosen NEP-413
messageandrecipientis a login. The NEP-413 tag keeps the bytes from being a transaction, but a signature over a message and recipient the caller picked logs in, as the key's account, to whatever site the caller names. Fix them in the module. - Do not hold funds on a signing key. An ed25519 key can sign NEAR transactions for its implicit account and Solana ones for the same public key; a secp256k1 key signs EVM transactions for its address. Funds sent there are controlled only by the code, outside every wallet policy. Money goes through the custody wallet.
- With
caller: "signer", the input is not the caller's intent. Any contract the caller interacts with can start a run under the caller's key with input of its own. - With
bind: "wasm", decide what to sign from the input and the code alone — never from secrets, environment variables or configuration. Whoever runs the binary controls those, and a user lured into calling someone else's run of the same code would sign under that runner's configuration.
What is trusted, and what is checked
The worker reads the run off the job the coordinator gave it, never off the module, and sends the job's user_account_id, predecessor_id, executed_wasm_sha256 and project_id with the key request; a project_id makes it a project run, none a direct run. The keystore takes those four fields on the trust of the worker's TEE attestation, exactly as it does for secrets. A worker that is not what it attests to be can name any caller, any build and any project, and receive their keys.
The keystore verifies on chain only what the chain can answer: for a project run, that the sha256 the worker measured on the running code is a WasmUrl version of that project and that the project exists with the owner its id names; for a vault, that it belongs to that owner. The worker checks every declared bind and caller against the run before any secret is decrypted; the keystore checks them again. A direct GitHub build is refused by the worker alone: the keystore sees only the hash of the bytes.
Prove a key is the project's#
A public key alone does not say who holds its secret. The run's attestation does: every OutLayer run carries an Intel TDX quote whose report data commits to the run's input, output, build (wasm_hash), caller and project — the task hash. A module that puts its public key in its output gets that key attested with it.
- The module returns its key. It puts
public-keyin its output — or, for freshness, a signature over a nonce the verifier chose, as the key-proof module does withsign-nep413and amessageandrecipientit fixes itself. - Get the run's attestation. An HTTPS
/callresponse carriesattestation_url:/attestations/by-call/{call_id}, relative to the API base (https://api.outlayer.ai). It answers once the worker has uploaded the quote. For an on-chain run:/attestations/by-tx/{tx_hash}. The same by-call path onapp.outlayer.aiopens the report with its Verify button. - Verify it. The quote is Intel-signed, its measurements are an approved worker build, and its task hash commits to this input and this output. Keep the request and response of an HTTPS call: only their hashes are stored.See OutLayer Verify.bash
…
- Read the attested fields.
project_idis the project; the caller —payment_key_ownerover HTTPS,caller_account_idon chain — is the account the key belongs to;wasm_hashis the build that ran. Audit that build: the attestation proves what ran, not that it returned the host'spublic-keyunaltered. - From then on, a signature by that key is the project's for that caller — checked against the public key alone, with no attestation per signature.
- The attestation names the project by its
owner/nameid; the key belongs to its on-chain uuid. A project deleted and created again under the same name has other keys, so the proof holds for the project that ran under that name in that run. - A
caller: "predecessor"key belongs to the account that called the contract; read it from the requesting transaction. Over HTTPS it is the payment key's owner. - A
bind: "wasm"key belongs to the build and the caller: the proof is for thatwasm_hash, with no project. - For an ed25519 key,
account-id == hex(public key)ties the NEP-413 answer to the implicit account whether or not that account has been funded.
Refusals#
Refused before the code runs:
| Cause | What clears it |
|---|---|
a wasm32-wasip1 build declares keys | build for wasm32-wasip2 |
more than 3 keys, a bad path or one declared twice (under any type), an unknown field, a type, 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 |
a secp256k1 key whose derived scalar is zero or not below the group order (probability about 2-128) | declare the key under another path |
| a worker or keystore without signing-key support | none from your side |
Inside a run, a call answers err for:
- an undeclared
path, or avaultargument that is not the key's declared vault; signwith an ed25519 key: a message over 65536 bytes; with a secp256k1 key: a message that is not exactly 32 bytes;sign-nep413: a key that is not ed25519, anoncethat is not exactly 32 bytes, amessageover 65536 bytes, arecipientorcallback-urlover 2048 bytes.
A GitHub-sourced module whose manifest does not reach the worker runs, and every call answers err.
Example: signing-key-probe#
wasi-examples/signing-key-probe exercises every host function, binding and refusal, with one build per manifest (bind: "project", bind: "wasm", a vault key, and secp256k1 keys in project-secp and wasm-secp). Its sign operation signs whatever bytes — for a secp256k1 key, whatever 32-byte digest — it is handed: that is what a probe is for, and exactly what a production module must not do.
- src/main.rs — the bindings,
sign_nep413(guest-built),host_nep413(sign-nep413) andevm_address - README.md — builds, operations, and the NEP-413 recipe with a Python verifier
- CONNECTOR_MANIFEST.md — the full manifest reference, section
signing_keys