BuildAudit
Verify Receipts Offline
Verify a Chio-mediated tool call offline with a signature, Merkle inclusion, and RFC 8785 canonical JSON.
Verify an evidence package
chio evidence verify --input ./package runs canonical hashing, receipt signature verification, checkpoint transparency validation, and inclusion-proof checks against a captured evidence package. The sections below describe those checks so you can implement or review a downstream verifier.Why Offline Verification
Chio is a trust-control plane, not a trust anchor. Operators, auditors, compliance teams, and downstream agents all need to confirm that a recorded tool call really happened, and that the kernel's decision was not rewritten after the fact. Querying the kernel cannot establish that independently because the kernel is under review.
Offline verification uses three static inputs: a signed receipt, a signed checkpoint, and independently pinned signer public keys. It returns a verification result without a live service or auth token.
- Auditors can replay evidence months later without the kernel still running.
- Compliance teams archive receipts as signed records for regulatory review.
- Downstream verifiers confirm upstream behavior without needing access to the upstream kernel.
- Forensics survive the kernel going offline or a private key being rotated.
What You Need
A receipt evidence package contains three records and a public key that your trust store supplies out of band.
| Record | Where It Comes From | What It Establishes |
|---|---|---|
| Signed receipt | receipts.ndjson in the evidence package | A kernel decision signed by a specified kernel key |
| Kernel checkpoint | checkpoints.ndjson in the evidence package | A Merkle root that commits a batch of receipts at a point in time |
| Inclusion proof | inclusion-proofs.ndjson in the evidence package | The receipt's canonical bytes are a leaf under the checkpoint root |
| Pinned kernel public key | Your trust store, out of band | The checkpoint signer is the kernel you expect |
The pinned key establishes who signed the checkpoint. If you accept whatever kernel_key the receipt tells you to accept, you are verifying against an attacker's key. Pin kernel signing keys the same way you pin TLS roots.
chio evidence export writes thirteen files unconditionally: twelve artifacts plus the manifest that hashes them.
$ chio --receipt-db ./receipts.db evidence export --admin-all --output ./package
$ ls -1 packageREADME.txt capability-lineage.ndjson checkpoint-consistency-proofs.ndjson checkpoint-equivocations.ndjson checkpoint-publications.ndjson checkpoint-witnesses.ndjson checkpoints.ndjson child-receipts.ndjson inclusion-proofs.ndjson manifest.json query.json receipts.ndjson retention.json
receipts.ndjson holds one ChioReceipt per line, wrapped as {seq, receipt}; checkpoints.ndjson one KernelCheckpoint per line; inclusion-proofs.ndjson one ReceiptInclusionProof. The four checkpoint-*.ndjson files carry the transparency material: publications, witnesses, consistency proofs, and any equivocations found. retention.json records the live database size and the oldest receipt timestamp, query.json the export query, and manifest.json declares schema chio.evidence_export_manifest.v1. Two more appear when you pass --policy-file: policy/source.yaml and policy/metadata.json.
Anatomy of a Receipt
An ChioReceipt is the signed record that a kernel evaluated one tool call. It has 24 fields. 21 of them are the signed body; the 3 that are not are bbs_signature, algorithm and signature. Of the signed set, 20 also feed the content-addressed id: every body field except the id itself. The omitted column is what a verifier has to be ready for: a field with skip_serializing_if is absent from the JSON rather than null, and a field without it is always there.
| Field | Type | Signed | In id | Omitted when |
|---|---|---|---|---|
id | String | yes | no | always present |
timestamp | u64 | yes | yes | always present |
capability_id | String | yes | yes | always present |
tool_server | String | yes | yes | always present |
tool_name | String | yes | yes | always present |
action | ToolCallAction | yes | yes | always present |
decision | Option<Decision> | yes | yes | none |
receipt_kind | ReceiptKind | yes | yes | always present |
boundary_class | BoundaryClass | yes | yes | always present |
observation_outcome | Option<ObservationOutcome> | yes | yes | none |
tool_origin | ToolOrigin | yes | yes | always present |
redaction_mode | RedactionMode | yes | yes | always present |
actor_chain | Vec<ActorRef> | yes | yes | empty |
content_hash | String | yes | yes | always present |
policy_hash | String | yes | yes | always present |
evidence | Vec<GuardEvidence> | yes | yes | empty |
metadata | Option<serde_json::Value> | yes | yes | none |
trust_level | TrustLevel | yes | yes | always present |
tenant_id | Option<String> | yes | yes | none |
bbs_projection_version | Option<String> | yes | yes | none |
kernel_key | PublicKey | yes | yes | always present |
bbs_signature | Option<BbsReceiptSignature> | no | no | none |
algorithm | Option<SigningAlgorithm> | no | no | is_default_optional_algorithm |
signature | Signature | no | no | always present |
Five of those always-present fields are the ones a hand-written example tends to drop: receipt_kind, boundary_class, tool_origin, redaction_mode and trust_level. None carries a serde default, so a receipt line without them is not a receipt this kernel wrote, and the reference verifiers raise on the missing key rather than filling one in. trust_level in particular is not defaulted: signing validation requires it to agree with receipt_kind, mediated with mediated, trace with verified, advisory with advisory.
The ChioReceiptBody type mirrors ChioReceipt minus signature, algorithm, and bbs_signature. It is the input the kernel recomputes the content-addressed id from and then wraps into the signing envelope described in Step 1. The signature is over that envelope, not the flat body, and the envelope carries bbs_signature back in, so the BBS material is signed even though it is not part of the body. A verifier that omits any signed field fails signature verification against current receipts.
Canonical JSON here means RFC 8785 (JSON Canonicalization Scheme). Two RFC 8785 serializers given the same value produce identical bytes, so a verifier can re-create the signed bytes without the kernel's serializer.
A complete receipt, from the cross-language conformance vector allow_receipt in tests/bindings/vectors/receipt/v1.json. Every SDK verifier on this page is tested against it, so it is the shape to hold a hand-written parser to:
{
"action": {
"parameter_hash": "035350b0e6a021a4c924a149111944945e94d12f6d8a5e24e4461e7009ebf929",
"parameters": {
"mode": "read",
"path": "/workspace/docs/roadmap.md"
}
},
"boundary_class": "prevent",
"capability_id": "cap-bindings-001",
"content_hash": "4062edaf750fb8074e7e83e0c9028c94e32468a8b6f1614774328ef045150f93",
"decision": {
"verdict": "allow"
},
"evidence": [
{
"details": "path allowed",
"guard_name": "ForbiddenPathGuard",
"verdict": true
},
{
"details": "no secrets detected",
"guard_name": "SecretLeakGuard",
"verdict": true
}
],
"id": "c9909f733d5fb367922293f0167d06db715393be114f212096cf2866f29433ea",
"kernel_key": "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c",
"metadata": {
"surface": "bindings-vectors",
"version": 1
},
"policy_hash": "policy-bindings-v1",
"receipt_kind": "mediated_decision",
"redaction_mode": "none",
"signature": "276a4c59dbc5e0c4fbd47beefa33515d207e8f516aca296afee2cb95a443bb18d31031cae0b69943e7a930be03b2e0a60156e5a26e732e888e32d4653d059f03",
"timestamp": 1710000200,
"tool_name": "file_read",
"tool_origin": "caller_executed",
"tool_server": "srv-files",
"trust_level": "mediated"
}Step 1: Verify the Signature
The receipt signature covers the canonical JSON of a signing envelope that wraps the body, not the bare body. Verify it as follows:
- Take the body: every field except
signature,algorithm, andbbs_signature. - Validate signable semantics and the BBS binding; reject if either fails.
- Recompute the content-addressed id over the body and confirm it equals
receipt.id. - Wrap into
ChioReceiptSigningBody, shaped{ id, body, bbs_signature }, wherebodyisChioReceiptIdInput(the body minus itsid). - Serialize that wrapper to canonical JSON per RFC 8785 (sorted keys, minimal escaping, JCS number form) and verify the signature with the embedded
kernel_key, but only after you have confirmed that key is one you trust.
The Rust implementation in chio-core-types re-derives the signed bytes. It recomputes the wire id, wraps the body into the signing envelope, re-canonicalizes that, and hands it to the signature verifier.
pub fn verify_signature(&self) -> Result<bool> {
let body = self.body();
if body.validate_signable_semantics().is_err() {
return Ok(false);
}
if validate_bbs_receipt_binding(&body, self.bbs_signature.as_ref()).is_err() {
return Ok(false);
}
if chio_receipt_id(&body)? != self.id {
return Ok(false);
}
let signing_body =
ChioReceiptSigningBody::from_body_and_bbs(&body, self.bbs_signature.as_ref());
self.kernel_key
.verify_canonical(&signing_body, &self.signature)
}The envelope it wraps the body into carries the recomputed id, the body minus that id, and the BBS material:
pub struct ChioReceiptSigningBody {
pub id: String,
pub body: ChioReceiptIdInput,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bbs_signature: Option<BbsReceiptSignature>,
}And the canonicalize-then-verify step at the end:
pub fn verify_canonical<T: Serialize>(&self, value: &T, signature: &Signature) -> Result<bool> {
let canonical = canonical_json_shared_bytes(value)?;
Ok(self.verify_shared_canonical(&canonical, signature))
}verify dispatches on the signature's own self-describing encoding, so the same call site handles Ed25519, P-256, P-384 and the hybrid post-quantum algorithm. A downstream verifier hard-coded to Ed25519 will reject valid receipts from a kernel running any other floor.
The high-level helper verify_receipt in chio-binding-helpers checks both the signature and the parameter hash in one call, and reports the decision kind so you do not re-parse the enum yourself.
use chio_binding_helpers::receipt::{
verify_receipt, verify_receipt_json, ReceiptDecisionKind,
};
let verification = verify_receipt(&receipt)?;
assert!(verification.signature_valid);
assert!(verification.parameter_hash_valid);
match verification.decision {
ReceiptDecisionKind::Allow => { /* happy path */ }
ReceiptDecisionKind::Deny => { /* denied, still signed */ }
ReceiptDecisionKind::Cancelled | ReceiptDecisionKind::Incomplete => { /* terminal */ }
ReceiptDecisionKind::None => { /* no decision: trace or advisory receipt */ }
}
// Or operate directly on the JSON bytes:
let verification = verify_receipt_json(json_line)?;Pin the kernel key
verify_signature checks the signature against receipt.kernel_key, which is part of the receipt body. An attacker who fabricates a receipt can sign it with their own key and put the matching public key in the body. Your verifier must separately confirm that receipt.kernel_key matches the kernel you expect, either from a trust store, a federation policy, or the checkpoint signer chain.Step 2: Verify Merkle Inclusion
A valid signature proves the kernel signed the bytes. It does not prove the kernel committed those bytes to its log. For that, you need the Merkle inclusion proof: a short audit path that reconstructs the checkpoint root from the receipt's canonical bytes.
Chio uses RFC 6962-style hashing (the Certificate Transparency variant) for the receipt log:
LeafHash(leaf_bytes) = SHA256(0x00 || leaf_bytes)NodeHash(left, right) = SHA256(0x01 || left || right)- Odd-last-node semantics are append-only: the last node carries upward unchanged, not duplicated.
A ReceiptInclusionProof contains the leaf index, the tree size at the time of commitment, and the audit path of sibling hashes from leaf to root. Verification needs no kernel interaction:
pub struct ReceiptInclusionProof {
/// Which checkpoint this proof is for.
pub checkpoint_seq: u64,
/// The seq of the receipt being proved.
pub receipt_seq: u64,
/// Index of this receipt in the Merkle leaf array.
pub leaf_index: usize,
/// The Merkle root this proof is against.
pub merkle_root: Hash,
/// The audit path proof.
pub proof: MerkleProof,
}MerkleProof carries tree_size, leaf_index and audit_path. ReceiptInclusionProof::verify checks that its own leaf_index agrees with the proof's before it walks the path, so a record whose two indices disagree is rejected without hashing anything.
The receipt_canonical_bytes you feed in must be the exact RFC 8785 serialization of the whole ChioReceipt (the signed envelope, not just the body). The exported line is not those bytes: the exporter writes ordinary serde JSON, in declaration order, wrapped as {seq, receipt}. So a verifier reads the line, parses it back into a receipt, and re-canonicalizes before hashing, which is exactly what chio evidence verify does. Hashing the line as it sits on disk fails.
The expected root must come from a signed checkpoint body (the next section). Do not accept a root out of the air; the whole inclusion proof must terminate at a checkpoint the kernel signed.
Step 3: Verify the Checkpoint Signature
A kernel checkpoint is a signed statement of the form these receipts committed to this Merkle root at this time. New checkpoints are issued under chio.checkpoint_statement.v2; chio.checkpoint_statement.v1 is the legacy tag for a body with no checkpoint-chain commitment, and the validator accepts either.
pub struct KernelCheckpointBody {
/// Schema identifier for new checkpoint issuance.
pub schema: String,
/// Monotonic checkpoint counter.
pub checkpoint_seq: u64,
/// First receipt seq in this batch.
pub batch_start_seq: u64,
/// Last receipt seq in this batch.
pub batch_end_seq: u64,
/// Number of leaves in the Merkle tree.
pub tree_size: usize,
/// Root from MerkleTree::from_leaves.
pub merkle_root: Hash,
/// Unix timestamp (seconds) when the checkpoint was issued.
pub issued_at: u64,
/// The kernel's signing key (public).
pub kernel_key: PublicKey,
/// Hash of the immediately preceding checkpoint body when this checkpoint extends a prior batch.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_non_null_option"
)]
pub previous_checkpoint_sha256: Option<String>,
/// RFC 6962 root over the checkpoint-chain leaves for checkpoint_seq 1
/// through this checkpoint, one leaf per checkpoint binding its sequence,
/// entry range, and batch root (see [`checkpoint_chain_leaf_hash`]). This
/// is the commitment that consistency proofs verify against. Absent on
/// v1 checkpoints and on detached v2 checkpoints built without chain
/// context.
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_non_null_option"
)]
pub chain_root: Option<Hash>,
}chain_root is what the v2 tag adds: the commitment a consistency proof verifies against. The enclosing KernelCheckpoint is that body plus a signature over its canonical JSON, and both types carry deny_unknown_fields, so an extra key is a parse failure rather than something a verifier ignores.
Checkpoint signature verification is the simple case: verify_checkpoint_signature canonicalizes the bare KernelCheckpointBody and verifies it against the embedded kernel_key, with no wrapping envelope. Receipts are not structurally identical: they add the ChioReceiptSigningBody wrapper plus the id-recomputation and BBS-binding checks from Step 1. A checkpoint has none of those.
use chio_kernel::checkpoint::{validate_checkpoint, verify_checkpoint_signature};
// Just the signature check.
let ok = verify_checkpoint_signature(&checkpoint)?;
assert!(ok, "checkpoint signature must verify");
// Full structural validation, then the signature.
validate_checkpoint(&checkpoint)?;validate_checkpoint rejects a checkpoint whose schema tag is neither supported version, whose tree_size or issued_at is zero, whose batch_end_seq precedes its batch_start_seq, whose tree_size is not batch_end_seq - batch_start_seq + 1, whose previous_checkpoint_sha256 is not 64 lowercase hex, whose v1 body carries a chain_root, or whose first v2 checkpoint lacks one or commits to the wrong chain leaf. Then it checks the signature.
When you have more than one checkpoint from the same kernel, you can chain them. Each checkpoint after the first carries previous_checkpoint_sha256, the SHA-256 digest of the previous body in canonical form. Contiguous checkpoint_seq values plus matching digests give you tamper-evident continuity: splicing or re-ordering breaks the chain.
use chio_kernel::checkpoint::{
build_checkpoint_consistency_proof,
build_checkpoint_transparency,
validate_checkpoint_transparency,
verify_checkpoint_consistency_proof,
};
// Validate an entire checkpoint set: derives publications, witnesses and
// consistency proofs, and returns Err on the first equivocation. The
// question mark IS the equivocation check; nothing is left to assert.
let transparency = validate_checkpoint_transparency(&checkpoints)?;
// To inspect equivocations rather than fail on them, build the summary
// instead and read its equivocations field.
let summary = build_checkpoint_transparency(&checkpoints)?;
// Optionally verify a prefix-growth proof independently. The builder also
// takes the chain leaf hashes the proof commits to.
let proof = build_checkpoint_consistency_proof(&previous, ¤t, &chain_leaf_hashes)?;
assert!(verify_checkpoint_consistency_proof(&previous, ¤t, &proof)?);Forks are equivocations
checkpoint_seq, the same cumulative tree size for one log, or the same predecessor digest, and their bodies hash differently, chio records a CheckpointEquivocation, and validate_checkpoint_transparency returns an error on the first one rather than a summary. Treat any equivocation as a compromise signal and quarantine the log.CLI Example: chio evidence verify
Point the CLI at a directory produced by chio evidence export to verify manifest SHA-256 hashes, receipt signatures, checkpoint signatures, transparency chain, publication state, and inclusion proofs.
The counts below are from the package exported above, built from three chio check calls, one allowed and two denied. All three are tool_receipts. Only the allowed one is an authorized_receipt, and that count is narrower than it looks: a receipt is authorized only when its recomputed id, its signature, and its action hash all check out and its decision is an allow, so a tampered allow drops out of the count without appearing as a denial.
$ chio evidence verify --input ./packageevidence package verified tool_receipts: 3 child_receipts: 0 checkpoints: 0 checkpoint_publications: 0 checkpoint_witnesses: 0 checkpoint_consistency_proofs: 0 checkpoint_equivocations: 0 capability_lineage: 3 inclusion_proofs: 0 uncheckpointed_receipts: 3 authorized_receipts: 1 trace_observations: 0 advisory_evaluations: 0 verified_files: 12 child_receipt_scope: FullQueryWindow transparency_preview_logs: 0 publication_state: transparency_preview
The JSON format is stable and machine-readable, so it can feed CI, a compliance check, or a release gate.
$ chio --format json evidence verify --input ./package | jq .{
"schema": "chio.evidence_export_manifest.v1",
"verifiedAt": 1788610111,
"toolReceipts": 3,
"childReceipts": 0,
"checkpoints": 0,
"checkpointPublications": 0,
"checkpointWitnesses": 0,
"checkpointConsistencyProofs": 0,
"checkpointEquivocations": 0,
"capabilityLineage": 3,
"inclusionProofs": 0,
"uncheckpointedReceipts": 3,
"receiptSemantics": {
"mediatedDecisions": 3,
"traceObservations": 0,
"advisoryEvaluations": 0,
"prevent": 3,
"detectOnly": 0,
"advisoryOnly": 0,
"cannotSee": 0,
"authorized": 1
},
"verifiedFiles": 12,
"childReceiptScope": "full_query_window",
"claimBoundary": {
"schema": "chio.evidence_transparency_claims.v1",
"publicationState": "transparency_preview",
"audit": {
"checkpointLogs": [],
"signedCheckpoints": 0,
"checkpointPublications": 0,
"checkpointWitnesses": 0,
"checkpointConsistencyProofs": 0,
"inclusionProofs": 0,
"capabilityLineageRecords": 3
}
}
}schema in the verify result is the manifest's own schema, copied through, not a separate result schema.
What a tampered package looks like
The manifest carries a SHA-256 for every file in the package, so editing any one of them after export breaks verification. Copy the package, overwrite query.json, and re-verify the copy:
$ cp -R ./package ./package-tampered
$ echo '{"tampered":true}' > ./package-tampered/query.json
$ chio evidence verify --input ./package-tamperederror [urn:chio:error:attest:provenance-missing]: evidence package file hash mismatch for query.json
context: {"domain":"attest","severity":"error","stability":"unstable","string_code":"CHIO-ATTEST-PROVENANCE-MISSING"}
suggested fix: Regenerate the evidence bundle and include provenance before submitting the operation.The error names the file, so a CI gate can report which artifact diverged rather than only that the package failed. In --json mode the same failure is one object with the same exit status, which is the shape to parse from a wrapper script. Note where it lands:
$ chio evidence verify --input ./package-tampered --json{"code":"urn:chio:error:attest:provenance-missing","message":"evidence package file hash mismatch for query.json","context":{"domain":"attest","severity":"error","stability":"unstable","string_code":"CHIO-ATTEST-PROVENANCE-MISSING"},"suggested_fix":"Regenerate the evidence bundle and include provenance before submitting the operation."}The typed error body goes to stderr, not stdout. Stdout stays empty on a failed verify, so a wrapper that reads only stdout gets nothing to parse and has to fall back to the exit code.
Verifying One Receipt In Process
A whole evidence package is the CLI's job. A single receipt does not need a subprocess: Python, TypeScript, and Go each ship an invariants module that recomputes the content-addressed id, canonicalizes the signing body to RFC 8785, checks the signature, rehashes the action parameters, and matches the signer against a pinned key. Same checks, same field names, same answer, because all three read the same conformance vectors.
$ pip install chio-sdk
$ npm install @chio-protocol/sdk
$ go get github.com/backbay-labs/chio/sdks/go/chio-goThe Python distribution chio-sdk installs the import package chio, whose verifier lives in chio.invariants. The TypeScript verifier is on the ./invariants subpath, one of four the package declares alongside the root, ./transport, and ./package.json. The Go module path is github.com/backbay-labs/chio/sdks/go/chio-go.
import json
from chio.invariants import verify_receipt_with_trusted_signers
receipt = json.loads(open("receipt.json").read())
report = verify_receipt_with_trusted_signers(receipt, [PINNED_KERNEL_KEY])
if not report["ok"]:
raise SystemExit(f"receipt rejected: {report}")import { readFileSync } from "node:fs";
import { verifyReceiptWithTrustedSigners } from "@chio-protocol/sdk/invariants";
const receipt = JSON.parse(readFileSync("receipt.json", "utf8"));
const report = verifyReceiptWithTrustedSigners(receipt, [PINNED_KERNEL_KEY]);
if (!report.ok) {
throw new Error(`receipt rejected: ${JSON.stringify(report)}`);
}func VerifyReceiptWithTrustedSigners(
receipt map[string]any,
trustedSigners []string,
) (ReceiptVerification, error)The report is a flat record, not a boolean, so a rejection tells you which check failed. Running the Python verifier over the allow_receipt case above, with that case's kernel key pinned as the trusted signer:
{
"signature_valid": true,
"parameter_hash_valid": true,
"receipt_id_valid": true,
"decision": "allow",
"receipt_kind": "mediated_decision",
"boundary_class": "prevent",
"trust_level": "mediated",
"result": "Authorized",
"authorized": true,
"signer_key_hex": "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c",
"signer_trusted": true,
"ok": true
}Now the same call over tampered_receipt_signature, the vector whose policy_hash was edited after signing, with the same key pinned:
{
"signature_valid": false,
"parameter_hash_valid": true,
"receipt_id_valid": false,
"decision": "allow",
"receipt_kind": "mediated_decision",
"boundary_class": "prevent",
"trust_level": "mediated",
"result": "Authorized",
"authorized": false,
"signer_key_hex": "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c",
"signer_trusted": true,
"ok": false
}Two fields carry the story. receipt_id_valid is false because the id is a hash of the body, so editing the body breaks the id before anyone reaches the signature. parameter_hash_valid stays true alongside it, which is not a contradiction: that check only rehashes action.parameters, and this vector left the action alone. And result still reads Authorized on the tampered record, because result is a label for what the receipt claims. What it establishes is authorized, and that is false. Gate on ok or authorized, never on result.
Pass an empty trusted-signer list and signer_trusted is false, which drags ok and authorized false with it even for a perfectly valid receipt. That is the fail-closed default: a signature you cannot attribute to a key you decided to trust proves only that somebody signed something.
Embedding Package Verification in a Service
A captured evidence package carries checkpoints and inclusion proofs as well as receipts, so verification inside a Node.js, Bun, or Python service (an audit dashboard, a CI gate) shells out to chio evidence verify and parses the JSON result. The CLI bundles canonical hashing, receipt-signature verification, checkpoint-transparency checks, and inclusion-proof verification in a single fail-closed command.
--format json is a top-level flag, so it goes before the subcommand. Put it after --input and the parser rejects the run. The JSON keys are camelCase:
import { spawnSync } from "node:child_process";
type VerifyResult = {
toolReceipts: number;
childReceipts: number;
checkpoints: number;
checkpointEquivocations: number;
verifiedFiles: number;
// ... plus publication/witness counters
};
export function verifyEvidencePackage(path: string): VerifyResult {
const proc = spawnSync(
"chio",
["--format", "json", "evidence", "verify", "--input", path],
{ encoding: "utf8" },
);
if (proc.status !== 0) {
throw new Error(
`chio evidence verify failed (exit ${proc.status}): ${proc.stdout || proc.stderr}`,
);
}
return JSON.parse(proc.stdout) as VerifyResult;
}
const result = verifyEvidencePackage("./package");
if (result.checkpointEquivocations > 0) {
throw new Error("kernel log has forked; quarantine and investigate");
}
console.log(result.toolReceipts, "tool receipts,", result.verifiedFiles, "files verified");import json
import subprocess
def verify_evidence_package(path: str) -> dict:
proc = subprocess.run(
["chio", "--format", "json", "evidence", "verify", "--input", path],
capture_output=True,
text=True,
)
if proc.returncode != 0:
raise RuntimeError(
f"chio evidence verify failed (exit {proc.returncode}): {proc.stdout or proc.stderr}"
)
return json.loads(proc.stdout)
result = verify_evidence_package("./package")
if result["checkpointEquivocations"] > 0:
raise SystemExit("kernel log has forked; quarantine and investigate")
print(result["toolReceipts"], "tool receipts,", result["verifiedFiles"], "files verified")Both wrappers read the same two keys off the JSON above, so against the package on this page they print the same line: three tool receipts, twelve files verified.
The error branch reads both streams because the typed urn:chio:error:* body arrives on stderr with stdout empty. A wrapper that captures only stdout gets a non-zero exit and nothing to report.
The CLI is the verifier
chio evidence verify performs every check in this guide in one binary: canonical JSON via RFC 8785, signature verification against whatever algorithm the signature declares, and RFC 6962 Merkle audit-path checks. Drive it from your host language and treat its JSON result as the verification contract, which is what the wrapper above does.Related Offline Verifiers
chio evidence verify is the general path for a captured evidence package. Three tools cover adjacent offline-verification needs.
- chio-eval-receipt is a reference verifier with no
chio-kerneldependency forchio.eval-report.bundle.v1bundles. It validates the closed field set, confirms the wrapped corpus identity, recomputes each receipt's SHA-256 and embeddedChioReceiptsignature, and verifies the outer bundle signature over the RFC 8785 canonical payload. It ships achio-eval-receiptbinary (verify,verify-fixture,verify-memo) and a PyO3 binding,chio_eval_receipt_py.verify_bundle_json. - chio replay <LOG> is a batch regression verifier that re-checks every Ed25519 signature and recomputes the Merkle root incrementally. It has a stable exit-code registry (
0verified,10verdict drift,20signature mismatch,30parse error,40schema mismatch,50redaction mismatch) plus--json,--expect-root, and--from-teeflags: a natural fit for a CI gate. - chio-attest-verify handles Sigstore supply-chain and TEE-quote offline verification (
chio attest supply-chain verify,chio attest runtime-quote verify), binding a TEE quote to a kernel signing key and receipt root.
Common Failure Modes and What They Mean
Offline verification fails in a small number of well-defined ways. Each one points at a different class of problem.
| Symptom | What It Means | What To Do |
|---|---|---|
| Receipt signature invalid | The body has been modified, or you canonicalized it differently than the kernel did | Confirm you are using an RFC 8785 serializer; dump the canonical bytes and diff against a known-good fixture |
| Inclusion proof fails | The receipt bytes you hashed are not the same bytes the kernel committed | Verify you are canonicalizing the whole ChioReceipt (including signature), not just the body |
| Checkpoint signature invalid | The checkpoint body was altered or the wrong key was used | Use validate_checkpoint; confirm checkpoint.body.kernel_key matches a pinned key |
| Manifest hash mismatch | A file in the evidence package was modified after export | Re-fetch the package from the authoritative source; do not trust the tampered copy |
| Checkpoint equivocation | Two checkpoints contradict each other on seq, tree size, or predecessor | The log has forked. Quarantine, open an incident, and investigate key compromise |
| Uncheckpointed receipt | The receipt is signed but has no checkpoint yet | Expected for very recent receipts. Re-export later, or gate on uncheckpointedReceipts being zero in the JSON result |
| Unknown signer key | The signature is cryptographically valid but the key is not in your trust store | Extend the trust store only through an approved federation process, or reject the key. |
Canonical JSON is unforgiving
skip_serializing_if, an extra whitespace byte, or a different number serialization will break the signature even when the data is semantically identical. If you implement canonicalization yourself instead of using the chio primitives, test against tests/bindings/vectors/receipt/v1.json before you ship. Each of its 14 cases carries a receipt_body_canonical_json string, so you can diff your bytes against the ones every SDK is held to.Summary
| Check | Procedure | What It Tells You |
|---|---|---|
| Receipt signature | A signature over the RFC 8785 bytes of the signing envelope | The kernel signed these exact fields |
| Parameter hash | action.verify_hash() | The recorded parameters match their hash |
| Inclusion proof | RFC 6962 audit path | The receipt is a leaf under the checkpoint root |
| Checkpoint signature | A signature over the RFC 8785 bytes of the bare checkpoint body | The kernel committed this batch at this time |
| Checkpoint continuity | previous_checkpoint_sha256 chain | No receipts were retroactively inserted or re-ordered |
| Trust store lookup | Out of band | The signing key is a kernel you actually trust |
Next Steps
- Receipts · receipt fields, decisions, evidence, and metadata
- Query & Audit Receipts · the live counterpart when you do have kernel access
- CLI Reference ·
chio evidence exportandchio evidence verify - Agent Passport · how checkpoint roots become portable reputation evidence