Understand the two verification layers
TrustedAIProxy does not ask you to trust a public key simply because the service sent it. The complete trust chain has two layers:
| Layer | What you verify | When |
|---|---|---|
| Workload attestation | Google OIDC token → Confidential Space, image digest, project/instance/service account → Ed25519 public key binding | On first connection, key changes, or proof expiry |
| Response attestation | Attested Ed25519 public key → upstream TLS, domain, path, model, request/response text, timestamp, and nonce | For every API response |
An approved Confidential Space workload generates the proof. It attests to what that workload observed over a normally verified TLS connection; it does not claim that OpenAI, Anthropic, or AWS signed the text.
Before you start: pin policy through a trusted out-of-band channel
Before inspecting any response to verify, obtain expected values from a contract, customer portal, or signed configuration bundle from the service. Self-reported response values must not define your trust policy.
| Required configuration | Example |
|---|---|
| Google issuer | https://confidentialcomputing.googleapis.com |
| Attestation audience | tap/customer/v1 |
| Image | Approved image reference and sha256:… digest |
| Operator identity | GCP project ID/number, zone, instance allowlist, and workload service account |
| Hardware policy | GCP_AMD_SEV or GCP_INTEL_TDX |
| Upstream policy | Allowed domains, paths, models, and corresponding protocol extractors |
| Attestation policy | ed25519, allowed profiles, and maximum time window |
| Replica configuration | A fixed numeric PostgreSQL secret version, or an explicit declaration that PostgreSQL is not used |
If the service provides only a public key without an image digest and instance policy, you cannot establish which program owns that key.
Obtain a public key bound to a Google attestation over HTTP
Generate a one-time challenge locally, then request the service's Confidential Space proof endpoint. Use a different 10–74 character URL-safe ASCII challenge each time.
export PROOF_CHALLENGE="$(openssl rand -hex 16)"
curl --fail --silent --show-error \
--header "Accept: application/json" \
--output confidential-attestation.json \
"https://SERVICE_HOST/.well-known/confidential-attestation?nonce=${PROOF_CHALLENGE}"The endpoint returns a Google-issued OIDC token and the Ed25519 public key intended for API response verification:
{
"token_type": "OIDC",
"attestation_token": "GOOGLE_SIGNED_JWT",
"audience": "tap/customer/v1",
"key_id": "ed25519-...",
"challenge_nonce": "CUSTOMER_CHALLENGE",
"proof_ref": "proof-...",
"expires_at": 1787839200,
"attestation_key": {
"algorithm": "ed25519",
"public_key": "BASE64URL_RAW_32_BYTE_PUBLIC_KEY",
"binding_nonce": "BASE64URL_SHA256_BINDING"
}
}Treat the entire JSON as untrusted until the Google token has been verified. The HTTP example only retrieves data; your client still needs JWT, SHA-256, and Ed25519 libraries to perform the following checks.
Verify the Google token and public key binding
Fetch Google's OIDC discovery document and its declared JWKS over HTTPS. Trust only an issuer that exactly matches local policy, and select the Google public key using the JWT header's kid.
curl --fail --silent --show-error \
--output google-oidc.json \
"https://confidentialcomputing.googleapis.com/.well-known/openid-configuration"
curl --fail --silent --show-error \
--output google-jwks.json \
"https://www.googleapis.com/service_accounts/v1/metadata/jwk/signer@confidentialspace-sign.iam.gserviceaccount.com"- Verify the JWT signature,
iss,aud, validity period, and allowed signature algorithm. Refresh JWKS for an unknownkid; never disable signature verification. - Check
swname=CONFIDENTIAL_SPACE,dbgstat=disabled-since-boot, Secure Boot, hardware type, and the out-of-band approved image digest, project, instance, and service account. - Confirm that the returned
challenge_nonceexactly matches the localPROOF_CHALLENGE,algorithmised25519and that the base64url-decoded public key is exactly 32 bytes. - Recompute the public key binding below. Confirm that it equals
binding_nonceand appears alongside the challenge in the Google token'seat_nonce.
binding_nonce = base64url_no_padding(
SHA256(
UTF8("attestation-ed25519-public-key-v1") ||
0x00 ||
raw_32_byte_ed25519_public_key
)
)Only after every check passes, cache public_key, key_id, proof_ref and expires_at together. Obtain a new proof when the proof expires, the public key changes, or local policy is updated.
Send an HTTP request and verify the response attestation
Call the service API as usual and save the original request, complete response body, and raw response headers. This example uses non-streaming OpenAI Chat Completions:
export API_KEY="YOUR_API_KEY"
curl --silent --show-error \
--dump-header response.headers \
--output response.json \
"https://SERVICE_HOST/v1/chat/completions" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
--data '{
"model": "APPROVED_MODEL",
"messages": [{"role": "user", "content": "Hello"}],
"stream": false
}'An attested response includes the following headers. If any are missing, duplicated, or malformed, mark it as unverified:
X-Attestation-Algorithm: ed25519
X-Attestation-Profile: llm-conversation-text-v1
X-Attestation-Key-Id: ed25519-...
X-Attestation-Domain: APPROVED_UPSTREAM_DOMAIN
X-Attestation-Path: /v1/chat/completions
X-Attestation-Model: APPROVED_MODEL
X-Attestation-Certificate-SHA256: 64_LOWERCASE_HEX
X-Attestation-Timestamp: UNIX_SECONDS
X-Attestation-Nonce: BASE64URL_NONCE
X-Attestation-Signed-Fields: tls_certificate_sha256,domain,request.path,request.body.model,request.body.messages,response.body.messages
X-Attestation-Signature: BASE64URL_ED25519_SIGNATURE
X-Attestation-Proof-Ref: proof-...How TAP generates the signature
- First, remove any forged or stale upstream
X-Attestation-*headers. - Verify the upstream TLS certificate and hostname normally, and record the upstream domain, request path, and leaf certificate SHA-256 fingerprint.
- Use the path's versioned extractor to normalize the actual model and request/response messages into plain-text structures that preserve roles, order, message boundaries, and original text.
- Add the protocol version, profile, key ID, Unix timestamp, and random nonce, then use RFC 8785 JCS to produce a deterministic UTF-8 JSON payload.
- Sign with the Ed25519 private key corresponding to the attested public key from step 1. The private key stays inside the workload. Return the signature and metadata needed to reconstruct the payload in response headers; leave the API response body unchanged.
If the path is unconfigured, the content type is unsupported, the body exceeds limits, extraction is incomplete, or upstream TLS verification fails, TAP does not generate local attestation headers. Unsupported content is forwarded normally; upstream TLS verification is never bypassed.
The JCS payload for the HTTP example above has this shape. Replace RESPONSE_TEXT with text extracted from the actual response according to the extractor rules:
{"domain":"APPROVED_UPSTREAM_DOMAIN","key_id":"ed25519-...","nonce":"BASE64URL_NONCE","profile":"llm-conversation-text-v1","request_fields":[{"name":"model","value":"APPROVED_MODEL"},{"name":"messages","value":[{"role":"user","text":"Hello"}]}],"request_path":"/v1/chat/completions","response_fields":[{"name":"messages","value":[{"role":"assistant","text":"RESPONSE_TEXT"}]}],"timestamp":1787835600,"tls_certificate_sha256":"64_lowercase_hex","version":"trusted-ai-proxy-v1"}How the client verifies it
- Confirm that
proof_ref,key_idmatch the valid proof cached in step 1, and use that proof's public key. - Strictly validate the algorithm, profile, signed-fields, header uniqueness, and formatting. The domain must match local policy. The path, model, and certificate fingerprint remain untrusted until signature verification succeeds.
- Using the original request and response, apply the extractor for the path indicated in the header to reconstruct
request_fieldsandresponse_fields, then combine them with header metadata to build the claims. - Apply RFC 8785 JCS to the claims, base64url-decode the 64-byte signature, and verify the payload using the attested Ed25519 public key.
- After signature verification, enforce local allowlists for the path, model, and optional certificate pin. Check the timestamp window and atomically store the nonce in a persistent cache; reject duplicate nonces.
If the response's X-Attestation-Proof-Ref differs from your local cache, generate a fresh challenge for it and request /.well-known/confidential-attestation?nonce=NEW_CHALLENGE&proof_ref=RESPONSE_PROOF_REF. Still perform every Google token and public key binding check from step 1.
export PROOF_CHALLENGE="$(openssl rand -hex 16)"
export PROOF_REF="proof-FROM-RESPONSE-HEADER"
curl --fail --silent --show-error --get \
--data-urlencode "nonce=${PROOF_CHALLENGE}" \
--data-urlencode "proof_ref=${PROOF_REF}" \
--output confidential-attestation.json \
"https://SERVICE_HOST/.well-known/confidential-attestation"What is signed for non-streaming responses
- Upstream HTTPS leaf certificate SHA-256, upstream domain, and request path;
- Actual model;
- Plain-text request and response messages, including roles, order, message boundaries, and text;
- Protocol version, profile, key ID, timestamp, and random nonce.
Images, files, audio, tool definitions/calls, reasoning, citations, and usage are outside the scope of llm-conversation-text-v1.
Streaming: attest to the request reaching the upstream
For OpenAI Chat Completions, OpenAI Responses, and Anthropic Messages, stream:true requests can receive llm-request-upstream-v1. Customers must generate a unique 10–74 character URL-safe ASCII challenge per API request and pass it using the mechanism agreed with the service:
export CHALLENGE="$(openssl rand -hex 16)"
curl --no-buffer --dump-header stream.headers \
"https://SERVICE_HOST/v1/chat/completions" \
-H "Authorization: Bearer ${API_KEY}" \
-H "Content-Type: application/json" \
-H "X-Attestation-Challenge: ${CHALLENGE}" \
--data '{
"model": "APPROVED_MODEL",
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'Before reading the first SSE body byte, the client should verify these additional fields:
X-Attestation-Profilemust bellm-request-upstream-v1;- The returned
X-Attestation-Challengemust exactly match this request's customer challenge; X-Attestation-Response-Statusmust match the response status;X-Attestation-Response-Content-Typemust be the normalizedtext/event-stream;- The signature covers model, messages,
stream:true, upstream TLS/domain/path, response metadata, and the challenge.
A gateway can modify, replace, or truncate the entire stream without invalidating this profile's signature. The product must clearly mark the streaming body as unverified; a blanket “response verified” status is incorrect.
Missing, duplicated, or invalid challenges may still allow normal stream forwarding, but no attestation headers are generated. Bedrock streaming paths currently also pass through without signatures.
Requirements for production clients
- Separate policy from responses: issuer, audience, digest, instances, domains, paths, and models must come from local policy.
- Parse strictly: reject duplicate JSON keys, duplicate or missing headers, invalid base64url, malformed Unicode, and unsupported profiles.
- Verify before trusting: do not trust a proof bundle's public key before verifying the Google token, or treat header paths/models as facts before Ed25519 verification.
- Reconstruct deterministically: normalize roles, order, message boundaries, and text using the corresponding extractor, then apply RFC 8785 JCS.
- Prevent replay: limit timestamp age and atomically store verified nonces in a persistent cache; fail on duplicate nonces.
- Cache proofs: cache by
proof_refwith the verified public key and expiry. Do not identify key rotation using only the key ID. - Make failures visible: distinguish “unverified”, “verification failed”, and “request verified, response unverified” as three separate states.
- Bound resources: limit proof bundles, headers, JSON bodies, network timeouts, and JWKS caches.
For full field ordering, JCS claims, and protocol-specific extraction rules, consult the repository's customer verification guide and reference implementations.
Handling failures
| Condition | Client conclusion |
|---|---|
| Any required header is missing | Unverified; do not display verification success. |
| Google token signature or policy mismatch | Untrusted workload; discard the public key and proof. |
| Ed25519 signature verification fails | Content or proof has changed; the response is invalid. |
| Timestamp outside window or duplicate nonce | Possible replay; the response is invalid. |
| Domain/path/model not allowed by local policy | The signature may be valid, but business policy rejects it. |
| Proof endpoint returns 404 | Unknown proof_ref; trust cannot be established. |
| Proof endpoint returns 410 | The replica holding the private key is offline; send a new API request later. |
| Proof endpoint returns 503 | Temporary issuance or cross-replica routing failure. Retry if appropriate, but never downgrade the check to verified. |
| Streaming request-upstream verification passes | Only the request and upstream metadata are attested; the streaming body remains unverified. |
Frequently asked questions
Do I need to install the MITM CA?
No. The MITM CA is used only on the internal link from the gateway to TAP. End users call the service over normal HTTPS and verify the Google attestation and Ed25519 signatures.
Why can't I trust the public key supplied by the service directly?
The service could generate another key at any time. First verify the Google token to confirm that the public key is bound to the approved Confidential Space image and instance policy.
Do I need a Google attestation for every request?
No. Use proof_ref to cache verified proofs and public keys until expires_at, a public key change, workload restart, or local policy update. Each API signature still needs independent verification.
Does successful verification prove that the “genuine model” was used?
It proves that TAP sent the signed model and messages to the specified upstream domain/path over verified TLS and observed the signed response text. It does not prove the provider's internal generation process or cover business fields outside the profile.