From 15d912a374268b8cd52b0488cf706bbba924a6cf Mon Sep 17 00:00:00 2001 From: itsmygithubacct Date: Tue, 4 Aug 2026 02:19:57 -0700 Subject: [PATCH 1/2] feat(receipts): trinote.receipt/v3 binds a receipt to the request that asked for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 commits a model, its input tokens, its output tokens and its sampling trace. It commits nothing about the request, so a valid old receipt can be presented against a fresh one and nothing in the receipt itself contradicts that. v3 adds one field, contextCommit, committed in BOTH signed messages and the body: v3 model {contextCommit, inputCommit, modelHash, outputCommit, traceCommit} v3 counterparty {contextCommit, inputCommit, modelHash, outputCommit} The counterparty signs it too. It co-signs only what it observed and never the internal trace, but it is the party attesting that this result answers this request — a counterparty signature without the context would be transferable between requests, which is the failure v3 exists to prevent. v2 is untouched. Its bytes, its receiptHash and its signatures are byte-identical to what they were before this commit, which matters because those digests are anchored on-chain and pinned by cross-language vectors. Passing context_commit to v2 or v1 is an error rather than a silent no-op, and emitting v3 without a valid 64-hex binding raises rather than producing a receipt that claims a freshness it does not have. tests/fixtures/receipt-v3.vectors.json holds signed-message bytes produced by an independent implementation of the same specification; the new tests compare this engine's encoding against them for v2 and v3 alike. If the two ever disagree about a byte, a signature made by one cannot be verified by the other. 15 new tests pass; 137 existing receipt, canonical and trace tests still pass. --- bonsai/src/trinote/receipts/receipt.py | 49 ++++++-- bonsai/tests/fixtures/receipt-v3.vectors.json | 119 ++++++++++++++++++ bonsai/tests/test_receipt_v3_context.py | 98 +++++++++++++++ 3 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 bonsai/tests/fixtures/receipt-v3.vectors.json create mode 100644 bonsai/tests/test_receipt_v3_context.py diff --git a/bonsai/src/trinote/receipts/receipt.py b/bonsai/src/trinote/receipts/receipt.py index 6126af4..7fa0dbe 100644 --- a/bonsai/src/trinote/receipts/receipt.py +++ b/bonsai/src/trinote/receipts/receipt.py @@ -23,13 +23,18 @@ """ from __future__ import annotations +import re from .canonical import canonical_bytes, commit, token_commit from .signing import LocalKey, sign from ..hashing.sha import sha256_hex + +_HEX64 = re.compile(r"\A[0-9a-f]{64}\Z") from ..infer_int.sampler import RECEIPT_SAFE_MODES, inv_temp_fp, top_p_fp, min_p_fp SCHEMA = "trinote.receipt/v2" PREIMAGE_SCHEMA = "trinote.receipt-preimage/v2" +SCHEMA_V3 = "trinote.receipt/v3" +PREIMAGE_SCHEMA_V3 = "trinote.receipt-preimage/v3" SCHEMA_V1 = "trinote.receipt/v1" PREIMAGE_SCHEMA_V1 = "trinote.receipt-preimage/v1" @@ -120,19 +125,34 @@ def build_receipt(*, model_hash: str, input_ids, output_ids, sampler, model_key: LocalKey, counterparty_key: LocalKey, trace: dict | None = None, model_label: str = "", artifact_digest: str | None = None, - fp_frac_bits: int = 16, schema_version: str = "v2") -> dict: + fp_frac_bits: int = 16, schema_version: str = "v2", + context_commit: str | None = None) -> dict: """Build a receipt BUNDLE for one inference turn. Pure: no I/O, no chain. Greedy → receiptBound. `schema_version="v2"` (default) commits a fully-integer sampler block (float-free, language-neutral); `fp_frac_bits` is the fixed-point scale (must match the engine's `model.cfg["frac"]`). Pass - `schema_version="v1"` ONLY to reproduce a historical float-block receiptHash byte-for-byte.""" - if schema_version not in ("v1", "v2"): # fail loud, never silently fall through to v2 - raise ValueError(f"unknown receipt schema_version {schema_version!r} (expected 'v1' or 'v2')") + `schema_version="v1"` ONLY to reproduce a historical float-block receiptHash byte-for-byte. + + `schema_version="v3"` additionally binds the receipt to the request that asked for it: the caller + supplies `context_commit` (semantos.trinote.context/v1) and it is committed inside BOTH signed + messages and the receipt body. v2 receipts are byte-unchanged — a v2 receiptHash computed before v3 + existed still recomputes identically, which matters because those digests are anchored on-chain.""" + if schema_version not in ("v1", "v2", "v3"): # fail loud, never silently fall through to v2 + raise ValueError(f"unknown receipt schema_version {schema_version!r} (expected 'v1', 'v2' or 'v3')") + if schema_version == "v3": + if not isinstance(context_commit, str) or not _HEX64.match(context_commit): + # a v3 receipt without a valid binding would claim freshness it does not have + raise ValueError("schema_version='v3' requires context_commit as 64 lowercase hex chars") + elif context_commit is not None: + raise ValueError(f"context_commit is only meaningful for v3, not {schema_version!r}") input_commit = token_commit(input_ids) output_commit = token_commit(output_ids) if schema_version == "v1": sampler_block = _sampler_to_block_v1(sampler) schema, preimage_schema = SCHEMA_V1, PREIMAGE_SCHEMA_V1 + elif schema_version == "v3": + sampler_block = sampler_to_block(sampler, fp_frac_bits) + schema, preimage_schema = SCHEMA_V3, PREIMAGE_SCHEMA_V3 else: sampler_block = sampler_to_block(sampler, fp_frac_bits) schema, preimage_schema = SCHEMA, PREIMAGE_SCHEMA @@ -148,7 +168,7 @@ def build_receipt(*, model_hash: str, input_ids, output_ids, sampler, "sampler": sampler_block, "miStatus": trace.get("miStatus", "pending"), # honest: MI attribution not wired } - if schema_version == "v2": + if schema_version in ("v2", "v3"): # The v2 float-free / language-neutral guarantee covers the WHOLE committed preimage, not just the # sampler block. The MI fields are empty today (P5/pending), but enforce the invariant now so that # when MI lands it must commit fixed-point ints (e.g. actFp=round(act·2^f)) rather than IEEE floats @@ -160,13 +180,20 @@ def build_receipt(*, model_hash: str, input_ids, output_ids, sampler, # 1st entry — model signs the full claim including the trace commitment. `key.sign` is polymorphic: # a LocalKey emits a symmetric HMAC vouch; an ECKey emits a third-party-verifiable secp256k1 signature. - model_msg = canonical_bytes({"modelHash": model_hash, "inputCommit": input_commit, - "outputCommit": output_commit, - "traceCommit": trace_block["traceCommit"]}) + model_entry = {"modelHash": model_hash, "inputCommit": input_commit, + "outputCommit": output_commit, "traceCommit": trace_block["traceCommit"]} + if context_commit is not None: + model_entry["contextCommit"] = context_commit + model_msg = canonical_bytes(model_entry) sig_model = model_key.sign(model_msg) # 2nd entry — counterparty co-signs only the input/output it observed (not the internal trace). - cp_msg = canonical_bytes({"modelHash": model_hash, "inputCommit": input_commit, - "outputCommit": output_commit}) + # the counterparty co-signs only what it observed — plus the context, or its + # signature would be transferable between requests + cp_entry = {"modelHash": model_hash, "inputCommit": input_commit, + "outputCommit": output_commit} + if context_commit is not None: + cp_entry["contextCommit"] = context_commit + cp_msg = canonical_bytes(cp_entry) sig_counterparty = counterparty_key.sign(cp_msg) body = { @@ -189,6 +216,8 @@ def build_receipt(*, model_hash: str, input_ids, output_ids, sampler, cp_pub = getattr(counterparty_key, "public_hex", None) if cp_pub: body["sigCounterpartyPubKey"] = cp_pub + if context_commit is not None: + body["contextCommit"] = context_commit receipt = dict(body) receipt["receiptHash"] = sha256_hex(canonical_bytes(body)) # commits the signed pair (3rd-entry payload) diff --git a/bonsai/tests/fixtures/receipt-v3.vectors.json b/bonsai/tests/fixtures/receipt-v3.vectors.json new file mode 100644 index 0000000..1157138 --- /dev/null +++ b/bonsai/tests/fixtures/receipt-v3.vectors.json @@ -0,0 +1,119 @@ +{ + "schema": "trinote.receipt/v3", + "note": "Signed-message and receipt-hash vectors. v2 rows pin what must not change. Synthetic inputs only.", + "messages": [ + { + "name": "v2-model", + "entry": "model", + "note": "unchanged from today \u2014 these bytes must never move", + "inputs": { + "model_hash": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "input_commit": "0101010101010101010101010101010101010101010101010101010101010101", + "output_commit": "0202020202020202020202020202020202020202020202020202020202020202", + "trace_commit": "0303030303030303030303030303030303030303030303030303030303030303" + }, + "canonicalText": "{\"inputCommit\":\"0101010101010101010101010101010101010101010101010101010101010101\",\"modelHash\":\"e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5\",\"outputCommit\":\"0202020202020202020202020202020202020202020202020202020202020202\",\"traceCommit\":\"0303030303030303030303030303030303030303030303030303030303030303\"}", + "signingDigest": "b9e44da58d175a5511b90b83e1e52db7f5fd7ba31ec6ef713e418c04d328da79" + }, + { + "name": "v2-counterparty", + "entry": "counterparty", + "note": "the counterparty co-signs only what it observed", + "inputs": { + "model_hash": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "input_commit": "0101010101010101010101010101010101010101010101010101010101010101", + "output_commit": "0202020202020202020202020202020202020202020202020202020202020202" + }, + "canonicalText": "{\"inputCommit\":\"0101010101010101010101010101010101010101010101010101010101010101\",\"modelHash\":\"e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5\",\"outputCommit\":\"0202020202020202020202020202020202020202020202020202020202020202\"}", + "signingDigest": "154553d06d1928ce2d9f3fd7ae7edde4569c0317f7c4fa647bd45e0bf8c62439" + }, + { + "name": "v3-model", + "entry": "model", + "note": "exactly one key added; contextCommit sorts first", + "inputs": { + "model_hash": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "input_commit": "0101010101010101010101010101010101010101010101010101010101010101", + "output_commit": "0202020202020202020202020202020202020202020202020202020202020202", + "trace_commit": "0303030303030303030303030303030303030303030303030303030303030303", + "context_commit": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0" + }, + "canonicalText": "{\"contextCommit\":\"c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0\",\"inputCommit\":\"0101010101010101010101010101010101010101010101010101010101010101\",\"modelHash\":\"e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5\",\"outputCommit\":\"0202020202020202020202020202020202020202020202020202020202020202\",\"traceCommit\":\"0303030303030303030303030303030303030303030303030303030303030303\"}", + "signingDigest": "951962e9fc34ab0acbbbccc4894c12154a1ba2eb72acc82b5caaf090ba88fa4a" + }, + { + "name": "v3-counterparty", + "entry": "counterparty", + "note": "the counterparty signs the context too, or its signature would be transferable between requests", + "inputs": { + "model_hash": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "input_commit": "0101010101010101010101010101010101010101010101010101010101010101", + "output_commit": "0202020202020202020202020202020202020202020202020202020202020202", + "context_commit": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0" + }, + "canonicalText": "{\"contextCommit\":\"c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0\",\"inputCommit\":\"0101010101010101010101010101010101010101010101010101010101010101\",\"modelHash\":\"e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5\",\"outputCommit\":\"0202020202020202020202020202020202020202020202020202020202020202\"}", + "signingDigest": "226af39bc6415e940ec740c38da09c4ecd8fd94d1575aaa50ce62f4291cd07f9" + }, + { + "name": "v3-model-different-context", + "entry": "model", + "note": "same execution, different request context \u2014 the signed bytes must differ", + "inputs": { + "model_hash": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "input_commit": "0101010101010101010101010101010101010101010101010101010101010101", + "output_commit": "0202020202020202020202020202020202020202020202020202020202020202", + "trace_commit": "0303030303030303030303030303030303030303030303030303030303030303", + "context_commit": "c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + }, + "canonicalText": "{\"contextCommit\":\"c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1\",\"inputCommit\":\"0101010101010101010101010101010101010101010101010101010101010101\",\"modelHash\":\"e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5\",\"outputCommit\":\"0202020202020202020202020202020202020202020202020202020202020202\",\"traceCommit\":\"0303030303030303030303030303030303030303030303030303030303030303\"}", + "signingDigest": "7014b249fc3b02fe4dc6b848df7d5d4f4721858fd20803da91c0bc96321953d0" + } + ], + "receipts": [ + { + "name": "v2-body", + "note": "abridged body; the point is that the hash covers every field", + "body": { + "schema": "trinote.receipt/v2", + "modelHash": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "inputCommit": "0101010101010101010101010101010101010101010101010101010101010101", + "outputCommit": "0202020202020202020202020202020202020202020202020202020202020202", + "receiptBound": true, + "sigModelKeyId": "key:model-a", + "sigCounterpartyKeyId": "key:cp-b" + }, + "receiptHash": "034c423b293b1d2fae6a81984c1010721db006aba1ac1c6d4b4ea5b243da3202" + }, + { + "name": "v3-body", + "note": "same execution plus contextCommit \u2014 a different receiptHash", + "body": { + "schema": "trinote.receipt/v3", + "modelHash": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "inputCommit": "0101010101010101010101010101010101010101010101010101010101010101", + "outputCommit": "0202020202020202020202020202020202020202020202020202020202020202", + "receiptBound": true, + "sigModelKeyId": "key:model-a", + "sigCounterpartyKeyId": "key:cp-b", + "contextCommit": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0" + }, + "receiptHash": "70c3eb418571bd898327b433141e0251b03975dae47d0ba80975808a900589f6" + }, + { + "name": "v3-body-hash-excludes-itself", + "note": "receiptHash recomputes identically when the field is already present", + "body": { + "schema": "trinote.receipt/v3", + "modelHash": "e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "inputCommit": "0101010101010101010101010101010101010101010101010101010101010101", + "outputCommit": "0202020202020202020202020202020202020202020202020202020202020202", + "receiptBound": true, + "sigModelKeyId": "key:model-a", + "sigCounterpartyKeyId": "key:cp-b", + "contextCommit": "c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0", + "receiptHash": "70c3eb418571bd898327b433141e0251b03975dae47d0ba80975808a900589f6" + }, + "receiptHash": "70c3eb418571bd898327b433141e0251b03975dae47d0ba80975808a900589f6" + } + ] +} diff --git a/bonsai/tests/test_receipt_v3_context.py b/bonsai/tests/test_receipt_v3_context.py new file mode 100644 index 0000000..d3a8bcd --- /dev/null +++ b/bonsai/tests/test_receipt_v3_context.py @@ -0,0 +1,98 @@ +"""v3 binds a receipt to the request that asked for it — and leaves v2 untouched. + +The vectors in `fixtures/receipt-v3.vectors.json` were produced by an independent +implementation of the same specification. Checking the engine against them is the +point: if these two ever disagree about a byte, a signature made by one cannot be +verified by the other, and the digests involved are anchored on-chain. +""" + +from __future__ import annotations + +import json +import pathlib + +import pytest + +from trinote.receipts.canonical import canonical_bytes +from trinote.receipts.receipt import SCHEMA, SCHEMA_V3, build_receipt, receipt_hash +from trinote.receipts.signing import LocalKey + +VECTORS = json.loads((pathlib.Path(__file__).parent / "fixtures" / "receipt-v3.vectors.json").read_text()) +MESSAGES = {case["name"]: case for case in VECTORS["messages"]} + +CONTEXT = "c0" * 32 + + +def _keys(): + return LocalKey(b"model-secret", "key:model-a"), LocalKey(b"cp-secret", "key:cp-b") + + +def _build(**over): + model_key, cp_key = _keys() + args = dict(model_hash="e5" * 32, input_ids=[1, 2, 3], output_ids=[4, 5], + sampler={"mode": "greedy"}, model_key=model_key, counterparty_key=cp_key) + args.update(over) + return build_receipt(**args)["receipt"] + + +def test_v2_is_unchanged_by_the_existence_of_v3(): + receipt = _build() + assert receipt["schema"] == SCHEMA + assert "contextCommit" not in receipt + # the hash still recomputes over the body — the property every anchored v2 + # commitment depends on + assert receipt_hash(receipt) == receipt["receiptHash"] + + +def test_v3_commits_the_context_in_the_body(): + receipt = _build(schema_version="v3", context_commit=CONTEXT) + assert receipt["schema"] == SCHEMA_V3 + assert receipt["contextCommit"] == CONTEXT + assert receipt_hash(receipt) == receipt["receiptHash"] + + +def test_v3_receipt_hash_differs_from_v2_for_the_same_execution(): + """Same model, same tokens, same trace — a different request context must not + produce the same receipt.""" + assert _build()["receiptHash"] != _build(schema_version="v3", context_commit=CONTEXT)["receiptHash"] + + +def test_a_different_context_changes_the_receipt(): + a = _build(schema_version="v3", context_commit=CONTEXT) + b = _build(schema_version="v3", context_commit="c1" * 32) + assert a["receiptHash"] != b["receiptHash"] + assert a["sigModel"] != b["sigModel"] + assert a["sigCounterparty"] != b["sigCounterparty"] + + +@pytest.mark.parametrize("bad", [None, "", "C0" * 32, "c0" * 31, 42]) +def test_v3_without_a_valid_binding_is_refused(bad): + """A v3 receipt without a valid context commitment would claim a freshness it + does not have, so it fails loud rather than emitting an unbound v3.""" + with pytest.raises(ValueError): + _build(schema_version="v3", context_commit=bad) + + +def test_context_commit_is_rejected_for_v2(): + with pytest.raises(ValueError): + _build(schema_version="v2", context_commit=CONTEXT) + + +def test_unknown_schema_version_fails_loud(): + with pytest.raises(ValueError): + _build(schema_version="v4") + + +@pytest.mark.parametrize("name", ["v2-model", "v2-counterparty", "v3-model", "v3-counterparty"]) +def test_signed_message_bytes_match_the_independent_vectors(name): + """The signed messages are rebuilt exactly as build_receipt assembles them, and + compared with bytes an independent implementation produced from the same inputs.""" + case = MESSAGES[name] + i = case["inputs"] + entry = {"modelHash": i["model_hash"], "inputCommit": i["input_commit"], + "outputCommit": i["output_commit"]} + if case["entry"] == "model": + entry["traceCommit"] = i["trace_commit"] + if i.get("context_commit") is not None: + entry["contextCommit"] = i["context_commit"] + assert canonical_bytes(entry).decode() == case["canonicalText"] From 2996f817626579762f16a08ce0a6a253ff58ceff Mon Sep 17 00:00:00 2001 From: itsmygithubacct Date: Tue, 4 Aug 2026 03:11:21 -0700 Subject: [PATCH 2/2] test(receipts): check the v3 vectors against what build_receipt actually signs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture carried more than the tests read. `signingDigest` was unchecked on all five messages, the whole `receipts` array went unread — including the v2 receiptHash that is anchored on-chain — and `v3-model-different-context` sat in the file but not in the parametrize list. Data that claims a coverage it does not have is worse than no data. The vector check also rebuilt the signed entry inside the test rather than taking it from build_receipt, so it compared an independent implementation against a shape this engine might not use. Renaming contextCommit to ctxCommit inside build_receipt left all 15 tests passing while nothing this engine signed could be verified by the other implementation — precisely the failure the vectors exist to catch. `_signed_entry` is now the single definition of the signed shape. The vector tests check its bytes and digests against the independent implementation; new tests check that build_receipt's own sigModel and sigCounterparty are that shape signed. Both halves must agree for either to pass, which closes the chain from build_receipt to the foreign bytes. v2's anchored receiptHash is asserted against a literal. Recomputing a receipt's hash and comparing it against itself passes just as happily when both sides move together, and an on-chain anchor is the one digest that cannot tolerate that. Message and receipt vectors are parametrized over the fixture itself, so a vector that is added but never asserted cannot pass unnoticed. Also moves _HEX64 below the imports; it was sitting between two of them. 24 tests, up from 15. --- bonsai/src/trinote/receipts/receipt.py | 2 +- bonsai/tests/test_receipt_v3_context.py | 103 +++++++++++++++++++++--- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/bonsai/src/trinote/receipts/receipt.py b/bonsai/src/trinote/receipts/receipt.py index 7fa0dbe..577712b 100644 --- a/bonsai/src/trinote/receipts/receipt.py +++ b/bonsai/src/trinote/receipts/receipt.py @@ -27,9 +27,9 @@ from .canonical import canonical_bytes, commit, token_commit from .signing import LocalKey, sign from ..hashing.sha import sha256_hex +from ..infer_int.sampler import RECEIPT_SAFE_MODES, inv_temp_fp, top_p_fp, min_p_fp _HEX64 = re.compile(r"\A[0-9a-f]{64}\Z") -from ..infer_int.sampler import RECEIPT_SAFE_MODES, inv_temp_fp, top_p_fp, min_p_fp SCHEMA = "trinote.receipt/v2" PREIMAGE_SCHEMA = "trinote.receipt-preimage/v2" diff --git a/bonsai/tests/test_receipt_v3_context.py b/bonsai/tests/test_receipt_v3_context.py index d3a8bcd..e7e2931 100644 --- a/bonsai/tests/test_receipt_v3_context.py +++ b/bonsai/tests/test_receipt_v3_context.py @@ -4,10 +4,20 @@ implementation of the same specification. Checking the engine against them is the point: if these two ever disagree about a byte, a signature made by one cannot be verified by the other, and the digests involved are anchored on-chain. + +Two things have to hold for that check to mean anything, so both are tested here: + +* the vectors' bytes are the bytes this engine's canonical encoder produces, and +* `build_receipt` actually signs that shape. + +`_signed_entry` is the single definition of the shape, used by both halves. Checking +the vectors against a shape that `build_receipt` never uses would pass while every +real signature diverged, which is the failure this file exists to make impossible. """ from __future__ import annotations +import hashlib import json import pathlib @@ -19,10 +29,27 @@ VECTORS = json.loads((pathlib.Path(__file__).parent / "fixtures" / "receipt-v3.vectors.json").read_text()) MESSAGES = {case["name"]: case for case in VECTORS["messages"]} +RECEIPTS = {case["name"]: case for case in VECTORS["receipts"]} CONTEXT = "c0" * 32 +def _signed_entry(*, model_hash, input_commit, output_commit, + trace_commit=None, context_commit=None) -> dict: + """The exact object `build_receipt` signs. + + The model entry carries the trace commitment; the counterparty entry does not, + because the counterparty co-signs only what it observed. `contextCommit` is + present for v3 and absent for v2, which is what keeps v2's bytes unmoved. + """ + entry = {"modelHash": model_hash, "inputCommit": input_commit, "outputCommit": output_commit} + if trace_commit is not None: + entry["traceCommit"] = trace_commit + if context_commit is not None: + entry["contextCommit"] = context_commit + return entry + + def _keys(): return LocalKey(b"model-secret", "key:model-a"), LocalKey(b"cp-secret", "key:cp-b") @@ -83,16 +110,72 @@ def test_unknown_schema_version_fails_loud(): _build(schema_version="v4") -@pytest.mark.parametrize("name", ["v2-model", "v2-counterparty", "v3-model", "v3-counterparty"]) +# ── what build_receipt actually signs ───────────────────────────────────────── +# Without these, the vector checks below compare an independent implementation +# against a shape assembled in this file, and a rename inside build_receipt would +# leave every vector passing while no signature verified against anything. + +@pytest.mark.parametrize("schema_version,context", [("v2", None), ("v3", CONTEXT)]) +def test_build_receipt_signs_the_documented_entry_shape(schema_version, context): + model_key, cp_key = _keys() + over = {"schema_version": schema_version} + if context is not None: + over["context_commit"] = context + receipt = _build(**over) + + common = dict(model_hash=receipt["modelHash"], input_commit=receipt["inputCommit"], + output_commit=receipt["outputCommit"], context_commit=receipt.get("contextCommit")) + model_entry = _signed_entry(trace_commit=receipt["trace"]["traceCommit"], **common) + cp_entry = _signed_entry(**common) + + assert receipt["sigModel"] == model_key.sign(canonical_bytes(model_entry)) + assert receipt["sigCounterparty"] == cp_key.sign(canonical_bytes(cp_entry)) + + +def test_the_counterparty_signature_covers_the_context(): + """A counterparty signature that omitted the context would be transferable + between requests — the failure v3 exists to prevent.""" + model_key, cp_key = _keys() + receipt = _build(schema_version="v3", context_commit=CONTEXT) + unbound = _signed_entry(model_hash=receipt["modelHash"], input_commit=receipt["inputCommit"], + output_commit=receipt["outputCommit"]) + assert receipt["sigCounterparty"] != cp_key.sign(canonical_bytes(unbound)) + + +# ── the independent vectors ─────────────────────────────────────────────────── +# Parametrised over the fixture itself: a vector that is added but never asserted +# would be dead data claiming a coverage it does not have. + +@pytest.mark.parametrize("name", sorted(MESSAGES)) def test_signed_message_bytes_match_the_independent_vectors(name): - """The signed messages are rebuilt exactly as build_receipt assembles them, and - compared with bytes an independent implementation produced from the same inputs.""" case = MESSAGES[name] i = case["inputs"] - entry = {"modelHash": i["model_hash"], "inputCommit": i["input_commit"], - "outputCommit": i["output_commit"]} - if case["entry"] == "model": - entry["traceCommit"] = i["trace_commit"] - if i.get("context_commit") is not None: - entry["contextCommit"] = i["context_commit"] - assert canonical_bytes(entry).decode() == case["canonicalText"] + entry = _signed_entry(model_hash=i["model_hash"], input_commit=i["input_commit"], + output_commit=i["output_commit"], trace_commit=i.get("trace_commit"), + context_commit=i.get("context_commit")) + encoded = canonical_bytes(entry) + assert encoded.decode() == case["canonicalText"] + # the digest is what actually gets signed; pin it too, so a change in the + # encoder cannot be absorbed by an equally-changed expectation + assert hashlib.sha256(encoded).hexdigest() == case["signingDigest"] + + +@pytest.mark.parametrize("name", sorted(RECEIPTS)) +def test_receipt_hashes_match_the_independent_vectors(name): + case = RECEIPTS[name] + assert receipt_hash(case["body"]) == case["receiptHash"] + + +def test_the_anchored_v2_receipt_hash_is_pinned(): + """The one that cannot be allowed to move: a v2 receiptHash is anchored on-chain, + so it is asserted against a literal rather than against a recomputation of itself. + A test that only checks a receipt against its own hash passes just as happily + when both sides move together.""" + assert receipt_hash(RECEIPTS["v2-body"]["body"]) == \ + "034c423b293b1d2fae6a81984c1010721db006aba1ac1c6d4b4ea5b243da3202" + + +def test_a_v3_receipt_hash_ignores_a_receipt_hash_already_in_the_body(): + with_field = RECEIPTS["v3-body-hash-excludes-itself"]["body"] + without_field = RECEIPTS["v3-body"]["body"] + assert receipt_hash(with_field) == receipt_hash(without_field)