Skip to content

Attestation & receipts

Every node runs inside an Intel TDX VM built from a signed mero-tee release. Before a client or another node sends it anything, it asks the node for a quote — a hardware-signed statement of what the VM is running — and checks it. The check lives in one place, calimero_client::attest, shared by the Rust client, meroctl, calimero-client-py and every node judging its peers; mero-js’s src/cloud-v2/attestation.ts implements the same check.

GET /attestation?nonce=<32 bytes hex>[&app=<app>|&appHash=<hex>][&purpose=receipts|kms|jobs] answers, unsealed:

Field Meaning
quote The TDX quote, base64 (a mock quote on --dev nodes)
transportKey The node’s X25519 transport key, hex
appHash A bundle hash bound into the quote (zeros when none was asked)
nonce The caller’s nonce, echoed
release { version, profile }: the mero-tee release and image profile the node says it runs
cluster, node, mock The cluster name, node id, and whether the quote is a mock
receiptKey, governance With purpose=receipts only: the node’s receipt key and the cluster’s governance
kms With purpose=kms only, and only when the node has a record: what it verified of the mero-kms replica each key came from (below)
jobKey With purpose=jobs only, on a job node: its Ed25519 job signing key

The quote’s 64 bytes of report data bind the request to the key:

transport: report_data = nonce ‖ SHA-256("calimero.tee-attest.transport-key.v1" ‖ app_hash ‖ transport_key)
receipts: report_data = nonce ‖ SHA-256("calimero.tee-attest.receipt-key.v2" ‖ receipt_key ‖ SHA-256(governance JSON))
kms: report_data = nonce ‖ SHA-256("calimero.tee-attest.kms-evidence.v1" ‖ transport_binding ‖ SHA-256(JCS(kms)))

governance JSON is Governance::canonical: its fields in declaration order, deployments and deployers only when not the defaults (Deployers).

transport_binding is the second half of the transport form, so a kms quote still attests the transport key; it also commits to the kms record.

The nonce makes the quote fresh: an old quote cannot be replayed. The second half commits the quote to a key that lives only in that VM’s memory. A caller that verifies the quote therefore knows the key belongs to a process running the measured image, and nobody else can open what is sealed to it.

attest::verify (transport) and verify_receipt_key (receipts) run the same steps (verify_report):

  1. Mock quotes are accepted only when the policy says allowMock (a development cluster), and then only their report data is checked.
  2. A measurement policy is required for a real quote. Measurements::None, the default, refuses every real quote. Pinned checks fixed MRTD/RTMR allowlists. Release reads the release the node names, refuses it if it is older than the floor (min_version, else DEFAULT_MIN_RELEASE = 2.3.109), fetches that release’s published-mrtds.json and verifies its Sigstore signature (calimero-tee-release), and requires the quote’s MRTD and RTMRs to match one of the allowed profiles. Debug profiles are dropped from the allowed list unless the caller asked for them: a debug TD lets the host read its memory.
  3. The quote is genuine: DCAP verification against Intel’s collateral (calimero-tee-attestation), the TD is not a debug TD, and the report data is exactly the expected 64 bytes.
  4. Policy: the TCB status is allowed (UpToDate unless the caller allows more), and the measurements match the profile found in step 2.

Only then does the client open a sealed session to the transport key.

A node’s quote proves it runs code that refuses a KMS outside the signed mero-kms release policy. It does not say, by itself, which KMS that code accepted. So the node keeps what it verified each time it took a key: the KMS quote’s MRTD and RTMR0–3, its TCB status, the release and profile of the signed policy it matched, the KMS URL’s host, the quote’s SHA-256 and the time (calimero_tee_attestation::KmsEvidence, recorded by calimero-kms-client). There is one record per key:

  • storage: the storage key merod fetches itself at start (--kms-url).
  • disk: the data disk’s key, which the image’s boot script fetches with merod kms disk-key --evidence-out before merod starts; merod reads that record from a tmpfs/ramfs file (--kms-disk-evidence), where only the TD can have written it.

Asked with purpose=kms, a node that has a record returns it as kms and binds SHA-256(JCS(kms)) into its quote (kms_report_data). attest::verify checks that binding whenever a document carries kms, so a relay or the host that swaps, adds or strips the record fails the check that attests the transport key, and a record that passes is as trustworthy as the node’s own code. The public attestation verifier (mero-tee attestation-verifier) shows it as “Keys from a verified KMS” and checks the KMS’s registers against the kms_allowed_* lists of the release it names, as it does on /kms.

The binding is opt in, so nothing changes for a client that does not ask: its report data is the transport form above, byte for byte. A node with no record (--dev, a KMS pinned by config rather than a signed release, or a merod before this) answers purpose=kms with the transport form and no kms, which a verifier shows as “not reported”, not as a failure.

A node opening a channel to a peer uses the same check with its own policy: Release with --peer-profiles (default: its own MERO_TEE_PROFILE) and --peer-min-release. A node trusts debug-profile peers only when it runs a debug image itself. What the peer’s verified attestation named as its release is remembered and decides whether the peer may receive an app’s data (release approvals).

The check runs the other way too. A node calling a peer proves its own attested transport key in the handshake (Noise IK), and the receiving node attests the caller’s registered address with a fresh nonce, under its own peer policy: the caller is the node whose quote names that key, on the release that quote names. So a node knows which node calls an internal route and on which release, and judges it as it judges where to send (Networking). The same goes for sessions: a node takes a session token only under the receipt key it attested (purpose=receipts) for the node that signed it, ahead of time for every registered peer (Failover).

Every successful /intents and /views answer carries a receipt: the answering node’s signed statement that entry index of app app produced this result.

