ahg / inference-receipts
Tamper-evident receipts for AI inference calls: SHA-256 hash chain, RFC 8785 JCS canonicalization, Ed25519 signatures. Pure PHP, zero framework dependencies. EU AI Act Article 12 / NIST AI RMF aligned.
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- ext-sodium: *
Requires (Dev)
- phpunit/phpunit: ^10.0|^11.0
This package is auto-updated.
Last update: 2026-06-25 17:14:57 UTC
README
Tamper-evident receipt chains for AI inference calls. Pure PHP, zero framework dependencies.
Implements the three primitives that EU AI Act Article 12 ("logs that cannot be modified") needs in practice:
- SHA-256 hash chain - each receipt embeds the hash of its predecessor, so modifying or deleting an entry breaks the chain at a detectable point.
- RFC 8785 JSON Canonicalization Scheme (JCS) - deterministic byte serialization of the receipt payload, so two implementations always agree on what to hash and sign.
- Ed25519 detached signatures - each entry is signed under a publisher key; verifiers can confirm authenticity offline given the public key.
Built for EU AI Act / NIST AI RMF / ISO 42001 record-keeping requirements, but the primitives are generic. Anywhere you need an append-only signed audit trail for AI agent actions, this fits.
Why
The AI Act enforces from 2 August 2026. Article 12 requires high-risk AI systems to keep automatic event logs across their lifecycle, in a form that ensures traceability and cannot be silently modified. Plain database rows do not meet that bar; tamper-evident logs do.
This library is byte-compatible with the nobulex TypeScript / Python reference where the on-wire format overlaps, so receipts can be cross-verified by any implementation that consumes the same vectors.
Installation
composer require ahg/inference-receipts
Requirements: PHP 8.2+, ext-sodium, ext-json, ext-mbstring.
Quick start
use AhgInferenceReceipts\KeyPair; use AhgInferenceReceipts\Signer; use AhgInferenceReceipts\ReceiptChain; use AhgInferenceReceipts\Storage\ArrayChainStore; $keyPair = KeyPair::generate(); $keyPair->saveTo('/etc/ai-keys/signing.sk', '/etc/ai-keys/signing.pk'); $store = new ArrayChainStore(); $chain = ReceiptChain::withSingleKey($store, new Signer($keyPair)); $receipt = $chain->append([ 'service' => 'llm', 'model_id' => 'mistral:7b-instruct-v0.2', 'model_version' => 'mistral-7b@2024-01', 'input_fingerprint' => hash('sha256', $userPrompt), 'output_fingerprint' => hash('sha256', $modelResponse), 'request_id' => $requestId, 'user_id' => $userId, 'tenant_id' => $tenantId, ]); // later, audit time $result = $chain->verify(); echo $result; // "PASS (1 receipts verified)" or "FAIL at seq N: <reason>"
On-wire format
{
"v": 1,
"seq": 0,
"ts": "2026-05-25T12:00:00.000Z",
"prev_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"payload": { ... arbitrary caller-supplied JSON-able data ... },
"kid": "a1b2c3d4e5f60718",
"alg": "ed25519",
"entry_hash": "<sha256 hex of JCS(toSigningView())>",
"signature": "<base64 ed25519 over the entry_hash bytes>"
}
v: format version (currently1)seq: 0-based monotonic counter within the chaints: RFC 3339 with millisecond precision in UTCprev_hash:entry_hashof the previous receipt, or 64 zero bytes for genesispayload: caller payload (input/output fingerprints, model id, etc)kid: stable 16-hex-char id derived fromSHA-256(publicKey)alg: signature algorithm name (ed25519)entry_hash: SHA-256 ofJcsEncoder::encode(toSigningView())where the signing view is{v, seq, ts, prev_hash, payload, kid, alg}signature: base64-encoded Ed25519 signature over the raw 32-byteentry_hashdigest
Storage adapters
The library ships ArrayChainStore for tests and ephemeral use. Production callers implement ChainStore:
interface ChainStore { public function append(Receipt $receipt): void; public function head(): ?Receipt; public function count(): int; public function range(int $fromSeq = 0, ?int $toSeq = null): iterable; }
Most production deployments back this with a relational table that has a UNIQUE constraint on entry_hash (so concurrent writers cannot both win the same seq). See the ahg/ai-compliance Laravel package for an Eloquent-backed reference implementation.
Key rotation
ReceiptChain accepts a publicKeyResolver callable so verifiers can support multiple historical keys:
$resolver = function (string $kid): ?string { return match ($kid) { '2025-key-id' => file_get_contents('/etc/ai-keys/2025-signing.pk'), '2026-key-id' => file_get_contents('/etc/ai-keys/2026-signing.pk'), default => null, }; }; $chain = new ReceiptChain($store, new Signer($currentKeyPair), $resolver);
A receipt signed under a rotated-out key still verifies as long as its kid resolves to the public-key bytes that were valid at signing time.
Verification
$result = $chain->verify(); if (!$result->ok) { fprintf(STDERR, "Chain broken at seq %d: %s\n", $result->brokenAtSeq, $result->reason); exit(1); } echo "Verified {$result->checkedCount} receipts\n";
verify() walks the chain in order and confirms, for each entry:
seqincrements monotonically fromfromSeqprev_hashmatches the previous receipt'sentry_hash(or the genesis hash forseq == 0)entry_hashequalsSHA-256(JcsEncoder::encode(receipt.toSigningView()))signaturevalidates under the public key resolved fromkid
If any check fails, verification stops and returns the first failure point.
Conformance
The conformance suite under tests/Conformance/ consumes the nobulex test vectors as fixtures, so this implementation stays byte-compatible with the broader ecosystem.
./tools/fetch-nobulex-vectors.sh vendor/bin/phpunit --testsuite conformance
If the fixtures are absent the relevant tests are skipped, so the default suite stays green for downstream consumers who do not pull external vectors.
Threat model
Detected:
- Modification of any field of any stored receipt (payload, ts, seq, kid, alg, prev_hash).
- Re-signing a tampered receipt under a different key (if the verifier's
publicKeyResolverrejects unknown kids). - Re-ordering, splicing, or deleting receipts (the chain's
prev_hashlinkage breaks).
Not detected (out of scope for this library):
- Operator with both write access to the chain AND control of the signing key can append fraudulent entries that verify cleanly. Mitigation: anchor chain head to an off-host append-only log on a different trust boundary; consider RFC 3161 trusted timestamping for high-value chains.
- Replay of a stale receipt to a different chain or tenant. Mitigation: include
tenant_idandrequest_idin the payload and check them at verification time.
License
Apache-2.0. See LICENSE and NOTICE.
Provenance
Built for Heratio (a Laravel 12 GLAM platform by The Archive and Heritage Group) to satisfy EU AI Act Article 12 record-keeping. Released standalone because the PHP ecosystem needs a peer to the TypeScript / Python nobulex implementation; receipts produced here are byte-compatible with the nobulex format where the shapes overlap.
Tracking the IETF draft draft-gogani-nobulex-proof-of-behavior for on-wire format alignment as the spec progresses.