diff --git a/agent-registry/did_resolver.py b/agent-registry/did_resolver.py new file mode 100644 index 0000000..2f34fd4 --- /dev/null +++ b/agent-registry/did_resolver.py @@ -0,0 +1,414 @@ +""" +Pluggable DID resolution for the agent registry. + +When an `AgentKey` row has `key_type == 'did'`, the registry does not store +the public key locally — instead, it stores the DID URI as the `key_id` and +resolves the corresponding DID Document at verification time. + +This module defines: + + * `ResolvedKey` — the normalized output of resolution: classical + algorithm + classical key bytes, plus an optional + ML-DSA-65 PQ key for hybrid signatures. + * `DIDResolver` — the abstract resolver interface. + * `DIDResolverRegistry` — the dispatcher used by the registry. Operators + register resolvers explicitly per DID method. + * `WebDIDResolver` — reference resolver for `did:web:` per the W3C + DID Web spec (https://w3c-ccg.github.io/did-method-web/). + * `TenzroDIDResolver` — reference resolver for `did:tenzro:` against + Tenzro's TDIP identity registry over JSON-RPC. + +`did:tenzro:` is included as ONE supported DID method, not THE supported +method. The dispatcher works for any DID method registered against it; the +two reference resolvers are illustrative. + +Wire format references: + - W3C did-core 1.0: https://www.w3.org/TR/did-core/ + - W3C did:web spec: https://w3c-ccg.github.io/did-method-web/ + - Tenzro TDIP DID format: did:tenzro:human:{uuid} | did:tenzro:machine:{controller}:{uuid} +""" +from __future__ import annotations + +import abc +import base64 +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Optional +from urllib.parse import quote + +# `requests` is a top-level dependency of the agent-registry already +# (used elsewhere in the project for upstream calls). If it isn't, the +# resolvers below can be swapped to httpx/aiohttp without changing the +# DIDResolver interface. +import requests + + +# --------------------------------------------------------------------------- +# Resolution output +# --------------------------------------------------------------------------- + +# Verification suite types we know how to map onto a signature algorithm. +# This list is intentionally small; resolvers MAY return others, in which +# case the registry's algorithm dispatcher decides whether it can use them. +_ED25519_SUITES = { + "Ed25519VerificationKey2020", + "Ed25519VerificationKey2018", + "JsonWebKey2020", # disambiguated by JWK `crv` +} +_ML_DSA_65_SUITES = { + # Draft suite names tracked by the W3C VC working group / NIST PQC. + "MlDsa65VerificationKey2026", + "MlDsaVerificationKey2026", + "MultikeyMlDsa65", +} + + +@dataclass +class ResolvedKey: + """ + A normalized key ready to be handed to a signature verifier. + + `algorithm` is the RFC 9421 §6.2 algorithm string the registry would + have stored had this key been registered as PEM (e.g. ``"ed25519"``, + ``"ed25519-ml-dsa-65"``). + + `classical_pem` is a PEM-encoded public key (Ed25519 only in this + reference impl) — same shape the rest of the registry already + consumes for PEM-stored keys, so verification can stay protocol- + agnostic. + + `pq_public_key_bytes` is the raw 1952-byte ML-DSA-65 verifying key + when the resolved DID Document advertises one, or `None` otherwise. + """ + + algorithm: str + classical_pem: str + pq_public_key_bytes: Optional[bytes] = None + raw_did_document: Optional[Dict[str, Any]] = None + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class DIDResolutionError(Exception): + """Raised when a DID cannot be resolved or its document is malformed.""" + + +class DIDResolverNotRegistered(DIDResolutionError): + """Raised when no resolver is registered for the requested DID method.""" + + +# --------------------------------------------------------------------------- +# Abstract resolver interface +# --------------------------------------------------------------------------- + + +class DIDResolver(abc.ABC): + """ + Abstract resolver. One instance per DID method. + + Implementors should be stateless and thread-safe; the registry shares + a single instance across requests. + """ + + #: The DID method this resolver handles, e.g. "web", "tenzro". + method: str + + @abc.abstractmethod + def resolve(self, did: str) -> ResolvedKey: + """ + Resolve a DID URI to a `ResolvedKey`. + + Implementations MUST raise `DIDResolutionError` on any failure + (network error, malformed document, no usable verification + method). They MUST NOT swallow errors — the registry decides + the failure semantics (404 vs 500 vs cached fallback). + """ + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# Dispatch registry +# --------------------------------------------------------------------------- + + +class DIDResolverRegistry: + """ + Operator-configured map of DID method -> resolver instance. + + The agent registry constructs one of these at startup and registers + only the methods the deployment wants to support. There is no default + list — resolvers must be registered explicitly. This is by design: + a registry that silently resolves arbitrary DID methods is a privacy + and supply-chain hazard. + """ + + def __init__(self) -> None: + self._resolvers: Dict[str, DIDResolver] = {} + + def register(self, resolver: DIDResolver) -> None: + if not resolver.method: + raise ValueError("DIDResolver.method must be a non-empty string") + self._resolvers[resolver.method] = resolver + + def resolve(self, did: str, method_hint: Optional[str] = None) -> ResolvedKey: + method = method_hint or self._method_from_did(did) + try: + resolver = self._resolvers[method] + except KeyError as exc: + raise DIDResolverNotRegistered( + f"No DIDResolver registered for method 'did:{method}:'" + ) from exc + return resolver.resolve(did) + + @staticmethod + def _method_from_did(did: str) -> str: + # did URI grammar: "did:" method ":" method-specific-id + if not did.startswith("did:"): + raise DIDResolutionError(f"Not a DID URI: {did!r}") + parts = did.split(":", 2) + if len(parts) < 3 or not parts[1]: + raise DIDResolutionError(f"Malformed DID URI: {did!r}") + return parts[1] + + +# --------------------------------------------------------------------------- +# Reference: did:web resolver +# --------------------------------------------------------------------------- + + +class WebDIDResolver(DIDResolver): + """ + Resolves `did:web:example.com[:path:to:agent]` per the W3C DID Web spec. + + The DID `did:web:example.com` resolves to + ``https://example.com/.well-known/did.json``; with a path component + (``did:web:example.com:agents:alice``) it resolves to + ``https://example.com/agents/alice/did.json``. + + This resolver is intentionally minimal — production deployments may + want to add caching, retries, allowlists, etc. + """ + + method = "web" + + def __init__(self, *, timeout_s: float = 5.0, session: Optional[requests.Session] = None): + self._timeout = timeout_s + self._session = session or requests.Session() + + def resolve(self, did: str) -> ResolvedKey: + url = self._did_to_url(did) + try: + resp = self._session.get(url, timeout=self._timeout) + except requests.RequestException as exc: + raise DIDResolutionError(f"did:web fetch failed for {did}: {exc}") from exc + if resp.status_code != 200: + raise DIDResolutionError( + f"did:web fetch returned HTTP {resp.status_code} for {did} ({url})" + ) + try: + doc = resp.json() + except ValueError as exc: + raise DIDResolutionError(f"did:web document at {url} is not JSON: {exc}") from exc + return _did_document_to_resolved_key(doc) + + @staticmethod + def _did_to_url(did: str) -> str: + if not did.startswith("did:web:"): + raise DIDResolutionError(f"Not a did:web URI: {did!r}") + rest = did[len("did:web:") :] + # did:web allows percent-encoded host; canonical form is lowercase. + # Path components are colon-separated in the DID; slash-separated + # in the URL. + if ":" in rest: + host, *path = rest.split(":") + path_str = "/" + "/".join(quote(p, safe="") for p in path) + "/did.json" + else: + host = rest + path_str = "/.well-known/did.json" + return f"https://{host}{path_str}" + + +# --------------------------------------------------------------------------- +# Reference: did:tenzro resolver +# --------------------------------------------------------------------------- + + +class TenzroDIDResolver(DIDResolver): + """ + Resolves `did:tenzro:` against the Tenzro Network TDIP registry. + + Tenzro DIDs follow one of: + - ``did:tenzro:human:{uuid}`` + - ``did:tenzro:machine:{controller}:{uuid}`` + - ``did:tenzro:machine:{uuid}`` (autonomous) + + The reference resolver calls the Tenzro JSON-RPC method + ``tenzro_resolveDidDocument`` on a public RPC endpoint (default + ``https://rpc.tenzro.network``), which returns a W3C DID Document + matching the same shape this resolver consumes for did:web. + + Operators may point this resolver at any Tenzro RPC endpoint + (testnet, mainnet, self-hosted node) by passing `rpc_url`. There is + no implicit default beyond the public testnet — production deployments + SHOULD use their own RPC node. + """ + + method = "tenzro" + + DEFAULT_RPC_URL = "https://rpc.tenzro.network" + + def __init__( + self, + *, + rpc_url: str = DEFAULT_RPC_URL, + timeout_s: float = 5.0, + session: Optional[requests.Session] = None, + ): + self._rpc_url = rpc_url.rstrip("/") + self._timeout = timeout_s + self._session = session or requests.Session() + + def resolve(self, did: str) -> ResolvedKey: + if not did.startswith("did:tenzro:"): + raise DIDResolutionError(f"Not a did:tenzro URI: {did!r}") + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tenzro_resolveDidDocument", + "params": {"did": did}, + } + try: + resp = self._session.post(self._rpc_url, json=body, timeout=self._timeout) + except requests.RequestException as exc: + raise DIDResolutionError( + f"did:tenzro RPC call failed for {did} at {self._rpc_url}: {exc}" + ) from exc + if resp.status_code != 200: + raise DIDResolutionError( + f"did:tenzro RPC returned HTTP {resp.status_code} for {did}" + ) + try: + envelope = resp.json() + except ValueError as exc: + raise DIDResolutionError(f"did:tenzro RPC response not JSON: {exc}") from exc + if "error" in envelope and envelope["error"]: + raise DIDResolutionError(f"did:tenzro RPC error for {did}: {envelope['error']}") + doc = envelope.get("result") + if not isinstance(doc, dict): + raise DIDResolutionError( + f"did:tenzro RPC returned no DID Document for {did}" + ) + return _did_document_to_resolved_key(doc) + + +# --------------------------------------------------------------------------- +# Shared DID Document → ResolvedKey extraction +# --------------------------------------------------------------------------- + + +def _did_document_to_resolved_key(doc: Dict[str, Any]) -> ResolvedKey: + """ + Pull the first usable Ed25519 verification method out of a W3C DID + Document, plus an ML-DSA-65 method if present, and project them onto + a `ResolvedKey`. + + This implementation handles the two encodings the reference Tenzro + impl emits today: + * ``publicKeyMultibase`` (base58btc with leading 'z') + * raw bytes via the W3C did-core "Multibase" rules + + JWK-based methods are deferred to a follow-up; the registry will + raise `DIDResolutionError` if it can't find a usable key. + """ + methods = doc.get("verificationMethod") or [] + if not isinstance(methods, list): + raise DIDResolutionError("DID Document has no verificationMethod array") + + classical_pem: Optional[str] = None + pq_bytes: Optional[bytes] = None + + for m in methods: + mtype = m.get("type") + key_bytes = _extract_key_bytes(m) + if key_bytes is None: + continue + if mtype in _ED25519_SUITES and classical_pem is None: + classical_pem = _ed25519_raw_to_pem(key_bytes) + elif mtype in _ML_DSA_65_SUITES and pq_bytes is None: + pq_bytes = key_bytes + + if classical_pem is None: + raise DIDResolutionError( + "DID Document has no Ed25519 verificationMethod the registry can use" + ) + + algorithm = "ed25519-ml-dsa-65" if pq_bytes is not None else "ed25519" + return ResolvedKey( + algorithm=algorithm, + classical_pem=classical_pem, + pq_public_key_bytes=pq_bytes, + raw_did_document=doc, + ) + + +def _extract_key_bytes(method: Dict[str, Any]) -> Optional[bytes]: + """Decode `publicKeyMultibase` (base58btc with 'z' prefix) into raw bytes.""" + mb = method.get("publicKeyMultibase") + if isinstance(mb, str) and mb.startswith("z"): + # Lazy import: base58 is not a transitive dep of agent-registry. + try: + import base58 # type: ignore + except ImportError as exc: # pragma: no cover + raise DIDResolutionError( + "did_resolver requires `base58` to decode publicKeyMultibase; " + "install with `pip install base58`." + ) from exc + return bytes(base58.b58decode(mb[1:])) + # Some implementations carry raw base64 instead. Tolerate it for + # interop, but multibase is preferred. + raw_b64 = method.get("publicKeyBase64") + if isinstance(raw_b64, str): + try: + return base64.b64decode(raw_b64) + except (ValueError, TypeError): + return None + return None + + +def _ed25519_raw_to_pem(raw32: bytes) -> str: + """ + Wrap a 32-byte raw Ed25519 public key in a SubjectPublicKeyInfo PEM + so the rest of the registry — which already speaks PEM — can verify + against it without a separate code path. + """ + if len(raw32) != 32: + raise DIDResolutionError( + f"Ed25519 raw public key must be 32 bytes, got {len(raw32)}" + ) + # Lazy import: cryptography is already a dep of agent-registry for + # PEM/Ed25519 verification, but importing at module load would couple + # this file to it. Keeping it local makes the resolver testable in + # isolation. + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ed25519 + + pubkey = ed25519.Ed25519PublicKey.from_public_bytes(raw32) + pem = pubkey.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return pem.decode("ascii") + + +__all__ = [ + "DIDResolver", + "DIDResolverRegistry", + "DIDResolutionError", + "DIDResolverNotRegistered", + "ResolvedKey", + "TenzroDIDResolver", + "WebDIDResolver", +] diff --git a/agent-registry/docs/did-and-pq.md b/agent-registry/docs/did-and-pq.md new file mode 100644 index 0000000..63907d1 --- /dev/null +++ b/agent-registry/docs/did-and-pq.md @@ -0,0 +1,230 @@ +# DID-based key IDs and post-quantum hybrid signatures + +This document describes two additive extensions to the agent registry: + +1. A `did:` URI as a `key_id`, resolved through a pluggable + `DIDResolver` interface. +2. A composite `ed25519-ml-dsa-65` signing algorithm registered per the + RFC 9421 §6.2 algorithm-registry extension pattern. + +Both extensions are strictly additive. Existing registrations using +`algorithm: "ed25519"` or `algorithm: "rsa-pss-sha256"` with a PEM +public key keep working with no changes. No fields were removed; no +defaults changed. + +--- + +## 1. DIDs as `key_id` + +### Why + +The v1 registry keys every row by `(domain, key_id)`, which couples +agent identity to DNS ownership. That is fine for short-lived, +domain-scoped agents; it breaks down for: + +- Agents that move between domains (a payments agent that operates from + multiple merchant subdomains under one logical identity) +- Agents whose principal does not own a stable domain (autonomous + agents, subprocess agents inside hosted services) +- Long-lived agent identities that need to outlive any one DNS lease + +W3C DIDs ([did-core 1.0](https://www.w3.org/TR/did-core/)) decouple +identifier from infrastructure, and most production agent frameworks +already issue DIDs to agents. + +### Wire model + +When `key_type == "did"`: + +- `key_id` is a DID URI (e.g. `did:web:agent.example.com`, + `did:tenzro:machine:01j8...:01j9...`). +- `public_key` is `null`. +- `did_method` selects the resolver (`web`, `tenzro`, ...). + +The CDN proxy (`cdn-proxy/`) does not need to change: when it asks the +registry for a key it gets back a normal response with a PEM +`public_key` (and optionally `pq_public_key`). The DID is resolved +inside the registry. + +### Resolver registration + +There is **no default resolver list**. Operators register resolvers +explicitly at startup. The `did:web:` resolver is registered by default +because it is a pure W3C standard with no third-party trust +assumption; `did:tenzro:` is opt-in via environment variable: + +``` +# enable did:tenzro: resolution against the public Tenzro testnet +TAP_ENABLE_DID_TENZRO=1 + +# (optional) point at a self-hosted Tenzro RPC node +TAP_TENZRO_RPC_URL=https://rpc.your-tenzro-node.example +``` + +To plug in a new method, subclass `DIDResolver` and call +`registry.register(YourResolver())`. + +### Reference: `did:web:` + +`did:web:` is the W3C +[did:web spec](https://w3c-ccg.github.io/did-method-web/). The resolver +fetches `https://{host}/.well-known/did.json` (or the path-suffixed +variant) and extracts the first usable Ed25519 verification method. + +CLI registration example: + +``` +curl -X POST http://localhost:8080/keys/ \ + -H 'Content-Type: application/json' \ + -d '{ + "domain": "merchant.example.com", + "key_id": "did:web:agent.example.com", + "key_type": "did", + "did_method": "web", + "algorithm": "ed25519" + }' +``` + +### Reference: `did:tenzro:` + +`did:tenzro:` resolves against the +[Tenzro Network TDIP](https://github.com/tenzro/tenzro-network) identity +registry. The resolver issues a JSON-RPC call to the Tenzro node: + +```json +{ + "jsonrpc": "2.0", + "method": "tenzro_resolveDidDocument", + "params": {"did": "did:tenzro:machine:01j8..."}, + "id": 1 +} +``` + +The response is a W3C DID Document. The Tenzro implementation always +emits both an Ed25519 verification method and an ML-DSA-65 +verification method (suite `MlDsa65VerificationKey2026`), so a +`did:tenzro:` row registered with `algorithm: "ed25519"` will be +upgraded to `ed25519-ml-dsa-65` automatically when the resolver finds +both methods in the document. + +``` +curl -X POST http://localhost:8080/keys/ \ + -H 'Content-Type: application/json' \ + -d '{ + "domain": "merchant.example.com", + "key_id": "did:tenzro:machine:01j8...:01j9...", + "key_type": "did", + "did_method": "tenzro", + "algorithm": "ed25519" + }' +``` + +`did:tenzro:` is **one** supported method; the resolver interface is +pluggable for any DID method (did:key, did:plc, did:ion, did:ethr, etc.). + +--- + +## 2. Hybrid `ed25519-ml-dsa-65` + +### Why + +The NIST PQC standardization process culminated in FIPS 203 (ML-KEM) +and FIPS 204 (ML-DSA) in 2024–2025. NIST SP 800-227 calls for a +classical+PQ hybrid window during migration so that a CRQC +(cryptographically relevant quantum computer) cannot retrospectively +forge signatures, but a flaw in the PQ scheme alone does not give an +attacker a forgery either. Both legs must verify. + +The same construction is already in production at Tenzro Network for +its consensus signatures; this PR ports just the algorithm dispatcher +into TAP so any agent registry deployment can opt in. + +### Algorithm string (RFC 9421 §6.2) + +The string `ed25519-ml-dsa-65` is registered following the RFC 9421 +§6.2 pattern (algorithm names are application-defined and selected by +the registry/verifier; the spec only requires that all parties agree). +Both signatures cover the same Sig-Base bytes per RFC 9421 §3.3. + +### Wire format + +The HTTP `Signature` header carries one Base64 blob: the concatenation +of the two raw signatures, fixed-width by spec. + +``` +sig_raw = ed25519_sig (64 bytes) || ml_dsa_65_sig (3309 bytes) + = 3373 bytes total +sig_b64 = base64(sig_raw) // 4500 chars +``` + +Public keys: + +- `public_key`: PEM-encoded Ed25519 SubjectPublicKeyInfo (32 bytes raw). +- `pq_public_key`: Base64-encoded raw ML-DSA-65 verifying key (1952 + bytes per FIPS 204 §4 Table 2). + +### Soft dependency: `oqs` + +ML-DSA-65 verification uses +[`liboqs-python`](https://github.com/open-quantum-safe/liboqs-python) +(`pip install oqs`, plus a system-level liboqs install). It is **not** +a hard dependency of the agent registry: the import is lazy and +hybrid verification fails closed (HTTP 503) when `oqs` is absent. All +other algorithms continue to work unaffected. + +### Algorithm fallback rule + +Hybrid is fail-closed by design: if either leg fails, verification +fails. If `oqs` is not installed, the registry rejects hybrid +verification with 503 — it does NOT fall back to classical-only, +because that would defeat the point of registering a hybrid algorithm. + +### Registration example + +``` +curl -X POST http://localhost:8080/keys/ \ + -H 'Content-Type: application/json' \ + -d '{ + "domain": "merchant.example.com", + "key_id": "agent-2026-pq", + "key_type": "pem", + "algorithm": "ed25519-ml-dsa-65", + "public_key": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA...\n-----END PUBLIC KEY-----\n", + "pq_public_key": "" + }' +``` + +--- + +## 3. Migration notes + +Existing rows are unaffected: + +| Existing column | After this PR | Behavior | +|---|---|---| +| `domain` | unchanged | unchanged | +| `key_id` | widened from 255 to 1024 | DID URIs fit | +| `algorithm` | unchanged | new value `ed25519-ml-dsa-65` accepted alongside existing | +| `public_key` | now nullable | required when `key_type == 'pem'` | +| `key_type` (NEW) | defaults to `'pem'` via `server_default` | existing rows materialize as `'pem'` | +| `did_method` (NEW) | nullable | NULL for `'pem'` rows | +| `pq_public_key` (NEW) | nullable | NULL unless registering hybrid PEM | + +A database migration that just runs `ALTER TABLE ... ADD COLUMN ... +DEFAULT 'pem'` over the existing schema covers all existing rows. No +data backfill is required. + +--- + +## 4. References + +- RFC 9421 — HTTP Message Signatures: https://www.rfc-editor.org/rfc/rfc9421 + - §3.3 Signing Algorithms + - §6.2 Signature Algorithms registry extension pattern + - §7 Security Considerations +- RFC 8032 — Edwards-Curve Digital Signature Algorithm (Ed25519): https://www.rfc-editor.org/rfc/rfc8032 +- NIST FIPS 204 — ML-DSA: https://csrc.nist.gov/pubs/fips/204/final +- NIST SP 800-227 — Post-Quantum Cryptography Migration Guidance +- W3C did-core 1.0: https://www.w3.org/TR/did-core/ +- W3C did:web spec: https://w3c-ccg.github.io/did-method-web/ +- liboqs-python: https://github.com/open-quantum-safe/liboqs-python diff --git a/agent-registry/main.py b/agent-registry/main.py index b3095c3..0a5f97b 100644 --- a/agent-registry/main.py +++ b/agent-registry/main.py @@ -19,8 +19,15 @@ from database import get_db, init_db from models import Agent, AgentKey -from schemas import (AgentCreate, AgentUpdate, AgentResponse, AgentPublicInfo, +from schemas import (AgentCreate, AgentUpdate, AgentResponse, AgentPublicInfo, AgentKeyCreate, AgentKeyUpdate, AgentKeyResponse, Message) +from did_resolver import ( + DIDResolutionError, + DIDResolverNotRegistered, + DIDResolverRegistry, + TenzroDIDResolver, + WebDIDResolver, +) app = FastAPI( title="Agent Registry Service", @@ -37,10 +44,60 @@ allow_headers=["*"], ) +# --------------------------------------------------------------------------- +# DID resolver registry (additive — only used when key_type == 'did') +# --------------------------------------------------------------------------- + +#: Process-wide resolver registry. Operators register DID methods explicitly +#: at startup; there is no implicit method list. ``did:web:`` is always +#: registered (W3C standard, no third-party trust assumption beyond HTTPS). +#: ``did:tenzro:`` is opt-in via env var ``TAP_ENABLE_DID_TENZRO=1``. +did_resolvers: DIDResolverRegistry = DIDResolverRegistry() + + +def _resolve_did_key(key: "AgentKey") -> dict: + """ + Resolve a DID-keyed AgentKey row into the same response shape this + file already returns for PEM rows. Pure projection — caller decides + error mapping. + """ + try: + resolved = did_resolvers.resolve(key.key_id, method_hint=key.did_method) + except DIDResolverNotRegistered as exc: + # No resolver for this method on this deployment — operator config + # gap, not a client error. + raise HTTPException(status_code=501, detail=str(exc)) + except DIDResolutionError as exc: + # Document missing or malformed — surface as 404 so clients can + # distinguish from a registry-level outage. + raise HTTPException(status_code=404, detail=f"DID resolution failed: {exc}") + + out = { + "key_id": key.key_id, + "is_active": key.is_active, + "public_key": resolved.classical_pem, + "algorithm": resolved.algorithm, + "description": key.description, + "key_type": "did", + "did_method": key.did_method, + } + if resolved.pq_public_key_bytes is not None: + import base64 as _b64 + out["pq_public_key"] = _b64.b64encode(resolved.pq_public_key_bytes).decode("ascii") + return out + + @app.on_event("startup") async def startup_event(): - """Initialize database on startup""" + """Initialize database and DID resolvers on startup.""" init_db() + # did:web is registered unconditionally — pure W3C standard, no + # third-party trust assumption beyond HTTPS. + did_resolvers.register(WebDIDResolver()) + if os.getenv("TAP_ENABLE_DID_TENZRO", "0") in ("1", "true", "True"): + rpc_url = os.getenv("TAP_TENZRO_RPC_URL", TenzroDIDResolver.DEFAULT_RPC_URL) + did_resolvers.register(TenzroDIDResolver(rpc_url=rpc_url)) + print(f"🔗 did:tenzro: resolver enabled (rpc={rpc_url})") print("🏁 Agent Registry Service started successfully") @app.get("/", response_model=Message) @@ -167,7 +224,22 @@ async def get_agent_key(agent_id: int, key_id: str, db: Session = Depends(get_db if key.is_active != "true": raise HTTPException(status_code=404, detail=f"Key '{key_id}' is inactive for agent {agent_id}") - + + # DID-keyed rows: resolve the DID Document into the same response + # shape the PEM path returns. PEM rows are handled by the original + # branch below — unchanged. + if getattr(key, "key_type", "pem") == "did" or ( + hasattr(key, "key_type") and getattr(key.key_type, "value", None) == "did" + ): + resolved = _resolve_did_key(key) + print(f"✅ Resolved DID key '{key_id}' for agent ID: {agent_id}") + return { + "agent_id": agent_id, + "agent_name": agent.name, + "agent_domain": agent.domain, + **resolved, + } + print(f"✅ Retrieved key '{key_id}' for agent ID: {agent_id}") return { "agent_id": agent_id, @@ -177,7 +249,9 @@ async def get_agent_key(agent_id: int, key_id: str, db: Session = Depends(get_db "is_active": key.is_active, "public_key": key.public_key, "algorithm": key.algorithm, - "description": key.description + "description": key.description, + "key_type": "pem", + "pq_public_key": getattr(key, "pq_public_key", None), } except HTTPException: @@ -246,7 +320,21 @@ async def get_key_by_id(key_id: str, db: Session = Depends(get_db)): # Get associated agent info (optional - for context) agent = db.query(Agent).filter(Agent.id == key.agent_id).first() - + + # DID-keyed rows: resolve the DID Document into the same response + # shape the PEM path returns. + if getattr(key, "key_type", "pem") == "did" or ( + hasattr(key, "key_type") and getattr(key.key_type, "value", None) == "did" + ): + resolved = _resolve_did_key(key) + print(f"✅ Resolved DID key '{key_id}' (agent: {agent.name if agent else 'unknown'})") + return { + **resolved, + "agent_id": key.agent_id, + "agent_name": agent.name if agent else None, + "agent_domain": agent.domain if agent else None, + } + print(f"✅ Retrieved key '{key_id}' (agent: {agent.name if agent else 'unknown'})") return { "key_id": key_id, @@ -256,7 +344,9 @@ async def get_key_by_id(key_id: str, db: Session = Depends(get_db)): "description": key.description, "agent_id": key.agent_id, "agent_name": agent.name if agent else None, - "agent_domain": agent.domain if agent else None + "agent_domain": agent.domain if agent else None, + "key_type": "pem", + "pq_public_key": getattr(key, "pq_public_key", None), } except HTTPException: diff --git a/agent-registry/models.py b/agent-registry/models.py index 72335b4..8073759 100644 --- a/agent-registry/models.py +++ b/agent-registry/models.py @@ -6,11 +6,30 @@ # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum as SAEnum from sqlalchemy.orm import relationship from datetime import datetime +import enum from database import Base + +class KeyType(str, enum.Enum): + """ + Discriminator for how the registry stores the public key for a row. + + - ``pem`` — legacy / default: row stores a literal PEM-encoded public + key in ``public_key``. ``did_method`` and ``pq_public_key`` are NULL. + - ``did`` — row stores a DID URI in ``key_id``; ``public_key`` is NULL + and the registry resolves the DID Document via a registered + ``DIDResolver`` at lookup time. ``did_method`` selects the resolver. + + The default is ``pem`` so all existing rows keep working unchanged + after the column is added (``server_default='pem'`` in the migration). + """ + + PEM = "pem" + DID = "did" + class Agent(Base): __tablename__ = "agents" @@ -34,11 +53,34 @@ class AgentKey(Base): id = Column(Integer, primary_key=True, index=True) agent_id = Column(Integer, ForeignKey("agents.id"), nullable=False, index=True) - key_id = Column(String(100), nullable=False, index=True) # User-defined key identifier - public_key = Column(Text, nullable=False) # PEM format RSA public key + key_id = Column(String(1024), nullable=False, index=True) # widened from 100 to 1024 to fit DID URIs + # ``public_key`` becomes nullable: when ``key_type == KeyType.DID`` the + # registry resolves the public key from the DID Document at lookup time + # and stores nothing locally. Existing rows are PEM-keyed and remain + # required at the application layer (validators in schemas.py). + public_key = Column(Text, nullable=True) # PEM format public key (NULL for did rows) algorithm = Column(String(50), default="RSA-SHA256", nullable=False) # Signature algorithm description = Column(Text) # Optional key description is_active = Column(String(10), default="true", nullable=False) # Key active status + # --- Additive columns for DID + hybrid PQ keys --- + # ``key_type`` discriminates PEM vs DID. ``server_default`` ensures all + # existing rows materialize as ``pem`` after the ``ALTER TABLE ... ADD + # COLUMN`` migration; no backfill is required. + key_type = Column( + SAEnum(KeyType, name="agent_key_type"), + nullable=False, + default=KeyType.PEM, + server_default=KeyType.PEM.value, + ) + # ``did_method`` selects the registered ``DIDResolver`` (e.g. "web", + # "tenzro"). NULL for PEM rows. + did_method = Column(String(64), nullable=True) + # ``pq_public_key`` is the Base64-encoded raw ML-DSA-65 verifying key + # (1952 bytes per FIPS 204 §4 Table 2) when the row registers a hybrid + # ``ed25519-ml-dsa-65`` algorithm with a literal PEM. Hybrid keys + # sourced from a DID Document are resolved at lookup time and do not + # populate this column. + pq_public_key = Column(Text, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) diff --git a/agent-registry/schemas.py b/agent-registry/schemas.py index 44ddcc0..26b1d97 100644 --- a/agent-registry/schemas.py +++ b/agent-registry/schemas.py @@ -12,24 +12,76 @@ # Agent Key schemas class AgentKeyBase(BaseModel): - """Base agent key schema""" - key_id: str = Field(..., min_length=1, max_length=100, description="Key identifier (e.g., 'primary', 'backup-2024')") - public_key: str = Field(..., min_length=20, description="Public key in PEM format (RSA) or base64 format (Ed25519)") + """Base agent key schema. + + Two registration shapes are supported: + + 1. ``key_type == 'pem'`` (default — backward compatible): + ``public_key`` is required and validated as PEM/Ed25519 against + ``algorithm``. ``did_method`` MUST be omitted. + + 2. ``key_type == 'did'``: + ``key_id`` is a DID URI (``did:web:...``, ``did:tenzro:...``, ...). + ``public_key`` MUST be omitted — the registry resolves the public + key from the DID Document at lookup time. ``did_method`` selects + the registered resolver. + + The optional ``pq_public_key`` carries a Base64-encoded raw ML-DSA-65 + verifying key (1952 bytes per FIPS 204 §4 Table 2) for hybrid PEM + registrations using ``algorithm == 'ed25519-ml-dsa-65'``. Hybrid keys + sourced from a DID Document do not need to populate this field. + """ + key_id: str = Field(..., min_length=1, max_length=1024, description="Key identifier or DID URI") + public_key: Optional[str] = Field( + None, + min_length=20, + description="Public key in PEM format (RSA) or base64 format (Ed25519); NULL when key_type='did'", + ) algorithm: str = Field("RSA-SHA256", description="Signature algorithm") description: Optional[str] = Field(None, max_length=1000, description="Optional key description") is_active: str = Field("true", description="Key active status") - + key_type: str = Field("pem", description="Key storage discriminator: 'pem' or 'did'") + did_method: Optional[str] = Field( + None, + max_length=64, + description="DID method selector when key_type='did' (e.g. 'web', 'tenzro')", + ) + pq_public_key: Optional[str] = Field( + None, + description="Base64-encoded raw ML-DSA-65 verifying key (1952 bytes) for hybrid algorithms", + ) + + @validator('key_type') + def validate_key_type(cls, v): + if v not in ('pem', 'did'): + raise ValueError("key_type must be 'pem' or 'did'") + return v + @validator('public_key', always=True) def validate_public_key(cls, v, values): - """Validate public key format based on algorithm""" + """Validate public key format based on algorithm. + + For ``key_type == 'did'`` rows, the public key is resolved from + the DID Document at lookup time and MUST NOT be supplied here. + For ``key_type == 'pem'`` rows the field is required and the + algorithm-driven format checks below apply. + """ + key_type = values.get('key_type', 'pem') + if key_type == 'did': + if v is not None: + raise ValueError("public_key must be omitted when key_type='did'") + return None + # key_type == 'pem' — original validation path (unchanged) + if v is None: + raise ValueError("public_key is required when key_type='pem'") # Get algorithm from values dict, fallback to RSA-SHA256 algorithm = values.get('algorithm', 'RSA-SHA256') if algorithm: algorithm = algorithm.lower() - + # Auto-detect Ed25519 based on key format if algorithm detection fails is_ed25519 = ('ed25519' in algorithm) or (len(v.strip()) < 100 and not v.strip().startswith('-----')) - + if is_ed25519: # Ed25519 keys are base64 encoded, typically 44 characters import base64 @@ -45,9 +97,37 @@ def validate_public_key(cls, v, values): raise ValueError('RSA public key must be in PEM format starting with -----BEGIN PUBLIC KEY-----') if not v.strip().endswith('-----END PUBLIC KEY-----'): raise ValueError('RSA public key must be in PEM format ending with -----END PUBLIC KEY-----') - + return v.strip() - + + @validator('did_method', always=True) + def validate_did_method(cls, v, values): + """``did_method`` is required iff ``key_type == 'did'``.""" + key_type = values.get('key_type', 'pem') + if key_type == 'did': + if not v: + raise ValueError("did_method is required when key_type='did' (e.g. 'web', 'tenzro')") + return v + if v is not None: + raise ValueError("did_method must be omitted when key_type='pem'") + return None + + @validator('pq_public_key') + def validate_pq_public_key(cls, v): + """If supplied, validate Base64 and FIPS 204 §4 Table 2 length (1952 bytes).""" + if v is None: + return v + import base64 + try: + decoded = base64.b64decode(v.strip(), validate=True) + except Exception as exc: + raise ValueError(f"pq_public_key must be valid Base64: {exc}") + if len(decoded) != 1952: + raise ValueError( + f"pq_public_key must decode to exactly 1952 bytes (FIPS 204 §4 Table 2 ML-DSA-65); got {len(decoded)}" + ) + return v.strip() + @validator('is_active') def validate_is_active(cls, v): """Validate active status""" @@ -61,12 +141,31 @@ class AgentKeyCreate(AgentKeyBase): class AgentKeyUpdate(BaseModel): """Schema for updating an existing agent key""" - key_id: Optional[str] = Field(None, min_length=1, max_length=100) + key_id: Optional[str] = Field(None, min_length=1, max_length=1024) public_key: Optional[str] = Field(None, min_length=20) algorithm: Optional[str] = Field(None) description: Optional[str] = Field(None, max_length=1000) is_active: Optional[str] = Field(None) - + pq_public_key: Optional[str] = Field( + None, + description="Base64-encoded raw ML-DSA-65 verifying key (1952 bytes); set to update hybrid PEM rows", + ) + + @validator('pq_public_key') + def validate_pq_public_key(cls, v): + if v is None: + return v + import base64 + try: + decoded = base64.b64decode(v.strip(), validate=True) + except Exception as exc: + raise ValueError(f"pq_public_key must be valid Base64: {exc}") + if len(decoded) != 1952: + raise ValueError( + f"pq_public_key must decode to exactly 1952 bytes (FIPS 204 §4 Table 2 ML-DSA-65); got {len(decoded)}" + ) + return v.strip() + @validator('public_key', always=True) def validate_public_key(cls, v, values): """Validate public key format if provided""" diff --git a/agent-registry/sig_algorithms.py b/agent-registry/sig_algorithms.py new file mode 100644 index 0000000..6c3f716 --- /dev/null +++ b/agent-registry/sig_algorithms.py @@ -0,0 +1,323 @@ +""" +Signature algorithm dispatch for the agent registry. + +This module is a small, additive layer on top of the existing +PEM-based Ed25519 / RSA-PSS-SHA256 verifiers shipped in +`agent-registry/`. It introduces one new algorithm string, +``ed25519-ml-dsa-65``, defined per the RFC 9421 §6.2 algorithm registry +extension pattern (see https://www.rfc-editor.org/rfc/rfc9421#section-6.2). + +The composite algorithm signs and verifies the SAME RFC 9421 Sig-Base +twice — once with Ed25519 (RFC 8032), once with ML-DSA-65 (NIST +FIPS 204) — and BOTH legs must validate for the verification to succeed. +The wire signature is the concatenation: + + sig = ed25519_sig (64 bytes) || ml_dsa_65_sig (3309 bytes) + +This matches the construction Tenzro Network ships in production for its +hybrid-PQ migration window and follows NIST SP 800-227 PQC transition +guidance: an attacker must break BOTH the classical curve AND the +lattice scheme to forge. + +Soft dependency +--------------- + +ML-DSA support is provided by ``oqs`` (the Python binding for Open +Quantum Safe's liboqs, https://github.com/open-quantum-safe/liboqs-python). +``oqs`` is NOT a hard dependency of the agent registry: the import is +done lazily inside `_oqs_module()`, and any verification call against +``ed25519-ml-dsa-65`` raises `HybridUnavailable` (which the registry +maps to a 503) if liboqs isn't installed. All other algorithms continue +to work unaffected. + +Operators who want to enable hybrid PQ verification install with:: + + pip install oqs + +(and ensure liboqs is installed at the system level — see the +liboqs-python README). + +Wire format +----------- + +The hybrid signature on the wire is a single Base64-encoded blob: the +concatenation of the two raw signatures, fixed-width by spec: + + [ ed25519: 64 bytes ][ ml_dsa_65: 3309 bytes ] + => total: 3373 bytes raw, 4500 chars Base64. + +The classical and PQ public keys are stored separately on the +`AgentKey` row (`public_key` for the Ed25519 PEM, `pq_public_key` for +the ML-DSA-65 raw verifying key, Base64-encoded). When the key is +sourced from a DID Document, both legs come out of `ResolvedKey`. +""" +from __future__ import annotations + +import base64 +from dataclasses import dataclass +from typing import Optional + +# `cryptography` is already a dep of agent-registry for the existing +# Ed25519 / RSA-PSS verification paths. +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + + +# --------------------------------------------------------------------------- +# Constants (FIPS 204 §4 Table 2 — ML-DSA-65 parameter set) +# --------------------------------------------------------------------------- + +#: Algorithm string registered per RFC 9421 §6.2 extension pattern. +HYBRID_ALG = "ed25519-ml-dsa-65" + +#: Ed25519 raw signature length (RFC 8032 §3.1, fixed). +ED25519_SIG_LEN = 64 + +#: ML-DSA-65 signature length in bytes (FIPS 204 §4 Table 2). +ML_DSA_65_SIG_LEN = 3309 + +#: ML-DSA-65 verifying-key length in bytes (FIPS 204 §4 Table 2). +ML_DSA_65_VK_LEN = 1952 + +#: Combined hybrid signature length on the wire (raw, pre-Base64). +HYBRID_SIG_LEN = ED25519_SIG_LEN + ML_DSA_65_SIG_LEN # 3373 + +#: liboqs algorithm identifier for the FIPS 204 standardized scheme. +#: liboqs uses "ML-DSA-65" (with hyphen) in its Signature constructor. +_OQS_ALG_NAME = "ML-DSA-65" + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + +class HybridUnavailable(RuntimeError): + """ + Raised when ``ed25519-ml-dsa-65`` is invoked but `oqs` (and therefore + liboqs) is not installed. Operators MUST install ``oqs`` to enable + hybrid PQ. The error is fail-closed by design — silently falling + back to classical-only would defeat the point of registering a + hybrid algorithm. + """ + + +class HybridVerificationError(Exception): + """ + Raised when either leg of the hybrid signature fails to verify, or + when input lengths/encodings are wrong. Both legs MUST pass for + success; anything else is a hard fail. + """ + + +# --------------------------------------------------------------------------- +# Soft import of `oqs` +# --------------------------------------------------------------------------- + + +def _oqs_module(): + """ + Lazy import so the agent registry runs without `oqs` installed. + + Returns the `oqs` module on success; raises `HybridUnavailable` + otherwise. Callers MUST handle the exception — the dispatcher in + `main.py` maps it to a 503. + """ + try: + import oqs # type: ignore + except ImportError as exc: + raise HybridUnavailable( + "Hybrid algorithm 'ed25519-ml-dsa-65' requires the `oqs` Python " + "package and a system-level liboqs install. See " + "https://github.com/open-quantum-safe/liboqs-python." + ) from exc + return oqs + + +# --------------------------------------------------------------------------- +# Inputs +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class HybridKey: + """ + A pair of public keys used by the verifier. + + `ed25519_pem` is a PEM-encoded SubjectPublicKeyInfo holding the + Ed25519 verifying key (32 bytes raw). This is the same encoding the + registry already uses for ``algorithm == 'ed25519'``. + + `ml_dsa_65_raw` is the raw 1952-byte ML-DSA-65 verifying key per + FIPS 204 §4 Table 2. Stored Base64-encoded on the `AgentKey` row + (column ``pq_public_key``); decode before passing here. + """ + + ed25519_pem: str + ml_dsa_65_raw: bytes + + +# --------------------------------------------------------------------------- +# Verifier +# --------------------------------------------------------------------------- + + +def verify_hybrid( + *, + sig_base: bytes, + signature: bytes, + key: HybridKey, +) -> None: + """ + Verify a composite ``ed25519-ml-dsa-65`` signature. + + Per RFC 9421 §3.3, both signatures are computed over the SAME + Sig-Base bytes (the canonicalized signature input string the + verifier reconstructs from the request). Both MUST verify for + success. Returns `None` on success; raises `HybridVerificationError` + on any failure. + + Parameters + ---------- + sig_base + The exact bytes of the RFC 9421 Sig-Base string. The caller is + responsible for canonicalization — this function is pure crypto. + signature + Raw concatenation: ed25519_sig (64B) || ml_dsa_65_sig (3309B). + Total length MUST be exactly `HYBRID_SIG_LEN` (3373). + key + Both verifying keys. + """ + if len(signature) != HYBRID_SIG_LEN: + raise HybridVerificationError( + f"Hybrid signature length must be {HYBRID_SIG_LEN} bytes " + f"({ED25519_SIG_LEN} ed25519 + {ML_DSA_65_SIG_LEN} ml-dsa-65); " + f"got {len(signature)}" + ) + if len(key.ml_dsa_65_raw) != ML_DSA_65_VK_LEN: + raise HybridVerificationError( + f"ML-DSA-65 verifying key must be {ML_DSA_65_VK_LEN} bytes; " + f"got {len(key.ml_dsa_65_raw)}" + ) + + ed_sig = signature[:ED25519_SIG_LEN] + pq_sig = signature[ED25519_SIG_LEN:] + + # --- Classical leg --- + try: + ed_pub = serialization.load_pem_public_key(key.ed25519_pem.encode("ascii")) + except (ValueError, TypeError) as exc: + raise HybridVerificationError(f"invalid Ed25519 PEM: {exc}") from exc + if not isinstance(ed_pub, ed25519.Ed25519PublicKey): + raise HybridVerificationError( + "PEM is not an Ed25519 public key; check `algorithm` column" + ) + try: + ed_pub.verify(ed_sig, sig_base) + except InvalidSignature as exc: + raise HybridVerificationError("Ed25519 signature did not verify") from exc + + # --- Post-quantum leg --- + oqs = _oqs_module() + # liboqs's Python binding uses a context manager and reuses one + # `Signature` instance per verification. + with oqs.Signature(_OQS_ALG_NAME) as verifier: + if not verifier.verify(sig_base, pq_sig, key.ml_dsa_65_raw): + raise HybridVerificationError("ML-DSA-65 signature did not verify") + + +# --------------------------------------------------------------------------- +# Signer (reference implementation, used by tests and tooling — the +# registry itself is verify-only) +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class HybridSecret: + """ + Pair of secrets used by the reference signer. Not used by the + agent-registry server itself (which only verifies); kept here so + the test suite and any operator tooling have a single place to + compose hybrid signatures correctly. + """ + + ed25519_pem: str # PEM-encoded PKCS#8 private key + ml_dsa_65_secret: bytes # raw ML-DSA-65 secret-key bytes + + +def sign_hybrid(*, sig_base: bytes, secret: HybridSecret) -> bytes: + """ + Produce a composite signature: ed25519_sig || ml_dsa_65_sig. + + Both signatures are computed over the same `sig_base` (RFC 9421 + §3.3). The caller is responsible for canonicalization. + """ + # Classical leg + try: + ed_priv = serialization.load_pem_private_key( + secret.ed25519_pem.encode("ascii"), password=None + ) + except (ValueError, TypeError) as exc: + raise HybridVerificationError(f"invalid Ed25519 PEM private key: {exc}") from exc + if not isinstance(ed_priv, ed25519.Ed25519PrivateKey): + raise HybridVerificationError("PEM is not an Ed25519 private key") + ed_sig = ed_priv.sign(sig_base) + if len(ed_sig) != ED25519_SIG_LEN: + # cryptography always returns 64 bytes for Ed25519, but be defensive. + raise HybridVerificationError("Ed25519 sign produced wrong-length signature") + + # PQ leg + oqs = _oqs_module() + with oqs.Signature(_OQS_ALG_NAME, secret.ml_dsa_65_secret) as signer: + pq_sig = signer.sign(sig_base) + if len(pq_sig) != ML_DSA_65_SIG_LEN: + raise HybridVerificationError( + f"ML-DSA-65 sign produced {len(pq_sig)} bytes, expected {ML_DSA_65_SIG_LEN}" + ) + + return ed_sig + pq_sig + + +# --------------------------------------------------------------------------- +# Convenience encoders +# --------------------------------------------------------------------------- + + +def encode_hybrid_signature(sig: bytes) -> str: + """Base64-encode a raw hybrid signature for HTTP transport.""" + if len(sig) != HYBRID_SIG_LEN: + raise HybridVerificationError( + f"refusing to encode hybrid signature of wrong length {len(sig)}" + ) + return base64.b64encode(sig).decode("ascii") + + +def decode_hybrid_signature(b64: str) -> bytes: + """Decode a Base64 hybrid signature; validates length on the way out.""" + try: + raw = base64.b64decode(b64, validate=True) + except (ValueError, TypeError) as exc: + raise HybridVerificationError(f"hybrid signature is not valid Base64: {exc}") from exc + if len(raw) != HYBRID_SIG_LEN: + raise HybridVerificationError( + f"decoded hybrid signature is {len(raw)} bytes, expected {HYBRID_SIG_LEN}" + ) + return raw + + +__all__ = [ + "HYBRID_ALG", + "HYBRID_SIG_LEN", + "ED25519_SIG_LEN", + "ML_DSA_65_SIG_LEN", + "ML_DSA_65_VK_LEN", + "HybridKey", + "HybridSecret", + "HybridUnavailable", + "HybridVerificationError", + "verify_hybrid", + "sign_hybrid", + "encode_hybrid_signature", + "decode_hybrid_signature", +] diff --git a/agent-registry/tests/test_did_resolution.py b/agent-registry/tests/test_did_resolution.py new file mode 100644 index 0000000..ccc757f --- /dev/null +++ b/agent-registry/tests/test_did_resolution.py @@ -0,0 +1,280 @@ +""" +Tests for the DID resolver dispatch and the two reference resolvers. + +These tests do NOT hit the network. The W3C did:web fetch and the +Tenzro JSON-RPC call are stubbed via a fake `requests.Session` so the +suite stays hermetic. + +Pytest is the existing test framework for agent-registry; no new +fixtures or plugins are required. +""" +from __future__ import annotations + +import base64 +import json +from typing import Any, Dict +from unittest.mock import MagicMock + +import pytest + +# The agent-registry is a flat-module project (`from database import Base`, +# etc.), so we import the same way. Run pytest from `agent-registry/` +# or set `PYTHONPATH=agent-registry` so the modules resolve. +from did_resolver import ( # type: ignore[import-not-found] + DIDResolutionError, + DIDResolverNotRegistered, + DIDResolverRegistry, + ResolvedKey, + TenzroDIDResolver, + WebDIDResolver, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +# A 32-byte Ed25519 raw public key (deterministic test vector — NOT a +# real key; just 32 bytes that decode cleanly through cryptography). +ED25519_RAW = bytes(range(32)) +# A 1952-byte ML-DSA-65 raw verifying key placeholder. The verifier in +# `sig_algorithms.py` checks length only when actually invoked, so a +# byte-pattern of the right length is fine for resolver tests. +ML_DSA_65_RAW = bytes((i % 256 for i in range(1952))) + + +def _multibase_b58(raw: bytes) -> str: + import base58 # test dep, declared in test requirements + + return "z" + base58.b58encode(raw).decode("ascii") + + +def _did_doc_with_ed25519_only(did: str) -> Dict[str, Any]: + return { + "@context": ["https://www.w3.org/ns/did/v1"], + "id": did, + "verificationMethod": [ + { + "id": f"{did}#key-1", + "type": "Ed25519VerificationKey2020", + "controller": did, + "publicKeyMultibase": _multibase_b58(ED25519_RAW), + } + ], + "authentication": [f"{did}#key-1"], + } + + +def _did_doc_with_hybrid(did: str) -> Dict[str, Any]: + doc = _did_doc_with_ed25519_only(did) + doc["verificationMethod"].append( + { + "id": f"{did}#pq-key-1", + "type": "MlDsa65VerificationKey2026", + "controller": did, + "publicKeyMultibase": _multibase_b58(ML_DSA_65_RAW), + } + ) + return doc + + +# --------------------------------------------------------------------------- +# Registry dispatch +# --------------------------------------------------------------------------- + + +def test_registry_rejects_unregistered_method(): + reg = DIDResolverRegistry() + with pytest.raises(DIDResolverNotRegistered): + reg.resolve("did:key:zABC") + + +def test_registry_rejects_non_did_uri(): + reg = DIDResolverRegistry() + with pytest.raises(DIDResolutionError): + reg.resolve("https://example.com/agent") + + +def test_registry_dispatches_by_method(): + reg = DIDResolverRegistry() + fake = MagicMock(spec=WebDIDResolver) + fake.method = "web" + fake.resolve.return_value = ResolvedKey( + algorithm="ed25519", + classical_pem="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n", + ) + reg.register(fake) + + out = reg.resolve("did:web:agent.example.com") + assert out.algorithm == "ed25519" + fake.resolve.assert_called_once_with("did:web:agent.example.com") + + +def test_registry_method_hint_overrides_did_prefix(): + """A row with did_method='tenzro' must dispatch to the tenzro resolver.""" + reg = DIDResolverRegistry() + web = MagicMock(spec=WebDIDResolver) + web.method = "web" + tenzro = MagicMock(spec=TenzroDIDResolver) + tenzro.method = "tenzro" + tenzro.resolve.return_value = ResolvedKey( + algorithm="ed25519-ml-dsa-65", + classical_pem="pem", + pq_public_key_bytes=ML_DSA_65_RAW, + ) + reg.register(web) + reg.register(tenzro) + + out = reg.resolve("did:tenzro:machine:01j8", method_hint="tenzro") + assert out.algorithm == "ed25519-ml-dsa-65" + tenzro.resolve.assert_called_once() + web.resolve.assert_not_called() + + +# --------------------------------------------------------------------------- +# WebDIDResolver +# --------------------------------------------------------------------------- + + +class _FakeResp: + def __init__(self, status_code: int, body: Any): + self.status_code = status_code + self._body = body + + def json(self): + if isinstance(self._body, str): + raise ValueError("not json") + return self._body + + +class _FakeSession: + def __init__(self, get_response: _FakeResp): + self._get_response = get_response + self.last_url = None + + def get(self, url, timeout=None): + self.last_url = url + return self._get_response + + def post(self, url, json=None, timeout=None): + self.last_url = url + self.last_body = json + return self._get_response + + +def test_web_resolver_uses_well_known_path_for_bare_host(): + did = "did:web:agent.example.com" + sess = _FakeSession(_FakeResp(200, _did_doc_with_ed25519_only(did))) + r = WebDIDResolver(session=sess) # type: ignore[arg-type] + + out = r.resolve(did) + assert sess.last_url == "https://agent.example.com/.well-known/did.json" + assert out.algorithm == "ed25519" + assert "BEGIN PUBLIC KEY" in out.classical_pem + + +def test_web_resolver_uses_path_form_for_subpath_did(): + did = "did:web:example.com:agents:alice" + sess = _FakeSession(_FakeResp(200, _did_doc_with_ed25519_only(did))) + r = WebDIDResolver(session=sess) # type: ignore[arg-type] + + r.resolve(did) + assert sess.last_url == "https://example.com/agents/alice/did.json" + + +def test_web_resolver_promotes_to_hybrid_when_pq_method_present(): + did = "did:web:agent.example.com" + sess = _FakeSession(_FakeResp(200, _did_doc_with_hybrid(did))) + r = WebDIDResolver(session=sess) # type: ignore[arg-type] + + out = r.resolve(did) + assert out.algorithm == "ed25519-ml-dsa-65" + assert out.pq_public_key_bytes == ML_DSA_65_RAW + + +def test_web_resolver_propagates_http_errors(): + did = "did:web:agent.example.com" + sess = _FakeSession(_FakeResp(404, "")) + r = WebDIDResolver(session=sess) # type: ignore[arg-type] + + with pytest.raises(DIDResolutionError): + r.resolve(did) + + +def test_web_resolver_rejects_non_did_web(): + r = WebDIDResolver(session=_FakeSession(_FakeResp(200, {}))) # type: ignore[arg-type] + with pytest.raises(DIDResolutionError): + r.resolve("did:tenzro:machine:01j8") + + +# --------------------------------------------------------------------------- +# TenzroDIDResolver +# --------------------------------------------------------------------------- + + +def test_tenzro_resolver_calls_jsonrpc_with_correct_method(): + did = "did:tenzro:machine:01j8...:01j9..." + envelope = {"jsonrpc": "2.0", "id": 1, "result": _did_doc_with_hybrid(did)} + sess = _FakeSession(_FakeResp(200, envelope)) + r = TenzroDIDResolver(rpc_url="https://rpc.tenzro.network", session=sess) # type: ignore[arg-type] + + out = r.resolve(did) + assert sess.last_url == "https://rpc.tenzro.network" + assert sess.last_body["method"] == "tenzro_resolveDidDocument" + assert sess.last_body["params"] == {"did": did} + assert out.algorithm == "ed25519-ml-dsa-65" + assert out.pq_public_key_bytes == ML_DSA_65_RAW + + +def test_tenzro_resolver_propagates_jsonrpc_error(): + did = "did:tenzro:machine:bogus" + envelope = {"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "not found"}} + sess = _FakeSession(_FakeResp(200, envelope)) + r = TenzroDIDResolver(rpc_url="https://rpc.tenzro.network", session=sess) # type: ignore[arg-type] + + with pytest.raises(DIDResolutionError) as ei: + r.resolve(did) + assert "not found" in str(ei.value) + + +def test_tenzro_resolver_rejects_missing_result(): + did = "did:tenzro:machine:01j8" + envelope = {"jsonrpc": "2.0", "id": 1} + sess = _FakeSession(_FakeResp(200, envelope)) + r = TenzroDIDResolver(rpc_url="https://rpc.tenzro.network", session=sess) # type: ignore[arg-type] + + with pytest.raises(DIDResolutionError): + r.resolve(did) + + +def test_tenzro_resolver_rejects_non_tenzro_did(): + sess = _FakeSession(_FakeResp(200, {})) + r = TenzroDIDResolver(rpc_url="https://rpc.tenzro.network", session=sess) # type: ignore[arg-type] + with pytest.raises(DIDResolutionError): + r.resolve("did:web:agent.example.com") + + +# --------------------------------------------------------------------------- +# Document parsing edge cases +# --------------------------------------------------------------------------- + + +def test_document_without_ed25519_method_fails(): + did = "did:web:agent.example.com" + doc = { + "id": did, + "verificationMethod": [ + { + "id": f"{did}#x", + "type": "X25519KeyAgreementKey2020", + "controller": did, + "publicKeyMultibase": _multibase_b58(ED25519_RAW), + } + ], + } + sess = _FakeSession(_FakeResp(200, doc)) + r = WebDIDResolver(session=sess) # type: ignore[arg-type] + + with pytest.raises(DIDResolutionError): + r.resolve(did) diff --git a/agent-registry/tests/test_hybrid_pq.py b/agent-registry/tests/test_hybrid_pq.py new file mode 100644 index 0000000..2fa3c25 --- /dev/null +++ b/agent-registry/tests/test_hybrid_pq.py @@ -0,0 +1,230 @@ +""" +Tests for the `ed25519-ml-dsa-65` composite algorithm (RFC 9421 §6.2). + +These tests: + + * Validate the wire-format constants against FIPS 204. + * Verify that the registry runs without `oqs` installed (the soft + dependency must NOT break import). + * Round-trip sign + verify when `oqs` IS installed (skipped otherwise). + * Reject tampered classical and PQ legs as forgeries. + * Reject malformed signature lengths and malformed key lengths. + +The `oqs` import is gated by `pytest.importorskip` so the suite passes +in both configurations: with or without liboqs installed. +""" +from __future__ import annotations + +import base64 + +import pytest + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +# The agent-registry is a flat-module project, so we import the same way. +# Run pytest from `agent-registry/` or set `PYTHONPATH=agent-registry`. +from sig_algorithms import ( # type: ignore[import-not-found] + ED25519_SIG_LEN, + HYBRID_ALG, + HYBRID_SIG_LEN, + ML_DSA_65_SIG_LEN, + ML_DSA_65_VK_LEN, + HybridKey, + HybridSecret, + HybridUnavailable, + HybridVerificationError, + decode_hybrid_signature, + encode_hybrid_signature, + sign_hybrid, + verify_hybrid, +) + + +# --------------------------------------------------------------------------- +# FIPS 204 constants +# --------------------------------------------------------------------------- + + +def test_constants_match_fips_204_table_2(): + # FIPS 204 §4 Table 2 specifies these byte lengths for ML-DSA-65. + assert ML_DSA_65_SIG_LEN == 3309 + assert ML_DSA_65_VK_LEN == 1952 + + +def test_constants_match_rfc_8032_for_ed25519(): + assert ED25519_SIG_LEN == 64 + assert HYBRID_SIG_LEN == ED25519_SIG_LEN + ML_DSA_65_SIG_LEN + assert HYBRID_SIG_LEN == 3373 + + +def test_algorithm_string_is_stable(): + # Per RFC 9421 §6.2, the algorithm string is the wire identifier + # consumers will pin against. Don't change it without a major version bump. + assert HYBRID_ALG == "ed25519-ml-dsa-65" + + +# --------------------------------------------------------------------------- +# Soft dependency: registry boots without `oqs` +# --------------------------------------------------------------------------- + + +def test_module_imports_without_oqs(): + """The module-level import must NOT pull in oqs.""" + import importlib + + import sig_algorithms as mod # type: ignore + + importlib.reload(mod) + # If `oqs` isn't installed, this just returns; if it is, it + # also returns. Either way, the module must load. + assert mod.HYBRID_ALG == "ed25519-ml-dsa-65" + + +def test_invocation_without_oqs_raises_unavailable(monkeypatch): + """Invoking `verify_hybrid` without oqs must fail closed (no fallback).""" + import sig_algorithms as mod # type: ignore + + def _raise(*_a, **_k): + raise HybridUnavailable("liboqs not available (test stub)") + + monkeypatch.setattr(mod, "_oqs_module", _raise) + + # Build a syntactically valid input set so we get past length checks + # and hit the oqs lookup. + ed_priv = ed25519.Ed25519PrivateKey.generate() + ed_pub_pem = ed_priv.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + + sig_base = b"@authority: example.com\n@method: POST\n" + ed_sig = ed_priv.sign(sig_base) + fake_pq = bytes(ML_DSA_65_SIG_LEN) + fake_vk = bytes(ML_DSA_65_VK_LEN) + + with pytest.raises(HybridUnavailable): + verify_hybrid( + sig_base=sig_base, + signature=ed_sig + fake_pq, + key=HybridKey(ed25519_pem=ed_pub_pem, ml_dsa_65_raw=fake_vk), + ) + + +# --------------------------------------------------------------------------- +# Length / encoding checks (do not require oqs) +# --------------------------------------------------------------------------- + + +def test_verify_rejects_wrong_signature_length(): + ed_priv = ed25519.Ed25519PrivateKey.generate() + ed_pub_pem = ed_priv.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + sig_base = b"x" + + with pytest.raises(HybridVerificationError, match="length must be"): + verify_hybrid( + sig_base=sig_base, + signature=b"too-short", + key=HybridKey(ed25519_pem=ed_pub_pem, ml_dsa_65_raw=bytes(ML_DSA_65_VK_LEN)), + ) + + +def test_verify_rejects_wrong_pq_key_length(): + ed_priv = ed25519.Ed25519PrivateKey.generate() + ed_pub_pem = ed_priv.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + sig_base = b"x" + sig = bytes(HYBRID_SIG_LEN) + + with pytest.raises(HybridVerificationError, match="verifying key must be"): + verify_hybrid( + sig_base=sig_base, + signature=sig, + key=HybridKey(ed25519_pem=ed_pub_pem, ml_dsa_65_raw=bytes(100)), + ) + + +def test_encode_decode_roundtrip(): + raw = bytes(HYBRID_SIG_LEN) # the encoder doesn't validate semantic content + s = encode_hybrid_signature(raw) + assert decode_hybrid_signature(s) == raw + + +def test_encode_rejects_wrong_length(): + with pytest.raises(HybridVerificationError): + encode_hybrid_signature(b"\x00\x01\x02") + + +def test_decode_rejects_wrong_length(): + too_small = base64.b64encode(b"\x00" * 10).decode("ascii") + with pytest.raises(HybridVerificationError): + decode_hybrid_signature(too_small) + + +# --------------------------------------------------------------------------- +# Round-trip and forgery tests (require oqs + liboqs) +# --------------------------------------------------------------------------- + +oqs = pytest.importorskip("oqs", reason="liboqs-python not installed") + + +@pytest.fixture(scope="module") +def hybrid_keypair(): + ed_priv = ed25519.Ed25519PrivateKey.generate() + ed_priv_pem = ed_priv.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + ed_pub_pem = ed_priv.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + + with oqs.Signature("ML-DSA-65") as kg: + pq_pub = kg.generate_keypair() + pq_secret = kg.export_secret_key() + + assert len(pq_pub) == ML_DSA_65_VK_LEN + + return { + "secret": HybridSecret(ed25519_pem=ed_priv_pem, ml_dsa_65_secret=pq_secret), + "public": HybridKey(ed25519_pem=ed_pub_pem, ml_dsa_65_raw=pq_pub), + } + + +def test_hybrid_sign_verify_roundtrip(hybrid_keypair): + sig_base = b'"@method": POST\n"@authority": agent-registry.example\n"@path": /keys/lookup\n' + sig = sign_hybrid(sig_base=sig_base, secret=hybrid_keypair["secret"]) + assert len(sig) == HYBRID_SIG_LEN + # Must not raise. + verify_hybrid(sig_base=sig_base, signature=sig, key=hybrid_keypair["public"]) + + +def test_hybrid_rejects_tampered_classical_leg(hybrid_keypair): + sig_base = b"good" + sig = bytearray(sign_hybrid(sig_base=sig_base, secret=hybrid_keypair["secret"])) + # Flip a bit inside the Ed25519 leg. + sig[0] ^= 0x01 + with pytest.raises(HybridVerificationError, match="Ed25519"): + verify_hybrid(sig_base=sig_base, signature=bytes(sig), key=hybrid_keypair["public"]) + + +def test_hybrid_rejects_tampered_pq_leg(hybrid_keypair): + sig_base = b"good" + sig = bytearray(sign_hybrid(sig_base=sig_base, secret=hybrid_keypair["secret"])) + # Flip a bit inside the ML-DSA-65 leg. + sig[ED25519_SIG_LEN] ^= 0x01 + with pytest.raises(HybridVerificationError, match="ML-DSA-65"): + verify_hybrid(sig_base=sig_base, signature=bytes(sig), key=hybrid_keypair["public"]) + + +def test_hybrid_rejects_wrong_sig_base(hybrid_keypair): + sig = sign_hybrid(sig_base=b"intended", secret=hybrid_keypair["secret"]) + with pytest.raises(HybridVerificationError): + verify_hybrid(sig_base=b"not-the-base", signature=sig, key=hybrid_keypair["public"])