Configuration Reference
This page enumerates every knob that shapes a Mero TEE deployment: the runtime
environment of the mero-kms key-management service, the instance metadata a
KMS replica reads, merod’s [tee.kms] client section, and the build inputs, Ansible variables and
versions.json that produce the node and KMS images. Defaults are taken
directly from the code and vars files, not from convention.
KMS environment variables
Section titled “KMS environment variables”The mero-kms binary reads all of its configuration from the process
environment at startup (Config::from_env, mero-kms/src/config/mod.rs). Every
value has a default except where noted; the parser fails fast on a malformed
value.
In production none of this is set by an operator. The KMS image bakes its
environment into /etc/mero-kms/kms.env at build time
(ansible/roles/mero-kms/templates/kms.env.j2), so it is part of the image’s
measurements. The only values that come from outside are the two cluster keys
that kms-init reads from instance metadata and hands over as
MERO_KMS_BOOTSTRAP and MERO_KMS_PEERS (below).
Changing anything else means building a different image, which a running
cluster refuses to join.
Network
Section titled “Network”| Variable | Type / Default | Meaning |
|---|---|---|
LISTEN_ADDR |
SocketAddr — 0.0.0.0:8080 |
Address and port the HTTP server binds. An unparseable value silently falls back to the default. The image bakes 0.0.0.0:8080 (kms_listen_port); the service is plain HTTP, reachable only inside the VPC. |
CORS_ALLOWED_ORIGINS |
CSV — (unset → CORS disabled) | Comma-separated allowed CORS origins. When unset or empty, no CorsLayer is installed at all. Each origin must be a valid header value or startup fails. |
Logging
Section titled “Logging”| Variable | Type / Default | Meaning |
|---|---|---|
RUST_LOG |
tracing filter — info |
Log filter. The image bakes info. mero-kms never logs the root or any derived key. |
Challenges
Section titled “Challenges”| Variable | Type / Default | Meaning |
|---|---|---|
CHALLENGE_TTL_SECS |
u64 — 60 |
Lifetime of a challenge in seconds. After expiry it can no longer be redeemed. |
MAX_CONSUMED_CHALLENGES |
usize — 10000 |
How many used, unexpired challenges one replica remembers in order to refuse their replay. Only challenges that released a key count. When full, /get-key answers 429 rate_limited. Must be greater than 0 or startup fails. |
Challenges are stateless across replicas (mero-kms/src/stateless_challenge.rs).
The challengeId is 16 random bytes, the expiry and a 32-byte tag (112 hex
characters). The tag and the nonce are HMAC-SHA256s over the random bytes, the
expiry and the peer ID, under separate domains and keyed with a challenge key
HKDF’d from the cluster root under a salt of its own
(mero-kms/tdx-root/challenge-mac/v1), so no node key path can name it. Any
replica behind the cluster’s URL recomputes the nonce of a challenge another
replica issued, and a challenge yields the right nonce only for the peer that
asked for it. An expiry later than now + CHALLENGE_TTL_SECS plus 30 seconds of
clock skew is refused, and so is an ID whose tag does not verify: made-up IDs
are rejected before any other check. Each replica remembers a challenge only once
the request redeeming it has verified and released a key, until it expires, so a
request that fails takes no place in the replay set.
There is no shared store and no Redis.
Profile & key derivation
Section titled “Profile & key derivation”| Variable | Type / Default | Meaning |
|---|---|---|
MERO_KMS_PROFILE |
string — locked-read-only |
KMS profile cohort. One of debug, debug-read-only, locked-read-only. If an image-profile pin file is present it must match (see below). |
KMS_POLICY_PROFILE |
string — (deprecated alias) | Legacy alias for MERO_KMS_PROFILE. If both are set they must agree; using it alone logs a deprecation warning. |
KEY_NAMESPACE_PREFIX |
string — merod/storage |
Namespace prefix for node key-derivation paths. Surrounding slashes are trimmed. Node keys are HKDF’d from the cluster root on {prefix}/{profile}/{peerId}. |
AGENT_KEY_NAMESPACE_PREFIX |
string — mero-agent/storage |
Namespace prefix for agent key-derivation paths, used for a quote that matched the agent allowlist. Must differ from KEY_NAMESPACE_PREFIX or the KMS refuses to start, so no agent can be released a node’s key. |
Node allowlist (baked)
Section titled “Node allowlist (baked)”| Variable | Type / Default | Meaning |
|---|---|---|
ENFORCE_MEASUREMENT_POLICY |
bool — true |
Whether MRTD/RTMR/TCB measurement checks are enforced. false parses quotes but skips policy checks — never safe for production; the image bakes true. |
MERO_KMS_REQUIRE_SEALED_KEY_RELEASE |
bool — true |
Refuse /get-key requests that do not ask for the key to be sealed to the node (sealToB64). An unsealed key is readable by whatever sits on the path to the KMS. The image bakes true. Setting it false serves merods older than 0.11.0-rc.47 (which cannot unseal) and reopens that hole for them. |
ALLOWED_TCB_STATUSES |
CSV — uptodate |
Allowed TCB status values (lowercased), for nodes and for joining replicas. |
ALLOWED_MRTD |
CSV — (empty) | Allowed node MRTD values. Each entry is a 48-byte (96 hex-char) TDX register value. |
ALLOWED_RTMR0 |
CSV — (empty) | Allowed node RTMR0 values (48-byte hex). |
ALLOWED_RTMR1 |
CSV — (empty) | Allowed node RTMR1 values (48-byte hex). |
ALLOWED_RTMR2 |
CSV — (empty) | Allowed node RTMR2 values (48-byte hex). |
ALLOWED_RTMR3 |
CSV — (empty) | Allowed node RTMR3 values (48-byte hex). |
Agent allowlist (baked, optional)
Section titled “Agent allowlist (baked, optional)”mero-kms can also serve a second image role, agent, from a second allowlist.
No release image bakes one: the agent image was retired with Calimero Cloud 1.0,
so release KMS images serve nodes only. Set all five or none: a partial agent allowlist
refuses startup, with enforcement on or off. Unset, the KMS serves nodes only.
The TCB status list above applies to agents too.
| Variable | Type / Default | Meaning |
|---|---|---|
AGENT_ALLOWED_MRTD |
CSV — (empty) | Allowed agent MRTD values (48-byte hex). |
AGENT_ALLOWED_RTMR0 |
CSV — (empty) | Allowed agent RTMR0 values (48-byte hex). |
AGENT_ALLOWED_RTMR1 |
CSV — (empty) | Allowed agent RTMR1 values (48-byte hex). |
AGENT_ALLOWED_RTMR2 |
CSV — (empty) | Allowed agent RTMR2 values (48-byte hex). |
AGENT_ALLOWED_RTMR3 |
CSV — (empty) | Allowed agent RTMR3 values (48-byte hex). |
Each allowlist is one policy entry. A quote passes only if all five of its registers are in the lists of one entry, and is released keys under that entry’s role: the node prefix for the node entry, the agent prefix for the agent entry. A quote mixing registers from both images matches neither.
Booleans accept 1/true/yes/on and 0/false/no/off (case-insensitive).
The ALLOWED_* values are the node image’s measurements for this profile, taken
from the node release’s published-mrtds.json at build time. This is the only
policy source: the KMS never fetches a policy at runtime. When enforcement is on,
every register allowlist of every entry (and the TCB status list) must be
non-empty or the service refuses to start (validate_policy_requirements,
mero-kms/src/policy.rs).
Cluster
Section titled “Cluster”The KMS runs as a cluster (design). Keys derive from a root that exists only in the memory of the cluster’s replicas; quotes come from configfs-tsm.
| Variable | Type / Default | Meaning |
|---|---|---|
MERO_KMS_BOOTSTRAP |
bool — false |
Generate the cluster’s root instead of joining. Set on exactly one replica, once, when the cluster is created. From kms-bootstrap metadata. |
MERO_KMS_PEERS |
CSV — (empty) | Base URLs of replicas to join from (http://10.0.0.5:8080). Untrusted: a wrong address only fails a join. A replica without MERO_KMS_BOOTSTRAP needs at least one. From kms-peers metadata. |
MERO_KMS_JOIN_RETRY_SECS |
u64 — 10 |
Pause between rounds of join attempts. |
A replica refuses to start in a debug TD, or when RTMR3 was never extended. It
answers /get-key with 503 policy_not_ready until it holds the root, and
/health reports clusterRootReady. It gives the root only to a replica whose
quote carries exactly its own MRTD and RTMR0–3, is not a debug TD, and has a TCB
status in ALLOWED_TCB_STATUSES (POST /cluster/nonce, then
POST /cluster/join; both sides attest). A join nonce is stateless: random
bytes, an expiry and an HMAC tag under a key the replica generates at startup, so
issuing them keeps nothing, and a nonce is remembered only once a join with it has
verified, which makes it single-use. The root is never written to disk and there
is no backup of it.
Development-only knob
Section titled “Development-only knob”| Variable | Type / Default | Meaning |
|---|---|---|
ACCEPT_MOCK_ATTESTATION |
bool — false |
Accept synthetic/mock TDX quotes. Only compiled in under the default-off mock-attestation Cargo feature. Production release binaries do not read this variable at all, and no mock code is present. |
KMS instance metadata
Section titled “KMS instance metadata”Read once at boot by kms-init (ansible/roles/mero-kms/templates/kms-init.sh.j2).
Metadata is written by whoever runs the VM, so it is untrusted, and every key
read is validated.
| Key | Meaning |
|---|---|
kms-bootstrap |
true on exactly one replica, the first of a new cluster, which generates the root. Anything other than true, false or empty stops the boot. Clear it (false) once the cluster has formed. |
kms-peers |
Comma-separated base URLs (http[s]://host[:port]) of replicas to join from. Required unless kms-bootstrap=true. |
logs-endpoint |
Optional. VictoriaLogs ingest URL, https:// except on a debug or debug-read-only image. vector ships the journal of mero-kms-init, mero-kms, vector and vmagent there, labelled instance_name="mero-kms". |
metrics-endpoint |
Optional. Remote-write URL, https:// except on a debug or debug-read-only image. vmagent ships node_exporter host metrics there, labelled instance_type="mero-kms" and instance_profile. Both listen on loopback only. |
observability-token |
Optional. Bearer token both shippers present. Read at boot only: rotating it means replacing the replicas, one at a time. |
A URL or token that could inject configuration (anything but a plain http(s)
URL or a plain token) turns shipping off for that replica; it never fails the
boot. Anyone who can set the endpoint can read what is shipped, so mero-kms logs
no key material (see “Logging” in mero-kms/README.md).
Image-profile pin (baked)
Section titled “Image-profile pin (baked)”| Path | Meaning |
|---|---|
/etc/mero-kms/image-profile |
Plain-text file containing the profile name (debug, debug-read-only, or locked-read-only). When present, any MERO_KMS_PROFILE env override must match it or startup is refused; an empty file also refuses startup. When absent (a local build), the profile comes from MERO_KMS_PROFILE. |
At boot kms-init extends RTMR3 with
calimero-rtmr3-v3:kms:<profile>:<roothash>, the same scheme the node image’s
calimero-init uses with role node. <roothash> is the dm-verity root hash
on the kernel command line (roothash=), so the event names the whole root
filesystem. It is fatal on locked-read-only, so a quote always names the
role, profile and root, and the KMS and node images can never measure alike.
Releases up to 2.3.118 extended calimero-rtmr3-v2:...:<calimero.root_hash>.
merod’s KMS client
Section titled “merod’s KMS client”merod 1.0 (in calimero-network/core) takes its storage key from mero-kms only
in TDX mode, through two flags that merod init writes to config.toml as
kms-url and kms-identity:
| Flag / key | Meaning |
|---|---|
--kms-url / kms-url |
The cluster’s VPC-internal URL. Node images write it from kms-url metadata at the node’s first boot. |
--kms-identity / kms-identity |
The identity (a protobuf-encoded libp2p Ed25519 keypair, 68 bytes) presented to the KMS, which derives the key per identity, so every node of a cluster must present the same one. Node images write it from kms-identity metadata (below) to /mnt/data/merod/kms-identity, on the encrypted data disk, 0600 and root-owned, before merod init; merod creates one only when that metadata is absent. |
The node image reads the cluster’s identity from kms-identity instance
metadata, which mdma mints once per cluster (new_kms_identity in
shared/mdma_common/cloud_v2.py) and stamps on every node of it:
| Metadata key | Value | When absent |
|---|---|---|
kms-identity |
Base64 of the protobuf-encoded libp2p Ed25519 keypair: 08 01 12 40, then the 32-byte secret and the 32-byte public key (68 bytes decoded, CAESQ… in base64). Read at the first boot only. Not base64, not 68 bytes, the wrong header, or a public key that is not the secret’s stops the boot, on every profile. |
locked-read-only with cluster set refuses to initialise merod (unless its store is memory-only); the debug profiles let merod generate a per-node identity and log that they did. |
It is not the data disk’s identity: the LUKS2 key comes from merod kms disk-key for a separate, per-node identity kept in the disk’s LUKS2 header,
so the cluster identity is needed only after the disk is open, and the disk
passphrase is never the KMS key a whole cluster derives its storage key from.
The value is never logged, and the log egress guard masks the keypair’s base64
and hex shapes.
merod trusts the KMS only if its quote matches the signed release the
environment names (MERO_KMS_RELEASE_TAG, else MERO_KMS_VERSION, else
MERO_TEE_VERSION, which the node images set from tee-release-version), for
the profile in MERO_TEE_PROFILE, and no older than MERO_TEE_MIN_VERSION.
It fetches and verifies that release’s signed
kms-attestation-policy[.<profile>].json (see
below). merod kms disk-key is the same client,
used by the image’s boot script to unlock the data disk.
| Flag / env | Meaning |
|---|---|
kms disk-key --evidence-out PATH |
Also write a record of the KMS the fetch verified (JSON, at most 16 KiB; tmpfs or ramfs only, and the file must not exist yet). The node images pass /run/calimero/kms-disk-evidence.json when the baked merod has the flag. |
run --kms-disk-evidence PATH / MEROD_KMS_DISK_EVIDENCE |
Report that record, bound into the node’s quote, beside the storage key’s KMS at GET /attestation?purpose=kms. The node images set the variable in /etc/calimero/merod.env after a fetch that wrote the record. Missing or unreadable: merod logs a warning and reports none. |
Deployment governance
Section titled “Deployment governance”merod 1.0 gives each of three decisions, the ones that let new code or a new destination reach an app’s data, to exactly one party (core’s deployment governance):
| Metadata key | merod flag | Decides | Values |
|---|---|---|---|
release-approvals |
--release-approvals |
which mero-tee releases (node images) may hold the apps’ data, so who approves a cluster upgrade to a new image | owner (default) or operator |
app-upgrades |
--app-upgrades |
who moves an app to another bundle | owner (default) or operator |
outbound-approvals |
--outbound-approvals |
who approves the URL prefixes an app may call out to | owner (default) or operator |
operator gives the decision to the operator, Calimero, through the Ops API,
for every app on the cluster. owner gives it to the parties around each app,
with a passkey, and the operator cannot: release approvals to the app’s
publisher (the organization its package’s signing key is bound to, else the
app’s own organization or account), app upgrades to whoever the package’s
signed manifest names (upgrades: owner, the default, or publisher), and
outbound approvals to the app’s owning organization or account. Who upgrades
an app is thus chosen per package, by its manifest, not per cluster: there is
no publisher value for these keys. mdma chooses per cluster when it creates
it, and the keys mean the same on a dedicated cluster and a shared one.
The values are fixed at the cluster’s genesis: the first node to lead the
system group writes the governance it was configured with into the system
state, a second genesis is refused, and every node’s receipt-key attestation
binds the state’s governance, which verify_app_cluster returns. So
calimero-init reads the keys on a node’s first boot only, into the merod init that writes its config.toml, on every node of the cluster, the
bootstrap node and the others alike: should the bootstrap node fail before
genesis commits, another founding node leads first and writes its own
configuration. Set the same values on every node of a cluster. On a node that
joins later they change nothing, and on every later boot they are ignored and
the boot logs that they were. A value other than owner or operator on a
first boot stops the boot before merod init, on every profile; the first
boot logs the governance it initialised with on one line, GOVERNANCE (fixed at the cluster's genesis, attested with every receipt key): ....
Who may deploy
Section titled “Who may deploy”Two more governance keys decide who may install apps on the cluster (core’s org-owned deployments spec). They are read, fixed, attested and logged exactly like the three above:
| Metadata key | merod flag | Decides | Values |
|---|---|---|---|
deployments |
--deployments |
whether any account may install apps, or only accounts and organizations holding a deployer grant | open (default) or granted |
deployers |
--deployers |
who grants deployers on a granted cluster: the operator, with the cluster’s operator key, or the cluster’s owner, once the operator names it |
operator (default) or owner |
mdma sets granted and operator on a shared cluster, so only Cloud
workspaces deploy on it, and granted and owner on a dedicated one, so only
the customer who bought it does. The operator may later move open to
granted through the Ops API, never back. These flags are newer than the
first merod 1.0 (merod 1.0.0-rc.5 and earlier do not take them): with either
key set and a merod that does not take its flag, the first boot stops before
merod init rather than found a cluster that is open to every account.
Who may sign up
Section titled “Who may sign up”One more key, read and fixed the same way, decides who may create an account on the cluster:
| Metadata key | merod flag | Decides | Values |
|---|---|---|---|
sign-up |
--sign-up |
whether anyone may create an account, only holders of an invite, or no one | open (default), invite or closed |
mdma sets invite or closed and leaves the key unset for open. The flag is
newer than --deployments (merod 1.0.0-rc.8 and earlier do not take it): with
the key unset nothing is passed, so such a merod boots as before; with it set
and a merod that does not take the flag, the first boot stops before merod init rather than found a cluster anyone may sign up to.
Image build inputs
Section titled “Image build inputs”The node and KMS images are built with mkosi from the Ansible roles, by
one build (mero-tee/mkosi-image/build.sh; ROLE picks which). Build inputs
come from versions.json and the build’s environment variables.
versions.json
Section titled “versions.json”The single source of pinned component versions (mero-tee/versions.json):
{ "traefikVersion": "3.5.0", "nodeExporterVersion": "1.9.1", "vmagentVersion": "1.132.0", "vectorVersion": "0.50.0", "imageVersion": "2.3.145", "merodVersion": "1.0.0-rc.15"}| Key | Meaning |
|---|---|
imageVersion |
The mero-tee release/image version. Must equal mero-kms/Cargo.toml package.version (enforced by the release version sync guard). |
merodVersion |
The calimero-network/core merod tag baked into the image. The node release’s preflight checks the tag exists (resolve-release-gate.sh). |
traefikVersion / nodeExporterVersion / vmagentVersion / vectorVersion |
Third-party components installed by the Ansible roles. |
build-inputs.lock.json
Section titled “build-inputs.lock.json”mero-tee/build-inputs.lock.json pins the sha256 of every archive the image
build downloads, for the versions versions.json names: merod and meroctl
from the core release (core 1.0 publishes no mero-auth), Traefik, node_exporter, vmagent and Vector.
group_vars/all.yml loads it as build_inputs. Every download in the Ansible
roles is a get_url with checksum: taken from it, so an archive replaced
upstream fails the build instead of being baked into an image. The same happens
to a version moved without a matching lock entry. Vector is installed from its
release archive, not by piping sh.vector.dev into a shell.
scripts/release/pin-build-inputs.sh # rewrite the lock from versions.jsonscripts/release/pin-build-inputs.sh --check # exit 1 if the lock is stalescripts/release/pin-build-inputs.sh --download # also hash each archiveThe script reads the checksum file each publisher ships with the release, such
as core’s SHA256SUMS. Review the lock change like code.
Two checks enforce the lock:
- CI’s
build-inputs-pinned-test.shrefuses a lock that disagrees withversions.json, and any download in the roles without a pinned checksum. - The node-image release preflight runs
--checkagainst the publishers.
Build environment
Section titled “Build environment”Read by mero-tee/mkosi-image/build.sh (and passed through by
scripts/release/mkosi/build-image.sh, which the releases run as root):
| Variable | Default | Meaning |
|---|---|---|
ROLE |
node |
node builds the node image (playbook.yml); kms the KMS cluster image (playbook-kms.yml), named merotee-kms-<profile>-<version> (dots in the version become dashes) in family merotee-kms-<profile>. |
PROFILE |
locked-read-only |
debug, debug-read-only or locked-read-only. Selects which Ansible roles run (lockdown and conformance only on locked-read-only) and the kernel command line. |
MERO_KMS_BINARY |
none | kms only: the mero-kms binary to bake in, built from the release commit. |
KMS_NODE_POLICY_FILE |
none | kms only: JSON with the node allowlist (allowed_tcb_statuses, allowed_mrtd, allowed_rtmr0..3) baked into /etc/mero-kms/kms.env, so it is part of the KMS image’s measurements. Each field holds 1-32 strings: 96 hex characters each, or TCB status names; the role refuses anything else. |
KMS_AGENT_POLICY_FILE |
none | kms only, optional: JSON with the agent allowlist, in the same shape, baked as AGENT_ALLOWED_*. Its allowed_tcb_statuses must equal the node file’s. Unset bakes no agent entry; the releases leave it unset. |
DOWNLOAD_CACHE |
OUTPUT_DIR/downloads |
Where the pinned release archives are downloaded and checked. |
The image’s version is versions.json imageVersion, and the image runs on
c3-standard-4 (Intel TDX).
Traefik on the node image
Section titled “Traefik on the node image”Traefik is a TLS front only: /etc/traefik/dynamic/routing.yml has one router that
sends every path, on :80 and :443, to merod on 127.0.0.1:2528 with no
middleware (no forwardAuth, rate limit, CORS or header rewriting). merod 1.0
authenticates its callers itself, inside the sealed channel, so Traefik never
sees a credential. See
What Traefik does on a 1.0 node.
scripts/ci/tests/traefik-passthrough-test.sh and the locked-read-only
conformance role pin that shape.
Its file provider watches /etc/traefik/dynamic, where
calimero-tls-cert-custom.service also writes custom-certs.yml, the
custom domains’ certificates (see
Custom-domain certificates).
v1’s play variables fleet_delegated_access and fleet_mdma_url, the
auth-node exemptions and device-key login went with mero-auth and the fleet
sidecar.
The base is Ubuntu 26.04 LTS (Resolute Raccoon), comfortably past the kernel
6.17+ that RTMR3 sysfs support needs, installed from a pinned snapshot of the
Ubuntu archive (Snapshot= in mero-tee/mkosi-image/mkosi.conf, with the tools
tree pinned to the same snapshot). Moving the snapshot changes every image’s
measurements.
The output image name is merotee-ubuntu-questing-25-10-<profile>-<version> in
family merotee-ubuntu-questing-<profile>. Those names intentionally still say
questing-25-10 after the base moved: mdma’s dispatcher resolves images by those
exact prefixes, so renaming them needs a paired mdma change and a deploy ordering.
Read the name as an identifier, not as a statement about the base release.
Attestation-policy JSON
Section titled “Attestation-policy JSON”Published by the Release mero-kms workflow in release mero-kms-v<version>,
consumed by merod, not by the KMS:
https://github.com/calimero-network/mero-tee/releases/download/mero-kms-v<version>/kms-attestation-policy.<profile>.jsonhttps://github.com/calimero-network/mero-tee/releases/download/mero-kms-v<version>/kms-attestation-policy.json # locked-read-onlyEach asset has a .sig and a .bundle.json beside it; merod verifies the keyless
cosign signature (a Fulcio certificate issued to the Release mero-kms workflow on
refs/heads/master of calimero-network/mero-tee, with a Rekor entry) before it
trusts the document.
The document carries a tag, role: "kms", profile, kms.provider: "mero-kms",
merod_config_path: "tee.kms.attestation", and a policy object with:
kms_allowed_mrtd,kms_allowed_rtmr0..3,kms_allowed_tcb_statuses: the KMS image’s measurements, which merod requires of the KMS’s/attestquote.node_allowed_mrtd,node_allowed_rtmr0..3,node_allowed_tcb_statuses: the node measurements this release’s KMS image bakes in, published for audit.
Next steps
Section titled “Next steps”- Release pipeline — how these versions and policies are produced and published.
- Runbooks — deploying the KMS cluster and node images with these settings.
- Error handling — what a misconfiguration surfaces as at runtime.
- Components — where each of these pieces runs.