Skip to content

Commit 4bda83a

Browse files
security: DID cryptographic identity audit — SDK fixes
- Add verify_w3c_credential() for offline W3C VC v2.0 verification - Add format param to get_reputation_credential() (avp/w3c) - Document did:key risk, challenge-response, credential signing in SECURITY.md - Part of full DID/crypto identity revision (Sprint 1+2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5827e0d commit 4bda83a

File tree

2 files changed

+123
-3
lines changed

2 files changed

+123
-3
lines changed

SECURITY.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,39 @@ We will acknowledge receipt within **48 hours** and aim to provide an initial as
3030
- **Audit trail** — SHA-256 hash-chained logs anchored to IPFS
3131
- **Key storage** — local keys saved with `chmod 0600` permissions
3232

33+
## Cryptographic Identity
34+
35+
### DID Method: did:key
36+
37+
AVP uses `did:key` (W3C CCG, Ed25519) for agent identifiers. This is a **stateless, self-certifying** method: the DID is derived deterministically from the public key.
38+
39+
**By design, did:key does not support:**
40+
- Key rotation (new key = new DID)
41+
- DID deactivation/revocation at the protocol level
42+
- Key recovery after loss
43+
44+
**Implications for key compromise:** If an agent's private key is compromised, the attacker gains full control of that identity. AVP mitigates this server-side: agents can be suspended or revoked via the AVP registry, and webhook alerts notify operators of anomalous score drops. However, this protection is scoped to the AVP ecosystem.
45+
46+
**Recommendations:**
47+
- Use encrypted key storage (`agent.save(passphrase="...")`) with Argon2id key derivation for production agents.
48+
- For long-lived, high-value agents, consider `did:web` (supports rotation, revocation, key history) when AVP adds support.
49+
- Store keys in hardware security modules (HSM) or secure enclaves where possible.
50+
- Monitor reputation velocity alerts for early compromise detection.
51+
52+
### Challenge-Response Authentication
53+
54+
- **Registration challenge:** 64 random hex chars (`secrets.token_hex(32)`), stored in Redis with 300-second TTL. Expired challenges auto-delete. One-time use — consumed on verification.
55+
- **API request auth:** Ed25519 signature over `{method}:{path}:{timestamp}:{nonce}:{body_sha256}`. Timestamp window: 60 seconds. Nonces tracked in Redis with 120-second TTL (2x timestamp window for safety margin). Redis unavailable = fail closed (503).
56+
- **Proof-of-Work:** Required on registration to prevent Sybil attacks. SHA-256 with configurable difficulty (default 24 leading zero bits).
57+
58+
### Credential Signing
59+
60+
Reputation credentials are signed by the server's Ed25519 key (configured via `CREDENTIAL_SIGNING_KEY_HEX`). Two formats available:
61+
- **AVP format:** Custom JSON with hex-encoded signature. Verify with `AVPAgent.verify_credential()`.
62+
- **W3C VC v2.0 format:** Standards-compliant Verifiable Credential with Data Integrity proof (`eddsa-jcs-2022`). Verify with any VC library or `AVPAgent.verify_w3c_credential()`.
63+
64+
Ephemeral signing keys are rejected in production (`CREDENTIAL_SIGNING_KEY_HEX` must be set).
65+
3366
## Disclosure Policy
3467

3568
We follow coordinated disclosure. Please do not open public issues for security vulnerabilities.

agentveil/agent.py

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,7 @@ def get_reputation_credential(
788788
self,
789789
did: Optional[str] = None,
790790
risk_level: str = "medium",
791+
format: str = "avp",
791792
) -> dict:
792793
"""
793794
Get a signed reputation credential for offline verification.
@@ -799,20 +800,26 @@ def get_reputation_credential(
799800
did: Agent DID (defaults to self)
800801
risk_level: "low" (60min TTL), "medium" (15min), "high" (5min).
801802
"critical" is rejected — use get_reputation() instead.
803+
format: "avp" (default, AVP-native format) or "w3c" (W3C VC v2.0).
804+
W3C format is verifiable by any standard VC library.
802805
803806
Returns:
804-
dict with did, score, confidence, issued_at, expires_at,
805-
ipfs_cid, risk_level, signature, signer_did
807+
dict — AVP format or W3C Verifiable Credential depending on format param.
808+
Use verify_credential() for AVP format, verify_w3c_credential() for W3C.
806809
"""
807810
if risk_level not in ("low", "medium", "high", "critical"):
808811
raise AVPValidationError(
809812
f"Invalid risk_level: {risk_level}. Must be low/medium/high/critical"
810813
)
814+
if format not in ("avp", "w3c"):
815+
raise AVPValidationError(
816+
f"Invalid format: {format}. Must be avp or w3c"
817+
)
811818
target = did or self._did
812819
with httpx.Client(base_url=self._base_url, timeout=self._timeout) as c:
813820
r = c.get(
814821
f"/v1/reputation/{target}/credential",
815-
params={"risk_level": risk_level},
822+
params={"risk_level": risk_level, "format": format},
816823
)
817824
return self._handle_response(r)
818825

@@ -877,6 +884,86 @@ def verify_credential(credential: dict) -> bool:
877884
except Exception:
878885
return False
879886

887+
@staticmethod
888+
def verify_w3c_credential(credential: dict) -> bool:
889+
"""
890+
Verify a W3C VC v2.0 reputation credential offline — no API call needed.
891+
892+
Checks:
893+
1. W3C VC structure (@context, type, proof)
894+
2. Temporal validity (validFrom/validUntil)
895+
3. Data Integrity proof (eddsa-jcs-2022): Ed25519 signature over
896+
JCS-canonicalized credential (proof removed)
897+
898+
Args:
899+
credential: The W3C VC dict from get_reputation_credential(format="w3c")
900+
901+
Returns:
902+
True if the credential is valid, not expired, and signature checks out
903+
"""
904+
import base58
905+
from datetime import datetime, timezone, timedelta
906+
907+
try:
908+
# Structure checks
909+
contexts = credential.get("@context", [])
910+
if "https://www.w3.org/ns/credentials/v2" not in contexts:
911+
return False
912+
types = credential.get("type", [])
913+
if "VerifiableCredential" not in types:
914+
return False
915+
proof = credential.get("proof")
916+
if not proof or proof.get("type") != "DataIntegrityProof":
917+
return False
918+
if proof.get("cryptosuite") != "eddsa-jcs-2022":
919+
return False
920+
921+
# Temporal validity
922+
now = datetime.now(timezone.utc)
923+
valid_until = credential.get("validUntil")
924+
if valid_until:
925+
expires = datetime.strptime(
926+
valid_until, "%Y-%m-%dT%H:%M:%SZ"
927+
).replace(tzinfo=timezone.utc)
928+
if now > expires:
929+
return False
930+
valid_from = credential.get("validFrom")
931+
if valid_from:
932+
starts = datetime.strptime(
933+
valid_from, "%Y-%m-%dT%H:%M:%SZ"
934+
).replace(tzinfo=timezone.utc)
935+
if now < starts - timedelta(seconds=60):
936+
return False
937+
938+
# Extract public key from verification method DID
939+
vm = proof.get("verificationMethod", "")
940+
signer_did = vm.split("#")[0]
941+
if not signer_did.startswith("did:key:z"):
942+
return False
943+
decoded = base58.b58decode(signer_did[9:])
944+
if len(decoded) < 2 or decoded[0] != 0xED or decoded[1] != 0x01:
945+
return False
946+
public_key = decoded[2:]
947+
if len(public_key) != 32:
948+
return False
949+
950+
# Reconstruct signed payload (credential without proof)
951+
payload = {k: v for k, v in credential.items() if k != "proof"}
952+
message = json.dumps(
953+
payload, sort_keys=True, separators=(",", ":")
954+
).encode()
955+
956+
# Decode multibase proof value and verify
957+
proof_value = proof.get("proofValue", "")
958+
if not proof_value.startswith("z"):
959+
return False
960+
signature = base58.b58decode(proof_value[1:])
961+
verify_key = VerifyKey(public_key)
962+
verify_key.verify(message, signature)
963+
return True
964+
except Exception:
965+
return False
966+
880967
def get_agent_info(self, did: Optional[str] = None) -> dict:
881968
"""Get public info about an agent."""
882969
target = did or self._did

0 commit comments

Comments
 (0)