Receipt Verification — v1
Status: Stable. The wire format below is locked for v1.x.
Version: v1.0
Reference implementation: standalone Python verifier (§ 8, 80 lines). A TypeScript reference exists in the gateway repo at packages/verify-receipt/.
A receipt is a self-contained, hash-chained, ECDSA-signed JSON document. Anyone holding the corresponding public key (published at /.well-known/jwks.json) can verify it offline — no call to our gateway is required. This page is the document an independent implementer can verify against in 30 minutes without contacting us.
1. Algorithm
Every receipt is signed with ECDSA on the NIST P-256 curve, JOSE alg name ES256 (RFC 7518 § 3.4).
| Field | Value |
|---|---|
| Curve | P-256 (a.k.a. secp256r1, prime256v1) |
| Hash | SHA-256 |
| JOSE alg | ES256 |
| Signature format on the wire | JOSE raw R || S concatenation, base64url, no padding |
| Signing root | AWS KMS (production); WebCrypto-generated keypair (sandbox) |
The signing key bytes never leave KMS in production. The gateway sends the SHA-256 pre-hash to KMS via Sign with MessageType=DIGEST; KMS returns a DER-encoded ECDSA signature, which the gateway converts to JOSE raw form before attaching.
2. Signature scope
The signed bytes are the JCS canonical form of the entire receipt object except the signature.value field.
canonical_bytes = JCS(receipt with signature.value omitted)
sha256_hash = SHA-256(canonical_bytes)
signature.value = base64url(ECDSA-P-256-Sign(sha256_hash, kms_private_key))
signature.kid and signature.alg ARE included in the hash. This commits the signer to a specific key + algorithm; an attacker cannot swap the kid for a key they control without breaking the signature.
A verifier reconstructs the canonical bytes by:
- Parsing the receipt JSON.
- Replacing
signature.valuewithundefined(i.e. removing the field;signature.kidandsignature.algstay). - Re-canonicalising via JCS.
3. JCS canonicalisation
Canonicalisation follows RFC 8785 — JSON Canonicalization Scheme (JCS) exactly. No deviations.
The points an honest implementer most often gets wrong:
- Object keys are sorted by their UTF-16 code-unit values, not bytewise UTF-8 and not by Unicode codepoint. (Most JS / Python / Go libraries default to one of the wrong orderings.)
- Numbers are serialised per ECMA-262 § 7.1.12.1 — integers as integers, floats with the minimal-digits canonical form.
1.0becomes1,1e10becomes10000000000, etc. - No insignificant whitespace between tokens.
- Strings use the strict JSON escape set plus
\u00XXfor everything else printable; no\u-escapes for ASCII characters that don't require them.
The reference TypeScript implementation lives in the gateway repo at packages/core/src/canonical.ts. The official Python package is rfc8785.
4. Audit chain — per-entry hash
Each entry in receipt.entries[] carries a hash field that chains the entry to the previous one. The genesis entry (index 0) uses a sentinel previousHash of 64 hex zeros.
The hash is computed over the entry's fields in this exact order:
const hashable = {
entryId: entry.entryId,
index: entry.index,
stepName: entry.stepName,
input: entry.input,
output: entry.output,
startTime: entry.startTime,
endTime: entry.endTime,
latencyMs: entry.latencyMs,
cost: entry.cost,
error: entry.error,
previousHash: entry.previousHash,
metadata: entry.metadata,
// checkpointSignature ONLY when the entry has one
...(entry.checkpointSignature !== undefined
? { checkpointSignature: entry.checkpointSignature }
: {}),
};
const entryHash = sha256(JCS(hashable)); // lowercase hex
const hashable = {
entryId: entry.entryId,
index: entry.index,
stepName: entry.stepName,
input: entry.input,
output: entry.output,
startTime: entry.startTime,
endTime: entry.endTime,
latencyMs: entry.latencyMs,
cost: entry.cost,
error: entry.error,
previousHash: entry.previousHash,
metadata: entry.metadata,
// checkpointSignature ONLY when the entry has one
...(entry.checkpointSignature !== undefined
? { checkpointSignature: entry.checkpointSignature }
: {}),
};
const entryHash = sha256(JCS(hashable)); // lowercase hex
JCS canonicalisation re-orders these keys lexicographically before hashing — the order in the source code above is the declaration order, which is irrelevant; what matters is that JCS produces a single deterministic byte sequence.
The previousHash of entry i+1 MUST equal the hash of entry i. The verifier walks the chain front-to-back; any mismatch is a CHAIN_HASH_MISMATCH failure.
5. Genesis is per-receipt
Every receipt is its own chain. There is no global anchor. The genesis entry (index 0) is hashed from the receipt's own creation parameters (UUID + creation timestamp), so two receipts created at different moments produce different genesis hashes.
genesis.previousHash = "0000000000000000000000000000000000000000000000000000000000000000" (64 hex zeros)
genesis.hash = sha256(JCS({ entryId: <uuid>,
index: 0,
stepName: "__genesis__",
input: null,
output: null,
startTime: receipt.created,
endTime: receipt.created,
latencyMs: 0,
cost: null,
error: null,
previousHash: <64 zeros>,
metadata: {} }))
A reader who's seen blockchain genesis blocks may assume a single global genesis hash. There isn't one. Verifying a receipt does not require any other receipt; the chain is self-rooted.
6. Outcome uniformity — refused receipts have the same chain shape
A receipt that records a refusal (math halt, boundary violation, schema fail, etc.) carries the same number of audit entries as a successful one — genesis plus every pipeline step. Steps after the refusal point still run, with synthetic post-refusal outputs, and are hash-chained the same way.
This is a deliberate property of the protocol: there is one pipeline with two outcomes, not two pipelines. A verifier treats refused receipts and executed receipts identically at the chain-integrity level. The semantic difference (executed vs refused) lives in receipt.paymentStatus and the contents of the offending step's output field, never in the chain shape.
Practically: if your verifier sees fewer entries on a refused receipt, the receipt is malformed.
7. Key lifecycle (JWKS extensions)
The JWKS endpoint at /.well-known/jwks.json returns every key ever active, not just the current signing key. Receipts signed under a rotated kid remain verifiable forever.
Each JWK in the keys array carries the standard JOSE fields (kty, crv, x, y, alg, use, kid) plus four private extensions:
| Extension | Type | Meaning |
|---|---|---|
ep_status | "active" | "verify-only" | "compromised" | Lifecycle state. Required. |
ep_active_from | ISO-8601 | When this key first became eligible to sign. Required. |
ep_active_through | ISO-8601 | Last moment this key was eligible to sign. Set on rotate-out. |
ep_compromised_at | ISO-8601 | Set when the key is marked compromised. |
A verifier MUST gate validity by status:
ep_status | A receipt with created in this range is valid |
|---|---|
active | any time |
verify-only | [ep_active_from, ep_active_through] |
compromised | created < ep_compromised_at; otherwise quarantined |
A receipt signed by an unknown kid (not present in JWKS) is treated as unknown_kid. Verifiers SHOULD return that status verbatim rather than collapsing it into a generic "invalid" — it lets operators distinguish cache staleness from forgery.
8. Reference implementation — Python (~80 lines)
This is enough to verify any Execution Protocol receipt offline. Drop it in a file, install two dependencies, and you have a working verifier.
pip install rfc8785 cryptography requests
pip install rfc8785 cryptography requests
"""
verify_receipt.py — minimal offline verifier for Execution Protocol receipts.
Usage:
python verify_receipt.py path/to/receipt.json
python verify_receipt.py path/to/receipt.json --jwks-url https://r.executionprotocol.dev/.well-known/jwks.json
Exits 0 on valid, 1 on invalid, 2 on IO error.
"""
import argparse, base64, hashlib, json, sys
from datetime import datetime, timezone
import requests
import rfc8785
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from cryptography.exceptions import InvalidSignature
def b64url_decode(s: str) -> bytes:
s += '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
def jcs(obj) -> bytes:
return rfc8785.dumps(obj) # bytes, RFC 8785 canonical
def compute_entry_hash(entry: dict) -> str:
fields = ['entryId','index','stepName','input','output','startTime',
'endTime','latencyMs','cost','error','previousHash','metadata']
hashable = {k: entry[k] for k in fields}
if 'checkpointSignature' in entry:
hashable['checkpointSignature'] = entry['checkpointSignature']
return hashlib.sha256(jcs(hashable)).hexdigest()
def verify_chain(receipt: dict) -> None:
GENESIS_PREV = '0' * 64
prev = GENESIS_PREV
for i, e in enumerate(receipt['entries']):
if e['previousHash'] != prev:
raise ValueError(f'CHAIN_HASH_MISMATCH at entry {i}: previousHash != prev hash')
recomputed = compute_entry_hash(e)
if recomputed != e['hash']:
raise ValueError(f'CHAIN_HASH_MISMATCH at entry {i}: hash field tampered')
prev = e['hash']
def parse_dt(s: str):
return datetime.fromisoformat(s.replace('Z', '+00:00'))
def find_jwk(jwks: dict, kid: str) -> dict:
for jwk in jwks['keys']:
if jwk.get('kid') == kid:
return jwk
raise ValueError(f'unknown_kid: {kid}')
def gate_status(jwk: dict, receipt_created: str) -> None:
status = jwk.get('ep_status', 'active')
created = parse_dt(receipt_created)
if status == 'active':
return
if status == 'verify-only':
through = parse_dt(jwk['ep_active_through'])
if created > through:
raise ValueError('verify-only key: receipt created after active window')
return
if status == 'compromised':
compromised_at = parse_dt(jwk['ep_compromised_at'])
if created >= compromised_at:
raise ValueError('quarantined: receipt signed at-or-after compromise timestamp')
return
raise ValueError(f'unknown ep_status: {status}')
def verify_signature(receipt: dict, jwk: dict) -> None:
# Reconstruct canonical bytes (signature.value omitted; kid+alg retained).
body = dict(receipt)
body['signature'] = {'kid': receipt['signature']['kid'], 'alg': receipt['signature']['alg']}
canonical = jcs(body)
# Build the public key from JWK x/y.
x = int.from_bytes(b64url_decode(jwk['x']), 'big')
y = int.from_bytes(b64url_decode(jwk['y']), 'big')
pub = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
# Convert JOSE raw R||S → DER for the cryptography library.
raw = b64url_decode(receipt['signature']['value'])
if len(raw) != 64:
raise ValueError(f'malformed signature length: {len(raw)} (expected 64)')
r = int.from_bytes(raw[:32], 'big')
s = int.from_bytes(raw[32:], 'big')
der = encode_dss_signature(r, s)
pub.verify(der, canonical, ec.ECDSA(hashes_sha256()))
def hashes_sha256():
from cryptography.hazmat.primitives import hashes
return hashes.SHA256()
def main():
ap = argparse.ArgumentParser()
ap.add_argument('receipt', help='path to receipt.json')
ap.add_argument('--jwks-url', default='https://r.executionprotocol.dev/.well-known/jwks.json')
args = ap.parse_args()
try:
receipt = json.loads(open(args.receipt, 'rb').read())
except OSError as e:
print(f'IO_ERROR: {e}'); sys.exit(2)
try:
jwks = requests.get(args.jwks_url, timeout=10).json()
verify_chain(receipt)
jwk = find_jwk(jwks, receipt['signature']['kid'])
gate_status(jwk, receipt['created'])
verify_signature(receipt, jwk)
except (ValueError, InvalidSignature) as e:
print(f'INVALID: {e}'); sys.exit(1)
except requests.RequestException as e:
print(f'JWKS_FETCH_ERROR: {e}'); sys.exit(2)
print('VALID'); sys.exit(0)
if __name__ == '__main__':
main()
"""
verify_receipt.py — minimal offline verifier for Execution Protocol receipts.
Usage:
python verify_receipt.py path/to/receipt.json
python verify_receipt.py path/to/receipt.json --jwks-url https://r.executionprotocol.dev/.well-known/jwks.json
Exits 0 on valid, 1 on invalid, 2 on IO error.
"""
import argparse, base64, hashlib, json, sys
from datetime import datetime, timezone
import requests
import rfc8785
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
from cryptography.exceptions import InvalidSignature
def b64url_decode(s: str) -> bytes:
s += '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
def jcs(obj) -> bytes:
return rfc8785.dumps(obj) # bytes, RFC 8785 canonical
def compute_entry_hash(entry: dict) -> str:
fields = ['entryId','index','stepName','input','output','startTime',
'endTime','latencyMs','cost','error','previousHash','metadata']
hashable = {k: entry[k] for k in fields}
if 'checkpointSignature' in entry:
hashable['checkpointSignature'] = entry['checkpointSignature']
return hashlib.sha256(jcs(hashable)).hexdigest()
def verify_chain(receipt: dict) -> None:
GENESIS_PREV = '0' * 64
prev = GENESIS_PREV
for i, e in enumerate(receipt['entries']):
if e['previousHash'] != prev:
raise ValueError(f'CHAIN_HASH_MISMATCH at entry {i}: previousHash != prev hash')
recomputed = compute_entry_hash(e)
if recomputed != e['hash']:
raise ValueError(f'CHAIN_HASH_MISMATCH at entry {i}: hash field tampered')
prev = e['hash']
def parse_dt(s: str):
return datetime.fromisoformat(s.replace('Z', '+00:00'))
def find_jwk(jwks: dict, kid: str) -> dict:
for jwk in jwks['keys']:
if jwk.get('kid') == kid:
return jwk
raise ValueError(f'unknown_kid: {kid}')
def gate_status(jwk: dict, receipt_created: str) -> None:
status = jwk.get('ep_status', 'active')
created = parse_dt(receipt_created)
if status == 'active':
return
if status == 'verify-only':
through = parse_dt(jwk['ep_active_through'])
if created > through:
raise ValueError('verify-only key: receipt created after active window')
return
if status == 'compromised':
compromised_at = parse_dt(jwk['ep_compromised_at'])
if created >= compromised_at:
raise ValueError('quarantined: receipt signed at-or-after compromise timestamp')
return
raise ValueError(f'unknown ep_status: {status}')
def verify_signature(receipt: dict, jwk: dict) -> None:
# Reconstruct canonical bytes (signature.value omitted; kid+alg retained).
body = dict(receipt)
body['signature'] = {'kid': receipt['signature']['kid'], 'alg': receipt['signature']['alg']}
canonical = jcs(body)
# Build the public key from JWK x/y.
x = int.from_bytes(b64url_decode(jwk['x']), 'big')
y = int.from_bytes(b64url_decode(jwk['y']), 'big')
pub = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
# Convert JOSE raw R||S → DER for the cryptography library.
raw = b64url_decode(receipt['signature']['value'])
if len(raw) != 64:
raise ValueError(f'malformed signature length: {len(raw)} (expected 64)')
r = int.from_bytes(raw[:32], 'big')
s = int.from_bytes(raw[32:], 'big')
der = encode_dss_signature(r, s)
pub.verify(der, canonical, ec.ECDSA(hashes_sha256()))
def hashes_sha256():
from cryptography.hazmat.primitives import hashes
return hashes.SHA256()
def main():
ap = argparse.ArgumentParser()
ap.add_argument('receipt', help='path to receipt.json')
ap.add_argument('--jwks-url', default='https://r.executionprotocol.dev/.well-known/jwks.json')
args = ap.parse_args()
try:
receipt = json.loads(open(args.receipt, 'rb').read())
except OSError as e:
print(f'IO_ERROR: {e}'); sys.exit(2)
try:
jwks = requests.get(args.jwks_url, timeout=10).json()
verify_chain(receipt)
jwk = find_jwk(jwks, receipt['signature']['kid'])
gate_status(jwk, receipt['created'])
verify_signature(receipt, jwk)
except (ValueError, InvalidSignature) as e:
print(f'INVALID: {e}'); sys.exit(1)
except requests.RequestException as e:
print(f'JWKS_FETCH_ERROR: {e}'); sys.exit(2)
print('VALID'); sys.exit(0)
if __name__ == '__main__':
main()
Note: this implementation is complete — it walks the chain, gates the kid by lifecycle status, and verifies the ECDSA signature. It does not verify the amendment chain or the cancellation policy; those are out-of-scope for v1 receipt verification (a separate spec page covers them).
9. Reference implementation — TypeScript
The TypeScript reference implementation lives in the gateway repo at packages/verify-receipt/. It exposes the same algorithm (RFC 8785 JCS canonicalisation, ECDSA P-256 verification, lifecycle gate) as the Python verifier above, plus a CLI for batch verification.
The package handles the full lifecycle gate (active / verify-only / compromised) automatically and is the canonical source the in-browser verifier on each receipt page derives from.
10. Verifying a live receipt by hand
To sanity-check your implementation against a real receipt:
- Pick any receipt URL:
https://r.executionprotocol.dev/r/<sig> - Fetch the JSON form:
curl 'https://r.executionprotocol.dev/r/<sig>?raw'(returns the receipt object) - Fetch JWKS:
curl https://r.executionprotocol.dev/.well-known/jwks.json - Run the Python verifier from § 8.
- Expect
VALIDand exit code0.
Tamper with any byte of the receipt — change a hash, an amount, swap an entry's output — and the verifier returns INVALID with the specific failure mode (chain mismatch, signature mismatch, etc.).
11. Test fixtures
Frozen verify-green / verify-red fixture pairs are not currently published at a stable URL. Any live receipt URL on r.executionprotocol.dev serves as a working example; the gateway issues new receipts every time the smoke tests run.
If you build a verifier and it disagrees with the canonical TypeScript implementation, email the receipt JSON + your verifier's output to security@executionprotocol.dev and we will reconcile.
Last updated: 2026-05-08.