Skip to content

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 with calimero-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, /provision and /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.

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.

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.

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.

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 owner
mero-agent-provision provision --gate <url> --policy <agent-policy.json> \
--agent <id> --key owner.key --secrets secrets.json
  1. 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.
  2. 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.
  3. 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_slot if every slot is taken). A claimed agent takes bundles from its owner’s key only (403 otherwise). The gate writes each secret to /mnt/agent/agents/<n>/secrets/<NAME>, readable by the gate (root) and that slot’s group only.
  4. 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.

mero-agent-provision release --gate <url> --policy <agent-policy.json> \
--agent <id> --key owner.key

The 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.

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-kms does this for the reference agent image of every profile, best effort, and publishes the signed kms-agent-attestation-policy.<profile>.json. Post-release agent e2e then runs the whole chain on the release (release pipeline).
  • Before merging. node-image-mkosi.yaml with role: agent builds 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.