Verified chat over confidential vLLM

Build a small chat client that verifies a c8s Load Balancer's TEE attestation with c8s-verify, then talks to a downstream vLLM running confidentially in the cluster — over a post-quantum over-encrypted channel.

This tutorial builds a tiny chat client that talks to a vLLM model server running inside a c8s cluster — but only after cryptographically verifying it's a genuine TEE. Using c8s-verify, the client fetches the Load Balancer's attestation, checks it against a pinned measurement and your cluster's mesh CA, and only then opens a post-quantum over-encrypted channel to the LB enclave. Every prompt and completion is sealed end-to-end — a TLS-terminating proxy in front of the LB sees only ciphertext.

If the design is new, skim Consumer Verification first; this is the hands-on version.

What you verify here is the LB, plus that it belongs to your cluster (its CDS-issued cert chains to your pinned mesh CA). By transitivity of trust, an attested LB only forwards to attested pods over the RA-TLS mesh — so verifying the LB transitively covers the vLLM backend behind it.

Prerequisites

1. Deploy a confidential vLLM backend

Run vLLM as a confidential workload. The confidential.ai/cw: vllm annotation opts the pod in — the value vllm is the workload id — so the operator injects the get-cert identity (and, under --kata, the confidential RuntimeClass) and mints a headless Service c8s-vllm.<namespace>.svc. Headless DNS returns pod IPs, which the node mesh wraps in attested mTLS. We serve facebook/opt-125m, a tiny model that's quick to load on CPU.

This tutorial uses a CPU build of vLLM.

vLLM's published vllm/vllm-openai image is a CUDA build that won't start without a GPU, and vLLM ships no official prebuilt CPU image — so build one from vLLM's CPU Dockerfile, push it to a registry your cluster can pull, and reference that image below (pinning its digest in Step 2).

Running the GPU build instead? Your cluster must be GPU-provisioned for confidential passthrough before c8s installs — GPUs bound to vfio-pci, GPU CC mode on, BAR resize done (this is host provisioning's job; do not install the NVIDIA GPU Operator — it assumes host-visible GPUs with a host driver and conflicts with the passthrough model). Then use the CUDA image and add a GPU resource limit with the per-model name your node advertises (kubectl describe node <gpu-node> | grep nvidia.com), e.g. nvidia.com/GB202GL_RTX_PRO_6000_BLACKWELL_SERVER_EDITION: 1 — the webhook injects the confidential GPU RuntimeClass from the GPU request alone. Don't set a memory limit on GPU pods, and expect one vCPU per GPU pod on SEV-SNP — see Kata containers.

# vllm.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: vllm, namespace: workloads }
spec:
  replicas: 1
  selector: { matchLabels: { app: vllm } }
  template:
    metadata:
      labels: { app: vllm }
      annotations: { confidential.ai/cw: vllm }   # workload id → headless Service c8s-vllm
    spec:
      containers:
        - name: vllm
          image: <your-registry>/vllm-cpu:v0.6.3   # your CPU build (see the callout above)
          args: ["--model", "facebook/opt-125m"]   # tiny test model
          ports: [{ containerPort: 8000 }]         # vLLM's OpenAI server port
kubectl create namespace workloads
kubectl apply -f vllm.yaml

You don't create a Service yourself — the operator mints the c8s-vllm headless Service from the annotation. (The pod won't actually start until its image is allowlisted, next.)

2. Allow the vLLM image

Nothing runs unless its image digest is on the allowlist — otherwise enforcement blocks the container at creation. Resolve the digest of the exact image you deployed:

crane digest <your-registry>/vllm-cpu:v0.6.3
# sha256:…

Add it to the live allowlist with c8s allowlist, signed by the operator key you created when installing the cluster (operator.key — its public half is what you pinned with --operator-keys). The CDS has no public ingress, so port-forward it first — along with the attestation-api, which the CLI uses to verify the CDS's own attestation before talking to it:

kubectl port-forward -n c8s-system svc/c8s-cds 8443:8443 &
kubectl port-forward -n c8s-system svc/c8s-attestation-api 8400:8400 &

c8s allowlist add sha256:<vllm-image-digest> <your-registry>/vllm-cpu:v0.6.3 \
  --url https://localhost:8443 \
  --attestation-api-url http://localhost:8400 \
  --operator-key operator.key
# added sha256:<vllm-image-digest>

The CLI will warn that no --measurements are pinned — it's accepting any attested CDS, which is fine for this tutorial cluster; in production pin your CDS launch digest so a write can never land on a rogue CDS. The enforcement plugins poll the CDS-served list, so the vLLM pod from Step 1 starts on its next retry:

kubectl get pods -n workloads -w
# vllm-…   2/2   Running   ← the vLLM container plus the injected c8s-cert sidecar

