Chio/Docs
LOGIN · JOIN

BuildOperations

Receipt Verification

Verify a saved decision without contacting its host. Select your trusted signer, inspect the result, and detect modified evidence.

On this page

A Chio receipt carries the decision, its argument binding, and the host’s signature, so anyone holding your public key can check it without reaching your kernel. Here Corbel Assurance verifies the package Larkfield exported, first with the Chio CLI and then with a short program of its own in Python, TypeScript, or Go.

Continues from Inspect Your Receipts · uses the evidence/ package from the fixer’s runs, 23 receipts signed by kernel 4f1c…a2e9

Verify the package as the auditor

If you no longer have the package, export it again from the project directory:

Larkfield · Terminalbash
chio evidence export --admin-all --output ./evidence

Corbel trusts Larkfield’s kernel because Larkfield handed over its card out of band, never because the package names a key. Corbel pins the card once into a trust file it keeps for this engagement, then verifies:

Corbel Assurancebash
chio trust pin receipt-kernel ./larkfield.card --trust ./corbel-trust.toml
chio evidence verify --input ./evidence --trust ./corbel-trust.toml
Output
pinned     larkfield  receipt-kernel  ed25519:4f1c…a2e9
file       ./corbel-trust.toml
verified   23 receipts, 0 checkpoints, kernel larkfield
trust      ./corbel-trust.toml

You should see all 23 receipts verified under the pinned key. The CLI checks every file hash in the manifest, every receipt signature, and the signed capability lineage behind each grant.

Verify each receipt from your own code

An auditor often wants the check inside its own tooling. Download the verification project, which holds the same verifier in three languages with locked dependencies. Write Larkfield’s kernel key into larkfield-signers.json as a one-element JSON array, then run the verifier over the package:

Terminal · Python and uvTerminal · Node and npmTerminal · Go
uv run --locked verify.py ./evidence/receipts.ndjson --trusted-signers larkfield-signers.json

Each program prints one result per receipt and exits zero only when all 23 pass. An empty file, a modified record, or a signer missing from larkfield-signers.json exits nonzero. For the fixer’s refused test write, the result reads:

Verification result · the refused writejson
{
  "signature_valid": true,
  "parameter_hash_valid": true,
  "receipt_id_valid": true,
  "signer_trusted": true,
  "authorized": false,
  "ok": true
}

The record is genuine and correctly signed, so ok is true; it records a denial, so authorized is false. An auditor checking that the refusal really happened needs exactly this pair.

Read integrity and authority separately

FieldWhat it establishes
signature_valid
The signed body matches the recorded public key.
parameter_hash_valid
The retained arguments match their signed hash.
receipt_id_valid
The content-addressed receipt identity matches the body.
signer_trusted
Your independent trust configuration accepts the signer.
ok
All four verification checks pass.
authorized
The record describes an authoritative allow. A valid denial can have ok: true and authorized: false.

None of these fields shows that an external effect happened. A signature proves Larkfield’s kernel decided; the fixer’s run events and test results are the separate record of what ran.

Use the verifier in your program

The essential operation is a pure function over a receipt and an independently selected signer set. The complete programs add file handling, empty-input checks, readable output, and a meaningful exit code.

verify.py · verificationverify.mjs · verificationverify.go · verification
from chio.invariants import verify_receipt_with_trusted_signers

result = verify_receipt_with_trusted_signers(receipt, signers)
Read verify.py 25 lines · complete file
verify.pypython
"""Verify every exported receipt using separately selected trusted signers."""
import argparse
import json
from pathlib import Path
from chio.invariants import verify_receipt_with_trusted_signers


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('receipts', nargs='?', type=Path, default=Path('fixtures/minimal-evidence/receipts.ndjson'))
    parser.add_argument('--trusted-signers', type=Path, default=Path('trusted-signers.json'))
    args = parser.parse_args()
    signers = json.loads(args.trusted_signers.read_text())
    if not isinstance(signers, list) or not signers or not all(isinstance(key, str) for key in signers):
        parser.error('trusted-signers must be a nonempty JSON array of public keys')
    records = [json.loads(line) for line in args.receipts.read_text().splitlines() if line.strip()]
    if not records:
        parser.error('receipt file is empty')
    results = [verify_receipt_with_trusted_signers(record.get('receipt', record), signers) for record in records]
    print(json.dumps(results, indent=2))
    return 0 if all(result['ok'] for result in results) else 1


