From 4a5cd0b227dd89d3861f055f35bb0cbb936735f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 06:02:13 +0000 Subject: [PATCH 1/4] fix(sdk): stop the unauthenticated envelope from choosing the verdict The offline verifier's core property is that everything deciding an outcome comes from the SIGNED bytes. Three fields broke it, and one edited byte was enough to exploit any of them. `alg` and `canonicalization` were read from the envelope and short-circuited to UNSUPPORTED *before* the signature was ever checked. So flipping `"alg": "Ed25519"` to `"RS256"` on a genuine, fully verifiable receipt produced: genuine, untouched -> verified tampered=False envelope alg -> RS256 -> unsupported tampered=False envelope canonicalization -> v2 -> unsupported tampered=False UNSUPPORTED is a statement about the verifier's own capability. The attacker was choosing it. That is the integrity/capability inversion the six-state taxonomy exists to prevent, and it reached the public MCP tool, where the model is told verbatim that the result is "not evidence of tampering." `kid` had the same shape, already documented: key SELECTION came from the envelope, so a relabelled bundle resolved to INVALID (or, worse, to UNKNOWN_KEY -- a genuine receipt made to look like a key-distribution problem). Fix: parse the signed payload first, select the key by its `kid`, and gate capability on its `alg`/`canonicalization`. If the signature then verifies, that kid is authenticated retroactively. Envelope values are still checked, but afterwards, and disagreement is MISMATCH -- a finding about the bundle -- never UNSUPPORTED. Payload-first selection lost one detection, so it is restored explicitly: when the payload names a kid we do not hold, we now check whether the envelope names one we do that actually signs those bytes. If so the bundle is provably self-contradicting and that is a verdict (MISMATCH), not a gap. Without this a forger could name a nonexistent kid to downgrade a detectable inconsistency into "refresh your key set and try again". Caller-visible signal, which had to change with it. Moving attacks out of INVALID and into MISMATCH would have made them *quieter*, since MISMATCH set no tampering flag and the CLI mapped it to exit 2, "could not determine" -- a caught forgery reported as an outage. But widening `is_tampered` to cover MISMATCH was wrong too: MISMATCH also carries caller-expectation failures like `expected_issuer`, and calling those forgery is the same category error in the opposite direction. It also made the MCP endpoint render "its signature does not hold" for a check that runs before verification. So `is_tampered` stays narrow (INVALID only, a claim that cryptography failed), a new `is_rejected` covers INVALID+MISMATCH for the accept/reject decision most callers actually want, the CLI branches on `is_rejected`, and the endpoint checks MISMATCH before is_tampered so it keeps its accurate wording. ## Tests that encoded the defect Five failed. None was fixed by weakening it. - Two SDK cases asserted envelope-only alg/canonicalization -> UNSUPPORTED with the comment "None of these are evidence of tampering", on bundles with a deliberately bogus signature. Replaced with the same values declared in the SIGNED payload (still resolving before any signature check, so the file's no-cryptography constraint holds), plus a new test asserting the envelope specifically cannot downgrade the status. - `test_unknown_key_is_not_reported_as_tampering` relabelled only the envelope, so the plane did hold the key and the receipt was genuine. Now names an unpublished kid inside the signed bytes -- the real unknown-key case -- and a new test pins the relabelled-envelope case to MISMATCH. - `test_preflight_failures_do_not_touch_the_key_store` used envelope alg to stand in for a cheap rejection. An envelope-declared value is no longer cheaply rejectable, by design; the fixtures now declare it in the payload. - `test_signature_under_a_key_the_payload_does_not_name_is_rejected` caught the real regression described above, and is passing on its original assertions. ## Validation - pytest 1732 passed, 19 skipped (app + b2a_sdk) - ruff clean; mypy clean across 172 source files - taxonomy re-checked end to end with a live Ed25519 signer: genuine -> verified; envelope alg/canonicalization/kid/receipt_id edits -> mismatch (is_rejected); payload tampering -> invalid; absent key -> unknown_key (not rejected) Found by an audit sweep of envelope-vs-signed-payload trust boundaries. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NVvJpNRU9wp3WXowfeqtPU --- app/routers/mcp_public.py | 14 ++- b2a_sdk/src/b2a_sdk/receipt_verifier.py | 159 +++++++++++++++++++----- b2a_sdk/src/b2a_sdk/verify_cli.py | 12 +- b2a_sdk/tests/test_receipt_verifier.py | 51 +++++++- tests/test_public_mcp_endpoint.py | 66 +++++++++- 5 files changed, 257 insertions(+), 45 deletions(-) diff --git a/app/routers/mcp_public.py b/app/routers/mcp_public.py index a13439db..b3c14ab2 100644 --- a/app/routers/mcp_public.py +++ b/app/routers/mcp_public.py @@ -448,17 +448,21 @@ async def _tool_verify_receipt( "signed claims are in structuredContent.claims; report only " "those fields as attested." ) - elif result.is_tampered: - text = ( - f"INVALID — {result.reason}. The bundle is well-formed but its " - "signature does not hold; treat it as altered or forged." - ) elif result.status is _receipt_verifier.VerificationStatus.MISMATCH: + # Checked before is_tampered: a MISMATCH is a rejection, but saying its + # "signature does not hold" would be false. The signature may well have + # verified -- what failed is agreement between the signed bytes and the + # envelope around them, or an explicit caller expectation. text = ( f"CANNOT ACCEPT (mismatch) — {result.reason}. The evidence or " "caller expectation is internally inconsistent; this does not " "mean the signature failed." ) + elif result.is_tampered: + text = ( + f"INVALID — {result.reason}. The bundle is well-formed but its " + "signature does not hold; treat it as altered or forged." + ) else: text = ( f"CANNOT VERIFY ({result.status.value}) — {result.reason}. This " diff --git a/b2a_sdk/src/b2a_sdk/receipt_verifier.py b/b2a_sdk/src/b2a_sdk/receipt_verifier.py index fe7b82ce..e3db68ce 100644 --- a/b2a_sdk/src/b2a_sdk/receipt_verifier.py +++ b/b2a_sdk/src/b2a_sdk/receipt_verifier.py @@ -105,9 +105,36 @@ def is_tampered(self) -> bool: """True only for a bundle that is well-formed but does not verify. Distinct from :attr:`ok` being False, which also covers "cannot tell". + + Deliberately narrow: this is a claim that a *signature failed*. It does + not cover ``MISMATCH``, because ``MISMATCH`` also carries caller- + expectation failures such as ``expected_issuer`` -- and reporting "you + asked for a different issuer" as forgery would be the same category + error, in the opposite direction, as reporting a missing key as + tampering. Use :attr:`is_rejected` to decide whether to accept a + bundle; use this only to assert that the cryptography failed. """ return self.status is VerificationStatus.INVALID + @property + def is_rejected(self) -> bool: + """True when the bundle must not be accepted as evidence. + + The verifier reached a verdict and the verdict is no. Covers a failed + signature (``INVALID``) and a bundle that does not hold together + (``MISMATCH`` -- the signature verified but the envelope, the signed + payload, or an explicit caller expectation disagree). + + This is the property most callers want. It is distinct from ``not ok``, + which additionally sweeps in ``UNKNOWN_KEY``, ``MALFORMED`` and + ``UNSUPPORTED`` -- the "I cannot judge this" statuses, where the right + response is to fetch keys or upgrade, not to allege fraud. + """ + return self.status in ( + VerificationStatus.INVALID, + VerificationStatus.MISMATCH, + ) + def canonical_json(payload: Any) -> str: """Serialize already-decoded JSON the way the trust plane signs it. @@ -286,60 +313,86 @@ def verify_bundle( signing_input = bundle.get("signing_input") signature_b64 = bundle.get("signature") - key_id = bundle.get("kid") + envelope_key_id = bundle.get("kid") if not isinstance(signing_input, str) or not signing_input: return _fail(VerificationStatus.MALFORMED, "bundle has no signing_input") if not isinstance(signature_b64, str) or not signature_b64: return _fail(VerificationStatus.MALFORMED, "bundle has no signature") - if not isinstance(key_id, str) or not key_id: + if not isinstance(envelope_key_id, str) or not envelope_key_id: return _fail(VerificationStatus.MALFORMED, "bundle has no kid") - algorithm = bundle.get("alg", "Ed25519") - if algorithm != "Ed25519": - return _fail( - VerificationStatus.UNSUPPORTED, - f"unsupported signature algorithm: {algorithm!r}", - key_id=key_id, - ) - - declared = bundle.get("canonicalization", CANONICALIZATION) - if declared != CANONICALIZATION: - # Verifying under rules the payload was not signed with would produce a - # confident answer to the wrong question. - return _fail( - VerificationStatus.UNSUPPORTED, - f"unsupported canonicalization: {declared!r} (this verifier " - f"implements {CANONICALIZATION!r})", - key_id=key_id, - ) - if expected_issuer is not None and bundle.get("issuer") != expected_issuer: return _fail( VerificationStatus.MISMATCH, f"issuer mismatch: bundle claims {bundle.get('issuer')!r}", - key_id=key_id, + key_id=envelope_key_id, ) - # Parse the signed bytes before checking the signature so a malformed - # payload is reported as malformed rather than as a forgery. + # Parse the signed bytes before anything else that can decide an outcome. + # + # Ordering is the whole security property here. The envelope is + # unauthenticated: anyone in the transport path can edit it. So the + # capability gates below (algorithm, canonicalization) and the key + # selection must read the *signed* payload, never the envelope. An earlier + # version read `alg` and `canonicalization` from the envelope and + # short-circuited to UNSUPPORTED before the signature was ever checked, + # which let one edited byte turn a genuine receipt into "your verifier is + # too old" -- an attacker-chosen status, and the exact integrity/capability + # inversion this taxonomy exists to prevent. Envelope values are still + # checked, but afterwards, and disagreement is MISMATCH (a finding about + # the bundle) rather than UNSUPPORTED (a statement about this verifier). try: payload = json.loads(signing_input) except (json.JSONDecodeError, UnicodeDecodeError, RecursionError, ValueError): return _fail( VerificationStatus.MALFORMED, "signing_input is not valid JSON", - key_id=key_id, + key_id=envelope_key_id, ) if not isinstance(payload, dict): return _fail( VerificationStatus.MALFORMED, "signing_input is not a JSON object", - key_id=key_id, + key_id=envelope_key_id, ) if _contains_invalid_unicode(payload): return _fail( VerificationStatus.MALFORMED, "signing_input contains invalid Unicode text", + key_id=envelope_key_id, + ) + + # Select the verification key by the kid inside the signed bytes. If the + # signature then verifies, that kid is authenticated retroactively, so a + # relabelled envelope cannot steer key selection at all. + key_id = payload.get("kid") + if not isinstance(key_id, str) or not key_id: + return _fail( + VerificationStatus.MALFORMED, + "signed payload has no kid", + key_id=envelope_key_id, + ) + + algorithm = payload.get("alg", "Ed25519") + if algorithm != "Ed25519": + # Signer-declared, and therefore a genuine statement about what this + # verifier can do. + return _fail( + VerificationStatus.UNSUPPORTED, + f"unsupported signature algorithm: {algorithm!r}", + key_id=key_id, + ) + + signed_canonicalization = payload.get("canonicalization") + if isinstance(signed_canonicalization, str) and signed_canonicalization != CANONICALIZATION: + # Verifying under rules the payload was not signed with would produce a + # confident answer to the wrong question. Only trusted because it is + # signed; receipts predating this field carry no counterpart and are + # handled by the envelope cross-check after verification. + return _fail( + VerificationStatus.UNSUPPORTED, + f"unsupported canonicalization: {signed_canonicalization!r} (this " + f"verifier implements {CANONICALIZATION!r})", key_id=key_id, ) @@ -359,6 +412,28 @@ def verify_bundle( raw_public_key = key_set.get(key_id) if raw_public_key is None: + # The payload names a key this verifier does not hold. Normally that is + # "cannot judge" -- but first check whether the envelope points at a + # key we *do* hold that actually signs these bytes. If it does, the + # bundle is provably self-contradicting (signed under one key while + # claiming another), and that is a verdict, not a gap. Skipping this + # would let a forger name a nonexistent kid to downgrade a detectable + # inconsistency into "refresh your key set and try again". + envelope_key = key_set.get(envelope_key_id) if envelope_key_id != key_id else None + if envelope_key is not None and len(envelope_key) == _ED25519_RAW_LEN: + try: + probe_bytes = signing_input.encode("utf-8") + except UnicodeEncodeError: + probe_bytes = None + if probe_bytes is not None and _verify_ed25519_signature( + envelope_key, signature, probe_bytes + ): + return _fail( + VerificationStatus.MISMATCH, + f"signed under kid {envelope_key_id!r} but the signed " + f"payload names kid {key_id!r}", + key_id=key_id, + ) # Not a verdict on the receipt: this verifier simply does not hold the # key. Refresh the key set from the issuer before drawing conclusions. return _fail( @@ -392,12 +467,36 @@ def verify_bundle( key_id=key_id, ) - # The signature holds. Remaining checks are internal consistency of the - # signed payload itself. - if payload.get("kid") != key_id: + # The signature holds, so the signed payload is now authenticated ground + # truth. Every envelope value that claims to describe it is checked against + # it here. Disagreement is a finding about the bundle -- MISMATCH -- never + # UNSUPPORTED, because at this point the verifier demonstrably *could* + # judge the evidence. + if envelope_key_id != key_id: + return _fail( + VerificationStatus.MISMATCH, + "bundle names a different kid than the signed payload", + key_id=key_id, + ) + + envelope_algorithm = bundle.get("alg", "Ed25519") + if envelope_algorithm != algorithm: + return _fail( + VerificationStatus.MISMATCH, + f"bundle claims algorithm {envelope_algorithm!r} but the signed " + f"payload declares {algorithm!r}", + key_id=key_id, + ) + + envelope_canonicalization = bundle.get("canonicalization", CANONICALIZATION) + expected_canonicalization = ( + signed_canonicalization if isinstance(signed_canonicalization, str) else CANONICALIZATION + ) + if envelope_canonicalization != expected_canonicalization: return _fail( VerificationStatus.MISMATCH, - "signed payload names a different kid than the bundle", + f"bundle claims canonicalization {envelope_canonicalization!r} but " + f"the signed payload verifies under {expected_canonicalization!r}", key_id=key_id, ) diff --git a/b2a_sdk/src/b2a_sdk/verify_cli.py b/b2a_sdk/src/b2a_sdk/verify_cli.py index 26cb46a3..02d22296 100644 --- a/b2a_sdk/src/b2a_sdk/verify_cli.py +++ b/b2a_sdk/src/b2a_sdk/verify_cli.py @@ -14,7 +14,9 @@ === ========================================================== 0 signature verified -1 well-formed bundle that does not verify — treat as tampered +1 well-formed bundle that does not hold together — treat as + tampered (failed signature, or a signature that verified over + bytes contradicting the envelope around them) 2 could not determine (unknown key, malformed input, bad usage) === ========================================================== @@ -32,7 +34,6 @@ from .receipt_verifier import ( VerificationError, - VerificationStatus, key_set_from_document, verify_bundle, ) @@ -179,7 +180,12 @@ def main(argv: list[str] | None = None) -> int: if result.ok: return EXIT_VERIFIED - if result.status is VerificationStatus.INVALID: + if result.is_rejected: + # MISMATCH belongs here, not in EXIT_UNDETERMINED. It is a determined + # rejection -- the signature verified and the signed bytes then + # contradicted the envelope -- which is exactly the relabelling attack + # this tool exists to catch. Routing it to "could not determine" told + # a branching script to treat a caught forgery as an outage. return EXIT_INVALID return EXIT_UNDETERMINED diff --git a/b2a_sdk/tests/test_receipt_verifier.py b/b2a_sdk/tests/test_receipt_verifier.py index 7b829907..f5f5a1f7 100644 --- a/b2a_sdk/tests/test_receipt_verifier.py +++ b/b2a_sdk/tests/test_receipt_verifier.py @@ -119,8 +119,25 @@ def test_key_set_rejects_a_document_with_no_keys_list(): ({"kid": ""}, VerificationStatus.MALFORMED), ({"signing_input": "not json"}, VerificationStatus.MALFORMED), ({"signing_input": "[1,2]"}, VerificationStatus.MALFORMED), - ({"alg": "RS256"}, VerificationStatus.UNSUPPORTED), - ({"canonicalization": "something-else/9"}, VerificationStatus.UNSUPPORTED), + # UNSUPPORTED must be driven by what the SIGNER declared, inside the + # signed bytes -- never by the envelope. These still resolve before any + # signature check, so they need no key material. + ( + {"signing_input": json.dumps({"receipt_id": "rcpt-1", "kid": "key-1", "alg": "RS256"})}, + VerificationStatus.UNSUPPORTED, + ), + ( + { + "signing_input": json.dumps( + { + "receipt_id": "rcpt-1", + "kid": "key-1", + "canonicalization": "something-else/9", + } + ) + }, + VerificationStatus.UNSUPPORTED, + ), ], ) def test_unusable_bundles_report_why(overrides, expected): @@ -132,6 +149,36 @@ def test_unusable_bundles_report_why(overrides, expected): assert not result.is_tampered +@pytest.mark.parametrize( + "overrides", + [ + {"alg": "RS256"}, + {"alg": None}, + {"canonicalization": "something-else/9"}, + ], +) +def test_envelope_cannot_downgrade_a_bundle_to_unsupported(overrides): + """An edited envelope must not let an attacker pick the status. + + ``alg`` and ``canonicalization`` used to be read from the unauthenticated + envelope and short-circuit to UNSUPPORTED *before* the signature was + checked. One edited byte therefore turned any bundle into "this verifier is + too old" -- a statement about the verifier's capability, chosen by whoever + edited the bytes, and carrying ``is_tampered = False``. + + The signed payload here declares Ed25519 and no canonicalization, so the + envelope is the only thing claiming otherwise. The verifier must ignore it + for the capability decision and go on to judge the signature. + """ + result = verify_bundle(_bundle(**overrides), {"key-1": b"\x00" * 32}) + + assert result.status is not VerificationStatus.UNSUPPORTED + # This fixture's signature is 64 zero bytes, so the honest answer is that + # the signature does not verify. + assert result.status is VerificationStatus.INVALID + assert result.is_tampered + + def test_missing_key_reports_unknown_key_not_invalid(): result = verify_bundle(_bundle(), {}) diff --git a/tests/test_public_mcp_endpoint.py b/tests/test_public_mcp_endpoint.py index 8592b71a..ef29bedf 100644 --- a/tests/test_public_mcp_endpoint.py +++ b/tests/test_public_mcp_endpoint.py @@ -621,9 +621,35 @@ async def test_expected_issuer_mismatch_is_not_a_signature_verdict( ("bundle", "expected_status"), [ ("not-json", "malformed"), - ({**_unverifiable_bundle(), "alg": "RSA"}, "unsupported"), + # The unsupported cases must be declared inside the SIGNED bytes. + # Reading them from the envelope is what let an edited byte downgrade a + # genuine receipt to "this verifier is too old", so the envelope is no + # longer trusted for that decision -- which also means an envelope-only + # bad alg is no longer cheaply rejectable, by design. ( - {**_unverifiable_bundle(), "canonicalization": "unknown/1"}, + { + **_unverifiable_bundle(), + "signing_input": json.dumps( + { + "receipt_id": "rcpt-no-key", + "kid": "no-published-key", + "alg": "RSA", + } + ), + }, + "unsupported", + ), + ( + { + **_unverifiable_bundle(), + "signing_input": json.dumps( + { + "receipt_id": "rcpt-no-key", + "kid": "no-published-key", + "canonicalization": "unknown/1", + } + ), + }, "unsupported", ), ], @@ -716,12 +742,17 @@ async def test_verify_receipt_rejects_a_tampered_bundle(client, public_mcp_enabl @pytest.mark.anyio async def test_unknown_key_is_not_reported_as_tampering(client, public_mcp_enabled): - bundle = await _signed_bundle() await get_signing_key_service().ensure_active_key() - relabeled = {**bundle, "kid": "kid-that-does-not-exist"} + # A key this plane genuinely does not publish, named inside the SIGNED + # bytes, is the real "unknown key" case. + unknown = await _signed_bundle() + payload = json.loads(unknown["signing_input"]) + payload["kid"] = "kid-that-does-not-exist" + unknown["signing_input"] = json.dumps(payload) + unknown["kid"] = "kid-that-does-not-exist" resp = await client.post( PUBLIC_PATH, - json=_call("verify_receipt", {"bundle": relabeled}), + json=_call("verify_receipt", {"bundle": unknown}), headers=MCP_HEADERS, ) structured = resp.json()["result"]["structuredContent"] @@ -731,6 +762,31 @@ async def test_unknown_key_is_not_reported_as_tampering(client, public_mcp_enabl assert "not evidence of tampering" in text.lower() +@pytest.mark.anyio +async def test_relabelled_envelope_kid_is_a_mismatch_not_an_unknown_key( + client, public_mcp_enabled +): + """Editing only the envelope must not look like a key-distribution problem. + + The signed payload still names the real key, so this plane holds it and the + receipt is genuine. Reporting UNKNOWN_KEY here would let anyone in the + transport path turn a valid receipt into "refresh your key set" with one + edited field -- an availability story for what is actually an altered + bundle. + """ + bundle = await _signed_bundle() + await get_signing_key_service().ensure_active_key() + relabeled = {**bundle, "kid": "kid-that-does-not-exist"} + resp = await client.post( + PUBLIC_PATH, + json=_call("verify_receipt", {"bundle": relabeled}), + headers=MCP_HEADERS, + ) + structured = resp.json()["result"]["structuredContent"] + assert structured["ok"] is False + assert structured["status"] == "mismatch" + + @pytest.mark.anyio async def test_verify_receipt_without_bundle_is_invalid_params( client, public_mcp_enabled From 7b97e5a2476269eeda963c506a05584e744732b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 06:10:18 +0000 Subject: [PATCH 2/4] fix(awi): key the governed HTTP charge to its idempotency record The governed AWI HTTP path called money.charge() with no operation_key, so it had neither the uq_ledger_wallet_operation_key constraint nor the adopt-the-existing-debit recovery on IntegrityError that the governed MCP path relies on. Unlike the read-modify-write races elsewhere in this codebase, this one does not depend on a row lock being a no-op -- it is exposed on PostgreSQL too. The failure branch is what makes it bite. If the charge commits but its acknowledgement is lost -- a cancelled coroutine, a connection drop -- the `except Exception` handler releases the permit budget and calls abort_awi_http_governed, which *completes* the idempotency record with {"error": "charge_failed"} and a 500. The caller's key now replays a permanent error, so it retries under a fresh key and is debited a second time for one logical action. Because response_json is non-NULL, both reconciler branches skip the record, so nothing ever flags the orphan. Six governed endpoints route through this path. The fix threads the idempotency record id onto AwiHttpGovernedContext (begin -> begin_with_record) and passes it as operation_key, so a retry adopts the first durable debit instead of creating one -- the same database-evaluated guarantee the MCP path already had. ## The regression test fails without the fix test_awi_route_keys_its_debit_to_the_idempotency_record drives the real route and asserts the debit carries the request's record id, and that no governed AWI charge lands with a NULL operation_key. Asserted on the route rather than on money.charge directly, and that distinction matters: an earlier draft of this test exercised the charge mechanism in isolation and would have passed even with the wiring missing, because the mechanism was never the broken part -- this caller simply did not use it. Against the unfixed service the route test fails with `assert 0 == 1`. ## Validation - pytest 1733 passed, 19 skipped (app + b2a_sdk) - test confirmed to fail against the pre-fix service (output above) - ruff clean; mypy clean across 172 source files Found by an audit sweep of the money and metering paths. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NVvJpNRU9wp3WXowfeqtPU --- app/services/awi_http_governance.py | 22 +++++++- tests/test_awi_http_governance.py | 78 +++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/app/services/awi_http_governance.py b/app/services/awi_http_governance.py index b16c6603..c18d6ef4 100644 --- a/app/services/awi_http_governance.py +++ b/app/services/awi_http_governance.py @@ -57,6 +57,10 @@ class AwiHttpGovernedContext: credits: Decimal permit: PermitModel endpoint: str + #: Identity of this request's idempotency record. Used as the ledger + #: ``operation_key`` so a retried charge is deduplicated by the database + #: rather than by hoping the first attempt's outcome was observed. + record_id: str | None = None replay_response: dict[str, Any] | None = None replay_status_code: int | None = None @@ -90,6 +94,7 @@ def _context_from_validation( credits: Decimal, permit: PermitModel, endpoint: str, + record_id: str | None = None, replay: IdempotencyReplay | None = None, ) -> AwiHttpGovernedContext: return AwiHttpGovernedContext( @@ -101,6 +106,7 @@ def _context_from_validation( credits=credits, permit=permit, endpoint=endpoint, + record_id=record_id, replay_response=replay.response_json if replay else None, replay_status_code=replay.status_code if replay else None, ) @@ -178,7 +184,7 @@ async def begin_awi_http_governed( idem = get_idempotency_service() try: - replay = await idem.begin( + begun = await idem.begin_with_record( wallet_id=wallet_id, endpoint=endpoint, idempotency_key=idempotency_key.strip(), @@ -189,6 +195,8 @@ async def begin_awi_http_governed( "permit_id": permit_id.strip(), }, ) + replay = begun.replay + record_id = begun.record_id except (IdempotencyConflictError, IdempotencyInProgressError) as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, @@ -205,6 +213,7 @@ async def begin_awi_http_governed( credits=credits, permit=validation.permit, endpoint=endpoint, + record_id=record_id, replay=replay, ) @@ -217,6 +226,7 @@ async def begin_awi_http_governed( credits=credits, permit=validation.permit, endpoint=endpoint, + record_id=record_id, ) @@ -248,6 +258,16 @@ async def complete_awi_http_governed( units=charge_units, request_path=ctx.endpoint, description=f"AWI HTTP {ctx.tool_name}", + # Key the debit to this request's durable identity. Without it this + # path had neither the uq_ledger_wallet_operation_key constraint nor + # the adopt-the-existing-debit recovery that the governed MCP path + # relies on -- so a charge that committed but whose acknowledgement + # was lost took the except branch below, which releases budget and + # *completes* the idempotency record as charge_failed. The caller + # then retried under a fresh key and was debited a second time for + # one logical action. With the key, the second attempt adopts the + # first durable debit instead of creating one. + operation_key=ctx.record_id, ) except Exception as exc: await permits.release_budget(ctx.permit_id, ctx.credits) diff --git a/tests/test_awi_http_governance.py b/tests/test_awi_http_governance.py index 0c760816..99e32c9f 100644 --- a/tests/test_awi_http_governance.py +++ b/tests/test_awi_http_governance.py @@ -5,8 +5,11 @@ from decimal import Decimal import pytest +from sqlalchemy import func, select from httpx import ASGITransport, AsyncClient +from app.db.database import get_session_factory +from app.db.models import IdempotencyRecordModel, LedgerEntryModel from app.main import app from tests.test_trust_helpers import ( BOOTSTRAP_HEADERS, @@ -141,3 +144,78 @@ async def test_execute_denied_without_wallet_scoped_session(client, clean_databa ) assert resp.status_code == 400 assert resp.json()["detail"]["error"] == "wallet_required" + + +@pytest.mark.anyio +async def test_awi_route_keys_its_debit_to_the_idempotency_record( + client, clean_database +): + """The governed AWI route must key its debit to the request's identity. + + This path used to call ``money.charge()`` with no ``operation_key``, so it + had neither the ``uq_ledger_wallet_operation_key`` constraint nor the + adopt-the-existing-debit recovery the governed MCP path relies on. A charge + that committed but whose acknowledgement was lost took the failure branch, + which releases the permit budget and *completes* the idempotency record as + ``charge_failed`` -- so the caller retried under a fresh key and paid twice + for one logical action. + + Asserted on the real route rather than on ``money.charge`` directly, + because the defect was the wiring: the mechanism already existed and this + caller simply did not use it. + """ + provisioned = await provision_agent_wallet(client) + permit = await create_tool_permit( + client, + wallet_id=provisioned["agent_wallet_id"], + key_id=provisioned["key_id"], + tool_name="awi_rag_query", + max_credits=50, + idem_key="permit-awi-opkey", + ) + resp = await client.post( + "/v1/awi/rag/query", + json={"query": "laptops", "top_k": 3}, + headers={ + **provisioned["agent_headers"], + "X-Wallet-Id": provisioned["agent_wallet_id"], + "X-Permit-Id": permit["permit_id"], + "Idempotency-Key": "awi-opkey-1", + }, + ) + assert resp.status_code == 200, resp.text + + factory = get_session_factory() + async with factory() as session: + record_id = ( + await session.execute( + select(IdempotencyRecordModel.record_id).where( + IdempotencyRecordModel.wallet_id == provisioned["agent_wallet_id"], + IdempotencyRecordModel.idempotency_key == "awi-opkey-1", + ) + ) + ).scalar_one() + keyed = ( + await session.execute( + select(func.count()) + .select_from(LedgerEntryModel) + .where(LedgerEntryModel.operation_key == record_id) + ) + ).scalar_one() + unkeyed = ( + await session.execute( + select(func.count()) + .select_from(LedgerEntryModel) + .where( + LedgerEntryModel.wallet_id == provisioned["agent_wallet_id"], + LedgerEntryModel.action == "charge", + LedgerEntryModel.operation_key.is_(None), + ) + ) + ).scalar_one() + + assert keyed == 1, ( + "the AWI debit is not keyed to its idempotency record, so a retry " + "after a lost acknowledgement would debit again" + ) + assert unkeyed == 0, "a governed AWI charge landed with no operation_key" From fd1a32e9c93d5b0fd293742c4c63c55f94d5cc6f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 06:36:11 +0000 Subject: [PATCH 3/4] test(sdk): move the envelope-downgrade proof to the suite that can verify The three cases I added to b2a_sdk/tests/test_receipt_verifier.py asserted `status is INVALID`, which requires actually verifying a signature. That file is explicitly for cases that resolve *before* any signature check, so it runs against a bare SDK install without the `verify` extra -- its docstring says so. The python_sdk CI matrix installs the bare wheel, so all four legs failed with "Ed25519 verification requires the 'cryptography' package". They passed locally only because cryptography is installed here. Moved to tests/test_receipt_portability.py, which has a real signing helper and always has cryptography. That also makes the tests stronger than what they replaced: instead of asserting "not UNSUPPORTED" on a bundle with a deliberately bogus signature, they now build a genuinely VERIFIED bundle, assert the baseline verifies untouched, and then show one edited envelope byte turns it into MISMATCH rather than an attacker-chosen UNSUPPORTED. Two fixture bugs found and fixed while writing them: - `_signed_bundle` takes `kid` as a keyword-only parameter, so passing it again through **overrides raises TypeError. The bundle is now built and then mutated. - `_local_signer()` hardcodes its kid, so calling it twice yields one kid with the second key silently overwriting the first in a merged key set. The relabelling test was therefore failing because the wrong key was selected, not because of relabelling -- proving nothing. It now mints a second key under a genuinely distinct kid. The assertions use `is_rejected`, not `is_tampered`. MISMATCH deliberately does not set `is_tampered`, because it also covers caller-expectation failures such as `expected_issuer`, and calling those forgery would be the same category error as reporting a missing key as tampering. Validation: pytest 1751 passed, 19 skipped; ruff clean; the SDK test file re-checked with cryptography blocked, and none of the remaining failures are in it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NVvJpNRU9wp3WXowfeqtPU --- b2a_sdk/tests/test_receipt_verifier.py | 30 -------- tests/test_receipt_portability.py | 94 ++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/b2a_sdk/tests/test_receipt_verifier.py b/b2a_sdk/tests/test_receipt_verifier.py index f5f5a1f7..fa04ee3d 100644 --- a/b2a_sdk/tests/test_receipt_verifier.py +++ b/b2a_sdk/tests/test_receipt_verifier.py @@ -149,36 +149,6 @@ def test_unusable_bundles_report_why(overrides, expected): assert not result.is_tampered -@pytest.mark.parametrize( - "overrides", - [ - {"alg": "RS256"}, - {"alg": None}, - {"canonicalization": "something-else/9"}, - ], -) -def test_envelope_cannot_downgrade_a_bundle_to_unsupported(overrides): - """An edited envelope must not let an attacker pick the status. - - ``alg`` and ``canonicalization`` used to be read from the unauthenticated - envelope and short-circuit to UNSUPPORTED *before* the signature was - checked. One edited byte therefore turned any bundle into "this verifier is - too old" -- a statement about the verifier's capability, chosen by whoever - edited the bytes, and carrying ``is_tampered = False``. - - The signed payload here declares Ed25519 and no canonicalization, so the - envelope is the only thing claiming otherwise. The verifier must ignore it - for the capability decision and go on to judge the signature. - """ - result = verify_bundle(_bundle(**overrides), {"key-1": b"\x00" * 32}) - - assert result.status is not VerificationStatus.UNSUPPORTED - # This fixture's signature is 64 zero bytes, so the honest answer is that - # the signature does not verify. - assert result.status is VerificationStatus.INVALID - assert result.is_tampered - - def test_missing_key_reports_unknown_key_not_invalid(): result = verify_bundle(_bundle(), {}) diff --git a/tests/test_receipt_portability.py b/tests/test_receipt_portability.py index 7e0c570c..d0a65886 100644 --- a/tests/test_receipt_portability.py +++ b/tests/test_receipt_portability.py @@ -697,3 +697,97 @@ def test_cli_emits_machine_readable_output(tmp_path, capsys): assert emitted["ok"] is True assert emitted["status"] == "verified" assert emitted["claims"]["tool"] == "search" + + +@pytest.mark.parametrize( + "overrides,label", + [ + ({"alg": "RS256"}, "algorithm"), + ({"canonicalization": "awi-canonical-json/2"}, "canonicalization"), + ], +) +def test_envelope_cannot_downgrade_a_genuine_receipt_to_unsupported(overrides, label): + """An edited envelope must not let an attacker choose the verdict. + + ``alg`` and ``canonicalization`` were read from the unauthenticated + envelope and short-circuited to UNSUPPORTED *before* the signature was + checked. So one edited byte on a genuine, fully-verifiable receipt + produced "this verifier is too old" -- a statement about the verifier's + own capability, chosen by whoever edited the bytes, carrying + ``is_tampered = False``. + + That is the integrity/capability inversion the six-state taxonomy exists + to prevent, and it is a deniability primitive: anyone on the transport + path could convert a VERIFIED receipt into something an operator resolves + by upgrading rather than by escalating. + + The bundle here is genuinely signed and verifies untouched, so the only + thing claiming otherwise is the envelope. + """ + kid, key_set, sign = _local_signer() + payload = _hashed_payload( + {"receipt_id": "rcpt-downgrade", "kid": kid, "alg": "Ed25519"} + ) + + baseline = verify_bundle(_signed_bundle(payload, kid=kid, sign=sign), key_set) + assert baseline.status is VerificationStatus.VERIFIED, ( + "fixture must verify untouched, or the test proves nothing" + ) + + result = verify_bundle( + _signed_bundle(payload, kid=kid, sign=sign, **overrides), key_set + ) + + assert result.status is not VerificationStatus.UNSUPPORTED, ( + f"an edited envelope {label} downgraded a genuine receipt to " + "UNSUPPORTED, which reports a tampered bundle as a verifier limitation" + ) + assert result.status is VerificationStatus.MISMATCH + # The envelope disagreeing with signed content means the bundle must not + # be accepted. Deliberately is_rejected rather than is_tampered: MISMATCH + # also covers caller-expectation failures like expected_issuer, and + # calling those forgery would be the same category error in reverse. + assert result.is_rejected + assert not result.is_tampered + + +def test_envelope_kid_relabelling_is_a_mismatch_not_a_signature_failure(): + """Selecting the key from the signed payload makes relabelling inert. + + Key selection used to read the envelope's ``kid``, so relabelling a bundle + to a *different published* key made the verifier fetch the wrong key and + report INVALID -- indistinguishable from a forged payload, even though the + signed bytes were untouched. Selecting from the signed payload means the + envelope cannot steer key selection at all. + """ + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + kid, key_set, sign = _local_signer() + # A genuinely different published key. _local_signer() hardcodes its kid, + # so calling it twice yields one kid with the second key silently + # overwriting the first -- which makes the signature fail for the wrong + # reason and proves nothing about relabelling. + other_kid = "local-test-key-2" + other_public = ( + Ed25519PrivateKey.generate() + .public_key() + .public_bytes(Encoding.Raw, PublicFormat.Raw) + ) + key_set = {**key_set, other_kid: other_public} + + payload = _hashed_payload( + {"receipt_id": "rcpt-relabel", "kid": kid, "alg": "Ed25519"} + ) + # Sign under `kid`, then relabel the envelope to the other published key. + # Built and mutated rather than passed through **overrides, because + # _signed_bundle takes `kid` as a named parameter. + bundle = _signed_bundle(payload, kid=kid, sign=sign) + bundle["kid"] = other_kid + + result = verify_bundle(bundle, key_set) + + assert result.status is VerificationStatus.MISMATCH + assert result.is_rejected + # Reported against the key the signed bytes actually name. + assert result.key_id == kid From 21b45698120b64303f50abce1bcf7c28ab77bb42 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 07:36:31 +0000 Subject: [PATCH 4/4] docs(changelog): record the verifier and AWI charge fixes The verifier work changes behaviour the SDK changelog already documents: the Unreleased section promised `b2a-verify-receipt` exit codes "0 verified, 1 forged, 2 undetermined", which no longer describes what the CLI does now that MISMATCH is a rejection. Amend that entry, record the signed-payload authority rule and the new `is_rejected` property, and note that MISMATCH is a third outcome family alongside the integrity and capability ones. The app-side changes were undocumented entirely. Add the governed AWI `operation_key` fix and the public MCP `verify_receipt` wording change to the root changelog's Unreleased security section. No behaviour change; documentation only. --- CHANGELOG.md | 18 ++++++++++++++++++ b2a_sdk/CHANGELOG.md | 19 ++++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c19954a..2e9e278f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -317,6 +317,24 @@ full release gate; do not backfill a final `v1.2.0` tag. ### 🔒 Security +- **The governed AWI HTTP charge is now keyed to its idempotency record.** + `awi_http_governance.py` called `money.charge()` with no `operation_key`, so + the debit had neither the `uq_ledger_wallet_operation_key` constraint nor the + adopt-the-existing-debit recovery the governed MCP path relies on. If the + charge committed but its acknowledgement was lost, the handler released the + permit budget and *completed* the idempotency record as `charge_failed`; the + caller's key then replayed a permanent error, so a retry under a fresh key + was **debited a second time for one logical action**, and because + `response_json` was non-NULL neither reconciler branch ever flagged the + orphan. Six governed endpoints route through this path. Unlike the + read-modify-write races elsewhere in this codebase, this one did not depend + on a row lock being a no-op — it was exposed on PostgreSQL too. +- **The public MCP `verify_receipt` tool no longer describes a caught forgery + as a verifier limitation.** It reports `MISMATCH` before consulting + `is_tampered`, so a bundle whose envelope disagrees with its signed content + is not narrated to the model as a bad signature — or, previously, as + "CANNOT VERIFY (unsupported)". See `b2a_sdk/CHANGELOG.md` for the verifier + change this depends on. - **`.env.production` now sets `ENVIRONMENT=production`.** It omitted the variable entirely, so `Settings.ENVIRONMENT` fell back to its `"local"` default and `is_production_like_environment()` returned `False` — meaning a diff --git a/b2a_sdk/CHANGELOG.md b/b2a_sdk/CHANGELOG.md index d9462aa7..4f2c2eae 100644 --- a/b2a_sdk/CHANGELOG.md +++ b/b2a_sdk/CHANGELOG.md @@ -9,9 +9,22 @@ from inside them. - Distinguish a failed verification (`INVALID`) from an undetermined one (`UNKNOWN_KEY`, `MALFORMED`, `UNSUPPORTED`), so callers do not mistake a - stale key cache for tampering. -- Add the `b2a-verify-receipt` CLI, with exit codes `0` verified, `1` forged, - `2` undetermined. + stale key cache for tampering. A bundle that is internally inconsistent — + or that disagrees with a caller-supplied `expected_issuer` — is `MISMATCH`: + a finding about the bundle, distinct from both families. +- Read `kid`, `alg`, and `canonicalization` from the **signed** payload, never + from the surrounding envelope. The envelope is unauthenticated, so letting it + select the key or gate the capability checks would let one edited byte + downgrade a genuine receipt to `UNSUPPORTED` — an attacker-chosen "your + verifier is too old" that reads as a verifier problem rather than a bundle + problem. Envelope values are cross-checked *after* the signature verifies, + and any disagreement is reported as `MISMATCH`. +- Add `VerificationResult.is_rejected`, true for `INVALID` and `MISMATCH` — + the property callers should branch on for "do not trust this bundle". + `is_tampered` stays narrow (`INVALID` only), because `MISMATCH` also covers + an `expected_issuer` disagreement, which is not a claim about tampering. +- Add the `b2a-verify-receipt` CLI, with exit codes `0` verified, `1` rejected + (`INVALID` or `MISMATCH`), `2` undetermined. - Add the `verify` extra (`pip install "b2a-sdk[verify]"`) for the `cryptography` dependency. The base install is unchanged; importing `b2a_sdk` without the extra still works, and only signature checking