c8s-verify (JavaScript)

The client-side library that verifies a c8s Load Balancer's attestation from a browser or Node and opens the post-quantum over-encrypted channel — installation, the C8sClient options, the session object, what connect() checks, and every error code.

c8s-verify is the client-side library that implements everything above: it verifies a c8s Load Balancer's attestation from a browser or Node, then opens the post-quantum over-encrypted channel that terminates inside the LB's enclave. Evidence is verified in your browser by the attestation-rs verifier compiled to WebAssembly, with the AMD roots bundled — no network calls during verification. Its only runtime dependency is mlkem-wasm for ML-KEM-768.

Use a build that speaks c8s-verify/v1.

The wire protocol is versioned and the LB serves exactly one binding: the identity-bound transcript above. A client that computes the older session-key-only report_data fails closed on a binding mismatch. Check the library's PROTOCOL.md against the version string the attestation bundle carries before pinning a release.

Prerequisites

  • Runtime: Node ≥ 20, or a modern browser — the library uses WebCrypto and WebAssembly.
  • Two values pinned out of band. Without both you cannot prove you reached your cluster, and the library refuses to run:
  • A reachable LB serving the c8s-verify/v1 endpoints under /.well-known/c8s/.

The browser verifier covers the SEV-SNP family only — bare snp and Azure vTPM az-snp. Intel TDX (tdx, az-tdx) is carried by the protocol and served by the LB, but the WASM verifier does not implement it; verify TDX deployments from the operator path instead.

Installation

The package name is c8s-verify and it is not on the public npm registry yet, so consume it from the c8s-verify-js repo — vendor it into your app or add it as a git submodule — and install its one runtime dependency:

npm install mlkem-wasm

The prebuilt WASM verifier ships in the repo, so verification itself makes no network calls.

Usage

The high-level C8sClient runs the entire flow — fetch the attestation, verify it, run the handshake, derive the channel — and returns a Session whose fetch is end-to-end encrypted to the enclave:

import { C8sClient } from "c8s-verify";

const client = new C8sClient({
  baseUrl: "https://lb.example.com",
  measurements: ["<SHA384_LAUNCH_DIGEST>"], // pinned out of band; empty is refused
  meshCaPem: pinnedMeshCaPem,               // pinned cluster anchor; required
});

// Generates a 32-byte nonce, fetches the LB attestation, verifies the hardware
// evidence, the measurement, the identity transcript, the leaf's chain to the
// pinned mesh CA, and the proof signature — then runs the X25519 + ML-KEM-768
// handshake and derives the AES-256-GCM channel.
const session = await client.connect();
console.log(session.attestation.measurement, session.attestation.cert.sha256);

// Every request on session.fetch is sealed end-to-end to the LB enclave —
// method, path, headers and body — underneath whatever TLS terminator is in front.
const res = await session.fetch("/v1/chat", { method: "POST", body: prompt });
console.log(res.text());

If any check fails, connect() throws a typed C8sVerifyError and no channel is established (fail-closed) — see Errors.

Configuration

new C8sClient(options):

OptionTypeDefaultDescription
baseUrlstring— (required)LB origin, e.g. https://lb.example.com.
measurementsstring[]— (required)Accepted launch digests (hex SHA-384). An empty list is rejected.
meshCaPemstring— (required)Pinned mesh CA (PEM) the LB's leaf must chain to.
platformstring"snp"Expected TEE platform; "az-snp" for Azure vTPM evidence.
requireFreshnessbooltrueRequire report_data to bind the identity transcript. false downgrades the check to a warnings[] entry the embedding app must inspect (UNSAFE for a live channel; for recorded-evidence demos).
fetchfunctionglobalThis.fetchCustom fetch implementation (e.g. for Node without a global fetch, or to inject headers).
wellKnownPrefixstring/.well-known/c8sBase path for the protocol endpoints.
atDatenowValidity reference time for certificate checks.

The session object

client.connect() resolves to a Session:

  • session.attestation — the verification result: measurement, platform, cert info (incl. cert.sha256), and any warnings.
  • session.fetch(path, init?) — an over-encrypted request. The entire request — method, path, headers, and body — is sealed with AES-256-GCM and POSTed to the tunnel endpoint, so a proxy in front of the LB sees only ciphertext. Resolves to { status, headers, bytes, text() }.

