The Default That Will Burn You in Production
on_chain_verification defaults to false.
That single field in TeeKeyExchangeConfig is a production trap. With it disabled, a key exchange succeeds if the attestation report passes local structural checks — provider match, measurement comparison, debug mode flag — but nobody verifies that the attestation corresponds to the hash submitted on-chain at provision time. A compromised operator can substitute a different TEE's attestation during key exchange. The keys are exchanged. Secrets are injected. Nothing fails. The attestation mismatch is invisible.
// crates/tee/src/config.rs
pub struct TeeKeyExchangeConfig {
pub session_ttl_secs: u64,
pub max_sessions: usize,
/// When enabled, key exchange performs dual verification:
/// 1. Local evidence check: is this a real TEE with the right measurement?
/// 2. On-chain hash comparison: does this attestation match the hash submitted
/// at provision time (keccak256(attestationJsonBytes) stored in contract)?
///
/// This prevents a compromised operator from substituting a different TEE's
/// attestation during key exchange.
#[serde(default)]
pub on_chain_verification: bool,
}
impl Default for TeeKeyExchangeConfig {
fn default() -> Self {
Self {
session_ttl_secs: 300,
max_sessions: 64,
on_chain_verification: false, // must be set true in production
}
}
}
This is not a papercut. It is the difference between a TEE system that can be spoofed and one that cannot. Every other gate below depends on knowing which enclave you are actually talking to.
Two Gates, Not One
Most Blueprint developers treat payment gating and TEE attestation as orthogonal concerns. Configure X402Middleware for payments, wire up TeeLayer for attestation, ship it. But in production, they compose into a single promotion condition. A service request that passes payment verification but comes from a debug-mode enclave is not production-safe. A TEE attestation that checks out locally but was never cross-referenced against the on-chain hash is not production-safe either.
The on-chain promotion path makes this concrete. A service moves from PendingRequest to Active only when approvalCount == operatorCount in ServicesApprovals.sol:
// tnt-core/src/core/ServicesApprovals.sol
function approveService(uint64 requestId, uint8 stakingPercent) external whenNotPaused nonReentrant {
// ...
_requestApprovals[requestId][msg.sender] = true;
req.approvalCount++;
emit ServiceApproved(requestId, msg.sender);
if (req.approvalCount == req.operatorCount) {
_activateService(requestId);
}
}
_activateService is only called when every operator in the request has approved. A service with three operators named in the request does not activate after one approval. It does not activate after two. It activates exactly once: when the count matches the operator count. Payment and staking verification happen before an operator can even submit an approval — an operator that fails _staking.isOperatorActive(msg.sender) is rejected at the gate, before the count advances.
This is gate one. Gate two is inside the enclave.
The Eight Conditions
Before a Blueprint service with ConfidentialityPolicy::TeeRequired should be trusted in production, eight conditions must hold simultaneously. Failing any one of them means the service should not be promoted.
1. debug_mode must be false
// crates/tee/src/attestation/claims.rs
pub struct AttestationClaims {
/// Whether the TEE is running in debug mode.
/// Debug mode enclaves should never be trusted in production.
#[serde(default)]
pub debug_mode: bool,
// ...
}
The comment is the policy: debug mode enclaves should never be trusted in production. A Nitro enclave built with --debug-mode produces attestation that any verifier can inspect and any tool can spoof. The hardware isolation properties do not hold. This field is populated from the raw attestation document — if the enclave set the debug flag, debug_mode is true, and promotion should be rejected.
2. on_chain_verification must be enabled
Already covered above. Without it, local attestation verification is the only check, and local verification cannot detect attestation substitution by a compromised operator. This must be true in TeeKeyExchangeConfig for any production deployment.
3. Attestation freshness: is_expired() must return false
// crates/tee/src/attestation/report.rs
impl AttestationReport {
pub fn is_expired(&self, max_age_secs: u64) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now.saturating_sub(self.issued_at_unix) > max_age_secs
}
}
The default max_attestation_age_secs in TeeConfig is 3600 (one hour). An expired attestation means the report was generated more than an hour ago. The enclave may have been rebooted, reprovisioned with a different binary, or had its measurement changed. Any of those invalidate the on-chain hash comparison. is_expired must return false before key exchange is permitted.
4. Measurement pinning: the Measurement digest must match
// crates/tee/src/attestation/report.rs
pub struct Measurement {
pub algorithm: String,
pub digest: String, // normalized to lowercase hex
}
pub struct AttestationReport {
pub measurement: Measurement,
// ...
}
The attestation report carries a hardware measurement — PCR values on Nitro, MRTD on TDX, the equivalent on SEV-SNP. At provision time, this measurement hash is submitted on-chain as keccak256(attestationJsonBytes). During key exchange with on_chain_verification: true, the verifier re-fetches that on-chain hash and checks it against the current report's evidence digest. If the enclave binary changed between provision and key exchange, the measurement changes, the evidence digest changes, and the on-chain comparison fails. Measurement pinning is the mechanism that makes TEE deployments immutable from the operator's perspective.
5. SecretInjectionPolicy must be SealedOnly
// crates/tee/src/config.rs
pub enum SecretInjectionPolicy {
/// Only valid for non-TEE (container) deployments.
EnvOrSealed,
/// Container recreation is forbidden. Mandatory for all TEE deployments.
SealedOnly,
}
The builder enforces this at construction time — any TeeConfig with a non-Disabled mode automatically gets SealedOnly. But configs deserialized from JSON or TOML go through validate() which applies the same check. The reason is not just security hygiene: env-var injection via container recreation invalidates attestation, breaks sealed secrets, and loses the on-chain deployment ID. If a deployed TEE service can have its secrets changed via environment variable, the entire attestation chain is moot.
6. Source filtering: ConfidentialityPolicy::TeeRequired restricts to container sources only
// crates/manager/src/protocol/tangle/event_handler.rs
fn supports_tee(source: &BlueprintSource) -> bool {
matches!(source, BlueprintSource::Container(_))
}
fn ordered_source_indices(
sources: &[BlueprintSource],
preferred_source: SourceType,
confidentiality_policy: ConfidentialityPolicy,
) -> Vec<usize> {
let require_tee = matches!(confidentiality_policy, ConfidentialityPolicy::TeeRequired);
let mut indexed: Vec<(usize, u8)> = sources
.iter()
.enumerate()
.filter(|(_, source)| !require_tee || supports_tee(source))
// ...
}
When ConfidentialityPolicy::TeeRequired, the event handler filters available sources to containers only. GitHub binaries and remote URLs are excluded — they run as native processes outside any enclave boundary. If the blueprint exposes no container source, the manager returns Error::TeeRuntimeUnavailable and the service does not start:
if matches!(
metadata.confidentiality_policy,
ConfidentialityPolicy::TeeRequired
) && ordered_source_idxs.is_empty()
{
return Err(Error::TeeRuntimeUnavailable {
reason: "Blueprint requires TEE execution but exposes no container source"
.to_string(),
});
}
7. Operator approval count must equal operator count
Covered in the contract section above. req.approvalCount == req.operatorCount is the exact condition. All operators named in the service request must call approveService before _activateService fires. A partial approval set means some operators have not committed their stake and agreed to the service parameters. The service does not go Active until the set is complete.
8. Payment and staking verified
Before any operator can submit an approval, _staking.isOperatorActive(msg.sender) must return true. The staking contract enforces that the operator has an active stake on-chain. Without active stake, the approval call reverts with OperatorNotActive. Payment verification on the client side happens before the initial service request is submitted — x402 payment headers are validated by the operator's gateway before the JobCall enters the execution pipeline. By the time a service request reaches the approval phase, the payment commitment is already part of the signed request that operators are approving.
How Operators Get Cryptographic Proof
Once a service is Active and jobs are running, operators need to know which enclave ran each job. This is what TeeLayer provides.
// crates/tee/src/middleware/tee_layer.rs
pub const TEE_ATTESTATION_DIGEST_KEY: &str = "tee.attestation.digest";
pub const TEE_PROVIDER_KEY: &str = "tee.provider";
pub const TEE_MEASUREMENT_KEY: &str = "tee.measurement";
TeeLayer is a Tower middleware layer. On every successful JobResult::Ok, it injects three metadata keys into the result head: the SHA-256 digest of the attestation evidence, the provider name, and the measurement string. Operators receive this in the job result and can independently verify: re-hash the evidence bytes and compare against the stored TEE_ATTESTATION_DIGEST_KEY. If the hash matches what was submitted at provision time, the operator has cryptographic proof that this specific job ran inside the specific enclave they approved.
// crates/tee/src/middleware/tee_layer.rs
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// ...
match result {
Some(mut result) => {
let JobResult::Ok { head, .. } = &mut result else {
return Poll::Ready(Ok(Some(result)));
};
if let Some(digest) = this.attestation_digest.take() {
head.metadata.insert(TEE_ATTESTATION_DIGEST_KEY, digest);
}
if let Some(provider) = this.provider.take() {
head.metadata.insert(TEE_PROVIDER_KEY, provider);
}
if let Some(measurement) = this.measurement.take() {
head.metadata.insert(TEE_MEASUREMENT_KEY, measurement);
}
Poll::Ready(Ok(Some(result)))
}
None => Poll::Ready(Ok(None)),
}
}
The attestation is injected from a shared Arc<Mutex<Option<AttestationReport>>>. The layer uses try_lock on the hot path to avoid blocking the service call. If the lock is contended, the keys are omitted from that result with a warning — a tradeoff that keeps job execution from stalling on attestation state reads.
The Key Exchange Session Lifecycle
Sealed secrets are how configuration, API keys, and model weights enter a TEE without the operator ever seeing them. The flow is two-phase: TEE generates an ephemeral X25519 keypair, attests the public key, and the client encrypts secrets to it after verifying the attestation.
// crates/tee/src/exchange/protocol.rs
pub struct KeyExchangeSession {
pub session_id: String,
pub public_key: Vec<u8>,
private_key: Vec<u8>, // zeroed on drop
pub created_at: u64,
pub ttl_secs: u64,
}
impl Drop for KeyExchangeSession {
fn drop(&mut self) {
self.private_key.zeroize();
}
}
Private keys are zeroed on drop via zeroize. The default session TTL is 300 seconds. Sessions are one-time use — consume_session atomically removes the session from the map on consumption, so the same session ID cannot be replayed:
// crates/tee/src/exchange/service.rs
pub async fn consume_session(&self, session_id: &str) -> Result<KeyExchangeSession, TeeError> {
let mut sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| TeeError::KeyExchange(format!("session not found: {session_id}")))?;
if session.is_expired() {
sessions.remove(session_id);
return Err(TeeError::KeyExchange(format!("session expired: {session_id}")));
}
// Session is valid — remove and return it (one-time use)
let session = sessions
.remove(session_id)
.expect("session exists; checked above");
Ok(session)
}
A background cleanup task runs every max(ttl_secs, 30) seconds to evict expired sessions that were never consumed. The TeeAuthService holds an AbortHandle to this task, which is cancelled on drop — no orphaned background tasks if the service is torn down.
The maximum concurrent sessions defaults to 64. If the limit is reached, create_session evicts expired sessions first before rejecting with a capacity error. This prevents session exhaustion from stale entries accumulating.
The Production Checklist
Putting it together: a Blueprint service with ConfidentialityPolicy::TeeRequired is production-ready when:
| Condition | Where enforced | Default |
|---|---|---|
debug_mode: false |
AttestationClaims from hardware |
false |
on_chain_verification: true |
TeeKeyExchangeConfig |
false — must override |
| Attestation not expired | AttestationReport::is_expired() |
3600s max age |
| Measurement matches on-chain |
on_chain_verification flow |
disabled by default |
SecretInjectionPolicy::SealedOnly |
TeeConfig builder |
auto-enforced when TEE enabled |
| Container source available |
ordered_source_indices filter |
fails hard if missing |
| All operators approved | approvalCount == operatorCount |
contract-enforced |
| Operator staking active | _staking.isOperatorActive |
contract-enforced |
Six of eight are enforced automatically by the type system or contracts. The two that require explicit action: on_chain_verification must be set to true, and the blueprint must expose a container source when TEE is required. Everything else fails closed.
Previous in the series: RFQ, Job Quotes, and On-Chain Verification. Next: TBD.
Top comments (0)