Verification & Measurement

Release signatures, runtime attestation, MRTD/RTMR enforcement, and operator verification paths

What Gets Released

Every release produces artifacts in exactly two families. Each family lives on its own GitHub release tag and carries its own signatures, checksums, and SBOM.

KMS Family

Tag pattern: mero-kms-vX.Y.Z

Contains the mero-kms-phala Docker image, attestation policy JSON, compose hash file, SBOM, SHA-256 checksums, and Sigstore signatures. Built by release-kms-phala.yaml.

Artifacts:

  • Docker image (GHCR)
  • attestation-policy-{profile}.json per profile
  • published-mrtds.json
  • compose-hash.txt
  • checksums.sha256
  • Sigstore bundle (.sigstore.json)
  • SBOM (sbom.spdx.json)

Node-Image Family

Tag pattern: mero-tee-vX.Y.Z

Contains the GCP TDX confidential VM image built by Packer + Ansible, plus the published MRTD/RTMR reference values, checksums, and Sigstore signatures. Built by release-node-image-gcp.yaml.

Artifacts:

  • GCP VM image (per profile)
  • published-mrtds.json
  • checksums.sha256
  • Sigstore bundle (.sigstore.json)
  • SBOM (sbom.spdx.json)
  • Auto-generated release notes

Release Signatures

Every release asset is signed with Sigstore keyless signing (Fulcio + Rekor). Signatures prove two things — and critically fail to prove a third.

Proves: Provenance

The artifact was produced by a specific GitHub Actions workflow run, from a specific commit, in the calimero-network/mero-tee repository. The OIDC identity chain is: GitHub → Fulcio certificate → Rekor transparency log.

Proves: Integrity

The artifact has not been modified since it was signed. Any bit flip in the file invalidates the Sigstore bundle. SHA-256 checksums provide a second independent integrity check.

Does NOT Prove: Runtime State

Signatures say nothing about whether a running instance loaded the signed artifact or whether it was tampered with after deployment. That is the job of runtime attestation (TDX quotes, MRTD/RTMR verification).

Operators must perform both release verification (signatures + checksums) and runtime attestation (TDX quote + measurement comparison) to establish full trust. Neither alone is sufficient.

Verification Matrix

Three actors verify three different things. Each row is a distinct trust relationship with its own protocol.

A

merod verifies KMS

Protocol: POST /attest with a fresh nonce → receive raw TDX quote → verify quote signature → confirm report_data contains nonce binding → compare MRTD against known-good KMS measurement from config.

When: Every node boot, before requesting any key. This is "Plane 1" in the trust model.

Rejects if: Quote signature invalid, nonce mismatch, MRTD not in expected set, report_data tampered.

B

KMS verifies merod

Protocol: Node calls POST /challenge → receives nonce → produces TDX quote binding nonce + peerId → calls POST /get-key with quote + signature → KMS verifies signature, quote, and policy.

When: Every key release request. This is "Plane 2" in the trust model.

Rejects if: Signature invalid, challenge expired/consumed, quote invalid, MRTD/RTMR0-3/TCB not in policy allowlist.

C

Operator verifies releases

Protocol: Download release assets → run verify-release-assets.sh → confirms Sigstore signatures against expected OIDC issuer and workflow identity → validates SHA-256 checksums → inspects published-mrtds.json and compatibility catalog.

When: Before deploying any new release. Before updating attestation policy.

Rejects if: Sigstore verification fails, checksum mismatch, unexpected workflow identity, MRTD values differ from expected.

Profile Compatibility

Node images are built in one of three profiles. Each profile produces distinct RTMR3 values, creating cryptographic trust cohorts. The KMS attestation policy is scoped per-profile — a policy for locked-read-only will never accept a debug node.

debug
Full shell access, SSH enabled, serial console. For development only. Never in production.
debug-read-only
Read-only root filesystem, SSH enabled. For staging / pre-production testing.
locked-read-only
Read-only root, no SSH, no serial. Hardened for production.

