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.
The attestation document
Section titled “The attestation document”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.
What the verifier checks
Section titled “What the verifier checks”attest::verify (transport) and verify_receipt_key (receipts) run the same
steps (verify_report):
- Mock quotes are accepted only when the policy says
allowMock(a development cluster), and then only their report data is checked. - A measurement policy is required for a real quote.
Measurements::None, the default, refuses every real quote.Pinnedchecks fixed MRTD/RTMR allowlists.Releasereads the release the node names, refuses it if it is older than the floor (min_version, elseDEFAULT_MIN_RELEASE=2.3.109), fetches that release’spublished-mrtds.jsonand 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. - 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. - Policy: the TCB status is allowed (
UpToDateunless 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.
Which KMS a node took its keys from
Section titled “Which KMS a node took its keys from”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 withmerod kms disk-key --evidence-outbefore 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.
Peers are judged the same way
Section titled “Peers are judged the same way”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).
Receipts
Section titled “Receipts”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 JSONsignature = 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.
Account proofs
Section titled “Account proofs”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 keyAccountProof { statement, key, signature }statement = { cluster, node, account, nonce, audience, issuedAt, expiresAt } // expiresAt = issuedAt + 300signature = 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.
Job nodes
Section titled “Job nodes”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.
Verifying the whole app cluster
Section titled “Verifying the whole app cluster”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.
Release attestations
Section titled “Release attestations”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.
Generating quotes
Section titled “Generating quotes”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.