A key feature in the trust model of Trusted Execution Environments (TEEs) is that they can provide proof that they are indeed TEEs. This process is called attestation, and the proofs provided are called attestation documents. Companies will often use TEEs without actually performing an attestation check, turning them into Blindly Trusted Execution Environments.

Not bothering to do attestation is understandable in some cases. If the company is confident in their CI pipeline and cloud security, maybe they can trust that the requests they’re sending will indeed reach the TEE they’ve deployed. But TEEs are designed to be attested, so they should be, especially if you tell your customers that you are using TEEs.

In this post, we’ll explain how we perform TEE attestation of an AWS Nitro Enclave from the browser, sharing plenty of snippets from our open-source production codebase along the way.

If any terms are unfamiliar, feel free to check this quick glossary as you go:


Glossary

  • Trusted Execution Environment (TEE): A hardware‑backed, isolated execution context that protects code and data from the rest of the host (including the OS and cloud operator).
  • Attestation Document: A signed statement, produced by the TEE’s hardware, proving what code is running and on what hardware.
  • AWS Nitro Enclave: Amazon’s TEE implementation for EC2 instances. It runs your code in a separate VM that has no network, disk, or interactive access except through explicit “vsock” channels.
  • Nitro Security Module (NSM): The on‑chip root of trust inside every Nitro host. It signs attestation documents and exposes the attestation API used here.
  • Platform Configuration Registers (PCRs): Hash registers inside the NSM that accumulate measurements (hashes) of firmware, kernel, and user code during boot.
  • PCR 8: The register that holds the hash of the public key of the user-supplied keypair at build-time. Verifying PCR8 lets you ensure that the TEE was built in an environment that had access to a specific secret key.
  • ML‑KEM: NIST’s post‑quantum Key Encapsulation Mechanism that we use to derive a shared key inside the enclave.
  • WSS: WebSocket over TLS. Gives us a stateful, end‑to‑end encrypted channel that persists across the attestation handshake and subsequent messages.

How to do TEE Attestation Properly

In a theoretical model where a client communicates directly with a TEE, attestation is quite simple: request an attestation document, verify it, and then send your sensitive data.

TEE(1)

However, this model assumes that you’re interacting directly with the TEE and not transmitting data over any sort of untrusted medium. But this doesn’t hold when you need to interact with a TEE over the internet. Your messages are routed through a complex mesh of networking layers, which could expose them to man-in-the-middle attacks:

TEE(2)

Even if you use encrypted channels such as HTTPS, that still doesn’t fix the issue, as typical HTTPS requests follow a stateless request-response model.

TEE(3)

The fix is to ensure that the encrypted channel is maintained across both requests. Then, even if an attacker reroutes the second request, they’ll only receive encrypted data, which they won’t have the appropriate keys to decrypt.

TEE(4)

Our Solution: Attestation over WebSockets

Performing attestation and subsequent requests over a WSS channel maintains a stateful, encrypted connection with the server.

TEE(5)

Implementation

We run the Proof Service for version 1 of our yellowpages product in an AWS Nitro Enclaves TEE. We use Evervault Enclaves to manage the deployment and hosting of our TEE. Their product handles HTTP routing for us and provides TLS termination within the TEE.

Note: this section includes mentions of the post-quantum key agreement scheme ML-KEM. Understanding ML-KEM isn’t required to follow our attestation implementation, but if you’d like to learn more, check out our post Guaranteeing post-quantum encryption in the browser: ML-KEM over WebSockets.

Server: Host a WSS endpoint

We use axum for our Rust server, and it has excellent WebSocket support.

#[tokio::main]
async fn main() {
...
let app = Router::new()
.route("/prove", get(handle_ws_upgrade))
...
...
}
pub async fn handle_ws_upgrade(
State(config): State<Config>,
...
ws: WebSocketUpgrade,
...
) -> impl IntoResponse {
log::info!("Received WebSocket upgrade request");
...
ws.on_upgrade(move |socket| run_pq_channel_protocol(socket, config))
}

Link to source code

Client: Establish a WebSocket Connection with Server

We use the standard JavaScript WebSocket client.

const ws = new WebSocket(
`${domains.proofService}/prove?...`
);

Link to source code

Client: Request Attestation Document

The client sends an initial handshake message, which doesn’t contain any sensitive data, since it hasn’t yet verified that it’s communicating with the expected TEE. This handshake message should include something unique that can be used as a challenge to the server. In our case, that unique data is an ephemeral ML-KEM public key.

const handshakeMessage: HandshakeMessage = {
ml_kem_768_encapsulation_key: mlKem768EncapsulationKeyBase64
};
ws.send(JSON.stringify(handshakeMessage));

Link to source code

Server: Fetch Attestation Document

At this point, the server must request a signed attestation document from the AWS Nitro Security Module (NSM). Evervault provides a simple endpoint within the Enclave that executes the underlying NSM API command to fetch the attestation document. We can embed challenge-specific data in these documents when we need to prove that the document was requested at a specific time and by a specific server instance. In this case, we include a hash of the ML-KEM ciphertext generated using the client’s ML-KEM public key. This binds the attestation document to this specific encrypted channel.

