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 +168 -0
- federlet/_time.py +15 -0
- federlet/admission.py +157 -0
- federlet/audit.py +39 -0
- federlet/client.py +184 -0
- federlet/crypto.py +57 -0
- federlet/discovery.py +210 -0
- federlet/health.py +63 -0
- federlet/membership.py +157 -0
- federlet/models.py +209 -0
- federlet/net.py +33 -0
- federlet/protocols/__init__.py +12 -0
- federlet/protocols/membership_store.py +31 -0
- federlet/protocols/nonce.py +24 -0
- federlet/protocols/rate_limit.py +48 -0
- federlet/py.typed +1 -0
- federlet/refresh.py +98 -0
- federlet/signing.py +198 -0
- federlet-0.1.0.dist-info/METADATA +557 -0
- federlet-0.1.0.dist-info/RECORD +22 -0
- federlet-0.1.0.dist-info/WHEEL +4 -0
- federlet-0.1.0.dist-info/licenses/LICENSE +183 -0
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
|