federlet 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
federlet/__init__.py ADDED
@@ -0,0 +1,168 @@
1
+ """Federlet.
2
+
3
+ A hubless HTTPS federation protocol for directory nodes (ADR-005).
4
+ """
5
+
6
+ from .admission import (
7
+ AdmissionDecision,
8
+ AdmissionPolicy,
9
+ EvidenceVerifier,
10
+ KeyContinuityDecision,
11
+ KeyContinuityPolicy,
12
+ admit_manifest,
13
+ check_key_continuity,
14
+ domain_evidence_verifier,
15
+ )
16
+ from .audit import audit_record
17
+ from .client import (
18
+ SIGNATURE_HEADER,
19
+ FederationClient,
20
+ ManifestVerificationError,
21
+ MissingCapabilitySummaryEndpointError,
22
+ MissingRevocationsEndpointError,
23
+ ResponseSignatureError,
24
+ )
25
+ from .crypto import (
26
+ JWK,
27
+ b64u_decode,
28
+ b64u_encode,
29
+ canonical_bytes,
30
+ generate_key,
31
+ public_jwk,
32
+ public_key_from_jwk,
33
+ )
34
+ from .discovery import (
35
+ DiscoveryOutcome,
36
+ DiscoveryRefreshReport,
37
+ refresh_discovered_members,
38
+ )
39
+ from .health import PeerHealthProbeResult, probe_peer_health
40
+ from .membership import (
41
+ DisclosurePolicy,
42
+ MemberRecord,
43
+ MembershipTable,
44
+ PeerState,
45
+ apply_revocation_notice,
46
+ disclose_members,
47
+ )
48
+ from .models import (
49
+ AdmissionEvidence,
50
+ CapabilitySummary,
51
+ Disclosure,
52
+ DomainProofEvidence,
53
+ GenericAdmissionEvidence,
54
+ HealthResponse,
55
+ IntroduceRequest,
56
+ IntroduceResponse,
57
+ Manifest,
58
+ ManifestLimits,
59
+ MemberRef,
60
+ Membership,
61
+ MembersResponse,
62
+ ProtocolResponse,
63
+ PublicKey,
64
+ RevocationNotice,
65
+ RevocationsResponse,
66
+ Signature,
67
+ SignedRequest,
68
+ )
69
+ from .net import SSRFError
70
+ from .protocols import (
71
+ MembershipStore,
72
+ NonceCache,
73
+ RateLimiter,
74
+ TokenBucketRateLimiter,
75
+ )
76
+ from .refresh import ManifestRefreshDecision, refresh_peer_manifest
77
+ from .signing import (
78
+ build_signed_request,
79
+ check_body_size,
80
+ check_manifest,
81
+ find_jwk,
82
+ sha256_hex,
83
+ sign_dict,
84
+ sign_manifest,
85
+ sign_model,
86
+ verify_dict,
87
+ verify_manifest,
88
+ verify_model,
89
+ verify_response_signature,
90
+ verify_revocation_notice,
91
+ verify_signed_request,
92
+ )
93
+
94
+ __all__ = [
95
+ "FederationClient",
96
+ "MissingCapabilitySummaryEndpointError",
97
+ "ManifestVerificationError",
98
+ "MissingRevocationsEndpointError",
99
+ "ResponseSignatureError",
100
+ "SIGNATURE_HEADER",
101
+ "SSRFError",
102
+ "AdmissionDecision",
103
+ "AdmissionPolicy",
104
+ "admit_manifest",
105
+ "check_key_continuity",
106
+ "domain_evidence_verifier",
107
+ "KeyContinuityDecision",
108
+ "KeyContinuityPolicy",
109
+ "audit_record",
110
+ "JWK",
111
+ "b64u_decode",
112
+ "b64u_encode",
113
+ "canonical_bytes",
114
+ "generate_key",
115
+ "public_jwk",
116
+ "public_key_from_jwk",
117
+ "DiscoveryOutcome",
118
+ "DiscoveryRefreshReport",
119
+ "refresh_discovered_members",
120
+ "PeerHealthProbeResult",
121
+ "probe_peer_health",
122
+ "DisclosurePolicy",
123
+ "MemberRecord",
124
+ "MembershipTable",
125
+ "PeerState",
126
+ "apply_revocation_notice",
127
+ "disclose_members",
128
+ "AdmissionEvidence",
129
+ "CapabilitySummary",
130
+ "Disclosure",
131
+ "DomainProofEvidence",
132
+ "GenericAdmissionEvidence",
133
+ "HealthResponse",
134
+ "IntroduceRequest",
135
+ "IntroduceResponse",
136
+ "Manifest",
137
+ "ManifestLimits",
138
+ "MemberRef",
139
+ "Membership",
140
+ "MembersResponse",
141
+ "ProtocolResponse",
142
+ "PublicKey",
143
+ "RevocationNotice",
144
+ "RevocationsResponse",
145
+ "Signature",
146
+ "SignedRequest",
147
+ "EvidenceVerifier",
148
+ "MembershipStore",
149
+ "NonceCache",
150
+ "RateLimiter",
151
+ "TokenBucketRateLimiter",
152
+ "ManifestRefreshDecision",
153
+ "refresh_peer_manifest",
154
+ "build_signed_request",
155
+ "check_body_size",
156
+ "check_manifest",
157
+ "find_jwk",
158
+ "sha256_hex",
159
+ "sign_dict",
160
+ "sign_manifest",
161
+ "sign_model",
162
+ "verify_dict",
163
+ "verify_manifest",
164
+ "verify_model",
165
+ "verify_response_signature",
166
+ "verify_revocation_notice",
167
+ "verify_signed_request",
168
+ ]
federlet/_time.py ADDED
@@ -0,0 +1,15 @@
1
+ """Shared UTC timestamp helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+
7
+
8
+ def utc_now() -> datetime:
9
+ return datetime.now(UTC)
10
+
11
+
12
+ def iso_z(dt: datetime | None) -> str | None:
13
+ if dt is None:
14
+ return None
15
+ return dt.astimezone(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
federlet/admission.py ADDED
@@ -0,0 +1,157 @@
1
+ """Small local-admission helper for signed node manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ipaddress
6
+ from dataclasses import dataclass, field
7
+ from typing import Literal, Protocol
8
+ from urllib.parse import urlparse
9
+
10
+ from .models import DomainProofEvidence, Manifest
11
+ from .net import is_disallowed_ip
12
+ from .signing import check_manifest, verify_model
13
+
14
+
15
+ class EvidenceVerifier(Protocol):
16
+ async def __call__(self, manifest: Manifest) -> tuple[bool, str]:
17
+ """Verify host-owned admission evidence for a manifest (may do I/O)."""
18
+ ...
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AdmissionPolicy:
23
+ federation_id: str
24
+ protocol_versions: set[str]
25
+ require_expires_at: bool = True
26
+ require_signed_http: bool = True
27
+ require_https: bool = True
28
+ allow_private_hosts: bool = False
29
+ allowed_endpoint_domains: set[str] = field(default_factory=set)
30
+ evidence_verifier: EvidenceVerifier | None = None
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class AdmissionDecision:
35
+ accepted: bool
36
+ reason: str = "ok"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class KeyContinuityPolicy:
41
+ allow_key_rotation: bool = True
42
+ evidence_verifier: EvidenceVerifier | None = None
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class KeyContinuityDecision:
47
+ action: Literal["accept", "quarantine", "reject"]
48
+ reason: str = "ok"
49
+
50
+
51
+ async def admit_manifest(
52
+ manifest: Manifest,
53
+ policy: AdmissionPolicy,
54
+ *,
55
+ max_skew_seconds: int = 300,
56
+ ) -> AdmissionDecision:
57
+ ok, reason = check_manifest(manifest, max_skew_seconds=max_skew_seconds)
58
+ if not ok:
59
+ return AdmissionDecision(False, reason)
60
+ if policy.require_expires_at and not manifest.expires_at:
61
+ return AdmissionDecision(False, "missing_expires_at")
62
+ if policy.federation_id not in manifest.federations:
63
+ return AdmissionDecision(False, "wrong_federation")
64
+ if not set(manifest.protocol_versions) & policy.protocol_versions:
65
+ return AdmissionDecision(False, "unsupported_protocol")
66
+ if policy.require_signed_http and "signed_http" not in manifest.auth_methods:
67
+ return AdmissionDecision(False, "signed_http_required")
68
+
69
+ endpoint_reason = _check_endpoint(manifest.endpoint, policy)
70
+ if endpoint_reason:
71
+ return AdmissionDecision(False, endpoint_reason)
72
+
73
+ if policy.evidence_verifier is not None:
74
+ ok, reason = await policy.evidence_verifier(manifest)
75
+ if not ok:
76
+ return AdmissionDecision(False, reason)
77
+ return AdmissionDecision(True)
78
+
79
+
80
+ async def check_key_continuity(
81
+ old_manifest: Manifest,
82
+ new_manifest: Manifest,
83
+ policy: KeyContinuityPolicy | None = None,
84
+ ) -> KeyContinuityDecision:
85
+ """Decide whether a refreshed manifest preserves signing-key continuity."""
86
+ policy = policy or KeyContinuityPolicy()
87
+ if old_manifest.node_id != new_manifest.node_id:
88
+ return KeyContinuityDecision("reject", "node_id_changed")
89
+ if old_manifest.org_id != new_manifest.org_id:
90
+ return KeyContinuityDecision("reject", "org_id_changed")
91
+
92
+ old_keys = {key.key_id: key.public_jwk for key in old_manifest.public_keys}
93
+ new_keys = {key.key_id: key.public_jwk for key in new_manifest.public_keys}
94
+ if old_keys == new_keys:
95
+ return KeyContinuityDecision("accept")
96
+
97
+ if not policy.allow_key_rotation:
98
+ return KeyContinuityDecision("reject", "rotation_denied")
99
+
100
+ signature = new_manifest.signature
101
+ if signature is not None:
102
+ old_jwk = old_keys.get(signature.key_id)
103
+ if old_jwk is not None and verify_model(new_manifest, old_jwk):
104
+ return KeyContinuityDecision("accept", "signed_rotation")
105
+
106
+ if policy.evidence_verifier is not None:
107
+ ok, reason = await policy.evidence_verifier(new_manifest)
108
+ if ok:
109
+ return KeyContinuityDecision("accept", "admission_evidence")
110
+ if reason == "rotation_denied":
111
+ return KeyContinuityDecision("reject", reason)
112
+
113
+ return KeyContinuityDecision("quarantine", "stale_manifest")
114
+
115
+
116
+ async def domain_evidence_verifier(manifest: Manifest) -> tuple[bool, str]:
117
+ """Minimal built-in verifier for ADR domain_proof evidence.
118
+
119
+ This is deliberately not a DNS/CA proof. It only checks that the manifest's
120
+ declared endpoint host is inside the declared domain. Stronger evidence
121
+ types (SPIFFE, partner credentials, charter keys) belong behind host-supplied
122
+ callbacks with their own trust material.
123
+ """
124
+ ev = manifest.admission_evidence
125
+ if not isinstance(ev, DomainProofEvidence):
126
+ return False, "unsupported_evidence"
127
+ if not ev.domain:
128
+ return False, "bad_domain_evidence"
129
+ if _host_in_domain(urlparse(manifest.endpoint).hostname or "", ev.domain):
130
+ return True, "ok"
131
+ return False, "domain_mismatch"
132
+
133
+
134
+ def _host_in_domain(host: str, domain: str) -> bool:
135
+ host = host.lower().rstrip(".")
136
+ domain = domain.lower().rstrip(".")
137
+ return host == domain or host.endswith("." + domain)
138
+
139
+
140
+ def _check_endpoint(endpoint: str, policy: AdmissionPolicy) -> str | None:
141
+ parsed = urlparse(endpoint)
142
+ host = (parsed.hostname or "").lower().rstrip(".")
143
+ if not host:
144
+ return "bad_endpoint"
145
+ if policy.require_https and parsed.scheme != "https":
146
+ return "https_required"
147
+ if policy.allowed_endpoint_domains and not any(
148
+ _host_in_domain(host, d) for d in policy.allowed_endpoint_domains
149
+ ):
150
+ return "endpoint_domain_denied"
151
+ try:
152
+ ip = ipaddress.ip_address(host)
153
+ except ValueError:
154
+ return None
155
+ if not policy.allow_private_hosts and is_disallowed_ip(ip):
156
+ return "private_endpoint_denied"
157
+ return None
federlet/audit.py ADDED
@@ -0,0 +1,39 @@
1
+ """Pure audit-record builder for host logging sinks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from ._time import iso_z, utc_now
9
+
10
+
11
+ def audit_record(
12
+ *,
13
+ event: str,
14
+ request_id: str | None = None,
15
+ query_id: str | None = None,
16
+ source_node_id: str | None = None,
17
+ target_node_id: str | None = None,
18
+ manifest_revision: int | None = None,
19
+ decision: str | None = None,
20
+ reason: str | None = None,
21
+ extra: Mapping[str, Any] | None = None,
22
+ ) -> dict[str, Any]:
23
+ record: dict[str, Any] = {
24
+ "timestamp": iso_z(utc_now()),
25
+ "event": event,
26
+ }
27
+ optional = {
28
+ "request_id": request_id,
29
+ "query_id": query_id,
30
+ "source_node_id": source_node_id,
31
+ "target_node_id": target_node_id,
32
+ "manifest_revision": manifest_revision,
33
+ "decision": decision,
34
+ "reason": reason,
35
+ }
36
+ record.update({key: value for key, value in optional.items() if value is not None})
37
+ if extra:
38
+ record.update({key: value for key, value in extra.items() if value is not None})
39
+ return record
federlet/client.py ADDED
@@ -0,0 +1,184 @@
1
+ """httpx client helpers for federation calls, with SSRF-safe manifest fetch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from urllib.parse import urlparse
7
+
8
+ import httpx
9
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
10
+
11
+ from .models import (
12
+ CapabilitySummary,
13
+ HealthResponse,
14
+ IntroduceRequest,
15
+ IntroduceResponse,
16
+ Manifest,
17
+ MembersResponse,
18
+ ProtocolResponse,
19
+ RevocationsResponse,
20
+ )
21
+ from .net import _assert_public_host
22
+ from .signing import build_signed_request, check_manifest, verify_response_signature
23
+
24
+ SIGNATURE_HEADER = "X-Federlet-Signature"
25
+
26
+
27
+ class ResponseSignatureError(ValueError):
28
+ """Raised when a peer returns an unsigned or unverifiable response."""
29
+
30
+
31
+ class ManifestVerificationError(ValueError):
32
+ """Raised when a fetched manifest is unsigned, stale, or unverifiable."""
33
+
34
+
35
+ class MissingRevocationsEndpointError(ValueError):
36
+ """Raised when a peer manifest does not advertise a revocations endpoint."""
37
+
38
+
39
+ class MissingCapabilitySummaryEndpointError(ValueError):
40
+ """Raised when a peer manifest does not advertise a capability summary endpoint."""
41
+
42
+
43
+ def _verify_response(
44
+ peer: Manifest,
45
+ resp: IntroduceResponse | MembersResponse | RevocationsResponse | CapabilitySummary,
46
+ ) -> bool:
47
+ return verify_response_signature(peer, resp)
48
+
49
+
50
+ class FederationClient:
51
+ def __init__(
52
+ self,
53
+ *,
54
+ node_id: str,
55
+ federation_id: str,
56
+ key: Ed25519PrivateKey,
57
+ key_id: str,
58
+ manifest_revision: int = 0,
59
+ allow_private: bool = False,
60
+ client: httpx.AsyncClient | None = None,
61
+ ) -> None:
62
+ self.node_id = node_id
63
+ self.federation_id = federation_id
64
+ self.key = key
65
+ self.key_id = key_id
66
+ self.manifest_revision = manifest_revision
67
+ self.allow_private = allow_private
68
+ self._http = client or httpx.AsyncClient(timeout=10.0, follow_redirects=False)
69
+
70
+ async def __aenter__(self) -> FederationClient:
71
+ return self
72
+
73
+ async def __aexit__(self, *exc: object) -> None:
74
+ await self.close()
75
+
76
+ def _signed_headers(
77
+ self, target_node_id: str, method: str, path: str, body: bytes
78
+ ) -> dict[str, str]:
79
+ env = build_signed_request(
80
+ self.key,
81
+ self.key_id,
82
+ federation_id=self.federation_id,
83
+ source_node_id=self.node_id,
84
+ target_node_id=target_node_id,
85
+ method=method,
86
+ path=path,
87
+ body=body,
88
+ source_manifest_revision=self.manifest_revision,
89
+ )
90
+ return {SIGNATURE_HEADER: env.model_dump_json(exclude_none=True)}
91
+
92
+ async def _send(
93
+ self,
94
+ target_node_id: str,
95
+ method: str,
96
+ url: str,
97
+ *,
98
+ body: bytes = b"",
99
+ params: dict | None = None,
100
+ ) -> httpx.Response:
101
+ """Sign, send, and raise-for-status a single federation call."""
102
+ headers = self._signed_headers(target_node_id, method, urlparse(url).path, body)
103
+ r = await self._http.request(
104
+ method, url, content=body or None, params=params, headers=headers
105
+ )
106
+ r.raise_for_status()
107
+ return r
108
+
109
+ async def fetch_manifest(
110
+ self, manifest_url: str, *, max_skew_seconds: int = 300
111
+ ) -> Manifest:
112
+ # DNS resolution is blocking; keep the SSRF guard off the event loop.
113
+ await asyncio.get_running_loop().run_in_executor(
114
+ None, _assert_public_host, manifest_url, self.allow_private
115
+ )
116
+ r = await self._http.get(manifest_url)
117
+ r.raise_for_status()
118
+ manifest = Manifest.model_validate(r.json())
119
+ ok, reason = check_manifest(manifest, max_skew_seconds=max_skew_seconds)
120
+ if not ok:
121
+ raise ManifestVerificationError(reason)
122
+ return manifest
123
+
124
+ async def introduce(
125
+ self, peer: Manifest, intro: IntroduceRequest
126
+ ) -> IntroduceResponse:
127
+ body = intro.model_dump_json(exclude_none=True).encode()
128
+ r = await self._send(
129
+ peer.node_id, "POST", peer.membership.introduce_url, body=body
130
+ )
131
+ resp = IntroduceResponse.model_validate(r.json())
132
+ if not _verify_response(peer, resp):
133
+ raise ResponseSignatureError("bad_signature")
134
+ return resp
135
+
136
+ async def get_members(
137
+ self, peer: Manifest, since: str | None = None
138
+ ) -> MembersResponse:
139
+ params = {"since": since} if since else None
140
+ r = await self._send(
141
+ peer.node_id, "GET", peer.membership.members_url, params=params
142
+ )
143
+ resp = MembersResponse.model_validate(r.json())
144
+ if not _verify_response(peer, resp):
145
+ raise ResponseSignatureError("bad_signature")
146
+ return resp
147
+
148
+ async def get_revocations(
149
+ self, peer: Manifest, since: str | None = None
150
+ ) -> RevocationsResponse:
151
+ if peer.membership.revocations_url is None:
152
+ raise MissingRevocationsEndpointError("missing_revocations_url")
153
+ params = {"since": since} if since else None
154
+ r = await self._send(
155
+ peer.node_id, "GET", peer.membership.revocations_url, params=params
156
+ )
157
+ resp = RevocationsResponse.model_validate(r.json())
158
+ if not _verify_response(peer, resp):
159
+ raise ResponseSignatureError("bad_signature")
160
+ return resp
161
+
162
+ async def get_capability_summary(self, peer: Manifest) -> CapabilitySummary:
163
+ if peer.capability_summary_url is None:
164
+ raise MissingCapabilitySummaryEndpointError(
165
+ "missing_capability_summary_url"
166
+ )
167
+ r = await self._send(peer.node_id, "GET", peer.capability_summary_url)
168
+ resp = CapabilitySummary.model_validate(r.json())
169
+ if not _verify_response(peer, resp):
170
+ raise ResponseSignatureError("bad_signature")
171
+ return resp
172
+
173
+ async def get_protocol(self, peer: Manifest) -> ProtocolResponse:
174
+ r = await self._send(
175
+ peer.node_id, "GET", f"{peer.endpoint.rstrip('/')}/protocol"
176
+ )
177
+ return ProtocolResponse.model_validate(r.json())
178
+
179
+ async def get_health(self, peer: Manifest) -> HealthResponse:
180
+ r = await self._send(peer.node_id, "GET", f"{peer.endpoint.rstrip('/')}/health")
181
+ return HealthResponse.model_validate(r.json())
182
+
183
+ async def close(self) -> None:
184
+ await self._http.aclose()
federlet/crypto.py ADDED
@@ -0,0 +1,57 @@
1
+ """Ed25519 keys, JWK conversion, canonical bytes, and detached signing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from typing import Any
7
+
8
+ import rfc8785
9
+ from cryptography.exceptions import InvalidSignature
10
+ from cryptography.hazmat.primitives import serialization
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
12
+ Ed25519PrivateKey,
13
+ Ed25519PublicKey,
14
+ )
15
+
16
+ JWK = dict[str, str]
17
+
18
+
19
+ def b64u_encode(raw: bytes) -> str:
20
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
21
+
22
+
23
+ def b64u_decode(s: str) -> bytes:
24
+ return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
25
+
26
+
27
+ def generate_key() -> Ed25519PrivateKey:
28
+ return Ed25519PrivateKey.generate()
29
+
30
+
31
+ def public_jwk(key: Ed25519PrivateKey | Ed25519PublicKey) -> JWK:
32
+ pub = key.public_key() if isinstance(key, Ed25519PrivateKey) else key
33
+ raw = pub.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
34
+ return {"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "x": b64u_encode(raw)}
35
+
36
+
37
+ def public_key_from_jwk(jwk: JWK) -> Ed25519PublicKey:
38
+ if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519":
39
+ raise ValueError("unsupported JWK; expected OKP/Ed25519")
40
+ return Ed25519PublicKey.from_public_bytes(b64u_decode(jwk["x"]))
41
+
42
+
43
+ def canonical_bytes(obj: Any) -> bytes:
44
+ """Deterministic JSON per RFC 8785 (JCS) so peers in any language agree."""
45
+ return rfc8785.dumps(obj)
46
+
47
+
48
+ def sign_bytes(key: Ed25519PrivateKey, data: bytes) -> str:
49
+ return b64u_encode(key.sign(data))
50
+
51
+
52
+ def verify_bytes(jwk: JWK, sig: str, data: bytes) -> bool:
53
+ try:
54
+ public_key_from_jwk(jwk).verify(b64u_decode(sig), data)
55
+ return True
56
+ except (InvalidSignature, ValueError):
57
+ return False