pcrzero
v0.1.3
Published
PCRZERO offline CLI + SDK — install: npm i -g pcrzero → pcr0 verify (A-CLI: zero phone-home)
Downloads
714
Readme
pcrzero
Install: npm i -g pcrzero → run pcr0 verify
npm i -g pcrzero
pcr0 verify --receipt ./receipt.wire --keys ./keys.json
# or without global install:
npx pcrzero pcr0 verify --receipt ./receipt.wire --keys ./keys.jsonPackage name pcrzero · command pcr0 (LEAD #162 G1b). Unscoped brand pcr0 as package name is blocked by npm (similarity to crc).
Inference delivery receipt specification
Public SoT (minimum surface): Scytalex-LLC/pcrzero-receipt-spec
SPEC.md· schema 1- Golden vectors (R4):
inference-receipt-vectors-v1.json
Working copies in this (private) SDK: SPEC.md, tests/fixtures/inference-receipt-vectors-v1.json.
This SPEC defines inference delivery receipts only. Attestation / policy-verify receipts from /v1/verify are a sibling product (shared dual-sig envelope; different body — see SPEC §1.1).
What this is
Two entry points, deliberately separated:
| Import | Contains | For |
|---|---|---|
| pcrzero | HTTP client, resources | servers calling the API |
| pcrzero/receipt | offline verifyReceipt only — no HTTP, no Node built-ins | edge runtimes, browsers, auditors |
| pcrzero/lock | fail-closed assertPayoutLock on verifyReceipt | payout APIs — sit on the wire that moves money |
| pcrzero/inference | dual-sign inference receipt kit | PoM / auditor offline |
| pcrzero/inclusion | verifyInclusion | day-tree proofs |
The split is the point. A receipt is meant to be verifiable by someone who has no account, no API key, and no network — so the code that does that must not drag an HTTP client along with it.
Quickstart
import { PCRZero } from 'pcrzero';
const pcrzero = new PCRZero({ apiKey: process.env.PCRZERO_API_KEY! });
const { verdict, reasons, receipt } = await pcrzero.verify({
document: base64Doc,
policy: { docType: 'nitro', measurements: { pcr0: [EXPECTED_PCR0] } },
});
if (verdict !== 'pass') throw new Error(`attestation rejected: ${reasons.join(', ')}`);
await db.receipts.put(receipt); // opaque string; verifiable offline, forevertests/quickstart.test.ts is this exact snippet compiled against the real exported types,
so the documented ergonomics cannot drift from the shipped API.
Verifying a receipt offline
The key set is public and unauthenticated, so a verifier needs no account and no client —
a plain fetch is the expected path. toReceiptKeys decodes it into what the verifier
takes; it accepts the raw wire shape (ml_dsa_public_key) and the typed client's
camelCase (mlDsaPublicKey) interchangeably, and throws naming the field if key material
is the wrong length or not hex.
import { verifyReceipt, toReceiptKeys } from 'pcrzero/receipt';
// Fetch once and cache — the endpoint is Cache-Control: public, max-age=3600.
const keys = toReceiptKeys(await (await fetch('https://api.pcrzero.com/v1/keys')).json());
const result = verifyReceipt(receipt, keys); // pure: no network, no clock, no API key
if (!result.valid) throw new Error(result.reasons[0]);
console.log(result.body.verdict, result.body.measurements.pcr0, result.body.iat);Key lifecycle — presence in the set is not validity forever
Each served key may carry a status: active (absent means active), retired
(honestly rotated out — its old receipts keep verifying), or revoked (compromised —
toReceiptKeys excludes it, so its receipts fail unknown_signing_key). The enum is
closed; an unknown status throws rather than being guessed at. notAfter is advisory
metadata and never enforced by the SDK — a receipt signed before a key's end-of-life
must keep verifying after it.
Because the verifier is offline, revocation reaches you only when you refresh:
re-fetch GET /v1/keys at least every 24 hours. An auditor examining a
revoked-key receipt deliberately can still construct a ReceiptKey by hand — the
exclusion is a default, not a lock.
Fetching the key set is the only networked step, and it is deliberately separable: pin the keys once and every later verification is fully offline, forever.
Payout lock (pcrzero/lock, 0.1.2)
Verify-only, no account, fail closed. Missing / invalid / fail receipts do not clear. Never charges. Never calls Scytalex on the hot path. Pin the keyset once; then this is pure.
import { toReceiptKeys } from 'pcrzero/receipt';
import { assertPayoutLock } from 'pcrzero/lock';
const keys = toReceiptKeys(pinnedKeysetJson); // from GET /v1/keys, cached
const seen = new Set<string>(); // persist across requests
function hexNonce(n: Uint8Array): string {
let h = '';
for (const b of n) h += b.toString(16).padStart(2, '0');
return h;
}
export function clearPayout(receipt: string | undefined): void {
const d = assertPayoutLock({ receipt, keys, seenNonces: seen });
if (!d.allowed) throw new Error(d.reason); // missing_receipt | invalid_receipt | verdict_not_pass | …
seen.add(hexNonce(d.body.nonce));
// only now move money
}verdict: "fail" is a bounce (verdict_not_pass). Optional nowMs + maxAgeSec, policyIds, and envs are extra allowlists — omit them and they do not run.
Every receipt carries both an Ed25519 and an ML-DSA-44 signature, always. verifyReceipt
defaults to full conformance and checks both; either failing is a rejection. Downgrading
is explicit and visible at the call site:
verifyReceipt(receipt, LIVE_KEYS, { conformance: 'classical-only' }); // NO PQ guaranteeThere is no silent fallback. The ML-DSA import is unconditional and at module scope, so a runtime missing the post-quantum dependency fails to load the module rather than quietly degrading to a classical-only check — a verifier that believes it is checking a PQ signature but is not would be worse than one that never had it.
The envelope is a fixed five elements, and that is a security property rather than a style choice. A four-element envelope is rejected in every conformance class, including classical-only, so an attacker cannot strip the post-quantum signature to force a weaker check. Two golden vectors exist solely to keep that true.
result.body uses the wire-literal field names from the signed bytes (doc_type,
doc_sha256) rather than camelCase. It is the signed artifact: what you inspect should be
exactly what was signed, with no translation layer in between.
Two things that surprise people
A failing verdict is not an error. verdict: "fail" resolves normally with HTTP 200 and
carries the measurements that explain the failure. A thrown PCRZeroApiError means we
could not evaluate — quota, auth, a malformed document — which is a categorically
different thing. Branch on error.code, never on error.message: the code is the
contract, the prose is not.
Measurement names are data, not field names. The SDK converts camelCase↔snake_case at
the wire boundary, but never touches the keys inside measurements. Those are wire-literal
register names chosen by the docType (pcr0, and for sev-snp host_data,
id_key_digest), and the offline verifier reads them straight out of CBOR without passing
through the case codec. Converting them here would give one logical field two different
names depending on which API you reached for.
Dependencies
Runtime dependencies are minimal and exist only for the receipt verifier:
| Package | Why |
|---|---|
| @noble/curves | Ed25519 verification (receipt classical signature) |
| @noble/post-quantum | ML-DSA-44 verification (receipt PQ signature) |
That is the whole list, and a test enforces it: tests/receipt/bundle.test.ts walks the
receipt subpath's transitive import graph and asserts it uses exactly the declared
runtime dependencies, imports no Node built-in, and reaches no module outside
src/receipt/. It caught a declared-but-unused @noble/hashes on its first run.
There is deliberately no CBOR library. The canonical codec is hand-rolled and narrow, because a third-party library's "deterministic mode" staying byte-stable across minor versions is not a guarantee a signature contract can rest on (spec §6).
Bundling for a browser: do NOT flatten @noble/hashes
A correct install has two copies, and that is not a mistake to clean up:
| Copy | Version | Needed by |
|---|---|---|
| top level | 1.8.x | @noble/curves (Ed25519) |
| nested under @noble/post-quantum | 2.2.x | @noble/post-quantum (ML-DSA-44) |
Flatten them — npm dedupe, an overrides entry, a bundler dedupe/vendor copy, or an
import map without scopes — and the two abytes signatures mix. ML-DSA verification then
throws from deep inside noble, where nothing names the real cause: the failure looks like
a broken receipt or a corrupt key, and the post-quantum half is exactly the half you cannot
afford to debug by guesswork.
Use an import-map scope (or the bundler's equivalent dedupe-off) to keep them separate.
tests/receipt/resolution.test.ts pins the split on our side and runs a real ML-DSA
sign/verify under it, but the flattening happens in your build, where our tests cannot
reach — so this warning is the guard. (Found by s1 bundling the demo console, 2026-08-07.)
The HTTP client has no runtime dependencies at all — it uses global fetch.
Offline CLI: pcr0 (#158 · PoM B3)
Product CLI for inference receipt dual-sign verify (schema v1 / #117). Trust base is
caller-supplied files only — offline by default, zero phone-home (A-CLI). Does not
claim training-data provenance (A-CLI-5). The live one-screen contract is pcr0 help
(what it proves, what it does not, exits 0/1/2). Usage errors name the gap and a next
flag; they do not reprint the full help.
# published (#162 G1b — package pcrzero, command pcr0):
npm i -g pcrzero
pcr0 help
pcr0 verify --receipt ./receipt.wire --keys ./keys.json
# or:
npx pcrzero pcr0 verify --receipt ./receipt.wire --keys ./keys.json
# optional local inclusion (all three required together; still offline):
pcr0 verify \
--receipt ./receipt.wire \
--keys ./keys.json \
--proof ./inclusion-proof.json \
--checkpoint ./checkpoint.json \
--anchors ./anchors.json| Exit | Meaning | |---|---| | 0 | valid receipt (and inclusion if requested) | | 1 | usage / IO / incomplete flags / network mode refused | | 2 | invalid receipt or inclusion |
--allow-network is rejected in v1 (fail closed). Keys file shape:
{
"keys": [
{
"kid": "<hex>",
"ml_dsa_public_key": "<hex>",
"ed25519_public_key": "<hex>"
}
]
}Stdout on success is JSON with valid, receipt_id, trust_base (caller-supplied /
not-checked-this-command), and a product disclaimer.
Format spec (public): https://github.com/Scytalex-LLC/pcrzero-receipt-spec/blob/main/SPEC.md · R4 vectors: same repo inference-receipt-vectors-v1.json. CLI tests: tests/cli-verify-inference.test.ts.
Development
npm test # vitest
npm run typecheck # tsc --noEmit
npm run build # tsc -> dist/fetch is injectable via the client config, so tests exercise the real request pipeline
against real Response objects rather than a mocking library.
