Skip to content

feat(tee): seal the TRACE signing key to the gateway measurement (RFC P3) #462

Open

Description

@imran-siddique

RFC section 5 P3, promoted ahead of the event log (P4) on 2026-08-01 because the event log is 0 bytes on every cloud vTPM we can provision while this is reachable today. This is the step that converts measurement into enforcement.

Why it matters

#432 now measures the gateway into a TPM_NT_EXTEND NV index and has the TPM certify the value either side of the extend, so a relying party can verify what code was running. But that is detection: a modified gateway still starts, still signs TRACE Claims, and is only caught if someone appraises the evidence afterwards.

Sealing closes that. If the TRACE signing key can only be unsealed under a policy that includes the measurement, a modified gateway cannot sign a claim at all. The failure becomes structural rather than something a verifier has to notice.

docs/spec/attestation.md section 4.2 already describes this as Phase 2 design. It is not implemented, and section 4.5 of the RFC says so explicitly so a reader does not assume otherwise.

Shape of the work

  1. Create the signing key as a sealed TPM object (TPM2_Create under a parent) rather than the in-process SigningKey() generated at startup step 3.
  2. Bind the unseal policy to the measurement. TPM2_PolicyNV against the gateway measurement index is the direct route, and it composes with TPM2_PolicyPCR over non-resettable SRTM PCRs if we want boot state in the policy too.
  3. Handle the ordering problem. The current sequence generates the signing key before the measurement, because the certify calls commit a nonce derived from that key (feat(tee): certify the gateway measurement so it is signed evidence (#432) #459). Sealing to the measurement inverts that dependency: the key cannot exist until the index holds the right value, but the index's certify commits a nonce from the key. This needs resolving, and it is the main design question here rather than an implementation detail. Options include deriving the certify nonce from something other than the signing key, or sealing a long-lived key and using a separate per-session key for the nonce.
  4. Decide the reseal story. The measurement changes on every legitimate upgrade, so the sealed blob must be re-created on deploy. A gateway that cannot unseal after a legitimate upgrade is an outage, so this needs an explicit operational path, not just a happy-path implementation.

Constraints already established

  • The measurement index is tamper-evident, not tamper-proof: TPM2_NV_UndefineSpace with owner authorization erases it, and guest root holds owner auth on Azure. Sealing to it inherits that ceiling. An adversary who can undefine the index can also destroy the seal, which is a denial of service rather than a forgery, but it should be stated rather than discovered.
  • Only the platform attestation key has provenance, and on some Azure hosts even its certificate is not chainable (security(tee): source and pin the Global Virtual TPM CA - 03 root, or Azure provenance is host-dependent #453). Sealing does not depend on the chain, so this is not blocking, but the overall assurance claim still is.

Definition of done

Implemented, unit tested, and validated on real hardware before the claim is made anywhere. The #460 run found two defects in code that had merged with green CI, so a sealing implementation that has never unsealed on a real TPM should not be described as working. docs/testing/hardware-validation.md is where the run gets recorded.

Refs #432, #439 (RFC section 5 P3 and section 4.5), #453.

Activity

  1. Yatsuiii commented on Aug 29, 2026

    @Yatsuiii
    Contributor

    Took the ordering problem in item 3, since it is named as the design question rather than an implementation detail. Claims about current behaviour are against main at 24b05ae. The two TPM mechanism questions it turns on are settled against a simulator, not hardware, and the section reporting them says so.

    The cycle, stated precisely

    startup.py:369 records the current constraint in its own comment:

    Step 3: signing key. Generated before the measurement (#432) because the measurement's TPM2_NV_Certify calls commit the attestation nonce, which is derived from this key.

    Following it through:

    • signing_key = SigningKey() at startup.py:372.
    • nonce = _attestation_nonce(...) at startup.py:389, whose first 32 bytes are jwk_thumbprint(signing_key.public_key_bytes).
    • that same nonce is passed to _extend_measurement at startup.py:396, which commits certify_qualifying_data(nonce, PHASE_PRE) and (nonce, PHASE_POST) at measurement.py:434 and :440.
    • and to get_attestation_report(nonce) at startup.py:401.

    So the signed evidence bracketing the extend commits the signing key. Sealing that key under TPM2_PolicyNV over the same index inverts it: the key cannot be unsealed until the index is final, and the evidence over that index commits the key.

    The cause is one value serving two requirements

    The certify pair and the attestation report have different needs, and startup.py:389 builds one value for both.

    The report needs the key binding. CRYPTO-001 checks cnf.jwk against report_data[:32] at verify.py:292.

    The certify pair needs freshness, and only freshness. verify_gateway_measurement takes expected_nonce as a parameter, and the step it gates appends "freshness" at nv_certify.py:309. The pair's authenticity comes from the AK chain and the two signatures, recorded at nv_certify.py:283 and :298, not from the signing key. Nothing in that verifier reads the gateway signing key at all.

    The signing key thumbprint currently supplies that freshness as a side effect of being regenerated each start. That is the only reason it is in there.

    Proposed break

    Give the certify pair its own freshness token and stop deriving it from the signing key. Then reorder:

    1. compute the measurement
    2. certify-pre, extend, certify-post, all bound to the freshness token
    3. the index is now final, so unseal the TRACE signing key under TPM2_PolicyNV
    4. build report_data as today, jwk_thumbprint(signing_key) || salt
    5. request the attestation report

    The certify no longer commits the key, so the cycle is gone.

    What this does not change, which is the point of preferring it: report_data keeps its current layout, so CRYPTO-001 at verify.py:292 is untouched, cnf.jwk re-derivation is untouched, and AUDIT-006's use of report_data[32:64] for the chain root at session/manager.py:99 is untouched. No security invariant moves. The signing key is still the key bound into the report and still the key that signs TRACE claims.

    What it does cost: verify_gateway_measurement's expected_nonce becomes an expected freshness value, and that value has to reach the verifier. It is already a parameter at nv_certify.py:227, so this is an addition to the envelope rather than a change to a check.

    Inside this repo that migration is close to free right now, which argues for doing it before it is not. The producer side is wired: build_envelope is called at startup.py:323 and the envelope is carried on the runtime context at startup.py:701. The consumer side is not: verify_gateway_measurement has no production caller, only unit tests, and it is not exported from cmcp_verify/__init__.py. I cannot speak for out-of-tree verifiers appraising the envelope already, which is the argument for versioning the envelope rather than redefining the field in place.

    The freshness token also has to be genuinely per-boot, or the certify pair becomes replayable. That is the one property worth a test before any of this lands.

    This is the first option item 3 lists. I went to the second one initially, splitting the key into an ephemeral attestation key and a sealed signing key, and discarded it: it moves the TRACE key binding to report_data[32:64], which forces an additive case into CRYPTO-001 and changes attestation.md. Breaking freshness out is strictly smaller and leaves the key-substitution defence alone.

    What I checked

    Both mechanism questions are answerable on a simulator, so I ran them rather than leaving them open. swtpm 0.10.1 with tpm2-tools 5.8, simulator only, no hardware. That settles the mechanism. It is not a hardware validation and says nothing about the Azure owner-auth constraints this issue already records.

    1. TPM2_PolicyNV binds against a TPM_NT_EXTEND index, and the post-extend value is predictable. Predicting H(old || data) for an index whose starting value is 32 zero bytes, then extending and comparing:

    predicted  ecb02820cd09d7dd48a8ce5381d991a0d250688c5e9c48886e3ce2c0346217a4
    actual     ecb02820cd09d7dd48a8ce5381d991a0d250688c5e9c48886e3ce2c0346217a4
    

    That matters for the reseal story: the policy for a value can be computed before the boot that produces it, so the blob can be created at deploy time.

    A key sealed under that PolicyNV signs while the index matches, and after a second extend moves the index the same key in the same context is refused. Both directions, not just the refusal.

    2. Creating and loading a policy-gated key does not require satisfying its policy. tpm2_create returns a 122-byte public area and 128-byte private blob with no policy session anywhere, and tpm2_load succeeds on the parent's auth. Only use is gated: signing with no policy session is refused with tpm:error(2.0): authValue or authPolicy is not available, while signing under a satisfied session on the same loaded key produces a 72-byte signature. Paired that way because a refusal alone would not show the policy was doing anything.

    What this does not establish: real-TPM behaviour under load, the Azure owner-auth ceiling on TPM2_NV_UndefineSpace this issue already states, or performance. Those need the platform.

    One scope note, and one question

    The reseal story in item 4 may be smaller than it reads. refresh_measurement_binding at report_binding.py:91 returns False for any provider outside MEASUREMENT_BOUND_PROVIDERS. Both extend paths, extend_gateway_measurement at startup.py:317 and certify_and_extend_gateway_measurement at :320, sit inside _extend_measurement, which is called once, from startup at :396. So on the TPM tier a policy reload does not re-extend the index. If that is right, the sealed blob survives reloads and only has to be re-created on deploy.

    That same reading raises a question I would rather ask than assume: if the index is extended only at startup, what commits a TPM-tier policy bundle that reloads mid-session? _notify_reload at evaluator.py:121 justifies skipping the TPM tier on the grounds that its NV index accumulates history, and I may be missing the path that does the extending. If there is one, pointing me at it removes the concern.

    Happy to take the freshness split as its own change, landed narrow, ahead of the sealing work.

  2. Yatsuiii commented on Sep 3, 2026

    @Yatsuiii
    Contributor

    Correction to my Aug 29 analysis: I withdraw the proposed freshness-token split.

    On the current TPM path, _attestation_nonce already retains a fresh 32-byte random salt. I also missed the implication of the mechanism result: a policy-gated TPM signing object can be loaded and its public area read before measurement; its authPolicy is enforced when signing is authorized. The public-key fingerprint can therefore be available for the existing certify → extend → certify sequence.

    Reproduction is in Yatsuiii/cmcp@aea368b. The lifecycle passes with swtpm. I also ran the same opt-in test against contributor-owned AMD firmware TPM hardware, including pre-state denial, authorized signing, post-mutation denial, and NV-index cleanup. That is contributor-supplied validation—not upstream CI coverage or project-owned hardware validation.

    This proves the TPM ordering mechanism, not P3 end to end. The test uses P-256/ECDSA, while the current TRACE signing path is Ed25519-only, so algorithm-profile and operational lifecycle decisions remain before #462 can close.

  3. imran-siddique commented on Sep 7, 2026

    @imran-siddique
    MemberAuthor

    The proposed freshness-token split was withdrawn. The contributor’s TPM lifecycle experiment is mechanism evidence; P3 still needs the Ed25519 versus TPM signing-profile decision and operational lifecycle design before end-to-end implementation can close this.

  4. imran-siddique commented on Oct 15, 2026

    @imran-siddique
    MemberAuthor

    Decision on the signing profile: TRACE keeps Ed25519, and the key is sealed. It becomes a TPM sealed data object under TPM2_PolicyNV over the gateway measurement index, so only the measured gateway can unseal it. The public key is stored in the clear beside the blob, so the thumbprint exists before the certify, extend, certify sequence and the item 3 ordering problem goes away. No ES256 profile is added.

    Two consequences. The key becomes long-lived per deployment instead of generated at each startup. And once unsealed it sits in process memory, so this stops a modified gateway from signing but does not protect the key inside a compromised measured one.

    Still open: the reseal path on a legitimate upgrade, item 4. @Yatsuiii, that piece is yours if you want it.

  5. Yatsuiii commented on Oct 16, 2026

    @Yatsuiii
    Contributor

    Taking item 4. It first needs a change to what the key is sealed under, and one precondition, so this covers all three.

    The measurement index cannot gate the unseal

    The index is an accumulating chain. I ran cMCP's own run_startup three times against one swtpm in production mode: the digest was identical each time, the index moved on every start, and each start's pre-value equalled the previous post-value. This is the same accumulation the Azure run recorded. PolicyNV compares against one exact value, so a key sealed to the index matches one position in the chain only.

    I sealed an Ed25519 seed under PolicyNV on that index, with the index extended by cMCP's extend_gateway_measurement through tpm2-pytss. The first start unseals. A plain restart, a crash between extend and reseal, and a single start with tampered inputs each lose the key for good. This corrects my Aug 29 comment: a key sealed at deploy time covers only the first start after deploy.

    Design

    • Gate. A second, ordinary NV index holds the current gateway digest, written right after the certified extend. The key is sealed under PolicyNV(gate == digest). After unsealing, the gateway overwrites the gate with a sentinel, so between starts another process with TPM access cannot unseal without writing the gate itself. The gate is per TPM, so this is one sealed gateway per TPM.
    • Parent. The storage parent is recreated from a fixed template on each start, never persisted. With an owner password set, a process without it cannot load the blob at all.
    • Opt-in, TPM tier only. ATTEST-004 requires a new tee_public_key on every startup, and attestation.md section 3.3.2 gets freshness on sev-snp, tdx and azure-cvm from regenerating the key. Sealing is off by default and applies to the tpm provider only. ATTEST-004 is amended to apply when sealing is off.
    • Provisioning. A key is minted only on a TPM with no gate and no blob. Any later key change goes through cmcp key rotate.
    • Failure. These each abort startup with their own error: a digest the blob is not sealed to, a blob that will not load on this TPM (another host, or after TPM2_Clear), a missing blob on a provisioned TPM, and a stored public key that does not match the unsealed secret. Nothing is minted automatically. A lock file makes a second process on the same state directory refuse to start.
    • Rotation. This replaces the rotation rule in attestation.md section 4.2, which mints a fresh key sealed to the new measurement on every upgrade. Minting on a mismatch would give a tampered gateway a working key, so sealing would enforce nothing. Policy reloads and policy-key revocations change the digest as well, so one reseal path covers every digest change, and a new key only comes from cmcp key rotate.
    • Boot state. security(tee): the gateway is never measured into a PCR, so the policy tamper claim is unenforced #432 and item 2 put non-resettable SRTM PCRs in the policy too. I am deferring that to P4. With the event log at 0 bytes on cloud vTPMs, PCR values after a firmware or kernel update cannot be predicted, so a PCR-bound key cannot be resealed ahead of the reboot and every host update would be an outage.

    Reseal

    • Policy reload. The measured digest covers every file in the policy bundle directory, including signing-key-revocations.json. So an approved reload, or revoking a policy signing key, changes the digest the next restart computes. The running gateway already holds the secret, so after a verified reload it seals it under the new digest without unsealing anything.
    • Upgrade. cmcp key reseal --to-digest <hex> runs before starting the new build. Blobs are kept per digest with the previous one retained, so rollback works and the public key is unchanged throughout. The policy digest is computed in software, which matches the TPM's own.
    • Measure. cmcp measure prints the digest and its components for the new image, since there is no offline way to get it today.
    • Rotate. cmcp key rotate replaces the key on purpose. docs/configuration.md says restarting rotates the key, which stops being true and gets corrected.

    Preconditions

    • _CONFIG_SECRET_KEYS in measurement.py excludes only bearer_token, and operator_token is a Config field. With the real loader, changing CMCP_OPERATOR_TOKEN changes the gateway digest and changing CMCP_BEARER_TOKEN does not. Rotating the operator token therefore already changes the digest today, and computing an expected digest needs the production secret. I will send excluding it as its own PR first.
    • The measurement extend uses empty owner auth, so on a host that sets an owner password the gateway currently fails with MEASUREMENT_UNAVAILABLE. The owner-password protection above only applies once owner auth is configurable, which is a separate change.

    What this guarantees, and what it does not

    It guarantees three things:

    • The blob is useless on any other TPM.
    • A gateway whose measured inputs differ from the sealed digest, with its measurement code intact, does not get the key and does not start.
    • The key survives restarts, so a relying party can pin it. With cmcp verify --trusted-key pinned to the first key, claims from a restart pass the pin and claims from a re-provisioned key fail it.

    It does not stop anyone who can issue owner-authorized TPM commands from writing the approved digest and unsealing, and on Azure guest root can. It does not stop a modified gateway that reports the approved digest. The extend-index version has the same limits. Because the key is sealed data, a verifier also cannot confirm from evidence that it is sealed, so no claim field will say so.

    Validation so far

    I prototyped the design with tpm2-pytss next to cMCP's own extend code, and ran it two ways.

    • swtpm 0.10.2, 13 scenarios, all passing:
      • restarts, tampered inputs and recovery;
      • the sentinel and the owner password;
      • the gate Name;
      • reseal, upgrade and rollback;
      • another TPM and TPM2_Clear;
      • a swapped public key, a shared TPM, the lock, Ed25519 signing.
    • Contributor-owned AMD firmware TPM (spec revision 1.59), 10 of those scenarios, all passing. The other three were left out on purpose: two would change the owner password or clear a TPM that holds other keys, and one does not touch the TPM. The TPM's handles and lockout counter were unchanged afterwards.

    Not yet validated: an Azure vTPM, the certify pair during startup, and the design integrated into startup itself.

    Not in these PRs

    PolicyAuthorize tickets, PolicyPCR (deferred to P4, above), configurable owner auth, a maximum key age, key transparency, and non-TPM tiers.

    Scope

    Two PRs:

    1. The operator_token fix.

    2. Sealing, opt-in. This covers:

      • the gate and sealed store;
      • startup integration with reseal on reload;
      • the key and measure commands;
      • a CI job with swtpm and tpm2-tools. tpm2-pytss is not a declared dependency today, so CI never imports the real TPM path; this adds it as an optional extra in the hashed requirements;
      • the spec and conformance changes;
      • the hardware record.

      One commit per step.

    Starting with the first.

  6. imran-siddique commented on Oct 16, 2026

    @imran-siddique
    MemberAuthor

    @Yatsuiii agreed on all of it. The measurement index accumulates, so PolicyNV over it matches one start only; seal under the gate index as you describe. Keep it opt-in on the tpm provider, with ATTEST-004 scoped to sealing off and cmcp key reseal and rotate replacing the mint-on-upgrade rule in attestation.md §4.2. Those spec changes go in PR 2 with the code. Deferring PCR binding to P4 is right while cloud vTPM event logs are empty. #768 has my approval and is waiting on a second.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions