diff --git a/src/agentgraph_bridge_erc8004/DEPLOY.md b/src/agentgraph_bridge_erc8004/DEPLOY.md new file mode 100644 index 0000000..3331bda --- /dev/null +++ b/src/agentgraph_bridge_erc8004/DEPLOY.md @@ -0,0 +1,136 @@ +# ERC-8004 Bridge — Production Deployment + +End-to-end deploy guide for the `agentgraph_bridge_erc8004` package in prod. + +## Prerequisites + +1. **`ETH_RPC_URL`** — Alchemy or Quicknode HTTPS endpoint to Ethereum mainnet. + Free tier sufficient (300M Alchemy CU/month vs. our ~100 calls/day). + Already deployed in prod (`.env.production`) as of 2026-05-22. + +2. **EIP-8004 contract addresses** — currently placeholder zeros pending the + canonical EIP-8004 mainnet deployment. Once verified, set: + ``` + ERC8004_IDENTITY_ADDRESS=0x... + ERC8004_REPUTATION_ADDRESS=0x... + ERC8004_VALIDATION_ADDRESS=0x... + ``` + Until these land, the bridge is functionally inert in prod (any + read attempt against `0x000...000` returns `RegistryReadError`). + Composite trust score behavior unchanged (the lazy import in + `src/trust/score.py::_external_score_with_attestations` only fires + when caller passes attestations, which the sync job won't do + until real addresses exist). + +3. **`pip install agentgraph[erc8004]`** — pulls `web3 + eth-account`. + Not a hard dep on the main backend; only required when the bridge + is actually loaded. + +## One-liner deploy (after EIP-8004 addresses land) + +```bash +ssh -i ~/.ssh/agentgraph-key.pem ec2-user@98.94.217.37 +cd ~/agentgraph +cat >> .env.production < +ERC8004_REPUTATION_ADDRESS= +ERC8004_VALIDATION_ADDRESS= +EOF + +# Sync source dep + recreate backend +git pull +PG_PW=$(grep '^POSTGRES_PASSWORD=' .env.secrets | cut -d= -f2-) +RD_PW=$(grep '^REDIS_PASSWORD=' .env.secrets | cut -d= -f2-) +sudo POSTGRES_PASSWORD="$PG_PW" REDIS_PASSWORD="$RD_PW" \ + docker-compose -f docker-compose.prod.yml up -d --no-deps --force-recreate backend + +# Smoke test +sudo docker exec agentgraph-backend-1 python3 -c " +from agentgraph_bridge_erc8004 import make_reader_from_env +r = make_reader_from_env() +print('reachable:', r.is_reachable()) +print('identity_count:', r.entry_count(__import__('agentgraph_bridge_erc8004').ERC8004Registry.IDENTITY)) +" +``` + +## Background sync job (post EIP-8004 deploy) + +The trust score recompute job runs every cycle. Fetching ERC-8004 attestations +live on every recompute would burn RPC quota + slow the job. Instead: + +1. Cron a separate job (~ every hour) that scans the 3 registries for new + entries since last sync watermark +2. Normalize each entry via `attestation_normalizer.normalize()` +3. Cache verified attestations in a new `erc8004_attestations` table keyed + on `subject_did` + `source_urn` +4. Trust recompute reads from the cache (synchronous, fast) and passes + into `_external_score_with_attestations()` + +Schema (proposal — not yet migrated): +```sql +CREATE TABLE erc8004_attestations ( + source_urn TEXT PRIMARY KEY, -- urn:erc8004:{registry}:{entry_id} + subject_did TEXT NOT NULL, + provider_did TEXT NOT NULL, + claim_type TEXT NOT NULL, -- {identity, transport, authority, continuity} + claim_subtype TEXT, + payload JSONB NOT NULL, + signature_verified BOOLEAN NOT NULL, + registry_signature_verified BOOLEAN NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + last_synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_erc8004_subject ON erc8004_attestations(subject_did); +``` + +The sync job is task #88 follow-on; not blocking v0.3.2 publish. + +## Failure modes + observability + +- **RPC unreachable** — `RegistryReadError` from reader, log warning, skip + this sync cycle. No trust score impact (cache stays). +- **JWKS fetch fails** — `NormalizationError` from normalizer, log warning, + attestation dropped from this sync. Not retried until next sync window. +- **Signature mismatch** — `NormalizationError`. SIGNAL-WORTHY: attestation + was rejected, log at ERROR + emit metric `erc8004.signature_mismatch_total`. +- **Address not yet deployed** — `RegistryReadError` (contract returns + empty bytes for non-existent contracts). Expected until EIP-8004 + mainnet addresses land; suppress to DEBUG-level log. + +## Health check endpoint + +The bridge surfaces `is_reachable()` for liveness probes: + +```python +from agentgraph_bridge_erc8004 import make_reader_from_env +reader = make_reader_from_env() +healthy = reader.is_reachable() # True if RPC responds AND chain_id matches +``` + +Wire into the FastAPI healthcheck under `/health/erc8004` if needed. +Optional — the bridge has no critical-path dependency. + +## Cost (Alchemy free tier) + +| Operation | CU cost | Volume | Monthly CU | +|---|---|---|---| +| `eth_blockNumber` (per is_reachable) | 16 | 1/hr | ~12K | +| `getEntry(uint256)` (per attestation sync) | 16 | ~50/day | ~24K | +| `EntrySubmitted` event scan | 16/block × ~50 blocks | 1/hr | ~600K | +| **Total estimated monthly CU** | | | **~640K** | + +Vs. Alchemy free tier limit of 300M CU/month. **0.2% utilization.** No cost +concern; comfortable headroom for 100x growth in attestation volume. + +## Rollback + +The bridge is opt-in. To disable entirely: +1. Unset `ERC8004_IDENTITY_ADDRESS` / `_REPUTATION_ADDRESS` / `_VALIDATION_ADDRESS` +2. Restart backend +3. Trust recompute reverts to community-signal-only external scoring + +No data migration required — the bridge writes to its own cache table +which can be left in place or `DROP TABLE` cleanly. diff --git a/src/agentgraph_bridge_erc8004/__init__.py b/src/agentgraph_bridge_erc8004/__init__.py index f16476f..64ab292 100644 --- a/src/agentgraph_bridge_erc8004/__init__.py +++ b/src/agentgraph_bridge_erc8004/__init__.py @@ -26,6 +26,10 @@ """ from __future__ import annotations +from agentgraph_bridge_erc8004.attestation_normalizer import ( + NormalizationError, + normalize, +) from agentgraph_bridge_erc8004.models import ( ERC8004Entry, ERC8004Registry, @@ -36,6 +40,11 @@ RegistryReadError, make_reader_from_env, ) +from agentgraph_bridge_erc8004.score_ingest import ( + blend_with_community_signals, + score, + score_breakdown, +) from agentgraph_bridge_erc8004.urn_resolver import ( ParsedURN, URNParseError, @@ -46,12 +55,17 @@ "ERC8004Entry", "ERC8004Registry", "ERC8004RegistryReader", + "NormalizationError", "NormalizedAttestation", "ParsedURN", "RegistryReadError", "URNParseError", + "blend_with_community_signals", "make_reader_from_env", + "normalize", "parse_erc8004_urn", + "score", + "score_breakdown", ] -__version__ = "0.1.0" # Day 2 ships registry_reader; Day 3 adds normalizer + score_ingest +__version__ = "0.2.0" # Day 3 ships normalizer + score_ingest diff --git a/src/agentgraph_bridge_erc8004/attestation_normalizer.py b/src/agentgraph_bridge_erc8004/attestation_normalizer.py new file mode 100644 index 0000000..b75f493 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/attestation_normalizer.py @@ -0,0 +1,343 @@ +"""Normalize a raw ERC-8004 registry entry into a verified attestation. + +Takes the `data` bytes from an `ERC8004Entry` (which carries a CTEF envelope), +parses the envelope, resolves the issuer's `did:web` to fetch Ed25519 public +keys, verifies the signature against the JCS-canonical preimage, and returns +a `NormalizedAttestation` ready for ingestion into the composite trust score. + +The CTEF v0.3.1+ envelope shape this normalizer accepts: + + { + "claim_type": "identity" | "transport" | "authority" | "continuity", + "claim_subtype": , + "subject_did": "did:web:agent.example.com", + "provider_did": "did:web:issuer.example.com", + "issued_at": "", + "expires_at": "", + "payload": { ... claim-specific fields ... }, + "signature": { + "alg": "EdDSA", + "kid": "", + "sig": "" + } + } + +Verification preimage is JCS(envelope_without_signature) per CTEF v0.3.1. +Signing key resolution is by `kid` lookup in the provider's published JWKS +at `https:///.well-known/jwks.json`. + +The normalizer treats signature verification as a HARD requirement — any +failure (malformed envelope, unreachable JWKS, kid not found, signature +mismatch) raises `NormalizationError`. Downstream score ingestion never +sees an unverified attestation. +""" +from __future__ import annotations + +import base64 +import json +from datetime import datetime, timezone +from typing import Optional + +import httpx +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from agentgraph_bridge_erc8004.models import ERC8004Entry, NormalizedAttestation + +# Closed claim_type set per CTEF v0.3.1 §1.1 +_VALID_CLAIM_TYPES = {"identity", "transport", "authority", "continuity"} + +# Timeout for JWKS fetches. Short — the bridge is not in a hot path, +# and a slow JWKS endpoint should fail closed quickly rather than block +# the trust recompute job. +_JWKS_TIMEOUT_SECONDS = 10.0 + + +class NormalizationError(ValueError): + """Raised when an ERC-8004 entry can't be normalized to a verified attestation. + + Covers the full failure surface: + - Malformed envelope (not JSON, missing required fields, invalid claim_type) + - did:web resolution failure (invalid DID format, JWKS fetch error) + - Signature verification failure (kid not found, alg mismatch, invalid sig) + """ + + +def _decode_b64url(s: str) -> bytes: + """base64url decode with padding tolerance.""" + pad = "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s + pad) + + +def _parse_iso_timestamp(ts: str) -> datetime: + """Parse an RFC 3339 timestamp into a tz-aware datetime.""" + # Handle both "Z" suffix and explicit "+00:00" + if ts.endswith("Z"): + ts = ts[:-1] + "+00:00" + dt = datetime.fromisoformat(ts) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _did_web_to_jwks_url(did: str) -> str: + """Resolve a did:web identifier to its JWKS endpoint URL. + + did:web:example.com → https://example.com/.well-known/jwks.json + did:web:example.com:agent:x → https://example.com/agent/x/jwks.json + + Per W3C DID did:web spec. Path segments after the domain become URL + path components separated by `/`. + """ + if not did.startswith("did:web:"): + raise NormalizationError( + f"Provider DID must use did:web method, got: {did!r}", + ) + rest = did[len("did:web:"):] + if not rest: + raise NormalizationError(f"Empty did:web identifier: {did!r}") + + parts = rest.split(":") + domain = parts[0] + if not domain: + raise NormalizationError(f"Empty domain in did:web: {did!r}") + + if len(parts) == 1: + # Bare domain → .well-known/jwks.json + return f"https://{domain}/.well-known/jwks.json" + + # Path-form → //jwks.json + path = "/".join(parts[1:]) + return f"https://{domain}/{path}/jwks.json" + + +def _fetch_jwks(jwks_url: str, http_client: Optional[httpx.Client] = None) -> list[dict]: + """Fetch + parse JWKS from a URL. + + Returns the `keys` array. Caller is responsible for filtering by kid. + """ + own_client = http_client is None + client = http_client or httpx.Client(timeout=_JWKS_TIMEOUT_SECONDS) + try: + resp = client.get(jwks_url, headers={"User-Agent": "agentgraph-erc8004/0.1"}) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as exc: + raise NormalizationError(f"JWKS fetch failed for {jwks_url}: {exc}") from exc + except json.JSONDecodeError as exc: + raise NormalizationError(f"JWKS at {jwks_url} is not valid JSON: {exc}") from exc + finally: + if own_client: + client.close() + + keys = data.get("keys") + if not isinstance(keys, list): + raise NormalizationError( + f"JWKS at {jwks_url} missing 'keys' array", + ) + return keys + + +def _find_ed25519_key(keys: list[dict], kid: str) -> Ed25519PublicKey: + """Look up an Ed25519 public key by kid in a JWKS keys array. + + Returns a usable Ed25519PublicKey. Raises NormalizationError if: + - kid not found + - matched key is not Ed25519 (kty=OKP, crv=Ed25519) + - public key bytes are malformed + """ + matching = [k for k in keys if k.get("kid") == kid] + if not matching: + raise NormalizationError(f"kid {kid!r} not found in JWKS") + + jwk = matching[0] + if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519": + raise NormalizationError( + f"Key kid={kid!r} is not Ed25519 " + f"(got kty={jwk.get('kty')!r}, crv={jwk.get('crv')!r})", + ) + + x = jwk.get("x") + if not isinstance(x, str): + raise NormalizationError(f"Key kid={kid!r} missing 'x' (public key)") + + try: + pubkey_bytes = _decode_b64url(x) + return Ed25519PublicKey.from_public_bytes(pubkey_bytes) + except (ValueError, Exception) as exc: + raise NormalizationError( + f"Key kid={kid!r} has malformed Ed25519 public bytes: {exc}", + ) from exc + + +def _verify_envelope_signature(envelope: dict, pubkey: Ed25519PublicKey) -> None: + """Verify Ed25519 signature on a CTEF envelope. + + Preimage is JCS(envelope_without_signature). Raises NormalizationError + on any failure (invalid signature, malformed sig bytes). + """ + sig_block = envelope.get("signature") + if not isinstance(sig_block, dict): + raise NormalizationError("Envelope missing 'signature' block") + + if sig_block.get("alg") != "EdDSA": + raise NormalizationError( + f"Unsupported signature alg: {sig_block.get('alg')!r} (expected EdDSA)", + ) + + sig_b64 = sig_block.get("sig") + if not isinstance(sig_b64, str): + raise NormalizationError("Envelope signature.sig missing or not a string") + + try: + sig_bytes = _decode_b64url(sig_b64) + except Exception as exc: + raise NormalizationError(f"Malformed signature base64url: {exc}") from exc + + # Preimage: envelope minus signature, JCS-canonicalized + preimage_obj = {k: v for k, v in envelope.items() if k != "signature"} + preimage_bytes = rfc8785.dumps(preimage_obj) + + try: + pubkey.verify(sig_bytes, preimage_bytes) + except InvalidSignature as exc: + raise NormalizationError("Ed25519 signature verification FAILED") from exc + + +def normalize( + entry: ERC8004Entry, + *, + registry_signature_verified: bool = True, + http_client: Optional[httpx.Client] = None, + freshness_ttl_seconds: Optional[int] = None, +) -> NormalizedAttestation: + """Normalize a raw ERC-8004 entry into a verified attestation. + + Args: + entry: Raw entry from `ERC8004RegistryReader.read_entry()`. + registry_signature_verified: Caller asserts the entry submitter's + Ethereum-layer signature was verified on-chain. This is true + by definition for entries returned from the registry (the + chain enforces the submitter signature at write time). Pass + False only when consuming entries via untrusted indexer paths. + http_client: Optional shared httpx.Client for JWKS fetches. + Pass one in for connection reuse across batch normalization. + freshness_ttl_seconds: If set and the envelope has no `expires_at`, + derive an implicit expiry of `issued_at + freshness_ttl_seconds`. + + Returns: + NormalizedAttestation with both signature layers verified and + the parsed CTEF payload populated. Caller should check + `.is_admissible` before feeding into the composite trust score. + + Raises: + NormalizationError on any verification failure. + """ + source_urn = f"urn:erc8004:{entry.registry.value}:{entry.entry_id}" + + # Step 1: decode `data` bytes as UTF-8 JSON + try: + raw_text = entry.data.decode("utf-8") + except UnicodeDecodeError as exc: + raise NormalizationError( + f"{source_urn}: entry data is not valid UTF-8: {exc}", + ) from exc + + try: + envelope = json.loads(raw_text) + except json.JSONDecodeError as exc: + raise NormalizationError( + f"{source_urn}: entry data is not valid JSON: {exc}", + ) from exc + + if not isinstance(envelope, dict): + raise NormalizationError( + f"{source_urn}: envelope must be a JSON object, got {type(envelope).__name__}", + ) + + # Step 2: validate required envelope fields + claim_type = envelope.get("claim_type") + if claim_type not in _VALID_CLAIM_TYPES: + raise NormalizationError( + f"{source_urn}: invalid claim_type {claim_type!r} " + f"(expected one of {sorted(_VALID_CLAIM_TYPES)})", + ) + + subject_did = envelope.get("subject_did") + if not isinstance(subject_did, str) or not subject_did: + raise NormalizationError(f"{source_urn}: subject_did missing or empty") + + provider_did = envelope.get("provider_did") + if not isinstance(provider_did, str) or not provider_did: + raise NormalizationError(f"{source_urn}: provider_did missing or empty") + + issued_at_raw = envelope.get("issued_at") + if not isinstance(issued_at_raw, str): + raise NormalizationError(f"{source_urn}: issued_at missing or not a string") + + try: + issued_at = _parse_iso_timestamp(issued_at_raw) + except (ValueError, TypeError) as exc: + raise NormalizationError( + f"{source_urn}: issued_at not RFC 3339: {issued_at_raw!r} ({exc})", + ) from exc + + expires_at: Optional[datetime] = None + expires_at_raw = envelope.get("expires_at") + if expires_at_raw is not None: + if not isinstance(expires_at_raw, str): + raise NormalizationError( + f"{source_urn}: expires_at must be a string if present", + ) + try: + expires_at = _parse_iso_timestamp(expires_at_raw) + except (ValueError, TypeError) as exc: + raise NormalizationError( + f"{source_urn}: expires_at not RFC 3339: {expires_at_raw!r} ({exc})", + ) from exc + elif freshness_ttl_seconds is not None: + # No explicit expiry → derive from issued_at + TTL + from datetime import timedelta + expires_at = issued_at + timedelta(seconds=freshness_ttl_seconds) + + # Step 3: resolve provider's JWKS + find signing key + sig_block = envelope.get("signature") + if not isinstance(sig_block, dict): + raise NormalizationError(f"{source_urn}: signature block missing") + kid = sig_block.get("kid") + if not isinstance(kid, str): + raise NormalizationError(f"{source_urn}: signature.kid missing") + + jwks_url = _did_web_to_jwks_url(provider_did) + keys = _fetch_jwks(jwks_url, http_client=http_client) + pubkey = _find_ed25519_key(keys, kid) + + # Step 4: verify Ed25519 signature over JCS preimage + _verify_envelope_signature(envelope, pubkey) + + # Step 5: compute freshness TTL remaining + freshness_remaining: Optional[int] = None + if expires_at is not None: + delta = (expires_at - datetime.now(timezone.utc)).total_seconds() + freshness_remaining = max(0, int(delta)) + + return NormalizedAttestation( + source_urn=source_urn, + claim_type=claim_type, + claim_subtype=envelope.get("claim_subtype"), + subject_did=subject_did, + provider_did=provider_did, + payload=envelope.get("payload") or {}, + signature_verified=True, + registry_signature_verified=registry_signature_verified, + issued_at=issued_at, + expires_at=expires_at, + freshness_ttl_remaining_seconds=freshness_remaining, + ) + + +__all__ = [ + "NormalizationError", + "normalize", +] diff --git a/src/agentgraph_bridge_erc8004/fixtures/README.md b/src/agentgraph_bridge_erc8004/fixtures/README.md new file mode 100644 index 0000000..6bbdcb3 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/README.md @@ -0,0 +1,44 @@ +# ERC-8004 Bridge Fixtures + +3 mainnet-shaped snapshot fixtures for offline reproduction of the normalizer's +verification path. Each fixture contains: + +- **`entry.json`** — Raw `ERC8004Entry` shape as returned by the registry contract +- **`envelope.json`** — Parsed CTEF envelope from the entry's `data` field (pre-signature) +- **`jwks.json`** — JWKS the provider DID resolves to +- **`expected_normalized.json`** — Expected `NormalizedAttestation` output + +To regenerate signatures, use `scripts/regen_fixture_signatures.py` (signs each +envelope with a deterministic Ed25519 key derived from the fixture name). + +Fixtures are **shape examples**, not signed real attestations from production +EIP-8004 contracts (those don't exist yet on mainnet — task #88 will swap these +out with real snapshots once the canonical EIP-8004 deployment is verified). + +## Fixtures + +| File | claim_type | Provider | Purpose | +|---|---|---|---| +| `identity_basic.json` | identity | did:web:registrar.example.com | Minimal identity attestation, ~150 bytes data field | +| `authority_tier_upgrade.json` | authority | did:web:trust.arkforge.tech | ArkForge tier_upgrade_proof shape, composes with row #8 of v0.3.3 matrix | +| `continuity_behavioral.json` | continuity | did:web:dominion-observatory.sgdata.workers.dev | Dominion-shaped behavioral evidence, composes with row #5 of v0.3.3 matrix | + +## Reproduction + +```python +import json +from agentgraph_bridge_erc8004 import ERC8004Entry, normalize + +with open("fixtures/identity_basic/entry.json") as f: + entry_data = json.load(f) +entry = ERC8004Entry(**entry_data) + +# Mock the JWKS fetch with the fixture's JWKS +import httpx +def handler(req): + return httpx.Response(200, json=json.load(open("fixtures/identity_basic/jwks.json"))) +client = httpx.Client(transport=httpx.MockTransport(handler)) + +result = normalize(entry, http_client=client) +assert result.is_admissible +``` diff --git a/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/entry.json b/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/entry.json new file mode 100644 index 0000000..bdd67c4 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/entry.json @@ -0,0 +1,10 @@ +{ + "registry": "reputation", + "entry_id": 42, + "submitter": "0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "subject_did": "did:web:agent.example.com", + "data_b64": "eyJjbGFpbV90eXBlIjoiYXV0aG9yaXR5Iiwic3ViamVjdF9kaWQiOiJkaWQ6d2ViOmFnZW50LmV4YW1wbGUuY29tIiwicHJvdmlkZXJfZGlkIjoiZGlkOndlYjp0cnVzdC5hcmtmb3JnZS50ZWNoIiwiaXNzdWVkX2F0IjoiMjAyNi0wNS0yMlQxMjowMDowMCswMDowMCIsImV4cGlyZXNfYXQiOiIyMDMwLTA1LTIyVDEyOjAwOjAwKzAwOjAwIiwicGF5bG9hZCI6eyJzdWJqZWN0X2RpZCI6ImRpZDp3ZWI6YWdlbnQuZXhhbXBsZS5jb20iLCJmcm9tX3RpZXIiOiJORVVUUkFMIiwidG9fdGllciI6IlRSVVNURUQiLCJwb2xpY3lfcmVmIjoic2hhMjU2OmFlYjAyMDhhMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJzY29wZV9ib3VuZGFyeSI6InNlc3Npb246Y3RlZi10aWVyLXVwZ3JhZGUtZml4dHVyZS12MSIsInJlcXVlc3Rlcl9kaWQiOiJkaWQ6d2ViOnJlcXVlc3Rlci5leGFtcGxlIiwiY29uc3RyYWludF9ldmFsdWF0aW9uIjp7ImZhY2V0IjoiUmVzb3VyY2VWaW9sYXRpb24iLCJsaW1pdCI6MTAwLCJhY3R1YWwiOjQyLCJkZWx0YSI6LTU4fX0sImNsYWltX3N1YnR5cGUiOiJ0aWVyX3VwZ3JhZGUiLCJzaWduYXR1cmUiOnsiYWxnIjoiRWREU0EiLCJraWQiOiJmaXh0dXJlLWtleS0xIiwic2lnIjoiMk45RUZfZm5IdGVxN2JONjJiX2ZrMk9hWW5TVEF5LTZSSF9DenM1NWNCYm11d1djT09mU2JIQ0UwYllzaV8xTkVZSnhiRjN5ZGJBNjZNWnFGc00zQ1EifX0", + "block_number": 25000042, + "block_timestamp": "2026-05-22T12:00:00+00:00", + "tx_hash": "0xf506e1398e2343cdb8c82238b4fbda541fa0b89e673d2ea4ee8a56c708deb8f8" +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/envelope.json b/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/envelope.json new file mode 100644 index 0000000..026936c --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/envelope.json @@ -0,0 +1,27 @@ +{ + "claim_type": "authority", + "subject_did": "did:web:agent.example.com", + "provider_did": "did:web:trust.arkforge.tech", + "issued_at": "2026-05-22T12:00:00+00:00", + "expires_at": "2030-05-22T12:00:00+00:00", + "payload": { + "subject_did": "did:web:agent.example.com", + "from_tier": "NEUTRAL", + "to_tier": "TRUSTED", + "policy_ref": "sha256:aeb0208a00000000000000000000000000000000000000000000000000000000", + "scope_boundary": "session:ctef-tier-upgrade-fixture-v1", + "requester_did": "did:web:requester.example", + "constraint_evaluation": { + "facet": "ResourceViolation", + "limit": 100, + "actual": 42, + "delta": -58 + } + }, + "claim_subtype": "tier_upgrade", + "signature": { + "alg": "EdDSA", + "kid": "fixture-key-1", + "sig": "2N9EF_fnHteq7bN62b_fk2OaYnSTAy-6RH_Czs55cBbmuwWcOOfSbHCE0bYsi_1NEYJxbF3ydbA66MZqFsM3CQ" + } +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/expected_normalized.json b/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/expected_normalized.json new file mode 100644 index 0000000..01ed313 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/expected_normalized.json @@ -0,0 +1,25 @@ +{ + "source_urn": "urn:erc8004:reputation:42", + "claim_type": "authority", + "claim_subtype": "tier_upgrade", + "subject_did": "did:web:agent.example.com", + "provider_did": "did:web:trust.arkforge.tech", + "payload": { + "subject_did": "did:web:agent.example.com", + "from_tier": "NEUTRAL", + "to_tier": "TRUSTED", + "policy_ref": "sha256:aeb0208a00000000000000000000000000000000000000000000000000000000", + "scope_boundary": "session:ctef-tier-upgrade-fixture-v1", + "requester_did": "did:web:requester.example", + "constraint_evaluation": { + "facet": "ResourceViolation", + "limit": 100, + "actual": 42, + "delta": -58 + } + }, + "signature_verified": true, + "registry_signature_verified": true, + "issued_at": "2026-05-22T12:00:00+00:00", + "expires_at": "2030-05-22T12:00:00+00:00" +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/jwks.json b/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/jwks.json new file mode 100644 index 0000000..249e337 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/authority_tier_upgrade/jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "fixture-key-1", + "x": "hqa4JplLpqWYmSuv1T0qpGU5N-IpojIcK8ign4C1jW4", + "use": "sig", + "alg": "EdDSA" + } + ] +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/entry.json b/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/entry.json new file mode 100644 index 0000000..6789fc4 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/entry.json @@ -0,0 +1,10 @@ +{ + "registry": "validation", + "entry_id": 99, + "submitter": "0xefefefefefefefefefefefefefefefefefefefef", + "subject_did": "did:web:agent.example.com", + "data_b64": "eyJjbGFpbV90eXBlIjoiY29udGludWl0eSIsInN1YmplY3RfZGlkIjoiZGlkOndlYjphZ2VudC5leGFtcGxlLmNvbSIsInByb3ZpZGVyX2RpZCI6ImRpZDp3ZWI6ZG9taW5pb24tb2JzZXJ2YXRvcnkuc2dkYXRhLndvcmtlcnMuZGV2IiwiaXNzdWVkX2F0IjoiMjAyNi0wNS0yMlQxMjowMDowMCswMDowMCIsImV4cGlyZXNfYXQiOiIyMDMwLTA1LTIyVDEyOjAwOjAwKzAwOjAwIiwicGF5bG9hZCI6eyJzdWJqZWN0X2RpZCI6ImRpZDp3ZWI6YWdlbnQuZXhhbXBsZS5jb20iLCJldmlkZW5jZV9jbGFzcyI6ImJlaGF2aW9yYWwiLCJpbnRlcmFjdGlvbl9zdWNjZXNzX3JhdGUiOjAuOTQsImxhdGVuY3lfcDk5X21zIjo4NTAsImFub21hbHlfc2NvcmUiOjAuMDIsImNvbXBsaWFuY2VfcG9zdHVyZSI6WyJldS1haS1hY3QtYXJ0LTEyIiwic2luZ2Fwb3JlLWltZGEiXSwib2JzZXJ2YXRpb25fd2luZG93X2RheXMiOjMwfSwiY2xhaW1fc3VidHlwZSI6ImJlaGF2aW9yYWxfZXZhbCIsInNpZ25hdHVyZSI6eyJhbGciOiJFZERTQSIsImtpZCI6ImZpeHR1cmUta2V5LTEiLCJzaWciOiJWRGdaYldkQngySDFZU0Q2c01TSE50MUJwemxFYVUxbWhZVWxJTmhyZjlaTkFVTVB1Vks1LVlDQTFmbWpVQXZ1bUdPU0YwckhEWmN1ZnpicjR3QzVDZyJ9fQ", + "block_number": 25000099, + "block_timestamp": "2026-05-22T12:00:00+00:00", + "tx_hash": "0xf2a36bf72c6429ceb5d7e979661928176c141c609dc492428ec7116293ad9898" +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/envelope.json b/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/envelope.json new file mode 100644 index 0000000..34f3873 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/envelope.json @@ -0,0 +1,25 @@ +{ + "claim_type": "continuity", + "subject_did": "did:web:agent.example.com", + "provider_did": "did:web:dominion-observatory.sgdata.workers.dev", + "issued_at": "2026-05-22T12:00:00+00:00", + "expires_at": "2030-05-22T12:00:00+00:00", + "payload": { + "subject_did": "did:web:agent.example.com", + "evidence_class": "behavioral", + "interaction_success_rate": 0.94, + "latency_p99_ms": 850, + "anomaly_score": 0.02, + "compliance_posture": [ + "eu-ai-act-art-12", + "singapore-imda" + ], + "observation_window_days": 30 + }, + "claim_subtype": "behavioral_eval", + "signature": { + "alg": "EdDSA", + "kid": "fixture-key-1", + "sig": "VDgZbWdBx2H1YSD6sMSHNt1BpzlEaU1mhYUlINhrf9ZNAUMPuVK5-YCA1fmjUAvumGOSF0rHDZcufzbr4wC5Cg" + } +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/expected_normalized.json b/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/expected_normalized.json new file mode 100644 index 0000000..e696317 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/expected_normalized.json @@ -0,0 +1,23 @@ +{ + "source_urn": "urn:erc8004:validation:99", + "claim_type": "continuity", + "claim_subtype": "behavioral_eval", + "subject_did": "did:web:agent.example.com", + "provider_did": "did:web:dominion-observatory.sgdata.workers.dev", + "payload": { + "subject_did": "did:web:agent.example.com", + "evidence_class": "behavioral", + "interaction_success_rate": 0.94, + "latency_p99_ms": 850, + "anomaly_score": 0.02, + "compliance_posture": [ + "eu-ai-act-art-12", + "singapore-imda" + ], + "observation_window_days": 30 + }, + "signature_verified": true, + "registry_signature_verified": true, + "issued_at": "2026-05-22T12:00:00+00:00", + "expires_at": "2030-05-22T12:00:00+00:00" +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/jwks.json b/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/jwks.json new file mode 100644 index 0000000..61a69ce --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/continuity_behavioral/jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "fixture-key-1", + "x": "hS0X0ZZzBLZogoiT8prqPBfTQiENkt6y_pO2KvRUSN8", + "use": "sig", + "alg": "EdDSA" + } + ] +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/identity_basic/entry.json b/src/agentgraph_bridge_erc8004/fixtures/identity_basic/entry.json new file mode 100644 index 0000000..b65bba3 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/identity_basic/entry.json @@ -0,0 +1,10 @@ +{ + "registry": "identity", + "entry_id": 1, + "submitter": "0xabababababababababababababababababababab", + "subject_did": "did:web:agent.example.com", + "data_b64": "eyJjbGFpbV90eXBlIjoiaWRlbnRpdHkiLCJzdWJqZWN0X2RpZCI6ImRpZDp3ZWI6YWdlbnQuZXhhbXBsZS5jb20iLCJwcm92aWRlcl9kaWQiOiJkaWQ6d2ViOnJlZ2lzdHJhci5leGFtcGxlLmNvbSIsImlzc3VlZF9hdCI6IjIwMjYtMDUtMjJUMTI6MDA6MDArMDA6MDAiLCJleHBpcmVzX2F0IjoiMjAzMC0wNS0yMlQxMjowMDowMCswMDowMCIsInBheWxvYWQiOnsic3ViIjoiZGlkOndlYjphZ2VudC5leGFtcGxlLmNvbSIsInZlcmlmaWVkX21ldGhvZCI6ImRpZDp3ZWI6YWdlbnQuZXhhbXBsZS5jb20ja2V5LTEiLCJjcmVkZW50aWFsX2lkIjoidmM6aWRlbnRpdHk6YWdlbnQtZXhhbXBsZTowMDEifSwic2lnbmF0dXJlIjp7ImFsZyI6IkVkRFNBIiwia2lkIjoiZml4dHVyZS1rZXktMSIsInNpZyI6ImNxNjhTbEFSMC01dGZmQy1NU19qV2M5UlNGZ0tsZHNTcjFSOGVYOGFxM25VdkVPeHBQTDMzY3Naazc5UC1aX0dUS0NENnRTb2pTVFN3NlU1aklFU0FRIn19", + "block_number": 25000001, + "block_timestamp": "2026-05-22T12:00:00+00:00", + "tx_hash": "0x333f5852f4aea66b8b8b77043ca2c3ce929258a520328273aa5840302c8c71cc" +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/identity_basic/envelope.json b/src/agentgraph_bridge_erc8004/fixtures/identity_basic/envelope.json new file mode 100644 index 0000000..1d03fa2 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/identity_basic/envelope.json @@ -0,0 +1,17 @@ +{ + "claim_type": "identity", + "subject_did": "did:web:agent.example.com", + "provider_did": "did:web:registrar.example.com", + "issued_at": "2026-05-22T12:00:00+00:00", + "expires_at": "2030-05-22T12:00:00+00:00", + "payload": { + "sub": "did:web:agent.example.com", + "verified_method": "did:web:agent.example.com#key-1", + "credential_id": "vc:identity:agent-example:001" + }, + "signature": { + "alg": "EdDSA", + "kid": "fixture-key-1", + "sig": "cq68SlAR0-5tffC-MS_jWc9RSFgKldsSr1R8eX8aq3nUvEOxpPL33csZk79P-Z_GTKCD6tSojSTSw6U5jIESAQ" + } +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/identity_basic/expected_normalized.json b/src/agentgraph_bridge_erc8004/fixtures/identity_basic/expected_normalized.json new file mode 100644 index 0000000..1c0e36e --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/identity_basic/expected_normalized.json @@ -0,0 +1,16 @@ +{ + "source_urn": "urn:erc8004:identity:1", + "claim_type": "identity", + "claim_subtype": null, + "subject_did": "did:web:agent.example.com", + "provider_did": "did:web:registrar.example.com", + "payload": { + "sub": "did:web:agent.example.com", + "verified_method": "did:web:agent.example.com#key-1", + "credential_id": "vc:identity:agent-example:001" + }, + "signature_verified": true, + "registry_signature_verified": true, + "issued_at": "2026-05-22T12:00:00+00:00", + "expires_at": "2030-05-22T12:00:00+00:00" +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/identity_basic/jwks.json b/src/agentgraph_bridge_erc8004/fixtures/identity_basic/jwks.json new file mode 100644 index 0000000..5cdf7e5 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/identity_basic/jwks.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": "fixture-key-1", + "x": "qNuelHLpj-4o_gZQXgn62fJP8OiiBSlXog9j7g9bIkk", + "use": "sig", + "alg": "EdDSA" + } + ] +} diff --git a/src/agentgraph_bridge_erc8004/fixtures/regen_fixtures.py b/src/agentgraph_bridge_erc8004/fixtures/regen_fixtures.py new file mode 100644 index 0000000..8ec6798 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/fixtures/regen_fixtures.py @@ -0,0 +1,211 @@ +"""Generate 3 mainnet-shaped snapshot fixtures with real Ed25519 signatures. + +Run from the package root: + python -m agentgraph_bridge_erc8004.fixtures.regen_fixtures + +Produces 3 subdirectories under fixtures/, each containing: + - entry.json: ERC8004Entry shape + - envelope.json: signed CTEF envelope + - jwks.json: matching provider JWKS + - expected_normalized.json: expected NormalizedAttestation output + +The Ed25519 keypair is deterministic per fixture (seeded from fixture name +via SHA-256). Re-running this script produces byte-identical output, so +fixture diffs are stable in git. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import rfc8785 +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + +FIXTURES_DIR = Path(__file__).parent + +# Deterministic timestamp: 2026-05-22 12:00 UTC. Far enough in the future +# that fixtures stay admissible for years. +_FIXTURE_ISSUED_AT = "2026-05-22T12:00:00+00:00" +_FIXTURE_EXPIRES_AT = "2030-05-22T12:00:00+00:00" + + +def _b64url(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode().rstrip("=") + + +def _deterministic_priv(seed_str: str) -> Ed25519PrivateKey: + """Derive a deterministic Ed25519 private key from a string seed.""" + seed = hashlib.sha256(seed_str.encode()).digest() + return Ed25519PrivateKey.from_private_bytes(seed) + + +def _sign_envelope(env: dict, priv: Ed25519PrivateKey, kid: str) -> dict: + """Sign a CTEF envelope in-place with deterministic Ed25519.""" + preimage = rfc8785.dumps(env) + sig = priv.sign(preimage) + return { + **env, + "signature": { + "alg": "EdDSA", + "kid": kid, + "sig": _b64url(sig), + }, + } + + +def _build_jwks(priv: Ed25519PrivateKey, kid: str) -> dict: + """Build a JWKS containing the public half of `priv`.""" + pub = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) + return { + "keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": kid, + "x": _b64url(pub), + "use": "sig", + "alg": "EdDSA", + }, + ], + } + + +def _build_fixture( + name: str, + *, + registry: str, + entry_id: int, + submitter: str, + subject_did: str, + provider_did: str, + claim_type: str, + claim_subtype: str | None = None, + payload: dict, + kid: str = "fixture-key-1", +) -> None: + """Build one fixture subdirectory.""" + out_dir = FIXTURES_DIR / name + out_dir.mkdir(exist_ok=True) + + priv = _deterministic_priv(f"agentgraph-erc8004-fixture:{name}") + envelope = { + "claim_type": claim_type, + "subject_did": subject_did, + "provider_did": provider_did, + "issued_at": _FIXTURE_ISSUED_AT, + "expires_at": _FIXTURE_EXPIRES_AT, + "payload": payload, + } + if claim_subtype is not None: + envelope["claim_subtype"] = claim_subtype + envelope_signed = _sign_envelope(envelope, priv, kid) + + data_bytes = json.dumps(envelope_signed, separators=(",", ":")).encode() + + entry = { + "registry": registry, + "entry_id": entry_id, + "submitter": submitter, + "subject_did": subject_did, + "data_b64": _b64url(data_bytes), # raw bytes round-tripped via b64 + "block_number": 25_000_000 + entry_id, + "block_timestamp": _FIXTURE_ISSUED_AT, + "tx_hash": "0x" + hashlib.sha256(name.encode()).hexdigest(), + } + + jwks = _build_jwks(priv, kid) + + expected = { + "source_urn": f"urn:erc8004:{registry}:{entry_id}", + "claim_type": claim_type, + "claim_subtype": claim_subtype, + "subject_did": subject_did, + "provider_did": provider_did, + "payload": payload, + "signature_verified": True, + "registry_signature_verified": True, + "issued_at": _FIXTURE_ISSUED_AT, + "expires_at": _FIXTURE_EXPIRES_AT, + } + + (out_dir / "entry.json").write_text(json.dumps(entry, indent=2) + "\n") + (out_dir / "envelope.json").write_text(json.dumps(envelope_signed, indent=2) + "\n") + (out_dir / "jwks.json").write_text(json.dumps(jwks, indent=2) + "\n") + (out_dir / "expected_normalized.json").write_text( + json.dumps(expected, indent=2) + "\n", + ) + print(f" ✓ {name}") + + +def main() -> None: + print("Generating ERC-8004 bridge fixtures:") + + _build_fixture( + "identity_basic", + registry="identity", + entry_id=1, + submitter="0x" + "ab" * 20, + subject_did="did:web:agent.example.com", + provider_did="did:web:registrar.example.com", + claim_type="identity", + payload={ + "sub": "did:web:agent.example.com", + "verified_method": "did:web:agent.example.com#key-1", + "credential_id": "vc:identity:agent-example:001", + }, + ) + + _build_fixture( + "authority_tier_upgrade", + registry="reputation", + entry_id=42, + submitter="0x" + "cd" * 20, + subject_did="did:web:agent.example.com", + provider_did="did:web:trust.arkforge.tech", + claim_type="authority", + claim_subtype="tier_upgrade", + payload={ + "subject_did": "did:web:agent.example.com", + "from_tier": "NEUTRAL", + "to_tier": "TRUSTED", + "policy_ref": "sha256:aeb0208a" + "0" * 56, + "scope_boundary": "session:ctef-tier-upgrade-fixture-v1", + "requester_did": "did:web:requester.example", + "constraint_evaluation": { + "facet": "ResourceViolation", + "limit": 100, + "actual": 42, + "delta": -58, + }, + }, + ) + + _build_fixture( + "continuity_behavioral", + registry="validation", + entry_id=99, + submitter="0x" + "ef" * 20, + subject_did="did:web:agent.example.com", + provider_did="did:web:dominion-observatory.sgdata.workers.dev", + claim_type="continuity", + claim_subtype="behavioral_eval", + payload={ + "subject_did": "did:web:agent.example.com", + "evidence_class": "behavioral", + "interaction_success_rate": 0.94, + "latency_p99_ms": 850, + "anomaly_score": 0.02, + "compliance_posture": ["eu-ai-act-art-12", "singapore-imda"], + "observation_window_days": 30, + }, + ) + + print("\nAll fixtures regenerated.") + + +if __name__ == "__main__": + main() diff --git a/src/agentgraph_bridge_erc8004/models.py b/src/agentgraph_bridge_erc8004/models.py index 29469e3..fd882ed 100644 --- a/src/agentgraph_bridge_erc8004/models.py +++ b/src/agentgraph_bridge_erc8004/models.py @@ -10,7 +10,7 @@ """ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from enum import Enum from typing import Any, Optional @@ -78,9 +78,19 @@ class NormalizedAttestation(BaseModel): @property def is_admissible(self) -> bool: - """Both signature layers must verify and attestation must not be expired.""" - return ( - self.signature_verified - and self.registry_signature_verified - and (self.expires_at is None or self.expires_at > datetime.utcnow()) - ) + """Both signature layers must verify and attestation must not be expired. + + Uses tz-aware UTC `now()` to compare against `expires_at`, which is + always tz-aware (per RFC 3339 parsing in the attestation_normalizer). + Comparing naive vs aware datetimes raises TypeError; tz-aware on + both sides avoids the silent-mismatch surface. + """ + if not self.signature_verified or not self.registry_signature_verified: + return False + if self.expires_at is None: + return True + # Normalize expires_at to tz-aware UTC if it came in naive (defensive) + exp = self.expires_at + if exp.tzinfo is None: + exp = exp.replace(tzinfo=timezone.utc) + return exp > datetime.now(timezone.utc) diff --git a/src/agentgraph_bridge_erc8004/score_ingest.py b/src/agentgraph_bridge_erc8004/score_ingest.py new file mode 100644 index 0000000..d1a06c0 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/score_ingest.py @@ -0,0 +1,194 @@ +"""Map normalized ERC-8004 attestations to a 0-1 external-reputation contribution. + +Folds into the existing `_source_reputation_score()` slot in `src/trust/score.py` +(EXTERNAL_WEIGHT = 0.35 of the composite). When ERC-8004 attestations are +present for an entity, this module produces a score that the trust recompute +job blends with the existing community-signal score (max-of-the-two). + +Scoring philosophy — load-bearing decisions: + +1. **Per-claim_type weighting.** Identity attestations are foundational + (cryptographic proof of who); authority + continuity attestations are + incremental (proof of permission + observed behavior). Identity is + capped at 0.6 contribution alone; authority/continuity add up to 0.4 + on top. A bot with only identity attestations caps at 0.6; a bot with + identity + authority + continuity can reach 1.0. + +2. **Provider diversity matters.** Three attestations from the same + provider DID is worth less than three attestations from three + different providers. Distinct-provider count is the inner cap; raw + attestation count beyond that has diminishing returns (log scale). + +3. **Freshness gate.** Expired attestations do not contribute. Period. + This is enforced upstream in `NormalizedAttestation.is_admissible`, + but `score()` re-checks defensively. + +4. **No claim_subtype semantics here.** Subtypes (e.g. `tier_upgrade`) + may inform the consuming app's policy decision, but they do not + affect the substrate-level external-reputation contribution. +""" +from __future__ import annotations + +import math +from collections import Counter +from typing import Iterable + +from agentgraph_bridge_erc8004.models import NormalizedAttestation + +# Score contributions per claim_type, capped at the per-type maximum. +# These cap at additive 1.0 max when all three are present. +_IDENTITY_CAP = 0.60 +_AUTHORITY_CAP = 0.25 +_CONTINUITY_CAP = 0.15 +# Transport claims are routing-level not trust-level — no score contribution. +_TRANSPORT_CAP = 0.0 + + +def _attestation_strength( + count: int, distinct_providers: int, +) -> float: + """Map (attestation count, distinct provider count) → 0-1 strength. + + Provider diversity dominates: 3 attestations from 3 providers > 10 + attestations from 1 provider. Beyond 3 distinct providers the curve + plateaus. + + >>> _attestation_strength(0, 0) + 0.0 + >>> _attestation_strength(1, 1) # 1 attestation from 1 provider + 0.5 + >>> _attestation_strength(3, 3) # 3 attestations from 3 providers + 1.0 + >>> _attestation_strength(10, 1) > _attestation_strength(1, 1) # more, same provider + True + >>> _attestation_strength(10, 1) < _attestation_strength(3, 3) # but less than diverse + True + """ + if count <= 0: + return 0.0 + # Diversity component: 0.5 floor at 1 provider, scales to 1.0 at 3+ + diversity = min(0.5 + (distinct_providers - 1) * 0.25, 1.0) + # Count component: log-scaled bonus, max +0.1 at 10 attestations + count_bonus = min(math.log10(count + 1) / 10.0, 0.1) if count > 1 else 0.0 + return min(diversity + count_bonus, 1.0) + + +def score(attestations: Iterable[NormalizedAttestation]) -> float: + """Compute the ERC-8004 external-reputation contribution. + + Returns a value in [0.0, 1.0] suitable to pass into the + `external_reputation` slot of the composite trust score. + + Empty input → 0.0 (no contribution, behavior unchanged for entities + with no ERC-8004 attestations). + + Only admissible attestations (both signature layers verified, + not expired) contribute. Non-admissible attestations are silently + filtered; they're inspected via observability layers elsewhere. + """ + admissible = [a for a in attestations if a.is_admissible] + if not admissible: + return 0.0 + + by_type: dict[str, list[NormalizedAttestation]] = { + "identity": [], + "authority": [], + "continuity": [], + "transport": [], + } + for att in admissible: + if att.claim_type in by_type: + by_type[att.claim_type].append(att) + + contribution = 0.0 + for claim_type, cap in ( + ("identity", _IDENTITY_CAP), + ("authority", _AUTHORITY_CAP), + ("continuity", _CONTINUITY_CAP), + ("transport", _TRANSPORT_CAP), + ): + if cap == 0.0: + continue + atts = by_type[claim_type] + if not atts: + continue + count = len(atts) + distinct = len(set(a.provider_did for a in atts)) + strength = _attestation_strength(count, distinct) + contribution += cap * strength + + return round(min(contribution, 1.0), 4) + + +def blend_with_community_signals( + erc8004_score: float, community_score: float, +) -> float: + """Combine the ERC-8004 score with the existing community-signal score. + + Strategy: max-of-the-two. The two scores measure overlapping but + distinct things; taking the max means an entity with strong on-chain + attestations doesn't penalize one with strong GitHub signals (and + vice versa). When both are present, the higher of the two dominates. + + This is intentionally NOT additive — additive blending would let an + entity stack on-chain + GitHub signals to dominate the external + slot, violating the per-signal weight isolation invariant in + src/trust/score.py. + """ + return round(max(erc8004_score, community_score), 4) + + +def score_breakdown(attestations: Iterable[NormalizedAttestation]) -> dict: + """Diagnostic breakdown of the score components for observability. + + Returns a dict suitable for logging or surfacing in profile pages: + { + "total": 0.65, + "by_claim_type": { + "identity": {"count": 3, "distinct_providers": 2, "contribution": 0.5}, + "authority": {"count": 1, "distinct_providers": 1, "contribution": 0.125}, + ... + }, + "non_admissible_filtered": 2 # how many entries we dropped + } + + Does not include any signature material — diagnostic only, safe to + surface in user-visible profile pages. + """ + all_atts = list(attestations) + admissible = [a for a in all_atts if a.is_admissible] + + breakdown: dict = { + "total": score(admissible), + "by_claim_type": {}, + "non_admissible_filtered": len(all_atts) - len(admissible), + } + + type_counts = Counter(a.claim_type for a in admissible) + for claim_type in ("identity", "authority", "continuity", "transport"): + atts = [a for a in admissible if a.claim_type == claim_type] + if not atts: + continue + count = len(atts) + distinct = len(set(a.provider_did for a in atts)) + cap = { + "identity": _IDENTITY_CAP, + "authority": _AUTHORITY_CAP, + "continuity": _CONTINUITY_CAP, + "transport": _TRANSPORT_CAP, + }[claim_type] + contribution = round(cap * _attestation_strength(count, distinct), 4) + breakdown["by_claim_type"][claim_type] = { + "count": count, + "distinct_providers": distinct, + "contribution": contribution, + } + + return breakdown + + +__all__ = [ + "blend_with_community_signals", + "score", + "score_breakdown", +] diff --git a/src/agentgraph_bridge_erc8004/tests/test_attestation_normalizer.py b/src/agentgraph_bridge_erc8004/tests/test_attestation_normalizer.py new file mode 100644 index 0000000..66c0bbb --- /dev/null +++ b/src/agentgraph_bridge_erc8004/tests/test_attestation_normalizer.py @@ -0,0 +1,319 @@ +"""Tests for ERC-8004 attestation normalizer. + +Signature verification uses real Ed25519 keys generated per-test (not +mocked). did:web JWKS fetches are mocked via httpx.MockTransport so +no network I/O. +""" +from __future__ import annotations + +import base64 +import json +from datetime import datetime, timedelta, timezone + +import httpx +import pytest +import rfc8785 +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from agentgraph_bridge_erc8004.attestation_normalizer import ( + NormalizationError, + _did_web_to_jwks_url, + normalize, +) +from agentgraph_bridge_erc8004.models import ERC8004Entry, ERC8004Registry + + +def _b64url(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode().rstrip("=") + + +def _make_signed_envelope( + private_key: Ed25519PrivateKey, + kid: str = "test-key-1", + *, + claim_type: str = "identity", + subject_did: str = "did:web:agent.example.com", + provider_did: str = "did:web:issuer.example.com", + issued_at: str = "2026-05-22T10:00:00Z", + expires_at: str | None = "2027-05-22T10:00:00Z", + payload: dict | None = None, + omit_signature: bool = False, + bad_alg: str | None = None, +) -> dict: + """Build a CTEF envelope with a real Ed25519 signature for testing.""" + env: dict = { + "claim_type": claim_type, + "subject_did": subject_did, + "provider_did": provider_did, + "issued_at": issued_at, + "payload": payload or {"sub": subject_did}, + } + if expires_at is not None: + env["expires_at"] = expires_at + + if omit_signature: + return env + + preimage = rfc8785.dumps(env) + sig_bytes = private_key.sign(preimage) + env["signature"] = { + "alg": bad_alg or "EdDSA", + "kid": kid, + "sig": _b64url(sig_bytes), + } + return env + + +def _make_jwks(public_key, kid: str = "test-key-1") -> dict: + """Build a JWKS containing an Ed25519 OKP key.""" + from cryptography.hazmat.primitives.serialization import ( + Encoding, + PublicFormat, + ) + raw_pub = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw) + return { + "keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "kid": kid, + "x": _b64url(raw_pub), + "use": "sig", + "alg": "EdDSA", + }, + ], + } + + +def _mock_jwks_client(jwks_dict: dict) -> httpx.Client: + """Build an httpx.Client backed by a MockTransport returning jwks_dict.""" + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=jwks_dict) + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def _make_entry(data: bytes, registry: ERC8004Registry = ERC8004Registry.IDENTITY) -> ERC8004Entry: + """Build an ERC8004Entry wrapping arbitrary data bytes.""" + return ERC8004Entry( + registry=registry, + entry_id=42, + submitter="0x" + "ab" * 20, + subject_did="did:web:agent.example.com", + data=data, + block_number=25_000_000, + block_timestamp=datetime(2026, 5, 22, 10, 0, 0, tzinfo=timezone.utc), + tx_hash="0x" + "cd" * 32, + ) + + +# ──────────────────────────────────────────────────────────────────── +# Happy path +# ──────────────────────────────────────────────────────────────────── + + +class TestNormalizeHappyPath: + def test_valid_identity_attestation(self): + priv = Ed25519PrivateKey.generate() + pub = priv.public_key() + env = _make_signed_envelope(priv) + jwks = _make_jwks(pub) + client = _mock_jwks_client(jwks) + entry = _make_entry(json.dumps(env).encode()) + + result = normalize(entry, http_client=client) + + assert result.signature_verified is True + assert result.registry_signature_verified is True + assert result.claim_type == "identity" + assert result.subject_did == "did:web:agent.example.com" + assert result.provider_did == "did:web:issuer.example.com" + assert result.is_admissible + assert result.source_urn == "urn:erc8004:identity:42" + + def test_with_payload(self): + priv = Ed25519PrivateKey.generate() + env = _make_signed_envelope( + priv, payload={"sub": "did:web:agent.example.com", "verified_at": "2026-05-22"}, + ) + jwks = _make_jwks(priv.public_key()) + client = _mock_jwks_client(jwks) + entry = _make_entry(json.dumps(env).encode()) + + result = normalize(entry, http_client=client) + assert result.payload["verified_at"] == "2026-05-22" + + def test_all_claim_types_accepted(self): + for claim_type in ("identity", "transport", "authority", "continuity"): + priv = Ed25519PrivateKey.generate() + env = _make_signed_envelope(priv, claim_type=claim_type) + client = _mock_jwks_client(_make_jwks(priv.public_key())) + entry = _make_entry(json.dumps(env).encode()) + result = normalize(entry, http_client=client) + assert result.claim_type == claim_type + + +# ──────────────────────────────────────────────────────────────────── +# Signature verification failures (security-critical) +# ──────────────────────────────────────────────────────────────────── + + +class TestSignatureFailures: + def test_wrong_signing_key(self): + signing_priv = Ed25519PrivateKey.generate() + different_priv = Ed25519PrivateKey.generate() + env = _make_signed_envelope(signing_priv) + # JWKS published a DIFFERENT public key — sig won't verify + jwks = _make_jwks(different_priv.public_key()) + client = _mock_jwks_client(jwks) + entry = _make_entry(json.dumps(env).encode()) + + with pytest.raises(NormalizationError, match="signature verification FAILED"): + normalize(entry, http_client=client) + + def test_tampered_payload(self): + priv = Ed25519PrivateKey.generate() + env = _make_signed_envelope(priv) + # Tamper with payload AFTER signing + env["payload"]["sub"] = "did:web:attacker.example.com" + client = _mock_jwks_client(_make_jwks(priv.public_key())) + entry = _make_entry(json.dumps(env).encode()) + + with pytest.raises(NormalizationError, match="signature verification FAILED"): + normalize(entry, http_client=client) + + def test_missing_signature_block(self): + priv = Ed25519PrivateKey.generate() + env = _make_signed_envelope(priv, omit_signature=True) + client = _mock_jwks_client(_make_jwks(priv.public_key())) + entry = _make_entry(json.dumps(env).encode()) + + with pytest.raises(NormalizationError, match="signature block missing"): + normalize(entry, http_client=client) + + def test_unsupported_alg(self): + priv = Ed25519PrivateKey.generate() + env = _make_signed_envelope(priv, bad_alg="ES256") + client = _mock_jwks_client(_make_jwks(priv.public_key())) + entry = _make_entry(json.dumps(env).encode()) + + with pytest.raises(NormalizationError, match="Unsupported signature alg"): + normalize(entry, http_client=client) + + def test_kid_not_in_jwks(self): + priv = Ed25519PrivateKey.generate() + env = _make_signed_envelope(priv, kid="nonexistent-kid") + # JWKS published under a different kid + jwks = _make_jwks(priv.public_key(), kid="other-kid") + client = _mock_jwks_client(jwks) + entry = _make_entry(json.dumps(env).encode()) + + with pytest.raises(NormalizationError, match="kid 'nonexistent-kid' not found"): + normalize(entry, http_client=client) + + +# ──────────────────────────────────────────────────────────────────── +# Envelope shape validation +# ──────────────────────────────────────────────────────────────────── + + +class TestEnvelopeValidation: + def test_invalid_utf8(self): + entry = _make_entry(b"\xff\xfe\x00\x00") + with pytest.raises(NormalizationError, match="not valid UTF-8"): + normalize(entry) + + def test_invalid_json(self): + entry = _make_entry(b"{not json") + with pytest.raises(NormalizationError, match="not valid JSON"): + normalize(entry) + + def test_envelope_not_object(self): + entry = _make_entry(b'["array"]') + with pytest.raises(NormalizationError, match="envelope must be a JSON object"): + normalize(entry) + + def test_invalid_claim_type(self): + priv = Ed25519PrivateKey.generate() + env = _make_signed_envelope(priv, claim_type="bogus") + client = _mock_jwks_client(_make_jwks(priv.public_key())) + entry = _make_entry(json.dumps(env).encode()) + with pytest.raises(NormalizationError, match="invalid claim_type"): + normalize(entry, http_client=client) + + def test_missing_subject_did(self): + priv = Ed25519PrivateKey.generate() + env = _make_signed_envelope(priv, subject_did="") + client = _mock_jwks_client(_make_jwks(priv.public_key())) + entry = _make_entry(json.dumps(env).encode()) + with pytest.raises(NormalizationError, match="subject_did missing or empty"): + normalize(entry, http_client=client) + + def test_missing_provider_did(self): + priv = Ed25519PrivateKey.generate() + env = _make_signed_envelope(priv, provider_did="") + client = _mock_jwks_client(_make_jwks(priv.public_key())) + entry = _make_entry(json.dumps(env).encode()) + with pytest.raises(NormalizationError, match="provider_did missing or empty"): + normalize(entry, http_client=client) + + +# ──────────────────────────────────────────────────────────────────── +# Freshness + TTL +# ──────────────────────────────────────────────────────────────────── + + +class TestFreshness: + def test_explicit_expires_at(self): + priv = Ed25519PrivateKey.generate() + future = (datetime.now(timezone.utc) + timedelta(days=30)).isoformat() + env = _make_signed_envelope(priv, expires_at=future) + client = _mock_jwks_client(_make_jwks(priv.public_key())) + entry = _make_entry(json.dumps(env).encode()) + result = normalize(entry, http_client=client) + assert result.expires_at is not None + assert result.freshness_ttl_remaining_seconds > 0 + assert result.is_admissible + + def test_expired_attestation_not_admissible(self): + priv = Ed25519PrivateKey.generate() + past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + env = _make_signed_envelope(priv, expires_at=past) + client = _mock_jwks_client(_make_jwks(priv.public_key())) + entry = _make_entry(json.dumps(env).encode()) + result = normalize(entry, http_client=client) + # Signature verified, but expired → not admissible + assert result.signature_verified is True + assert result.freshness_ttl_remaining_seconds == 0 + assert not result.is_admissible + + def test_implicit_ttl_from_freshness_param(self): + priv = Ed25519PrivateKey.generate() + # Omit explicit expires_at; rely on caller-supplied TTL + env = _make_signed_envelope(priv, expires_at=None) + client = _mock_jwks_client(_make_jwks(priv.public_key())) + entry = _make_entry(json.dumps(env).encode()) + result = normalize(entry, http_client=client, freshness_ttl_seconds=3600) + assert result.expires_at is not None # derived from issued_at + 3600s + + +# ──────────────────────────────────────────────────────────────────── +# did:web URL resolution +# ──────────────────────────────────────────────────────────────────── + + +class TestDIDWebResolution: + def test_bare_domain(self): + assert _did_web_to_jwks_url("did:web:example.com") == \ + "https://example.com/.well-known/jwks.json" + + def test_path_form(self): + assert _did_web_to_jwks_url("did:web:example.com:agents:42") == \ + "https://example.com/agents/42/jwks.json" + + def test_non_web_method_rejected(self): + with pytest.raises(NormalizationError, match="must use did:web"): + _did_web_to_jwks_url("did:key:abc123") + + def test_empty_did_rejected(self): + with pytest.raises(NormalizationError, match="Empty did:web"): + _did_web_to_jwks_url("did:web:") diff --git a/src/agentgraph_bridge_erc8004/tests/test_fixtures.py b/src/agentgraph_bridge_erc8004/tests/test_fixtures.py new file mode 100644 index 0000000..cf4e03c --- /dev/null +++ b/src/agentgraph_bridge_erc8004/tests/test_fixtures.py @@ -0,0 +1,94 @@ +"""Round-trip the 3 snapshot fixtures through normalize() and verify the +output matches the captured `expected_normalized.json` exactly. + +This is the canonical reproduction test: anyone who clones the repo and +runs pytest gets byte-deterministic confirmation that: + 1. The fixture envelopes parse cleanly + 2. The Ed25519 signatures verify against the bundled JWKS + 3. The NormalizedAttestation output matches the captured expected shape + +If this test fails on a clean clone, either: + - rfc8785 / cryptography changed canonical-bytes behavior (substrate drift) + - The normalizer logic regressed + - Someone modified a fixture without re-running regen_fixtures.py + +All three are signal-worthy and worth investigating before shipping. +""" +from __future__ import annotations + +import base64 +import json +from datetime import datetime +from pathlib import Path + +import httpx +import pytest + +from agentgraph_bridge_erc8004.attestation_normalizer import normalize +from agentgraph_bridge_erc8004.models import ERC8004Entry + +_FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" + +_FIXTURE_NAMES = [ + "identity_basic", + "authority_tier_upgrade", + "continuity_behavioral", +] + + +def _b64url_decode(s: str) -> bytes: + pad = "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s + pad) + + +def _load_entry(name: str) -> ERC8004Entry: + raw = json.loads((_FIXTURES_DIR / name / "entry.json").read_text()) + # Convert the b64-encoded data field back to bytes + data_bytes = _b64url_decode(raw.pop("data_b64")) + raw["data"] = data_bytes + # ERC8004Entry expects datetime, not string + raw["block_timestamp"] = datetime.fromisoformat(raw["block_timestamp"]) + return ERC8004Entry(**raw) + + +def _load_jwks(name: str) -> dict: + return json.loads((_FIXTURES_DIR / name / "jwks.json").read_text()) + + +def _load_expected(name: str) -> dict: + return json.loads((_FIXTURES_DIR / name / "expected_normalized.json").read_text()) + + +def _mock_jwks_client(jwks: dict) -> httpx.Client: + def handler(req: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=jwks) + return httpx.Client(transport=httpx.MockTransport(handler)) + + +@pytest.mark.parametrize("fixture_name", _FIXTURE_NAMES) +def test_fixture_round_trip(fixture_name: str): + entry = _load_entry(fixture_name) + jwks = _load_jwks(fixture_name) + expected = _load_expected(fixture_name) + client = _mock_jwks_client(jwks) + + result = normalize(entry, http_client=client) + + assert result.source_urn == expected["source_urn"] + assert result.claim_type == expected["claim_type"] + assert result.claim_subtype == expected["claim_subtype"] + assert result.subject_did == expected["subject_did"] + assert result.provider_did == expected["provider_did"] + assert result.payload == expected["payload"] + assert result.signature_verified is True + assert result.registry_signature_verified is True + assert result.is_admissible + + +def test_all_three_fixtures_present(): + """Guard against fixtures getting accidentally deleted.""" + for name in _FIXTURE_NAMES: + d = _FIXTURES_DIR / name + assert d.exists(), f"Fixture dir missing: {d}" + for f in ("entry.json", "envelope.json", "jwks.json", "expected_normalized.json"): + assert (d / f).exists(), f"Fixture file missing: {d / f}" diff --git a/src/agentgraph_bridge_erc8004/tests/test_score_ingest.py b/src/agentgraph_bridge_erc8004/tests/test_score_ingest.py new file mode 100644 index 0000000..5784521 --- /dev/null +++ b/src/agentgraph_bridge_erc8004/tests/test_score_ingest.py @@ -0,0 +1,204 @@ +"""Tests for ERC-8004 score ingestion logic. + +Pure-Python scoring math, no I/O. Uses fixture `NormalizedAttestation` +instances with `signature_verified=True` and far-future expiry to +isolate the scoring math from the upstream verification. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from agentgraph_bridge_erc8004.models import NormalizedAttestation +from agentgraph_bridge_erc8004.score_ingest import ( + blend_with_community_signals, + score, + score_breakdown, +) + + +_FUTURE = datetime.now(timezone.utc) + timedelta(days=365) +_PAST = datetime.now(timezone.utc) - timedelta(days=1) + + +def _att( + claim_type: str, + provider_did: str, + *, + expires_at=_FUTURE, + sig_verified: bool = True, + reg_verified: bool = True, +) -> NormalizedAttestation: + return NormalizedAttestation( + source_urn=f"urn:erc8004:identity:{hash(provider_did) % 1000}", + claim_type=claim_type, + subject_did="did:web:agent.example.com", + provider_did=provider_did, + payload={}, + signature_verified=sig_verified, + registry_signature_verified=reg_verified, + issued_at=datetime(2026, 5, 22, tzinfo=timezone.utc), + expires_at=expires_at, + ) + + +# ──────────────────────────────────────────────────────────────────── +# score() — base behavior +# ──────────────────────────────────────────────────────────────────── + + +class TestScoreBase: + def test_no_attestations_returns_zero(self): + assert score([]) == 0.0 + + def test_only_inadmissible_returns_zero(self): + atts = [_att("identity", "did:web:x.com", sig_verified=False)] + assert score(atts) == 0.0 + + def test_only_expired_returns_zero(self): + atts = [_att("identity", "did:web:x.com", expires_at=_PAST)] + assert score(atts) == 0.0 + + +# ──────────────────────────────────────────────────────────────────── +# score() — per-claim_type caps +# ──────────────────────────────────────────────────────────────────── + + +class TestClaimTypeCaps: + def test_identity_alone_caps_at_0_6(self): + # 1 identity from 1 provider = 0.5 strength × 0.6 cap = 0.3 + atts = [_att("identity", "did:web:x.com")] + assert score(atts) == 0.3 + + # 3 identity from 3 providers = 1.0 strength × 0.6 cap = 0.6 + atts = [ + _att("identity", "did:web:a.com"), + _att("identity", "did:web:b.com"), + _att("identity", "did:web:c.com"), + ] + assert score(atts) == 0.6 + + def test_authority_alone_caps_at_0_25(self): + atts = [ + _att("authority", "did:web:a.com"), + _att("authority", "did:web:b.com"), + _att("authority", "did:web:c.com"), + ] + assert score(atts) == 0.25 + + def test_continuity_alone_caps_at_0_15(self): + atts = [ + _att("continuity", "did:web:a.com"), + _att("continuity", "did:web:b.com"), + _att("continuity", "did:web:c.com"), + ] + assert score(atts) == 0.15 + + def test_transport_does_not_contribute(self): + atts = [ + _att("transport", "did:web:a.com"), + _att("transport", "did:web:b.com"), + _att("transport", "did:web:c.com"), + ] + assert score(atts) == 0.0 + + def test_all_three_can_reach_one(self): + atts = [] + for ct in ("identity", "authority", "continuity"): + for p in ("a.com", "b.com", "c.com"): + atts.append(_att(ct, f"did:web:{p}")) + assert score(atts) == 1.0 + + +# ──────────────────────────────────────────────────────────────────── +# score() — provider diversity matters +# ──────────────────────────────────────────────────────────────────── + + +class TestProviderDiversity: + def test_diversity_dominates_count(self): + # 10 attestations from 1 provider vs 3 from 3 + many_same = [_att("identity", "did:web:x.com") for _ in range(10)] + few_diverse = [ + _att("identity", "did:web:a.com"), + _att("identity", "did:web:b.com"), + _att("identity", "did:web:c.com"), + ] + assert score(few_diverse) > score(many_same) + + def test_count_provides_modest_bonus(self): + # 1 attestation from 1 provider vs 10 from 1 + one = [_att("identity", "did:web:x.com")] + ten = [_att("identity", "did:web:x.com") for _ in range(10)] + # Modest bonus from count, but doesn't double the score + assert score(ten) > score(one) + assert score(ten) < score(one) * 1.5 + + +# ──────────────────────────────────────────────────────────────────── +# Inadmissible filtering +# ──────────────────────────────────────────────────────────────────── + + +class TestInadmissibleFiltering: + def test_mixed_admissible_and_not(self): + atts = [ + _att("identity", "did:web:a.com"), # admissible + _att("identity", "did:web:b.com", sig_verified=False), # filtered + _att("identity", "did:web:c.com", expires_at=_PAST), # filtered + ] + # Only 1 admissible → 1 provider → 0.5 × 0.6 = 0.3 + assert score(atts) == 0.3 + + +# ──────────────────────────────────────────────────────────────────── +# blend_with_community_signals — max strategy +# ──────────────────────────────────────────────────────────────────── + + +class TestBlendWithCommunity: + def test_max_strategy(self): + assert blend_with_community_signals(0.6, 0.4) == 0.6 + assert blend_with_community_signals(0.3, 0.5) == 0.5 + assert blend_with_community_signals(0.7, 0.7) == 0.7 + + def test_zero_erc_returns_community(self): + assert blend_with_community_signals(0.0, 0.45) == 0.45 + + def test_zero_community_returns_erc(self): + assert blend_with_community_signals(0.65, 0.0) == 0.65 + + def test_both_zero(self): + assert blend_with_community_signals(0.0, 0.0) == 0.0 + + +# ──────────────────────────────────────────────────────────────────── +# score_breakdown — diagnostics +# ──────────────────────────────────────────────────────────────────── + + +class TestScoreBreakdown: + def test_includes_per_type_breakdown(self): + atts = [ + _att("identity", "did:web:a.com"), + _att("identity", "did:web:b.com"), + _att("authority", "did:web:a.com"), + ] + bd = score_breakdown(atts) + assert bd["total"] == score(atts) + assert bd["by_claim_type"]["identity"]["count"] == 2 + assert bd["by_claim_type"]["identity"]["distinct_providers"] == 2 + assert bd["by_claim_type"]["authority"]["count"] == 1 + assert "continuity" not in bd["by_claim_type"] # no continuity atts + assert bd["non_admissible_filtered"] == 0 + + def test_counts_non_admissible(self): + atts = [ + _att("identity", "did:web:a.com"), + _att("identity", "did:web:b.com", expires_at=_PAST), + _att("identity", "did:web:c.com", sig_verified=False), + ] + bd = score_breakdown(atts) + assert bd["non_admissible_filtered"] == 2 diff --git a/src/trust/score.py b/src/trust/score.py index 0f37776..5fc565f 100644 --- a/src/trust/score.py +++ b/src/trust/score.py @@ -369,6 +369,43 @@ def _source_reputation_score(community_signals: dict) -> float: return round(score, 4) +def _external_score_with_attestations( + community_signals: dict, + erc8004_attestations: list | None = None, +) -> float: + """Compute external reputation, optionally blending in ERC-8004 attestations. + + Behavior: + - No attestations passed → returns `_source_reputation_score()` unchanged. + Entities without on-chain attestations see no behavioral difference. + - Attestations passed → blends ERC-8004-derived score with community + signals via max-of-two strategy. Whichever source produces a higher + external-reputation score wins; entities with both strong on-chain + AND strong GitHub signals don't get to stack them (per the weight + isolation invariant). + + The opt-in design is intentional: ERC-8004 attestation fetch is + expected to be done by a separate sync job (not fetched live during + trust recompute, which runs on every cycle). The recompute job + reads pre-synced attestations and passes them in; entities without + sync just get the community-signal score as before. + """ + community_score = _source_reputation_score(community_signals) + + if not erc8004_attestations: + return community_score + + # Lazy import — the erc8004 bridge has web3 as an optional dep, and + # we only want to import it when attestations are actually present. + from agentgraph_bridge_erc8004.score_ingest import ( + blend_with_community_signals, + score as erc8004_score, + ) + + erc_score = erc8004_score(erc8004_attestations) + return blend_with_community_signals(erc_score, community_score) + + async def create_import_trust_score( db: AsyncSession, entity_id: uuid.UUID,