Private Agents
A private agent runs as a third TDX image role, agent, beside nodes and the KMS.
Nobody but the agent can read its secrets, signing key or state, and anyone can check
which build runs. The rationale, the trust that remains and the open decisions are in
the design.
The image holds three programs. One TD hosts several owners’ agents, each in its own slot (see Agents on one TD):
agent-init(boot): extends RTMR3 withcalimero-rtmr3-v3:agent:<profile>:<roothash>(the dm-verity root hash), then opens the encrypted data disk with a key from mero-kms. Its role string is what the KMS’s agent policy entry matches.mero-agent-gate: serves/attest,/provisionand/release. It holds each agent’s signing key and takes each agent’s secrets, so an agent itself can be any program, open or closed.mero-agent: the agent, one per slot. It reads its slot’s secrets from files and signs warrants with its attested key.
Boot and the disk key
Section titled “Boot and the disk key”agent-init runs the same merod kms disk-key handshake a node does, against the
release’s KMS. The KMS matches the quote against its policy entries. Only the
agent entry matches an agent image, so the key is derived under
mero-agent/storage/<profile>/<identity> (see
Key Release). An agent can never be released a node’s key.
The disk is LUKS2 with hmac-sha256 integrity. A blank disk is formatted; a LUKS disk
is opened; anything else stops the boot, including a plain ext4 disk. A new release has
a new KMS root, so a disk from an earlier release does not open. The disk is a cache:
anything that must outlive a release lives in Calimero or is provisioned again.
Instance metadata carries addresses only:
| Key | Meaning |
|---|---|
kms-url |
The mero-kms the disk key comes from. Required unless ephemeral-store. |
tee-release-version |
The mero-kms release merod verifies that KMS against. Never older than the image. |
relay-url |
The relay node the agent sends warrants to. |
ephemeral-store |
true: state in TD memory (a tmpfs), no KMS. For the release pipeline’s measurement VM. |
logs-endpoint, metrics-endpoint, observability-token |
As on the KMS. The agent’s own unit is not shipped. |
Agents on one TD
Section titled “Agents on one TD”One agent TD hosts several owners’ agents, one per slot. Each agent has an
agent id ([a-z0-9][a-z0-9-]{0,62}), chosen by whoever places it (mdma), and is
claimed by its own owner. The image bakes the number of slots (mero_agent_slots).
Slot n has:
- its own Unix user and group,
mero-agent-<n>, and its own unit,mero-agent@<n>.service; - its own directory on the encrypted disk,
/mnt/agent/agents/<n>/, holding its claim, signing key and secrets (readable by the gate and that slot’s group only) and its state (writable by that slot’s user only).
A slot’s unit writes only its own state and sees no other process
(ProtectProc=invisible). The gate never hands one agent another’s key. What remains
shared is the TD’s kernel: an agent that escapes its sandbox reaches the others on
the same TD. An owner who wants that isolation too runs their agent on its own TD.
Attestation
Section titled “Attestation”POST /attest {"nonceB64": <32 bytes>, "agentId": "<id>"}→ {"quoteB64", "reportDataHex", "provisioningPublicKeyB64", "agentId", "signingPublicKeyB64", "ownerPublicKeyB64"}
report_data = nonce ‖ SHA-256("mero-agent-gate/attest/v3" ‖ provisioning_pub ‖ len(agent_id) ‖ agent_id ‖ signing_pub ‖ owner_pub)The provisioning key (X25519) is generated in memory at every start of the gate,
and shared by the TD’s agents. The agent’s signing key (Ed25519) is generated when
the agent is claimed, and kept in its slot on the encrypted disk. Its owner key is
the X25519 key that claimed it (see below). While the agent is unclaimed, both are 32
zero bytes and absent from the response. One quote proves which signing key and owner
that agent id has, on a TD running the measured image. Without agentId, the quote
names no agent: the release pipeline measures the TD that way.
Before authorizing the signing key as the agent’s device, an account owner runs
mero-agent-provision attest --gate <url> --policy <agent-policy.json> --agent <id>,
or uses the Agent tab of the attestation verifier. Both verify the quote, the
nonce, the agent id and key binding, and the registers against the agent policy, and
only then show the key and its owner. Authorize the signing key only when the owner
shown is your own key.
Provisioning secrets
Section titled “Provisioning secrets”The image is public, so it holds no secrets. They arrive sealed to the TD:
mero-agent-provision keygen --out owner.key # once; the key stays with the ownermero-agent-provision provision --gate <url> --policy <agent-policy.json> \ --agent <id> --key owner.key --secrets secrets.json- The CLI verifies the gate’s quote for that agent as above, and refuses an agent whose quote binds another owner. Nothing is sent before both pass.
- It seals
{"secrets": {"NAME": "value"}}with RFC 9180 HPKE (DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20-Poly1305) to the provisioning key. It uses Auth mode under the owner’s X25519 key. - The first valid bundle for an unknown agent id claims a free slot for it: the gate
makes the agent’s signing key and stores the sender’s key in the slot before
writing any secret (
503 no_free_slotif every slot is taken). A claimed agent takes bundles from its owner’s key only (403otherwise). The gate writes each secret to/mnt/agent/agents/<n>/secrets/<NAME>, readable by the gate (root) and that slot’s group only. - The CLI attests again and checks the fresh quote binds its key as the owner.
The HPKE info binds the message to the recipient key and the agent id. A message
cannot be replayed to another TD, to another agent, or after the gate restarts; a
provisioner whose /attest predates a restart gets 409 stale_provisioning_key and
attests again.
No provisioner is baked into the image. Neither Calimero, which builds and runs it, nor anyone else chooses who sets an agent’s secrets: its first owner does. The claim lives on the encrypted disk, so it survives reboots and ends with the disk or a release. Whoever reaches a fresh agent id first can claim it, but that is never silent: the claimant’s key is in every quote for that agent, so the real owner sees “not yours”, sends nothing and is placed again. Claim an agent as soon as it is placed.
Releasing an agent
Section titled “Releasing an agent”mero-agent-provision release --gate <url> --policy <agent-policy.json> \ --agent <id> --key owner.keyThe owner seals {"release": true} for the agent, in the same way. The gate stops the
slot’s unit, erases the slot’s claim, signing key, secrets and state, and starts the
unit again, empty, for the slot’s next owner. The CLI attests again and checks the
quote binds no owner. Any other key gets 403.
Measuring and releasing an agent image
Section titled “Measuring and releasing an agent image”No KMS can list an agent image before it is measured, so each measurement boots the
image with ephemeral-store=true and has Intel Trust Authority verify its quote. The
result, agent-attestation-policy.<profile>.json, is what a KMS image serving agents
is built with (kms_agent_policy_file).
- Release.
Release mero-kmsdoes this for the reference agent image of every profile, best effort, and publishes the signedkms-agent-attestation-policy.<profile>.json. Post-release agent e2e then runs the whole chain on the release (release pipeline). - Before merging.
node-image-mkosi.yamlwithrole: agentbuilds the agent image from a branch, with the stub agent or (agent: mero-bot) the pinned mero-bot bundle, measures it, claims an agent on it with a throwaway owner key and provisions a throwaway secret. A KMS that serves the agent is built only by the release.
merod trusts only a KMS whose policy a release signed. A branch can therefore prove each half on hardware, but an agent getting its disk key from a KMS is first seen on a release.