Add signed service manifest contract

This commit is contained in:
jwinterm
2026-07-11 11:05:48 -04:00
parent 1651514c8e
commit bb63585098
14 changed files with 1936 additions and 0 deletions
+3
View File
@@ -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
+92
View File
@@ -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
+71
View File
@@ -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")
+141
View File
@@ -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 "
+2
View File
@@ -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():
+880
View File
@@ -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
+7
View File
@@ -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
+5
View File
@@ -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.
+4
View File
@@ -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.
+93
View File
@@ -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 <RFC3339>` 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.
+60
View File
@@ -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"
}
}
+290
View File
@@ -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"
}
+66
View File
@@ -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"
}
+222
View File
@@ -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()