mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
881 lines
33 KiB
Python
881 lines
33 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
from typing import Any
|
|
from urllib.parse import urlsplit
|
|
|
|
from .bip340 import bip340_verify, tagged_hash as _tagged_hash
|
|
from .canonical_json import MAX_SAFE_INTEGER, canonicalize_jcs, strict_json_loads
|
|
from .capabilities import INTERACTION_TIERS
|
|
|
|
|
|
SERVICE_MANIFEST_SCHEMA = "amp.service-manifest.v1"
|
|
SERVICE_MANIFEST_SCHEMA_PATH = Path("schemas/amp.service-manifest.v1.schema.json")
|
|
DELEGATION_SCHEMA = "amp.service-key-delegation.v1"
|
|
SIGNATURE_ALGORITHM = "BIP340-secp256k1"
|
|
CANONICALIZATION = "RFC8785-JCS"
|
|
|
|
TRANSPORTS = ("clearnet", "tor", "i2p", "gemini", "ipfs", "reticulum")
|
|
PUBLIC_INTERACTION_TIERS = tuple(tier for tier in INTERACTION_TIERS if tier != "internal")
|
|
AUTH_METHODS = ("none", "nip98", "smirk-action", "smirk-session", "siwe", "http-session")
|
|
PAYMENT_METHODS = (
|
|
"none",
|
|
"smirk-wallet",
|
|
"server-invoice",
|
|
"payment-capability",
|
|
"static-instructions",
|
|
)
|
|
|
|
MAX_ROUTES = 64
|
|
MAX_MANIFEST_BYTES = 256 * 1024
|
|
MAX_CLOCK_SKEW = timedelta(minutes=5)
|
|
HEX_32_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
HEX_64_RE = re.compile(r"^[0-9a-f]{128}$")
|
|
SERVICE_ID_RE = re.compile(r"^amp:[0-9a-f]{64}$")
|
|
ONION_V3_RE = re.compile(r"^[a-z2-7]{56}\.onion$")
|
|
RFC3339_RE = re.compile(
|
|
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$"
|
|
)
|
|
|
|
TOP_LEVEL_FIELDS = {"schema", "payload", "delegation", "signature"}
|
|
PAYLOAD_FIELDS = {
|
|
"service_id",
|
|
"name",
|
|
"root_public_key",
|
|
"api",
|
|
"routes",
|
|
"issued_at",
|
|
"expires_at",
|
|
"sequence",
|
|
"previous_manifest_digest",
|
|
}
|
|
API_FIELDS = {"contract", "version", "schema_sha256"}
|
|
ROUTE_FIELDS = {
|
|
"transport",
|
|
"endpoint",
|
|
"context",
|
|
"interaction_tier",
|
|
"priority",
|
|
"auth",
|
|
"payments",
|
|
"capabilities",
|
|
}
|
|
SIGNATURE_FIELDS = {"algorithm", "canonicalization", "public_key", "value"}
|
|
DELEGATION_FIELDS = {"payload", "signature"}
|
|
DELEGATION_PAYLOAD_FIELDS = {
|
|
"schema",
|
|
"service_id",
|
|
"root_public_key",
|
|
"signing_public_key",
|
|
"issued_at",
|
|
"expires_at",
|
|
"sequence",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ServiceManifestIssue:
|
|
path: str
|
|
code: str
|
|
message: str
|
|
|
|
|
|
def read_service_manifest(path: Path) -> dict[str, Any]:
|
|
try:
|
|
raw = path.read_bytes()
|
|
if len(raw) > MAX_MANIFEST_BYTES:
|
|
raise ValueError(f"manifest exceeds {MAX_MANIFEST_BYTES} bytes")
|
|
data = strict_json_loads(raw.decode("utf-8"))
|
|
except FileNotFoundError as exc:
|
|
raise ValueError(f"service manifest not found: {path}") from exc
|
|
except (UnicodeError, json.JSONDecodeError, ValueError) as exc:
|
|
raise ValueError(f"invalid service manifest JSON: {path}: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"invalid service manifest JSON: {path}: root must be an object")
|
|
return data
|
|
|
|
|
|
def service_id_for_root_key(root_public_key: str) -> str:
|
|
key = _decode_hex(root_public_key, 32, "root public key")
|
|
return f"amp:{_tagged_hash('AMPG/service-id/v1', key).hex()}"
|
|
|
|
|
|
def service_manifest_digest(payload: dict[str, Any]) -> bytes:
|
|
return _tagged_hash("AMPG/service-manifest/v1", canonicalize_jcs(payload))
|
|
|
|
|
|
def delegation_digest(payload: dict[str, Any]) -> bytes:
|
|
return _tagged_hash("AMPG/service-key-delegation/v1", canonicalize_jcs(payload))
|
|
|
|
|
|
def validate_service_manifest(
|
|
data: Any,
|
|
*,
|
|
now: datetime | None = None,
|
|
minimum_sequence: int | None = None,
|
|
minimum_delegation_sequence: int | None = None,
|
|
expected_previous: str | None = None,
|
|
verify_signature: bool = True,
|
|
) -> list[ServiceManifestIssue]:
|
|
issues: list[ServiceManifestIssue] = []
|
|
if not isinstance(data, dict):
|
|
return [ServiceManifestIssue("$", "type", "service manifest must be a JSON object")]
|
|
|
|
_unknown_fields("$", data, TOP_LEVEL_FIELDS, issues)
|
|
_required_fields("$", data, {"schema", "payload", "signature"}, issues)
|
|
if data.get("schema") != SERVICE_MANIFEST_SCHEMA:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.schema", "schema", f"must be {SERVICE_MANIFEST_SCHEMA!r}"
|
|
)
|
|
)
|
|
|
|
payload = data.get("payload")
|
|
payload_times = _validate_payload(payload, issues)
|
|
signature = data.get("signature")
|
|
signing_key = _validate_signature_object("$.signature", signature, issues)
|
|
root_key = payload.get("root_public_key") if isinstance(payload, dict) else None
|
|
service_id = payload.get("service_id") if isinstance(payload, dict) else None
|
|
|
|
delegation = data.get("delegation")
|
|
delegation_times: tuple[datetime | None, datetime | None] = (None, None)
|
|
if delegation is not None:
|
|
delegation_times = _validate_delegation(
|
|
delegation,
|
|
root_key=root_key,
|
|
service_id=service_id,
|
|
signing_key=signing_key,
|
|
issues=issues,
|
|
verify_signature=verify_signature,
|
|
)
|
|
elif signing_key and root_key and signing_key != root_key:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.delegation",
|
|
"required",
|
|
"a delegated signing key requires a root-signed delegation",
|
|
)
|
|
)
|
|
elif signing_key and root_key and signing_key == root_key:
|
|
pass
|
|
|
|
if delegation is not None and signing_key and root_key and signing_key == root_key:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.delegation",
|
|
"unexpected",
|
|
"delegation is only valid when the signing key differs from the root key",
|
|
)
|
|
)
|
|
|
|
issued_at, expires_at = payload_times
|
|
delegated_at, delegated_until = delegation_times
|
|
if issued_at and expires_at and issued_at >= expires_at:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.payload.expires_at", "range", "must be later than issued_at"
|
|
)
|
|
)
|
|
if issued_at and delegated_at and issued_at < delegated_at:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.payload.issued_at", "delegation-window", "precedes delegation validity"
|
|
)
|
|
)
|
|
if expires_at and delegated_until and expires_at > delegated_until:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.payload.expires_at", "delegation-window", "exceeds delegation validity"
|
|
)
|
|
)
|
|
|
|
if now is not None:
|
|
check_time = _as_utc(now)
|
|
if issued_at and issued_at > check_time + MAX_CLOCK_SKEW:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.payload.issued_at", "not-yet-valid", "is too far in the future"
|
|
)
|
|
)
|
|
if expires_at and expires_at <= check_time:
|
|
issues.append(ServiceManifestIssue("$.payload.expires_at", "expired", "has expired"))
|
|
if delegated_at and delegated_at > check_time + MAX_CLOCK_SKEW:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.delegation.payload.issued_at",
|
|
"not-yet-valid",
|
|
"delegation is too far in the future",
|
|
)
|
|
)
|
|
if delegated_until and delegated_until <= check_time:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.delegation.payload.expires_at", "expired", "delegation has expired"
|
|
)
|
|
)
|
|
|
|
sequence = payload.get("sequence") if isinstance(payload, dict) else None
|
|
previous = payload.get("previous_manifest_digest") if isinstance(payload, dict) else None
|
|
if minimum_sequence is not None and isinstance(sequence, int) and sequence < minimum_sequence:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.payload.sequence",
|
|
"rollback",
|
|
f"must be at least the pinned sequence {minimum_sequence}",
|
|
)
|
|
)
|
|
if expected_previous is not None and previous != expected_previous:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.payload.previous_manifest_digest",
|
|
"chain",
|
|
"does not match the pinned previous manifest digest",
|
|
)
|
|
)
|
|
|
|
delegation_sequence = None
|
|
if isinstance(delegation, dict) and isinstance(delegation.get("payload"), dict):
|
|
delegation_sequence = delegation["payload"].get("sequence")
|
|
if (
|
|
minimum_delegation_sequence is not None
|
|
and isinstance(delegation_sequence, int)
|
|
and delegation_sequence < minimum_delegation_sequence
|
|
):
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
"$.delegation.payload.sequence",
|
|
"rollback",
|
|
f"must be at least the pinned delegation sequence {minimum_delegation_sequence}",
|
|
)
|
|
)
|
|
|
|
if verify_signature and isinstance(payload, dict) and isinstance(signature, dict):
|
|
try:
|
|
digest = service_manifest_digest(payload)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
else:
|
|
_verify_signed_payload("$.signature", signature, digest, issues)
|
|
return issues
|
|
|
|
|
|
def service_manifest_json_schema() -> dict[str, Any]:
|
|
return {
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"$id": "urn:amp:schema:service-manifest:v1",
|
|
"title": "AMP Signed Service Manifest",
|
|
"description": "Signed public identity and multi-transport route contract.",
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["schema", "payload", "signature"],
|
|
"properties": {
|
|
"schema": {"const": SERVICE_MANIFEST_SCHEMA},
|
|
"payload": {"$ref": "#/$defs/payload"},
|
|
"delegation": {"$ref": "#/$defs/delegation"},
|
|
"signature": {"$ref": "#/$defs/signature"},
|
|
},
|
|
"$defs": {
|
|
"hex32": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
|
|
"hex64": {"type": "string", "pattern": "^[0-9a-f]{128}$"},
|
|
"timestamp": {"type": "string", "format": "date-time", "maxLength": 40},
|
|
"signature": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["algorithm", "canonicalization", "public_key", "value"],
|
|
"properties": {
|
|
"algorithm": {"const": SIGNATURE_ALGORITHM},
|
|
"canonicalization": {"const": CANONICALIZATION},
|
|
"public_key": {"$ref": "#/$defs/hex32"},
|
|
"value": {"$ref": "#/$defs/hex64"},
|
|
},
|
|
},
|
|
"api": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["contract", "version", "schema_sha256"],
|
|
"properties": {
|
|
"contract": {"type": "string", "minLength": 1, "maxLength": 120},
|
|
"version": {"type": "string", "minLength": 1, "maxLength": 40},
|
|
"schema_sha256": {"$ref": "#/$defs/hex32"},
|
|
},
|
|
},
|
|
"route": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": [
|
|
"transport",
|
|
"endpoint",
|
|
"context",
|
|
"interaction_tier",
|
|
"priority",
|
|
"auth",
|
|
"payments",
|
|
],
|
|
"properties": {
|
|
"transport": {"enum": list(TRANSPORTS)},
|
|
"endpoint": {"type": "string", "minLength": 1, "maxLength": 2048},
|
|
"context": {"enum": list(TRANSPORTS)},
|
|
"interaction_tier": {"enum": list(PUBLIC_INTERACTION_TIERS)},
|
|
"priority": {"type": "integer", "minimum": 0, "maximum": 1000},
|
|
"auth": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"uniqueItems": True,
|
|
"items": {"enum": list(AUTH_METHODS)},
|
|
},
|
|
"payments": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"uniqueItems": True,
|
|
"items": {"enum": list(PAYMENT_METHODS)},
|
|
},
|
|
"capabilities": {
|
|
"type": "array",
|
|
"maxItems": 64,
|
|
"uniqueItems": True,
|
|
"items": {"type": "string", "minLength": 1, "maxLength": 120},
|
|
},
|
|
},
|
|
},
|
|
"payload": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": [
|
|
"service_id",
|
|
"name",
|
|
"root_public_key",
|
|
"routes",
|
|
"issued_at",
|
|
"expires_at",
|
|
"sequence",
|
|
],
|
|
"properties": {
|
|
"service_id": {"type": "string", "pattern": "^amp:[0-9a-f]{64}$"},
|
|
"name": {"type": "string", "minLength": 1, "maxLength": 120},
|
|
"root_public_key": {"$ref": "#/$defs/hex32"},
|
|
"api": {"$ref": "#/$defs/api"},
|
|
"routes": {
|
|
"type": "array",
|
|
"minItems": 1,
|
|
"maxItems": MAX_ROUTES,
|
|
"items": {"$ref": "#/$defs/route"},
|
|
},
|
|
"issued_at": {"$ref": "#/$defs/timestamp"},
|
|
"expires_at": {"$ref": "#/$defs/timestamp"},
|
|
"sequence": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"maximum": MAX_SAFE_INTEGER,
|
|
},
|
|
"previous_manifest_digest": {"$ref": "#/$defs/hex32"},
|
|
},
|
|
},
|
|
"delegationPayload": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": [
|
|
"schema",
|
|
"service_id",
|
|
"root_public_key",
|
|
"signing_public_key",
|
|
"issued_at",
|
|
"expires_at",
|
|
"sequence",
|
|
],
|
|
"properties": {
|
|
"schema": {"const": DELEGATION_SCHEMA},
|
|
"service_id": {"type": "string", "pattern": "^amp:[0-9a-f]{64}$"},
|
|
"root_public_key": {"$ref": "#/$defs/hex32"},
|
|
"signing_public_key": {"$ref": "#/$defs/hex32"},
|
|
"issued_at": {"$ref": "#/$defs/timestamp"},
|
|
"expires_at": {"$ref": "#/$defs/timestamp"},
|
|
"sequence": {
|
|
"type": "integer",
|
|
"minimum": 1,
|
|
"maximum": MAX_SAFE_INTEGER,
|
|
},
|
|
},
|
|
},
|
|
"delegation": {
|
|
"type": "object",
|
|
"additionalProperties": False,
|
|
"required": ["payload", "signature"],
|
|
"properties": {
|
|
"payload": {"$ref": "#/$defs/delegationPayload"},
|
|
"signature": {"$ref": "#/$defs/signature"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def service_manifest_schema_json() -> str:
|
|
return json.dumps(service_manifest_json_schema(), indent=2, sort_keys=True) + "\n"
|
|
|
|
|
|
def _validate_payload(
|
|
payload: Any, issues: list[ServiceManifestIssue]
|
|
) -> tuple[datetime | None, datetime | None]:
|
|
path = "$.payload"
|
|
if not isinstance(payload, dict):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be an object"))
|
|
return None, None
|
|
_unknown_fields(path, payload, PAYLOAD_FIELDS, issues)
|
|
_required_fields(
|
|
path,
|
|
payload,
|
|
{
|
|
"service_id",
|
|
"name",
|
|
"root_public_key",
|
|
"routes",
|
|
"issued_at",
|
|
"expires_at",
|
|
"sequence",
|
|
},
|
|
issues,
|
|
)
|
|
_string(path + ".name", payload.get("name"), issues, minimum=1, maximum=120)
|
|
service_id = payload.get("service_id")
|
|
root_key = payload.get("root_public_key")
|
|
_pattern(path + ".service_id", service_id, SERVICE_ID_RE, issues)
|
|
_pattern(path + ".root_public_key", root_key, HEX_32_RE, issues)
|
|
if isinstance(service_id, str) and isinstance(root_key, str) and HEX_32_RE.fullmatch(root_key):
|
|
expected = service_id_for_root_key(root_key)
|
|
if service_id != expected:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
path + ".service_id", "service-id", "does not match root_public_key"
|
|
)
|
|
)
|
|
|
|
if "api" in payload:
|
|
_validate_api(payload["api"], issues)
|
|
_validate_routes(payload.get("routes"), issues)
|
|
sequence = payload.get("sequence")
|
|
_integer(path + ".sequence", sequence, issues, minimum=1, maximum=MAX_SAFE_INTEGER)
|
|
if "previous_manifest_digest" in payload:
|
|
_pattern(
|
|
path + ".previous_manifest_digest",
|
|
payload["previous_manifest_digest"],
|
|
HEX_32_RE,
|
|
issues,
|
|
)
|
|
issued_at = _timestamp(path + ".issued_at", payload.get("issued_at"), issues)
|
|
expires_at = _timestamp(path + ".expires_at", payload.get("expires_at"), issues)
|
|
try:
|
|
canonicalize_jcs(payload)
|
|
except (TypeError, ValueError) as exc:
|
|
issues.append(ServiceManifestIssue(path, "canonicalization", str(exc)))
|
|
return issued_at, expires_at
|
|
|
|
|
|
def _validate_api(value: Any, issues: list[ServiceManifestIssue]) -> None:
|
|
path = "$.payload.api"
|
|
if not isinstance(value, dict):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be an object"))
|
|
return
|
|
_unknown_fields(path, value, API_FIELDS, issues)
|
|
_required_fields(path, value, API_FIELDS, issues)
|
|
_string(path + ".contract", value.get("contract"), issues, minimum=1, maximum=120)
|
|
_string(path + ".version", value.get("version"), issues, minimum=1, maximum=40)
|
|
_pattern(path + ".schema_sha256", value.get("schema_sha256"), HEX_32_RE, issues)
|
|
|
|
|
|
def _validate_routes(value: Any, issues: list[ServiceManifestIssue]) -> None:
|
|
path = "$.payload.routes"
|
|
if not isinstance(value, list):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be an array"))
|
|
return
|
|
if not value:
|
|
issues.append(ServiceManifestIssue(path, "range", "must contain at least one route"))
|
|
if len(value) > MAX_ROUTES:
|
|
issues.append(
|
|
ServiceManifestIssue(path, "range", f"must contain at most {MAX_ROUTES} routes")
|
|
)
|
|
seen: set[tuple[str, str]] = set()
|
|
for index, route in enumerate(value):
|
|
route_path = f"{path}[{index}]"
|
|
if not isinstance(route, dict):
|
|
issues.append(ServiceManifestIssue(route_path, "type", "must be an object"))
|
|
continue
|
|
_unknown_fields(route_path, route, ROUTE_FIELDS, issues)
|
|
_required_fields(
|
|
route_path,
|
|
route,
|
|
{
|
|
"transport",
|
|
"endpoint",
|
|
"context",
|
|
"interaction_tier",
|
|
"priority",
|
|
"auth",
|
|
"payments",
|
|
},
|
|
issues,
|
|
)
|
|
transport = route.get("transport")
|
|
context = route.get("context")
|
|
endpoint = route.get("endpoint")
|
|
_enum(route_path + ".transport", transport, TRANSPORTS, issues)
|
|
_enum(route_path + ".context", context, TRANSPORTS, issues)
|
|
if isinstance(transport, str) and isinstance(context, str) and context != transport:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
route_path + ".context", "context", "must match transport in v1"
|
|
)
|
|
)
|
|
_enum(
|
|
route_path + ".interaction_tier",
|
|
route.get("interaction_tier"),
|
|
PUBLIC_INTERACTION_TIERS,
|
|
issues,
|
|
)
|
|
_integer(route_path + ".priority", route.get("priority"), issues, minimum=0, maximum=1000)
|
|
_enum_array(route_path + ".auth", route.get("auth"), AUTH_METHODS, issues)
|
|
_enum_array(route_path + ".payments", route.get("payments"), PAYMENT_METHODS, issues)
|
|
if "capabilities" in route:
|
|
_string_array(route_path + ".capabilities", route["capabilities"], issues)
|
|
if isinstance(endpoint, str) and isinstance(transport, str) and transport in TRANSPORTS:
|
|
_validate_endpoint(route_path + ".endpoint", transport, endpoint, issues)
|
|
key = (transport, endpoint)
|
|
if key in seen:
|
|
issues.append(
|
|
ServiceManifestIssue(route_path, "duplicate", "duplicates another route")
|
|
)
|
|
seen.add(key)
|
|
else:
|
|
_string(route_path + ".endpoint", endpoint, issues, minimum=1, maximum=2048)
|
|
|
|
|
|
def _validate_endpoint(
|
|
path: str, transport: str, endpoint: str, issues: list[ServiceManifestIssue]
|
|
) -> None:
|
|
_string(path, endpoint, issues, minimum=1, maximum=2048)
|
|
if not endpoint or len(endpoint) > 2048:
|
|
return
|
|
try:
|
|
parsed = urlsplit(endpoint)
|
|
port = parsed.port
|
|
except ValueError as exc:
|
|
issues.append(ServiceManifestIssue(path, "format", f"invalid endpoint: {exc}"))
|
|
return
|
|
if parsed.username or parsed.password or parsed.fragment or parsed.query:
|
|
issues.append(
|
|
ServiceManifestIssue(path, "format", "must not include credentials, query, or fragment")
|
|
)
|
|
host = (parsed.hostname or "").lower()
|
|
allowed_schemes = {
|
|
"clearnet": {"https"},
|
|
"tor": {"http", "https"},
|
|
"i2p": {"http", "https"},
|
|
"gemini": {"gemini"},
|
|
"ipfs": {"ipfs"},
|
|
"reticulum": {"reticulum"},
|
|
}[transport]
|
|
if parsed.scheme.lower() not in allowed_schemes:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
path,
|
|
"transport-endpoint",
|
|
f"{transport} endpoint must use one of: {', '.join(sorted(allowed_schemes))}",
|
|
)
|
|
)
|
|
if transport == "tor" and not ONION_V3_RE.fullmatch(host):
|
|
issues.append(ServiceManifestIssue(path, "transport-endpoint", "must use a v3 .onion host"))
|
|
elif transport == "i2p" and not host.endswith(".i2p"):
|
|
issues.append(ServiceManifestIssue(path, "transport-endpoint", "must use an .i2p host"))
|
|
elif transport in {"clearnet", "gemini"} and not host:
|
|
issues.append(ServiceManifestIssue(path, "transport-endpoint", "must include a host"))
|
|
elif transport == "ipfs" and not (parsed.netloc or parsed.path):
|
|
issues.append(
|
|
ServiceManifestIssue(path, "transport-endpoint", "must include an IPFS address")
|
|
)
|
|
elif transport == "reticulum" and not (parsed.netloc or parsed.path):
|
|
issues.append(
|
|
ServiceManifestIssue(path, "transport-endpoint", "must include a Reticulum destination")
|
|
)
|
|
if port is not None and not (1 <= port <= 65535):
|
|
issues.append(ServiceManifestIssue(path, "range", "port must be between 1 and 65535"))
|
|
|
|
|
|
def _validate_signature_object(
|
|
path: str, value: Any, issues: list[ServiceManifestIssue]
|
|
) -> str | None:
|
|
if not isinstance(value, dict):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be an object"))
|
|
return None
|
|
_unknown_fields(path, value, SIGNATURE_FIELDS, issues)
|
|
_required_fields(path, value, SIGNATURE_FIELDS, issues)
|
|
if value.get("algorithm") != SIGNATURE_ALGORITHM:
|
|
issues.append(
|
|
ServiceManifestIssue(path + ".algorithm", "algorithm", f"must be {SIGNATURE_ALGORITHM}")
|
|
)
|
|
if value.get("canonicalization") != CANONICALIZATION:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
path + ".canonicalization",
|
|
"canonicalization",
|
|
f"must be {CANONICALIZATION}",
|
|
)
|
|
)
|
|
public_key = value.get("public_key")
|
|
_pattern(path + ".public_key", public_key, HEX_32_RE, issues)
|
|
_pattern(path + ".value", value.get("value"), HEX_64_RE, issues)
|
|
return public_key if isinstance(public_key, str) and HEX_32_RE.fullmatch(public_key) else None
|
|
|
|
|
|
def _validate_delegation(
|
|
value: Any,
|
|
*,
|
|
root_key: Any,
|
|
service_id: Any,
|
|
signing_key: str | None,
|
|
issues: list[ServiceManifestIssue],
|
|
verify_signature: bool,
|
|
) -> tuple[datetime | None, datetime | None]:
|
|
path = "$.delegation"
|
|
if not isinstance(value, dict):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be an object"))
|
|
return None, None
|
|
_unknown_fields(path, value, DELEGATION_FIELDS, issues)
|
|
_required_fields(path, value, DELEGATION_FIELDS, issues)
|
|
payload = value.get("payload")
|
|
signature = value.get("signature")
|
|
delegation_key = _validate_signature_object(path + ".signature", signature, issues)
|
|
if not isinstance(payload, dict):
|
|
issues.append(ServiceManifestIssue(path + ".payload", "type", "must be an object"))
|
|
return None, None
|
|
_unknown_fields(path + ".payload", payload, DELEGATION_PAYLOAD_FIELDS, issues)
|
|
_required_fields(path + ".payload", payload, DELEGATION_PAYLOAD_FIELDS, issues)
|
|
if payload.get("schema") != DELEGATION_SCHEMA:
|
|
issues.append(
|
|
ServiceManifestIssue(path + ".payload.schema", "schema", f"must be {DELEGATION_SCHEMA}")
|
|
)
|
|
_pattern(path + ".payload.service_id", payload.get("service_id"), SERVICE_ID_RE, issues)
|
|
_pattern(path + ".payload.root_public_key", payload.get("root_public_key"), HEX_32_RE, issues)
|
|
_pattern(
|
|
path + ".payload.signing_public_key",
|
|
payload.get("signing_public_key"),
|
|
HEX_32_RE,
|
|
issues,
|
|
)
|
|
_integer(
|
|
path + ".payload.sequence",
|
|
payload.get("sequence"),
|
|
issues,
|
|
minimum=1,
|
|
maximum=MAX_SAFE_INTEGER,
|
|
)
|
|
if service_id is not None and payload.get("service_id") != service_id:
|
|
issues.append(
|
|
ServiceManifestIssue(path + ".payload.service_id", "binding", "service mismatch")
|
|
)
|
|
if root_key is not None and payload.get("root_public_key") != root_key:
|
|
issues.append(
|
|
ServiceManifestIssue(path + ".payload.root_public_key", "binding", "root key mismatch")
|
|
)
|
|
if signing_key is not None and payload.get("signing_public_key") != signing_key:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
path + ".payload.signing_public_key", "binding", "signing key mismatch"
|
|
)
|
|
)
|
|
if root_key is not None and delegation_key is not None and delegation_key != root_key:
|
|
issues.append(
|
|
ServiceManifestIssue(path + ".signature.public_key", "binding", "must be the root key")
|
|
)
|
|
issued_at = _timestamp(path + ".payload.issued_at", payload.get("issued_at"), issues)
|
|
expires_at = _timestamp(path + ".payload.expires_at", payload.get("expires_at"), issues)
|
|
if issued_at and expires_at and issued_at >= expires_at:
|
|
issues.append(
|
|
ServiceManifestIssue(
|
|
path + ".payload.expires_at", "range", "must be later than issued_at"
|
|
)
|
|
)
|
|
try:
|
|
canonicalize_jcs(payload)
|
|
except (TypeError, ValueError) as exc:
|
|
issues.append(ServiceManifestIssue(path + ".payload", "canonicalization", str(exc)))
|
|
if verify_signature and isinstance(signature, dict):
|
|
try:
|
|
digest = delegation_digest(payload)
|
|
except (TypeError, ValueError):
|
|
pass
|
|
else:
|
|
_verify_signed_payload(path + ".signature", signature, digest, issues)
|
|
return issued_at, expires_at
|
|
|
|
|
|
def _verify_signed_payload(
|
|
path: str,
|
|
signature: dict[str, Any],
|
|
digest: bytes,
|
|
issues: list[ServiceManifestIssue],
|
|
) -> None:
|
|
try:
|
|
public_key = _decode_hex(signature.get("public_key"), 32, "public key")
|
|
value = _decode_hex(signature.get("value"), 64, "signature")
|
|
except (TypeError, ValueError):
|
|
return
|
|
if not bip340_verify(public_key, digest, value):
|
|
issues.append(ServiceManifestIssue(path + ".value", "signature", "verification failed"))
|
|
|
|
|
|
def _timestamp(
|
|
path: str, value: Any, issues: list[ServiceManifestIssue]
|
|
) -> datetime | None:
|
|
if (
|
|
not isinstance(value, str)
|
|
or not value
|
|
or len(value) > 40
|
|
or not RFC3339_RE.fullmatch(value)
|
|
):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be an RFC 3339 timestamp string"))
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
issues.append(ServiceManifestIssue(path, "format", "must be an RFC 3339 timestamp"))
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
issues.append(ServiceManifestIssue(path, "format", "must include a UTC offset"))
|
|
return None
|
|
return _as_utc(parsed)
|
|
|
|
|
|
def _as_utc(value: datetime) -> datetime:
|
|
if value.tzinfo is None:
|
|
raise ValueError("datetime must be timezone-aware")
|
|
return value.astimezone(timezone.utc)
|
|
|
|
|
|
def _unknown_fields(
|
|
path: str,
|
|
value: dict[str, Any],
|
|
allowed: set[str],
|
|
issues: list[ServiceManifestIssue],
|
|
) -> None:
|
|
for field in sorted(set(value) - allowed):
|
|
issues.append(ServiceManifestIssue(f"{path}.{field}", "unknown-field", "unknown field"))
|
|
|
|
|
|
def _required_fields(
|
|
path: str,
|
|
value: dict[str, Any],
|
|
required: set[str],
|
|
issues: list[ServiceManifestIssue],
|
|
) -> None:
|
|
for field in sorted(required - set(value)):
|
|
issues.append(ServiceManifestIssue(f"{path}.{field}", "required", "missing required field"))
|
|
|
|
|
|
def _string(
|
|
path: str,
|
|
value: Any,
|
|
issues: list[ServiceManifestIssue],
|
|
*,
|
|
minimum: int,
|
|
maximum: int,
|
|
) -> None:
|
|
if not isinstance(value, str):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be a string"))
|
|
elif not minimum <= len(value) <= maximum:
|
|
issues.append(
|
|
ServiceManifestIssue(path, "range", f"length must be between {minimum} and {maximum}")
|
|
)
|
|
|
|
|
|
def _pattern(
|
|
path: str,
|
|
value: Any,
|
|
pattern: re.Pattern[str],
|
|
issues: list[ServiceManifestIssue],
|
|
) -> None:
|
|
if not isinstance(value, str):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be a string"))
|
|
elif not pattern.fullmatch(value):
|
|
issues.append(ServiceManifestIssue(path, "format", "has invalid format"))
|
|
|
|
|
|
def _enum(
|
|
path: str,
|
|
value: Any,
|
|
allowed: tuple[str, ...],
|
|
issues: list[ServiceManifestIssue],
|
|
) -> None:
|
|
if value not in allowed:
|
|
issues.append(
|
|
ServiceManifestIssue(path, "enum", f"must be one of: {', '.join(allowed)}")
|
|
)
|
|
|
|
|
|
def _integer(
|
|
path: str,
|
|
value: Any,
|
|
issues: list[ServiceManifestIssue],
|
|
*,
|
|
minimum: int,
|
|
maximum: int,
|
|
) -> None:
|
|
if isinstance(value, bool) or not isinstance(value, int):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be an integer"))
|
|
elif not minimum <= value <= maximum:
|
|
issues.append(
|
|
ServiceManifestIssue(path, "range", f"must be between {minimum} and {maximum}")
|
|
)
|
|
|
|
|
|
def _enum_array(
|
|
path: str,
|
|
value: Any,
|
|
allowed: tuple[str, ...],
|
|
issues: list[ServiceManifestIssue],
|
|
) -> None:
|
|
if not isinstance(value, list):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be an array"))
|
|
return
|
|
if not value:
|
|
issues.append(ServiceManifestIssue(path, "range", "must not be empty"))
|
|
seen: set[str] = set()
|
|
for index, item in enumerate(value):
|
|
_enum(f"{path}[{index}]", item, allowed, issues)
|
|
if isinstance(item, str) and item in seen:
|
|
issues.append(ServiceManifestIssue(f"{path}[{index}]", "duplicate", "duplicate value"))
|
|
if isinstance(item, str):
|
|
seen.add(item)
|
|
if "none" in seen and len(seen) > 1:
|
|
issues.append(ServiceManifestIssue(path, "combination", "none cannot be combined"))
|
|
|
|
|
|
def _string_array(path: str, value: Any, issues: list[ServiceManifestIssue]) -> None:
|
|
if not isinstance(value, list):
|
|
issues.append(ServiceManifestIssue(path, "type", "must be an array"))
|
|
return
|
|
if len(value) > 64:
|
|
issues.append(ServiceManifestIssue(path, "range", "must contain at most 64 values"))
|
|
seen: set[str] = set()
|
|
for index, item in enumerate(value):
|
|
item_path = f"{path}[{index}]"
|
|
_string(item_path, item, issues, minimum=1, maximum=120)
|
|
if isinstance(item, str) and item in seen:
|
|
issues.append(ServiceManifestIssue(item_path, "duplicate", "duplicate value"))
|
|
if isinstance(item, str):
|
|
seen.add(item)
|
|
|
|
|
|
def _decode_hex(value: Any, size: int, label: str) -> bytes:
|
|
if not isinstance(value, str) or len(value) != size * 2:
|
|
raise ValueError(f"{label} must be {size} bytes of lowercase hex")
|
|
try:
|
|
decoded = bytes.fromhex(value)
|
|
except ValueError as exc:
|
|
raise ValueError(f"{label} must be lowercase hex") from exc
|
|
if value != value.lower():
|
|
raise ValueError(f"{label} must be lowercase hex")
|
|
return decoded
|