AgentVault relay receipts are signed JSON documents that bind a session's inputs, outputs, and governance artefacts to a specific relay execution. This guide explains what receipts prove, how to verify them, and how to interpret the fields they contain.
For full protocol context see docs/protocol-spec.md.
- Introduction
- Receipt Versions
- Verification Algorithm — v1
- Verification Algorithm — v2
- Receipt Field Reference
- Getting the Verification Key
- Using the MCP Tool
- Common Pitfalls
Every AgentVault session produces a signed receipt. The receipt is a JSON document issued by the relay after execution completes. It records what contract governed the session, what inputs were committed, what output was produced, and which relay signed it.
A valid receipt signature proves:
- The output was produced by a relay holding the corresponding private key.
- The contract, output schema, and prompt template bound into the receipt are the exact ones that governed execution (via content hashes).
- The output in the receipt is the output the relay produced — it has not been altered after signing.
- The input commitments (participant input hashes) were computed by the same relay that signed the receipt.
- Relay honesty. The relay could have modified inputs before passing them to the model or fabricated output without calling a model at all. Receipts prove what the relay attests; they do not prove the relay behaved honestly.
- Model identity.
claims.model_identity_assertedis the model ID returned by the provider API. The relay asserts it but cannot independently prove it. - Input privacy. The relay receives participant inputs in plaintext. Inputs are not encrypted end-to-end. The relay discards raw inputs after receipt construction, retaining only commitment hashes.
- Simultaneous delivery. Receipts do not prove both parties received the output at the same time.
A receipt signature alone is not a full security guarantee. You must also read
assurance_level (v2 receipts) to understand what external evidence backs the
receipt. See Assurance Levels below.
v1 receipts have a flat field layout. The schema_version field is "1.0.0".
The signature is a hex-encoded string in the signature field.
Key signing properties:
- Domain separator:
VCAV-RECEIPT-V1: - Signature encoding: hex string (128 characters / 64 bytes)
- Algorithm: Ed25519
- Signed over: SHA-256 of
"VCAV-RECEIPT-V1:" + JCS(receipt_without_signature)
{
"schema_version": "1.0.0",
"session_id": "...",
"contract_hash": "...",
"output_schema_hash": "...",
"output": { "..." : "..." },
"signature": "a3f2...hex...9d01"
}v2 receipts reorganise all fields into two sections — commitments (independently
verifiable) and claims (relay-asserted). The receipt_schema_version field is
"2.1.0". The signature is an object with an alg field.
Key signing properties:
- Domain separator:
VCAV-RECEIPT-V2: - Signature encoding: base64url string in
signature.value - Algorithm: specified in
signature.alg(Ed25519 is the default for AgentVault) - Signed over: SHA-256 of
"VCAV-RECEIPT-V2:" + JCS(receipt_without_signature_object)
{
"receipt_schema_version": "2.1.0",
"receipt_canonicalization": "JCS_V1",
"receipt_id": "...",
"session_id": "...",
"assurance_level": "SELF_ASSERTED",
"operator": { "operator_id": "...", "operator_key_fingerprint": "..." },
"commitments": { "..." : "..." },
"claims": { "..." : "..." },
"signature": {
"alg": "Ed25519",
"value": "base64url...",
"signed_fields": "ALL_EXCEPT_SIGNATURE"
}
}The domain separator change ensures that a valid v1 signature cannot be misinterpreted as a valid v2 signature even if the JSON happens to parse under both schemas.
1. Parse the receipt JSON into a document object.
2. Extract the `signature` field (hex string). If absent, reject.
3. Remove the `signature` field from the document.
4. JCS-canonicalize the remaining document (RFC 8785).
5. Build the signing message:
message = "VCAV-RECEIPT-V1:" + canonical_json_string
6. Compute: digest = SHA-256(message)
7. Hex-decode the signature and the verifying key.
8. Verify Ed25519(digest, signature_bytes, public_key_bytes).
Uses @noble/curves, @noble/hashes, and json-canonicalize.
import { ed25519 } from '@noble/curves/ed25519';
import { sha256 } from '@noble/hashes/sha256';
import { hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
import { canonicalize } from 'json-canonicalize';
function verifyReceiptV1(
receipt: Record<string, unknown>,
publicKeyHex: string,
): boolean {
// 1. Extract and remove the signature field
const sigHex = receipt['signature'];
if (typeof sigHex !== 'string') {
throw new Error('Missing or non-string signature field');
}
const { signature: _removed, ...unsigned } = receipt;
// 2. JCS-canonicalize
const canonical = canonicalize(unsigned);
// 3. Build message and digest
const message = 'VCAV-RECEIPT-V1:' + canonical;
const digest = sha256(utf8ToBytes(message));
// 4. Decode and verify
const sigBytes = hexToBytes(sigHex);
const pubKeyBytes = hexToBytes(publicKeyHex);
return ed25519.verify(sigBytes, digest, pubKeyBytes);
}Uses PyNaCl and jcs (RFC 8785 canonicalization).
import hashlib
import binascii
import jcs # pip install jcs
import nacl.signing # pip install pynacl
def verify_receipt_v1(receipt: dict, public_key_hex: str) -> bool:
# 1. Extract and remove the signature field
sig_hex = receipt.get('signature')
if not isinstance(sig_hex, str):
raise ValueError('Missing or non-string signature field')
unsigned = {k: v for k, v in receipt.items() if k != 'signature'}
# 2. JCS-canonicalize (RFC 8785)
canonical_bytes = jcs.canonicalize(unsigned) # returns bytes
# 3. Build message and digest
message = b'VCAV-RECEIPT-V1:' + canonical_bytes
digest = hashlib.sha256(message).digest()
# 4. Decode and verify
sig_bytes = binascii.unhexlify(sig_hex)
pub_key_bytes = binascii.unhexlify(public_key_hex)
verify_key = nacl.signing.VerifyKey(pub_key_bytes)
try:
verify_key.verify(digest, sig_bytes)
return True
except nacl.exceptions.BadSignatureError:
return False1. Parse the receipt JSON.
2. Check receipt_canonicalization == "JCS_V1". Reject any other value.
3. Extract the `signature` object. If absent or not an object, reject.
4. Read signature.alg. Currently only "Ed25519" is supported.
5. Remove the entire `signature` object from the document.
6. JCS-canonicalize the remaining document (RFC 8785).
7. Build the signing message:
message = "VCAV-RECEIPT-V2:" + canonical_json_string
8. Compute: digest = SHA-256(message)
9. Base64url-decode signature.value.
10. Hex-decode the verifying key.
11. Verify Ed25519(digest, signature_bytes, public_key_bytes).
After signature verification, you may optionally verify commitments if you hold the original artefacts — see Commitment Verification below.
import { ed25519 } from '@noble/curves/ed25519';
import { sha256 } from '@noble/hashes/sha256';
import { hexToBytes, utf8ToBytes } from '@noble/hashes/utils';
import { canonicalize } from 'json-canonicalize';
function base64urlToBytes(b64url: string): Uint8Array {
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=');
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function verifyReceiptV2(
receipt: Record<string, unknown>,
publicKeyHex: string,
): boolean {
// 1. Check canonicalization marker
if (receipt['receipt_canonicalization'] !== 'JCS_V1') {
throw new Error('Unsupported receipt_canonicalization');
}
// 2. Extract the signature object
const sigObj = receipt['signature'];
if (typeof sigObj !== 'object' || sigObj === null) {
throw new Error('Missing or invalid signature object');
}
const sig = sigObj as Record<string, unknown>;
if (sig['alg'] !== 'Ed25519') {
throw new Error(`Unsupported signature algorithm: ${String(sig['alg'])}`);
}
const sigValue = sig['value'];
if (typeof sigValue !== 'string') {
throw new Error('signature.value must be a string');
}
// 3. Remove the signature object
const { signature: _removed, ...unsigned } = receipt;
// 4. JCS-canonicalize
const canonical = canonicalize(unsigned);
// 5. Build message and digest
const message = 'VCAV-RECEIPT-V2:' + canonical;
const digest = sha256(utf8ToBytes(message));
// 6. Decode and verify
const sigBytes = base64urlToBytes(sigValue);
const pubKeyBytes = hexToBytes(publicKeyHex);
return ed25519.verify(sigBytes, digest, pubKeyBytes);
}import hashlib
import binascii
import base64
import jcs
import nacl.signing
def base64url_decode(s: str) -> bytes:
# Add padding if needed
remainder = len(s) % 4
if remainder:
s += '=' * (4 - remainder)
return base64.urlsafe_b64decode(s)
def verify_receipt_v2(receipt: dict, public_key_hex: str) -> bool:
# 1. Check canonicalization marker
if receipt.get('receipt_canonicalization') != 'JCS_V1':
raise ValueError('Unsupported receipt_canonicalization')
# 2. Extract the signature object
sig_obj = receipt.get('signature')
if not isinstance(sig_obj, dict):
raise ValueError('Missing or invalid signature object')
alg = sig_obj.get('alg')
if alg != 'Ed25519':
raise ValueError(f'Unsupported signature algorithm: {alg}')
sig_value = sig_obj.get('value')
if not isinstance(sig_value, str):
raise ValueError('signature.value must be a string')
# 3. Remove the signature object
unsigned = {k: v for k, v in receipt.items() if k != 'signature'}
# 4. JCS-canonicalize
canonical_bytes = jcs.canonicalize(unsigned)
# 5. Build message and digest
message = b'VCAV-RECEIPT-V2:' + canonical_bytes
digest = hashlib.sha256(message).digest()
# 6. Decode and verify
sig_bytes = base64url_decode(sig_value)
pub_key_bytes = binascii.unhexlify(public_key_hex)
verify_key = nacl.signing.VerifyKey(pub_key_bytes)
try:
verify_key.verify(digest, sig_bytes)
return True
except nacl.exceptions.BadSignatureError:
return FalseAfter verifying the signature, you can optionally verify individual commitments if you hold the original artefacts. Commitment verification is independent of signature verification — it checks that the hashes in the receipt match the source documents.
import { canonicalize } from 'json-canonicalize';
import { sha256 } from '@noble/hashes/sha256';
import { utf8ToBytes, bytesToHex } from '@noble/hashes/utils';
function sha256hex(data: string): string {
return bytesToHex(sha256(utf8ToBytes(data)));
}
// Recompute contract_hash and compare to commitments.contract_hash
function verifyContractHash(
contract: Record<string, unknown>,
receipt: Record<string, unknown>,
): boolean {
const canonical = canonicalize(contract);
const computed = sha256hex(canonical);
const commitments = receipt['commitments'] as Record<string, unknown>;
return computed === commitments['contract_hash'];
}
// Recompute output_hash from inline output and compare
function verifyOutputHash(
output: Record<string, unknown>,
receipt: Record<string, unknown>,
): boolean {
const canonical = canonicalize(output);
const computed = sha256hex(canonical);
const commitments = receipt['commitments'] as Record<string, unknown>;
return computed === commitments['output_hash'];
}
// Verify a participant's input commitment
function verifyInputCommitment(
participantId: string,
participantInput: Record<string, unknown>,
receipt: Record<string, unknown>,
): boolean {
const canonical = canonicalize(participantInput);
const computed = sha256hex(canonical);
const commitments = receipt['commitments'] as Record<string, unknown>;
const inputCommitments = commitments['input_commitments'] as Array<Record<string, unknown>>;
const entry = inputCommitments.find((ic) => ic['participant_id'] === participantId);
if (!entry) return false;
return computed === entry['input_hash'];
}| Field | Type | Description |
|---|---|---|
receipt_schema_version |
string | Always "2.1.0" for v2 receipts |
receipt_canonicalization |
string | Always "JCS_V1" |
receipt_id |
string (UUID) | Unique receipt identifier |
session_id |
string (UUID) | Session this receipt covers |
issued_at |
string (ISO 8601) | Timestamp the relay signed the receipt |
assurance_level |
string | See Assurance Levels |
operator |
object | Relay operator identity |
commitments |
object | Independently verifiable hashes |
claims |
object | Relay-asserted, not independently verifiable |
provider_attestation |
object? | Provider-signed inference metadata (optional) |
tee_attestation |
object? | TEE hardware attestation (optional) |
signature |
object | Receipt signature |
All fields in commitments are independently verifiable — a third party with
access to the original inputs can recompute them.
| Field | How to verify |
|---|---|
contract_hash |
Recompute SHA-256(JCS(contract_document)) |
schema_hash |
Recompute SHA-256(JCS(output_schema)) |
output_hash |
Recompute SHA-256(JCS(output)) |
input_commitments[] |
Each participant recomputes SHA-256(JCS(own_input)) |
assembled_prompt_hash |
Recompute SHA-256(assembled_prompt_bytes) — requires the relay's prompt assembly implementation |
prompt_template_hash |
Recompute SHA-256(prompt_template_bytes) |
effective_config_hash |
Recompute SHA-256(JCS(preflight_bundle)) |
preflight_bundle.* |
Hash each constituent artefact independently |
The output field inside commitments is an inline convenience copy of the
session output. When present, verifiers must check that
SHA-256(JCS(commitments.output)) matches commitments.output_hash.
All fields in claims are relay-asserted. The relay includes them as a service
to consumers, but a third party cannot independently verify them without additional
attestation infrastructure.
| Field | Meaning |
|---|---|
model_identity_asserted |
Model ID returned by the provider API |
model_identity_attested |
Model ID from provider-signed metadata (requires provider_attestation) |
model_profile_hash_asserted |
Hash of the model profile document used |
runtime_hash_asserted |
SHA-256 of the relay software build |
runtime_hash_attested |
Same, TEE-attested (requires tee_attestation) |
budget_enforcement_mode |
How token/entropy budget was enforced: enforced, advisory, disabled |
provider_latency_ms |
Wall-clock time for the provider API call |
token_usage |
Prompt, completion, and total token counts |
relay_software_version |
Semver of the relay that issued the receipt |
status |
Session outcome status |
signal_class |
Classification of the signal produced |
execution_lane |
standard (software) or tee (TEE) |
channel_capacity_bits_upper_bound |
Computed upper bound on schema information capacity (bits) |
channel_capacity_measurement_version |
Algorithm version for capacity computation (e.g., "enum_cardinality_v1") |
entropy_budget_bits |
Budget declared in the contract |
schema_entropy_ceiling_bits |
Schema-level entropy ceiling |
budget_usage |
{pair_id, bits_used_before, bits_used_after, budget_limit, enforcement_tier} |
Present when claims.execution_lane is tee. The tee_attestation
object is a top-level receipt field alongside commitments and claims.
| Field | Meaning |
|---|---|
tee_type |
Simulated (testing) or SevSnp (AMD SEV-SNP hardware) |
attestation_hash |
SHA-256 of the platform attestation report |
receipt_signing_pubkey_hex |
Ed25519 public key bound into the TEE attestation |
transcript_hash_hex |
SHA-512 of the canonical session transcript (embedded in SEV-SNP user_data) |
TEE commitments add initiator_submission_hash and responder_submission_hash to
the commitments object, binding each participant's raw submission into the
attestation chain.
The assurance_level field is required in v2 receipts and describes the strength
of external evidence backing the receipt.
| Value | Meaning |
|---|---|
SELF_ASSERTED |
The relay signs its own receipt. No external attestation. Claims are relay assertions only. |
OPERATOR_AUDITED |
The operator publishes a verifiable audit trail. Claims can be cross-checked against the audit log. |
PROVIDER_ATTESTED |
The model provider supplied signed inference metadata. claims.model_identity_attested can be trusted. |
TEE_ATTESTED |
Hardware TEE attestation (AMD SEV-SNP) binds the receipt to the CVM measurement. The relay binary, signing key, and session transcript are hardware-verified. Operational via av-tee relay. |
The software relay operates at SELF_ASSERTED. The TEE relay (av-tee) operates
at TEE_ATTESTED on AMD SEV-SNP hardware (validated on GCP N2D instances).
Never display "receipt verified" without also showing the assurance level — a
SELF_ASSERTED receipt is much weaker than a TEE_ATTESTED one.
{
"operator_id": "relay.agentvault.dev",
"operator_key_fingerprint": "abcd1234...",
"operator_key_discovery": "https://relay.agentvault.dev/.well-known/av-keys.json"
}operator_key_fingerprint is the SHA-256 hex digest of the raw 32-byte public key.
operator_key_discovery is an optional URI where the operator publishes their
canonical key for pinning — not present in self-asserted deployments.
{
"alg": "Ed25519",
"value": "base64url-encoded-64-bytes",
"signed_fields": "ALL_EXCEPT_SIGNATURE"
}signed_fields: "ALL_EXCEPT_SIGNATURE" indicates that every field in the receipt
except the signature object itself is covered by the signature. This is the only
value currently issued.
The relay exposes its Ed25519 verifying key at the /health endpoint:
GET http://localhost:4840/health
{
"status": "ok",
"version": "0.8.0",
"verifying_key_hex": "a3f2...64-hex-chars...9d01"
}verifying_key_hex is 64 hex characters (32 bytes), the raw Ed25519 public key.
In the current SELF_ASSERTED implementation, the key in /health is the relay's
self-reported key. There is no external certificate authority binding the key to
the operator identity.
For production use, consider:
- Fetch the key once and pin it. Do not re-fetch the key on each verification — a compromised relay could swap keys mid-session.
- Record the key alongside the receipt. Store
verifying_key_hexat session time so you can verify the receipt offline later. - Use
operator_key_fingerprint(v2) as a consistency check. After fetching the key, compute SHA-256 of the 32-byte key and compare toreceipt.operator.operator_key_fingerprint. A mismatch means the key you fetched does not match the key embedded in the receipt.
import { sha256 } from '@noble/hashes/sha256';
import { hexToBytes, bytesToHex } from '@noble/hashes/utils';
function checkKeyFingerprint(
publicKeyHex: string,
receipt: Record<string, unknown>,
): boolean {
const keyBytes = hexToBytes(publicKeyHex);
const fingerprint = bytesToHex(sha256(keyBytes));
const operator = receipt['operator'] as Record<string, unknown>;
return fingerprint === operator['operator_key_fingerprint'];
}The agentvault-mcp-server package exposes an agentvault.verify_receipt tool
that wraps the verification logic described above.
{
"name": "agentvault.verify_receipt",
"arguments": {
"receipt": { /* receipt JSON object */ },
"public_key_hex": "a3f2...optional...", // omit to fetch from relay
"relay_url": "http://localhost:4840" // default if public_key_hex omitted
}
}If public_key_hex is omitted, the tool fetches it from relay_url/health.
Pass public_key_hex explicitly when verifying offline or when you have pinned
the key.
{
"valid": true,
"schema_version": "2.1.0",
"assurance_level": "SELF_ASSERTED", // v2 only
"operator_id": "relay.agentvault.dev", // v2 only
"errors": [],
"warnings": []
}valid: true means the Ed25519 signature verified. It does not mean commitments
were verified — that requires the caller to hold the original artefacts.
{
"tool": "agentvault.verify_receipt",
"arguments": {
"receipt": {
"receipt_schema_version": "2.1.0",
"receipt_canonicalization": "JCS_V1",
"receipt_id": "a1b2c3d4-...",
"session_id": "e5f6a7b8-...",
"issued_at": "2026-03-04T12:00:00Z",
"assurance_level": "SELF_ASSERTED",
"operator": {
"operator_id": "relay.agentvault.dev",
"operator_key_fingerprint": "abcd1234..."
},
"commitments": {
"contract_hash": "...",
"schema_hash": "...",
"output_hash": "...",
"input_commitments": [],
"assembled_prompt_hash": "...",
"prompt_assembly_version": "1.0.0"
},
"claims": {},
"signature": {
"alg": "Ed25519",
"value": "base64url...",
"signed_fields": "ALL_EXCEPT_SIGNATURE"
}
},
"public_key_hex": "a3f2...64-hex..."
}
}The verification algorithm requires JCS canonicalization. Standard JSON serializers
do not sort object keys deterministically. Using JSON.stringify or pretty-print
JSON will produce a different byte sequence and signature verification will fail.
Correct libraries:
- TypeScript / JavaScript:
json-canonicalize(npm) - Python:
jcs(pip), orcanonicaljson - Go:
github.com/cyberphone/json-canonicalization - Rust: canonicalization wrapper over
serde_jsonwith deterministic key ordering (as inreceipt-core::canonicalize)
For v1, strip the signature string field.
For v2, strip the entire signature object — including alg, value, and
signed_fields. Leaving any part of the signature object in the document will
produce a different canonical form and verification will fail.
// v1 — strip the string field
const { signature: _removed, ...unsigned } = receiptV1;
// v2 — strip the entire object
const { signature: _removed, ...unsigned } = receiptV2;The domain separator is prepended to the canonical JSON as a plain string — no null byte, no length prefix, no newline. The colon is part of the separator.
v1: "VCAV-RECEIPT-V1:" + canonical_json_string
v2: "VCAV-RECEIPT-V2:" + canonical_json_string
A missing colon or trailing whitespace will produce a different digest.
The relay does not sign the canonical bytes directly. It signs the SHA-256 hash of the full signing message (domain separator + canonical JSON). Verify against the hash, not the raw message.
// Correct — verify over the SHA-256 digest
const message = 'VCAV-RECEIPT-V1:' + canonical;
const digest = sha256(utf8ToBytes(message));
ed25519.verify(sigBytes, digest, pubKeyBytes);
// Wrong — will always fail; do not pass raw message bytes
ed25519.verify(sigBytes, utf8ToBytes(message), pubKeyBytes);Never assume a receipt version. Always read receipt_schema_version (v2) or
schema_version (v1) and dispatch to the correct verification path. Applying v1
verification to a v2 receipt will fail because v2's signature field is an
object, not a string, and the domain separator is different.
Verifiers must reject receipts with unknown versions (fail-closed).
signature.value in v2 receipts is base64url-encoded (URL-safe alphabet, no
padding). Standard base64 decoders may fail on the - and _ characters.
Use a base64url decoder, or replace - with + and _ with / before
passing to a standard base64 decoder, then add the correct padding.
A valid signature only proves the receipt was not tampered with after the relay
signed it. It does not prove the relay was honest during execution. Always
communicate the assurance_level alongside any "verified" status.