Profile-Isolated Trust Cohorts

Each profile writes a unique event to RTMR3 at boot. Because RTMR extension is hardware-enforced and one-way (SHA-384 extend), a node cannot change its RTMR3 after boot. The KMS policy contains a per-profile allowlist.

// Each profile's policy file only allows its own RTMR3 value:
attestation-policy-debug.json → allowed_rtmr3: ["aabbcc..."]
attestation-policy-debug-read-only.json → allowed_rtmr3: ["ddeeff..."]
attestation-policy-locked-read-only.json → allowed_rtmr3: ["112233..."]

// NEVER mix debug RTMR3 values into a production policy.
// Doing so allows debug nodes to obtain production keys.

Rule: Never add debug profile RTMR3 values to a production KMS policy. Profile isolation is a security boundary, not a convenience flag.

Operator Quick Path

For operators who need to verify a release before deployment. This is the fast path — for full audit, see the "Minimal Download Sets" section below.

Step 1: Run verify-release-assets.sh

# Verify KMS release
$ ./scripts/verify-release-assets.sh mero-kms-v1.2.0

# Verify node-image release
$ ./scripts/verify-release-assets.sh mero-tee-v1.2.0

# The script:
# 1. Downloads release assets from GitHub
# 2. Verifies Sigstore signatures (cosign verify-blob)
# 3. Checks SHA-256 checksums
# 4. Validates OIDC issuer = https://token.actions.githubusercontent.com
# 5. Validates workflow identity matches expected release workflow
# 6. Prints PASS/FAIL for each asset

Step 2: Inspect Compatibility Map

Each release includes a compatibility catalog linking KMS versions to compatible node-image versions. Verify the version pair you intend to deploy is listed.

# In the release assets:
$ cat compatibility-catalog.json | jq '.entries[] | select(.kms == "1.2.0")'

# Confirms which node-image versions are compatible with this KMS

Step 3: Confirm Profile & Policy URL

Before deploying, verify the KMS is configured with the correct profile's policy URL:

# The KMS fetches policy from this URL pattern:
MERO_KMS_POLICY_URL=https://github.com/calimero-network/mero-tee/releases/download/mero-kms-v1.2.0/attestation-policy-locked-read-only.json

# Optionally pin by hash:
MERO_KMS_POLICY_SHA256=e3b0c44298fc1c149afbf4c8996fb924...

Client Attestation Config

Nodes need attestation configuration to know which KMS measurements to expect (Plane 1 verification). Two scripts handle generation and application.

generate-merod-kms-phala-attestation-config.sh

# Generate attestation config for a specific KMS release + profile
$ ./scripts/generate-merod-kms-phala-attestation-config.sh \
    --kms-version 1.2.0 \
    --profile locked-read-only \
    --output merod-attestation.json

# The script:
# 1. Fetches published-mrtds.json from the KMS release
# 2. Extracts MRTD for the given KMS version
# 3. Generates a config block with expected_mrtd, kms_url, nonce_binding
# 4. Writes JSON config suitable for merod's --attestation-config flag

Applying Config

# Apply generated config to a running merod instance
$ ./scripts/apply-merod-attestation-config.sh \
    --config merod-attestation.json \
    --node-data-dir /var/lib/merod

# Or pass at boot:
$ merod run --attestation-config merod-attestation.json

The generated config ties the node to a specific KMS release. When the KMS is upgraded, re-run the generation script with the new version and redeploy the config.

Compose Hash

The compose hash proves the Docker Compose configuration used to run the KMS has not been modified. It is a SHA-256 digest of the normalized docker-compose.yml file.

What It Proves

The compose hash binds the KMS deployment configuration (image tag, environment variables, volume mounts, network config) to a specific hash. If any field in the compose file changes, the hash changes.

Trust Path

Trusted: From Verified Attestation

