diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..84412c3 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,137 @@ +# tools + +Five programs. The first checks the committed examples; the rest are a working +implementation of the protocol. + +| File | What it is | +|---|---| +| `validate.py` | The conformance validator: 66 checks over the committed examples and the Section 13.3 vectors. Needs only `jsonschema` and `referencing`. It recomputes digests and Merkle roots but verifies no signature. | +| `pactcore.py` | Canonicalization, digests, JWS signing and verification over the transmitted protected header, identifier normalization, the assurance constraint in exact decimal arithmetic, and the RFC 9162 Merkle tree. | +| `facilitator.py` | A reference Facilitator: the six operations of Table 1 over five paths, the Figure 2 state machine including the challenge window and the Figure 6 overturned-PASS path, the Section 7.4 waterfall, schema validation of every posted object, a signed capability document, and RFC 9457 refusals in the draft's own namespace that name the rule. `--rules` prints what it enforces and what it chose. | +| `agents.py` | Buyer, Seller, Verifier and Challenger clients. | +| `measure.py` | Drives five contracts through the terminal states on a clock the harness advances, exercises 24 refusals and 2 acceptances each on the rule it is named for, asserts that money balances, checks every minted object and the capability document against the schemas, and reports costs. | + +``` +pip install jsonschema referencing cryptography +python3 tools/validate.py +python3 tools/measure.py +python3 tools/facilitator.py --rules +``` + +`validate.py` needs only the first two packages, so a checkout still validates +on a machine with nothing else installed. The Facilitator refuses to start +without `jsonschema`, because Section 12.1 makes schema conformance a MUST at +Propose and a Facilitator that skips it is not one. + +## What this does and does not answer + +Section 15 of the draft records that no Facilitator, Buyer or Seller exchanging +messages over the Section 12 endpoints was known to the author when -01 was +posted. This is that implementation, and it is one implementation written by the +same person who wrote the specification, which is the weakest possible evidence +that the specification is implementable. The falsifiable experiment of Section +1.4 needs *two independent* implementations settling each other's contracts. +This is an invitation for the second, not a substitute for it. + +The signatures are real Ed25519. The contracts `measure.py` mints are fresh and +so are the keys, deliberately: the committed examples under `examples/` keep +their placeholder signature values because the published Internet-Draft prints +their digests in Section 14 and cannot be corrected, so re-signing them would +silently desynchronise this repository from that document. Real keys and +recomputed digests belong together in a -02. + +No payment rail is touched. Section 1.2 puts the rail out of scope and +`price.settlement` names a binding; money here is an integer number of cents in +three pools. What is real is the object flow, the state machine, the signature +verification and the arithmetic. + +## Not implemented, and refused rather than faked + +A contract that needs any of these is refused at Propose with a problem body +saying so, rather than accepted and then stranded or silently mishandled: +subcontracts (Section 10; `liability.parent` is refused as `parent-unresolvable`), +release modes other than on-verification, assurance modes other than certain, +verification profiles other than acceptance, challenge deposits, settlement +bindings other than the one the capability document advertises, and amounts +finer than a cent. Key resolution is an in-process registry with the Section +13.1.1 interface; the network lookup is the part that is stubbed. + +## Choices the draft left to the implementer + +Each of these is a place where the -01 text is silent or says two things. +`facilitator.py --rules` prints the same list. They are choices, not rules, and +a second implementation is free to choose differently, which is exactly the +kind of disagreement the experiment exists to surface. + +1. **The Bond on ABANDONED.** Section 6 slashes it "to the extent of + `restitution_basis`", which under `released` with nothing released is zero. + Section 7.6 returns the Bond on FINAL or SETTLED and says nothing about the + third terminal state. This implementation returns it. Measured: a Seller that + signs, posts 18.00, and never delivers gets the whole 18.00 back. +2. **Rank 3 restores the Buyer's loss, net of rank 1.** Read literally, basis + `price` would pay the Bond on top of a reversed escrow in the pre-release + failure, a windfall the draft's own `remainder_to` rule exists to prevent. + Under the net reading the two basis values differ only when release was + partial; under on-verification they never differ. +3. **The bounty.** The draft requires it to be non-exclusive and forbids capping + it at a fraction "chosen for tidiness", and does not fix it. This + implementation pays the whole remaining Bond after rank 3, split equally among + successful Challengers. With K independent discoverers a full bounty each is + not fundable from one Bond, which the draft's text assumes it is. +4. **Rank 2 pays 0.00.** The Challenge object has no member for the documented + costs rank 2 reimburses, and its schema is closed. +5. **A Challenge with no Verdict inside `max_dispute_seconds` lapses**; the + earlier Verdict stands and the window is not extended. The draft declares the + bound and never applies it. +6. **PROPOSED is not observable.** With no rail the pools are debited in memory + when a co-signed contract is accepted, so the 201 reports FUNDED. + +## Measured on 12 September 2026 + +Intel Core i9-9880H at 2.30 GHz, Python 3.12.11, Ed25519, single host, loopback +HTTP, in-memory store, no payment rail, the Facilitator's clock advanced by the +harness. The same code has produced per-call figures two to three times apart +across sessions on the same laptop; the order of magnitude is the result. + +| Path | Exchanges | Request / response bytes | Attestation amounts (settled / restituted / slashed) | +|---|---|---|---| +| FINAL: PASS, window closes | 4 | 3,065 / 4,011 | 180.00 / 0.00 / 0.00 | +| SETTLED: verifier FAIL | 4 | 3,071 / 4,014 | 0.00 / 0.00 / 18.00 | +| ABANDONED: no Delivery | 3 | 1,469 / 3,780 | 0.00 / 0.00 / 0.00 | +| SETTLED: PASS overturned by a Challenge | 6 | 4,458 / 5,446 | 180.00 / 18.00 / 18.00 | + +Each lifecycle completes in 25 to 45 ms, most of it schema validation of the +posted objects. Per call, medians: canonicalize a contract 51 us; canonicalize +and digest 60 us; sign a contract including canonicalization 128 us; verify a +contract signature end to end 198 us, of which the Ed25519 primitive over 1.5 KB +is 123 us; normalize an identifier 1.3 us; the assurance constraint in exact +decimal 2.1 us; an RFC 9162 root over 2, 8 and 64 leaves 4, 21 and 179 us. + +The overturned-PASS row is the one the restitution basis does any work in, and +its amounts are what Section 11's worked attestation should carry: the draft's +example has restituted 18.00 with settled 0.00, which fits neither path. + +There is no verification-cost figure. The example instrument is a pytest module +whose runtime says nothing about real work, and an earlier version of this file +reported a number for it that was pytest's import time. + +## Corrections + +This file has been wrong twice, and both are recorded here rather than deleted, +because the point of publishing a specification for demolition is lost if the +corrections are not published too. + +An earlier version claimed, as a defect, that "a defrauded buyer still recovers +nothing from the bond". That was wrong, and it was wrong in the transcript +printed directly beneath it: rank 1 returns the whole escrow to the Buyer before +rank 3 is reached, so the Buyer's loss is zero and a restitution payment of zero +is correct. + +An earlier version of `facilitator.py` returned the Bond and reached FINAL in the +same call that recorded a PASS, so no challenge window ever opened and the +Figure 6 path was unreachable in the only release mode the draft requires. A +second adversarial review on 11 September found that, along with a deadline +parsed in local time, Verdict and Challenge commitments that could be bypassed +by omitting a member, a bounty paid to a Challenger that did not exist, an +unsigned capability document, and no schema validation at Propose. All are fixed +and the numbers above are from the corrected code. diff --git a/tools/agents.py b/tools/agents.py new file mode 100644 index 0000000..45fe6a5 --- /dev/null +++ b/tools/agents.py @@ -0,0 +1,217 @@ +"""Buyer, Seller, Verifier and Challenger: the client half of the reference pair. + +Each party holds one key and speaks HTTP to a Facilitator. Nothing here trusts +the Facilitator's word for anything it can check itself: a party verifies the +attestation it is handed, and recomputes the contract digest rather than +accepting the one it is told. + +The contracts these agents mint are fresh, with fresh keys and real signatures. +They are deliberately NOT the committed examples under examples/, whose digests +the published Internet-Draft prints in Section 14 and cannot be corrected. +""" + +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from typing import Any + +import pactcore as pc + +MEDIA_CONTRACT = "application/pact-contract+json" +MEDIA_DELIVERY = "application/pact-delivery+json" +MEDIA_VERDICT = "application/pact-verdict+json" +MEDIA_CHALLENGE = "application/pact-challenge+json" + + +@dataclass +class Wire: + """A record of one request and response, for the measurement harness.""" + method: str + path: str + status: int + request_bytes: int + response_bytes: int + seconds: float + + +class Client: + def __init__(self, base: str) -> None: + self.base = base.rstrip("/") + self.wire: list[Wire] = [] + + def _call(self, method: str, path: str, body: dict | None, + content_type: str | None) -> tuple[int, dict]: + raw = json.dumps(body, separators=(",", ":")).encode() if body else None + req = urllib.request.Request(self.base + path, data=raw, method=method) + if content_type: + req.add_header("Content-Type", content_type) + started = time.perf_counter() + try: + with urllib.request.urlopen(req) as resp: + out = resp.read() + status = resp.status + except urllib.error.HTTPError as exc: + out = exc.read() + status = exc.code + elapsed = time.perf_counter() - started + self.wire.append(Wire(method, path, status, len(raw or b""), len(out), elapsed)) + return status, json.loads(out or b"{}") + + def post(self, path: str, body: dict, ct: str) -> tuple[int, dict]: + return self._call("POST", path, body, ct) + + def get(self, path: str) -> tuple[int, dict]: + return self._call("GET", path, None, None) + + # -- named operations, Table 1 ---------------------------------------- + def propose(self, vtc): return self.post("/pact/v1/contracts", vtc, MEDIA_CONTRACT) + def deliver(self, d): return self.post("/pact/v1/deliveries", d, MEDIA_DELIVERY) + def verdict(self, v): return self.post("/pact/v1/verdicts", v, MEDIA_VERDICT) + def challenge(self, c): return self.post("/pact/v1/challenges", c, MEDIA_CHALLENGE) + def contract(self, vid): return self.get(f"/pact/v1/contracts/{vid}") + def attestation(self, vid): return self.get(f"/pact/v1/attestations/{vid}") + def capability(self): return self.get("/.well-known/pact-facilitator") + + +@dataclass +class Party: + did: str + key: pc.Key + client: Client + + def sign_into(self, obj: dict, typ: str, array: bool = False) -> dict: + return pc.attach(obj, pc.sign(obj, self.key, typ), array) + + +def make_party(did: str, resolver: pc.KeyResolver, client: Client, + alg: str = "EdDSA") -> Party: + key = resolver.register(pc.Key.generate(f"{did}#key-1", alg)) + return Party(did=did, key=key, client=client) + + +# -------------------------------------------------------------------------- +# Contract construction +# -------------------------------------------------------------------------- + +def draft_contract(vid: str, buyer: str, seller: str, facilitator: str, + verifier: str | None, *, price: str = "180.00", bond: str = "18.00", + fund: str = "0.50", q_min: float = 0.9091, + deadline: str = "2027-01-01T00:00:00Z", + release: str = "on-verification", + restitution_basis: str = "released", + spec_hash: str | None = None, + criteria_hash: str | None = None) -> dict: + """An unsigned contract in the shape schemas/vtc.schema.json requires.""" + parties = {"buyer": buyer, "seller": seller, "facilitator": facilitator} + if verifier is not None: + parties["verifier"] = verifier # absent: Section 9.1 is derived per signer + return { + "pact": "0.1", + "type": "VerifiableTaskContract", + "id": vid, + "parties": parties, + "task": { + "spec_hash": spec_hash or pc.h(b"taskspec placeholder"), + "spec_uri": "https://buyer.example/specs/taskspec.json", + "deadline": deadline, + }, + "price": { + "amount": price, "currency": "USDC", + "settlement": "pact-escrow", "network": "eip155:8453", + }, + "verification": { + "tier": "T0-reexec", "profile": "acceptance", + "criteria_hash": criteria_hash or pc.h(b"criteria placeholder"), + }, + "assurance": {"mode": "certain", "q_min": q_min}, + "release": release, + "liability": { + "seller_bond": bond, "verification_fund": fund, + "cap": price, "restitution_basis": restitution_basis, + }, + "challenge": {"window_seconds": 3600, "max_dispute_seconds": 86400}, + } + + +def cosign(vtc: dict, buyer: Party, seller: Party) -> dict: + """Both parties sign the same bytes: the contract without its signatures. + + That is what makes the digest meaningful. vtc_hash is then taken over the + contract WITH the signature set, so the commitment proves who agreed. + """ + entries = [pc.sign(vtc, buyer.key, MEDIA_CONTRACT), + pc.sign(vtc, seller.key, MEDIA_CONTRACT)] + vtc["signatures"] = entries + return vtc + + +def make_delivery(vtc: dict, seller: Party, work: bytes, + results: bytes) -> dict: + d = { + "pact": "0.1", + "type": "Delivery", + "vtc_id": vtc["id"], + "vtc_hash": pc.digest_over(pc.hashable(vtc)), + "work_hash": pc.h(work), + "work_uri": "https://cdn.seller.example/o/" + pc.h(work)[7:15], + "input_hash": pc.h(b"inputs"), + "delivered_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "evidence": { + "profile": "acceptance", + "instrument_hash": vtc["verification"]["criteria_hash"], + "results_hash": pc.h(results), + "results_uri": "https://cdn.seller.example/r/" + pc.h(results)[7:15], + }, + } + return seller.sign_into(d, MEDIA_DELIVERY) + + +def make_verdict(vtc: dict, delivery: dict, verifier: Party, + outcome: str) -> dict: + v = { + "pact": "0.1", + "type": "Verdict", + "vtc_id": vtc["id"], + "delivery_hash": pc.digest_over(pc.hashable(delivery)), + "outcome": outcome, + "profile": "acceptance", + "instrument_hash": vtc["verification"]["criteria_hash"], + "results_hash": delivery["evidence"]["results_hash"], + "evaluated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + return verifier.sign_into(v, MEDIA_VERDICT) + + +def make_challenge(vtc: dict, delivery: dict, challenger: Party, + failing: list[str]) -> dict: + c = { + "pact": "0.1", + "type": "Challenge", + "vtc_id": vtc["id"], + "delivery_hash": pc.digest_over(pc.hashable(delivery)), + "proof": { + "profile": "acceptance", + "instrument_hash": vtc["verification"]["criteria_hash"], + "results_hash": pc.h(b"independent re-execution"), + "results_uri": "https://watch.example/o/a91e", + "failing_checks": failing, + }, + } + return challenger.sign_into(c, MEDIA_CHALLENGE) + + +def check_attestation(att: dict, resolver: pc.KeyResolver, + facilitator: str) -> tuple[bool, str]: + """A party checks the record it is handed rather than taking it on trust. + + Section 11 makes the Facilitator the required signer precisely so that a + slashed Seller cannot decline to co-sign its own conviction. The other side + of that is that the Facilitator's signature is what makes the record + evidence, so it has to actually verify. + """ + return pc.verify_object(att, resolver, + "application/pact-attestation+json", [facilitator]) diff --git a/tools/facilitator.py b/tools/facilitator.py new file mode 100644 index 0000000..3f733ea --- /dev/null +++ b/tools/facilitator.py @@ -0,0 +1,1083 @@ +"""A reference Facilitator: the six operations of Section 12 over five paths. + +This is the first implementation that speaks the protocol. Section 15 of the +-01 records that no Facilitator, Buyer or Seller exchanging messages over +these endpoints was known to the author when the draft was posted; this file +and agents.py are the answer to that, and the count of independent +implementations is still one, which is not the falsifiable experiment of +Section 1.4. Two independent implementations settling each other's contracts +is that experiment. This is the first half of it. + +What it enforces, with the section each rule comes from, is listed in RULES. +Where the draft is silent an implementer has to choose; every such choice is +listed in CHOICES and repeated in tools/README.md, because a choice presented +as a rule is how a second implementer ends up disagreeing with the first. + +Storage is in memory. Money is an integer number of cents in three pools. No +payment rail is touched: Section 1.2 puts the rail out of scope, and a +settlement binding names one. What is real here is the object flow, the state +machine, the signature verification and the arithmetic. + +Run it: + + python3 tools/facilitator.py --port 8402 + +Then drive it with agents.py, or measure it with measure.py. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any + +import pactcore as pc + +ROOT = pathlib.Path(__file__).resolve().parent.parent + +RULES = """ +Section 5 a VTC is valid only if buyer and seller each signed it once; nobody else signs it +Section 5.3 liability is REQUIRED, and a contract without it is not a PACT contract +Section 6 evidence absent or nonconformant: refuse the Delivery AND apply 7.4 as though FAIL; + input_hash REQUIRED where the tier re-executes; deadline with no Delivery: ABANDONED +Section 7.1 cumulative release before a recorded Verdict never exceeds the Bond (on-verification + releases nothing before a Verdict, so the cap is satisfied by construction) +Section 7.2 the assurance constraint is evaluated exactly, BEFORE funds lock; failure is refused +Section 7.3 release modes this Facilitator does not advertise are refused at propose +Section 7.4 the five-rank remedy waterfall, restitution before bounty or remainder; a bounty is + paid only to a Challenger that exists; nothing above liability.cap leaves the Seller +Section 7.5 a Challenge is a fraud proof submitted for evaluation, accepted only inside the + window, only with a proof conformant to the profile, and it settles nothing itself; + a Verdict on a Challenge supersedes the earlier one and both are kept +Section 7.6 the Bond is returned when the contract reaches FINAL or SETTLED, less what 7.4 took +Section 8 the capability document is signed and validates against facilitator.schema.json +Section 9.1 verifier independence is DERIVED by comparing normalized party identifiers, at + propose for a named verifier and at Verdict for the signer; never read from a field +Section 3 a Facilitator MUST NOT act as Verifier for a contract it settles +Section 10.1 a contract carrying liability.parent is refused: subcontracts are NOT implemented +Section 11 an attestation is issued for every terminal contract, signed by the Facilitator, + never requiring the signature of the party whose loss it records +Section 12.1 every posted object validates against its published schema and the 13.2 checks +Section 12.2 a POST whose body canonicalizes to a known digest returns 200 and the CURRENT + resource; the same id with a different digest returns 409 +Section 12.3 every failure is an RFC 9457 problem document naming the rule +Section 12.4 a Verdict signer MUST satisfy 9.1 (or be the named verifier); no Verdict without + a recorded Delivery; a Verdict commits to the contract's instrument and profile +Section 13.1 JWS with a detached payload over the TRANSMITTED protected header, an algorithm + allowlist, kid inside the protected header, typ compared to the media type +Section 16.7 a contract naming another Facilitator, or a settlement, network or asset this one + does not advertise, is refused +Section 16.11 the public key resolved for every accepted signature is recorded with the object +""" + +CHOICES = """ +Where the -01 text is silent or inconsistent this implementation chose, and says so: +C1 ABANDONED: the Bond is slashed to the extent of restitution_basis (zero under `released` + with nothing released) and the rest is RETURNED. Section 7.6 returns the Bond only on + FINAL or SETTLED and says nothing about ABANDONED. +C2 Rank 3 restores the Buyer's LOSS up to the basis, net of what rank 1 already returned. + Read literally, basis `price` would pay the Bond on top of a reversed escrow. +C3 Rank 4 pays the whole remaining Bond as the bounty, split equally among the successful + Challengers. The draft bounds the bounty and does not fix it; with K discoverers the + non-exclusive full bounty is not fundable from one Bond. +C4 Rank 2 pays 0.00: the Challenge object has no member for documented costs. +C5 A Challenge that receives no Verdict within max_dispute_seconds lapses and the contract + returns to RELEASING; the window it interrupted is not extended. +C6 PROPOSED is never observable: with no rail the pools are debited in memory the moment a + co-signed contract is accepted, so the 201 reports FUNDED. +C7 Amounts are settled in whole cents; a contract with more decimal places is refused. +C8 Not implemented: subcontracts (Section 10), release modes other than on-verification, + challenge deposits, committed-sample assurance, network key resolution, any rail. +""" + +# Section 18.5: identifiers are appended to this prefix, which the draft owns. +PROBLEM_BASE = "https://pact-spec.github.io/problem/" + +# Table 9 entries first, with the status and section the draft assigns; then the +# document-local types this implementation needs, each naming the section whose +# rule it reports. Section 18.5 permits a document-local namespace. +PROBLEMS = { + "assurance-constraint-unsatisfied": (422, "Section 7.2"), + "evidence-nonconformant": (422, "Section 6"), + "parent-unresolvable": (422, "Section 10.1"), + "finality-ordering-violation": (422, "Section 10.3"), + "parties-not-distinct": (422, "Section 13.2"), + "algorithm-not-permitted": (400, "Section 13.1"), + "verifier-not-independent": (422, "Section 9.1"), + "release-exceeds-bond": (409, "Section 7.1"), + # document-local + "schema-invalid": (422, "Section 13.2"), + "liability-missing": (422, "Section 5.3"), + "signature-invalid": (401, "Section 13.1"), + "signature-missing": (401, "Section 13.1"), + "unexpected-signer": (422, "Section 5"), + "facilitator-cannot-verify": (422, "Section 3"), + "facilitator-mismatch": (422, "Section 16.7"), + "settlement-unsupported": (422, "Section 8"), + "release-mode-unsupported": (422, "Section 7.3"), + "assurance-unsupported": (422, "Section 7.2"), + "deadline-invalid": (422, "Section 13.2"), + "amount-invalid": (422, "Section 13.2"), + "no-recorded-delivery": (409, "Section 12.4"), + "verdict-nonconformant": (422, "Section 12.4"), + "proof-nonconformant": (422, "Section 7.5"), + "challenge-window-closed": (409, "Section 7.5"), + "wrong-state": (409, "Section 12"), + "object-conflict": (409, "Section 12.2"), + "unknown-contract": (404, "Section 12"), + "payload-too-large": (413, "Section 12"), + "internal-error": (500, "Section 12"), +} + +TERMINAL = ("FINAL", "SETTLED", "ABANDONED") + +MEDIA_CONTRACT = "application/pact-contract+json" +MEDIA_DELIVERY = "application/pact-delivery+json" +MEDIA_VERDICT = "application/pact-verdict+json" +MEDIA_CHALLENGE = "application/pact-challenge+json" +MEDIA_ATTESTATION = "application/pact-attestation+json" +MEDIA_FACILITATOR = "application/pact-facilitator+json" + +MAX_BODY = 1 << 20 # one MiB; a contract is under two KB + + +class Refuse(Exception): + def __init__(self, kind: str, detail: str, **extra: Any) -> None: + self.kind = kind + self.detail = detail + self.extra = extra + super().__init__(detail) + + +# -------------------------------------------------------------------------- +# Schemas. The published ones, loaded once. The Facilitator refuses to start +# without jsonschema because Section 12.1 makes schema conformance a MUST at +# Propose and a Facilitator that skips it is not one. +# -------------------------------------------------------------------------- + +class Schemas: + def __init__(self) -> None: + try: + from jsonschema import Draft202012Validator + from referencing import Registry, Resource + except ImportError as exc: # pragma: no cover + raise RuntimeError( + "the reference Facilitator needs `jsonschema` and `referencing` " + "to validate posted objects; pip install them") from exc + docs = {p.name: json.loads(p.read_text()) + for p in (ROOT / "schemas").glob("*.schema.json")} + registry = Registry().with_resources( + [(name, Resource.from_contents(s)) for name, s in docs.items()]) + self._v = {name: Draft202012Validator(s, registry=registry) + for name, s in docs.items()} + + def check(self, obj: Any, name: str) -> None: + errors = sorted(self._v[name].iter_errors(obj), key=lambda e: list(e.path)) + if errors: + e = errors[0] + where = "/".join(str(x) for x in e.path) or "(root)" + raise Refuse("schema-invalid", + f"does not validate against {name} at {where}: {e.message}", + schema=name, path=where) + + +# -------------------------------------------------------------------------- + +def parse_rfc3339(s: str) -> float: + """RFC 3339 to a POSIX timestamp, UTC. Accepts Z, an offset, fractions. + + An earlier version used time.mktime(strptime(...)), which interprets the + string in the host's local zone and accepts one fixed format, so ABANDONED + fired hours early or late depending on where the Facilitator ran. + """ + try: + if s.endswith("Z") or s.endswith("z"): + s = s[:-1] + "+00:00" + dt = datetime.fromisoformat(s) + except (ValueError, TypeError) as exc: + raise Refuse("deadline-invalid", f"task.deadline {s!r} is not RFC 3339") from exc + if dt.tzinfo is None: + raise Refuse("deadline-invalid", f"task.deadline {s!r} carries no zone") + return dt.astimezone(timezone.utc).timestamp() + + +def iso(ts: float) -> str: + return datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def money_cents(label: str, amount: Any) -> int: + try: + return pc.cents(amount) + except (ValueError, InvalidOperation, TypeError) as exc: + raise Refuse("amount-invalid", + f"{label} {amount!r}: this Facilitator settles in whole cents " + f"and refuses amounts it cannot represent") from exc + + +@dataclass +class Contract: + vtc: dict + state: str = "PROPOSED" + pools: pc.Pools = field(default_factory=pc.Pools) + delivery: dict | None = None + verdicts: list[dict] = field(default_factory=list) + challenges: list[dict] = field(default_factory=list) + attestation: dict | None = None + window_opened_at: float | None = None + disputed_at: float | None = None + created_at: float = 0.0 + deadline: float = 0.0 + keys: dict[str, str] = field(default_factory=dict) # Section 16.11 record + + @property + def id(self) -> str: + return self.vtc["id"] + + @property + def buyer(self) -> str: + return self.vtc["parties"]["buyer"] + + @property + def seller(self) -> str: + return self.vtc["parties"]["seller"] + + @property + def verdict(self) -> dict | None: + return self.verdicts[-1] if self.verdicts else None + + @property + def challenge(self) -> dict | None: + return self.challenges[-1] if self.challenges else None + + @property + def window_seconds(self) -> int: + return int(self.vtc["challenge"]["window_seconds"]) + + @property + def max_dispute_seconds(self) -> int: + return int(self.vtc["challenge"].get("max_dispute_seconds", 0)) + + def digest(self) -> str: + return pc.digest_over(pc.hashable(self.vtc)) + + +class Facilitator: + """The settlement service. Thread safe under the threading HTTP server.""" + + def __init__(self, identity: str, key: pc.Key, resolver: pc.KeyResolver, + now: Any = time.time, base_url: str = "http://127.0.0.1:8402") -> None: + self.identity = identity + self.key = key + self.resolver = resolver + self.now = now + self.base_url = base_url.rstrip("/") + self.schemas = Schemas() + self.contracts: dict[str, Contract] = {} + self.seen: dict[tuple[str, str], str] = {} # (kind, digest) -> vtc_id + self.lock = threading.RLock() + self.message_count = 0 + # What this Facilitator advertises, Section 8. Contracts outside it are + # refused at propose rather than accepted and stranded. + self.settlement_bindings = [ + {"id": "pact-escrow", "networks": ["eip155:8453"], "assets": ["USDC"]}] + self.release_modes = ["on-verification"] + self.verification_profiles = ["acceptance"] + self.assurance_modes = ["certain"] + self.max_contract_value = {"amount": "50000.00", "currency": "USDC"} + + # -- Section 12.2 ------------------------------------------------------ + def _digest(self, obj: dict) -> str: + return pc.digest_over(pc.hashable(obj)) + + def _remember(self, kind: str, obj: dict, vid: str) -> None: + self.seen[(kind, self._digest(obj))] = vid + + def _replay(self, kind: str, obj: dict) -> tuple[int, dict] | None: + """200 with the CURRENT resource, not a snapshot taken at creation.""" + vid = self.seen.get((kind, self._digest(obj))) + if vid is None: + return None + c = self.contracts[vid] + self._tick(c) + if kind == "contract": + return 200, self._with_state(c, c.vtc) + if kind == "delivery": + return 200, self._with_state(c, c.delivery) + if kind == "verdict": + return 200, self._with_state(c, obj) + return 200, self._with_state(c, obj) + + def _with_state(self, c: Contract, obj: dict) -> dict: + out = dict(obj) + out["state"] = c.state # Section 12: added here, never signed or hashed + return out + + # -- time ---------------------------------------------------------------- + def _tick(self, c: Contract) -> None: + """Advance the contract along every clock-driven edge that is due.""" + self._expire_if_due(c) + self._lapse_dispute_if_due(c) + self._close_window_if_due(c) + + def _record_keys(self, c: Contract, obj: dict) -> None: + # Section 16.11: record the key material resolved at acceptance, so a + # later rotation or revocation does not orphan a signature already + # accepted. The resolver here is in-process; the record is real. + for kid in pc.signer_kids(obj): + key = self.resolver.resolve(kid) + if key is not None and kid not in c.keys: + c.keys[kid] = pc.b64u(pc.public_bytes(key)) + + # -- Propose, Section 12.1 -------------------------------------------- + def propose(self, vtc: dict) -> tuple[int, dict]: + with self.lock: + hit = self._replay("contract", vtc) + if hit is not None: + return hit + + self.schemas.check(vtc, "vtc.schema.json") + vid = vtc["id"] + if vid in self.contracts: + raise Refuse("object-conflict", + f"contract {vid} exists with a different digest") + if vtc.get("pact") != "0.1" or vtc.get("type") != "VerifiableTaskContract": + raise Refuse("schema-invalid", "pact version or type is not one this " + "Facilitator implements") + + parties = vtc["parties"] + buyer, seller = parties["buyer"], parties["seller"] + if pc.same_party(buyer, seller): + raise Refuse("parties-not-distinct", + "buyer and seller are the same party after the " + "normalization of Section 9.1", buyer=buyer, seller=seller) + if not pc.same_party(parties["facilitator"], self.identity): + raise Refuse("facilitator-mismatch", + "the contract names a different Facilitator; accepting it " + "would make this signed instrument replayable across venues", + named=parties["facilitator"], this=self.identity) + named = parties.get("verifier") + if named: + for who, label in ((buyer, "buyer"), (seller, "seller"), + (self.identity, "facilitator")): + if pc.same_party(named, who): + raise Refuse("verifier-not-independent", + f"parties.verifier is the {label} after " + f"normalization; such a contract could never be " + f"verified", verifier=named) + + liability = vtc.get("liability") + if not liability: + raise Refuse("liability-missing", "a contract that does not allocate " + "liability is not a PACT contract") + if "parent" in liability: + raise Refuse("parent-unresolvable", + "subcontracts (Section 10) are not implemented by this " + "Facilitator; a contract naming a parent is refused rather " + "than accepted with the parent ignored") + + price_m = vtc["price"] + binding = next((b for b in self.settlement_bindings + if b["id"] == price_m["settlement"]), None) + if (binding is None or price_m["network"] not in binding["networks"] + or price_m["currency"] not in binding["assets"]): + raise Refuse("settlement-unsupported", + "settlement, network or asset is not one this Facilitator " + "advertises in its capability document", + settlement=price_m["settlement"], network=price_m["network"], + currency=price_m["currency"]) + if vtc["release"] not in self.release_modes: + raise Refuse("release-mode-unsupported", + f"release mode {vtc['release']!r} is not implemented; " + f"accepting it would strand the contract in RELEASING", + supported=self.release_modes) + mode = vtc["assurance"]["mode"] + if mode == "open": + raise Refuse("assurance-unsupported", + "a contract MUST NOT declare open as its sole source of " + "assurance (Section 7.2)") + if mode not in self.assurance_modes: + raise Refuse("assurance-unsupported", + f"assurance mode {mode!r} is not implemented", + supported=self.assurance_modes) + if vtc["verification"]["profile"] not in self.verification_profiles: + raise Refuse("settlement-unsupported", + f"verification profile {vtc['verification']['profile']!r} " + f"is not one this Facilitator supports", + supported=self.verification_profiles) + + deadline = parse_rfc3339(vtc["task"]["deadline"]) + if deadline <= self.now(): + raise Refuse("deadline-invalid", + "task.deadline is already past on this Facilitator's clock; " + "the contract would be ABANDONED the moment it was funded") + + price = money_cents("price.amount", price_m["amount"]) + bond = money_cents("liability.seller_bond", liability["seller_bond"]) + fund = money_cents("liability.verification_fund", liability["verification_fund"]) + cap = money_cents("liability.cap", liability["cap"]) + maxv = money_cents("max_contract_value", self.max_contract_value["amount"]) + if price > maxv: + raise Refuse("settlement-unsupported", + f"price exceeds this Facilitator's max_contract_value " + f"{self.max_contract_value['amount']}") + + # Section 5: exactly the Buyer's and the Seller's signatures, each + # once, verifying against keys bound to those identifiers. A third + # signer changes the digest without changing the agreement. + ok, why = pc.verify_object(vtc, self.resolver, MEDIA_CONTRACT, [buyer, seller]) + if not ok: + raise Refuse(_sig_kind(why), why) + for kid in pc.signer_kids(vtc): + if not (pc.kid_covers(kid, buyer) or pc.kid_covers(kid, seller)): + raise Refuse("unexpected-signer", + "the contract carries a signature from a party that is " + "neither its Buyer nor its Seller", signer=kid) + + # Section 7.2: evaluated exactly, BEFORE funds lock. + q_min = vtc["assurance"]["q_min"] + if not pc.assurance_holds(price_m["amount"], liability["seller_bond"], + q_min, "0"): + need = pc.required_bond(float(price_m["amount"]), float(q_min), 0.0) + raise Refuse( + "assurance-constraint-unsatisfied", + f"Bond {liability['seller_bond']} is below the minimum {need:.2f} " + f"required for q_min {float(q_min):.2f} at price " + f"{price_m['amount']} with E 0.00.", + required_bond=f"{need:.2f}", declared_bond=liability["seller_bond"], + q_min=q_min, price=price_m["amount"]) + + c = Contract(vtc=vtc, created_at=self.now(), deadline=deadline) + c.pools.escrow = price + c.pools.bond = bond + c.pools.bond_initial = bond + c.pools.fund = fund + c.pools.cap = cap + c.pools.note(f"locked escrow {pc.money(price)}, bond {pc.money(bond)}, " + f"fund {pc.money(fund)} (in memory: no rail, so PROPOSED is " + f"not observable and the contract is FUNDED at once)") + c.state = "FUNDED" + self._record_keys(c, vtc) + self.contracts[c.id] = c + self._remember("contract", vtc, c.id) + return 201, self._with_state(c, vtc) + + # -- Retrieve ---------------------------------------------------------- + def get_contract(self, vid: str) -> tuple[int, dict]: + with self.lock: + c = self._contract(vid) + self._tick(c) + return 200, self._with_state(c, c.vtc) + + def _contract(self, vid: str) -> Contract: + c = self.contracts.get(vid) + if c is None: + raise Refuse("unknown-contract", f"no contract {vid}") + return c + + # -- Section 6: deadline expiry --------------------------------------- + def _expire_if_due(self, c: Contract) -> None: + if c.state != "FUNDED" or self.now() < c.deadline: + return + p = c.pools + p.paid_to_buyer += p.escrow + p.note(f"deadline {iso(c.deadline)} passed with no Delivery: returned escrow " + f"{pc.money(p.escrow)} to buyer") + p.escrow = 0 + # Section 6: slash the Bond to the extent of restitution_basis. Under + # `released` with nothing released that extent is zero. What happens to + # the rest is unspecified (Section 7.6 covers FINAL and SETTLED only); + # CHOICES C1: it is returned. + owed = self._basis_owed(c) + slashed = min(owed, p.bond, p.cap) + if slashed: + p.bond -= slashed + p.paid_to_buyer += slashed + p.restituted += slashed + p.note(f"restitution basis {c.vtc['liability']['restitution_basis']!r} slashes " + f"{pc.money(slashed)} from the bond on ABANDONED (Section 6)") + self._return_bond(c, "C1: the draft does not say; returned") + c.state = "ABANDONED" + self._attest(c, outcome="abandoned") + + def _basis_owed(self, c: Contract) -> int: + """Rank 3: the Buyer's loss, up to the basis, net of rank 1 (CHOICES C2).""" + p = c.pools + basis = c.vtc["liability"]["restitution_basis"] + ceiling = p.released if basis == "released" else pc.cents(c.vtc["price"]["amount"]) + loss = pc.cents(c.vtc["price"]["amount"]) - p.paid_to_buyer # escrow not yet back + return max(0, min(ceiling, loss)) + + def _return_bond(self, c: Contract, why: str) -> None: + # Section 7.6: returned at FINAL or SETTLED, less what 7.4 applied. + p = c.pools + if p.bond: + p.note(f"returned bond {pc.money(p.bond)} to seller ({why})") + p.bond_returned += p.bond + p.bond = 0 + if p.fund: + p.note(f"returned unspent verification fund {pc.money(p.fund)} to seller") + p.fund_returned += p.fund + p.fund = 0 + + # -- the challenge window, Section 7.5 -------------------------------- + def _close_window_if_due(self, c: Contract) -> None: + if c.state != "RELEASING" or c.window_opened_at is None: + return + if self.now() - c.window_opened_at < c.window_seconds: + return + c.pools.note(f"challenge window of {c.window_seconds}s closed with no " + f"successful Challenge") + self._return_bond(c, "Section 7.6, FINAL") + c.state = "FINAL" + self._attest(c, outcome="performed") + + def _lapse_dispute_if_due(self, c: Contract) -> None: + # CHOICES C5. The draft bounds a dispute by max_dispute_seconds and does + # not say what happens when the bound passes with no Verdict. + if c.state != "DISPUTED" or c.disputed_at is None or not c.max_dispute_seconds: + return + if self.now() - c.disputed_at < c.max_dispute_seconds: + return + c.pools.note(f"no Verdict within max_dispute_seconds {c.max_dispute_seconds}; " + f"the Challenge lapses and the earlier Verdict stands (C5)") + c.disputed_at = None + c.state = "RELEASING" + + # -- Submit Delivery, Section 12 -------------------------------------- + def submit_delivery(self, dlv: dict) -> tuple[int, dict]: + with self.lock: + hit = self._replay("delivery", dlv) + if hit is not None: + return hit + for member in ("vtc_id", "vtc_hash", "signature"): + if not isinstance(dlv.get(member), (str, dict)): + raise Refuse("schema-invalid", f"the Delivery lacks {member}") + c = self._contract(dlv["vtc_id"]) + self._tick(c) + if dlv["vtc_hash"] != c.digest(): + raise Refuse("object-conflict", "vtc_hash does not commit to this contract", + expected=c.digest(), received=dlv["vtc_hash"]) + if c.state != "FUNDED": + raise Refuse("wrong-state", f"contract {c.id} is {c.state}; a Delivery " + f"is only accepted in FUNDED") + + # Section 12: a Delivery not signed by the contract's Seller is + # rejected, and nobody else may sign it. + ok, why = pc.verify_object(dlv, self.resolver, MEDIA_DELIVERY, [c.seller]) + if not ok: + raise Refuse(_sig_kind(why), why) + + # Section 6: shape, not substance. Absent or nonconformant evidence + # is refused AND remedied as though a FAIL Verdict had been recorded, + # which is the rule the draft calls the one that makes silence + # expensive. Both halves are normative (line 832). + reason = self._delivery_nonconformance(c, dlv) + if reason is None: + # Shape of everything else: a schema failure inside `evidence` + # is nonconformance too; elsewhere it is a plain 13.2 refusal. + try: + self.schemas.check(dlv, "delivery.schema.json") + except Refuse as exc: + if str(exc.extra.get("path", "")).startswith("evidence"): + reason = exc.detail + else: + raise + if reason is not None: + c.pools.note(f"nonconformant Delivery: {reason}; applying Section 7.4 as " + f"though a FAIL Verdict were recorded") + self._record_keys(c, dlv) + self._apply_waterfall(c, challengers=[]) + raise Refuse("evidence-nonconformant", reason, state=c.state, + remedy="Section 7.4 applied as though FAIL") + + c.delivery = dlv + c.state = "DELIVERED" + self._record_keys(c, dlv) + self._remember("delivery", dlv, c.id) + return 202, self._with_state(c, dlv) + + def _delivery_nonconformance(self, c: Contract, dlv: dict) -> str | None: + ev = dlv.get("evidence") + ver = c.vtc["verification"] + if not isinstance(ev, dict): + return "the Delivery carries no evidence member" + if ev.get("profile") != ver["profile"]: + return (f"evidence profile {ev.get('profile')!r} does not match the " + f"contract's declared profile {ver['profile']!r}") + if ev.get("instrument_hash") != ver["criteria_hash"]: + return "the evidence does not commit to the instrument the contract committed to" + if "results_hash" not in ev: + return "the acceptance profile requires results_hash in the evidence" + if "results_uri" in ev and "results_hash" not in ev: + return "results_uri without a sibling results_hash (Section 5.1)" + if ver["tier"] == "T0-reexec" and "input_hash" not in dlv: + return ("input_hash is REQUIRED for a tier whose fraud proof re-executes " + "(Section 6)") + return None + + def _delivery_digest(self, c: Contract) -> str: + if c.delivery is None: + raise Refuse("no-recorded-delivery", + f"contract {c.id} has no recorded Delivery to judge") + return pc.digest_over(pc.hashable(c.delivery)) + + # -- Record Verdict, Section 12.4 ------------------------------------- + def record_verdict(self, verdict: dict) -> tuple[int, dict]: + with self.lock: + hit = self._replay("verdict", verdict) + if hit is not None: + return hit + self.schemas.check(verdict, "verdict.schema.json") + c = self._contract(verdict["vtc_id"]) + self._tick(c) + recorded = self._delivery_digest(c) + if verdict["delivery_hash"] != recorded: + raise Refuse("object-conflict", + "delivery_hash does not commit to the recorded Delivery", + expected=recorded, received=verdict["delivery_hash"]) + if c.state not in ("DELIVERED", "DISPUTED"): + raise Refuse("wrong-state", + f"contract {c.id} is {c.state}; a Verdict is accepted on " + f"DELIVERED, or on DISPUTED to resolve a Challenge") + + # A Verdict commits to the instrument it ran (Section 12.4). One + # over a different instrument or profile is the Section 16.3 + # substitution attack from the verifier's side. + ver = c.vtc["verification"] + if verdict["instrument_hash"] != ver["criteria_hash"]: + raise Refuse("verdict-nonconformant", + "instrument_hash is not the instrument the contract committed to", + expected=ver["criteria_hash"], received=verdict["instrument_hash"]) + if verdict["profile"] != ver["profile"]: + raise Refuse("verdict-nonconformant", + f"profile {verdict['profile']!r} is not the contract's " + f"{ver['profile']!r}") + if verdict["outcome"] not in ("PASS", "FAIL"): + raise Refuse("verdict-nonconformant", "outcome must be PASS or FAIL") + + kids = pc.signer_kids(verdict) + if not kids: + raise Refuse("signature-missing", "the Verdict carries no signature") + self._check_verdict_signer(c, kids) + named = c.vtc["parties"].get("verifier") + ok, why = pc.verify_object(verdict, self.resolver, MEDIA_VERDICT, + [named] if named else []) + if not ok: + raise Refuse(_sig_kind(why), why) + + # Every check passed; only now does state move. + c.verdicts.append(verdict) # Section 7.5: both are recorded + self._record_keys(c, verdict) + outcome = verdict["outcome"] + if c.state == "DELIVERED": + if outcome == "PASS": + self._release_on_pass(c) + else: + c.state = "DISPUTED" + self._apply_waterfall(c, challengers=[]) + else: # DISPUTED: this Verdict resolves the open Challenge(s) + c.disputed_at = None + if outcome == "FAIL": + c.pools.note("Challenge upheld: this Verdict supersedes the PASS") + self._apply_waterfall(c, challengers=list(c.challenges)) + else: + c.pools.note("Challenge rejected: the PASS stands; window resumes") + c.state = "RELEASING" + self._close_window_if_due(c) + self._remember("verdict", verdict, c.id) + return 201, self._with_state(c, verdict) + + def _check_verdict_signer(self, c: Contract, kids: list[str]) -> None: + # Draft line 1852: where the contract names parties.verifier the Verdict + # MUST be signed by that party; otherwise 9.1 is evaluated against the + # signer. Section 3: never the Facilitator. Section 7.5: never a + # Challenger judging its own Challenge. + named = c.vtc["parties"].get("verifier") + if named and not any(pc.kid_covers(k, named) for k in kids): + raise Refuse("verifier-not-independent", + "the contract names a verifier and the Verdict is not signed " + "by that party", signer=kids[0], required_verifier=named) + for kid in kids: + if pc.kid_covers(kid, c.seller): + raise Refuse("verifier-not-independent", + "the Verdict is signed by the contract's Seller", + signer=kid, seller=c.seller) + if pc.kid_covers(kid, c.buyer): + raise Refuse("verifier-not-independent", + "the Verdict is signed by the contract's Buyer", + signer=kid, buyer=c.buyer) + if pc.kid_covers(kid, self.identity): + raise Refuse("facilitator-cannot-verify", + "a Facilitator MUST NOT act as Verifier for a contract it " + "settles", signer=kid, facilitator=self.identity) + for ch in c.challenges: + for ck in pc.signer_kids(ch): + if c.state == "DISPUTED" and pc.same_party(ck, kid): + raise Refuse("verifier-not-independent", + "a Challenger's own assertion is not a Verdict " + "(Section 7.5)", signer=kid) + + def _release_on_pass(self, c: Contract) -> None: + # on-verification: the price releases on PASS, the window opens now, + # and the Bond and fund stay locked until it closes (draft lines + # 477 to 482). An earlier version returned the Bond here and reached + # FINAL in the same call, so no window ever opened and the Figure 6 + # path was unreachable in the only mode the draft requires. + p = c.pools + # Section 7.1: cumulative release before a Verdict never exceeds the + # Bond. Under on-verification nothing is released before the Verdict, + # so E is zero here by construction and the cap cannot bind. + p.paid_to_seller += p.escrow + p.released += p.escrow + p.note(f"released escrow {pc.money(p.escrow)} to seller on PASS; challenge " + f"window of {c.window_seconds}s opens") + p.escrow = 0 + c.state = "RELEASING" + c.window_opened_at = self.now() + + # -- Open challenge, Section 7.5 -------------------------------------- + def open_challenge(self, ch: dict) -> tuple[int, dict]: + with self.lock: + hit = self._replay("challenge", ch) + if hit is not None: + return hit + self.schemas.check(ch, "challenge.schema.json") + c = self._contract(ch["vtc_id"]) + self._tick(c) + recorded = self._delivery_digest(c) + if ch["delivery_hash"] != recorded: + raise Refuse("object-conflict", + "delivery_hash does not commit to the recorded Delivery", + expected=recorded, received=ch["delivery_hash"]) + if c.state not in ("RELEASING", "DISPUTED"): + raise Refuse("challenge-window-closed", + f"no challenge window is open: contract {c.id} is {c.state}, " + f"and under on-verification the window opens when a PASS " + f"is recorded and closes {c.window_seconds}s later", + state=c.state) + if c.window_opened_at is None or \ + self.now() - c.window_opened_at >= c.window_seconds: + raise Refuse("challenge-window-closed", + f"the {c.window_seconds}s challenge window has closed") + + # Section 7.5: a proof that does not conform to the profile is + # refused. Under the acceptance profile that means the committed + # instrument and a results digest. + proof = ch["proof"] + ver = c.vtc["verification"] + if proof.get("profile") != ver["profile"]: + raise Refuse("proof-nonconformant", + f"proof profile {proof.get('profile')!r} is not the " + f"contract's {ver['profile']!r}") + if proof.get("instrument_hash") != ver["criteria_hash"]: + raise Refuse("proof-nonconformant", + "the proof does not commit to the instrument the contract " + "committed to") + if "results_hash" not in proof: + raise Refuse("proof-nonconformant", + "the acceptance profile requires results_hash in the proof") + + ok, why = pc.verify_object(ch, self.resolver, MEDIA_CHALLENGE, []) + if not ok: + raise Refuse(_sig_kind(why), why) + kids = pc.signer_kids(ch) + if any(pc.kid_covers(k, c.seller) for k in kids): + raise Refuse("unexpected-signer", "a Seller cannot challenge its own " + "Delivery", signer=kids[0]) + + # A Challenge is a fraud proof submitted for evaluation, not a + # finding. It moves the contract to DISPUTED and nothing else; the + # Verdict that resolves it comes from an independent party. + c.challenges.append(ch) + if c.state != "DISPUTED": + c.disputed_at = self.now() + c.state = "DISPUTED" + c.pools.note(f"challenge accepted from {kids[0]}; awaiting a Verdict from " + f"an independent evaluator ({len(c.challenges)} open)") + self._record_keys(c, ch) + self._remember("challenge", ch, c.id) + return 202, self._with_state(c, ch) + + # -- Section 7.4, the five ranks in order ------------------------------ + def _apply_waterfall(self, c: Contract, challengers: list[dict]) -> None: + p = c.pools + moved_from_seller = 0 + + # 1. Reverse any unreleased escrow to the Buyer. + if p.escrow: + p.paid_to_buyer += p.escrow + p.note(f"rank 1: reversed unreleased escrow {pc.money(p.escrow)} to buyer") + p.escrow = 0 + + # 2. Reimburse the successful Challenger's documented costs FROM THE + # VERIFICATION FUND. The Challenge object has no member in which to + # document them (a -02 item), so this is 0.00 (CHOICES C4). + if challengers: + p.note("rank 2: the Challenge carries no cost claim; reimbursed 0.00 from " + "the verification fund (C4)") + + # 3. Restore the Buyer from the Bond, up to restitution_basis, net of + # what rank 1 already returned (CHOICES C2), and never beyond cap. + owed = self._basis_owed(c) + restitution = min(owed, p.bond, p.cap - moved_from_seller) + if restitution: + p.bond -= restitution + p.paid_to_buyer += restitution + p.restituted += restitution + moved_from_seller += restitution + p.note(f"rank 3: restitution basis {c.vtc['liability']['restitution_basis']!r}, " + f"buyer's loss {pc.money(owed)}, paid {pc.money(restitution)} from bond") + + # 4. The Challenger bounty from the remaining Bond: only to a + # Challenger that exists, the whole remainder, split equally among + # successful Challengers (CHOICES C3). + if challengers and p.bond: + pool = min(p.bond, p.cap - moved_from_seller) + share = pool // len(challengers) + paid = share * len(challengers) + p.bond -= paid + p.paid_to_challenger += paid + moved_from_seller += paid + p.note(f"rank 4: bounty {pc.money(paid)} from the remaining bond to " + f"{len(challengers)} challenger(s), {pc.money(share)} each (C3)") + elif not challengers: + p.note("rank 4: no Challenger, no bounty") + + # 5. Direct any remainder per liability.remainder_to, within cap. + remainder_to = c.vtc["liability"].get("remainder_to", "sink") + if p.bond: + movable = min(p.bond, p.cap - moved_from_seller) + if movable: + if remainder_to == "buyer": + p.paid_to_buyer += movable + else: + p.remainder += movable + p.bond -= movable + moved_from_seller += movable + p.note(f"rank 5: remainder {pc.money(movable)} directed to {remainder_to}") + if p.bond: + p.note(f"liability.cap reached: {pc.money(p.bond)} of the bond is not " + f"the Facilitator's to move and is returned") + + self._return_bond(c, "Section 7.6, SETTLED") + c.state = "SETTLED" + self._attest(c, outcome="slashed") + + # -- Section 11, the Work Attestation --------------------------------- + def _attest(self, c: Contract, outcome: str) -> None: + """Issued for every terminal contract, signed by the Facilitator alone. + + Under the -00 a slashed Seller simply declined to co-sign its own + conviction, which made the reputation layer structurally incapable of + recording a negative outcome. The Seller does not consent to this + record and its consent is not required. + """ + p = c.pools + att = { + "pact": "0.1", + "type": "WorkAttestation", + "vtc_id": c.id, + "vtc_hash": c.digest(), + "parties": {"buyer": c.buyer, "seller": c.seller, "facilitator": self.identity}, + "subject": c.seller, + "role": "seller", + "outcome": outcome, + "amounts": { + "settled": pc.money(p.paid_to_seller), + # Restitution is what the Buyer recovered FROM THE BOND. Escrow + # coming back is the Buyer's own money and is not restitution. + "restituted": pc.money(p.restituted), + "slashed": pc.money(p.bond_initial - p.bond_returned - p.bond), + "currency": c.vtc["price"]["currency"], + }, + "opened_at": iso(c.created_at), + "settled_at": iso(self.now()), + } + if c.delivery is not None: + att["work_hash"] = c.delivery["work_hash"] + att["signatures"] = [pc.sign(att, self.key, MEDIA_ATTESTATION)] + self.schemas.check(att, "attestation.schema.json") + c.attestation = att + + def get_attestation(self, vid: str) -> tuple[int, dict]: + with self.lock: + c = self._contract(vid) + self._tick(c) + if c.attestation is None: + raise Refuse("wrong-state", + f"contract {vid} is {c.state} and not terminal, so no " + f"attestation exists yet") + return 200, c.attestation + + # -- Section 8 ----------------------------------------------------------- + def capability_document(self) -> dict: + doc = { + "pact": "0.1", + "facilitator": self.identity, + "settlement_bindings": self.settlement_bindings, + "release_modes": self.release_modes, + "verification_profiles": self.verification_profiles, + "assurance_modes": self.assurance_modes, + "max_contract_value": self.max_contract_value, + "endpoints": { + "contract": self.base_url + "/pact/v1/contracts", + "delivery": self.base_url + "/pact/v1/deliveries", + "verdict": self.base_url + "/pact/v1/verdicts", + "challenge": self.base_url + "/pact/v1/challenges", + "attestation": self.base_url + "/pact/v1/attestations", + }, + # no challenge_deposit member: absent means none is required + } + doc["signature"] = pc.sign(doc, self.key, MEDIA_FACILITATOR) + self.schemas.check(doc, "facilitator.schema.json") + return doc + + +def _sig_kind(why: str) -> str: + return "algorithm-not-permitted" if why.startswith("algorithm") else "signature-invalid" + + +# -------------------------------------------------------------------------- +# HTTP +# -------------------------------------------------------------------------- + +ROUTES = { + "contracts": "propose", + "deliveries": "submit_delivery", + "verdicts": "record_verdict", + "challenges": "open_challenge", +} + +MEDIA = { + "propose": MEDIA_CONTRACT, + "submit_delivery": MEDIA_DELIVERY, + "record_verdict": MEDIA_VERDICT, + "open_challenge": MEDIA_CHALLENGE, +} + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + server_version = "pact-reference-facilitator/0.2" + + @property + def fac(self) -> Facilitator: + return self.server.facilitator # type: ignore[attr-defined] + + def log_message(self, fmt: str, *args: Any) -> None: + if getattr(self.server, "verbose", False): # type: ignore[attr-defined] + super().log_message(fmt, *args) + + def _send(self, status: int, body: dict, content_type: str) -> None: + raw = json.dumps(body, separators=(",", ":")).encode() + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + self.fac.message_count += 1 + + def _problem(self, exc: Refuse) -> None: + status, section = PROBLEMS.get(exc.kind, (400, "Section 12.3")) + body = { + "type": PROBLEM_BASE + exc.kind, + "title": exc.kind.replace("-", " "), + "status": status, + "detail": exc.detail, + "section": section.split(" ", 1)[1], # draft Figure 12: "7.2", not "Section 7.2" + } + body.update(exc.extra) + self._send(status, body, "application/problem+json") + + def _guard(self, fn) -> None: + try: + fn() + except Refuse as exc: + self._problem(exc) + except Exception as exc: # pragma: no cover + # Never a 409 dressed as a rule, and never the traceback: a caller + # cannot locate a Python exception in the specification. + self._problem(Refuse("internal-error", + f"the Facilitator failed internally ({type(exc).__name__})")) + + def do_GET(self) -> None: + def run() -> None: + if self.path == "/.well-known/pact-facilitator": + self._send(200, self.fac.capability_document(), MEDIA_FACILITATOR) + return + m = re.match(r"^/pact/v1/(contracts|attestations)/([^/]+)$", self.path) + if not m: + raise Refuse("unknown-contract", f"no route for {self.path}") + kind, vid = m.groups() + if kind == "contracts": + status, body = self.fac.get_contract(vid) + self._send(status, body, MEDIA_CONTRACT) + else: + status, body = self.fac.get_attestation(vid) + self._send(status, body, MEDIA_ATTESTATION) + self._guard(run) + + def do_POST(self) -> None: + def run() -> None: + m = re.match(r"^/pact/v1/(contracts|deliveries|verdicts|challenges)$", self.path) + if not m: + raise Refuse("unknown-contract", f"no route for {self.path}") + op = ROUTES[m.group(1)] + length = int(self.headers.get("Content-Length", 0)) + if length > MAX_BODY: + raise Refuse("payload-too-large", f"body exceeds {MAX_BODY} bytes") + try: + obj = json.loads(self.rfile.read(length) or b"{}") + except ValueError: + raise Refuse("schema-invalid", "body is not JSON") + if not isinstance(obj, dict): + raise Refuse("schema-invalid", "body is not a JSON object") + status, body = getattr(self.fac, op)(obj) + self._send(status, body, MEDIA[op]) + self._guard(run) + + +def serve(facilitator: Facilitator, port: int = 8402, + verbose: bool = False) -> ThreadingHTTPServer: + httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler) + httpd.facilitator = facilitator # type: ignore[attr-defined] + httpd.verbose = verbose # type: ignore[attr-defined] + return httpd + + +def build(identity: str = "did:web:settle.example", now: Any = time.time, + base_url: str = "http://127.0.0.1:8402") -> tuple[Facilitator, pc.KeyResolver]: + resolver = pc.KeyResolver() + key = resolver.register(pc.Key.generate(identity + "#key-1")) + return Facilitator(identity, key, resolver, now=now, base_url=base_url), resolver + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--port", type=int, default=8402) + ap.add_argument("--verbose", action="store_true") + ap.add_argument("--rules", action="store_true", + help="print the rules this implementation enforces, and its choices, and exit") + args = ap.parse_args() + if args.rules: + print(RULES.strip()) + print() + print(CHOICES.strip()) + return + fac, _ = build(base_url=f"http://127.0.0.1:{args.port}") + httpd = serve(fac, args.port, args.verbose) + print(f"reference Facilitator {fac.identity} on http://127.0.0.1:{args.port}") + print(f"capability document at http://127.0.0.1:{args.port}/.well-known/pact-facilitator") + httpd.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/tools/measure.py b/tools/measure.py new file mode 100644 index 0000000..c07c1c9 --- /dev/null +++ b/tools/measure.py @@ -0,0 +1,604 @@ +"""Drive the reference pair through every terminal state and report what it costs. + +Four contracts, because FINAL, SETTLED and ABANDONED are mutually exclusive +branches of Figure 2 and one contract cannot reach all three, and because the +Figure 6 path (a Challenge overturns a PASS) is the one that exercises the +restitution basis. Each is minted fresh with real Ed25519 keys, validated +against the published schemas, settled over HTTP against tools/facilitator.py, +and its attestation verified by the party that receives it. + +Time is the Facilitator's clock (draft line 480), and here that clock is a +counter the harness advances, so the challenge window and the deadline are +exercised deterministically rather than waited for. + +Then the refusals, because a settlement service is defined as much by what it +refuses as by what it accepts, each one driving the rule it is named for, and +then microbenchmarks for the operations that scale with traffic. + + python3 tools/measure.py # human readable + python3 tools/measure.py --json # machine readable + +Every number is produced on the machine that runs this, and the report names +that machine. Numbers quoted anywhere else should carry the same hardware line. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import platform +import statistics +import subprocess +import sys +import threading +import time + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +import agents +import facilitator as fac_mod +import pactcore as pc + +ROOT = pathlib.Path(__file__).resolve().parent.parent +BUYER = "did:web:buyer.example:agents:procure-1" +SELLER = "did:web:dataforge.example:agents:etl-3" +VERIFIER = "did:web:audit.example" +FACILITATOR = "did:web:settle.example" +WINDOW = 3600 +DISPUTE = 86400 + +REPORT: dict = {"scenarios": {}, "refusals": {}, "acceptances": {}, "micro": {}, + "capability_document": {}, "environment": {}} + + +def _cpu_name() -> str: + """platform.processor() returns "i386" on macOS, which tells a reader nothing.""" + try: + out = subprocess.run(["sysctl", "-n", "machdep.cpu.brand_string"], + capture_output=True, text=True, timeout=5) + if out.returncode == 0 and out.stdout.strip(): + return out.stdout.strip() + except Exception: + pass + return platform.processor() or platform.machine() + + +_SCHEMAS = None + + +def _schema_check(obj: dict, schema_file: str) -> str: + """Validate a minted object against the published schema. Loaded once, and + never inside a timed region.""" + global _SCHEMAS + if _SCHEMAS is None: + _SCHEMAS = fac_mod.Schemas() + try: + _SCHEMAS.check(obj, schema_file) + except fac_mod.Refuse as exc: + return f"INVALID: {exc.detail}" + return "valid" + + +class Clock: + """The Facilitator's clock, advanced by the harness.""" + def __init__(self) -> None: + self.t = time.time() + + def __call__(self) -> float: + return self.t + + def advance(self, seconds: float) -> None: + self.t += seconds + + def at(self, seconds_ahead: float) -> str: + return fac_mod.iso(self.t + seconds_ahead) + + +class Harness: + def __init__(self, port: int) -> None: + self.clock = Clock() + base = f"http://127.0.0.1:{port}" + self.fac, self.resolver = fac_mod.build(FACILITATOR, now=self.clock, base_url=base) + self.httpd = fac_mod.serve(self.fac, port) + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + self.client = agents.Client(base) + self.buyer = agents.make_party(BUYER, self.resolver, self.client) + self.seller = agents.make_party(SELLER, self.resolver, self.client) + self.verifier = agents.make_party(VERIFIER, self.resolver, self.client) + + def stop(self) -> None: + self.httpd.shutdown() + self.httpd.server_close() + + def fresh(self, vid: str, verifier: str | None = VERIFIER, **kw) -> dict: + kw.setdefault("deadline", self.clock.at(7 * 86400)) + vtc = agents.draft_contract(vid, BUYER, SELLER, FACILITATOR, verifier, **kw) + return agents.cosign(vtc, self.buyer, self.seller) + + +def conservation(c) -> dict: + """Every cent that went in is somewhere it can be named, at a terminal state. + + This is a ledger identity over the Facilitator's own pools, which is what + an in-memory implementation can check. It is asserted, not merely + computed: a scenario whose money does not balance fails the run. + """ + p = c.pools + put_in = p.bond_initial + pc.cents(c.vtc["price"]["amount"]) + \ + pc.cents(c.vtc["liability"]["verification_fund"]) + accounted = (p.paid_to_buyer + p.paid_to_seller + p.paid_to_challenger + + p.remainder + p.bond_returned + p.fund_returned + + p.escrow + p.bond + p.fund) + out = {"in": pc.money(put_in), "accounted": pc.money(accounted), + "balanced": put_in == accounted} + assert out["balanced"], f"money does not balance for {c.id}: {out}" + return out + + +def scenario(h: Harness, name: str, run) -> dict: + before = len(h.client.wire) + t0 = time.perf_counter() + detail = run() + elapsed = time.perf_counter() - t0 + wire = h.client.wire[before:] + out = { + "terminal_state": detail["state"], + "messages": len(wire), + "request_bytes": sum(w.request_bytes for w in wire), + "response_bytes": sum(w.response_bytes for w in wire), + "wall_ms": round(elapsed * 1000, 2), + "per_message": [ + {"op": f"{w.method} {w.path}", "status": w.status, + "req": w.request_bytes, "resp": w.response_bytes, + "ms": round(w.seconds * 1000, 2)} for w in wire], + } + c = detail.pop("contract") + att = c.attestation + ok, why = agents.check_attestation(att, h.resolver, FACILITATOR) + out["attestation_verifies"] = ok + out["attestation_reason"] = why + out["amounts"] = att["amounts"] + out["ledger"] = c.pools.ledger + out["money"] = conservation(c) + out["schema"] = {k: _schema_check(v, f) for k, (v, f) in detail.pop("objects", {}).items()} + out.update({k: v for k, v in detail.items() if k != "state"}) + REPORT["scenarios"][name] = out + return out + + +# -------------------------------------------------------------------------- +# The terminal states +# -------------------------------------------------------------------------- + +def run_final(h: Harness) -> dict: + """PASS, window closes with no Challenge, FINAL. Four exchanges.""" + vtc = h.fresh("vtc_final_01") + st, _ = h.client.propose(vtc) + assert st == 201, st + dlv = agents.make_delivery(vtc, h.seller, b"the delivered bytes", b"results") + st, _ = h.client.deliver(dlv) + assert st == 202, st + vd = agents.make_verdict(vtc, dlv, h.verifier, "PASS") + st, body = h.client.verdict(vd) + assert st == 201 and body["state"] == "RELEASING", (st, body.get("state")) + h.clock.advance(WINDOW + 1) # the window closes on the Facilitator's clock + st, att = h.client.attestation(vtc["id"]) # the GET is what notices it + assert st == 200, att + c = h.fac.contracts[vtc["id"]] + return {"state": c.state, "contract": c, + "objects": {"contract": (vtc, "vtc.schema.json"), + "delivery": (dlv, "delivery.schema.json"), + "verdict": (vd, "verdict.schema.json"), + "attestation": (att, "attestation.schema.json")}} + + +def run_settled(h: Harness) -> dict: + """The verifier records FAIL: DISPUTED, remedy, SETTLED. Four exchanges.""" + vtc = h.fresh("vtc_settled_01") + h.client.propose(vtc) + dlv = agents.make_delivery(vtc, h.seller, b"plausible but wrong", b"bad results") + h.client.deliver(dlv) + st, body = h.client.verdict(agents.make_verdict(vtc, dlv, h.verifier, "FAIL")) + assert st == 201 and body["state"] == "SETTLED", (st, body.get("state")) + st, att = h.client.attestation(vtc["id"]) + c = h.fac.contracts[vtc["id"]] + return {"state": c.state, "contract": c, + "buyer_recovered": pc.money(c.pools.paid_to_buyer), + "seller_received": pc.money(c.pools.paid_to_seller)} + + +def run_abandoned(h: Harness) -> dict: + """Signed, funded, never delivered: the deadline passes. Three exchanges.""" + vtc = h.fresh("vtc_abandoned_01", deadline=h.clock.at(3600)) + st, _ = h.client.propose(vtc) + assert st == 201, st + h.clock.advance(3601) + st, body = h.client.contract(vtc["id"]) # the GET is what notices the expiry + assert st == 200 and body["state"] == "ABANDONED", (st, body.get("state")) + st, att = h.client.attestation(vtc["id"]) + c = h.fac.contracts[vtc["id"]] + return {"state": c.state, "contract": c, + "buyer_recovered": pc.money(c.pools.paid_to_buyer), + "bond_returned_to_seller": pc.money(c.pools.bond_returned)} + + +def run_overturned(h: Harness) -> dict: + """Figure 6: PASS, the price releases, the Buyer challenges inside the + window, an independent Verdict upholds the Challenge, the waterfall runs + with the price already gone. Five exchanges. This is the path where the + restitution basis does any work.""" + vtc = h.fresh("vtc_overturned_01") + h.client.propose(vtc) + dlv = agents.make_delivery(vtc, h.seller, b"looked fine at first", b"results") + h.client.deliver(dlv) + st, body = h.client.verdict(agents.make_verdict(vtc, dlv, h.verifier, "PASS")) + assert st == 201 and body["state"] == "RELEASING", (st, body.get("state")) + h.clock.advance(600) + ch = agents.make_challenge(vtc, dlv, h.buyer, ["row_count_min"]) + st, body = h.client.challenge(ch) + assert st == 202 and body["state"] == "DISPUTED", (st, body) + st, body = h.client.verdict(agents.make_verdict(vtc, dlv, h.verifier, "FAIL")) + assert st == 201 and body["state"] == "SETTLED", (st, body) + st, att = h.client.attestation(vtc["id"]) + c = h.fac.contracts[vtc["id"]] + return {"state": c.state, "contract": c, + "released_before_failure": pc.money(c.pools.released), + "buyer_recovered_from_bond": pc.money(c.pools.restituted), + "challenger_bounty": pc.money(c.pools.paid_to_challenger), + "objects": {"challenge": (ch, "challenge.schema.json")}} + + +def run_settled_price(h: Harness) -> dict: + """The pre-release FAIL again, with restitution_basis "price". Under the + net-of-loss reading (facilitator.py CHOICES C2) the Buyer's loss is zero + after rank 1 either way, so the basis changes nothing here; it only + matters once value has been released, which is run_overturned.""" + vtc = h.fresh("vtc_settled_price", restitution_basis="price") + h.client.propose(vtc) + dlv = agents.make_delivery(vtc, h.seller, b"plausible but wrong", b"bad") + h.client.deliver(dlv) + h.client.verdict(agents.make_verdict(vtc, dlv, h.verifier, "FAIL")) + h.client.attestation(vtc["id"]) + c = h.fac.contracts[vtc["id"]] + return {"state": c.state, "contract": c, + "buyer_recovered": pc.money(c.pools.paid_to_buyer), + "from_bond": pc.money(c.pools.restituted)} + + +# -------------------------------------------------------------------------- +# Refusals. What a settlement service will not do, each driving the rule it +# is named for. Two acceptances are reported separately because they are +# acceptances, not refusals, and counting them as refusals was a lie. +# -------------------------------------------------------------------------- + +def run_refusals(h: Harness) -> None: + def record(name: str, status: int, body: dict) -> None: + REPORT["refusals"][name] = { + "status": status, + "type": body.get("type", "").rsplit("/", 1)[-1], + "section": body.get("section"), + "detail": body.get("detail", "")[:200], + } + + def accept(name: str, status: int, state: str, section: str, detail: str) -> None: + REPORT["acceptances"][name] = {"status": status, "state": state, + "section": section, "detail": detail} + + def delivered(vid: str, **kw) -> tuple[dict, dict]: + vtc = h.fresh(vid, **kw) + h.client.propose(vtc) + dlv = agents.make_delivery(vtc, h.seller, b"w", b"r") + h.client.deliver(dlv) + return vtc, dlv + + # Section 7.2, before funds lock: 18.00 against q_min 0.90 needs 20.00. + record("bond_below_constraint", *h.client.propose(h.fresh("r_bond", q_min=0.90))) + + # Section 13.2: schema conformance at propose (a zero window). + bad = agents.draft_contract("r_schema", BUYER, SELLER, FACILITATOR, VERIFIER, + deadline=h.clock.at(86400)) + bad["challenge"]["window_seconds"] = 0 + record("schema_invalid_zero_window", *h.client.propose(agents.cosign(bad, h.buyer, h.seller))) + + # Section 13.2 / Table 9: buyer and seller the same party after normalization. + same = agents.draft_contract("r_same", BUYER, BUYER + "/", FACILITATOR, VERIFIER, + deadline=h.clock.at(86400)) + record("buyer_equals_seller", *h.client.propose(agents.cosign(same, h.buyer, h.buyer))) + + # Section 16.7: a contract naming another Facilitator is a replayable instrument. + other = agents.draft_contract("r_venue", BUYER, SELLER, "did:web:elsewhere.example", + VERIFIER, deadline=h.clock.at(86400)) + record("contract_names_other_facilitator", *h.client.propose(agents.cosign(other, h.buyer, h.seller))) + + # Section 7.3 / 8: a release mode this Facilitator does not advertise. + record("release_mode_not_advertised", + *h.client.propose(h.fresh("r_mode", release="on-window"))) + + # Section 9.1 at propose: the Seller named as its own verifier. + record("named_verifier_is_seller", + *h.client.propose(h.fresh("r_selfnamed", verifier=SELLER))) + + # Section 10.1: subcontracts are not implemented and are refused, not ignored. + child = agents.draft_contract("r_child", BUYER, SELLER, FACILITATOR, VERIFIER, + deadline=h.clock.at(86400)) + child["liability"]["parent"] = {"vtc_id": "vtc_nowhere", "vtc_hash": pc.h(b"x")} + record("subcontract_refused", *h.client.propose(agents.cosign(child, h.buyer, h.seller))) + + # Section 5: a third party's signature on a co-signed contract. + third = agents.make_party("did:web:bystander.example", h.resolver, h.client) + extra = h.fresh("r_thirdsig") + extra["signatures"].append(pc.sign({k: v for k, v in extra.items() if k != "signatures"}, + third.key, agents.MEDIA_CONTRACT)) + record("third_party_signature", *h.client.propose(extra)) + + # Section 13.1 / Table 9: alg none, with a correct typ so only the allowlist fires. + tampered = h.fresh("r_alg") + prot = pc.b64u(pc.jcs({"alg": "none", "kid": h.buyer.key.kid, + "typ": agents.MEDIA_CONTRACT})) + tampered["signatures"][0]["protected"] = prot + record("algorithm_none", *h.client.propose(tampered)) + + # Section 12.2: the same id with different bytes is 409. + vtc3 = h.fresh("r_idem") + h.client.propose(vtc3) + altered = json.loads(json.dumps(vtc3)) + altered["liability"]["seller_bond"] = "19.00" + altered = agents.cosign({k: v for k, v in altered.items() if k != "signatures"}, + h.buyer, h.seller) + record("altered_contract_same_id", *h.client.propose(altered)) + + # Section 12: a Delivery from a look-alike of the Seller's identifier. + evil = agents.make_party(SELLER + ".evil", h.resolver, h.client) + vtc7 = h.fresh("r_prefix") + h.client.propose(vtc7) + record("delivery_by_prefix_lookalike", + *h.client.deliver(agents.make_delivery(vtc7, evil, b"w", b"r"))) + + # Section 6: a Delivery must carry evidence conformant to the profile. + vtc5 = h.fresh("r_noevidence") + h.client.propose(vtc5) + d5 = agents.make_delivery(vtc5, h.seller, b"w", b"r") + del d5["evidence"] + record("delivery_without_evidence", + *h.client.deliver(h.seller.sign_into(d5, agents.MEDIA_DELIVERY))) + + # Section 6: input_hash is REQUIRED where the tier re-executes. + vtc8 = h.fresh("r_noinput") + h.client.propose(vtc8) + d8 = agents.make_delivery(vtc8, h.seller, b"w", b"r") + del d8["input_hash"] + record("delivery_without_input_hash", + *h.client.deliver(h.seller.sign_into(d8, agents.MEDIA_DELIVERY))) + + # Section 12.4: no Verdict without a recorded Delivery. + vtc2 = h.fresh("r_nodelivery") + h.client.propose(vtc2) + _, some_dlv = delivered("r_donor") + record("verdict_without_delivery", + *h.client.verdict(agents.make_verdict(vtc2, some_dlv, h.verifier, "PASS"))) + + # Section 9.1, DERIVED: no verifier named, and the Seller signs the Verdict. + vtc_s, d_s = delivered("r_selfverify", verifier=None) + record("verdict_signed_by_seller", + *h.client.verdict(agents.make_verdict(vtc_s, d_s, h.seller, "PASS"))) + + # Section 3: no verifier named, and the Facilitator signs the Verdict. + fac_party = agents.Party(FACILITATOR, h.fac.key, h.client) + record("verdict_signed_by_facilitator", + *h.client.verdict(agents.make_verdict(vtc_s, d_s, fac_party, "PASS"))) + + # Draft line 1852: the contract names a verifier, so only that party judges. + stranger = agents.make_party("did:web:watchdog.example", h.resolver, h.client) + vtc4, d4 = delivered("r_stranger") + record("verdict_by_unnamed_party", + *h.client.verdict(agents.make_verdict(vtc4, d4, stranger, "PASS"))) + + # Section 12.4: a Verdict over a different instrument. + wrong_inst = agents.make_verdict(vtc4, d4, h.verifier, "PASS") + wrong_inst["instrument_hash"] = pc.h(b"some other instrument") + wrong_inst["signature"] = pc.sign({k: v for k, v in wrong_inst.items() if k != "signature"}, + h.verifier.key, agents.MEDIA_VERDICT) + record("verdict_over_other_instrument", *h.client.verdict(wrong_inst)) + + # RFC 8725 3.11 and vector V-05: typ carries the full media type. + wrong_typ = agents.make_verdict(vtc4, d4, h.verifier, "PASS") + wrong_typ["signature"] = pc.sign({k: v for k, v in wrong_typ.items() if k != "signature"}, + h.verifier.key, agents.MEDIA_DELIVERY) + record("verdict_signed_with_delivery_typ", *h.client.verdict(wrong_typ)) + + # Section 7.5: a Challenge before any window is open. + record("challenge_before_window", + *h.client.challenge(agents.make_challenge(vtc4, d4, h.buyer, ["rows"]))) + + # Now a PASS, so the window opens; then the two window-related refusals + # and the acceptance that matters most. + h.client.verdict(agents.make_verdict(vtc4, d4, h.verifier, "PASS")) + bad_proof = agents.make_challenge(vtc4, d4, h.buyer, ["rows"]) + bad_proof["proof"]["instrument_hash"] = pc.h(b"not the committed instrument") + bad_proof["signature"] = pc.sign({k: v for k, v in bad_proof.items() if k != "signature"}, + h.buyer.key, agents.MEDIA_CHALLENGE) + record("challenge_proof_nonconformant", *h.client.challenge(bad_proof)) + + st, body = h.client.challenge(agents.make_challenge(vtc4, d4, stranger, ["rows"])) + c4 = h.fac.contracts["r_stranger"] + accept("challenge_alone_does_not_settle", st, body.get("state", "?"), "7.5", + f"bond still locked: {pc.money(c4.pools.bond)} of " + f"{pc.money(c4.pools.bond_initial)}; attestation issued: {c4.attestation is not None}") + + # Section 7.5: the Challenger's own assertion is not a Verdict. + record("verdict_by_the_challenger", + *h.client.verdict(agents.make_verdict(vtc4, d4, stranger, "FAIL"))) + + # A second PASS on a fresh contract, then a Challenge after the window. + vtc9, d9 = delivered("r_late") + h.client.verdict(agents.make_verdict(vtc9, d9, h.verifier, "PASS")) + h.clock.advance(WINDOW + 1) + record("challenge_after_window", + *h.client.challenge(agents.make_challenge(vtc9, d9, h.buyer, ["rows"]))) + + # Section 12: a Verdict on a contract that is already FINAL. + record("verdict_after_final", + *h.client.verdict(agents.make_verdict(vtc9, d9, h.verifier, "FAIL"))) + + # Section 12.2: the same body twice is the same resource, 200 not 409. + st, body = h.client.propose(vtc3) + accept("resubmit_identical_contract", st, body.get("state", "?"), "12.2", + "the current resource, not a snapshot") + + +# -------------------------------------------------------------------------- +# Capability document, Section 8 +# -------------------------------------------------------------------------- + +def run_capability(h: Harness) -> None: + st, doc = h.client.capability() + ok, why = pc.verify_object(doc, h.resolver, fac_mod.MEDIA_FACILITATOR, [FACILITATOR]) + REPORT["capability_document"] = { + "status": st, + "schema": _schema_check(doc, "facilitator.schema.json"), + "signature_verifies": ok, + "release_modes": doc.get("release_modes"), + "settlement_bindings": doc.get("settlement_bindings"), + } + + +# -------------------------------------------------------------------------- +# Microbenchmarks +# -------------------------------------------------------------------------- + +def bench(fn, n: int = 2000) -> dict: + for _ in range(50): + fn() + samples = [] + for _ in range(7): + t0 = time.perf_counter() + for _ in range(n): + fn() + samples.append((time.perf_counter() - t0) / n * 1e6) + return {"median_us": round(statistics.median(samples), 2), + "min_us": round(min(samples), 2), "calls": n} + + +def run_micro(h: Harness) -> None: + vtc = h.fresh("vtc_bench") + dlv = agents.make_delivery(vtc, h.seller, b"w" * 4096, b"r" * 1024) + key = h.buyer.key + + # Labels say what is timed. "sign" and "verify" both include canonicalizing + # the contract to rebuild the detached payload, because that is what a + # Facilitator does per signature; the raw Ed25519 primitive is a fraction + # of each and is reported on its own so the reader can subtract. + REPORT["micro"]["canonicalize_contract"] = bench(lambda: pc.jcs(vtc)) + REPORT["micro"]["canonicalize_and_digest_contract"] = bench( + lambda: pc.digest_over(pc.hashable(vtc))) + REPORT["micro"]["sign_contract_incl_canonicalization"] = bench( + lambda: pc.sign(vtc, key, agents.MEDIA_CONTRACT), n=500) + entry = pc.sign(vtc, key, agents.MEDIA_CONTRACT) + REPORT["micro"]["verify_contract_signature_end_to_end"] = bench( + lambda: pc.verify_entry(vtc, entry, h.resolver), n=500) + msg = b"x" * 1500 + sig = key.sign_bytes(msg) + REPORT["micro"]["ed25519_sign_primitive_1500B"] = bench(lambda: key.sign_bytes(msg), n=500) + REPORT["micro"]["ed25519_verify_primitive_1500B"] = bench( + lambda: key.verify_bytes(sig, msg), n=500) + REPORT["micro"]["normalize_identifier"] = bench(lambda: pc.norm(SELLER)) + REPORT["micro"]["assurance_constraint_exact_decimal"] = bench( + lambda: pc.assurance_holds("180.00", "18.00", "0.9091", "0")) + + # RFC 9162 root over N leaves, pactcore.mth. The Facilitator does not build + # trees (subcontracts are not implemented); this is the primitive a + # Section 10 implementation would call per parent attestation. + for n in (2, 8, 64): + leaves = [pc.jcs({"child": i}) for i in range(n)] + REPORT["micro"][f"rfc9162_root_{n}_leaves"] = bench( + lambda leaves=leaves: pc.mth(leaves), n=200 if n > 8 else 1000) + + REPORT["micro"]["canonical_bytes"] = { + "contract": len(pc.jcs(pc.hashable(vtc))), + "delivery": len(pc.jcs(pc.hashable(dlv))), + } + # There is deliberately NO T0-reexec figure. An earlier version timed + # examples/acceptance-harness/test_acceptance.py by running it as a plain + # script, which executes no tests at all; the number was pytest's import + # time. An honest figure needs the harness invoked through pytest, which + # currently fails because pytest_addoption sits in a test module rather + # than a conftest.py, and moving it changes criteria_hash: a -02 item. + + +# -------------------------------------------------------------------------- + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--port", type=int, default=8412) + ap.add_argument("--json", action="store_true") + args = ap.parse_args() + + REPORT["environment"] = { + "python": platform.python_version(), + "platform": platform.platform(), + "processor": _cpu_name(), + "signature_algorithm": "Ed25519 (EdDSA, RFC 8037)", + "note": "single host, loopback HTTP, in-memory store, no payment rail; the " + "Facilitator's clock is advanced by the harness", + } + + h = Harness(args.port) + try: + _schema_check({}, "vtc.schema.json") # load schemas outside any timed region + run_capability(h) + scenario(h, "FINAL", lambda: run_final(h)) + scenario(h, "SETTLED", lambda: run_settled(h)) + scenario(h, "ABANDONED", lambda: run_abandoned(h)) + scenario(h, "OVERTURNED_PASS", lambda: run_overturned(h)) + scenario(h, "SETTLED_basis_price", lambda: run_settled_price(h)) + run_refusals(h) + run_micro(h) + finally: + h.stop() + + if args.json: + print(json.dumps(REPORT, indent=1)) + return + + e = REPORT["environment"] + print(f"reference pair on {e['processor']}, Python {e['python']}, {e['signature_algorithm']}") + print(f"{e['note']}\n") + + cd = REPORT["capability_document"] + print(f"CAPABILITY DOCUMENT status {cd['status']}, schema {cd['schema']}, " + f"signature verifies: {cd['signature_verifies']}\n") + + print("TERMINAL STATES") + for name, s in REPORT["scenarios"].items(): + print(f" {name:<20} {s['messages']} messages, {s['request_bytes']}B out / " + f"{s['response_bytes']}B back, {s['wall_ms']}ms, attestation verifies: " + f"{s['attestation_verifies']}, money balanced: {s['money']['balanced']}") + print(f" amounts {s['amounts']}") + bad = [k for k, v in s.get("schema", {}).items() if v != "valid"] + if bad: + print(f" SCHEMA FAILURES: {bad}") + print() + + print(f"REFUSALS ({len(REPORT['refusals'])})") + for name, r in REPORT["refusals"].items(): + print(f" {name:<34} {r['status']} {r['type']:<34} {r['section']}") + print(f"\nACCEPTANCES ({len(REPORT['acceptances'])})") + for name, r in REPORT["acceptances"].items(): + print(f" {name:<34} {r['status']} state {r['state']:<12} {r['section']} {r['detail']}") + print() + + print("MICROBENCHMARKS, median microseconds per call") + for name, m in REPORT["micro"].items(): + if isinstance(m, dict) and "median_us" in m: + print(f" {name:<40} {m['median_us']:>10.2f} us") + cb = REPORT["micro"].get("canonical_bytes", {}) + if cb: + print(f" canonical bytes contract {cb['contract']}, " + f"delivery {cb['delivery']}") + print("\n no T0-reexec figure is reported; see the comment in run_micro") + + +if __name__ == "__main__": + main() diff --git a/tools/pactcore.py b/tools/pactcore.py new file mode 100644 index 0000000..4419b49 --- /dev/null +++ b/tools/pactcore.py @@ -0,0 +1,495 @@ +"""Shared PACT primitives: canonicalization, digests, JWS, and the assurance constraint. + +This module is the part of the reference implementation that both the +Facilitator (facilitator.py) and the party agents (agents.py) need. It is +deliberately separate from tools/validate.py, which stays a self-contained +conformance checker over the committed examples and must keep passing on a +machine with nothing but jsonschema installed. + +Two things here are worth reading before trusting a number produced with it. + +The canonicalizer is the same restricted RFC 8785 implementation validate.py +uses. It is correct for the value types PACT objects carry and is not a +conforming general JCS implementation; what it does get right, and what the +Section 13.3 vectors pin, is the UTF-16 code unit key order of RFC 8785 +section 3.2.3, which json.dumps(sort_keys=True) does not implement. + +The signatures are real. Section 13.1 fixes the JWS Signing Input as +ASCII(BASE64URL(UTF8(protected)) || "." || BASE64URL(JCS(object))) over the +object with its signing member removed, and Section 6 computes vtc_hash over +the contract *including* its signatures member. Those two facts are why a +contract digest proves who agreed rather than merely what was written, and +why the signing input and the digest are computed over different bytes. Both +are implemented here and exercised by the measurement harness. + +The committed examples under examples/ keep their placeholder signature +values on purpose. The published Internet-Draft prints their digests in +Section 14 and cannot be corrected, so re-signing them would silently +desynchronise the repository from the document. Everything in this module +mints fresh keys and fresh contracts at run time instead. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import unicodedata +from decimal import Decimal +from dataclasses import dataclass, field +from typing import Any, Callable + +# Ed25519 and P-256 come from `cryptography`. It is an optional dependency: +# validate.py's sixty-six checks do not need it, and this module is only +# imported by the Facilitator and the agents. +try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, + ) + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric.utils import ( + decode_dss_signature, + encode_dss_signature, + ) + from cryptography.exceptions import InvalidSignature + + HAVE_CRYPTO = True +except ImportError: # pragma: no cover + HAVE_CRYPTO = False + + class InvalidSignature(Exception): + pass + + +# -------------------------------------------------------------------------- +# RFC 8785 canonicalization and digests +# -------------------------------------------------------------------------- + +def _utf16_key_order(obj: Any) -> Any: + """Reorder object keys by UTF-16 code unit, per RFC 8785 section 3.2.3. + + Comparing UTF-16 big-endian encodings bytewise is equivalent to comparing + sequences of UTF-16 code units. json.dumps preserves insertion order, so + building the dict in the right order is enough; sort_keys must NOT also + be set, since that re-sorts by code point and the two orders diverge + above the Basic Multilingual Plane. + """ + if isinstance(obj, dict): + return {k: _utf16_key_order(obj[k]) + for k in sorted(obj, key=lambda s: s.encode("utf-16-be"))} + if isinstance(obj, list): + return [_utf16_key_order(v) for v in obj] + return obj + + +def jcs(obj: Any) -> bytes: + """Restricted RFC 8785 canonical serialization. See the module docstring.""" + return json.dumps(_utf16_key_order(obj), separators=(",", ":"), + ensure_ascii=False).encode("utf-8") + + +def h(b: bytes) -> str: + return "sha256:" + hashlib.sha256(b).hexdigest() + + +def digest_over(obj: Any) -> str: + """Digest over the whole object as it stands, signatures included. + + This is the Section 6 construction. Use it for vtc_hash, and never for a + signing input. + """ + return h(jcs(obj)) + + +# PACT objects carry their signatures two ways, and both are in the schemas. +# The contract and the Work Attestation take an array, because more than one +# party signs them. Delivery, Verdict and Challenge take a single object, +# because exactly one party does. +SIGNING_MEMBERS = ("signatures", "signature") + +# The Facilitator adds `state` to a response body. Section 12 says it is not +# part of the signed object and MUST NOT be included when the object is +# canonicalized or hashed, so it is stripped everywhere alongside signatures. +UNSIGNED_MEMBERS = SIGNING_MEMBERS + ("state",) + + +def signable(obj: dict) -> dict: + return {k: v for k, v in obj.items() if k not in UNSIGNED_MEMBERS} + + +def hashable(obj: dict) -> dict: + """The object as it is committed to: signatures kept, transport state dropped.""" + return {k: v for k, v in obj.items() if k != "state"} + + +def signature_entries(obj: dict) -> list[dict]: + """Every signature entry on an object, whichever member carries them.""" + if "signatures" in obj: + return list(obj["signatures"]) + if "signature" in obj: + return [obj["signature"]] + return [] + + +def attach(obj: dict, entry: dict, array: bool) -> dict: + obj["signatures" if array else "signature"] = [entry] if array else entry + return obj + + +# -------------------------------------------------------------------------- +# Identifier normalization, Section 9.1 +# -------------------------------------------------------------------------- + +def norm(identifier: str) -> str: + """Normalize a party identifier per Section 9.1, exactly as written. + + "strip leading and trailing whitespace; lower-case the scheme and, for + did:web and https identifiers, the host; remove any fragment (a "#" and + everything after it) and any trailing "/" or ".". Percent-encoding MUST NOT + be decoded, since an open-ended decoder is its own attack surface." + + Note what this does NOT do. It does not case-fold the whole identifier: the + path of a did:web is case sensitive, and folding it would merge two + distinct parties. An earlier version of this function folded everything and + never stripped the fragment, which is both too permissive and too strict in + different places. + """ + s = unicodedata.normalize("NFC", identifier).strip() + s = s.split("#", 1)[0] + + if ":" in s: + scheme, rest = s.split(":", 1) + scheme = scheme.lower() + if scheme == "did": + parts = rest.split(":") + if parts: + parts[0] = parts[0].lower() # the DID method + if parts[0] == "web" and len(parts) > 1: + parts[1] = parts[1].lower() # the host + rest = ":".join(parts) + elif scheme in ("http", "https") and rest.startswith("//"): + host, sep, tail = rest[2:].partition("/") + rest = "//" + host.lower() + sep + tail + s = scheme + ":" + rest + + while s.endswith(("/", ".")): + s = s[:-1] + return s + + +def same_party(a: str, b: str) -> bool: + return norm(a) == norm(b) + + +# -------------------------------------------------------------------------- +# The assurance constraint, Section 7.2 +# -------------------------------------------------------------------------- + +def required_bond(price: float, q: float, released: float = 0.0) -> float: + """B >= P(1-q)/q + E. + + E, the amount already paid out before a Verdict is recorded, is the only + term this specification contributes; the rest is the classical deterrence + bound (Polinsky and Shavell; Belenkiy et al. Theorem 1; Mamageishvili and + Felten for rollup validators). Optimistic release both pays a defecting + Seller and puts that payment beyond recovery, so the required Bond rises + with it one for one. + """ + if q <= 0: + raise ValueError("q must be greater than zero") + return price * (1.0 - q) / q + released + + +def assurance_holds(price: str | float, bond: str | float, q_min: str | float, + released: str | float = "0") -> bool: + """B >= P(1-q)/q + E, evaluated exactly. + + Multiplied through by q (which is positive) this is B*q >= P*(1-q) + E*q, + which needs no division and no rounding. An earlier version rounded both + sides to the nearest cent first, and a bond one hundredth of a cent short + of the bound passed. + """ + P, B, q, E = (Decimal(str(x)) for x in (price, bond, q_min, released)) + if q <= 0: + raise ValueError("q must be greater than zero") + return B * q >= P * (1 - q) + E * q + + +# -------------------------------------------------------------------------- +# JWS General JSON Serialization with a detached payload, Section 13.1 +# -------------------------------------------------------------------------- + +ALLOWED_ALGS = ("EdDSA", "ES256", "ES384") + + +def b64u(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode("ascii").rstrip("=") + + +def b64u_decode(s: str) -> bytes: + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def signing_input(protected_b64: str, obj: dict) -> bytes: + """ASCII(BASE64URL(UTF8(protected)) || "." || BASE64URL(JCS(object))). + + Exactly RFC 7515 section 5.1, over the object with its signing member + removed. The payload is never transmitted; a verifier rebuilds it from + the object it holds. The protected header is used AS TRANSMITTED: an + earlier version re-serialized the parsed header, which meant a + conformant JWS whose header bytes differed from this module's own + serialization (whitespace, key order) was rejected, and the commitment + depended on a re-serialization the signer never saw. + """ + return (protected_b64 + "." + b64u(jcs(signable(obj)))).encode("ascii") + + +@dataclass +class Key: + """A party key. `kid` is the URI a verifier resolves, per Section 13.1.1.""" + kid: str + alg: str + private: Any = None + public: Any = None + + @classmethod + def generate(cls, kid: str, alg: str = "EdDSA") -> "Key": + if not HAVE_CRYPTO: + raise RuntimeError( + "the `cryptography` package is required to mint keys; " + "install it with `pip install cryptography`") + if alg == "EdDSA": + sk = Ed25519PrivateKey.generate() + elif alg == "ES256": + sk = ec.generate_private_key(ec.SECP256R1()) + elif alg == "ES384": + sk = ec.generate_private_key(ec.SECP384R1()) + else: + raise ValueError(f"unsupported alg {alg}") + return cls(kid=kid, alg=alg, private=sk, public=sk.public_key()) + + def sign_bytes(self, data: bytes) -> bytes: + if self.alg == "EdDSA": + return self.private.sign(data) + curve_hash = hashes.SHA256() if self.alg == "ES256" else hashes.SHA384() + der = self.private.sign(data, ec.ECDSA(curve_hash)) + r, s = decode_dss_signature(der) + size = 32 if self.alg == "ES256" else 48 + return r.to_bytes(size, "big") + s.to_bytes(size, "big") + + def verify_bytes(self, sig: bytes, data: bytes) -> None: + if self.alg == "EdDSA": + self.public.verify(sig, data) + return + size = 32 if self.alg == "ES256" else 48 + if len(sig) != size * 2: + raise InvalidSignature("bad JWS ECDSA signature length") + r = int.from_bytes(sig[:size], "big") + s = int.from_bytes(sig[size:], "big") + curve_hash = hashes.SHA256() if self.alg == "ES256" else hashes.SHA384() + self.public.verify(encode_dss_signature(r, s), data, ec.ECDSA(curve_hash)) + + +class KeyResolver: + """Maps a `kid` to a public key. + + Section 13.1.1 resolves a kid as a DID URL (DID Core, did:web) or as an + https URI naming a JWK Set (RFC 7517). Both are network lookups with + caching and revocation semantics that a reference implementation should + not fake. This resolver is an in-process registry with the same interface, + so the Facilitator's verification path is real and only the transport of + the public key is stubbed. What that costs a measurement is one network + round trip per unseen kid, which is stated wherever numbers are reported. + """ + + def __init__(self) -> None: + self._keys: dict[str, Key] = {} + + def register(self, key: Key) -> Key: + self._keys[key.kid] = key + return key + + def resolve(self, kid: str) -> Key | None: + return self._keys.get(kid) + + +def public_bytes(key: "Key") -> bytes: + """Raw public key bytes, for the Section 16.11 record of what was resolved.""" + from cryptography.hazmat.primitives import serialization + if key.alg == "EdDSA": + return key.public.public_bytes(serialization.Encoding.Raw, + serialization.PublicFormat.Raw) + return key.public.public_bytes(serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint) + + +def sign(obj: dict, key: Key, typ: str) -> dict: + """Return one entry for the object's `signatures` array. + + `typ` is the full media type, per RFC 8725 section 3.11: a bare "JWT" or + an omitted typ lets an attacker present a token minted for one purpose as + one minted for another. + """ + protected_b64 = b64u(jcs({"alg": key.alg, "kid": key.kid, "typ": typ})) + sig = key.sign_bytes(signing_input(protected_b64, obj)) + return {"protected": protected_b64, "signature": b64u(sig)} + + +def verify_entry(obj: dict, entry: dict, resolver: KeyResolver, + expect_typ: str | None = None) -> tuple[bool, str]: + """Verify one signature entry. Returns (ok, reason).""" + try: + protected = json.loads(b64u_decode(entry["protected"])) + except Exception: + return False, "protected header is not valid base64url JSON" + + for member in ("alg", "kid", "typ"): + if member not in protected: + return False, f"protected header is missing {member}" + if "kid" in entry: + # Section 13.1: a kid outside the signed header is attacker-controlled. + return False, "kid carried as a sibling of the protected header" + # RFC 8725 section 3.11 and conformance vector V-05: typ carries the full + # media type so a signature minted over one object cannot be presented as + # one minted over another. This was previously accepted as an argument and + # never compared, which made V-05 pass in validate.py and fail over the wire. + if expect_typ is not None and protected.get("typ") != expect_typ: + return False, (f"typ {protected.get('typ')!r} does not match the expected " + f"{expect_typ!r}") + + alg = protected["alg"] + if alg not in ALLOWED_ALGS: + # Rejecting `none` and everything off the allowlist is the whole point: + # absent one, the attacker selects the algorithm. The reason string + # starts with "algorithm" so a caller can map it to Table 9's + # algorithm-not-permitted rather than a generic signature failure. + return False, f"algorithm {alg!r} is not permitted" + + key = resolver.resolve(protected["kid"]) + if key is None: + return False, f"cannot resolve kid {protected['kid']!r}" + if key.alg != alg: + return False, f"alg {alg!r} does not match the resolved key" + + try: + key.verify_bytes(b64u_decode(entry["signature"]), + signing_input(entry["protected"], obj)) + except Exception: + return False, "signature does not verify" + return True, "ok" + + +def signer_kids(obj: dict) -> list[str]: + out = [] + for entry in signature_entries(obj): + try: + out.append(json.loads(b64u_decode(entry["protected"]))["kid"]) + except Exception: + continue + return out + + +def kid_covers(kid: str, party: str) -> bool: + """True when `kid` is a key identifier belonging to `party`. + + Section 9.1 normalization strips the fragment, so a kid of + did:web:seller.example#key-1 normalizes to the party identifier itself and + this is an equality test. It used to be a prefix test, which is a hole: + did:web:acme.example.evil starts with did:web:acme.example and would have + signed as its neighbour. Line 1965 of the draft requires equality of the + normalized identifier, not containment. + """ + return norm(kid) == norm(party) + + +def verify_object(obj: dict, resolver: KeyResolver, typ: str, + required_parties: list[str]) -> tuple[bool, str]: + """Verify every signature and check that each required party signed once.""" + entries = signature_entries(obj) + if not entries: + return False, "object carries no signatures" + + for entry in entries: + ok, why = verify_entry(obj, entry, resolver, expect_typ=typ) + if not ok: + return False, why + + kids = signer_kids(obj) + for party in required_parties: + covering = [k for k in kids if kid_covers(k, party)] + if not covering: + return False, f"no signature from {party}" + if len(covering) > 1: + return False, f"more than one signature from {party}" + return True, "ok" + + +# -------------------------------------------------------------------------- +# Merkle tree, RFC 9162 section 2.1.1 +# -------------------------------------------------------------------------- + +def mth(items: list[bytes]) -> bytes: + """Merkle Tree Hash over `items`, exactly as RFC 9162 defines it. + + Leaves are hashed with a 0x00 prefix and internal nodes with 0x01, and an + n-item tree splits at k, the largest power of two strictly less than n. + That split is not the same as promoting an odd node, which is what the + -00 said and what an earlier revision of this repository implemented. + """ + if not items: + return hashlib.sha256(b"").digest() + if len(items) == 1: + return hashlib.sha256(b"\x00" + items[0]).digest() + k = 1 + while k * 2 < len(items): + k *= 2 + return hashlib.sha256(b"\x01" + mth(items[:k]) + mth(items[k:])).digest() + + +# -------------------------------------------------------------------------- +# Money. Contracts carry decimal strings; arithmetic happens in cents. +# -------------------------------------------------------------------------- + +def cents(amount: str | float) -> int: + """Whole cents. Amounts with more than two decimal places are refused at + propose by the Facilitator (it settles in cents); this never rounds.""" + d = Decimal(str(amount)) + scaled = d * 100 + if scaled != scaled.to_integral_value(): + raise ValueError(f"amount {amount!r} is not a whole number of cents") + return int(scaled) + + +def money(c: int) -> str: + return f"{c / 100:.2f}" + + +@dataclass +class Pools: + """The three pools of Section 7.1, in cents. + + Keeping the Verification Fund separate from the Bond is not tidiness. Under + the -00 a Challenger was reimbursed from the slashed Bond, so reimbursement + was capped by the Bond, and for any re-execution profile the cost of + producing a fraud proof approximates the cost of the work itself. That MUST + was unsatisfiable in the ordinary case. + """ + escrow: int = 0 + bond: int = 0 + fund: int = 0 + bond_initial: int = 0 + bond_returned: int = 0 + fund_returned: int = 0 + cap: int = 0 # liability.cap: the most the Facilitator may move from the Seller + released: int = 0 # E in the constraint of Section 7.2 + paid_to_buyer: int = 0 + restituted: int = 0 + paid_to_seller: int = 0 + paid_to_challenger: int = 0 + remainder: int = 0 + ledger: list[str] = field(default_factory=list) + + def note(self, line: str) -> None: + self.ledger.append(line)