diff --git a/README.md b/README.md index 1d3553d..0e8f18f 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,17 @@ Set `ClientConfig(consumer_auth_mode="required")` when a workload must not silently fall back to anonymous dispatch if consumer-token acquisition fails. The default remains `"optional"`; `"disabled"` skips token acquisition. +### Restricted trust-domain directories + +Restricted operation is opt-in and fail closed. Supply a +`RestrictedDirectoryContext` whose membership credential is referenced through +an environment variable or an owner-only regular file. The client sends it only +to the configured directory, refuses redirects and legacy discovery fallback, +and accepts candidates or tokens only when the directory returns a matching, +current operation decision. Public behavior is unchanged when the context is +absent. This client does not expose peer-gossip or CIP-worker participation, so +those capabilities are not implied by restricted directory support. + ### Experimental local candidate rankers Library users may attach a `CandidateRanker` to test a learned or @@ -672,6 +683,13 @@ distributed store. None of these APIs is used by normal discovery, submission or node serving. They are unstable research surfaces and must not be treated as deployed protocol guarantees. +`iicp_client.restricted_membership` verifies the shared restricted trust-domain +membership and authenticated-gossip fixtures with the same bounded refusal +reasons as the Rust reference. It is a verification foundation only: Python +bootstrap, peer storage, relay selection and CIP do not claim restricted-domain +support until issue #103 and the cross-implementation black-box gate are +complete. Public behavior is unchanged. + --- ## Development diff --git a/src/iicp_client/__init__.py b/src/iicp_client/__init__.py index aa3f4b2..0a068e0 100644 --- a/src/iicp_client/__init__.py +++ b/src/iicp_client/__init__.py @@ -90,6 +90,17 @@ qualify_service, qualify_service_async, ) +from iicp_client.restricted_directory import ( + PROFILE_ID as RESTRICTED_TRUST_DOMAIN_PROFILE_ID, +) +from iicp_client.restricted_directory import ( + RestrictedDirectoryContext, + RestrictedEligibility, + SecretRef, +) +from iicp_client.restricted_directory import ( + validate_decision as validate_restricted_directory_decision, +) from iicp_client.routing_policy import ( ROUTING_POLICY_REFUSAL_CODE, filter_nodes_for_routing_policy, @@ -142,6 +153,11 @@ __all__ = [ "IicpClient", "IicpError", + "RestrictedDirectoryContext", + "RestrictedEligibility", + "SecretRef", + "RESTRICTED_TRUST_DOMAIN_PROFILE_ID", + "validate_restricted_directory_decision", "EFFECTIVE_CAPABILITY_PROFILE_ID", "EFFECTIVE_CAPABILITY_SCHEMA_VERSION", "CapabilityClaimProvenance", diff --git a/src/iicp_client/_http.py b/src/iicp_client/_http.py index bdbe605..7915acf 100644 --- a/src/iicp_client/_http.py +++ b/src/iicp_client/_http.py @@ -43,11 +43,15 @@ async def get_json( component: str = "directory", tls_verify: bool = True, traceparent: str | None = None, + extra_headers: dict[str, str] | None = None, + follow_redirects: bool = False, ) -> dict[str, Any]: timeout = timeout_ms / 1000.0 headers = {"traceparent": traceparent or _traceparent()} + if extra_headers: + headers.update(extra_headers) try: - async with httpx.AsyncClient(timeout=timeout, verify=_tls_context(tls_verify)) as client: + async with httpx.AsyncClient(timeout=timeout, verify=_tls_context(tls_verify), follow_redirects=follow_redirects) as client: resp = await client.get(url, params=params, headers=headers) except httpx.TimeoutException: raise IicpError( diff --git a/src/iicp_client/client.py b/src/iicp_client/client.py index cc5047b..8f8479c 100644 --- a/src/iicp_client/client.py +++ b/src/iicp_client/client.py @@ -21,6 +21,7 @@ from iicp_client.errors import IicpError from iicp_client.policy import ensure_intent_allowed from iicp_client.request_projection import project_execution_constraints, project_route_options +from iicp_client.restricted_directory import validate_decision from iicp_client.routing_policy import ( ROUTING_POLICY_REFUSAL_CODE, filter_nodes_for_routing_policy, @@ -148,6 +149,11 @@ def __init__(self, config: ClientConfig | None = None) -> None: self._ct_cache: dict[tuple[str, str], tuple[str, int]] = {} self._dispatch_ticket_key: str | None = None self._candidate_ranker: CandidateRanker | None = None + self._restricted_eligibility: dict[int, object] = {} + if self._cfg.restricted_directory is not None: + self._cfg.restricted_directory.validate() + if self._cfg.route_discovery_mode == "legacy": + raise ValueError("restricted directory mode cannot use legacy route discovery") def with_candidate_ranker(self, ranker: CandidateRanker) -> IicpClient: """Attach an optional ranker for candidates that already passed eligibility.""" @@ -171,22 +177,29 @@ async def _acquire_consumer_token(self, target_node_id: str, intent: str, timeou return tok base = self._cfg.directory_url.rstrip("/").removesuffix("/api") url = f"{base}/api/v1/consumer-token" + restricted = self._cfg.restricted_directory try: - async with httpx.AsyncClient(timeout=timeout_s) as client: + async with httpx.AsyncClient(timeout=timeout_s, follow_redirects=False) as client: + headers = {"Authorization": f"Bearer {node_token}"} + if restricted: + headers.update(restricted.headers()) r = await client.post( url, json={"target_node_id": target_node_id, "intent": intent}, - headers={"Authorization": f"Bearer {node_token}"}, + headers=headers, ) if r.status_code == 201: data = r.json() + if restricted: + validate_decision(data, restricted, "consumer_token") token: str = data.get("token", "") exp_unix: int = int(data.get("expires_at", 0)) if token: self._ct_cache[cache_key] = (token, exp_unix) return token except Exception: - pass + if restricted: + raise return None def _select_candidates(self, all_nodes: list[Node], top_n: int) -> list[Node]: @@ -281,6 +294,8 @@ async def _ticketed_candidates( excluded: list[str] = [] candidates: list[Node] = [] headers = {"Accept": "application/json", "Content-Type": "application/json"} + if self._cfg.restricted_directory is not None: + headers.update(self._cfg.restricted_directory.headers()) if traceparent: headers["traceparent"] = traceparent @@ -298,6 +313,11 @@ async def _ticketed_candidates( error_code = data.get("error", {}).get("code") if isinstance(data, dict) else None if response.status_code == 201: + eligibility = ( + validate_decision(data, self._cfg.restricted_directory, "dispatch_ticket") + if self._cfg.restricted_directory is not None + else None + ) ticket = data.get("ticket") if isinstance(data, dict) else None node_id = data.get("node_id") if isinstance(data, dict) else None if self._dispatch_ticket_key is None: @@ -334,6 +354,8 @@ async def _ticketed_candidates( route = {**route, "node_id": data.get("node_id", route.get("node_id"))} node = self._node_from_route(route, ticket_id_prefix=data.get("ticket_id_prefix")) if node is not None: + if eligibility is not None: + self._restricted_eligibility[id(node)] = eligibility candidates.append(node) excluded.append(node.node_id[:8]) continue @@ -349,6 +371,8 @@ async def _ticketed_candidates( if response.status_code in {404, 405, 501} or ( response.status_code == 503 and error_code == "not_configured" ): + if self._cfg.restricted_directory is not None: + raise IicpError("restricted_directory_fallback_refused", "restricted mode cannot fall back to legacy discovery", "directory") raise _LegacyDiscoveryRequired raise IicpError( code=f"IICP-DISPATCH-TICKET-{response.status_code}", @@ -398,6 +422,13 @@ async def discover_async( component="directory", tls_verify=self._cfg.tls_verify, traceparent=traceparent, + extra_headers=self._cfg.restricted_directory.headers() if self._cfg.restricted_directory else None, + follow_redirects=self._cfg.restricted_directory is None, + ) + eligibility = ( + validate_decision(data, self._cfg.restricted_directory, "discovery") + if self._cfg.restricted_directory is not None + else None ) elapsed = int((time.monotonic() - t0) * 1000) @@ -438,6 +469,8 @@ async def discover_async( ): continue nodes.append(node) + if eligibility is not None: + self._restricted_eligibility[id(node)] = eligibility diversity = data.get("diversity_evidence") return NodeList( nodes=nodes, diff --git a/src/iicp_client/restricted_directory.py b/src/iicp_client/restricted_directory.py new file mode 100644 index 0000000..23f18e9 --- /dev/null +++ b/src/iicp_client/restricted_directory.py @@ -0,0 +1,76 @@ +"""Fail-closed restricted trust-domain directory operation boundary.""" +from __future__ import annotations + +import os +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +from iicp_client.errors import IicpError + +PROFILE_ID = "urn:iicp:profile:restricted-trust-domain:v1" +DECISION_SCHEMA = "iicp.restricted-trust-domain.directory-decision.v0" + +def _refused(message: str) -> IicpError: + return IicpError("restricted_directory_decision_refused", message, "directory", retryable=False) + +@dataclass(frozen=True) +class SecretRef: + """Reference to membership material; the value is never serialized.""" + kind: Literal["environment", "file"] + value: str + + def resolve(self) -> str: + secret = "" + if self.kind == "environment": + secret = os.environ.get(self.value, "") + elif self.kind == "file": + path = Path(self.value) + if not path.is_symlink() and path.is_file() and not path.stat().st_mode & 0o077: + secret = path.read_text(encoding="utf-8").strip() + if not secret: + raise _refused("restricted directory membership credential is unavailable") + return secret + +@dataclass(frozen=True) +class RestrictedDirectoryContext: + domain_id: str + authority_id: str + subject_id: str + subject_kind: Literal["node", "client", "directory"] + minimum_membership_generation: int + membership_credential: SecretRef + + def validate(self) -> None: + if (not self.domain_id.strip() or not self.authority_id.strip() or not self.subject_id.strip() + or self.subject_kind not in {"node", "client", "directory"} + or self.minimum_membership_generation < 1): + raise _refused("restricted directory context is incomplete") + self.membership_credential.resolve() + + def headers(self) -> dict[str, str]: + return {"X-IICP-Membership": self.membership_credential.resolve(), "X-IICP-Subject-Id": self.subject_id} + +@dataclass(frozen=True) +class RestrictedEligibility: + domain_id: str + authority_id: str + membership_generation: int + membership_expires_at: int + +def validate_decision(body: dict[str, Any], context: RestrictedDirectoryContext, operation: str) -> RestrictedEligibility: + raw = body.get("restricted_domain_decision") + expected = {"schema", "profile", "decision", "operation", "domain_id", "authority_id", "subject_kind", "membership_generation", "membership_expires_at"} + if not isinstance(raw, dict) or set(raw) != expected: + raise _refused("restricted directory decision is missing or malformed") + generation, expiry = raw["membership_generation"], raw["membership_expires_at"] + if (not isinstance(generation, int) or isinstance(generation, bool) + or not isinstance(expiry, int) or isinstance(expiry, bool)): + raise _refused("restricted directory decision is malformed") + if (raw["schema"] != DECISION_SCHEMA or raw["profile"] != PROFILE_ID or raw["decision"] != "eligible" + or raw["operation"] != operation or raw["domain_id"] != context.domain_id + or raw["authority_id"] != context.authority_id or raw["subject_kind"] != context.subject_kind + or generation < context.minimum_membership_generation or expiry <= int(time.time())): + raise _refused("restricted directory decision does not match the request context") + return RestrictedEligibility(raw["domain_id"], raw["authority_id"], generation, expiry) diff --git a/src/iicp_client/restricted_membership.py b/src/iicp_client/restricted_membership.py new file mode 100644 index 0000000..3964da4 --- /dev/null +++ b/src/iicp_client/restricted_membership.py @@ -0,0 +1,203 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Restricted trust-domain membership and gossip verification. + +This is a direct projection of the shared pre-normative IICP fixture. Directory +bearer credentials are deliberately outside this module and must not enter peer +gossip. +""" + +from __future__ import annotations + +import base64 +import hashlib +import uuid +from dataclasses import dataclass +from typing import Any, cast + +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +MEMBERSHIP_SCHEMA = "iicp.restricted-trust-domain.membership-assertion.v0" +RESTRICTED_PROFILE = "urn:iicp:profile:restricted-trust-domain:v1" +_MEMBERSHIP_DOMAIN = b"IICP-RTD-MEMBERSHIP-V0\n" +_GOSSIP_DOMAIN = b"IICP-RTD-GOSSIP-V0\n" + + +class MembershipRefusal(ValueError): + """Bounded refusal safe for cross-SDK comparison.""" + + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +@dataclass(frozen=True) +class MembershipPolicy: + domain_id: str + authority_id: str + authority_key_id: str + authority_public_key_ed25519: str + minimum_generation: int + maximum_clock_skew_seconds: int + + +def _refuse(code: str) -> None: + raise MembershipRefusal(code) + + +def _b64url(value: Any, length: int) -> bytes: + if not isinstance(value, str): + _refuse("membership_malformed") + try: + raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + except (ValueError, TypeError): + _refuse("membership_malformed") + if len(raw) != length or base64.urlsafe_b64encode(raw).decode().rstrip("=") != value: + _refuse("membership_malformed") + return raw + + +def _mapping(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + _refuse("membership_malformed") + return value + + +def _exact_keys(value: dict[str, Any], expected: set[str]) -> None: + if set(value) != expected: + _refuse("membership_malformed") + + +def _nonempty(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _integer(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def _validate_shape(envelope: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + _exact_keys(envelope, {"assertion", "signature"}) + assertion = _mapping(envelope.get("assertion")) + signature = _mapping(envelope.get("signature")) + _exact_keys( + assertion, + { + "schema", "profile", "assertion_id", "domain_id", "subject", "issuer", + "issued_at", "expires_at", "generation", "scopes", "audience", + }, + ) + subject = _mapping(assertion.get("subject")) + issuer = _mapping(assertion.get("issuer")) + _exact_keys(subject, {"kind", "id", "key_id", "public_key_ed25519"}) + _exact_keys(issuer, {"id", "key_id"}) + if set(signature) not in ({"algorithm", "value"}, {"algorithm", "key_id", "value"}): + _refuse("membership_malformed") + try: + uuid.UUID(str(assertion.get("assertion_id"))) + except (ValueError, TypeError, AttributeError): + _refuse("membership_malformed") + if not all(_nonempty(value) for value in ( + assertion.get("domain_id"), subject.get("id"), subject.get("key_id"), + issuer.get("id"), issuer.get("key_id"), + )): + _refuse("membership_malformed") + if not all(_integer(assertion.get(key)) for key in ("issued_at", "expires_at", "generation")): + _refuse("membership_malformed") + scopes = assertion.get("scopes") + audience = assertion.get("audience") + if ( + not isinstance(scopes, list) or not scopes or not all(_nonempty(item) for item in scopes) + or not isinstance(audience, list) or not audience or not all(_nonempty(item) for item in audience) + or assertion["expires_at"] <= assertion["issued_at"] + ): + _refuse("membership_malformed") + return assertion, signature + + +def verify_membership( + envelope: dict[str, Any], + policy: MembershipPolicy, + expected_subject: str, + required_scope: str, + now: int, +) -> None: + assertion, signature = _validate_shape(_mapping(envelope)) + subject = assertion["subject"] + issuer = assertion["issuer"] + if ( + assertion["schema"] != MEMBERSHIP_SCHEMA + or assertion["profile"] != RESTRICTED_PROFILE + or signature.get("algorithm") != "Ed25519" + ): + _refuse("membership_unsupported") + if issuer["id"] != policy.authority_id or issuer["key_id"] != policy.authority_key_id: + _refuse("membership_authority_invalid") + if assertion["domain_id"] != policy.domain_id or policy.domain_id not in assertion["audience"]: + _refuse("membership_domain_mismatch") + if subject["kind"] != "node" or subject["id"] != expected_subject: + _refuse("membership_subject_mismatch") + if assertion["issued_at"] > now + policy.maximum_clock_skew_seconds: + _refuse("membership_not_yet_valid") + if assertion["expires_at"] <= now: + _refuse("membership_expired") + if assertion["generation"] < policy.minimum_generation: + _refuse("membership_generation_revoked") + if required_scope not in assertion["scopes"]: + _refuse("membership_scope_missing") + try: + key = Ed25519PublicKey.from_public_bytes(_b64url(policy.authority_public_key_ed25519, 32)) + key.verify( + _b64url(signature.get("value"), 64), + _MEMBERSHIP_DOMAIN + rfc8785.dumps(assertion), + ) + except InvalidSignature: + _refuse("membership_signature_invalid") + + +def verify_gossip( + gossip: dict[str, Any], + membership: dict[str, Any], + policy: MembershipPolicy, + payload: bytes, + now: int, + *, + replay_seen: bool = False, +) -> None: + gossip = _mapping(gossip) + _exact_keys(gossip, {"proof", "signature"}) + proof = _mapping(gossip.get("proof")) + signature = _mapping(gossip.get("signature")) + _exact_keys( + proof, + {"sender_id", "domain_id", "sent_at", "replay_id", "payload_sha256", "membership_assertion_id"}, + ) + _exact_keys(signature, {"algorithm", "key_id", "value"}) + verify_membership(membership, policy, str(proof.get("sender_id", "")), "peers", now) + try: + uuid.UUID(str(proof.get("replay_id"))) + except (ValueError, TypeError, AttributeError): + _refuse("membership_malformed") + assertion = membership["assertion"] + if signature.get("algorithm") != "Ed25519" or signature.get("key_id") != assertion["subject"]["key_id"]: + _refuse("membership_unsupported") + if proof.get("domain_id") != policy.domain_id: + _refuse("membership_domain_mismatch") + if proof.get("membership_assertion_id") != assertion["assertion_id"]: + _refuse("membership_subject_mismatch") + if replay_seen: + _refuse("gossip_replay") + sent_at_value = proof.get("sent_at") + if not _integer(sent_at_value): + _refuse("gossip_stale") + sent_at = cast(int, sent_at_value) + if sent_at > now + policy.maximum_clock_skew_seconds or now - sent_at > policy.maximum_clock_skew_seconds: + _refuse("gossip_stale") + if proof.get("payload_sha256") != hashlib.sha256(payload).hexdigest(): + _refuse("gossip_payload_mismatch") + try: + key = Ed25519PublicKey.from_public_bytes(_b64url(assertion["subject"]["public_key_ed25519"], 32)) + key.verify(_b64url(signature.get("value"), 64), _GOSSIP_DOMAIN + rfc8785.dumps(proof)) + except InvalidSignature: + _refuse("membership_signature_invalid") diff --git a/src/iicp_client/types.py b/src/iicp_client/types.py index 1e3461a..3a549fa 100644 --- a/src/iicp_client/types.py +++ b/src/iicp_client/types.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from iicp_client.errors import IicpError + from iicp_client.restricted_directory import RestrictedDirectoryContext from iicp_client.runtime_identity import RuntimeIdentityOptions @@ -57,6 +58,7 @@ class ClientConfig: # directory explicitly lacks the endpoint. ticketed and legacy force a mode. route_discovery_mode: str = "auto" profile_request: ProfileRequest | None = None + restricted_directory: RestrictedDirectoryContext | None = None @dataclass diff --git a/tests/fixtures/restricted-trust-domain-membership-v0.json b/tests/fixtures/restricted-trust-domain-membership-v0.json new file mode 100644 index 0000000..4477b3a --- /dev/null +++ b/tests/fixtures/restricted-trust-domain-membership-v0.json @@ -0,0 +1,193 @@ +{ + "fixture_version": "0.1.0-draft", + "status": "pre-normative", + "canonicalization": "RFC8785-JCS", + "authority_public_key_ed25519": "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ", + "vectors": [ + { + "id": "membership-valid", + "envelope": { + "assertion": { + "schema": "iicp.restricted-trust-domain.membership-assertion.v0", + "profile": "urn:iicp:profile:restricted-trust-domain:v1", + "assertion_id": "00000000-0000-4000-8000-000000000001", + "domain_id": "domain-test-a", + "subject": { + "kind": "node", + "id": "did:iicp:test:node-a", + "key_id": "did:iicp:test:node-a#key-1", + "public_key_ed25519": "5_FioQvsVZr-oZXk3OhLaVaNXSywlj60RsBoXisX8vA" + }, + "issuer": { + "id": "did:iicp:test:directory-a", + "key_id": "did:iicp:test:directory-a#key-1" + }, + "issued_at": 1800000000, + "expires_at": 1800000300, + "generation": 7, + "scopes": [ + "bootstrap", + "peers", + "relay" + ], + "audience": [ + "domain-test-a" + ] + }, + "signature": { + "algorithm": "Ed25519", + "value": "qIJRiBzjxHznHbWvTyWuq2_w4c94AyMIvKg4KsDMHBvaJCekmpOurGQr07bwQ_WxdFWMfCFUiRhmEL2t25W1CA" + } + }, + "expected": "valid" + }, + { + "id": "membership-wrong-domain-tamper", + "envelope": { + "assertion": { + "schema": "iicp.restricted-trust-domain.membership-assertion.v0", + "profile": "urn:iicp:profile:restricted-trust-domain:v1", + "assertion_id": "00000000-0000-4000-8000-000000000001", + "domain_id": "domain-test-b", + "subject": { + "kind": "node", + "id": "did:iicp:test:node-a", + "key_id": "did:iicp:test:node-a#key-1", + "public_key_ed25519": "5_FioQvsVZr-oZXk3OhLaVaNXSywlj60RsBoXisX8vA" + }, + "issuer": { + "id": "did:iicp:test:directory-a", + "key_id": "did:iicp:test:directory-a#key-1" + }, + "issued_at": 1800000000, + "expires_at": 1800000300, + "generation": 7, + "scopes": [ + "bootstrap", + "peers", + "relay" + ], + "audience": [ + "domain-test-a" + ] + }, + "signature": { + "algorithm": "Ed25519", + "value": "qIJRiBzjxHznHbWvTyWuq2_w4c94AyMIvKg4KsDMHBvaJCekmpOurGQr07bwQ_WxdFWMfCFUiRhmEL2t25W1CA" + } + }, + "expected": "invalid_signature" + } + ], + "gossip_vectors": [ + { + "id": "gossip-valid", + "membership": { + "assertion": { + "schema": "iicp.restricted-trust-domain.membership-assertion.v0", + "profile": "urn:iicp:profile:restricted-trust-domain:v1", + "assertion_id": "00000000-0000-4000-8000-000000000001", + "domain_id": "domain-test-a", + "subject": { + "kind": "node", + "id": "did:iicp:test:node-a", + "key_id": "did:iicp:test:node-a#key-1", + "public_key_ed25519": "5_FioQvsVZr-oZXk3OhLaVaNXSywlj60RsBoXisX8vA" + }, + "issuer": { + "id": "did:iicp:test:directory-a", + "key_id": "did:iicp:test:directory-a#key-1" + }, + "issued_at": 1800000000, + "expires_at": 1800000300, + "generation": 7, + "scopes": [ + "bootstrap", + "peers", + "relay" + ], + "audience": [ + "domain-test-a" + ] + }, + "signature": { + "algorithm": "Ed25519", + "value": "qIJRiBzjxHznHbWvTyWuq2_w4c94AyMIvKg4KsDMHBvaJCekmpOurGQr07bwQ_WxdFWMfCFUiRhmEL2t25W1CA" + } + }, + "gossip": { + "proof": { + "sender_id": "did:iicp:test:node-a", + "domain_id": "domain-test-a", + "sent_at": 1800000010, + "replay_id": "00000000-0000-4000-8000-000000000002", + "payload_sha256": "f4d849192ee9df78674456159e3fa9c72d27eac78c4d340c827fa90153933dcd", + "membership_assertion_id": "00000000-0000-4000-8000-000000000001" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "did:iicp:test:node-a#key-1", + "value": "HicFAjc-v46v1XSCvMkJbsTewvagN0Kuxkzyv3v_yw02y_OmIb0vzGt8nfw5Ad6cGmli81SK0vKjeZW3YLczCQ" + } + }, + "payload_utf8": "{\"peers\":[]}", + "expected": "valid" + }, + { + "id": "gossip-replay", + "membership": { + "assertion": { + "schema": "iicp.restricted-trust-domain.membership-assertion.v0", + "profile": "urn:iicp:profile:restricted-trust-domain:v1", + "assertion_id": "00000000-0000-4000-8000-000000000001", + "domain_id": "domain-test-a", + "subject": { + "kind": "node", + "id": "did:iicp:test:node-a", + "key_id": "did:iicp:test:node-a#key-1", + "public_key_ed25519": "5_FioQvsVZr-oZXk3OhLaVaNXSywlj60RsBoXisX8vA" + }, + "issuer": { + "id": "did:iicp:test:directory-a", + "key_id": "did:iicp:test:directory-a#key-1" + }, + "issued_at": 1800000000, + "expires_at": 1800000300, + "generation": 7, + "scopes": [ + "bootstrap", + "peers", + "relay" + ], + "audience": [ + "domain-test-a" + ] + }, + "signature": { + "algorithm": "Ed25519", + "value": "qIJRiBzjxHznHbWvTyWuq2_w4c94AyMIvKg4KsDMHBvaJCekmpOurGQr07bwQ_WxdFWMfCFUiRhmEL2t25W1CA" + } + }, + "gossip": { + "proof": { + "sender_id": "did:iicp:test:node-a", + "domain_id": "domain-test-a", + "sent_at": 1800000010, + "replay_id": "00000000-0000-4000-8000-000000000002", + "payload_sha256": "f4d849192ee9df78674456159e3fa9c72d27eac78c4d340c827fa90153933dcd", + "membership_assertion_id": "00000000-0000-4000-8000-000000000001" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "did:iicp:test:node-a#key-1", + "value": "HicFAjc-v46v1XSCvMkJbsTewvagN0Kuxkzyv3v_yw02y_OmIb0vzGt8nfw5Ad6cGmli81SK0vKjeZW3YLczCQ" + } + }, + "payload_utf8": "{\"peers\":[]}", + "seen_replay_ids": [ + "00000000-0000-4000-8000-000000000002" + ], + "expected": "replay_detected" + } + ] +} diff --git a/tests/test_restricted_directory.py b/tests/test_restricted_directory.py new file mode 100644 index 0000000..ed3a40c --- /dev/null +++ b/tests/test_restricted_directory.py @@ -0,0 +1,57 @@ +import os +import time + +import httpx +import pytest + +from iicp_client import ClientConfig, IicpClient, RestrictedDirectoryContext, SecretRef +from iicp_client.errors import IicpError +from iicp_client.restricted_directory import PROFILE_ID, validate_decision + + +def context() -> RestrictedDirectoryContext: + os.environ["IICP_TEST_RESTRICTED_MEMBER"] = "member-token" + return RestrictedDirectoryContext("domain-a", "did:iicp:test:directory-a", "client-a", "client", 7, SecretRef("environment", "IICP_TEST_RESTRICTED_MEMBER")) + + +def decision(operation: str = "discovery", **changes: object) -> dict[str, object]: + value = {"schema": "iicp.restricted-trust-domain.directory-decision.v0", "profile": PROFILE_ID, + "decision": "eligible", "operation": operation, "domain_id": "domain-a", + "authority_id": "did:iicp:test:directory-a", "subject_kind": "client", + "membership_generation": 7, "membership_expires_at": int(time.time()) + 300} + value.update(changes) + return {"restricted_domain_decision": value} + + +def test_context_and_decision_fail_closed(tmp_path): + assert validate_decision(decision(), context(), "discovery").membership_generation == 7 + for body in ({}, decision(operation="bootstrap"), decision(domain_id="domain-b"), decision(membership_generation=6), decision(membership_expires_at=1)): + with pytest.raises(IicpError): + validate_decision(body, context(), "discovery") + secret = tmp_path / "member" + secret.write_text("secret") + secret.chmod(0o644) + with pytest.raises(IicpError): + SecretRef("file", str(secret)).resolve() + + +@pytest.mark.asyncio +async def test_restricted_discovery_sends_membership_and_requires_decision(monkeypatch): + seen: dict[str, str] = {} + + async def handler(request: httpx.Request) -> httpx.Response: + seen.update({k.lower(): v for k, v in request.headers.items()}) + body = {**decision(), "nodes": []} + return httpx.Response(200, json=body) + + original = httpx.AsyncClient + monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **kw: original(transport=httpx.MockTransport(handler), **{k:v for k,v in kw.items() if k != "transport"})) + client = IicpClient(ClientConfig(directory_url="https://directory.test", route_discovery_mode="ticketed", restricted_directory=context())) + await client.discover_async("urn:iicp:intent:llm:chat:v1") + assert seen["x-iicp-membership"] == "member-token" + assert seen["x-iicp-subject-id"] == "client-a" + + +def test_restricted_mode_refuses_legacy_fallback(): + with pytest.raises(ValueError, match="legacy"): + IicpClient(ClientConfig(route_discovery_mode="legacy", restricted_directory=context())) diff --git a/tests/test_restricted_membership.py b/tests/test_restricted_membership.py new file mode 100644 index 0000000..8443800 --- /dev/null +++ b/tests/test_restricted_membership.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from iicp_client.restricted_membership import ( + MembershipPolicy, + MembershipRefusal, + verify_gossip, + verify_membership, +) + +FIXTURE = json.loads((Path(__file__).parent / "fixtures/restricted-trust-domain-membership-v0.json").read_text()) +POLICY = MembershipPolicy( + domain_id="domain-test-a", + authority_id="did:iicp:test:directory-a", + authority_key_id="did:iicp:test:directory-a#key-1", + authority_public_key_ed25519=FIXTURE["authority_public_key_ed25519"], + minimum_generation=7, + maximum_clock_skew_seconds=60, +) + + +def refusal(call) -> str: + with pytest.raises(MembershipRefusal) as caught: + call() + return caught.value.code + + +def test_membership_vectors_match_shared_fixture() -> None: + fixture_path = Path(__file__).parent / "fixtures/restricted-trust-domain-membership-v0.json" + assert hashlib.sha256(fixture_path.read_bytes()).hexdigest() == "78cb70b19dabed5be0175555cf2b4bb123dd4bc77ce36b67b745f311f3d941d4" + for vector in FIXTURE["vectors"]: + def call(vector=vector) -> None: + verify_membership(vector["envelope"], POLICY, "did:iicp:test:node-a", "peers", 1_800_000_100) + + if vector["expected"] == "valid": + call() + else: + assert refusal(call) == "membership_domain_mismatch" + + +def test_gossip_vectors_match_shared_fixture() -> None: + for vector in FIXTURE["gossip_vectors"]: + def call(vector=vector) -> None: + verify_gossip( + vector["gossip"], + vector["membership"], + POLICY, + vector["payload_utf8"].encode(), + 1_800_000_010, + replay_seen=bool(vector.get("seen_replay_ids")), + ) + if vector["expected"] == "valid": + call() + else: + assert refusal(call) == {"replay_detected": "gossip_replay"}[vector["expected"]] + + +def test_lifecycle_and_scope_refusals_are_bounded() -> None: + valid = FIXTURE["vectors"][0]["envelope"] + assert refusal(lambda: verify_membership(valid, POLICY, "other", "peers", 1_800_000_100)) == "membership_subject_mismatch" + assert refusal(lambda: verify_membership(valid, POLICY, "did:iicp:test:node-a", "missing", 1_800_000_100)) == "membership_scope_missing" + assert refusal(lambda: verify_membership(valid, POLICY, "did:iicp:test:node-a", "peers", 1_800_000_300)) == "membership_expired"