Prefer a version-controlled allowlist? The bootstrap path pins digests in a values file instead (nriImagePolicy.bootstrapAllowlist.digests), baking them into the boot-time floor the chart seeds into the CDS — better suited to GitOps flows. See Seeding and bootstrap and Managing it with the c8s CLI.

3. Point the Load Balancer at vLLM

You don't hand-edit nginx. The chart wires the front door — tls-lb (public TLS + over-encryption) → your engine, over the node mesh's attested mTLS — and you aim it at your vLLM workload with --workload-ref + --upstream, plus a small values file for the attestation endpoints:

# values.yaml
tlsLb:
  attest:
    enabled: true     # serve the c8s-verify/v1 attestation + over-encryption endpoints

Apply the routing with one upgrade — re-run your original c8s install with the two flags and -f values.yaml added:

c8s install \
  --workload-ref vllm=workloads/deployment/vllm:8000 \
  --upstream vllm \
  -f values.yaml   # plus the flags you first installed with (e.g. --single-node --operator-keys operator.pub)

The ref's :8000 (vLLM's default port) derives tlsLb.upstream = c8s-vllm.workloads.svc.cluster.local:8000, so tls-lb dials vLLM's headless Service directly and that hop rides the node mesh's attested mTLS — hence no nginx surgery. The confidential.ai/cw: vllm stamp the ref applies is a no-op — Step 1's manifest already carries it. A request your client sends over the over-encryption tunnel (Step 6) is decrypted inside the LB enclave and forwarded over the mesh to vLLM's OpenAI-compatible /v1/chat/completions.

4. Gather the two pinned values

Verification is meaningless without pinning your cluster's identity out of band:

  • LB launch measurement — the SHA-384 launch digest of the LB enclave. Use the published or recomputed digest (see Obtaining launch measurements).
  • Mesh CA certificate — your cluster's CDS mesh CA (PEM). Capture it from a trusted context at install time (the CDS publishes its public CA bundle, e.g. GET /ca). This is the cluster-unique anchor — why.
export LB_MEASUREMENT=<sha384-launch-digest-of-the-lb>
# plus mesh-ca.pem, captured out of band from your cluster operator / CDS bootstrap

5. Verify and connect

Create a C8sClient with the LB origin and your pinned values, then connect(). That generates a nonce, fetches the LB attestation, verifies the attestation evidence in WASM, checks the measurement and the report_data binding, confirms the CDS cert chains to your mesh CA, and runs the X25519 + ML-KEM-768 handshake — all fail-closed:

import { C8sClient } from "c8s-verify";
import { readFileSync } from "node:fs";

const client = new C8sClient({
  baseUrl: "https://chat.example.com",            // your c8s LB
  measurements: [process.env.LB_MEASUREMENT],     // pinned LB launch digest
  meshCaPem: readFileSync("mesh-ca.pem", "utf8"), // pinned cluster anchor
});

const session = await client.connect();           // throws C8sVerifyError on any failure
console.error(`✓ verified LB enclave — measurement ${session.attestation.measurement}`);

If anything is off — wrong measurement, tampered evidence, or a cert that doesn't chain to your mesh CA — connect() throws and no channel is opened. See what it verifies.

6. Chat over the sealed channel

session.fetch seals the entire request and sends it down the tunnel; the LB enclave decrypts it, forwards it to vLLM over the mesh, and seals the response back. Call vLLM's OpenAI-compatible endpoint — the model must match what you served in Step 1:

async function ask(session, messages) {
  const res = await session.fetch("/v1/chat/completions", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ model: "facebook/opt-125m", messages }),
  });
  if (res.status !== 200) throw new Error(`vLLM returned HTTP ${res.status}: ${res.text()}`);
  return JSON.parse(res.text()).choices[0].message.content;
}

Wrap it in a REPL for an actual chat loop (chat.mjs):

import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

const rl = createInterface({ input, output });
const history = [];
for (;;) {
  const prompt = await rl.question("you> ");
  if (!prompt || prompt === "/exit") break;
  history.push({ role: "user", content: prompt });
  const reply = await ask(session, history);       // session from Step 5
  history.push({ role: "assistant", content: reply });
  console.log(`bot> ${reply}\n`);
}
rl.close();

Run it:

LB_MEASUREMENT=$LB_MEASUREMENT node chat.mjs

What you've proven

By the time the first reply comes back, you've established — from an untrusted client, over an untrusted network — that:

  1. the LB you reached is a genuine TEE running the exact measured image you pinned;
  2. it belongs to your cluster (its cert chains to your mesh CA), not an attacker's look-alike;
  3. the session key was minted inside that enclave (the report_data binding), so the over-encrypted channel terminates there and nowhere else;
  4. by transitivity, the vLLM backend it forwards to is itself an attested pod on the c8s mesh.

Your prompts and the model's completions are sealed end-to-end to the enclave — the TLS terminator, the host, and the infrastructure operator never see plaintext.

For the bare-evidence path (verifying without opening the over-encryption channel) and the full API, see c8s-verify.