Receipt { app, index, resultHash, node, key, signature }
resultHash = hex(SHA-256(JCS(result))) // RFC 8785 canonical JSON
signature = Ed25519(receipt_key,
"calimero.receipt.v1\0" ‖ app ‖ 0 ‖ index (u64 BE) ‖ resultHash ‖ 0 ‖ node (u64 BE))

The receipt key is an Ed25519 key each node makes on its first start and keeps in its data directory wrapped under its K_r (signing_key.rs), so a restart keeps it and only the release whose mero-kms gives that K_r opens it. It also signs the node’s sessions (Sessions). It is attested on its own (purpose=receipts), together with the cluster’s governance. So a receipt is checkable later, offline, by anyone who holds that node’s receipt-key attestation: it shows that a genuine node of a cluster deployed under those rules returned that answer.

A third party (Calimero Cloud’s console, which records which account on a cluster is a developer’s) can learn which account a person controls without taking their word for it. It hands the person a fresh nonce; their browser, signed in with a passkey, asks the node to sign it:

POST /auth/account-proof { nonce, audience } // a passkey session; not an API key
AccountProof { statement, key, signature }
statement = { cluster, node, account, nonce, audience, issuedAt, expiresAt } // expiresAt = issuedAt + 300
signature = Ed25519(receipt_key, "calimero.account-proof.v1\0" ‖ JCS(statement))

account is the session’s own; the body names only the nonce (16 to 128 characters) and the audience (1 to 128), each in letters, digits and -_.:/. A session opened on an app’s frontend or a registered origin is refused, so an app cannot vouch for its user to someone else. The third party checks the signature under that node’s attested receipt key: from its own purpose=receipts attestation, or, as the cluster’s operator, from GET /ops/nodes/{node}/receipt-key ({ node, key, release, attestedAt }: the key the answering node attested for that peer, or its own), read over its attested channel. Then it checks the statement: its cluster, its own nonce (once), its own audience, and expiresAt. calimero_client::attest::AccountProof::verify checks the signature.

A job node (merod run --job-node) runs compute for a cluster’s apps and holds no replica, vote or cluster key. GET /attestation?purpose=jobs on it binds its transport key and its job signing key in one quote. The report data is nonce ‖ SHA-256("calimero.tee-attest.job-key.v1" ‖ transportKey ‖ jobKey ‖ release.version ‖ cluster ‖ node), so a quote for one key pair cannot vouch for another, nobody on the path can rewrite the release the leader judges the node by, and a quote cannot be replayed as another node or cluster. A job node attests nothing else: GET /attestation without purpose=jobs, or with any other purpose, is 404. So the channels a cluster node opens after attesting a transport key (the cluster key’s delivery, peer requests carrying the cluster secret, Raft, blob pushes) cannot open to a job node. calimero_client::attest::verify_job_key checks it with the same quote and measurement policy as every other attestation. A cluster node answers purpose=jobs with 404. The cluster’s leader attests a job node this way, under its --peer-profiles and --peer-min-release, and uses it only if the release it names is one the app’s owner approved. Clients never need it.

GET /apps/{app}/attestation?nonce= asks every replica of the app for its receipt-key attestation over the caller’s nonce and returns them together. Client::verify_app_cluster checks each one with the client’s policy, checks each replica answers as the node it is listed as, and requires every replica to attest the same governance. It answers the attested receipt key per node and that governance. A replica that does not answer, or does not pass, fails the whole check.

What a user learns from it:

  • every node that holds the app’s data is a genuine TD on an image they trust;
  • which keys sign the app’s receipts (a receipt from any other key is not one to trust);
  • who may move the app to new code, approve images, and approve outbound destinations (Deployment governance).

The entry node gathers the documents but cannot forge them: each quote binds the caller’s fresh nonce and is verified against Intel’s collateral.

A node fetches its key from mero-kms when it starts, before it serves anything, and mero-kms releases a key to any TD whose quote matches the signed release policy. So by the time a node answers /attestation, the key is already out. A manager that wants to decide first (is this the image and merod it meant to start?) asks for a release attestation, which merod attest makes with no key, no store and no running node:

report_data = nonce(32) || SHA-256("calimero.tee-attest.release.v1"
|| for each of merodVersion, merodSha256, teeRelease, teeProfile, identity:
u32 BE length || UTF-8 bytes (length 0 when null))

The claim is what the node says it is: its merod release and executable digest, the mero-tee release and profile the image names, and, when it has one before the KMS, the peer id of the identity it will ask the KMS a key for. The claim proves nothing by itself; the quote’s registers do, and the claim is bound so it cannot be swapped under them. The domain tag keeps it from ever equalling a transport, receipt-key or admission quote’s second half.

calimero_client::attest::release::verify_release runs the same quote check as every client (mock only with allowMock, DCAP, the measurement policy, a Release policy reading the release the claim names, the TCB status), checks the nonce and the report data, and refuses an envelope whose copy of the registers, or whose claimed profile, is not what the verified quote shows. meroctl attest-release prints its verdict. A verifier that is not Rust gives calimero-tee-verify the quote, its nonce and the second half above as app_hash_hex, and checks the reported registers against the release’s signed published-mrtds.json itself.

A booting TD that has no key yet serves it with merod attest --serve ADDR (GET /attestation/release?nonce=), so the manager attests it over its own fresh nonce; the boot script stops it before merod run takes the address.

A TDX node produces quotes through the kernel’s TSM configfs interface (calimero_tee_attestation::generate_attestation). A --dev node (and merod local) produces mock quotes with the same report-data layout, which a client accepts only with allowMock; meroctl node add … --allow-mock keeps that permission for one saved node only. A node without --dev always produces real quotes and refuses mock quotes from its peers.