diff --git a/README.md b/README.md index c1c7b44..0b149c5 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ API remains internal even when the public site is available on Tor or I2P. - [Daemon management](docs/daemon-management.md) - [Domain onboarding](docs/domain-onboarding.md) - [Fixture manifests](docs/fixture-manifests.md) +- [Signed service manifests](docs/service-manifests.md) - [State contract](docs/state-contract.md) - [Visual guide](docs/visual-guide.md) - [Interaction capabilities](docs/interaction-capabilities.md) @@ -173,6 +174,8 @@ python3 -m ampg --config examples/wownero.gateway.toml routes explain python3 -m ampg --config examples/wownero.gateway.toml routes validate python3 tools/generate_route_manifest.py examples/route-catalog.json examples/route-manifest.json python3 -m ampg route-manifest validate examples/route-manifest.json +python3 -m ampg service-manifest validate examples/service-manifest.json --at 2027-01-01T00:00:00Z +python3 -m ampg service-manifest digest examples/service-manifest.json python3 -m ampg --config examples/wownero.gateway.toml audit python3 -m ampg --config examples/i2p-only.gateway.toml build python3 -m ampg docs generate diff --git a/ampg/bip340.py b/ampg/bip340.py new file mode 100644 index 0000000..0025fbc --- /dev/null +++ b/ampg/bip340.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import hashlib + + +SECP256K1_FIELD_SIZE = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F +SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +SECP256K1_GENERATOR = ( + 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, + 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, +) +Point = tuple[int, int] | None + + +def tagged_hash(tag: str, value: bytes) -> bytes: + tag_hash = hashlib.sha256(tag.encode("utf-8")).digest() + return hashlib.sha256(tag_hash + tag_hash + value).digest() + + +def bip340_verify(public_key: bytes, message: bytes, signature: bytes) -> bool: + """Verify a BIP-340 signature over the 32-byte AMPG contract digest.""" + + if len(public_key) != 32 or len(message) != 32 or len(signature) != 64: + return False + point = _lift_x(int.from_bytes(public_key, "big")) + if point is None: + return False + r = int.from_bytes(signature[:32], "big") + s = int.from_bytes(signature[32:], "big") + if r >= SECP256K1_FIELD_SIZE or s >= SECP256K1_ORDER: + return False + challenge = int.from_bytes( + tagged_hash("BIP0340/challenge", signature[:32] + public_key + message), "big" + ) % SECP256K1_ORDER + result = point_add( + point_mul(s, SECP256K1_GENERATOR), + point_mul(SECP256K1_ORDER - challenge, point), + ) + return result is not None and result[1] % 2 == 0 and result[0] == r + + +def _lift_x(x: int) -> Point: + if x >= SECP256K1_FIELD_SIZE: + return None + y_sq = (pow(x, 3, SECP256K1_FIELD_SIZE) + 7) % SECP256K1_FIELD_SIZE + y = pow(y_sq, (SECP256K1_FIELD_SIZE + 1) // 4, SECP256K1_FIELD_SIZE) + if pow(y, 2, SECP256K1_FIELD_SIZE) != y_sq: + return None + return x, y if y % 2 == 0 else SECP256K1_FIELD_SIZE - y + + +def point_add(left: Point, right: Point) -> Point: + if left is None: + return right + if right is None: + return left + x1, y1 = left + x2, y2 = right + if x1 == x2 and y1 != y2: + return None + if left == right: + if y1 == 0: + return None + slope = ( + (3 * x1 * x1) + * pow(2 * y1, SECP256K1_FIELD_SIZE - 2, SECP256K1_FIELD_SIZE) + % SECP256K1_FIELD_SIZE + ) + else: + slope = ( + (y2 - y1) + * pow( + (x2 - x1) % SECP256K1_FIELD_SIZE, + SECP256K1_FIELD_SIZE - 2, + SECP256K1_FIELD_SIZE, + ) + % SECP256K1_FIELD_SIZE + ) + x3 = (slope * slope - x1 - x2) % SECP256K1_FIELD_SIZE + y3 = (slope * (x1 - x3) - y1) % SECP256K1_FIELD_SIZE + return x3, y3 + + +def point_mul(scalar: int, point: Point) -> Point: + result: Point = None + addend = point + while scalar: + if scalar & 1: + result = point_add(result, addend) + addend = point_add(addend, addend) + scalar >>= 1 + return result diff --git a/ampg/canonical_json.py b/ampg/canonical_json.py new file mode 100644 index 0000000..1459601 --- /dev/null +++ b/ampg/canonical_json.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +from typing import Any + + +MAX_SAFE_INTEGER = 9_007_199_254_740_991 + + +def strict_json_loads(text: str) -> Any: + """Load the integer-only I-JSON profile used by signed AMPG contracts.""" + + def object_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON object key: {key!r}") + result[key] = value + return result + + def reject_float(value: str) -> float: + raise ValueError(f"floating-point JSON numbers are not permitted: {value}") + + def reject_constant(value: str) -> None: + raise ValueError(f"non-I-JSON number is not permitted: {value}") + + return json.loads( + text, + object_pairs_hook=object_pairs, + parse_float=reject_float, + parse_constant=reject_constant, + ) + + +def canonicalize_jcs(value: Any) -> bytes: + """Return RFC 8785 bytes for AMPG's integer-only I-JSON profile.""" + + return _canonicalize(value).encode("utf-8") + + +def _canonicalize(value: Any) -> str: + if value is None: + return "null" + if value is True: + return "true" + if value is False: + return "false" + if isinstance(value, int): + if abs(value) > MAX_SAFE_INTEGER: + raise ValueError(f"integer exceeds I-JSON safe range: {value}") + return str(value) + if isinstance(value, float): + raise TypeError("floating-point values are not supported by signed AMPG contracts") + if isinstance(value, str): + _reject_surrogates(value) + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + if isinstance(value, list): + return "[" + ",".join(_canonicalize(item) for item in value) + "]" + if isinstance(value, dict): + if not all(isinstance(key, str) for key in value): + raise TypeError("JSON object keys must be strings") + keys = sorted(value, key=lambda key: key.encode("utf-16-be")) + return "{" + ",".join( + _canonicalize(key) + ":" + _canonicalize(value[key]) for key in keys + ) + "}" + raise TypeError(f"unsupported JSON value: {type(value).__name__}") + + +def _reject_surrogates(value: str) -> None: + if any(0xD800 <= ord(char) <= 0xDFFF for char in value): + raise ValueError("lone Unicode surrogate is not valid I-JSON") diff --git a/ampg/cli.py b/ampg/cli.py index 54bcba0..3d7a9f0 100644 --- a/ampg/cli.py +++ b/ampg/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +from datetime import datetime, timezone from pathlib import Path import sys @@ -95,6 +96,12 @@ from .route_manifest import ( ) from .route_policy import RouteExposure, RouteIssue, route_exposures, route_issues from .selection import protocols_for_selection, select_profile, select_protocols +from .service_manifest import ( + read_service_manifest, + service_manifest_digest, + service_manifest_schema_json, + validate_service_manifest, +) from .state_contract import StatePathContract, state_contract from .status import DoctorIssue, TransportStatus, doctor_gateway, gateway_status @@ -537,6 +544,36 @@ def main(argv: list[str] | None = None) -> int: type=Path, help="Write the JSON Schema to this path instead of stdout.", ) + service_manifest_parser = subcommands.add_parser( + "service-manifest", + help="Validate or inspect a signed public service manifest.", + ) + service_manifest_subcommands = service_manifest_parser.add_subparsers( + dest="service_manifest_command", + required=True, + ) + service_manifest_validate = service_manifest_subcommands.add_parser( + "validate", + help="Validate structure, validity, delegation, and BIP-340 signatures.", + ) + service_manifest_validate.add_argument("path", type=Path, help="Signed manifest JSON path.") + service_manifest_validate.add_argument( + "--at", help="Validate at an RFC 3339 timestamp instead of the current time." + ) + service_manifest_validate.add_argument( + "--skip-time", action="store_true", help="Skip issued-at and expiration checks." + ) + service_manifest_validate.add_argument("--minimum-sequence", type=int) + service_manifest_validate.add_argument("--minimum-delegation-sequence", type=int) + service_manifest_validate.add_argument("--expected-previous") + service_manifest_digest_parser = service_manifest_subcommands.add_parser( + "digest", help="Print the domain-separated payload digest." + ) + service_manifest_digest_parser.add_argument("path", type=Path) + service_manifest_schema = service_manifest_subcommands.add_parser( + "schema", help="Print the generated service-manifest JSON Schema." + ) + service_manifest_schema.add_argument("--output", type=Path) audit_parser = subcommands.add_parser("audit", help="Audit source HTML quality.") audit_parser.add_argument( "--fail-on-warn", @@ -561,6 +598,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_docs(args) if args.command == "route-manifest": return _cmd_route_manifest(args) + if args.command == "service-manifest": + return _cmd_service_manifest(args) config = _selected_config(load_config(args.config), args) if args.command == "plan": return _cmd_plan(config, write_artifacts=_write_artifacts_enabled(config, args)) @@ -1891,6 +1930,108 @@ def _cmd_route_manifest(args) -> int: return 1 +def _cmd_service_manifest(args) -> int: + if args.service_manifest_command == "schema": + content = service_manifest_schema_json() + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(content, encoding="utf-8") + print(f"AMPG_SERVICE_MANIFEST_SCHEMA path={args.output}") + else: + print(content, end="") + return 0 + + try: + data = read_service_manifest(args.path) + except ValueError as exc: + print( + f'AMPG_SERVICE_MANIFEST path={args.path} status=error message="{exc}"', + file=sys.stderr, + ) + return 1 + + if args.service_manifest_command == "digest": + payload = data.get("payload") + if not isinstance(payload, dict): + print( + f'AMPG_SERVICE_MANIFEST path={args.path} status=error message="payload must be an object"', + file=sys.stderr, + ) + return 1 + issues = validate_service_manifest(data, verify_signature=False) + if issues: + for issue in issues: + print( + "AMPG_SERVICE_MANIFEST_ISSUE " + f"path={args.path} json_path={issue.path} " + f'code={issue.code} message="{issue.message}"' + ) + print( + f"AMPG_SERVICE_MANIFEST path={args.path} status=fail issues={len(issues)}" + ) + return 1 + try: + digest = service_manifest_digest(payload).hex() + except (TypeError, ValueError) as exc: + print( + f'AMPG_SERVICE_MANIFEST path={args.path} status=error message="{exc}"', + file=sys.stderr, + ) + return 1 + print(f"AMPG_SERVICE_MANIFEST_DIGEST path={args.path} digest={digest}") + return 0 + + if args.service_manifest_command == "validate": + try: + now = None if args.skip_time else _service_manifest_time(args.at) + except ValueError as exc: + print( + f'AMPG_SERVICE_MANIFEST path={args.path} status=error message="{exc}"', + file=sys.stderr, + ) + return 1 + issues = validate_service_manifest( + data, + now=now, + minimum_sequence=args.minimum_sequence, + minimum_delegation_sequence=args.minimum_delegation_sequence, + expected_previous=args.expected_previous, + ) + for issue in issues: + print( + "AMPG_SERVICE_MANIFEST_ISSUE " + f"path={args.path} json_path={issue.path} " + f'code={issue.code} message="{issue.message}"' + ) + if issues: + print( + f"AMPG_SERVICE_MANIFEST path={args.path} status=fail issues={len(issues)}" + ) + return 1 + payload = data["payload"] + print( + "AMPG_SERVICE_MANIFEST " + f"path={args.path} schema={data['schema']} " + f"service_id={payload['service_id']} sequence={payload['sequence']} " + f"routes={len(payload['routes'])} " + f"digest={service_manifest_digest(payload).hex()} status=ok" + ) + return 0 + return 1 + + +def _service_manifest_time(value: str | None) -> datetime: + if value is None: + return datetime.now(timezone.utc) + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("--at must be an RFC 3339 timestamp") from exc + if parsed.tzinfo is None: + raise ValueError("--at must include a UTC offset") + return parsed.astimezone(timezone.utc) + + def _print_route_exposure(exposure: RouteExposure) -> None: print( "AMPG_ROUTE " diff --git a/ampg/docsgen.py b/ampg/docsgen.py index 107c23b..49342c4 100644 --- a/ampg/docsgen.py +++ b/ampg/docsgen.py @@ -12,6 +12,7 @@ from .metadata import ( TRANSPORT_INTERACTION_CAPABILITIES, ) from .route_manifest import ROUTE_MANIFEST_SCHEMA_PATH, route_manifest_schema_json +from .service_manifest import SERVICE_MANIFEST_SCHEMA_PATH, service_manifest_schema_json GENERATED_DIR = Path("docs/generated") @@ -24,6 +25,7 @@ def generate_docs(root: Path, *, check: bool = False) -> list[Path]: GENERATED_DIR / "interaction-capabilities.md": _interaction_capabilities_doc(), GENERATED_DIR / "render-profiles.md": _render_profiles_doc(), ROUTE_MANIFEST_SCHEMA_PATH: route_manifest_schema_json(), + SERVICE_MANIFEST_SCHEMA_PATH: service_manifest_schema_json(), } changed: list[Path] = [] for rel_path, content in docs.items(): diff --git a/ampg/service_manifest.py b/ampg/service_manifest.py new file mode 100644 index 0000000..ddaae5d --- /dev/null +++ b/ampg/service_manifest.py @@ -0,0 +1,880 @@ +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 diff --git a/docs/architecture.md b/docs/architecture.md index 1380ea8..f0ee868 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,6 +98,13 @@ Fixture manifests are the AMPG-to-AMPB boundary. They declare the published URL, transport, logical context, and transport-context isolation result. They do not prescribe browser tabs, processes, or local daemon ownership; those decisions remain inside AMPB. +Signed service manifests are the service-to-client identity boundary. An application-owned +route manifest supplies publication policy, while a service manifest represents approved public +policy plus deployed transport addresses. AMPG currently validates manifests and calculates their +digests; applications or hardened external signers own private-key operations. A shared service +identity binds endpoints but never merges browser origins or transport contexts. See +[Signed service manifests](service-manifests.md). + ## Platform providers Platform providers decide what daemon management means on the current host. AMPG detects diff --git a/docs/autodocs.md b/docs/autodocs.md index 259ae1f..f8f7b07 100644 --- a/docs/autodocs.md +++ b/docs/autodocs.md @@ -23,6 +23,10 @@ code. Generated artifacts are committed, then checked in CI by regenerating and : Generated from the route-manifest validator. App repos can use it to emit and validate `ampg.route-manifest.v1` without depending on AMPG internals. +`schemas/amp.service-manifest.v1.schema.json` +: Generated from the signed service-manifest validator. Clients and applications can share + the public identity, route, delegation, and signature contract without importing AMPG. + `openapi.json` : Generated if AMPG exposes a local control API for status, builds, plans, and daemon health. @@ -67,5 +71,6 @@ outside the public repo. - [ ] Generate daemon adapter docs from registered adapters. - [ ] Generate renderer docs from renderer defaults. - [ ] Generate route-manifest JSON Schema from code. +- [ ] Generate service-manifest JSON Schema from code. - [ ] Commit generated artifacts. - [ ] Fail CI when regeneration produces a diff. diff --git a/docs/fixture-manifests.md b/docs/fixture-manifests.md index eb6061f..10494af 100644 --- a/docs/fixture-manifests.md +++ b/docs/fixture-manifests.md @@ -5,6 +5,10 @@ AMPG writes deterministic fixture manifests so AMPB can verify that generated site variants route to the expected browser transport context. +Fixture manifests are test contracts, not signed service discovery. See +[Signed service manifests](service-manifests.md) for the client-facing identity and endpoint +contract. + Current manifests use `ampg.fixture-manifest.v2`. The browser-facing `checks.context` field is a logical transport context and does not imply a separate browser profile or window. diff --git a/docs/service-manifests.md b/docs/service-manifests.md new file mode 100644 index 0000000..d35e54d --- /dev/null +++ b/docs/service-manifests.md @@ -0,0 +1,93 @@ +# Signed Service Manifests + +> Status: draft | Updated 2026-07-11 | Applies to: AMPG service-manifest v1 consumers and producers + +`amp.service-manifest.v1` binds one logical service identity to a bounded set of public +transport endpoints. AMPG can validate, inspect, and calculate digests for these manifests. + +A valid manifest proves that its signed endpoints were approved by the service key. It does +not make different endpoints equivalent web origins. Browsers and clients must continue to +isolate cookies, storage, credentials, cache, DNS, and connections by transport context. + +A self-signed manifest does not bootstrap trust by itself. Clients must pin the service id or +root key through an explicit import, trusted local configuration, QR code, or another authenticated +discovery channel before accepting endpoint updates. + +## Contract + +The generated JSON Schema is +[`schemas/amp.service-manifest.v1.schema.json`](../schemas/amp.service-manifest.v1.schema.json). +A cross-language golden vector is available at +[`tests/fixtures/amp.service-manifest.v1.golden.json`](../tests/fixtures/amp.service-manifest.v1.golden.json). +A standalone signed example is available at +[`examples/service-manifest.json`](../examples/service-manifest.json). + +The signed payload contains: + +- a service id derived from the root public key; +- a display name and optional API contract digest; +- one or more public transport endpoints; +- an issue time, expiration time, and monotonic sequence; +- an optional previous-manifest digest for update chaining. + +Each route declares its transport, endpoint, matching transport context, maximum public +interaction tier, priority, authentication methods, payment methods, and optional capability +names. `internal` routes are invalid in a service manifest. + +The optional delegation envelope lets an offline root key authorize a distinct online signing +key for a bounded time. The service id remains derived from the root key. + +## Canonical Bytes And Signatures + +The v1 payload uses the [RFC 8785 JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785.html) +with an integer-only I-JSON profile. Floating-point values, duplicate object keys, lone Unicode +surrogates, and integers outside the interoperable I-JSON range are rejected. + +AMPG calculates the 32-byte payload digest with tagged SHA-256 using the tag +`AMPG/service-manifest/v1`. The signature is a 64-byte +[BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki) Schnorr signature over +that digest using a 32-byte x-only secp256k1 public key. + +Service ids use tagged SHA-256 with `AMPG/service-id/v1`. Delegations use +`AMPG/service-key-delegation/v1`. + +AMPG implements dependency-free signature verification. It does not generate service private +keys or sign operational manifests. Applications or a hardened external signer own private-key +operations. + +## Validation + +```sh +python3 -m ampg service-manifest validate service-manifest.json +python3 -m ampg service-manifest validate service-manifest.json \ + --minimum-sequence 7 \ + --minimum-delegation-sequence 3 \ + --expected-previous "$PREVIOUS_DIGEST" +python3 -m ampg service-manifest digest service-manifest.json +python3 -m ampg service-manifest schema --output service-manifest.schema.json +``` + +Validation checks strict JSON parsing, fields and bounds, service-id derivation, endpoint and +transport compatibility, public-only interaction tiers, time validity, rollback constraints, +delegation bindings, and BIP-340 signatures. + +Manifest files are limited to 256 KiB. Consumers should persist the highest accepted manifest and +delegation sequences per pinned service id and supply them during later validation. + +`--at ` makes time checks reproducible. `--skip-time` is intended for fixture and +forensic inspection; it does not disable structural or cryptographic checks. + +## Manifest Boundaries + +AMPG uses three separate contracts: + +- `ampg.route-manifest.v1` is unsigned application-owned publication policy input. +- `amp.service-manifest.v1` is signed public service identity and endpoint discovery. +- `ampg.fixture-manifest.v2` contains expected routes for AMPG/AMPB compatibility tests. + +The service manifest may contain public capabilities and a reviewed API contract digest. It +must not contain deny rules, internal route names, upstream addresses, daemon keys, credentials, +worker inventory, or deployment notes. + +Clients should probe only the selected route or routes the user explicitly approves. Probing +every advertised transport in parallel can correlate one client across networks. diff --git a/examples/service-manifest.json b/examples/service-manifest.json new file mode 100644 index 0000000..1797865 --- /dev/null +++ b/examples/service-manifest.json @@ -0,0 +1,60 @@ +{ + "payload": { + "api": { + "contract": "amp.example.customer-api", + "schema_sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "version": "1.0.0" + }, + "expires_at": "2036-01-01T00:00:00Z", + "issued_at": "2026-01-01T00:00:00Z", + "name": "AMP Service Manifest Golden Fixture", + "root_public_key": "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + "routes": [ + { + "auth": [ + "nip98" + ], + "capabilities": [ + "catalog.read", + "job.submit", + "job.status" + ], + "context": "clearnet", + "endpoint": "https://ampgateway.site/", + "interaction_tier": "transactional", + "payments": [ + "server-invoice" + ], + "priority": 10, + "transport": "clearnet" + }, + { + "auth": [ + "nip98" + ], + "capabilities": [ + "catalog.read", + "job.submit", + "job.status" + ], + "context": "tor", + "endpoint": "http://fy2zosxp4oqqcacxcegvwshtmsiiyztnwoinbm46yoig4o3kveidotyd.onion/", + "interaction_tier": "transactional", + "payments": [ + "server-invoice" + ], + "priority": 20, + "transport": "tor" + } + ], + "sequence": 1, + "service_id": "amp:5603a5186ef45121612a9b5ff37ae53b81000881d864df7f367aeba037c0e346" + }, + "schema": "amp.service-manifest.v1", + "signature": { + "algorithm": "BIP340-secp256k1", + "canonicalization": "RFC8785-JCS", + "public_key": "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + "value": "0c0edc3bc02c3697f7bffd9bd6c03558b400c586ef0d8daf48e8486d387aae5aee1e17a5243514e4254a534574d34362224df73a99f332e1747a648eb44c42b9" + } +} diff --git a/schemas/amp.service-manifest.v1.schema.json b/schemas/amp.service-manifest.v1.schema.json new file mode 100644 index 0000000..c12f914 --- /dev/null +++ b/schemas/amp.service-manifest.v1.schema.json @@ -0,0 +1,290 @@ +{ + "$defs": { + "api": { + "additionalProperties": false, + "properties": { + "contract": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "schema_sha256": { + "$ref": "#/$defs/hex32" + }, + "version": { + "maxLength": 40, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "contract", + "version", + "schema_sha256" + ], + "type": "object" + }, + "delegation": { + "additionalProperties": false, + "properties": { + "payload": { + "$ref": "#/$defs/delegationPayload" + }, + "signature": { + "$ref": "#/$defs/signature" + } + }, + "required": [ + "payload", + "signature" + ], + "type": "object" + }, + "delegationPayload": { + "additionalProperties": false, + "properties": { + "expires_at": { + "$ref": "#/$defs/timestamp" + }, + "issued_at": { + "$ref": "#/$defs/timestamp" + }, + "root_public_key": { + "$ref": "#/$defs/hex32" + }, + "schema": { + "const": "amp.service-key-delegation.v1" + }, + "sequence": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "service_id": { + "pattern": "^amp:[0-9a-f]{64}$", + "type": "string" + }, + "signing_public_key": { + "$ref": "#/$defs/hex32" + } + }, + "required": [ + "schema", + "service_id", + "root_public_key", + "signing_public_key", + "issued_at", + "expires_at", + "sequence" + ], + "type": "object" + }, + "hex32": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "hex64": { + "pattern": "^[0-9a-f]{128}$", + "type": "string" + }, + "payload": { + "additionalProperties": false, + "properties": { + "api": { + "$ref": "#/$defs/api" + }, + "expires_at": { + "$ref": "#/$defs/timestamp" + }, + "issued_at": { + "$ref": "#/$defs/timestamp" + }, + "name": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "previous_manifest_digest": { + "$ref": "#/$defs/hex32" + }, + "root_public_key": { + "$ref": "#/$defs/hex32" + }, + "routes": { + "items": { + "$ref": "#/$defs/route" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + }, + "sequence": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "service_id": { + "pattern": "^amp:[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "service_id", + "name", + "root_public_key", + "routes", + "issued_at", + "expires_at", + "sequence" + ], + "type": "object" + }, + "route": { + "additionalProperties": false, + "properties": { + "auth": { + "items": { + "enum": [ + "none", + "nip98", + "smirk-action", + "smirk-session", + "siwe", + "http-session" + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "capabilities": { + "items": { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + "maxItems": 64, + "type": "array", + "uniqueItems": true + }, + "context": { + "enum": [ + "clearnet", + "tor", + "i2p", + "gemini", + "ipfs", + "reticulum" + ] + }, + "endpoint": { + "maxLength": 2048, + "minLength": 1, + "type": "string" + }, + "interaction_tier": { + "enum": [ + "static", + "interactive-lite", + "identity", + "transactional", + "realtime" + ] + }, + "payments": { + "items": { + "enum": [ + "none", + "smirk-wallet", + "server-invoice", + "payment-capability", + "static-instructions" + ] + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "priority": { + "maximum": 1000, + "minimum": 0, + "type": "integer" + }, + "transport": { + "enum": [ + "clearnet", + "tor", + "i2p", + "gemini", + "ipfs", + "reticulum" + ] + } + }, + "required": [ + "transport", + "endpoint", + "context", + "interaction_tier", + "priority", + "auth", + "payments" + ], + "type": "object" + }, + "signature": { + "additionalProperties": false, + "properties": { + "algorithm": { + "const": "BIP340-secp256k1" + }, + "canonicalization": { + "const": "RFC8785-JCS" + }, + "public_key": { + "$ref": "#/$defs/hex32" + }, + "value": { + "$ref": "#/$defs/hex64" + } + }, + "required": [ + "algorithm", + "canonicalization", + "public_key", + "value" + ], + "type": "object" + }, + "timestamp": { + "format": "date-time", + "maxLength": 40, + "type": "string" + } + }, + "$id": "urn:amp:schema:service-manifest:v1", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Signed public identity and multi-transport route contract.", + "properties": { + "delegation": { + "$ref": "#/$defs/delegation" + }, + "payload": { + "$ref": "#/$defs/payload" + }, + "schema": { + "const": "amp.service-manifest.v1" + }, + "signature": { + "$ref": "#/$defs/signature" + } + }, + "required": [ + "schema", + "payload", + "signature" + ], + "title": "AMP Signed Service Manifest", + "type": "object" +} diff --git a/tests/fixtures/amp.service-manifest.v1.golden.json b/tests/fixtures/amp.service-manifest.v1.golden.json new file mode 100644 index 0000000..3b2fb33 --- /dev/null +++ b/tests/fixtures/amp.service-manifest.v1.golden.json @@ -0,0 +1,66 @@ +{ + "canonical_payload_utf8": "{\"api\":{\"contract\":\"amp.example.customer-api\",\"schema_sha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"version\":\"1.0.0\"},\"expires_at\":\"2036-01-01T00:00:00Z\",\"issued_at\":\"2026-01-01T00:00:00Z\",\"name\":\"AMP Service Manifest Golden Fixture\",\"root_public_key\":\"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9\",\"routes\":[{\"auth\":[\"nip98\"],\"capabilities\":[\"catalog.read\",\"job.submit\",\"job.status\"],\"context\":\"clearnet\",\"endpoint\":\"https://ampgateway.site/\",\"interaction_tier\":\"transactional\",\"payments\":[\"server-invoice\"],\"priority\":10,\"transport\":\"clearnet\"},{\"auth\":[\"nip98\"],\"capabilities\":[\"catalog.read\",\"job.submit\",\"job.status\"],\"context\":\"tor\",\"endpoint\":\"http://fy2zosxp4oqqcacxcegvwshtmsiiyztnwoinbm46yoig4o3kveidotyd.onion/\",\"interaction_tier\":\"transactional\",\"payments\":[\"server-invoice\"],\"priority\":20,\"transport\":\"tor\"}],\"sequence\":1,\"service_id\":\"amp:5603a5186ef45121612a9b5ff37ae53b81000881d864df7f367aeba037c0e346\"}", + "description": "Public cross-language golden vector. The test secret key is not operational.", + "manifest": { + "payload": { + "api": { + "contract": "amp.example.customer-api", + "schema_sha256": "1111111111111111111111111111111111111111111111111111111111111111", + "version": "1.0.0" + }, + "expires_at": "2036-01-01T00:00:00Z", + "issued_at": "2026-01-01T00:00:00Z", + "name": "AMP Service Manifest Golden Fixture", + "root_public_key": "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + "routes": [ + { + "auth": [ + "nip98" + ], + "capabilities": [ + "catalog.read", + "job.submit", + "job.status" + ], + "context": "clearnet", + "endpoint": "https://ampgateway.site/", + "interaction_tier": "transactional", + "payments": [ + "server-invoice" + ], + "priority": 10, + "transport": "clearnet" + }, + { + "auth": [ + "nip98" + ], + "capabilities": [ + "catalog.read", + "job.submit", + "job.status" + ], + "context": "tor", + "endpoint": "http://fy2zosxp4oqqcacxcegvwshtmsiiyztnwoinbm46yoig4o3kveidotyd.onion/", + "interaction_tier": "transactional", + "payments": [ + "server-invoice" + ], + "priority": 20, + "transport": "tor" + } + ], + "sequence": 1, + "service_id": "amp:5603a5186ef45121612a9b5ff37ae53b81000881d864df7f367aeba037c0e346" + }, + "schema": "amp.service-manifest.v1", + "signature": { + "algorithm": "BIP340-secp256k1", + "canonicalization": "RFC8785-JCS", + "public_key": "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + "value": "0c0edc3bc02c3697f7bffd9bd6c03558b400c586ef0d8daf48e8486d387aae5aee1e17a5243514e4254a534574d34362224df73a99f332e1747a648eb44c42b9" + } + }, + "payload_digest": "178adc9a4630cda6f6e8b3d1c289c38c346602b2067bf5794a02e4b20bd37a63", + "test_secret_key": "0000000000000000000000000000000000000000000000000000000000000003" +} diff --git a/tests/test_service_manifest.py b/tests/test_service_manifest.py new file mode 100644 index 0000000..43faab3 --- /dev/null +++ b/tests/test_service_manifest.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import contextlib +from copy import deepcopy +from datetime import datetime, timezone +import io +import json +from pathlib import Path +import tempfile +import unittest + +from ampg.cli import main +from ampg import bip340 +from ampg import service_manifest as sm + + +ROOT = Path(__file__).resolve().parents[1] +GOLDEN_PATH = ROOT / "tests/fixtures/amp.service-manifest.v1.golden.json" + + +def golden() -> dict: + return json.loads(GOLDEN_PATH.read_text(encoding="utf-8")) + + +def test_sign(secret: int, message: bytes) -> tuple[str, str]: + point = bip340.point_mul(secret, bip340.SECP256K1_GENERATOR) + assert point is not None + adjusted = secret if point[1] % 2 == 0 else bip340.SECP256K1_ORDER - secret + public_key = point[0].to_bytes(32, "big") + secret_bytes = adjusted.to_bytes(32, "big") + aux_hash = bip340.tagged_hash("BIP0340/aux", bytes(32)) + masked = bytes(left ^ right for left, right in zip(secret_bytes, aux_hash)) + nonce = int.from_bytes( + bip340.tagged_hash("BIP0340/nonce", masked + public_key + message), "big" + ) % bip340.SECP256K1_ORDER + nonce_point = bip340.point_mul(nonce, bip340.SECP256K1_GENERATOR) + assert nonce_point is not None + adjusted_nonce = nonce if nonce_point[1] % 2 == 0 else bip340.SECP256K1_ORDER - nonce + r = nonce_point[0].to_bytes(32, "big") + challenge = int.from_bytes( + bip340.tagged_hash("BIP0340/challenge", r + public_key + message), "big" + ) % bip340.SECP256K1_ORDER + signature = r + ( + (adjusted_nonce + challenge * adjusted) % bip340.SECP256K1_ORDER + ).to_bytes(32, "big") + assert sm.bip340_verify(public_key, message, signature) + return public_key.hex(), signature.hex() + + +class ServiceManifestTest(unittest.TestCase): + def test_official_bip340_vector(self): + public_key = bytes.fromhex( + "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + ) + signature = bytes.fromhex( + "e907831f80848d1069a5371b402410364bdf1c5f8307b0084c55f1ce2dca821" + "525f66a4a85ea8b71e482a74f382d2ce5ebeee8fdb2172f477df4900d310536c0" + ) + self.assertTrue(sm.bip340_verify(public_key, bytes(32), signature)) + self.assertFalse(sm.bip340_verify(public_key, bytes([1]) + bytes(31), signature)) + + def test_cross_language_golden_vector(self): + fixture = golden() + manifest = fixture["manifest"] + + self.assertEqual( + fixture["canonical_payload_utf8"].encode(), + sm.canonicalize_jcs(manifest["payload"]), + ) + self.assertEqual( + fixture["payload_digest"], sm.service_manifest_digest(manifest["payload"]).hex() + ) + self.assertEqual( + [], + sm.validate_service_manifest( + manifest, now=datetime(2027, 1, 1, tzinfo=timezone.utc) + ), + ) + + def test_jcs_uses_utf16_property_order_and_rejects_floats(self): + self.assertEqual( + '{"😀":2,"\ue000":1}'.encode(), + sm.canonicalize_jcs({"\ue000": 1, "😀": 2}), + ) + with self.assertRaisesRegex(TypeError, "floating-point"): + sm.canonicalize_jcs({"value": 1.5}) + with self.assertRaisesRegex(ValueError, "safe range"): + sm.canonicalize_jcs({"value": sm.MAX_SAFE_INTEGER + 1}) + + def test_strict_loader_rejects_duplicate_keys_and_floats(self): + with self.assertRaisesRegex(ValueError, "duplicate JSON object key"): + sm.strict_json_loads('{"schema":"one","schema":"two"}') + with self.assertRaisesRegex(ValueError, "floating-point"): + sm.strict_json_loads('{"sequence":1.0}') + + def test_manifest_file_size_is_bounded(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "large.json" + path.write_bytes(b" " * (sm.MAX_MANIFEST_BYTES + 1)) + with self.assertRaisesRegex(ValueError, "exceeds"): + sm.read_service_manifest(path) + + def test_tampering_expiration_and_rollback_fail_closed(self): + manifest = deepcopy(golden()["manifest"]) + manifest["payload"]["routes"][0]["endpoint"] = "https://changed.example/" + issues = sm.validate_service_manifest( + manifest, + now=datetime(2040, 1, 1, tzinfo=timezone.utc), + minimum_sequence=2, + expected_previous="22" * 32, + ) + codes = {issue.code for issue in issues} + self.assertTrue({"signature", "expired", "rollback", "chain"}.issubset(codes)) + + def test_timestamp_format_is_strict(self): + manifest = deepcopy(golden()["manifest"]) + manifest["payload"]["issued_at"] = "20260101T00:00:00Z" + issues = sm.validate_service_manifest(manifest, verify_signature=False) + self.assertIn( + "$.payload.issued_at", + {issue.path for issue in issues if issue.code == "type"}, + ) + + def test_internal_routes_and_transport_mismatches_are_rejected(self): + manifest = deepcopy(golden()["manifest"]) + route = manifest["payload"]["routes"][1] + route["interaction_tier"] = "internal" + route["endpoint"] = "https://example.com/" + route["context"] = "clearnet" + + issues = sm.validate_service_manifest(manifest, verify_signature=False) + by_path = {issue.path: issue.code for issue in issues} + self.assertEqual("enum", by_path["$.payload.routes[1].interaction_tier"]) + self.assertEqual("transport-endpoint", by_path["$.payload.routes[1].endpoint"]) + self.assertEqual("context", by_path["$.payload.routes[1].context"]) + + def test_delegated_signer_is_bound_to_root_and_validity_window(self): + manifest = deepcopy(golden()["manifest"]) + root_secret = int(golden()["test_secret_key"], 16) + signing_secret = 4 + signing_key, _ = test_sign(signing_secret, bytes(32)) + delegation_payload = { + "schema": sm.DELEGATION_SCHEMA, + "service_id": manifest["payload"]["service_id"], + "root_public_key": manifest["payload"]["root_public_key"], + "signing_public_key": signing_key, + "issued_at": "2025-12-01T00:00:00Z", + "expires_at": "2037-01-01T00:00:00Z", + "sequence": 1, + } + root_key, delegation_signature = test_sign( + root_secret, sm.delegation_digest(delegation_payload) + ) + signing_key, manifest_signature = test_sign( + signing_secret, sm.service_manifest_digest(manifest["payload"]) + ) + manifest["delegation"] = { + "payload": delegation_payload, + "signature": { + "algorithm": sm.SIGNATURE_ALGORITHM, + "canonicalization": sm.CANONICALIZATION, + "public_key": root_key, + "value": delegation_signature, + }, + } + manifest["signature"] = { + "algorithm": sm.SIGNATURE_ALGORITHM, + "canonicalization": sm.CANONICALIZATION, + "public_key": signing_key, + "value": manifest_signature, + } + + self.assertEqual( + [], + sm.validate_service_manifest( + manifest, now=datetime(2027, 1, 1, tzinfo=timezone.utc) + ), + ) + rollback = sm.validate_service_manifest( + manifest, + now=datetime(2027, 1, 1, tzinfo=timezone.utc), + minimum_delegation_sequence=2, + ) + self.assertIn( + "$.delegation.payload.sequence", + {issue.path for issue in rollback if issue.code == "rollback"}, + ) + + def test_schema_is_generated_and_cli_validates_and_digests(self): + manifest = golden()["manifest"] + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "manifest.json" + schema_path = Path(tmp) / "schema.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + validate_status = main( + ["service-manifest", "validate", str(path), "--at", "2027-01-01T00:00:00Z"] + ) + digest_status = main(["service-manifest", "digest", str(path)]) + schema_status = main( + ["service-manifest", "schema", "--output", str(schema_path)] + ) + generated_schema = schema_path.read_text(encoding="utf-8") + + self.assertEqual((0, 0, 0), (validate_status, digest_status, schema_status)) + output = stdout.getvalue() + self.assertIn("schema=amp.service-manifest.v1", output) + self.assertIn(f"digest={golden()['payload_digest']}", output) + self.assertEqual(sm.service_manifest_schema_json(), generated_schema) + + def test_generated_schema_file_matches_code(self): + schema_path = ROOT / "schemas/amp.service-manifest.v1.schema.json" + self.assertEqual(sm.service_manifest_schema_json(), schema_path.read_text(encoding="utf-8")) + + def test_standalone_example_matches_golden_manifest(self): + example = json.loads((ROOT / "examples/service-manifest.json").read_text(encoding="utf-8")) + self.assertEqual(golden()["manifest"], example) + + +if __name__ == "__main__": + unittest.main()