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 Packer / Ansible /
versions.json inputs 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 key-derivation paths. Surrounding slashes are trimmed. Keys are HKDF’d from the cluster root on {prefix}/{profile}/{peerId}. |
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). |
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 (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. 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. 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-v2:kms:<profile>:<root hash>, the same scheme the node image’s
calimero-init uses with role node. It is fatal on locked-read-only, so a
quote always names the role, profile and build, and the KMS and node images can
never measure alike.
merod’s KMS client ([tee.kms])
Section titled “merod’s KMS client ([tee.kms])”merod (in calimero-network/core) reads its KMS settings from the [tee.kms]
section of config.toml:
| Key | Meaning |
|---|---|
tee.kms.url |
The cluster’s VPC-internal URL. Node images write it from kms-url metadata via merod init --kms-url. |
tee.kms.tls.* |
Optional TLS settings for reaching the KMS. |
tee.kms.attestation.* |
The KMS allowlists merod enforces on /attest: MRTD, RTMR0–3 and TCB statuses, as published in the release’s kms_allowed_* lists. merod fills them from the signed release policy named by MERO_TEE_VERSION (tee-release-version on the node images). |
On the node images, tee-release-version metadata names the release, and merod
fetches and verifies that release’s signed kms-attestation-policy[.<profile>].json
(see below).
Image build inputs
Section titled “Image build inputs”The mero-tee node image and the KMS image are built with Packer + Ansible, from
the same template (image_role picks which). Build inputs come from
three places: versions.json, the Packer variables, and CI environment
overrides consumed by build-and-release.sh.
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.102", "merodVersion": "0.11.0-rc.67"}| 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. Overridable at build time via GATED_MEROD_VERSION. |
traefikVersion / nodeExporterVersion / vmagentVersion / vectorVersion |
Third-party components installed by the Ansible roles. |
Packer variables
Section titled “Packer variables”Declared in mero-tee/ubuntu.pkr.hcl; the x86 var-file ubuntu-x86.pkrvars.hcl
sets the build-host defaults.
| Variable | Default | Meaning |
|---|---|---|
lockdown_profile |
locked-read-only |
Which profile to build. Validated against debug, debug-read-only, locked-read-only. Selects which Ansible roles run (lockdown + conformance only for locked-read-only). |
instance_type |
n2-standard-2 (from var-file) |
Build-host machine type. No TDX needed to build; the output image runs on c3-standard-4 (Intel TDX) at runtime. |
cpu_architecture |
amd64 (from var-file) |
Build architecture. |
merod_version |
"" (supplied at build) |
merod tag baked in; comes from versions.json/GATED_MEROD_VERSION. |
traefik_version / node_exporter_version / vmagent_version / vector_version |
"" (supplied at build) |
Component versions passed through from versions.json. |
project_id |
calimero-p2p-development |
GCP project for the build. |
region |
europe-west4 |
GCP region. |
zone |
europe-west4-a |
GCP zone. |
subnetwork |
"" |
Optional build subnetwork. |
version |
"" (supplied at build) |
Image version, threaded into the image name/family. |
image_role |
node |
node builds the node image (playbook.yml); kms builds the KMS cluster image (playbook-kms.yml), named merotee-kms-<profile>-<version> (dots in the version become dashes) in family merotee-kms-<profile>. |
mero_kms_binary |
"" |
kms only: path of the mero-kms binary to bake in, built from the release commit. |
kms_node_policy_file |
"" |
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. |
Ansible play variables
Section titled “Ansible play variables”Defined at play level in mero-tee/playbook.yml (so several roles see one
value), and overridable at build time with -e.
| Variable | Default | Meaning |
|---|---|---|
fleet_delegated_access |
true for debug-read-only and locked-read-only; false for debug |
Whether this image is a relay: whether a keyholder with no account on the node may present a signed warrant and have the node write on their behalf. Drives every edge of the posture: the Traefik routers exempting /admin-api/contexts/<ctx>/intents, /admin-api/groups/<group>/context-intents, /admin-api/groups/<group>/governance-intents and the sealed-transport envelope (/sealed/v2) from auth-node, and merod’s server.admin.delegated_access. One variable because these nodes run merod in proxy auth mode, so Traefik is the only gate and a drift between the two would be silent in the unsafe direction. See Fleet HA sidecar → Delegated execution. |
fleet_mdma_url |
https://manager.cloud.calimero.network |
Where the fleet sidecar polls for assignments. Baked (and so measured); the sidecar exits if unset. |
fleet_auth_token |
"" |
Sent as X-Fleet-Token on sidecar requests when set. Supplied by the release workflow from the FLEET_AUTH_TOKEN repository secret (PKR_VAR_fleet_auth_token). Baked and so measured — rotating it is an image release, not a restart — and nodes must roll before the manager gets its matching MDMA_FLEET_AUTH_TOKEN. See Supplying the fleet token. |
What auth-node checks a token against
Section titled “What auth-node checks a token against”auth-node forwards each request to mero-auth’s /auth/validate, which checks
a scoped token against the route named in X-Forwarded-Uri and
X-Forwarded-Method. The middleware sets trustForwardHeader: false, so
Traefik fills in those headers from the request it is actually routing and
discards whatever the client sent. With true, a token scoped to one route
could get through to another by claiming the first route in its own
X-Forwarded-Uri. Nothing sits in front of the node’s Traefik that could be
trusted with those headers, and the entrypoints configure no
forwardedHeaders for the same reason. scripts/ci/tests/traefik-forward-auth-test.sh
and the locked-read-only conformance role both pin this setting.
The paths exempt from auth-node
Section titled “The paths exempt from auth-node”Every /admin-api/ path goes through the auth-node forwardAuth except four,
and they are exempt for the same reason: the request carries its own authority
rather than borrowing the node’s. They differ in who serves them.
| Path | Served by | Why it is exempt |
|---|---|---|
POST /admin-api/namespaces/<ns>/admit |
every node image | The join op arrives already signed by the joiner’s device key, and merod refuses it unless this node’s account is on the invitation’s admitters list — which the inviter signed. |
GET/POST /admin-api/contexts/<ctx>/intents |
relays only (fleet_delegated_access) |
The body holds a warrant signed by the author’s device key, committing to this context, method and arguments. |
GET/POST /admin-api/groups/<group>/context-intents |
relays only (fleet_delegated_access) |
Delegated context creation. The body holds a creation warrant signed by the author’s device key, committing to this group, the application, the init arguments and this relay as executor; merod refuses it unless the author may create contexts in the group. GET (optionally ?author=<hex>) is the descriptor a client reads first. |
GET/POST /admin-api/groups/<group>/governance-intents |
relays only (fleet_delegated_access) |
Delegated governance. The body holds a governance warrant signed by a group member’s device key, which merod verifies before applying the change; this relay submits it for the member. GET is the descriptor a client reads first. |
Admission is not gated on fleet_delegated_access, and that asymmetry is
deliberate. Delegated execution is a hosted capability — the node authors on
someone else’s behalf, which is what a fleet relay is assigned to do. Admission
is not hosted and involves no cloud at all: the admitters list is chosen by
the inviter, who may name any node, including a peer’s self-hosted one. Two
people running their own nodes can invite and admit each other with no account
anywhere, so gating admission on a fleet variable would have made a
peer-to-peer flow a cloud-only feature.
Serving admission everywhere grants no node authority it did not already have:
a node named in no invitation’s admitters list refuses every request to that
route, whatever image it runs. The signed list is the gate, not the build flag.
The cost of the wider exposure is that any node will verify signatures for an unauthenticated caller, so this is a request-rate surface like any other public route.
All four exemptions are anchored regexes (a lowercase 64-hex id and a literal
final segment), never prefixes: a PathPrefix on /admin-api/namespaces/ would
have exempted namespace creation and deletion along with admission, and one on
/admin-api/groups/ would have exempted /admin-api/groups/<group>/contexts and
every membership route with creation and governance. Traefik matches them against the path
only, so the descriptor’s ?author= query needs no room in the pattern.
scripts/ci/tests/traefik-delegated-route-test.sh routes the allowed paths and
their near misses (uppercase hex, 63 or 65 hex characters, a trailing slash, an
extra segment) through every router and requires each near miss to land on
auth-node. The rendered file is
asserted at build time — admission on every image, delegated execution,
creation and governance only where the image was built to relay — so a template regression cannot reach a
release.
Device-key login, and who merod thinks the caller is
Section titled “Device-key login, and who merod thinks the caller is”A relay also lets a keyholder open a session: mero-auth’s account_proof
provider, device-key login. The session is scoped to context:query,
context:intent, context:subscribe and the caller-scoped listings
(context:list-own, namespace:list-own, group:list-own), and it goes
through auth-node like any other token. Three things make that safe on a
multi-tenant node, all driven by fleet_delegated_access:
| Edge | What it does |
|---|---|
mero-auth (mero-auth-start.sh) |
Runs mero-auth on the baked /etc/calimero/auth.toml, which enables no provider, until the fleet sidecar has recorded this node’s device signing key in /mnt/data/fleet/login-node-key; then on a /run copy adding [providers] account_proof = true and [account_proof] node_key = …, allowed_audiences = []. The provider refuses to start without the key, and a mero-auth that does not start takes every forwardAuth with it, so it is never enabled early. Only on an image carrying the /etc/calimero/device-key-login marker. |
merod (calimero-init.sh.j2) |
Sets server.proxy_identity=true (core 0.11.0-rc.60+). In proxy auth mode core installs no guard, so without it every caller is anonymous: the listings answer node-wide and /query refuses. With it, merod takes the account and device from X-Auth-Account / X-Auth-Device and scopes each caller to its own groups. Fatal if it cannot be set. |
| Traefik | auth-node lists both headers in authResponseHeaders, so mero-auth’s answer replaces a client’s on every guarded route; strip-proxy-identity, on both entrypoints, deletes them from every request before any router runs, so the exempt routes above cannot carry a forged account. |
The sidecar re-reads the key from GET /admin-api/identity every five minutes
and restarts mero-auth only when it changes. The value a browser has to pin is
that same publicKey. allowed_audiences = [] accepts a session for any client
origin: the session decides who may ask, never what the answer contains,
since every read re-checks membership and every write needs the member’s own
warrant. scripts/ci/tests/traefik-proxy-identity-test.sh,
mero-auth-device-login-test.sh and fleet-sidecar-login-key-test.sh pin the
three edges.
The base image is hardcoded for reproducibility: source_image_family = ubuntu-2604-lts-amd64 from ubuntu-os-cloud (Ubuntu 26.04 LTS, comfortably past
the kernel 6.17+ that RTMR3 sysfs support needs), disk_size = 20 GB pd-ssd.
That line in ubuntu.pkr.hcl is the single source of truth — CI’s
Validate Packer source image step reads the pin from the file rather than
repeating it, so it can never check one base while Packer builds from another.
The pin has to name a supported release, not merely a new-enough kernel:
Canonical delists an EOL Ubuntu from ubuntu-os-cloud, which fails the build
outright. That is what happened in September 2026 — 25.10 (Questing) is an
interim release, went EOL in July 2026, and its family disappeared. An LTS avoids
the nine-month repeat.
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.
build-and-release.sh environment overrides
Section titled “build-and-release.sh environment overrides”Optional CI overrides read by mero-tee/build-and-release.sh:
| Variable | Meaning |
|---|---|
GATED_MEROD_VERSION |
Overrides merodVersion from versions.json. |
PACKER_GCP_PROJECT_ID (or GOOGLE_CLOUD_PROJECT / CLOUDSDK_CORE_PROJECT) |
Overrides the Packer project_id. |
PACKER_GCP_REGION / PACKER_GCP_ZONE / PACKER_GCP_SUBNETWORK |
Override the corresponding Packer vars. |
PACKER_FORCE_BUILD |
When true, passes packer build -force to replace pre-existing image artifacts. |
The script builds one profile when its first argument is a profile name,
otherwise all three (locked-read-only, debug-read-only, debug).
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.