@phala/aci-verifier
v0.8.0
Published
TypeScript ACI verifier and verified fetch client for browsers, Node.js, and Bun.
Readme
@phala/aci-verifier
A TypeScript verifier for Attested Confidential Inference
(ACI), for the browser, Node 20.18+, and Bun 1.4+.
verifyService(url)
fetches a service's report with a fresh nonce and returns a full §9.1
transcript, including the hardware quote, verified with
@phala/dcap-qvl against the
Phala PCCS. Every other check uses Web Crypto: Ed25519, X25519, HKDF, AES-GCM,
SHA-256. A prebuilt ESM bundle (npm run build:bundle) drops into a
<script type="module">.
The npm package is ESM-only. Browser-aware bundlers select the browser
condition automatically; native browser imports can use
@phala/aci-verifier/browser. Runtime-aware applications import
connectAci() from @phala/aci-verifier/runtime; package conditions select
the tested Node or Bun transport. Explicit /node and /bun entries are also
available. Use the browser entry for document verification and the Node or Bun
entries for a pinned transport.
ACI documents verify over their JCS form (spec Appendix A), so this library canonicalizes whatever it parsed and hashes foreign bytes (HTTP bodies, evidence) exactly as observed.
What it verifies
- A whole service (§9.1):
verifyService(url)fetches the report with a fresh nonce and runs the transcript: the quote to the Intel vendor root (check 1, via@phala/dcap-qvl), the binding chain (checks 2–3), and the compose measurement (check 4) when the service publishesapp_compose. Returns{ verdict, lines, verification, composeHash }.verifyQuoteandverifyComposeMeasurementare the individual checks. - Reviewed release allowlist (§1.3): pass
acceptedComposeHashesto accept only reviewedsha256(app_compose)values measured into RTMR3. Without an allowlist the measurement is verified and reported, but the verifier does not claim that the release was reviewed. See Release acceptance. - Report binding (§9.1 checks 2–3):
verifyReportBinding(report, nonce)recomputes the keyset digest over the servedworkload_keysetobject's JCS form, rebuilds the attestation statement for the nonce you supplied, checks it hashes toreport_data, and checks the keyset is not expired. The result carries the established keyset (digest, parsed form) for every later check. - Receipts (§9.3):
verifyReceipt(document, keyset, establishedDigest)verifies the Ed25519 signature over the JCS form of the document minus itssignaturemember, under the keyset entrykey_idnames, and that the document binds to the established keyset digest.checkRequestBodyHash/checkResponseBodyHashcover checks 3–4. - Sessions (§8, §9.2):
computeSessionIdhashes the JCS form of the parsed session document for comparison against the id a signed receipt committed to;checkSessionEvidencechecksevidence.datahashes toevidence.digest. - Aggregator checks (§9.3(5)-(6)):
receiptTranscript(..., upstream)adds upstream-1 (the serving upstream was verified and cites a session) and upstream-2 (the cited session hashes to its id, coversserved_at, carries matching evidence, and is one you pinned withprovider.aci_session_ids). Pass{ session, pinnedSessions, requiresVerified }; anything absent is a skip with its reason, never a pass. - E2EE key verification, not an E2EE request builder. Report verification
establishes the quote-bound
e2ee_public_keys, including the suites in the E2EE v2 compatibility protocol. This package does not encrypt or decrypt content fields. Callers can implement that field-level wire contract or use a v2-capable client.
Current limits
- No dstack boot-measurement reconstruction. Quote verification authenticates the quote's RTMR fields, and this package replays RTMR3 only.
- No custody check. §9.1 check 5 (the dstack KMS chain) is not implemented in this package; it reports a skip.
- No TLS observation in a browser. A browser cannot see the server
certificate, so id-6 needs the SPKI your own TLS stack observed (the
channeloption), or thepapCLI /pap serveproxy, which can; with neither, a live run fails id-6 (§1.1). - No deep audit of upstream evidence (§9.2(4)).
checkSessionEvidenceproves the cited session'sevidence.datahashes toevidence.digestand stops there; neither in-tree verifier appraises the evidence itself (conformance gaps item 17). - Ed25519 receipts only. A receipt keyed to any other algorithm is reported as a failed signature check, not verified.
Verification failures return { ok: false, checks }; malformed input throws.
Release status: Public releases are available from npm. The repository builds an ESM package with declarations, validates it with publint and Are The Types Wrong, and publishes it with npm provenance from a
clients-v<version>GitHub Release.
Clients do not check whether the gateway runs a production or development dstack
OS image. A development image permits operator access to the gateway. Users who
need assurance about the OS image should verify the quote, event log, and VM
configuration with an independent dstack verifier. The deprecated
requireProductionOs option remains accepted for compatibility and is ignored.
Usage
One call runs the ACI checks:
import { verifyService } from '@phala/aci-verifier';
const { verdict, lines } = await verifyService('https://tee.redpill.ai');
console.log(verdict.line); // VERIFIED / PARTIAL / NOT VERIFIED
for (const l of lines) console.log(l.status, l.id, l.title);Runtime SDK and agent frameworks
Node and Bun applications establish the same instance-scoped, SPKI-pinned
connection and inject its ordinary fetch into any HTTP-based
OpenAI-compatible SDK. The connection rejects HTTP, cross-origin requests,
expired identities, and TLS peers whose SPKI is not in the verified workload
keyset.
import OpenAI from 'openai';
import { connectAci } from '@phala/aci-verifier/runtime';
const apiKey = process.env.ACI_API_KEY;
if (!apiKey) throw new Error('ACI_API_KEY is required');
const aci = await connectAci({
baseURL: 'https://tee.redpill.ai/v1',
serving: {
// Every successful JSON POST must carry a receipt and cite verified serving.
requireVerified: true,
requireReceipt: true,
},
});
if (!aci.identity.transcript.verdict.verified) {
throw new Error(aci.identity.transcript.verdict.line);
}
const openai = new OpenAI({
baseURL: aci.baseURL,
apiKey,
fetch: aci.fetch,
});
const response = await openai.chat.completions.create({
model: 'z-ai/glm-5.2',
messages: [{ role: 'user', content: 'Hello' }],
});
// The transport hashes the exact request/response wire bodies while streaming
// and reuses this request's authorization only to fetch its private receipt.
// Verification is on demand, so normal inference latency does not include a
// receipt/session fetch.
const audit = await aci.verifyReceipt();
if (!audit.transcript.verdict.verified) {
throw new Error(audit.transcript.verdict.line);
}
await aci.refresh(); // Verify a fresh report and rotate the scoped dispatcher.
await aci.close();Install the two ESM packages with npm install openai @phala/aci-verifier.
Run the same source under Node 20.18.1+ or Bun 1.4.0+; the /runtime export
selects the matching pinned transport. Set ACI_API_KEY to the RedPill key.
The SDK adds it to the inference request; aci.fetch retains that request's
authorization in its bounded exchange history and reuses it only for the
matching private receipt lookup.
For release-level pinning, add the reviewed deployment's compose hash under
policy.acceptedComposeHashes. Take the hash from authenticated release
metadata, never from the endpoint you are verifying: a hash copied from the
service only trusts it on first use.
The public API is identical in Node and Bun. Internally, Node passes a scoped
undici dispatcher to fetch, while Bun passes its documented
tls callback and proxy options.
Both adapters use the same hostname/SPKI check and the same quote, policy,
receipt, session, rotation, and lifecycle implementation. Use /node or
/bun only when a bundler cannot select runtime export conditions correctly.
aci.receipts() lists the bounded in-memory exchange history. A receipt can
only receive a complete transport audit while its exchange is retained; an
unknown id fails instead of returning a misleading verdict with skipped body
hashes.
OpenAI Agents SDK
Use a runner-scoped OpenAIProvider so one agent stack owns one verified
connection. setDefaultOpenAIClient() also accepts this client, but changes a
process-wide default and is a worse fit when several gateways coexist.
import { Agent, OpenAIProvider, Runner } from '@openai/agents';
const modelProvider = new OpenAIProvider({
openAIClient: openai,
useResponses: false,
});
const runner = new Runner({ modelProvider });
const agent = new Agent({
name: 'Private agent',
instructions: 'Be concise.',
model: 'your-model',
});
const result = await runner.run(agent, 'Summarize this document.');
console.log(result.finalOutput);
await modelProvider.close();
await aci.close();aci.fetch protects the HTTP model calls only. Agent tools, MCP calls and
tracing use their own transports and remain separate trust boundaries.
Vercel AI SDK
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { generateText } from 'ai';
const gateway = createOpenAICompatible({
name: 'aci',
baseURL: aci.baseURL,
apiKey,
fetch: aci.fetch,
});
const result = await generateText({
model: gateway('your-model'),
prompt: 'Summarize this document.',
});LangChain JS
import { ChatOpenAI } from '@langchain/openai';
const model = new ChatOpenAI({
model: 'your-model',
apiKey,
configuration: {
baseURL: aci.baseURL,
fetch: aci.fetch,
},
});
const result = await model.invoke('Summarize this document.');These integrations use documented transport hooks in OpenAI Node, OpenAI Agents JS, Vercel AI SDK, and LangChain JS.
This transport does not cover WebSocket model calls. For browser clients,
WebSocket-only frameworks, or software that cannot inject a custom fetch, run
pap serve and point the framework at its local OpenAI-compatible endpoint
instead.
Or drive the individual checks:
import {
verifyReportBinding,
verifyReceipt,
checkResponseBodyHash,
} from '@phala/aci-verifier';
// Establish the workload identity for a fresh nonce (§9.1 checks 2–3).
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)), (b) => b.toString(16).padStart(2, '0')).join('');
const report = await (await fetch(`${base}/v1/aci/attestation?nonce=${nonce}`)).json();
const v = await verifyReportBinding(report, nonce);
if (!v.ok) throw new Error('report failed: ' + JSON.stringify(v.checks));
// Check the response against its receipt (§9.3). The receipt document
// comes from GET /v1/aci/receipts/{id}, {id} from the X-Receipt-Id header.
const result = await verifyReceipt(receipt, v.keyset!, v.workloadKeysetDigest!);
if (!result.ok) throw new Error('receipt failed: ' + JSON.stringify(result.checks));
// Checks 3–4: the bytes you sent and received match what the receipt commits to.
if (!(await checkResponseBodyHash(result.payload!, responseBytes))) {
throw new Error('response bytes do not match the receipt');
}Development
npm install
npm test # tsc + node:test; test/vectors.test.ts pins every
# construction against the ACI and E2EE v2 vector documents
npm run test:bun # the same pinned-transport contract on Bun 1.4+
npm run build # emit dist/ (ESM + .d.ts)