Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 105 additions & 3 deletions scripts/verify_proof.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------------------------------------------
Expand All @@ -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):
Expand Down Expand Up @@ -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 = []
Expand All @@ -224,13 +236,103 @@ 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.

Deliberately labelled 'self-consistency': it cannot detect a fabricated proof,
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)

Expand Down
2 changes: 1 addition & 1 deletion tests/test_commitments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
63 changes: 55 additions & 8 deletions tests/test_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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 ---
Expand Down Expand Up @@ -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
Loading
Loading