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 complete tdxImage tuple 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/v1 endpoints 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 build

Consume 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):

OptionTypeDefaultDescription
baseUrlstring— (required)LB origin, e.g. https://lb.example.com.
measurementsstring[]—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.
meshCaPemstring—Pinned mesh CA (PEM) the LB's leaf must chain to; trustClass: "specific-cluster". Each PEM block is trusted independently, so prefer one.
allowlistUint8Array | 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.
workloadNamestring—Expected matched-workload name on the mesh leaf.
routeDigestsstring[]—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.
routeOperatorKeysstring—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.
routeFloorRouteFloor—With routeOperatorKeys: previously accepted { revision, digest }, rejecting an older revision or the same revision with another digest. See Persisting the route floor.
platformstring"snp"Expected TEE platform: "snp", "az-snp", "tdx", or "az-tdx".
generationstringfrom the responseSEV-SNP processor generation ("milan", "genoa", "turin"); platform: "snp" only.
tdxImageobject—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.
expectedRtmr3string—TDX RTMR[3] seed (96 hex characters), before measured policies extend it. Excludes operatorPublicKeyPem and rtmrs[3].
operatorPublicKeyPemstring—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.
rtmrsobject—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.
allowPartialboolfalsePermit a session with a partial policy-history verdict; inspect attestation.notProven. Does not bypass rejected evidence or policy checks.
initDataHashstring—SHA-256 init-data digest (64 hex characters), compared with SNP HOST_DATA, TDX MRCONFIGID, or Azure vTPM PCR[8].
sandboxIdstring—Expected CRI pod sandbox ID on the mesh leaf; requires meshCaPem.
minTcbobject—Minimum SEV-SNP TCB floor; below it fails with tcb_denied. SNP platforms only.
snpCrlUint8Array—DER AMD KDS CRL for the deployment's generation, fetched by the caller. Without it, revocation is not checked and a warning says so.
tdxCollateralobject—Caller-supplied Intel PCS collateral; all six fields in TDX collateral are required. platform: "tdx" only.
tdxTcbStatusesstring[]["UpToDate"]Nonempty accepted TDX TCB statuses from the list below; requires tdxCollateral. Unknown values and Revoked are rejected.
requireCollateralboolfalsetrue 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.
requireFreshnessbooltrueRequire 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).
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.
atDatenowMesh certificate validity and TDX collateral reference time. The WASM interface documents Intel certificate validity against current time.

TDX collateral

tdxCollateral has these required properties:

PropertyTypeContents
tcbInfostring | Uint8ArrayVerbatim TDX TCB Info JSON body.
tcbInfoIssuerChainstringURL-decoded PEM issuer chain for TCB Info.
qeIdentitystring | Uint8ArrayVerbatim QE Identity JSON body.
qeIdentityIssuerChainstringURL-decoded PEM issuer chain for QE Identity.
pckCrlstring | Uint8ArrayPCK issuer's CRL, as PEM text or DER bytes.
rootCaCrlstring | Uint8ArrayIntel 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"), cert info (incl. cert.sha256), collateralVerified, workload when a workload policy was pinned, routeDigest (the route manifest the router had applied when it admitted the session), routePolicy ("pinned" with routeDigests, "operator-signed" with routeOperatorKeys, or "unverified"), and routeRevision with routeOperatorKeys. It also returns identityBound, reportDataMatch, verdict ("verified" or "partial"), notProven for partial results, and warnings. When applicable, rtmrsPinned lists <index>:<pin> entries, measuredPolicies lists the replayed policy digests, sandboxId records a matched pin, and tdxTcb contains status, fmspc, and advisoryIds.
  • 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 sealed Host header; without one in init.headers, fetch adds the base URL's host (with the port when non-default). Pass Host yourself 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 the X-C8s-Exporter header.

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:

  1. 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.
  2. 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 against report_data; on Azure it is passed to the vTPM verifier for the quote's extraData. The mesh leaf's ECDSA-SHA384 signature authenticates that transcript.
  3. Caller acceptance. Configured workload, allowlist, sandbox, route, and policy-history pins constrain what the client accepts. An attested route digest alone reports routing state; routeDigests or routeOperatorKeys adds caller acceptance of that state. Operator-signed acceptance also checks the published manifest and rollback floor.
  4. 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:

CodeMeaning
invalid_requestBad client arguments, including an unknown platform, missing client anchor, or missing required collateral input.
nonce_mismatchThe response did not echo the nonce that was sent.
verification_failedHardware signature / vendor chain or platform check failed.
report_data_mismatchThe freshness anchor did not match: report_data on SNP/TDX or the TPM quote's extraData on Azure.
measurement_deniedThe launch digest is not in the allowlist.
measurement_incompleteA TDX deployment-class verdict was asked for without tdxImage or both explicit image-register pins.
rtmr_denied / rtmr3_deniedAn image-register pin failed, or the RTMR[3] seed/history failed to match the quote.
tcb_deniedThe platform TCB is below minTcb, or the TDX TCB status is not accepted.
collateral_denied / collateral_requiredCollateral verification rejected the evidence, or the verifier did not confirm verification of supplied/required collateral.
invalid_cert / cert_chainThe leaf/CA certificate is malformed or outside its validity window, or the leaf did not chain to the mesh CA.
identity_bindingA required anchor or identity proof is missing or malformed, the bundle version is incompatible, or proof of possession failed.
key_bindingThe key exchange is not bound to the attested report.
workload_not_attested / workload_invalidA workload policy is pinned and the leaf carries no stamp, or a malformed one.
workload_denied / workload_unresolved / allowlist_deniedThe stamped name differs from workloadName, does not resolve in the pinned allowlist, or the stamp commits a different allowlist.
route_deniedThe route digest the evidence binds is not in routeDigests, or not the published manifest's.
route_manifest_unavailable / route_manifest_invalid / route_manifest_deniedWith routeOperatorKeys: the published manifest could not be fetched (publication off, or no CORS), is malformed, or fails its signature, schema, or rollback floor.
route_unmatchedWith routeOperatorKeys: the attested manifest routes the request to no backend.
channel_errorThe key exchange or an over-encrypted request failed, including an expired session.
policy_history_absentpinPolicies was set but no measured policy replayed against RTMR[3].
policy_history_not_pinnedA replayed policy or the leaf's issuing policy is outside pinPolicies.
policy_history_unprovenconnect() received a partial verdict without allowPartial; inspect details.notProven.
init_data_deniedThe init-data digest did not match initDataHash.
sandbox_deniedThe pinned sandbox ID differs, is absent, or is malformed.
unsupportedglobalThis.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