The compose hash is meaningful only when obtained through the verified attestation path. The KMS embeds the compose hash in its TDX quote report_data. A verified TDX quote proves the compose file was loaded at boot time.

NOT Trusted: From Provisioning Metadata

Do not trust compose hash values obtained from instance metadata, operator-provided config, or any non-attested source. Only the TDX-attested value is authoritative.

Extracting from Release Assets

# The compose hash is included in signed release assets:
$ cat compose-hash.txt
sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

# Compare against the value in a verified TDX quote:
# 1. Obtain quote via POST /attest
# 2. Parse report_data to extract compose hash
# 3. Compare: quote_compose_hash == release_compose_hash

If the values differ, the running KMS is not using the expected configuration. Investigate before trusting key releases from that instance.

MRTD / RTMR Verification

The core measurement verification flow: compare observed values from a verified TDX quote against the reference values published in published-mrtds.json for a given release + profile combination.

Step-by-Step

1

Get Observed Values

Obtain a TDX quote from the target instance via POST /attest (for KMS) or from the node's local TDX guest driver. Parse the quote to extract MRTD, RTMR0, RTMR1, RTMR2, RTMR3.

2

Verify the Quote

Before trusting any values, verify the TDX quote cryptographic signature. Either verify locally or submit to Intel Trust Authority. An unverified quote is meaningless.

3

Fetch Reference Values

Download published-mrtds.json from the appropriate GitHub release tag. For KMS: mero-kms-vX.Y.Z. For node images: mero-tee-vX.Y.Z.

$ gh release download mero-kms-v1.2.0 -p 'published-mrtds.json'
$ cat published-mrtds.json | jq '.'
4

Compare by Release + Profile

Look up the entry in published-mrtds.json matching your target release version and profile. Compare each register:

observed.mrtd == published[version][profile].mrtd // VM image identity
observed.rtmr0 == published[version][profile].rtmr0 // firmware
observed.rtmr1 == published[version][profile].rtmr1 // kernel
observed.rtmr2 == published[version][profile].rtmr2 // application
observed.rtmr3 == published[version][profile].rtmr3 // profile event
5

Full Policy Tuple Enforcement

For KMS policy verification, all five registers plus the TCB status must match the policy. The full tuple is: (mrtd, rtmr0, rtmr1, rtmr2, rtmr3, tcb_status). Any single mismatch is a policy violation.

Why RTMRs May Match Across Profiles

It is normal for some RTMR values to be identical between profiles of the same release. The key discriminator is RTMR3.

MRTD

Changes when the VM image changes. Different images → different MRTD. Within the same release, MRTD may differ per profile if the image differs, or match if the base image is shared.

RTMR0 / RTMR1

Measure firmware and kernel. If all profiles use the same firmware and kernel (common), RTMR0 and RTMR1 will be identical across profiles. This is expected — profile differentiation is not their job.

RTMR2

Measures the application layer. May diverge across profiles if the application config or binary differs, or match if the same app is loaded with different runtime params.

RTMR3 (Discriminator)

Always differs between profiles within the same release. Each profile writes a unique boot event to RTMR3. This is the definitive profile separator. If RTMR3 matches the policy allowlist, the profile is confirmed.

RTMR0 Troubleshooting

A common operator issue: the RTMR0 value published in GitHub release assets does not match the RTMR0 observed on a live node. Here's why and how to fix it.

Why GitHub Expected ≠ Live Node

Different Image / Boot Chain

The live node booted a different VM image than the one from the release. This can happen if the cloud provider updated the base image, the image was rebuilt with a different firmware version, or the wrong image family was selected.

Stale Release Asset

The published-mrtds.json in the release was generated from a build that used a different firmware than the currently deployed image. This can happen if the image was rebuilt without cutting a new release.

How to Fix

1

Confirm Image Provenance

Verify the live node is running the exact image from the release. Check the GCP image name, family, and project against the release notes.

