Repository navigation
Description
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
- Create the signing key as a sealed TPM object (
TPM2_Createunder a parent) rather than the in-processSigningKey()generated at startup step 3. - Bind the unseal policy to the measurement.
TPM2_PolicyNVagainst the gateway measurement index is the direct route, and it composes withTPM2_PolicyPCRover non-resettable SRTM PCRs if we want boot state in the policy too. - 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.
- 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_UndefineSpacewith 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.
Activity
Yatsuiii commentedon Aug 29, 2026
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
mainat24b05ae. 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:369records the current constraint in its own comment:Following it through:
signing_key = SigningKey()atstartup.py:372.nonce = _attestation_nonce(...)atstartup.py:389, whose first 32 bytes arejwk_thumbprint(signing_key.public_key_bytes).nonceis passed to_extend_measurementatstartup.py:396, which commitscertify_qualifying_data(nonce, PHASE_PRE)and(nonce, PHASE_POST)atmeasurement.py:434and:440.get_attestation_report(nonce)atstartup.py:401.So the signed evidence bracketing the extend commits the signing key. Sealing that key under
TPM2_PolicyNVover 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:389builds one value for both.The report needs the key binding. CRYPTO-001 checks
cnf.jwkagainstreport_data[:32]atverify.py:292.The certify pair needs freshness, and only freshness.
verify_gateway_measurementtakesexpected_nonceas a parameter, and the step it gates appends"freshness"atnv_certify.py:309. The pair's authenticity comes from the AK chain and the two signatures, recorded atnv_certify.py:283and: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:
TPM2_PolicyNVreport_dataas today,jwk_thumbprint(signing_key) || saltThe certify no longer commits the key, so the cycle is gone.
What this does not change, which is the point of preferring it:
report_datakeeps its current layout, so CRYPTO-001 atverify.py:292is untouched,cnf.jwkre-derivation is untouched, and AUDIT-006's use ofreport_data[32:64]for the chain root atsession/manager.py:99is 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'sexpected_noncebecomes an expected freshness value, and that value has to reach the verifier. It is already a parameter atnv_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_envelopeis called atstartup.py:323and the envelope is carried on the runtime context atstartup.py:701. The consumer side is not:verify_gateway_measurementhas no production caller, only unit tests, and it is not exported fromcmcp_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 changesattestation.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_PolicyNVbinds against aTPM_NT_EXTENDindex, and the post-extend value is predictable. PredictingH(old || data)for an index whose starting value is 32 zero bytes, then extending and comparing: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
PolicyNVsigns 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_createreturns a 122-byte public area and 128-byte private blob with no policy session anywhere, andtpm2_loadsucceeds on the parent's auth. Only use is gated: signing with no policy session is refused withtpm: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_UndefineSpacethis 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_bindingatreport_binding.py:91returns False for any provider outsideMEASUREMENT_BOUND_PROVIDERS. Both extend paths,extend_gateway_measurementatstartup.py:317andcertify_and_extend_gateway_measurementat: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_reloadatevaluator.py:121justifies 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.
Yatsuiii commentedon Sep 3, 2026
Correction to my Aug 29 analysis: I withdraw the proposed freshness-token split.
On the current TPM path,
_attestation_noncealready 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; itsauthPolicyis 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.
imran-siddique commentedon Sep 7, 2026
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.
imran-siddique commentedon Oct 15, 2026
Decision on the signing profile: TRACE keeps Ed25519, and the key is sealed. It becomes a TPM sealed data object under
TPM2_PolicyNVover 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.
Yatsuiii commentedon Oct 16, 2026
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_startupthree 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.PolicyNVcompares against one exact value, so a key sealed to the index matches one position in the chain only.I sealed an Ed25519 seed under
PolicyNVon that index, with the index extended by cMCP'sextend_gateway_measurementthrough 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
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.tee_public_keyon every startup, andattestation.mdsection 3.3.2 gets freshness onsev-snp,tdxandazure-cvmfrom regenerating the key. Sealing is off by default and applies to thetpmprovider only. ATTEST-004 is amended to apply when sealing is off.cmcp key rotate.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.attestation.mdsection 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 fromcmcp key rotate.Reseal
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.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.cmcp measureprints the digest and its components for the new image, since there is no offline way to get it today.cmcp key rotatereplaces the key on purpose.docs/configuration.mdsays restarting rotates the key, which stops being true and gets corrected.Preconditions
_CONFIG_SECRET_KEYSinmeasurement.pyexcludes onlybearer_token, andoperator_tokenis aConfigfield. With the real loader, changingCMCP_OPERATOR_TOKENchanges the gateway digest and changingCMCP_BEARER_TOKENdoes 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.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:
cmcp verify --trusted-keypinned 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.
TPM2_Clear;Not yet validated: an Azure vTPM, the certify pair during startup, and the design integrated into startup itself.
Not in these PRs
PolicyAuthorizetickets,PolicyPCR(deferred to P4, above), configurable owner auth, a maximum key age, key transparency, and non-TPM tiers.Scope
Two PRs:
The
operator_tokenfix.Sealing, opt-in. This covers:
keyandmeasurecommands;One commit per step.
Starting with the first.
imran-siddique commentedon Oct 16, 2026
@Yatsuiii agreed on all of it. The measurement index accumulates, so
PolicyNVover it matches one start only; seal under the gate index as you describe. Keep it opt-in on thetpmprovider, with ATTEST-004 scoped to sealing off andcmcp key resealandrotatereplacing 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.fix(tee): keep the fcntl lock out of mypy's Windows check
test: put cmcp_runtime.config back after the dev-mode freeze tests
ci: run the TPM tier in the test job, where coverage is uploaded
test(tee): produce the sealed key's failures on a real swtpm
fix(tee): only TPM_RC_HANDLE means the gate is not defined
test(tee): the load fault on a real swtpm, and the docs to match
fix(tee): reseal only the bundle the reload verified
fix(tee): let rotation finish a gate that was never written
docs(spec): the TPM quote does not sign the salt
feat(tee): opt-in sealed TRACE signing key on the TPM tier (#779)