diff --git a/CHANGELOG.md b/CHANGELOG.md index 7117e26..a72da1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,43 @@ 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 +- `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 + +--- + ## [1.8.2] — 2026-09-13 ### Internal diff --git a/scripts/verify_proof.py b/scripts/verify_proof.py index 123b32a..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 ----------------------------------------------------------- @@ -133,7 +137,13 @@ 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", + "identity_consistent") def _canonical_json(data): @@ -199,7 +209,9 @@ 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 = _disclosed_map(proof, disclosure) if not disclosed: return expected bad = [] @@ -224,6 +236,92 @@ 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? + + 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 +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 = _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_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..566d7b8 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 --- @@ -251,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_identity_anchoring.py b/tests/test_identity_anchoring.py new file mode 100644 index 0000000..29e7be8 --- /dev/null +++ b/tests/test_identity_anchoring.py @@ -0,0 +1,260 @@ +"""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_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" + + +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..6b6ba30 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,10 @@ 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, + "identity_consistent": None, } assert proof["_raw_chain_hash"] == commitments_root(proof["commitments"]) @@ -539,7 +547,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 +577,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..5dd97fb 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,10 @@ 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 IDENTITY_FIELDS: + 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 +186,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_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 + 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..8aecc62 100644 --- a/tests/test_verify_proof.py +++ b/tests/test_verify_proof.py @@ -463,3 +463,117 @@ 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 + + +# --- 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, diff --git a/trust_layer/proofs.py b/trust_layer/proofs.py index 566ff58..292237c 100644 --- a/trust_layer/proofs.py +++ b/trust_layer/proofs.py @@ -10,14 +10,40 @@ 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_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 +# 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: @@ -49,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, @@ -72,6 +99,14 @@ 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 + 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 @@ -92,11 +127,12 @@ 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, }, "certification_fee": payment_data, + "identity_consistent": identity_consistent, "timestamp": timestamp, "_raw_request_hash": request_hash, "_raw_response_hash": response_hash, @@ -162,6 +198,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 +296,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. " 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,