if __name__ == '__main__':
    raise SystemExit(main())
Read verify.mjs 10 lines · complete file
verify.mjsjavascript
import { readFileSync } from 'node:fs';
import { verifyReceiptWithTrustedSigners } from '@chio-protocol/sdk/invariants';
const [receipts = 'fixtures/minimal-evidence/receipts.ndjson', trust = 'trusted-signers.json'] = process.argv.slice(2);
const signers = JSON.parse(readFileSync(trust, 'utf8'));
if (!Array.isArray(signers) || !signers.length || signers.some(key => typeof key !== 'string')) throw Error('Trusted signers must be a nonempty JSON array of public keys');
const records = readFileSync(receipts, 'utf8').split('\n').filter(line => line.trim()).map(JSON.parse);
if (!records.length) throw Error('Receipt file is empty');
const results = records.map(record => verifyReceiptWithTrustedSigners(record.receipt ?? record, signers));
console.log(JSON.stringify(results, null, 2));
process.exitCode = results.every(result => result.ok) ? 0 : 1;
Read verify.go 76 lines · complete file
verify.gogo
package main

import (
	"bufio"
	"encoding/json"
	"flag"
	"fmt"
	"github.com/backbay-labs/chio/sdks/go/chio-go/invariants"
	"os"
)

func run() error {
	trust := flag.String("trusted-signers", "trusted-signers.json", "JSON array of independently selected trusted public keys")
	flag.Parse()
	receiptPath := "fixtures/minimal-evidence/receipts.ndjson"
	if flag.NArg() > 0 {
		receiptPath = flag.Arg(0)
	}
	keys, err := os.ReadFile(*trust)
	if err != nil {
		return err
	}
	var signers []string
	if err := json.Unmarshal(keys, &signers); err != nil {
		return err
	}
	if len(signers) == 0 {
		return fmt.Errorf("trusted signer set is empty")
	}
	f, err := os.Open(receiptPath)
	if err != nil {
		return err
	}
	defer f.Close()
	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 4096), 4*1024*1024)
	count := 0
	valid := true
	for scanner.Scan() {
		if len(scanner.Bytes()) == 0 {
			continue
		}
		var record map[string]any
		if err := json.Unmarshal(scanner.Bytes(), &record); err != nil {
			return err
		}
		receipt, ok := record["receipt"].(map[string]any)
		if !ok {
			receipt = record
		}
		result, err := invariants.VerifyReceiptWithTrustedSigners(receipt, signers)
		if err != nil {
			return err
		}
		out, err := json.MarshalIndent(result, "", "  ")
		if err != nil {
			return err
		}
		fmt.Println(string(out))
		valid = valid && result.Ok
		count++
	}
	if err := scanner.Err(); err != nil {
		return err
	}
	if count == 0 || !valid {
		return fmt.Errorf("receipt verification failed")
	}
	return nil
}
func main() {
	if err := run(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

Keep trust outside the package

Both verifiers take the signer set from a file the auditor controls: corbel-trust.toml for the CLI and larkfield-signers.json for the programs. Taking the key from the receipt being checked would let the sender choose its own trust. A package re-signed under another key passes every check inside it and still fails signer_trusted.

To give a recipient only part of the history, export one tenant with --tenant instead of --admin-all, as Export and Verify Evidence shows.

Read the complete verification report

Add --json for the report a pipeline keeps: counts of tool receipts, child receipts, checkpoints, and lineage records, the files the manifest covered, and the transparency boundary of the package.

Corbel Assurancebash
chio evidence verify --input ./evidence --trust ./corbel-trust.toml --json

Keep the language verifier’s signer check as a separate acceptance step beside the CLI report; each covers the other’s blind spot.

Make a controlled change

Copy the package, overwrite its query metadata in the copy, and verify the copy:

Corbel Assurancebash
cp -R evidence tampered
printf '{"tampered":true}\n' > tampered/query.json
chio evidence verify --input ./tampered --json
Output
{"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":"query.json changed after the package was exported; verify the package as exported, or export it again"}

You should see exit status 1 and a hash mismatch naming query.json. The manifest check runs first, so the change is caught before any signature is read. To test signer trust instead, put a different key in larkfield-signers.json and rerun a language verifier: every signature stays valid while signer_trusted and ok become false.

An attacker who edits a record and also recomputes every manifest checksum gets past the hash check and fails on the signature. The project’s semantic checks rehash the manifest after each change to prove it:

Read check_rehashed.py · complete semantic tampering checks 73 lines · complete file
check_rehashed.py · complete semantic tampering checkspython
"""Require semantic rejection even after an attacker updates package checksums."""

import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile


def check(binary, original, destination):
    destination.mkdir(parents=True, exist_ok=True)
    subprocess.run(
        [binary, "evidence", "verify", "--input", str(original), "--json"],
        check=True,
        capture_output=True,
        timeout=60,
    )
    cases = []
    for name in ("unsigned-lineage", "changed-signed-token", "changed-receipt"):
        package = destination / name
        shutil.copytree(original, package)
        relative = "receipts.ndjson" if name == "changed-receipt" else "capability-lineage.ndjson"
        path = package / relative
        records = [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
        if name == "unsigned-lineage":
            records[0].pop("signed_capability")
            records[0]["provenance"] = "legacy_projection"
        elif name == "changed-signed-token":
            records[0]["signed_capability"]["subject"] = "01" * 32
        else:
            records[0]["receipt"]["tool_name"] = "substituted_tool"
        path.write_text("".join(json.dumps(record) + "\n" for record in records))
        manifest_path = package / "manifest.json"
        manifest = json.loads(manifest_path.read_text())
        for entry in manifest["files"]:
            payload = (package / entry["path"]).read_bytes()
            entry["sha256"] = hashlib.sha256(payload).hexdigest()
            entry["bytes"] = len(payload)
        manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
        result = subprocess.run(
            [binary, "evidence", "verify", "--input", str(package), "--json"],
            capture_output=True,
            text=True,
            timeout=60,
        )
        (destination / (name + ".stdout.json")).write_text(result.stdout)
        (destination / (name + ".stderr.txt")).write_text(result.stderr)
        if result.returncode == 0:
            raise ValueError("Rehashed malicious package was accepted: " + name)
        # A verifier crash or CLI usage error is not a successful refusal.
        output = json.loads(result.stderr)
        if not output.get("code", "").startswith("urn:chio:error:attest:"):
            raise ValueError("Expected a structured semantic verification failure: " + name)
        cases.append(name)
    return {"refused_rehashed_packages": cases}


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--chio", default=os.environ.get("CHIO_BIN") or shutil.which("chio"))
    parser.add_argument("--output", type=Path)
    args = parser.parse_args()
    if not args.chio:
        parser.error("Install the matching Chio CLI or set CHIO_BIN")
    root = Path(__file__).resolve().parent
    if args.output:
        print(json.dumps(check(args.chio, root / "fixtures/minimal-evidence", args.output)))
    else:
        with tempfile.TemporaryDirectory(prefix="chio-rehashed-evidence-") as temporary:
            print(json.dumps(check(args.chio, root / "fixtures/minimal-evidence", Path(temporary))))

Find the record and its lineage

The package directory holds receipts.ndjson, capability-lineage.ndjson, the manifest, query metadata, retention metadata, and checkpoint-related files. The manifest covers every package file except itself. The trusted-signer file sits outside that package.

Each lineage record carries the original signed capability, its issuer, subject, and granted operation. A projection without that signed provenance is refused at the evidence boundary. The fixer’s receipts have no checkpoint witnesses or publication evidence until you publish checkpoints; Inspect Your Receipts reads individual records field by field.

What this project exercises

The CLI accepts a complete package and refuses a modified file, unsigned lineage, a changed signed capability, and a changed receipt; the last three hold even when every manifest checksum is recomputed. The language programs refuse an untrusted signer. The project’s source and dependency locks make each check inspectable.

Continue with scoped evidence export or query the application’s receipt store. Keep the operation, its record, and your trust configuration associated as the application grows.