What it verifies

connect() (via verifyAttestation) performs these checks, failing closed on the first failure:

  1. nonce echo — the response carries back the exact nonce the client sent.
  2. hardware signature + vendor chain — verified in WASM against the bundled AMD roots.
  3. measurement ∈ allowlist — the report's launch digest matches a pinned measurement (case-insensitive hex).
  4. identity transcriptreport_data equals SHA-384 over the length-prefixed (version, mesh-CA digest, mesh-leaf digest, X25519 pubkey, ML-KEM-768 pubkey, nonce). One check proves the evidence is fresh, that the session key was generated inside the attested TEE, and which cluster identity it belongs to.
  5. leaf chains to the pinned mesh CA — cluster identity.
  6. proof of possession — the ECDSA-SHA384 signature over the transcript verifies under the leaf's public key.

The verified transcript is then the HKDF salt, so a channel cannot be derived from checks that did not pass.

The browser's bare-snp WASM path enforces fewer checks than the Go and Rust verifiers the cluster runs: it omits the VMPL-0 requirement, debug-policy rejection, the minimum-TCB floor, and VEK validity/CRL. A browser client would accept a debug-enabled or non-VMPL-0 guest whose measurement is allowlisted. See Limitations.

Errors

Every failure throws a typed C8sVerifyError carrying a .code (and .details). The codes mirror the c8s server error envelope where they overlap:

CodeMeaning
invalid_requestBad client arguments (e.g. missing baseUrl, empty measurement allowlist).
nonce_mismatchThe response did not echo the nonce that was sent.
verification_failedHardware signature / vendor chain or platform check failed.
report_data_mismatchreport_data did not match the expected transcript.
measurement_deniedThe launch digest is not in the allowlist.
invalid_cert / cert_chainThe leaf is malformed, or did not chain to the mesh CA.
identity_bindingmeshCaPem is missing, or the proof of possession failed.
key_bindingThe session key is not bound to the attested report.
channel_errorThe handshake or an over-encrypted request failed.
unsupportedUnsupported platform or protocol feature.

The server side answers in the same envelope, with binding_unavailable when the LB has no mesh identity credentials configured and attestation_unavailable when it cannot reach its attestation-api.

Lower-level: verifying bare evidence

If you obtain SNP evidence through your own transport (e.g. a discovery document) rather than the c8s-verify/v1 challenge/response bundle, use verifyEvidence. It runs the same hardware verification and measurement / platform checks, plus a report_data binding when you pass expectedReportData — with no bundle, nonce, session key, or certificate required (do any mesh-CA chaining yourself):

import { verifyEvidence } from "c8s-verify";

const r = await verifyEvidence(evidence /* { attestation_report, cert_chain:{ vcek } } */, {
  generation: "genoa",          // "milan" | "genoa" | "turin" (required for bare snp)
  measurements: ["<SHA384_LAUNCH_DIGEST>"],
  expectedReportData,           // optional Uint8Array, exactly as the producer bound it
});
console.log(r.measurement, r.reportDataMatch, r.claims);

Pass expectedReportData unpadded — the anchor the producer bound (48 bytes for c8s bindings), not the zero-padded 64-byte hardware field. The raw WASM entrypoints verifySnp and verifyAzSnp are also exported for full control.

Try it offline

The repo ships a self-contained mock LB so you can run the whole flow with no TEE:

npm install
npm run gen-fixtures   # openssl mesh CA + leaf, copies recorded SNP evidence
npm run demo           # serves the mock LB + browser demo on http://localhost:8799

Open the page and click Run verification; each step shows green or red, and a Tamper with evidence toggle flips a byte of the signed report to demonstrate failing closed. The recorded evidence is real hardware-signed SNP evidence, so the signature, measurement, certificate chain, and post-quantum channel are all genuine — only the live report_data binding is simulated, which is the same downgrade cds-attest --evidence-fixture serves and why that flag is dev only.

See also