$ gcloud compute instances describe NODE_NAME --format='get(disks[0].source)'
# Compare against image name in release notes
2

Check Firmware Version

If the image matches but RTMR0 differs, the firmware (TDVF) may have been updated by the cloud provider. Compare the firmware hash in the quote against the expected value.

3

Re-Measure or Re-Release

If the deployed image is correct but measurements drifted (firmware update), either: (a) rebuild and re-release the image to pick up new measurements, or (b) update the attestation policy to include the new RTMR0 value after verifying the firmware change is legitimate.

RTMR3 Image Legitimacy

RTMR3 is the per-profile runtime measurement register. Understanding how it's generated is critical for verifying image legitimacy and detecting binary-swap attacks.

Generation Formula

// RTMR3 is an extend-only register. Each extend operation:
RTMR3_new = SHA384(RTMR3_old || event_data)

// Initial state (before any extensions):
RTMR3_initial = 0x000...000 // 48 bytes of zeros

// At boot, the image writes a profile-specific event:
event_data = SHA384("mero-tee:profile:{profile_name}")

// Final RTMR3 (single extension from zero):
RTMR3 = SHA384(0x000...000 || SHA384("mero-tee:profile:locked-read-only"))

Release Artifacts

Each release computes and publishes the expected RTMR3 per profile in published-mrtds.json. The CI pipeline extends from the known initial state using the profile string, so the published value is deterministic and reproducible.

Client Verification Paths

Path A: Compare Against Published

Download published-mrtds.json from the verified release. Look up the RTMR3 for your profile. Compare against the RTMR3 in a verified TDX quote. Match = legitimate.

Path B: Recompute Locally

Compute the expected RTMR3 yourself using the formula above with the profile string. Compare against the quote. This verifies the published value itself is correct.

Binary-Swap Mitigation

An attacker who replaces the merod binary inside the VM changes MRTD (the image hash changes). But could they create a malicious image that extends the same RTMR3? No — RTMR3 is extended during early boot by trusted firmware code before the application binary runs. The application cannot control what gets extended to RTMR3 unless it controls the boot chain, which would change RTMR0/RTMR1.

Safety Improvements

  • Multi-register binding: The KMS policy checks all five registers simultaneously. A swap that preserves RTMR3 will fail on MRTD or RTMR2.
  • Kernel command-line in RTMR2/3: Some configurations extend the kernel cmdline into RTMRs. Changing boot parameters (to disable security) changes RTMR values.
  • Profile event is early-boot: The profile string is written to RTMR3 during initramfs, before any user-space code executes.

Minimal Download Sets

You don't need every release asset to verify. Two modes: quick verify (operator confidence) and full audit (complete reproducibility check).

KMS Family

Quick Verify

  • checksums.sha256
  • checksums.sha256.sigstore.json
  • published-mrtds.json
$ ./scripts/verify-release-assets.sh mero-kms-v1.2.0 --quick

Full Audit

  • All of the above, plus:
  • attestation-policy-{profile}.json
  • compose-hash.txt
  • sbom.spdx.json
  • Docker image digest verification

Node-Image Family

Quick Verify

  • checksums.sha256
  • checksums.sha256.sigstore.json
  • published-mrtds.json
$ ./scripts/verify-release-assets.sh mero-tee-v1.2.0 --quick

Full Audit

  • All of the above, plus:
  • sbom.spdx.json
  • GCP image metadata verification
  • Packer manifest comparison

Verification Examples

Expected script output and common failure patterns. Expand each section for detailed examples.

KMS Release Verification — Expected Output
$ ./scripts/verify-release-assets.sh mero-kms-v1.2.0

[INFO] Downloading release assets for mero-kms-v1.2.0...
[INFO] Verifying Sigstore signature for checksums.sha256...
[PASS] Sigstore signature valid
    Issuer: https://token.actions.githubusercontent.com
    Workflow: .github/workflows/release-kms-phala.yaml
    Repository: calimero-network/mero-tee
