c8s-verify (JavaScript)
JavaScript client options, attestation results, policy-history acceptance, channel binding, and errors for the C8s Load Balancer.
c8s-verify verifies a C8s Load Balancer's (router) attestation in a browser or Node
and opens a post-quantum over-encrypted channel to the router: application-layer encryption
inside HTTPS. X-Wing is the key-exchange mechanism. This reference describes the
TypeScript implementation vendored with this documentation site. It calls the
attestation-rs WebAssembly verifier for hardware evidence and uses mlkem-wasm and
@noble/hashes for the X-Wing exchange.
The Rust/WASM implementation and bundled trust roots have not been independently verified for this reference. The checks below describe the vendored TypeScript call paths and their expected WASM results. Local evidence checks use supplied inputs; browser module loading can fetch the WASM asset, and operator-signed route acceptance fetches the published manifest.
Use a build that speaks c8s/attest-pq/v1.
This JavaScript client supports the c8s/attest-pq/v1 binding: one POST to
/.well-known/c8s/attest-pq carries the attestation and the X-Wing key exchange together.
The client has no fallback binding; it rejects an incompatible response version.
Check the library's PROTOCOL.md against the version string the attestation bundle carries
before pinning a release.
Prerequisites
- Runtime: Node ≥ 20.19 for the locked dependencies, or a browser with WebAssembly and WebCrypto support including X25519.
- A pinned image. One or more SHA-384 launch digests in
measurements(see Obtaining launch measurements), or on Intel TDX the completetdxImagetuple or MRTD plus explicit RTMR[1]/RTMR[2] pins. - At least one cluster or deployment anchor, pinned out of band. The library requires:
- the mesh CA certificate (PEM) from your cluster's Certificate Distribution Service (CDS) — the cluster-unique anchor (see why cluster identity is pinned);
- or the canonical allowlist bytes, enforced against the router's identity certificate (the mesh leaf). Its matched-workload stamp records the matched workload name and the allowlist policy digest used at issuance. This identifies a deployment class; it cannot distinguish a genuine clone with the same public inputs.
- A reachable LB serving the
c8s/attest-pq/v1endpoints under/.well-known/c8s/.
The client exposes verifier entrypoints for AMD SEV-SNP (snp, Azure vTPM az-snp) and Intel TDX (tdx, Azure
vTPM az-tdx).
Installation
The package is named c8s-verify. It declares Node ≥ 20, while its locked @noble/hashes
dependency requires Node ≥ 20.19. The documentation checkout contains the TypeScript snapshot
at website/vendor/c8s-verify-js, relative to the website repository root. That snapshot lacks
the Rust verifier source and its tracked submodule commit, so it is insufficient for a fresh
WASM build.
The package metadata identifies https://github.com/confidential-dot-ai/c8s-verify-js.git
as the source repository. A build needs a Git checkout of that package with its pinned
vendor/attestation-rs submodule entry, access to that submodule, npm, Rust with the
wasm32-unknown-unknown target, and wasm-pack. Public npm availability and the current contents
of the external repositories have not been verified for this reference.
From the package checkout root (c8s-verify-js, containing package.json), the source-build
commands are:
git submodule update --init vendor/attestation-rs
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
npm ci
npm run build:wasm
npm run buildConsume the built checkout from your application with npm install <path-to-checkout>.
These commands describe the vendored scripts; a complete WASM build and hardware-backed
connection have not been validated here.
Usage
The high-level C8sClient sends a nonce and an X-Wing encapsulation key in one attestation
POST, verifies the response, decapsulates, and derives a channel. With routeOperatorKeys,
connect() also fetches the route manifest, can refetch it after a digest mismatch, and retries
once when the attested route revision is below the rollback floor. It returns a Session for
encrypted application requests:
import { C8sClient } from "c8s-verify";
const client = new C8sClient({
baseUrl: "https://lb.example.com",
measurements: ["<SHA384_LAUNCH_DIGEST>"], // pinned out of band
meshCaPem: pinnedMeshCaPem, // specific-cluster anchor
});
const session = await client.connect();
console.log(session.attestation.measurement, session.attestation.cert.sha256);
const res = await session.fetch("/v1/chat", { method: "POST", body: prompt });
console.log(res.text());<SHA384_LAUNCH_DIGEST> is the LB node's launch digest. For bare-metal Intel TDX, use
an image manifest obtained through a trusted build-artifact path:
import { C8sClient, parseImageManifest } from "c8s-verify";
const client = new C8sClient({
baseUrl: "https://lb.example.com",
platform: "tdx",
tdxImage: parseImageManifest(pinnedManifestBytes),
meshCaPem: pinnedMeshCaPem,
});parseImageManifest accepts a string or Uint8Array containing mrtd, rtmr1, and rtmr2,
either at the top level or under tdx. It validates the tuple's shape; the caller supplies its
provenance. For Azure TDX, select platform: "az-tdx".
A rejected check prevents connect() from returning a session. A partial policy-history result
also prevents it by default. Library validation failures use C8sVerifyError; transport and
runtime failures can have other types. See Errors.
Configuration
new C8sClient(options):
| Option | Type | Default | Description |
|---|---|---|---|
baseUrl | string | — (required) | LB origin, e.g. https://lb.example.com. |
measurements | string[] | — | Accepted launch digests (hex SHA-384). A nonempty list is required without tdxImage; a nonempty list conflicts with tdxImage, but [] is accepted alongside the tuple. |
meshCaPem | string | — | Pinned mesh CA (PEM) the LB's leaf must chain to; trustClass: "specific-cluster". Each PEM block is trusted independently, so prefer one. |
allowlist | Uint8Array | string | — | Exact canonical allowlist bytes (as GET /allowlist serves them, never re-serialized); trustClass: "deployment-class" when meshCaPem is absent. At least one of meshCaPem and allowlist is required. |
workloadName | string | — | Expected matched-workload name on the mesh leaf. |
routeDigests | string[] | — | Accepted route digests (64 lowercase hex, as c8s routes digest prints); the one the evidence binds must be among them, or connect() fails with route_denied. Pass the old and the new digest while a manifest rolls over. Excludes routeOperatorKeys. |
routeOperatorKeys | string | — | PEM bundle of operator public keys (ECDSA P-256, P-384, or P-521) the router's published route manifest (/.well-known/c8s/routes) must be signed under; its digest must be the one the evidence binds. Requires router.routePublication.enabled. Excludes routeDigests. |
routeFloor | RouteFloor | — | With routeOperatorKeys: previously accepted { revision, digest }, rejecting an older revision or the same revision with another digest. See Persisting the route floor. |
platform | string | "snp" | Expected TEE platform: "snp", "az-snp", "tdx", or "az-tdx". |
generation | string | from the response | SEV-SNP processor generation ("milan", "genoa", "turin"); platform: "snp" only. |
tdxImage | object | — | TDX image pin { mrtd, rtmr1, rtmr2 } from parseImageManifest; replaces measurements. A TDX deployment-class verdict needs this tuple or measurements plus both rtmrs[1] and rtmrs[2]. Requires a TDX platform. |
expectedRtmr3 | string | — | TDX RTMR[3] seed (96 hex characters), before measured policies extend it. Excludes operatorPublicKeyPem and rtmrs[3]. |
operatorPublicKeyPem | string | — | Exact operator public-key PEM bytes hashed at boot; derives the seed SHA-384(0x00*48 ‖ SHA-384(pem)). Requires tdxImage; excludes other RTMR[3] pin forms. |
rtmrs | object | — | TDX register pins { 1?: string, 2?: string, 3?: string }, each 96 hex characters. Registers 1 and 2 exclude tdxImage; register 3 requires it and excludes other RTMR[3] pin forms. |
pinPolicies | (string | Uint8Array)[] | — | Nonempty accepted policy set: sha256:<64 lowercase hex> digests or exact canonical document bytes. Requires tdxImage and an RTMR[3] pin. Covers every replayed policy and the leaf's issuing policy. |
allowPartial | bool | false | Permit a session with a partial policy-history verdict; inspect attestation.notProven. Does not bypass rejected evidence or policy checks. |
initDataHash | string | — | SHA-256 init-data digest (64 hex characters), compared with SNP HOST_DATA, TDX MRCONFIGID, or Azure vTPM PCR[8]. |
sandboxId | string | — | Expected CRI pod sandbox ID on the mesh leaf; requires meshCaPem. |
minTcb | object | — | Minimum SEV-SNP TCB floor; below it fails with tcb_denied. SNP platforms only. |
snpCrl | Uint8Array | — | DER AMD KDS CRL for the deployment's generation, fetched by the caller. Without it, revocation is not checked and a warning says so. |
tdxCollateral | object | — | Caller-supplied Intel PCS collateral; all six fields in TDX collateral are required. platform: "tdx" only. |
tdxTcbStatuses | string[] | ["UpToDate"] | Nonempty accepted TDX TCB statuses from the list below; requires tdxCollateral. Unknown values and Revoked are rejected. |
requireCollateral | bool | false | true requires snpCrl on SNP or tdxCollateral on tdx; missing input or az-tdx yields invalid_request. Supplied collateral must be confirmed verified even when this flag is false, or the result fails with collateral_required. |
requireFreshness | bool | true | Require the platform freshness anchor 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). |
fetch | function | globalThis.fetch | Custom fetch implementation (e.g. for Node without a global fetch, or to inject headers). |
wellKnownPrefix | string | /.well-known/c8s | Base path for the protocol endpoints. |
at | Date | now | Mesh certificate validity and TDX collateral reference time. The WASM interface documents Intel certificate validity against current time. |
TDX collateral
tdxCollateral has these required properties:
| Property | Type | Contents |
|---|---|---|
tcbInfo | string | Uint8Array | Verbatim TDX TCB Info JSON body. |
tcbInfoIssuerChain | string | URL-decoded PEM issuer chain for TCB Info. |
qeIdentity | string | Uint8Array | Verbatim QE Identity JSON body. |
qeIdentityIssuerChain | string | URL-decoded PEM issuer chain for QE Identity. |
pckCrl | string | Uint8Array | PCK issuer's CRL, as PEM text or DER bytes. |
rootCaCrl | string | Uint8Array | Intel root CA CRL, as PEM text or DER bytes. |
tdxTcbStatuses accepts UpToDate, SWHardeningNeeded, ConfigurationNeeded,
ConfigurationAndSWHardeningNeeded, OutOfDate, and OutOfDateConfigurationNeeded.
Although the exported status type includes Revoked, caller policy cannot accept it.
Persisting the route floor
client.routeFloor returns the newest accepted route manifest's revision and digest. Its shape
is { revision: number, digest: string }: revision is an integer from 1 through
Number.MAX_SAFE_INTEGER, and digest is 64 lowercase hexadecimal characters. The constructor
validates this shape. JSON preserves both fields, so a browser can save and restore it:
import { C8sClient } from "c8s-verify";
const storageKey = "lb.example.com:route-floor";
const saved = localStorage.getItem(storageKey);
const client = new C8sClient({
baseUrl: "https://lb.example.com",
measurements: ["<SHA384_LAUNCH_DIGEST>"],
meshCaPem: pinnedMeshCaPem,
routeOperatorKeys: pinnedRouteOperatorKeysPem,
routeFloor: saved === null ? undefined : JSON.parse(saved),
});
const session = await client.connect();
const floor = client.routeFloor;
if (floor !== undefined) localStorage.setItem(storageKey, JSON.stringify(floor));Keep the saved floor associated with the same deployment and trust configuration. Persistence
protects against rollback across restarts only while the stored floor remains intact.
connect() retries once for a revision below the floor; a same-revision/different-digest
conflict fails without that retry.
The session object
client.connect() resolves to a Session:
session.attestation— the verification result:measurement,platform,trustClass("specific-cluster"or"deployment-class"),certinfo (incl.cert.sha256),collateralVerified,workloadwhen a workload policy was pinned,routeDigest(the route manifest the router had applied when it admitted the session),routePolicy("pinned"withrouteDigests,"operator-signed"withrouteOperatorKeys, or"unverified"), androuteRevisionwithrouteOperatorKeys. It also returnsidentityBound,reportDataMatch,verdict("verified"or"partial"),notProvenfor partial results, andwarnings. When applicable,rtmrsPinnedlists<index>:<pin>entries,measuredPolicieslists the replayed policy digests,sandboxIdrecords a matched pin, andtdxTcbcontainsstatus,fmspc, andadvisoryIds.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 ciphertext for the application contents. It can still observe the session ID, sequence numbers, sizes, timing, and fixed tunnel path. The router routes by the sealedHostheader; without one ininit.headers,fetchadds the base URL's host (with the port when non-default). PassHostyourself to reach another routed host on the same router, or when the base URL is an IP or a port-forward. Resolves to{ status, headers, headersList, bytes, text() }.session.exporter— the 32-byte channel-binding exporter; the LB forwards the same value to the backend as theX-C8s-Exporterheader.
The LB keeps a session for its --session-ttl idle window (default 5m, refreshed on use) and
retires it at --session-max-age (default 5h). Past either limit a tunnel request fails with
channel_error; call client.connect() again. A route or router-identity digest change also
refuses later requests with an outer HTTP 503 and ends the PQ session. The server codes are
route_digest_changed and router_digest_changed; this client exposes them as generic
channel_error. Checks at request admission do not cancel an already running exchange.
The client does not reconnect or replay application requests automatically.
What it verifies
connect() separates evidence validation, caller policy acceptance, and channel derivation:
- Evidence and identity. It checks the protocol, nonce and X-Wing key echo, chains the mesh leaf to the selected CA, and calls the platform's WASM hardware verifier. The TypeScript layer checks its success and platform results and enforces the requested launch measurement, register, TCB, collateral, and init-data policies.
- Transcript and proof of possession. The freshness anchor commits SHA-384 over the
length-prefixed
(c8s-verify/v1, front-door mode, mesh-CA digest, mesh-leaf digest, X-Wing key, X-Wing ciphertext, session ID, nonce, route digest). On SNP and bare-metal TDX the anchor is checked againstreport_data; on Azure it is passed to the vTPM verifier for the quote'sextraData. The mesh leaf's ECDSA-SHA384 signature authenticates that transcript. - Caller acceptance. Configured workload, allowlist, sandbox, route, and policy-history
pins constrain what the client accepts. An attested route digest alone reports routing state;
routeDigestsorrouteOperatorKeysadds caller acceptance of that state. Operator-signed acceptance also checks the published manifest and rollback floor. - Channel binding. After these checks and the partial-verdict gate, the client decapsulates the X-Wing ciphertext and uses the transcript hash as the HKDF-SHA256 salt to derive the AES-256-GCM channel and exporter.
These bindings authenticate the relationship between evidence, credentials, and channel keys. The claim that keys and application plaintext stay inside the intended TEE also depends on the accepted guest image and credential isolation. C8s admission refuses application containers that mount the platform credential volume. A transcript signature alone does not independently prove arbitrary runtime code placement.
Measured policies and partial results
With an RTMR[3] pin, the client replays the bundle's measured_policies from the operator-key
seed, extending a running measurement with each policy digest. Only the leading sequence
ending at the attested RTMR[3] value is authenticated; later reported entries are outside that
quote. For the authenticated digests, pinPolicies supplies the caller's acceptance of their
contents. Every replayed digest and the mesh leaf's issuing-policy digest must be accepted. An empty history with pinPolicies
fails with policy_history_absent; an unaccepted digest fails with policy_history_not_pinned.
A nonempty replayed history without pinPolicies is partial. Even with those pins, an unstamped
leaf, an issuing-policy stamp without a pinned mesh CA, or evidence without this check's live
nonce binding leaves a partial result. connect() rejects partial results with
policy_history_unproven unless allowPartial: true is set.
Policy measurement is optional and TDX-only. The stock image with platform services built in
leaves it disabled. Helm rejects enabling it with the node.baked deployment setting, where
the node image owns those services and their configuration. History verification therefore
requires a deployment that measures policies. If RTMR[3] equals the initial operator-key seed
with zero policies replayed, omitting pinPolicies can yield verdict: "verified" without
proving policy history.
verdict describes the policy-history judgment, not every security property. Inspect
warnings, identityBound, collateralVerified, and routePolicy too. For example,
requireFreshness: false can allow a session whose transcript is not hardware-bound, and
omitting revocation collateral leaves revocation unchecked. minTcb and tdxTcbStatuses
express caller TCB policy; hardware validity alone does not select an acceptable patch level.
See Limitations and the
verification model.
Errors
Library validation failures use C8sVerifyError with .code and .details. Native fetch,
WebCrypto, module-loading, or decoding errors can escape without that type. The client codes
mirror the C8s server error envelope where they overlap:
| Code | Meaning |
|---|---|
invalid_request | Bad client arguments, including an unknown platform, missing client anchor, or missing required collateral input. |
nonce_mismatch | The response did not echo the nonce that was sent. |
verification_failed | Hardware signature / vendor chain or platform check failed. |
report_data_mismatch | The freshness anchor did not match: report_data on SNP/TDX or the TPM quote's extraData on Azure. |
measurement_denied | The launch digest is not in the allowlist. |
measurement_incomplete | A TDX deployment-class verdict was asked for without tdxImage or both explicit image-register pins. |
rtmr_denied / rtmr3_denied | An image-register pin failed, or the RTMR[3] seed/history failed to match the quote. |
tcb_denied | The platform TCB is below minTcb, or the TDX TCB status is not accepted. |
collateral_denied / collateral_required | Collateral verification rejected the evidence, or the verifier did not confirm verification of supplied/required collateral. |
invalid_cert / cert_chain | The leaf/CA certificate is malformed or outside its validity window, or the leaf did not chain to the mesh CA. |
identity_binding | A required anchor or identity proof is missing or malformed, the bundle version is incompatible, or proof of possession failed. |
key_binding | The key exchange is not bound to the attested report. |
workload_not_attested / workload_invalid | A workload policy is pinned and the leaf carries no stamp, or a malformed one. |
workload_denied / workload_unresolved / allowlist_denied | The stamped name differs from workloadName, does not resolve in the pinned allowlist, or the stamp commits a different allowlist. |
route_denied | The route digest the evidence binds is not in routeDigests, or not the published manifest's. |
route_manifest_unavailable / route_manifest_invalid / route_manifest_denied | With routeOperatorKeys: the published manifest could not be fetched (publication off, or no CORS), is malformed, or fails its signature, schema, or rollback floor. |
route_unmatched | With routeOperatorKeys: the attested manifest routes the request to no backend. |
channel_error | The key exchange or an over-encrypted request failed, including an expired session. |
policy_history_absent | pinPolicies was set but no measured policy replayed against RTMR[3]. |
policy_history_not_pinned | A replayed policy or the leaf's issuing policy is outside pinPolicies. |
policy_history_unproven | connect() received a partial verdict without allowPartial; inspect details.notProven. |
init_data_denied | The init-data digest did not match initDataHash. |
sandbox_denied | The pinned sandbox ID differs, is absent, or is malformed. |
unsupported | globalThis.crypto.subtle is unavailable; other runtime capability failures can be native exceptions. |
The server returns binding_unavailable for missing front-door configuration or mesh identity,
temporarily unreadable credentials, or a renewing identity whose allowlist digest differs from
the router's current identity. Renewal can require a later retry. It returns
attestation_unavailable when evidence cannot be obtained, routes_unavailable before routes
are applied, and routes_stale when their freshness window expires. The client maps a non-2xx
attestation response to verification_failed; it does not preserve those server codes.
Lower-level: verifying bare evidence
If you obtain evidence through your own transport (e.g. a
discovery document) rather than the c8s/attest-pq/v1 bundle,
use verifyEvidence (platform: "snp", "az-snp", "tdx", or "az-tdx"). It calls the
platform hardware verifier and applies supplied measurement/platform policies.
expectedReportData adds a binding check. It accepts no mesh certificate or session exchange,
and does not establish a channel:
import { verifyEvidence } from "c8s-verify";
const r = await verifyEvidence(evidence /* { attestation_report, cert_chain:{ vcek } } */, {
generation: "genoa", // "milan" | "genoa" | "turin"
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,
verifyAzSnp, verifyTdx, and verifyAzTdx are also exported for full control.
VerifyEvidenceOptions also accepts operatorPublicKeyPem, rtmrs, pinPolicies,
initDataHash, and caller-supplied measuredPolicies (at most 256 sha256:<hex> digests).
The RTMR[3] replay authenticates the matching prefix. The image and register option constraints above
still apply. Unlike C8sClient, bare evidence permits omitted or empty measurements with a
warning. It returns verdict, notProven, warnings, rtmrsPinned, measuredPolicies,
collateralVerified, and tdxTcb when applicable.
For policy-history freshness, set nonceBound: true only when expectedReportData commits a
nonce generated for this check; the option requires expectedReportData. A saved quote cannot
prove policies enforced after it was captured. Bare evidence has no leaf, so this path judges
history without checking a leaf's issuing policy. Call the exported requireVerified(result)
to reject a partial result; inspect the other result fields as well.
Try it offline
The vendored demo includes a mock LB and recorded evidence fixtures. After the WASM build,
its package script order is npm run gen-fixtures, npm run build:demo, then npm run demo.
build:demo produces the browser assets in dist-demo; the ordinary build does not.
The mock flow relaxes the live transcript binding, so it exercises client behavior without proving a live attested channel. See the
verified chat tutorial for the application flow.
See also
- The verification model — the design this library implements, and why it pins a cluster anchor.
- Verified chat over confidential vLLM — the library end to end against a real cluster.
- Verify a deployment — the operator-side path, for CI.