Delegated Execution Client Contract
A client that holds an account root and a device key, and runs no node of its own, reaches a context through somebody else’s node. This page is the contract for that: the exact bytes it signs, the exact shapes it sends, and which parts are live today.
It exists so a client can be built against a fixed target rather than against a guess. Every field here is quoted from the code that implements it, and anything still due to change says so where it appears.
| Contract | Status | Tracking |
|---|---|---|
| Discovery — where to write and as whom | Live | — |
| Warrant and delegated write | Live; app_version and cited heads signed but not enforced |
#3933 |
Session — challenge and LoginStatement |
Live | #3930 |
| Read — an authenticated account reads a context | Live | #3931 |
0. Primitives
Section titled “0. Primitives”Two conventions run through everything below.
Domain-separated hashing
Section titled “Domain-separated hashing”Every signature in Calimero is over a 32-byte digest produced by one function, so
a signature minted for one purpose can never be replayed as another. Length-prefixing
each segment is what stops ("ab", "c") and ("a", "bc") hashing alike.
pub fn domain_hash(domain: &[u8], parts: &[&[u8]]) -> [u8; 32] { let mut hasher = Sha256::new(); hasher.update((domain.len() as u64).to_le_bytes()); hasher.update(domain); for part in parts { hasher.update((part.len() as u64).to_le_bytes()); hasher.update(part); } hasher.finalize().into()}In other words: SHA256( u64le(len(domain)) ‖ domain ‖ ( u64le(len(part)) ‖ part )* ),
with every length a little-endian u64.
A client implementing this in another language must reproduce it byte for byte. It is the single most likely place for a cross-language client to go wrong, because a missing length prefix still produces plausible-looking digests that never verify.
Encoding on the wire
Section titled “Encoding on the wire”Signed structures cross the HTTP boundary as hex-encoded borsh, never as JSON. The canonical form of a signed statement is its borsh encoding; re-describing its fields as JSON would create a second spelling that could disagree with the bytes the signature covers.
Everything unsigned — request envelopes, arguments, responses — is JSON, camelCase.
1. Discovery — live today
Section titled “1. Discovery — live today”Before minting a warrant a client needs two facts it cannot derive: which node to
talk to, and which account that node will name as executor.
Which relay, from the cloud:
GET /api/cloud/me/namespaces/{namespace_id}/relays{ "namespace_id": "…", "relays": [ { "peer_id": "12D3KooW…", "relay_url": "https://node-7.example.net", // where to send the intent "executor_account": "…", // what the warrant must name "authorship_ready": true, // may it author here yet "status": "active", "assigned_at": "…", "confirmed_at": "…", "last_seen_at": "…" } ]}Whether this context is open to delegation, from the node itself:
GET /admin-api/contexts/{context_id}/intents{ "executorAccount": "…", "canAuthorOnBehalf": false, "groupId": "…", "grantedOnGroupId": null}canAuthorOnBehalf: false is the default state of every context — authorship is
implied by neither membership nor admin, and is never granted implicitly. A client
should read this before signing anything and, when it is false, tell the user to ask
an admin of groupId for the grant rather than minting a warrant that will be
refused after the nonce is already spent.
A TEE relay (RelayTee) answers true by its role, with no grant. A TEE replica
(ReadOnlyTee) always answers false and no grant changes that: the namespace has
to admit its TEEs in relay mode.
See what the relay must be granted.
2. Warrant and delegated write
Section titled “2. Warrant and delegated write”What a warrant commits to
Section titled “What a warrant commits to”// crates/account/src/warrant.rs — live todaypub struct Warrant { pub context: ContextId, pub author_account: AccountId, pub author_device_key: PublicKey, pub executor: AccountId, pub app_version: ApplicationId, // the exact build, not a version string pub method: String, // in the clear pub intent_hash: [u8; 32], // H(method ‖ args) pub account_heads: Vec<[u8; 32]>, // at most 64 pub governance_floor: Vec<[u8; 32]>, // at most 64 pub nonce: u64, pub not_after: u64, pub signature: [u8; 64],}The arguments are committed to as a hash rather than carried; the method itself travels openly, so a peer can select a per-method write-set without reversing a hash against the app’s ABI:
intent_hash = domain_hash(b"calimero.warrant.intent.v1", &[method.as_bytes(), args])And the signature covers, in this order:
signature = sign(domain_hash(b"calimero.warrant.v2", &[ context.digest(), author_account.as_bytes(), author_device_key, // 32 bytes executor.as_bytes(), app_version, // 32 bytes method.as_bytes(), // variable length intent_hash, // 32 bytes &(account_heads.len() as u64).to_le_bytes(), ..account_heads, // 32 bytes each, in order &(governance_floor.len() as u64).to_le_bytes(), ..governance_floor, // 32 bytes each, in order &nonce.to_le_bytes(), ¬_after.to_le_bytes(),]))Each head list is preceded by its own count, and that is not decoration.
domain_hash length-prefixes every part it is given, so the heads are individually
unambiguous — but the two lists are adjacent, so without the counts
account_heads = [a, b], governance_floor = [] would hash identically to
[a], [b], and a relay could relabel which plane a head was cited from.
Submitting it
Section titled “Submitting it”POST /admin-api/contexts/{context_id}/intents{ "method": "set", "argsJson": { "key": "a", "value": "b" }, "warrant": "<hex borsh of Warrant>", "authorProof": "<hex borsh of AccountProof<DeviceCert>>"}The client supplies only its own half of the proof. The executor’s proof and signing key are attached by the node from its own credentials, because the warrant authorizes an operator account and the author has no business knowing which of that operator’s processes will run it.
// 200{ "data": { "rootHash": "…", // the context's scope root after the run "returns": { /* the method's own return value, or null */ } }}AccountProof<T> is the self-contained credential that ties a device key to an account:
pub struct AccountProof<T> { pub genesis: AccountGenesis, // hashes to the AccountId pub chain: Vec<RootKeyHandoff>, // root rollovers, epoch 0 upward pub statement: T, // here: DeviceCert}2b. Delegated context creation
Section titled “2b. Delegated context creation”A warrant writes into a context that exists. To create one, sign a
ContextCreationWarrant — the group, a 32-byte seed the node derives the context
id from, the application, optional service and name, and a hash of the init
arguments — and present it to a relay:
GET /admin-api/groups/{group_id}/context-intents?author={account}POST /admin-api/groups/{group_id}/context-intents// GET 200 — ask first; it signs nothing and spends nothing{ "data": { "executorAccount": "…", "groupId": "…", "canCreateOnBehalf": true, "authorMayCreate": true } }
// POST body{ "warrant": "<hex borsh of ContextCreationWarrant>", "authorProof": "<hex borsh of AccountProof<DeviceCert>>", "initArgs": { /* the JSON init() receives */ } }
// POST 200{ "data": { "contextId": "…", "groupId": "…", "memberPublicKey": "…" } }init_hash = domain_hash("calimero.context-creation.init.v1", [init_args]), where
init_args is the JSON the node re-encodes from initArgs (compact, keys in the
order sent). The signing preimage is
domain_hash("calimero.context-creation-warrant.v1", parts) over, in order: group,
seed, author account, author device key, executor, application id, then for each
of service_name and name a one-byte presence tag (00/01) followed by its
UTF-8 bytes (empty when absent), then init_hash, the u64le-counted account
heads and governance floor, u64le(nonce) and u64le(not_after).
The author needs admin or CAN_CREATE_CONTEXT in the group; the relay needs
only standing to act for members (a RelayTee, or CAN_AUTHOR_ON_BEHALF). The
nonce is spent in the new context’s ledger, so the device’s ordinary nonce
source is the right one. Refusals: 400 malformed, 403 not authorized (author,
relay, expiry, arguments, replay), 404 unknown group or application, 409 the
group targets a different application than the one signed. Vectors are under
Test vectors.
2c. Delegated governance
Section titled “2c. Delegated governance”The same pattern covers governance: adding the other person to a DM, creating a
channel’s subgroup, renaming it. Sign a GovernanceWarrant over one op in its
delegable form and present it with the op’s bytes:
GET /admin-api/groups/{group_id}/governance-intentsPOST /admin-api/groups/{group_id}/governance-intents// GET 200 — signs nothing, spends nothing{ "data": { "executorAccount": "…", "groupId": "…", "canActOnBehalf": true } }
// POST body — every field hex borsh{ "warrant": "<GovernanceWarrant>", "authorProof": "<AccountProof<DeviceCert>>", "op": "<GroupOp, or RootOp, in its delegable form>" }
// POST 200 — the group the op acted on; the new subgroup for a creation{ "data": { "groupId": "…" } }The warrant’s kind names the plane (0 group op, published on {group_id};
1 root op, published on the namespace, so {group_id} is the namespace id) and
scope is that group. It commits to the op, not a description of it:
op_hash = domain_hash("calimero.governance-warrant.op.v1", [[kind], op_bytes]),
where op_bytes is exactly the op field. The signing preimage is
domain_hash("calimero.governance-warrant.v1", parts) over, in order: scope,
[kind], author account, author device key, executor, op_hash, the
u64le-counted account heads and governance floor, u64le(nonce) and
u64le(not_after).
The delegable form is the op as you mean it, with the fields only the relay
can compute cleared: a MemberRemoved/MemberLeft’s two expected hashes are zero
or empty, and a GroupDeleted’s cascade lists are empty. The relay fills them in.
For GroupCreated, pick the new subgroup’s id at random and name yourself as
admin; the id is inside what you sign, so the relay keeps it.
Delegable: MemberAdded, MemberRemoved, MemberLeft, MemberRoleSet,
MemberCapabilitySet, DefaultCapabilitiesSet, SubgroupVisibilitySet, group,
member and context metadata, ContextDetached, context capabilities; and, as root
ops, GroupCreated, GroupReparented, GroupDeleted, and MemberJoinedOpen for
yourself. A capability change may not grant or withdraw CAN_AUTHOR_ON_BEHALF.
Every peer applies the op as you, so your own role and capabilities decide:
MANAGE_MEMBERS to add or remove, admin to add an admin, CAN_CREATE_SUBGROUP
to create a subgroup. The relay needs only standing to act for members. A relay
that creates a subgroup for you is seated in it with that standing, and holds its
key, so it can serve it straight away. Nonces are spent per group and author
device. Refusals: 400 malformed, 403 not authorized (the reason is in the
body), 404 unknown group, 409 a subgroup id that already exists. Vectors are
under Test vectors.
3. Session — live
Section titled “3. Session — live”A device key obtains a session in two calls. Implemented by
#3930; enable the
account_proof provider on the node to use it.
Challenge
Section titled “Challenge”GET /auth/challenge{ "data": { "challenge": "<64 hex chars — 32 bytes>", "expires_at": 1789470000 // unix seconds; 60s from issue by default }, "error": null}The 32 bytes are expiry(8) ‖ nonce(8) ‖ tag(16), the tag an HMAC over the first two
under a key only the node holds. So the node stores nothing at issue time — which
matters, because this endpoint is unauthenticated and a per-request row would hand
an anonymous caller a write amplifier.
It is single-use, and spent only after a signature over it verifies. Burning it on presentation would let anyone who can see a challenge in flight invalidate it before its holder finishes signing.
404 means the provider is not enabled on this node.
LoginStatement
Section titled “LoginStatement”// domain calimero.auth.login.v1 · borsh · signed by the DEVICE keypub struct LoginStatement { pub node: PublicKey, // this node's identity, as the client pinned it pub audience: Audience, // WebOrigin(String) | CodeSigningId(String) | Cli pub challenge: [u8; 32], pub session_key: PublicKey, // the ephemeral key the session speaks with pub device_key: PublicKey, // what the signature is checked against pub issued_at: u64, pub expires_at: u64, pub signature: [u8; 64],}signature = sign(domain_hash(b"calimero.auth.login.v1", &[ node, // 32 bytes &audience_tag_and_bytes, // 1-byte variant tag, then the payload &challenge, // 32 bytes session_key, // 32 bytes device_key, // 32 bytes &issued_at.to_le_bytes(), &expires_at.to_le_bytes(),]))The audience tag is 0 for WebOrigin, 1 for CodeSigningId, 2 for Cli,
followed by the variant’s string bytes (empty for Cli). The tag is load-bearing:
without it a statement minted for the web origin "x" would verify as one minted
for the code-signing id "x".
The client learns the node’s identity from a pinned certificate, never from the
challenge — otherwise an attacker who can answer on the node’s behalf chooses what
the device signs about. That is the field’s whole purpose: a hostile relay can fetch
a real challenge from the target node and serve it to a user as its own, and only
node refuses the replay.
Exchange
Section titled “Exchange”The login goes through the node’s existing token endpoint, with
auth_method: "account_proof" — not a bespoke route. That is what gives a
device-key session the same refresh, revocation and rate limiting every other
session gets.
POST /auth/token{ "auth_method": "account_proof", "public_key": "<the session public key — must match the statement's session_key>", "client_name": "<the node URL, for node-binding the token>", "timestamp": 1789470000, "provider_data": { "challenge": "<64 hex chars>", "login_statement": "<hex borsh of LoginStatement>", "account_proof": "<hex borsh of AccountProof<DeviceCert>>" }}// 200{ "data": { "access_token": "<JWT; subject is the ACCOUNT, not the device>", "refresh_token": "<JWT>" }, "error": null}The session is granted to the account: the JWT’s sub is the account id, which
/auth/validate forwards to handlers as X-Auth-User.
Membership is not established by logging in. It is evaluated per request against the target context’s group — a relay serves several tenants, so a session must not carry a standing right to read.
4. Read — live
Section titled “4. Read — live”Implemented by #3931.
POST /admin-api/contexts/{context_id}/queryAuthorization: Bearer <session token>{ "method": "get", "argsJson": { "key": "a" }}// 200{ "data": { "returns": { /* the method's return value */ } } }Three refusals a client must handle distinctly, because they mean different things to a user:
| Status | Meaning | What the client should do |
|---|---|---|
401 |
no session, or it expired | re-run the login exchange |
403 |
the account is not a member of this context’s group | tell the user; do not retry |
409 |
the method is not read-only | a bug in the client’s method choice — this is a write, mint a warrant |
That last one is the one to get right. Reads are gated on the method being declared
read-only, and the gate fails closed: a method that declares nothing is refused
rather than guessed at. See #3932
for how that declaration is derived from the method’s receiver — which is why the
gate is usable at all, and why a client can call an ordinary &self method without
the app author having annotated anything.
Three things about this endpoint are worth knowing before building against it.
The account is never in the body. It comes from the session, so there is no
field a caller could set to read as somebody else. A session that is not
account-anchored — a node-owner or client-key token — gets 401 here, not because
it lacks permission but because this route answers “what may this account see”
and such a session names no account. A node owner reads through the ordinary
execute path.
Membership is re-checked on every call, against the group owning the context, never cached from the session. One relay serves several tenants, so a session that carried a standing right to read would keep serving somebody after the removal op the node has already applied. Inheritance resolves, so a subgroup member reads a context they hold through a parent.
No rootHash in the response, unlike the intent reply. That field answers “did
this change anything?” and for a read the answer is structurally no; returning it
would invite a client to watch it for changes that can never come.
5. Inventory — what this account can reach
Section titled “5. Inventory — what this account can reach”A client that runs no node still has to find out which namespaces and contexts it may read or write, or every id has to be handed to it out of band. Three routes answer that, all with the session token and nothing else:
GET /admin-api/namespaces → the namespaces this account is a member ofGET /admin-api/contexts → the contexts inside them (each with its groupId)GET /admin-api/contexts/{ctx} → one context, if this account is a memberAll three resolve the caller’s groups from the store on every request and return only what those admit. Membership is never read off the session: a namespace or context disappears from the listing when the removal op lands, not when the token expires. A context whose owning group cannot be resolved is excluded, not included — the listings fail closed.
GET /admin-api/contexts/{ctx} applies the same rule to a named id, so a context
the listing hides cannot be reached by guessing its id: the answer is 403, and
404 means the node genuinely does not hold it.
Node-owner sessions, and nodes running with no auth guard at all, keep the node-wide view of all three — narrowing there would empty the endpoints on every single-tenant node without closing anything.
6. End to end
Section titled “6. End to end”1. GET /api/cloud/me/namespaces/{ns}/relays → relay_url, executor_account2. GET /auth/challenge → challenge3. sign LoginStatement with the DEVICE key4. POST /auth/token (auth_method=account_proof) → session token5. GET /admin-api/namespaces → my namespaces GET /admin-api/contexts → my contexts, with groupId6. GET /admin-api/contexts/{ctx}/intents → canAuthorOnBehalf? ├── false → stop; ask an admin of groupId for the grant └── true → continue7. READ POST /admin-api/contexts/{ctx}/query (token; no warrant)8. WRITE sign a Warrant with the DEVICE key POST /admin-api/contexts/{ctx}/intents (token + warrant + authorProof)Every step works today.
On a TEE relay, steps 6 and 8 can travel sealed to the relay’s attested key, so
the warrant and the arguments are readable only inside its TD. Attest with
POST /admin-api/tee/attest (bindTransportKey, includeCollateral), verify
the quote, then send those requests through /sealed/v2. On a relay that leaves
auth to a proxy (hosted nodes), the session steps (2–4) and reads (5, 7) cannot
be sealed. See how a client reaches a node.
Test vectors
Section titled “Test vectors”Check your implementation against these before pointing it at a node. They are asserted by the Rust tests named beside each block, so they cannot drift from the code without CI going red.
domain_hash
Section titled “domain_hash”crates/account/src/tests/domain.rs::domain_hash_has_known_answers
| domain | parts | digest |
|---|---|---|
"" |
— | af5570f5a1810b7af78caf4bc70a660f0df51e42baf91d4de5b2328de0e83dfc |
"ab" |
["c"] |
43ee655579de01ca739b3f95c1c2d3f46d353b2c0df818064ea594506cdb2617 |
"a" |
["bc"] |
9a8acca1b6c6c0befd3fbc756aed625da998c998f7252e738c4ef061906b9b21 |
Start with the first row. It is SHA-256 of eight zero bytes — the u64le
length of an empty domain — not SHA-256 of the empty string. If you get
e3b0c442…, you are omitting the length prefixes, and nothing below will match.
The last two rows are the whole reason the prefixes exist: both hash the letters
abc, and they must differ.
Warrant
Section titled “Warrant”crates/account/src/tests/warrant_wire_fixture.rs
Inputs: context = 0x11×32, author_account = 0x22×32, executor =
0x33×32, app_version = 0x44×32, method = "set", args =
{"key":"k","value":"v"} (exactly those bytes, no whitespace), account_heads =
[0x55×32], governance_floor = [0x66×32], nonce = 42, not_after =
1700000000, author device secret = 0x07 seeded.
intent_hash dc066cc8524c74dc21714174009df536376e3151f5b92f0a676defde599dbae5signing preimage cd03cfaad8fb5f5aa143c76f35f39363b6608b820ab318a53ed01be901c7c6c7author_device_key ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22cThe full 351-byte encoding, field by field:
offset field bytes value 0 context 32 1111…11 32 author_account 32 2222…22 64 author_device_key 32 ea4a6c63… 96 executor 32 3333…33 128 app_version 32 4444…44 160 method len 4 03000000 (3, little-endian) 164 method 3 736574 ("set") 167 intent_hash 32 dc066cc8… 199 account_heads len 4 01000000 (1, little-endian) 203 account_heads[0] 32 5555…55 235 governance_floor len 4 01000000 (1, little-endian) 239 governance_floor[0] 32 6666…66 271 nonce 8 2a00000000000000 (42, little-endian) 279 not_after 8 00f1536500000000 (1700000000, little-endian) 287 signature 64 4007d416…v1 was pure fixed-width concatenation — no length prefixes, no tags — and v2 gives
that up on purpose: method is a String and the two head lists are Vecs, so
three u32 little-endian counts now sit inside the encoding. The offsets above hold
only for these inputs; a method of another length moves everything after it.
Note the two widths. The counts inside the borsh encoding are u32; the counts
the signing preimage hashes (above) are u64. They are different serializations
of the same numbers and a builder that reuses one for the other produces a warrant
that is well-formed and verifies nowhere.
Every integer here is little-endian, in the encoding and in domain_hash alike.
That is the second most common cross-language mistake after the missing length
prefix.
Creation warrant
Section titled “Creation warrant”crates/account/src/tests/creation_wire_fixture.rs
Inputs: group = 0x11×32, seed = 0x12×32, author_account = 0x22×32,
executor = 0x33×32, application_id = 0x44×32, service_name absent,
name = "general", init args = {"name":"general"}, account_heads =
[0x55×32], governance_floor = [0x66×32], nonce = 42, not_after =
1700000000, author device secret = 0x07 seeded.
init_hash 074dc4be8c7abe685532a48947430edd0301b42f60222e715a834e723d2a055esigning preimage f838cd94995573a7fea9768a82b6207632c4a89ce37c860e0d05febf5609644fauthor_device_key ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22cencoding 389 bytes; service_name encodes as 00, name as 01 07000000 67656e6572616csignature 7e47c0655b1b0ef65a420ef301f6888328579abc153b122d9e7f6396c8757aa5 14d8a4e65ff85912dfb20f6858fcdbfc60818214ca2e6adcbfbf4dae3d9fd906Governance warrant
Section titled “Governance warrant”crates/account/src/tests/governance_wire_fixture.rs
Inputs: scope = 0x11×32, kind = 1 (root), author_account = 0x22×32,
executor = 0x33×32, op bytes = 010203, account_heads = [0x55×32],
governance_floor = [0x66×32], nonce = 42, not_after = 1700000000, author
device secret = 0x07 seeded.
op_hash d6bc121f9fcf7b85bea94d356d620c14dd8e1ae5fa2317cbcfc2c486cf04dfb3signing preimage 23110c9012218d6996c33991173280928db4cc030a77164a31a2b1e47946bf80author_device_key ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22cencoding 313 bytessignature 27a7c779c5d7aef80dcf7125c2ef733bb0f4bbe20d84a75d0b132516d3b2a1a0 0e292b674f927df135d6f631dc7768f2e40a0c1c1040f5fc923385d2b5b05e07Real ops, from delegated_governance_op_vectors in crates/governance-types:
MemberAdded { member: 0x44×32, role: Member } op 01 44…44 01 op_hash c48dce4ba9da20980a86832b133e7040ec67c293987c0f131140291a71041cd6 (kind 0)MemberRemoved { member: 0x44×32 }, delegable form op 02 44…44 00×32 00000000GroupCreated { group_id: 0x55×32, parent_id: 0x11×32, restricted: true, admin: 0x22×32 } op 00 55…55 11…11 01 22…22 op_hash 0ae00fae1b87e3b0f285246632ebe31e2e3b687a563a1f5299be997657dfd55f (kind 1)