mirror of
https://github.com/Such-Software/adaptive-multi-protocol-gateway.git
synced 2026-10-17 07:31:32 +02:00
Add signed service manifest contract
This commit is contained in:
@@ -104,6 +104,7 @@ API remains internal even when the public site is available on Tor or I2P.
|
|||||||
- [Daemon management](docs/daemon-management.md)
|
- [Daemon management](docs/daemon-management.md)
|
||||||
- [Domain onboarding](docs/domain-onboarding.md)
|
- [Domain onboarding](docs/domain-onboarding.md)
|
||||||
- [Fixture manifests](docs/fixture-manifests.md)
|
- [Fixture manifests](docs/fixture-manifests.md)
|
||||||
|
- [Signed service manifests](docs/service-manifests.md)
|
||||||
- [State contract](docs/state-contract.md)
|
- [State contract](docs/state-contract.md)
|
||||||
- [Visual guide](docs/visual-guide.md)
|
- [Visual guide](docs/visual-guide.md)
|
||||||
- [Interaction capabilities](docs/interaction-capabilities.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 -m ampg --config examples/wownero.gateway.toml routes validate
|
||||||
python3 tools/generate_route_manifest.py examples/route-catalog.json examples/route-manifest.json
|
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 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/wownero.gateway.toml audit
|
||||||
python3 -m ampg --config examples/i2p-only.gateway.toml build
|
python3 -m ampg --config examples/i2p-only.gateway.toml build
|
||||||
python3 -m ampg docs generate
|
python3 -m ampg docs generate
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -95,6 +96,12 @@ from .route_manifest import (
|
|||||||
)
|
)
|
||||||
from .route_policy import RouteExposure, RouteIssue, route_exposures, route_issues
|
from .route_policy import RouteExposure, RouteIssue, route_exposures, route_issues
|
||||||
from .selection import protocols_for_selection, select_profile, select_protocols
|
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 .state_contract import StatePathContract, state_contract
|
||||||
from .status import DoctorIssue, TransportStatus, doctor_gateway, gateway_status
|
from .status import DoctorIssue, TransportStatus, doctor_gateway, gateway_status
|
||||||
|
|
||||||
@@ -537,6 +544,36 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
type=Path,
|
type=Path,
|
||||||
help="Write the JSON Schema to this path instead of stdout.",
|
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 = subcommands.add_parser("audit", help="Audit source HTML quality.")
|
||||||
audit_parser.add_argument(
|
audit_parser.add_argument(
|
||||||
"--fail-on-warn",
|
"--fail-on-warn",
|
||||||
@@ -561,6 +598,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
return _cmd_docs(args)
|
return _cmd_docs(args)
|
||||||
if args.command == "route-manifest":
|
if args.command == "route-manifest":
|
||||||
return _cmd_route_manifest(args)
|
return _cmd_route_manifest(args)
|
||||||
|
if args.command == "service-manifest":
|
||||||
|
return _cmd_service_manifest(args)
|
||||||
config = _selected_config(load_config(args.config), args)
|
config = _selected_config(load_config(args.config), args)
|
||||||
if args.command == "plan":
|
if args.command == "plan":
|
||||||
return _cmd_plan(config, write_artifacts=_write_artifacts_enabled(config, args))
|
return _cmd_plan(config, write_artifacts=_write_artifacts_enabled(config, args))
|
||||||
@@ -1891,6 +1930,108 @@ def _cmd_route_manifest(args) -> int:
|
|||||||
return 1
|
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:
|
def _print_route_exposure(exposure: RouteExposure) -> None:
|
||||||
print(
|
print(
|
||||||
"AMPG_ROUTE "
|
"AMPG_ROUTE "
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .metadata import (
|
|||||||
TRANSPORT_INTERACTION_CAPABILITIES,
|
TRANSPORT_INTERACTION_CAPABILITIES,
|
||||||
)
|
)
|
||||||
from .route_manifest import ROUTE_MANIFEST_SCHEMA_PATH, route_manifest_schema_json
|
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")
|
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 / "interaction-capabilities.md": _interaction_capabilities_doc(),
|
||||||
GENERATED_DIR / "render-profiles.md": _render_profiles_doc(),
|
GENERATED_DIR / "render-profiles.md": _render_profiles_doc(),
|
||||||
ROUTE_MANIFEST_SCHEMA_PATH: route_manifest_schema_json(),
|
ROUTE_MANIFEST_SCHEMA_PATH: route_manifest_schema_json(),
|
||||||
|
SERVICE_MANIFEST_SCHEMA_PATH: service_manifest_schema_json(),
|
||||||
}
|
}
|
||||||
changed: list[Path] = []
|
changed: list[Path] = []
|
||||||
for rel_path, content in docs.items():
|
for rel_path, content in docs.items():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
transport, logical context, and transport-context isolation result. They do not prescribe
|
||||||
browser tabs, processes, or local daemon ownership; those decisions remain inside AMPB.
|
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
|
||||||
|
|
||||||
Platform providers decide what daemon management means on the current host. AMPG detects
|
Platform providers decide what daemon management means on the current host. AMPG detects
|
||||||
|
|||||||
@@ -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
|
: Generated from the route-manifest validator. App repos can use it to emit and validate
|
||||||
`ampg.route-manifest.v1` without depending on AMPG internals.
|
`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`
|
`openapi.json`
|
||||||
: Generated if AMPG exposes a local control API for status, builds, plans, and daemon
|
: Generated if AMPG exposes a local control API for status, builds, plans, and daemon
|
||||||
health.
|
health.
|
||||||
@@ -67,5 +71,6 @@ outside the public repo.
|
|||||||
- [ ] Generate daemon adapter docs from registered adapters.
|
- [ ] Generate daemon adapter docs from registered adapters.
|
||||||
- [ ] Generate renderer docs from renderer defaults.
|
- [ ] Generate renderer docs from renderer defaults.
|
||||||
- [ ] Generate route-manifest JSON Schema from code.
|
- [ ] Generate route-manifest JSON Schema from code.
|
||||||
|
- [ ] Generate service-manifest JSON Schema from code.
|
||||||
- [ ] Commit generated artifacts.
|
- [ ] Commit generated artifacts.
|
||||||
- [ ] Fail CI when regeneration produces a diff.
|
- [ ] Fail CI when regeneration produces a diff.
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
AMPG writes deterministic fixture manifests so AMPB can verify that generated site
|
AMPG writes deterministic fixture manifests so AMPB can verify that generated site
|
||||||
variants route to the expected browser transport context.
|
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`
|
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
|
field is a logical transport context and does not imply a separate browser profile or
|
||||||
window.
|
window.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user