From 445a03bf635e487bf8d88b8dc47c23f7a13934cf Mon Sep 17 00:00:00 2001 From: desiorac Date: Sun, 13 Sep 2026 19:30:57 +0200 Subject: [PATCH 1/3] secu: ancrer le triplet d identite (spec 3.1) et l ouvrir aux tiers Mesure de depart, par execution : sur une preuve 3.0, passer agent_identity_verified de False a True et agent_identity de did:web:evil.example a did:web:trust.arkforge.tech laisse verify_proof_integrity a True et le chain hash inchange au bit pres. Les trois champs etaient servis publiquement et engages nulle part. - generate_proof engage le triplet dans chain_data, toujours, une identite absente engagee a null : l engager seulement quand il est present laisserait un emetteur omettre les champs et le scorer sans engagement a refuser - normalize_identity_verified : True ou None, jamais False, normalise une seule fois a la source pour que parties et chain_data ne puissent pas diverger (meme famille que le bug Redis du binding : ecrire dans un store, lire l autre) - get_public_proof publie disclosed (nonce + valeur des trois champs) et lit les champs plats depuis la donnee engagee : editer parties ne change plus rien de ce qu un lecteur voit - verify_proof_integrity refuse une preuve 3.1 sans engagement d identite - verify_proof.py : temoin "agent identity" distinct, et une ligne explicite sur une preuve anterieure a 3.1 qui declare une identite. Trouve en jouant le tiers, pas en relisant : le script ne disait rien du tout sur une preuve 2.0 et le silence sur une affirmation non adossee se lit comme un accord 791 tests verts, 7 mutations sur 7 tuees. Deux tests passaient pour la mauvaise raison (egalite triviale, racine recalculee) et ont ete corriges apres mutation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CzXFq94XGK7vcDgoSRsPFp --- CHANGELOG.md | 27 +++++ scripts/verify_proof.py | 73 +++++++++++- tests/test_commitments.py | 2 +- tests/test_identity.py | 23 ++-- tests/test_identity_anchoring.py | 197 +++++++++++++++++++++++++++++++ tests/test_integration.py | 4 +- tests/test_proofs.py | 2 +- tests/test_proxy.py | 2 +- tests/test_receipt.py | 17 ++- tests/test_spec_conformance.py | 50 +++++++- tests/test_verify_proof.py | 74 ++++++++++++ trust_layer/proofs.py | 56 ++++++++- 12 files changed, 503 insertions(+), 24 deletions(-) create mode 100644 tests/test_identity_anchoring.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7117e26..d7375da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ Versions follow [Semantic Versioning](https://semver.org/). --- +## [1.9.0] — 2026-09-13 + +### Added +- spec 3.1 : le bloc d'identité entre dans les champs engagés, toujours, un agent sans + identité l'engageant à `null`. Jusqu'à 3.0 il était servi publiquement et engagé nulle + part : hors racine Merkle, donc hors `hashes.chain`, hors signature Ed25519, donc hors + jeton RFC 3161 et hors Rekor. L'émetteur pouvait le réécrire après ancrage sans qu'aucun + témoin externe ne bouge +- `identity_consistent` engagé avec eux : c'est un jugement sur l'identité, l'ancrer à + côté de ses trois voisins évite de reconstruire le même trou un champ plus à gauche +- `disclosed` dans la vue publique : les nonces du bloc, publiés, pour que n'importe qui + ouvre l'identité et la recoupe avec l'engagement ancré +- `verify_proof.py` : témoin « agent identity » distinct, et une ligne explicite sur une + preuve antérieure à 3.1 qui déclare une identité — le silence sur une affirmation non + adossée se lit comme un accord +- vecteurs de conformité 13 et 14 de proof-spec 3.1 + +### Fixed +- les champs plats d'identité de la vue publique sont lus depuis la donnée engagée, plus + depuis `parties` : éditer `parties` ne change plus rien de ce qu'un lecteur voit + +### Changed +- `agent_identity_verified` vaut `True` ou `None`, jamais `False`, normalisé une seule + fois à la source pour que la valeur engagée et la valeur servie ne puissent pas diverger + +--- + ## [1.8.2] — 2026-09-13 ### Internal diff --git a/scripts/verify_proof.py b/scripts/verify_proof.py index 123b32a..40755c9 100755 --- a/scripts/verify_proof.py +++ b/scripts/verify_proof.py @@ -133,7 +133,12 @@ def strip_sha256(value): # preimage ambiguity of concatenation. Mirrors trust_layer/proofs.py:159. LEGACY_SPEC_VERSIONS = {"1.0", "1.1", "2.0", None} # Spec versions whose chain hash is the Merkle root of per-field commitments. -COMMITMENT_SPEC_VERSIONS = {"3.0"} +COMMITMENT_SPEC_VERSIONS = {"3.0", "3.1"} +# Spec versions that commit the identity triple and publish its nonces, so any third +# party opens it from the proof alone. Before 3.1 these three fields were served next +# to the proof but covered by no anchor. +IDENTITY_SPEC_VERSIONS = {"3.1"} +IDENTITY_FIELDS = ("agent_identity", "agent_identity_verified", "did_resolution_status") def _canonical_json(data): @@ -199,7 +204,10 @@ def check_commitments(proof, rep, disclosure): f"Merkle root of {len(commitments)} field commitments, recomputed from public " "data alone (proves nothing on its own)") - disclosed = (disclosure or {}).get("disclosed") or {} + # Spec 3.1 publishes the identity triple's nonces in the proof itself, so this + # opening needs no out-of-band material. An owner-supplied bundle adds to it. + disclosed = dict(proof.get("disclosed") or {}) + disclosed.update((disclosure or {}).get("disclosed") or {}) if not disclosed: return expected bad = [] @@ -224,6 +232,64 @@ def check_commitments(proof, rep, disclosure): return expected +def check_identity(proof, rep, disclosed, commitments): + """Spec 3.1: is the agent identity shown the one the anchors cover? + + Deliberately its own witness. The chain-hash line says the published commitments + reproduce the anchored root; this line says the identity VALUES served alongside + open those commitments. Before 3.1 the second half did not exist, and an Index + ranking agents on ``agent_identity_verified`` was ranking on the issuer's word. + + What this establishes is non-repudiation, not third-party verification of the + binding itself: no public artefact proves the Ed25519 challenge-response ever + happened. It proves the issuer cannot change its mind after anchoring. + """ + if proof.get("spec_version") not in IDENTITY_SPEC_VERSIONS: + claimed = proof.get("agent_identity") or proof.get("agent_identity_verified") + if not claimed: + return # nothing claimed, nothing to say + rep.add("agent identity", SKIP, + f"{proof.get('agent_identity') or 'identity'} declared " + f"(verified={proof.get('agent_identity_verified')!r}) but spec " + f"{proof.get('spec_version')} predates 3.1: these fields are covered by " + "no anchor, the issuer can restate them at will — NOT evidence") + return + missing = [f for f in IDENTITY_FIELDS if f not in commitments] + if missing: + rep.add("agent identity", FAIL, + "spec 3.1 proof with no commitment for " + ", ".join(missing)) + return + unopened = [f for f in IDENTITY_FIELDS if f not in disclosed] + if unopened: + rep.add("agent identity", FAIL, + "committed but not opened: " + ", ".join(unopened)) + return + bad = [] + for field in IDENTITY_FIELDS: + item = disclosed[field] + try: + recomputed = _commit(field, item["nonce"], item["value"]) + except (KeyError, ValueError, TypeError) as e: + bad.append(f"{field}: unusable triplet ({e})") + continue + if recomputed != strip_sha256(commitments[field]): + bad.append(f"{field}: served value does not match its anchored commitment") + if bad: + rep.add("agent identity", FAIL, "; ".join(bad)[:300]) + return + identity = disclosed["agent_identity"]["value"] + verified = disclosed["agent_identity_verified"]["value"] + status = disclosed["did_resolution_status"]["value"] + if verified is True: + rep.add("agent identity", OK, + f"{identity} — verified DID, status {status}; the issuer committed to " + "this before anchoring and cannot restate it") + else: + rep.add("agent identity", OK, + f"{identity or 'none declared'} — self-declared, NOT a verified DID " + f"(status {status}); anchored as such") + + def check_chain_hash(proof, rep, disclosure=None): """Recompute the chain hash from the proof's own fields. @@ -231,6 +297,9 @@ def check_chain_hash(proof, rep, disclosure=None): only a corrupted one. It does establish one thing the anchors do not — that the anchored chain hash really covers the request and response hashes shown. """ + disclosed = dict(proof.get("disclosed") or {}) + disclosed.update((disclosure or {}).get("disclosed") or {}) + check_identity(proof, rep, disclosed, proof.get("commitments") or {}) if proof.get("spec_version") in COMMITMENT_SPEC_VERSIONS: return check_commitments(proof, rep, disclosure) diff --git a/tests/test_commitments.py b/tests/test_commitments.py index ffa236c..6cb5f92 100644 --- a/tests/test_commitments.py +++ b/tests/test_commitments.py @@ -112,7 +112,7 @@ def test_generated_proof_is_spec_3_and_self_verifies(): proof = generate_proof({"t": 1}, {"r": "ok"}, {"transaction_id": "pi_x"}, "2026-09-13T10:00:00+00:00", buyer_fingerprint="f" * 64, seller="api.example.com") - assert proof["spec_version"] == "3.0" + assert proof["spec_version"] == "3.1" assert proof["hashes"]["chain"] == f"sha256:{commitments_root(proof['commitments'])}" assert verify_proof_integrity(proof) diff --git a/tests/test_identity.py b/tests/test_identity.py index 602e5d3..f6e27c4 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -172,12 +172,14 @@ def test_no_header_preserves_identity(tmp_path): # --- 7. Chain hash unchanged with/without identity (backward compat) --- -def test_identity_is_not_part_of_the_chain_commitment(): - """Identity stays out of the chain preimage, with or without it. - - Since spec 3.0 two proofs over the same data never share a chain hash — each - field is committed under a fresh nonce — so the invariant is checked on the - committed field set, not on the hash. +def test_identity_is_part_of_the_chain_commitment(): + """Spec 3.1 inverts the 3.0 invariant: the identity triple IS committed. + + Up to 3.0 this test asserted the opposite, and that was the defect written down + as an invariant: identity served publicly, committed nowhere, therefore rewritable + by the issuer after anchoring. The committed field set is the same either way — + an absent identity is a committed ``None`` — but the committed VALUES differ, so + two proofs that differ only by identity no longer share a chain preimage. """ common = dict( request_data={"target": "https://example.com"}, @@ -190,10 +192,15 @@ def test_identity_is_not_part_of_the_chain_commitment(): proof_without = generate_proof(**common) proof_with = generate_proof(**common, agent_identity="my-agent", agent_version="1.0") + # Same field set: the triple is always committed, present or not. assert set(proof_without["_chain_data"]) == set(proof_with["_chain_data"]) - assert proof_without["_chain_data"] == proof_with["_chain_data"] - assert "agent_identity" not in proof_with["_chain_data"] + # Different values: identity now lands inside what the anchors cover. + assert proof_without["_chain_data"] != proof_with["_chain_data"] + assert proof_with["_chain_data"]["agent_identity"] == "my-agent" + assert proof_without["_chain_data"]["agent_identity"] is None assert proof_with["parties"]["agent_identity"] == "my-agent" + # agent_version stays out: it carries no claim the Index scores. + assert "agent_version" not in proof_with["_chain_data"] # --- 8. Integration: POST /v1/proxy with identity headers --- diff --git a/tests/test_identity_anchoring.py b/tests/test_identity_anchoring.py new file mode 100644 index 0000000..4dce826 --- /dev/null +++ b/tests/test_identity_anchoring.py @@ -0,0 +1,197 @@ +"""Spec 3.1 — the identity triple is anchored, and a third party can open it. + +Until 3.0 inclusive, ``agent_identity``, ``agent_identity_verified`` and +``did_resolution_status`` were served publicly but lived outside ``chain_data``: +outside the Merkle root, outside ``hashes.chain``, outside the Ed25519 signature, +therefore outside TSA and Rekor. The issuer could rewrite them after the fact and +every external anchor still verified. The witness for that is +``test_tampering_the_identity_triple_now_breaks_integrity``: it is the exact +falsification that used to pass. + +Anchoring alone is not enough here. A commitment with a secret nonce is +non-falsifiable but unreadable: the third party recomputes the root from digests, +never from values. These three values must stay readable, so 3.1 publishes their +nonces alongside them. Hiding is deliberately given up on this triple, and only +on it. +""" + +import json + +import pytest + +from trust_layer.commitments import verify_disclosure +from trust_layer.proofs import ( + IDENTITY_FIELDS, + SPEC_VERSION, + generate_proof, + get_full_proof, + get_public_proof, + verify_proof_integrity, +) + + +def _proof(**kw): + base = dict( + request_data={"q": "x"}, + response_data={"r": "y"}, + payment_data={"transaction_id": "pi_secret_value"}, + timestamp="2026-09-13T10:00:00Z", + buyer_fingerprint="f" * 64, + seller="corpus.arkforge.tech", + agent_identity="did:web:agent.example", + agent_identity_verified=True, + did_resolution_status="bound", + ) + base.update(kw) + return generate_proof(**base) + + +def test_spec_version_is_3_1(): + assert SPEC_VERSION == "3.1" + assert _proof()["spec_version"] == "3.1" + + +def test_the_identity_triple_is_committed(): + proof = _proof() + assert set(IDENTITY_FIELDS) <= set(proof["commitments"]) + assert set(IDENTITY_FIELDS) <= set(proof["_chain_data"]) + + +def test_tampering_the_identity_triple_now_breaks_integrity(): + """The exact falsification that passed under 3.0, field by field.""" + for field, forged in ( + ("agent_identity", "did:web:trust.arkforge.tech"), + ("agent_identity_verified", True), + ("did_resolution_status", "bound"), + ): + proof = _proof(agent_identity="did:web:evil.example", + agent_identity_verified=None, + did_resolution_status="unverified") + assert verify_proof_integrity(proof) is True + proof["parties"][field] = forged + proof["_chain_data"][field] = forged + assert verify_proof_integrity(proof) is False, f"{field} is still forgeable" + + +def test_a_third_party_opens_the_triple_from_public_data_alone(): + proof = _proof() + public = get_public_proof(proof) + disclosed = public["disclosed"] + assert set(disclosed) == set(IDENTITY_FIELDS) + for field, item in disclosed.items(): + assert verify_disclosure(field, item["nonce"], item["value"], + public["commitments"][field]) + + +def test_a_forged_public_value_fails_to_open(): + proof = _proof(agent_identity_verified=None, did_resolution_status="unverified") + public = get_public_proof(proof) + public["disclosed"]["agent_identity_verified"]["value"] = True + item = public["disclosed"]["agent_identity_verified"] + assert not verify_disclosure("agent_identity_verified", item["nonce"], item["value"], + public["commitments"]["agent_identity_verified"]) + + +def test_top_level_identity_cannot_diverge_from_the_disclosed_value(): + """The restatement attack, played at serving time. + + ``parties`` is what an issuer would edit to restate an identity: it is a plain + stored field, covered by nothing. The flat fields of the public view must be read + from the committed data, so that editing ``parties`` changes nothing a reader sees. + Asserting equality on an untampered proof would pass either way and measure zero. + """ + proof = _proof(agent_identity="did:web:agent.example", + agent_identity_verified=None, did_resolution_status="unverified") + proof["parties"]["agent_identity"] = "did:web:trust.arkforge.tech" + proof["parties"]["agent_identity_verified"] = True + proof["parties"]["did_resolution_status"] = "bound" + public = get_public_proof(proof) + for field in IDENTITY_FIELDS: + assert public[field] == public["disclosed"][field]["value"] + assert public["agent_identity"] == "did:web:agent.example" + assert public["agent_identity_verified"] is None + + +def test_publishing_identity_nonces_discloses_nothing_else(): + public = get_public_proof(_proof()) + blob = json.dumps(public) + assert "pi_secret_value" not in blob + assert "f" * 64 not in blob + assert set(public["disclosed"]) == set(IDENTITY_FIELDS) + + +@pytest.mark.parametrize("verified,status", [ + (True, "bound"), + (False, "unverified"), + (None, "unverified"), + (None, None), +]) +def test_every_identity_state_round_trips(verified, status): + """False must not normalise to None on one side and stay False on the other. + + Same family as the Redis binding bug: write in one store, read in the other. + """ + proof = _proof(agent_identity_verified=verified, did_resolution_status=status) + assert verify_proof_integrity(proof) is True + public = get_public_proof(proof) + for field in IDENTITY_FIELDS: + item = public["disclosed"][field] + assert verify_disclosure(field, item["nonce"], item["value"], + public["commitments"][field]) + # False is normalised to None once, at the source, so both sides agree. + assert public["agent_identity_verified"] is (True if verified else None) + assert proof["parties"]["agent_identity_verified"] == public["agent_identity_verified"] + + +def test_an_agent_without_identity_still_commits_the_triple(): + """Absent identity is a committed None, never a missing commitment. + + Otherwise an issuer drops the three fields and the scorer has nothing to refuse. + """ + proof = _proof(agent_identity=None, agent_identity_verified=None, + did_resolution_status=None) + assert set(IDENTITY_FIELDS) <= set(proof["commitments"]) + assert verify_proof_integrity(proof) is True + public = get_public_proof(proof) + assert public["disclosed"]["agent_identity"]["value"] is None + + +def test_a_3_1_proof_missing_identity_commitments_is_refused(): + """A coherent 3.1 proof that simply dropped the identity commitments. + + The chain hash is recomputed over the reduced set, so the Merkle root matches and + every generic check passes. Only the explicit 'a 3.1 proof commits the triple' rule + catches it. Deleting the field without rebuilding the root would fail on the root + instead, and leave that rule untested. + """ + from trust_layer.commitments import commitments_root + proof = _proof() + for holder in ("commitments", "_chain_data", "_commitment_nonces"): + for field in IDENTITY_FIELDS: + del proof[holder][field] + proof["hashes"]["chain"] = f"sha256:{commitments_root(proof['commitments'])}" + assert verify_proof_integrity(proof) is False + + +def test_3_0_proofs_still_verify(): + """Existing anchored proofs keep verifying; only their identity is unanchored.""" + proof = _proof() + legacy = {k: v for k, v in proof.items() + if k not in ("_chain_data", "_commitment_nonces", "commitments", "hashes")} + legacy_chain = {k: v for k, v in proof["_chain_data"].items() + if k not in IDENTITY_FIELDS} + from trust_layer.commitments import build_commitments + commitments, nonces, chain_hash = build_commitments(legacy_chain) + legacy["spec_version"] = "3.0" + legacy["commitments"] = commitments + legacy["_commitment_nonces"] = nonces + legacy["_chain_data"] = legacy_chain + legacy["hashes"] = dict(proof["hashes"], chain=f"sha256:{chain_hash}") + assert verify_proof_integrity(legacy) is True + assert "disclosed" not in get_public_proof(legacy) + + +def test_owner_view_still_carries_every_nonce(): + full = get_full_proof(_proof()) + assert set(IDENTITY_FIELDS) <= set(full["commitment_nonces"]) + assert "transaction_id" in full["commitment_nonces"] diff --git a/tests/test_integration.py b/tests/test_integration.py index d9462c2..4429b1c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -294,7 +294,7 @@ def test_proxy_full_flow_has_signature_and_spec(client, api_key): assert r.status_code == 200 proof = r.json()["proof"] - assert proof["spec_version"] == "3.0" + assert proof["spec_version"] == "3.1" assert proof["arkforge_signature"].startswith("ed25519:") assert proof["arkforge_pubkey"].startswith("ed25519:") assert proof["upstream_timestamp"] == "Thu, 26 Feb 2026 17:00:00 GMT" @@ -309,7 +309,7 @@ def test_proxy_full_flow_has_signature_and_spec(client, api_key): r2 = client.get(f"/v1/proof/{proof_id}") assert r2.status_code == 200 public = r2.json() - assert public["spec_version"] == "3.0" + assert public["spec_version"] == "3.1" assert public["arkforge_signature"].startswith("ed25519:") assert public["upstream_timestamp"] == "Thu, 26 Feb 2026 17:00:00 GMT" diff --git a/tests/test_proofs.py b/tests/test_proofs.py index 8ec24af..534d17c 100644 --- a/tests/test_proofs.py +++ b/tests/test_proofs.py @@ -148,7 +148,7 @@ def test_generate_proof_includes_spec_version(): {"target": "https://example.com"}, {"result": "ok"}, {"transaction_id": "pi_spec"}, "2026-02-26T10:00:00Z", ) - assert proof.get("spec_version") == "3.0" + assert proof.get("spec_version") == "3.1" def test_chain_hash_canonical_json_no_preimage_ambiguity(): diff --git a/tests/test_proxy.py b/tests/test_proxy.py index a217c41..da6eba9 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -315,7 +315,7 @@ async def test_execute_proxy_has_spec_version(test_api_key): api_key=test_api_key, ) - assert result["proof"]["spec_version"] == "3.0" + assert result["proof"]["spec_version"] == "3.1" @pytest.mark.asyncio diff --git a/tests/test_receipt.py b/tests/test_receipt.py index 0eb6947..5d895b3 100644 --- a/tests/test_receipt.py +++ b/tests/test_receipt.py @@ -335,7 +335,7 @@ def test_chain_hash_with_receipt_commits_to_it(self): """ args = self._make_proof_args() proof = generate_proof(**args, receipt_content_hash="deadbeef" * 8) - assert proof["spec_version"] == SPEC_VERSION == "3.0" + assert proof["spec_version"] == SPEC_VERSION == "3.1" assert "receipt_content_hash" in proof["_chain_data"] assert "receipt_content_hash" in proof["commitments"] @@ -343,7 +343,7 @@ def test_chain_hash_without_receipt_is_the_same_spec(self): """One spec version now: the committed field set is what differs.""" args = self._make_proof_args() proof = generate_proof(**args) - assert proof["spec_version"] == SPEC_VERSION == "3.0" + assert proof["spec_version"] == SPEC_VERSION == "3.1" assert "receipt_content_hash" not in proof["_chain_data"] def test_chain_hash_differs_with_receipt(self): @@ -354,7 +354,11 @@ def test_chain_hash_differs_with_receipt(self): assert proof_without["hashes"]["chain"] != proof_with["hashes"]["chain"] def test_chain_hash_without_receipt_commits_to_exactly_these_fields(self): - """The committed field set is the spec 3.0 equivalent of the old formula.""" + """The committed field set, pinned. Spec 3.1 added the identity triple. + + This assertion is the wire format: anything that changes it changes what the + anchors cover, so it must be a deliberate spec bump, never a side effect. + """ from trust_layer.commitments import commitments_root args = self._make_proof_args() proof = generate_proof(**args) @@ -369,6 +373,9 @@ def test_chain_hash_without_receipt_commits_to_exactly_these_fields(self): "timestamp": "2026-02-28T12:00:00Z", "buyer_fingerprint": "abc123", "seller": "api.example.com", + "agent_identity": None, + "agent_identity_verified": None, + "did_resolution_status": None, } assert proof["_raw_chain_hash"] == commitments_root(proof["commitments"]) @@ -539,7 +546,7 @@ def test_proxy_with_provider_payment(self, client): assert pe["receipt_content_hash"] is not None assert pe["receipt_content_hash"].startswith("sha256:") assert pe["verification_status"] == "fetched" - assert proof["spec_version"] == "3.0" + assert proof["spec_version"] == "3.1" def test_proxy_without_provider_payment_unchanged(self, client): api_key = self._setup_free_key(client) @@ -569,7 +576,7 @@ def test_proxy_without_provider_payment_unchanged(self, client): data = resp.json() proof = data.get("proof", {}) assert proof.get("provider_payment") is None - assert proof["spec_version"] == "3.0" + assert proof["spec_version"] == "3.1" def test_public_proof_endpoint_includes_provider_payment(self, client): """Verify that GET /v1/proof/{proof_id} returns provider_payment.""" diff --git a/tests/test_spec_conformance.py b/tests/test_spec_conformance.py index 083ee4f..b65b966 100644 --- a/tests/test_spec_conformance.py +++ b/tests/test_spec_conformance.py @@ -15,7 +15,7 @@ from trust_layer.commitments import commit, commitments_root from trust_layer.merkle import inclusion_root, leaf_hash, merkle_root -from trust_layer.proofs import canonical_json, sha256_hex +from trust_layer.proofs import IDENTITY_FIELDS, canonical_json, sha256_hex # Local proof-spec repo (preferred — always in sync) VECTORS_LOCAL = Path(__file__).parent.parent.parent / "proof-spec" / "test-vectors.json" @@ -123,6 +123,11 @@ def test_chain_hash(vector): chain_data["upstream_timestamp"] = inp["upstream_timestamp"] if inp.get("receipt_content_hash"): chain_data["receipt_content_hash"] = inp["receipt_content_hash"] + if vector.get("spec_version") == "3.1": + # The identity triple is always committed, a missing identity as null. + for field in ("agent_identity", "agent_identity_verified", + "did_resolution_status"): + chain_data[field] = inp[field] assert chain_data == expected["chain_data"], f"Committed field set drifted for {vector['name']}" commitments = {f: commit(f, bytes.fromhex(inp["nonces"][f]), v).hex() @@ -182,3 +187,46 @@ def test_batch_anchor_tree(vector): root, consumed = inclusion_root(leaves[i], i, expected["tree_size"], path) assert root.hex() == expected["root"], f"Inclusion path for leaf {i} misses the root" assert consumed == len(path), f"Inclusion path for leaf {i} carries unused siblings" + + +_IDENTITY_VECTORS = [v for v in _vectors_data["vectors"] if v.get("spec_version") == "3.1"] + + +@pytest.mark.parametrize( + "vector", _IDENTITY_VECTORS, ids=[v["name"] for v in _IDENTITY_VECTORS], +) +def test_identity_triple_opens_from_published_nonces(vector): + """Spec 3.1 section: the three identity nonces are public, so anyone opens them. + + This is the whole point of the version bump. If the published nonces stopped + opening their commitments, an Index built on agent_identity_verified would be + back to trusting the issuer, and nothing else in the suite would notice. + """ + from trust_layer.commitments import verify_disclosure + expected = vector["expected"] + published = expected["published_nonces"] + assert set(published) == set(IDENTITY_FIELDS) + for field, nonce in published.items(): + assert verify_disclosure(field, nonce, expected["chain_data"][field], + expected["commitments"][field]), field + + +@pytest.mark.parametrize( + "vector", _IDENTITY_VECTORS, ids=[v["name"] for v in _IDENTITY_VECTORS], +) +def test_a_forged_identity_value_does_not_open(vector): + """The negative witness: without it the test above measures nothing.""" + from trust_layer.commitments import verify_disclosure + expected = vector["expected"] + assert not verify_disclosure( + "agent_identity_verified", + expected["published_nonces"]["agent_identity_verified"], + not expected["chain_data"]["agent_identity_verified"], + expected["commitments"]["agent_identity_verified"], + ) + + +def test_the_vector_file_covers_spec_3_1(): + """A vector file that never reached 3.1 would let the suite pass on 3.0 alone.""" + assert _vectors_data["spec_version"].startswith("3.1"), _vectors_data["spec_version"] + assert _IDENTITY_VECTORS, "no spec 3.1 vector: the conformance suite measures nothing new" diff --git a/tests/test_verify_proof.py b/tests/test_verify_proof.py index fa9bf09..7bf7b39 100644 --- a/tests/test_verify_proof.py +++ b/tests/test_verify_proof.py @@ -463,3 +463,77 @@ def test_a_malformed_commitment_fails_without_crashing(client, monkeypatch, valu public["commitments"]["seller"] = value rep = _run(public, offline=True) # must not raise assert _status(rep, "chain hash") == vp.FAIL + + +# --- spec 3.1: the identity triple, and what the verifier says when it is unanchored --- + +def _proof_3_1(**kw): + from trust_layer.proofs import generate_proof, get_public_proof + base = dict( + request_data={"entity_id": "ENT-4417"}, response_data={"agrement": "valide"}, + payment_data={"transaction_id": "free_tier"}, timestamp="2026-09-13T12:00:00Z", + buyer_fingerprint="f" * 64, seller="corpus.arkforge.tech", + agent_identity="did:web:agent.example", agent_identity_verified=True, + did_resolution_status="bound", + ) + base.update(kw) + public = get_public_proof(generate_proof(**base)) + public["proof_id"] = "prf_test_3_1" + return public + + +def _identity_row(proof): + rep = vp.Report() + vp.check_chain_hash(proof, rep) + rows = [r for r in rep.rows if r[0] == "agent identity"] + return rows[0] if rows else None + + +def test_the_identity_triple_opens_for_a_third_party(): + row = _identity_row(_proof_3_1()) + assert row is not None and row[1] == vp.OK + assert "did:web:agent.example" in row[2] + + +def test_a_restated_identity_is_refused(): + proof = _proof_3_1(agent_identity_verified=None, did_resolution_status="unverified") + proof["disclosed"]["agent_identity_verified"]["value"] = True + proof["agent_identity_verified"] = True + row = _identity_row(proof) + assert row[1] == vp.FAIL and "anchored commitment" in row[2] + + +def test_a_3_1_proof_stripped_of_its_disclosure_is_refused(): + """Serving the flat fields without the nonces must not read as verified.""" + proof = _proof_3_1() + del proof["disclosed"] + row = _identity_row(proof) + assert row[1] == vp.FAIL and "not opened" in row[2] + + +def test_an_unverified_identity_is_reported_as_such_not_as_a_failure(): + proof = _proof_3_1(agent_identity="self-declared-agent", agent_identity_verified=None, + did_resolution_status="unverified") + row = _identity_row(proof) + assert row[1] == vp.OK + assert "NOT a verified DID" in row[2] + + +def test_a_pre_3_1_identity_claim_is_flagged_as_unanchored(): + """Found by running the published procedure, not by reading it. + + A spec 2.0 proof serving an identity used to print no identity line at all: the + reader saw the flat field and nothing said it was covered by no anchor. Silence + on an unbacked claim reads as assent. + """ + proof = _load("proof_rekor.json") + assert proof["spec_version"] not in vp.IDENTITY_SPEC_VERSIONS + assert proof.get("agent_identity") + row = _identity_row(proof) + assert row is not None, "an unanchored identity claim must not pass in silence" + assert row[1] == vp.SKIP and "NOT evidence" in row[2] + + +def test_a_pre_3_1_proof_without_identity_says_nothing(): + proof = dict(_load("proof_rekor.json"), agent_identity=None, agent_identity_verified=None) + assert _identity_row(proof) is None diff --git a/trust_layer/proofs.py b/trust_layer/proofs.py index 566ff58..c0e6dff 100644 --- a/trust_layer/proofs.py +++ b/trust_layer/proofs.py @@ -10,14 +10,36 @@ from .config import PROOFS_DIR from .persistence import save_json, load_json -SPEC_VERSION = "3.0" # per-field commitments, chain hash = their Merkle root +SPEC_VERSION = "3.1" # 3.0 + the identity triple committed and publicly opened +SPEC_VERSION_COMMITMENTS = "3.0" # per-field commitments, chain hash = their Merkle root SPEC_VERSION_VALUES = "1.2" # canonical_json over the values themselves (pre-3.0) SPEC_VERSION_RECEIPT = "2.1" # same, with receipt evidence # Legacy spec versions that used concatenation — still verified for backward compat _LEGACY_SPEC_VERSIONS = {"1.0", "1.1", "2.0", None} # Spec versions whose chain hash is the Merkle root of per-field commitments. -_COMMITMENT_SPEC_VERSIONS = {"3.0"} +_COMMITMENT_SPEC_VERSIONS = {"3.0", "3.1"} + +# Spec 3.1: the identity triple joins the chain fields, so the anchors cover it. +# Up to 3.0 it was served publicly but committed nowhere, which left the issuer +# free to rewrite it after anchoring with every external witness still verifying. +IDENTITY_FIELDS = ("agent_identity", "agent_identity_verified", "did_resolution_status") +_IDENTITY_SPEC_VERSIONS = {"3.1"} + +# These three are the only fields whose nonce is published. A commitment hides its +# value; these values must stay readable by a third party, so 3.1 trades hiding for +# openability on this triple and on nothing else. +_PUBLIC_NONCE_FIELDS = IDENTITY_FIELDS + + +def normalize_identity_verified(value) -> Optional[bool]: + """``True`` or ``None`` — never ``False``. + + Called once, at the source, so ``parties`` and ``chain_data`` cannot disagree. + A ``False`` stored on one side and a ``None`` on the other would make every + unverified proof fail its own integrity check. + """ + return True if value else None def canonical_json(data: dict) -> str: @@ -72,6 +94,13 @@ def generate_proof( chain_data["upstream_timestamp"] = upstream_timestamp if receipt_content_hash: chain_data["receipt_content_hash"] = receipt_content_hash + # Spec 3.1: the identity triple is always committed, including when it is absent. + # Committing it only when present would let an issuer simply omit the fields and + # leave the scorer with no commitment to refuse. + identity_verified = normalize_identity_verified(agent_identity_verified) + chain_data["agent_identity"] = agent_identity + chain_data["agent_identity_verified"] = identity_verified + chain_data["did_resolution_status"] = did_resolution_status # Spec 3.0: hashes.chain BECOMES the Merkle root of the per-field commitments. # Nothing that was public stops being public; what is public becomes sufficient. from .commitments import build_commitments @@ -92,7 +121,7 @@ def generate_proof( "buyer_fingerprint": buyer_fingerprint, "seller": seller, "agent_identity": agent_identity, - "agent_identity_verified": agent_identity_verified if agent_identity_verified else None, + "agent_identity_verified": identity_verified, "did_resolution_status": did_resolution_status, "agent_version": agent_version, }, @@ -162,6 +191,10 @@ def verify_proof_integrity(proof: dict) -> bool: commitments = proof.get("commitments") or {} if not commitments: return False + # A 3.1 proof that drops an identity commitment is not a 3.1 proof. + if spec_version in _IDENTITY_SPEC_VERSIONS: + if not set(IDENTITY_FIELDS) <= set(commitments): + return False try: if commitments_root(commitments) != expected_chain: return False @@ -256,6 +289,23 @@ def get_public_proof(proof: dict) -> dict: "did_resolution_status": proof.get("parties", {}).get("did_resolution_status"), "seller": proof.get("parties", {}).get("seller"), } + # Spec 3.1: open the identity triple to everyone. The third party checks each + # (field, nonce, value) against the commitment the anchors cover, instead of + # taking the issuer's word for the flat fields above — which is exactly what + # the DID binding existed to remove. + if proof.get("spec_version") in _IDENTITY_SPEC_VERSIONS: + nonces = proof.get("_commitment_nonces") or {} + chain_data = proof.get("_chain_data") or {} + disclosed = {} + for field in _PUBLIC_NONCE_FIELDS: + if field in nonces and field in chain_data: + disclosed[field] = {"nonce": nonces[field], "value": chain_data[field]} + if disclosed: + result["disclosed"] = disclosed + # Single source: a flat field can never show something other than the + # value that actually opens the anchored commitment. + for field, item in disclosed.items(): + result[field] = item["value"] if is_demo: result["demo_notice"] = ( "This is a demo proof generated without a real upstream call. " From ac88f061aa18045e2d171da799b606c6acf88a2a Mon Sep 17 00:00:00 2001 From: desiorac Date: Sun, 13 Sep 2026 19:35:53 +0200 Subject: [PATCH 2/3] secu: identity_consistent rejoint le bloc d identite engage Trouve en relecture : identity_consistent est un jugement SUR l identite, calcule par le proxy et servi publiquement, et il etait reste hors chain_data. Ancrer ses trois voisins en le laissant dehors reconstruisait le meme trou un champ plus a gauche, sur le champ que le rendu HTML affiche. Le calcul ne depend de rien que produise l appel amont : il remonte avant generate_proof, qui l engage et le porte dans l enregistrement, donc la vue publique et l engagement partent du meme endroit. Mesure par execution du chemin reel : preuve construite par le chemin demo, ecrite puis RECHARGEE du disque, procedure tierce jouee dessus -> 4 champs ouverts contre leurs engagements ancres. C est ce chemin que sert la prod, pas l objet en memoire sur lequel portaient les autres verifications. 795 tests verts, 3 mutations de plus tuees (dont la non-persistance des nonces, qui rendrait une preuve 3.1 sans disclosed au tiers). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CzXFq94XGK7vcDgoSRsPFp --- scripts/verify_proof.py | 3 +- tests/test_identity_anchoring.py | 63 ++++++++++++++++++++++++++++++++ tests/test_receipt.py | 1 + tests/test_spec_conformance.py | 5 +-- trust_layer/proofs.py | 9 ++++- trust_layer/proxy.py | 20 +++++----- 6 files changed, 87 insertions(+), 14 deletions(-) diff --git a/scripts/verify_proof.py b/scripts/verify_proof.py index 40755c9..dd788aa 100755 --- a/scripts/verify_proof.py +++ b/scripts/verify_proof.py @@ -138,7 +138,8 @@ def strip_sha256(value): # party opens it from the proof alone. Before 3.1 these three fields were served next # to the proof but covered by no anchor. IDENTITY_SPEC_VERSIONS = {"3.1"} -IDENTITY_FIELDS = ("agent_identity", "agent_identity_verified", "did_resolution_status") +IDENTITY_FIELDS = ("agent_identity", "agent_identity_verified", "did_resolution_status", + "identity_consistent") def _canonical_json(data): diff --git a/tests/test_identity_anchoring.py b/tests/test_identity_anchoring.py index 4dce826..29e7be8 100644 --- a/tests/test_identity_anchoring.py +++ b/tests/test_identity_anchoring.py @@ -46,6 +46,69 @@ def _proof(**kw): return generate_proof(**base) +def test_identity_consistent_is_committed_too(): + """Same family as the triple, same defect if left out. + + ``identity_consistent`` is a judgment ON the identity, computed by the proxy and + served publicly. Anchoring the three fields next to it and leaving it out would + rebuild the same hole one field to the left. + """ + proof = _proof(identity_consistent=True) + assert "identity_consistent" in proof["commitments"] + assert verify_proof_integrity(proof) is True + proof["identity_consistent"] = False + proof["_chain_data"]["identity_consistent"] = False + assert verify_proof_integrity(proof) is False + + +def test_identity_consistent_opens_publicly_and_cannot_be_restated(): + proof = _proof(identity_consistent=False) + proof["identity_consistent"] = True # what a restating issuer would edit + public = get_public_proof(proof) + assert public["identity_consistent"] is False + item = public["disclosed"]["identity_consistent"] + assert verify_disclosure("identity_consistent", item["nonce"], item["value"], + public["commitments"]["identity_consistent"]) + + +def test_a_stored_and_reloaded_proof_still_opens(): + """The nonces must survive the disk, or a third party gets 'committed but not opened'. + + Every other check here runs on the in-memory record; production serves a reloaded + one. Writing in one shape and reading in another is the defect this repo has already + paid for twice. + """ + import os + from trust_layer.config import PROOFS_DIR + from trust_layer.proofs import load_proof, store_proof + proof = dict(_proof(), proof_id="prf_roundtrip_anchoring") + store_proof("prf_roundtrip_anchoring", proof) + try: + back = load_proof("prf_roundtrip_anchoring") + assert verify_proof_integrity(back) is True + public = get_public_proof(back) + assert set(public["disclosed"]) == set(IDENTITY_FIELDS) + for field, item in public["disclosed"].items(): + assert verify_disclosure(field, item["nonce"], item["value"], + public["commitments"][field]) + finally: + os.remove(PROOFS_DIR / "prf_roundtrip_anchoring.json") + + +def test_the_demo_path_produces_a_valid_3_1_proof(): + """demo.py assembles its record by hand; it must not drift from generate_proof.""" + import os + from trust_layer.config import PROOFS_DIR + from trust_layer.demo import build_demo_proof + record = build_demo_proof("https://example.com/api", {"q": 1}) + try: + assert record["spec_version"] == SPEC_VERSION + assert verify_proof_integrity(record) is True + assert set(get_public_proof(record)["disclosed"]) == set(IDENTITY_FIELDS) + finally: + os.remove(PROOFS_DIR / f"{record['proof_id']}.json") + + def test_spec_version_is_3_1(): assert SPEC_VERSION == "3.1" assert _proof()["spec_version"] == "3.1" diff --git a/tests/test_receipt.py b/tests/test_receipt.py index 5d895b3..6b6ba30 100644 --- a/tests/test_receipt.py +++ b/tests/test_receipt.py @@ -376,6 +376,7 @@ def test_chain_hash_without_receipt_commits_to_exactly_these_fields(self): "agent_identity": None, "agent_identity_verified": None, "did_resolution_status": None, + "identity_consistent": None, } assert proof["_raw_chain_hash"] == commitments_root(proof["commitments"]) diff --git a/tests/test_spec_conformance.py b/tests/test_spec_conformance.py index b65b966..5dd97fb 100644 --- a/tests/test_spec_conformance.py +++ b/tests/test_spec_conformance.py @@ -125,8 +125,7 @@ def test_chain_hash(vector): chain_data["receipt_content_hash"] = inp["receipt_content_hash"] if vector.get("spec_version") == "3.1": # The identity triple is always committed, a missing identity as null. - for field in ("agent_identity", "agent_identity_verified", - "did_resolution_status"): + for field in IDENTITY_FIELDS: chain_data[field] = inp[field] assert chain_data == expected["chain_data"], f"Committed field set drifted for {vector['name']}" @@ -195,7 +194,7 @@ def test_batch_anchor_tree(vector): @pytest.mark.parametrize( "vector", _IDENTITY_VECTORS, ids=[v["name"] for v in _IDENTITY_VECTORS], ) -def test_identity_triple_opens_from_published_nonces(vector): +def test_identity_block_opens_from_published_nonces(vector): """Spec 3.1 section: the three identity nonces are public, so anyone opens them. This is the whole point of the version bump. If the published nonces stopped diff --git a/trust_layer/proofs.py b/trust_layer/proofs.py index c0e6dff..292237c 100644 --- a/trust_layer/proofs.py +++ b/trust_layer/proofs.py @@ -23,7 +23,11 @@ # Spec 3.1: the identity triple joins the chain fields, so the anchors cover it. # Up to 3.0 it was served publicly but committed nowhere, which left the issuer # free to rewrite it after anchoring with every external witness still verifying. -IDENTITY_FIELDS = ("agent_identity", "agent_identity_verified", "did_resolution_status") +# ``identity_consistent`` sits here too: it is a judgment ON the identity, computed by +# the proxy and served publicly. Anchoring its three neighbours and leaving it out would +# rebuild the same hole one field to the left. +IDENTITY_FIELDS = ("agent_identity", "agent_identity_verified", "did_resolution_status", + "identity_consistent") _IDENTITY_SPEC_VERSIONS = {"3.1"} # These three are the only fields whose nonce is published. A commitment hides its @@ -71,6 +75,7 @@ def generate_proof( agent_version: Optional[str] = None, agent_identity_verified: Optional[bool] = None, did_resolution_status: Optional[str] = None, + identity_consistent: Optional[bool] = None, upstream_timestamp: Optional[str] = None, receipt_content_hash: Optional[str] = None, provider_payment: Optional[dict] = None, @@ -101,6 +106,7 @@ def generate_proof( chain_data["agent_identity"] = agent_identity chain_data["agent_identity_verified"] = identity_verified chain_data["did_resolution_status"] = did_resolution_status + chain_data["identity_consistent"] = identity_consistent # Spec 3.0: hashes.chain BECOMES the Merkle root of the per-field commitments. # Nothing that was public stops being public; what is public becomes sufficient. from .commitments import build_commitments @@ -126,6 +132,7 @@ def generate_proof( "agent_version": agent_version, }, "certification_fee": payment_data, + "identity_consistent": identity_consistent, "timestamp": timestamp, "_raw_request_hash": request_hash, "_raw_response_hash": response_hash, diff --git a/trust_layer/proxy.py b/trust_layer/proxy.py index 715c562..394a6f2 100644 --- a/trust_layer/proxy.py +++ b/trust_layer/proxy.py @@ -765,15 +765,8 @@ async def execute_proxy( buyer_fingerprint = sha256_hex(api_key) seller = target_domain proof_id = proof_id_for_debit - proof = generate_proof(request_data, response_data, payment_data, timestamp, buyer_fingerprint, seller, - agent_identity=agent_identity, agent_version=agent_version, - agent_identity_verified=agent_identity_verified, - did_resolution_status=did_resolution_status, - upstream_timestamp=upstream_timestamp, - receipt_content_hash=receipt_content_hash, - provider_payment=provider_payment_record) - - # Compute identity_consistent flag + # identity_consistent is computed BEFORE the proof: spec 3.1 commits it, so it has to + # exist when the commitments are built. It depends on nothing the call produces. identity_consistent = None if agent_identity: if key_info.get("verified_did") and agent_identity == key_info["verified_did"]: @@ -790,6 +783,15 @@ async def execute_proxy( else: identity_consistent = True + proof = generate_proof(request_data, response_data, payment_data, timestamp, buyer_fingerprint, seller, + agent_identity=agent_identity, agent_version=agent_version, + agent_identity_verified=agent_identity_verified, + did_resolution_status=did_resolution_status, + identity_consistent=identity_consistent, + upstream_timestamp=upstream_timestamp, + receipt_content_hash=receipt_content_hash, + provider_payment=provider_payment_record) + verification_url = f"{TRUST_LAYER_BASE_URL}/v1/proof/{proof_id}" proof_record = { "proof_id": proof_id, From 8b5e91ad5eb353cbe9a64c7c6f7236491bec46c3 Mon Sep 17 00:00:00 2001 From: desiorac Date: Sun, 13 Sep 2026 20:43:53 +0200 Subject: [PATCH 3/3] secu: la procedure tierce rend un verdict, jamais une trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relecture §4.2 (Sonnet, lecture seule, perimetre = les commits de la PR) : REJETE, deux defauts eleves reels, revérifiés par execution avant traitement. - verify_proof.py plantait sur un `disclosed` non-dict (ValueError) et sur un engagement d identite non-string (AttributeError depuis strip_sha256, hors du try qui protege deja _commit). Le test generique existant mutait `seller`, qui ne traverse jamais check_identity : le chemin n etait couvert par rien. - Corrige en classe et pas en cas : strip_sha256 devient totale, `disclosed` est lu defensivement des deux sources, et tout temoin qui leve devient un FAIL. Ce script est la procedure qu un tiers execute ET un gate bloquant du deploiement ; dans les deux roles une trace est pire qu un echec. - X-Agent-Identity borne a 256 car. sans caractere de controle. Le defaut n est pas nouveau, mais 3.1 change sa nature : la valeur devient gravee et publiee. Borne choisie apres mesure de la prod (2 identites, 27 car. max), pas au jugé. Un troisieme constat du rapport (suite rouge) etait un artefact de mon propre `git checkout main` dans ~/proof-spec pendant la tentative de merge : la contrainte d ordre reste reelle, le defaut de code non. 814 tests verts, 4 mutations de plus tuees (14 au total). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CzXFq94XGK7vcDgoSRsPFp --- CHANGELOG.md | 10 +++++++++ scripts/verify_proof.py | 44 ++++++++++++++++++++++++++++++++------ tests/test_identity.py | 40 ++++++++++++++++++++++++++++++++++ tests/test_verify_proof.py | 40 ++++++++++++++++++++++++++++++++++ trust_layer/app.py | 18 ++++++++++++++++ 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7375da..a72da1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,16 @@ Versions follow [Semantic Versioning](https://semver.org/). ### Changed - `agent_identity_verified` vaut `True` ou `None`, jamais `False`, normalisé une seule fois à la source pour que la valeur engagée et la valeur servie ne puissent pas diverger +- `X-Agent-Identity` borné à 256 caractères, caractères de contrôle refusés (400). Depuis + 3.1 la valeur est engagée et publiée : elle est gravée, alors qu'elle était jusque-là + nettoyable côté serveur. Borne choisie après mesure de la prod (2 identités, 27 car. max) + +### Fixed (relecture §4.2) +- `verify_proof.py` rendait une trace d'exception au lieu d'un verdict sur un `disclosed` + ou un engagement malformé. C'est à la fois la procédure qu'un tiers exécute et un gate + bloquant du déploiement : dans les deux rôles une trace est pire qu'un échec. `strip_sha256` + devient totale, `disclosed` est lu défensivement, et **tout témoin qui lève devient un + échec** — la classe, pas les deux cas trouvés --- diff --git a/scripts/verify_proof.py b/scripts/verify_proof.py index dd788aa..045ec6b 100755 --- a/scripts/verify_proof.py +++ b/scripts/verify_proof.py @@ -123,7 +123,11 @@ def openssl(args, stdin=None): def strip_sha256(value): - return (value or "").replace("sha256:", "") + """Total on purpose: a proof is untrusted input, and every caller compares the + result. A non-string here used to raise AttributeError from inside a witness.""" + if not isinstance(value, str): + return "" + return value.replace("sha256:", "") # --- 1. chain hash ----------------------------------------------------------- @@ -207,8 +211,7 @@ def check_commitments(proof, rep, disclosure): # Spec 3.1 publishes the identity triple's nonces in the proof itself, so this # opening needs no out-of-band material. An owner-supplied bundle adds to it. - disclosed = dict(proof.get("disclosed") or {}) - disclosed.update((disclosure or {}).get("disclosed") or {}) + disclosed = _disclosed_map(proof, disclosure) if not disclosed: return expected bad = [] @@ -233,6 +236,34 @@ def check_commitments(proof, rep, disclosure): return expected +def _disclosed_map(proof, disclosure): + """The union of what the proof publishes and what the owner handed over. + + Both come from outside; anything that is not a dict of dicts is dropped rather + than allowed to raise from the middle of a witness. + """ + out = {} + for source in (proof.get("disclosed"), (disclosure or {}).get("disclosed")): + if isinstance(source, dict): + out.update({k: v for k, v in source.items() if isinstance(v, dict)}) + return out + + +def _witness(rep, label, fn, *args, **kwargs): + """Run one witness; an exception becomes a FAIL, never a traceback. + + This script is both the procedure a third party executes and a blocking gate of + the deployment. In either role a traceback is worse than a failure: the third + party gets no verdict at all, and the pipeline breaks instead of refusing. + """ + try: + return fn(*args, **kwargs) + except Exception as e: # noqa: BLE001 — deliberate catch-all + rep.add(label, FAIL, f"witness crashed on malformed input: " + f"{type(e).__name__}: {e}"[:300]) + return None + + def check_identity(proof, rep, disclosed, commitments): """Spec 3.1: is the agent identity shown the one the anchors cover? @@ -298,9 +329,10 @@ def check_chain_hash(proof, rep, disclosure=None): only a corrupted one. It does establish one thing the anchors do not — that the anchored chain hash really covers the request and response hashes shown. """ - disclosed = dict(proof.get("disclosed") or {}) - disclosed.update((disclosure or {}).get("disclosed") or {}) - check_identity(proof, rep, disclosed, proof.get("commitments") or {}) + disclosed = _disclosed_map(proof, disclosure) + commitments = proof.get("commitments") + _witness(rep, "agent identity", check_identity, proof, rep, disclosed, + commitments if isinstance(commitments, dict) else {}) if proof.get("spec_version") in COMMITMENT_SPEC_VERSIONS: return check_commitments(proof, rep, disclosure) diff --git a/tests/test_identity.py b/tests/test_identity.py index f6e27c4..566d7b8 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -258,3 +258,43 @@ def test_proof_endpoint_shows_identity(client, api_key): # identity_consistent is still publicly visible assert proof_data["identity_consistent"] is True assert proof_data["integrity_verified"] is True + + +# --- 10. X-Agent-Identity est borné depuis la spec 3.1 --- +# +# Avant 3.1 la valeur était servie en clair mais restait modifiable côté serveur. +# Depuis 3.1 elle est engagée et publiée dans `disclosed` : elle est gravée. Ce qui +# était un champ sale devient un stockage arbitraire permanent, et c'est ce lot qui +# change sa nature. Mesuré sur la prod avant de choisir la borne : 2 identités +# distinctes, 27 caractères au plus, aucun caractère de contrôle. + +@pytest.mark.parametrize("mauvais", [ + "x" * 257, + "did:web:a\x00b", + "did:web:a\nInjected: header", + "\x1b[31mrouge", +]) +def test_une_identite_hors_bornes_est_refusee(client, api_key, mauvais): + r = client.post( + "/v1/proxy", + json={"target": "https://example.com/api", "payload": {}}, + headers={"Authorization": f"Bearer {api_key}", "X-Agent-Identity": mauvais}, + ) + assert r.status_code == 400, r.text + assert "agent_identity" in r.text + + +@pytest.mark.parametrize("bon", ["did:web:trust.arkforge.tech", "arkforge-agent-client", + "x" * 256, "did:key:z6Mk" + "A" * 40]) +def test_les_identites_reelles_passent(client, api_key, bon): + """La borne ne doit refuser aucune valeur que la production porte aujourd'hui.""" + mock_http = _mock_http_client() + with patch("httpx.AsyncClient", return_value=mock_http), \ + patch("trust_layer.proxy._post_proof_background", new_callable=AsyncMock): + r = client.post( + "/v1/proxy", + json={"target": "https://example.com/api", "payload": {}}, + headers={"Authorization": f"Bearer {api_key}", "X-Agent-Identity": bon}, + ) + assert r.status_code == 200, r.text + assert r.json()["proof"]["parties"]["agent_identity"] == bon diff --git a/tests/test_verify_proof.py b/tests/test_verify_proof.py index 7bf7b39..8aecc62 100644 --- a/tests/test_verify_proof.py +++ b/tests/test_verify_proof.py @@ -537,3 +537,43 @@ def test_a_pre_3_1_identity_claim_is_flagged_as_unanchored(): def test_a_pre_3_1_proof_without_identity_says_nothing(): proof = dict(_load("proof_rekor.json"), agent_identity=None, agent_identity_verified=None) assert _identity_row(proof) is None + + +# --- la procédure publiée rend un verdict, jamais une trace d'exception --- +# +# Trouvé par la relecture §4.2. Ce script est deux choses à la fois : la procédure +# qu'un tiers exécute, et un gate bloquant du déploiement. Dans les deux rôles, une +# trace d'exception est pire qu'un échec : le tiers n'obtient aucun verdict, et le +# pipeline casse au lieu de refuser proprement. + +@pytest.mark.parametrize("garbage", ["une chaine", ["a", "b"], 42, 3.5, True]) +def test_a_malformed_disclosed_block_yields_a_verdict_not_a_traceback(garbage): + proof = dict(_proof_3_1(), disclosed=garbage) + rep = vp.Report() + vp.check_chain_hash(proof, rep) # ne doit pas lever + assert rep.rows, "aucun témoin rendu" + + +@pytest.mark.parametrize("garbage", [42, None, ["a"], {"x": 1}, 3.5]) +def test_a_malformed_identity_commitment_yields_a_verdict_not_a_traceback(garbage): + """Le test générique existant mute `seller`, qui ne traverse jamais check_identity.""" + proof = _proof_3_1() + proof["commitments"]["agent_identity"] = garbage + rep = vp.Report() + vp.check_chain_hash(proof, rep) + row = [r for r in rep.rows if r[0] == "agent identity"] + assert row and row[0][1] == vp.FAIL + + +def test_no_check_can_kill_the_run_with_an_exception(monkeypatch): + """La classe, pas les deux cas trouvés : un témoin qui lève doit devenir un échec. + + Sinon chaque nouveau témoin réintroduit la même panne, et elle ne se voit qu'au + premier artefact malformé rencontré en vrai. + """ + def boom(*a, **kw): + raise RuntimeError("témoin cassé") + monkeypatch.setattr(vp, "check_identity", boom) + rep = vp.Report() + vp.check_chain_hash(_proof_3_1(), rep) + assert rep.failed, "une exception d'un témoin doit se lire comme un échec" diff --git a/trust_layer/app.py b/trust_layer/app.py index 7cdc7d7..38998a9 100644 --- a/trust_layer/app.py +++ b/trust_layer/app.py @@ -286,6 +286,9 @@ def filter(self, record: logging.LogRecord) -> bool: # --- Proof access tracking (Redis-backed, in-memory fallback) --- _proof_access_counts: dict[str, list[float]] = defaultdict(list) # fallback only _ABUSE_THRESHOLD = 100 # max requests per hour per IP +# agent_identity is committed and publicly disclosed from spec 3.1 on: once anchored it +# cannot be edited or removed. A DID fits far below this; production carries 27 chars. +_MAX_AGENT_IDENTITY_LEN = 256 _ABUSE_WINDOW = 3600 # --- Failed auth rate limiting (brute-force protection on API key endpoints) --- @@ -697,6 +700,21 @@ async def proxy_endpoint( amount = 0.0 try: + # Spec 3.1 commits agent_identity and publishes it in `disclosed`: the value is + # engraved and served forever. Bound it here, before it reaches the commitments. + # Measured on production before choosing 256: 2 distinct identities, 27 chars max. + if x_agent_identity is not None: + if len(x_agent_identity) > _MAX_AGENT_IDENTITY_LEN or any( + ord(c) < 32 or ord(c) == 127 for c in x_agent_identity + ): + return JSONResponse(status_code=400, content={ + "error": "invalid_agent_identity", + "message": ( + f"agent_identity must be at most {_MAX_AGENT_IDENTITY_LEN} " + "characters and carry no control character" + ), + }) + result = await execute_proxy( target=target, method=method,