[INFO] Verifying SHA-256 checksums...
[PASS] attestation-policy-locked-read-only.json: OK
[PASS] published-mrtds.json: OK
[PASS] compose-hash.txt: OK
[PASS] sbom.spdx.json: OK

✓ All 4 assets verified successfully
Node-Image Release Verification — Expected Output
$ ./scripts/verify-release-assets.sh mero-tee-v1.2.0

[INFO] Downloading release assets for mero-tee-v1.2.0...
[INFO] Verifying Sigstore signature for checksums.sha256...
[PASS] Sigstore signature valid
    Issuer: https://token.actions.githubusercontent.com
    Workflow: .github/workflows/release-node-image-gcp.yaml
    Repository: calimero-network/mero-tee
[INFO] Verifying SHA-256 checksums...
[PASS] published-mrtds.json: OK
[PASS] sbom.spdx.json: OK

✓ All 2 assets verified successfully
Common Failure Patterns

Sigstore Verification Failed

[FAIL] Sigstore signature invalid for checksums.sha256
    Error: certificate identity mismatch
    Expected: .github/workflows/release-kms-phala.yaml
    Got: .github/workflows/build-dev.yaml

The asset was signed by a different workflow than expected. This could indicate a tampered release or an incorrect release tag. Do not deploy.

Checksum Mismatch

[FAIL] attestation-policy-locked-read-only.json: FAILED
    Expected: a1b2c3d4...
    Got: e5f6a7b8...

The downloaded file differs from the signed checksum. Re-download the asset. If it persists, the release may have been tampered with.

OIDC Issuer Mismatch

[FAIL] Unexpected OIDC issuer
    Expected: https://token.actions.githubusercontent.com
    Got: https://other-issuer.example.com

The certificate was not issued by GitHub Actions. This is a critical failure — the signing identity cannot be trusted.

Missing Assets

[FAIL] Release mero-kms-v1.2.0 is missing expected assets:
    - checksums.sha256.sigstore.json
    - published-mrtds.json

The release is incomplete. Do not deploy. Check if the release workflow completed successfully.

Operator Troubleshooting Checklist
  1. Verify you have the right release tag. KMS uses mero-kms-vX.Y.Z, node images use mero-tee-vX.Y.Z. Mixing them up is a common mistake.
  2. Check cosign is installed. The verification script requires cosign v2+. Run cosign version to verify.
  3. Check network access. Sigstore verification requires access to rekor.sigstore.dev and fulcio.sigstore.dev. Ensure these are not blocked by firewall rules.
  4. Re-download assets. Partial downloads or network corruption can cause checksum failures. Delete and re-download.
  5. Inspect the Sigstore bundle. If signature verification fails, inspect the .sigstore.json file to check the embedded certificate and verify the signing time.
  6. Compare MRTD values. If measurements don't match, compare published-mrtds.json across releases to see when the value changed.
  7. Check compatibility catalog. Ensure the KMS + node-image version pair is listed as compatible before diagnosing measurement mismatches.

Workflow Identity

Release signatures embed the GitHub Actions workflow identity. This is the strongest anchor tying a release to its source. Operators should verify the workflow identity matches expectations.

KMS Releases

Workflow: .github/workflows/release-kms-phala.yaml
Repository: calimero-network/mero-tee
OIDC Issuer: https://token.actions.githubusercontent.com
Trigger: release tag matching mero-kms-v*

Node-Image Releases

Workflow: .github/workflows/release-node-image-gcp.yaml
Repository: calimero-network/mero-tee
OIDC Issuer: https://token.actions.githubusercontent.com
Trigger: release tag matching mero-tee-v*

Any release signed by a different workflow or repository is not legitimate, even if the Sigstore signature is technically valid. The verify-release-assets.sh script checks this automatically.