/// pq_channel.rs
pub async fn run_pq_channel_protocol(mut socket: WebSocket, config: Config) {
log::info!("WebSocket connection established");
// Step 1: Perform handshake ...
let ... = match perform_handshake(&mut socket)...
...
}
async fn perform_handshake(socket: &mut WebSocket) -> Result<SharedKey<MlKem768>, WsCloseCode> {
...
// Get attestation document for the ciphertext
let auth_attestation_doc = ok_or_internal_error!(
generate_auth_attestation_doc(&ciphertext).await,
"Failed to get attestation document for handshake"
);
...
}
async fn generate_auth_attestation_doc(ciphertext_bytes: &[u8]) -> Result<String, WsCloseCode> {
// Calculate SHA256 hash of ciphertext bytes
let hash = sha256::Hash::hash(ciphertext_bytes);
// Create the user data struct with base64-encoded hash
let user_data = AuthAttestationDocUserData {
ml_kem_768_ciphertext_hash: base64.encode(hash),
};
// Serialize to JSON and base64 encode
let user_data_base64 = ok_or_internal_error!(
serde_json::to_string(&user_data).map(|json| base64.encode(json.as_bytes())),
"Failed to encode auth attestation user data"
);
// Request attestation document
request_attestation_doc(user_data_base64).await
}
/// utils.rs
pub async fn request_attestation_doc(user_data: String) -> Result<String, WsCloseCode> {
let client = Client::new();
// Create the attestation request
let request_body = AttestationRequest {
challenge: user_data,
};
// Send request to the attestation endpoint
let response = ok_or_internal_error!(
client
.post("http://127.0.0.1:9999/attestation-doc")
.json(&request_body)
.send()
.await,
"Failed to fetch attestation document from endpoint"
);
...
// Base64 encode the attestation document
Ok(base64.encode(attestation_bytes))
}

Links to source code: pq_channel.rs, utils.rs

Server: Send the Attestation Document Back to the Client

We transmit the base64-encoded attestation document back to the client over the WebSocket, wrapped in JSON.

async fn perform_handshake(socket: &mut WebSocket) -> Result<SharedKey<MlKem768>, WsCloseCode> {
...
// Create and send the response
let handshake_response = HandshakeResponse {
ml_kem_768_ciphertext: ciphertext_base64,
auth_attestation_doc,
};
...
ok_or_internal_error!(
socket.send(WsMessage::Text(response_json.into())).await,
"Failed to send handshake response"
);
...
}

Link to source code

Client: Verify the Attestation Document

First, the client checks whether the ML-KEM ciphertext was correctly generated using the public key it provided. Then it verifies that the attestation document satisfies each of the following properties:

  • It was signed by the AWS Nitro Security Module public key.
  • It contains the expected PCR8 hash measurement, which proves that the TEE was built and signed using our CI pipeline’s secret key.
  • It contains the expected challenge: a hash of the ML-KEM ciphertext.

We use Evervault’s wasm-attestation-bindings package to perform this verification in the browser using WebAssembly.

/// api.ts
const handshakeResponse = await raceWithTimeout<HandshakeResponse>(...);
...
const attestationDoc = handshakeResponse.auth_attestation_doc as AttestationDocBase64;
try {
await verifyAttestationDoc(
attestationDoc,
expectedPCR8,
mlKem768CiphertextBytes
);
...
}
...
/// cryptography.ts
import init, {
validateAttestationDocPcrs,
getUserData,
} from '@evervault/wasm-attestation-bindings';
...
export async function verifyAttestationDoc(
attestationDoc: AttestationDocBase64,
pcr8: PCR8Value,
mlKem768Ciphertext: MlKem768CiphertextBytes
): Promise<void> {
try {
...
const pcrs = new PCRs(
undefined, // pcr_0
undefined, // pcr_1
undefined, // pcr_2
pcr8, // pcr_8
undefined // hash_algorithm
);
// note: includes checking Nitro Security Module signature
const pcrsValid = validateAttestationDocPcrs(attestationDoc, [pcrs]);
if (!pcrsValid) {
throw new Error('PCR8 verification failed');
}
// Step 2: Verify user data contains matching ciphertext hash
await verifyAttestationDocUserData(attestationDoc, mlKem768Ciphertext);
}
...
}

Links to source code: api.ts, cryptography.ts

Client: Transmit Sensitive Data over the WebSocket

At this point, the client has verified that it’s communicating over an encrypted channel with one of our TEEs (using post-quantum encryption in our case). The client can safely transmit encrypted data using subsequent WebSocket messages, knowing that the data can only be decrypted within the TEE.

Further Improvements

  • Check all PCR measurements: Currently, we only check the PCR8 measurement, as this remains consistent across deployments. This allows us to deploy new versions of the Proof Service without risking race conditions where the frontend might expect invalid measurements during deployment. However, to further improve the trust model, we could fetch the latest PCR measurements at runtime and design a solution to eliminate race conditions during deployment.
  • Use hash of the ML-KEM public key in the challenge instead of the ML-KEM ciphertext: Currently, the attestation document is generated using a hash of the ML-KEM ciphertext as the challenge. This requires the client to first verify that the ciphertext is legitimate before verifying the attestation document. This isn’t a problem in our case, since the client must verify the ciphertext anyway, but the design might be cleaner if we used a hash of the ML-KEM public key as the challenge instead.

Conclusion

In this post, we demonstrated how to perform TEE attestation of an AWS Nitro Enclave from the browser. This gives our clients an improved trust model and is also a genuine security improvement: if someone managed to deploy malicious code to a server hosted behind our Proof Service’s domain 😈, their server wouldn’t be able to produce an attestation document that our clients would trust 👿.

Use Trusted Execution Environments, not Blindly Trusted Execution Environments.