route-record: a third party's hop has a signed record of what was sent, what came back, and what the provider bills #572

Merged
dcharlot merged 3 commits from wai/third-party-hop into main 2026-11-02 11:04:26 -05:00
12 changed files with 4387 additions and 16 deletions

View file

@ -6,6 +6,38 @@ wire format may change and such changes are recorded here.
## Unreleased
### A third party's hop has a signed record: what was sent, what came back, and what the provider bills
- **The third-party hop record** ([extensions/route-record.md](extensions/route-record.md) §6.2, §7.2 step 9),
`wai-route-third-party-hop`: a hop a router sends to a third party's endpoint states who it called (`provider`,
`origin`), on whose account (`operator` or `customer`, never the key), the provider's id for the work, each exchange
(method, URL, times, status, and the body sent and received by SHA-256 and length, the response kept where it was
JSON), the outputs by name and content hash, what the provider bills (`usage`) and what it reported charging
(`charge`, its own figure in its own terms, never added to anything). It holds no secret: no header has a member, and
no URL states its query, a provider's signed link being a bearer token; `route_record::third_party::kept_response`
and `url_without_query` make a response and a URL what a record may state. It is signed by the router under its own
domain and named by its digest.
- **It is bound to its hop.** A verification document may have `third_party`, the records' texts, beside its receipts: a
record is held to the route record's signer and job, to its attempt's endpoint, receipt, outcome, error and times, to
that endpoint's candidate's provider and unit, and when it served, its outputs to what the hop's receipt names and
the attempt's count (`third_party_invalid`, `third_party_mismatch`, `third_party_output_mismatch`). No route record
changes: the record names the receipt the route record already names. A document without `third_party` reads exactly
as before, and its outcome has no such member.
- **The corpus.** Routes 126 → 236 vectors: nine accepted (served on the operator's account, with the provider's charge
and job id, a failure the provider bills beside a served hop, one it does not bill, records given out of order, the
customer's account, under a route record of version 2, a response as a record keeps it, and none given) and 101
controls of one defect each, every form and bound of §6.2 among them. `live-conformance/route_record_verify.py`
verifies each from this text, and stops a mutated copy at its first failure, since it only asks whether one fails.
- **Breaking:** `api:rust:wai::route_record::Accepted::third_party`: what the records state, `None` when the document has
none. A struct literal of `Accepted` needs it.
- **Breaking:** `api:rust:wai::route_record::Document::third_party`: the records' texts, `None` when the document has
none. A struct literal of `Document` needs it.
- **Breaking:** `api:rust:wai::route_record::Reason::ThirdPartyInvalid`, `api:rust:wai::route_record::Reason::ThirdPartyMismatch`
and `api:rust:wai::route_record::Reason::ThirdPartyOutputMismatch`: three reasons, which an exhaustive match over `Reason`
needs.
- **Breaking:** `api:rust:wai::route_record::Reason::ALL`: the list of §8's codes holds those three, an array of 25. A
use that names its length needs the new one.
### `c2pa` 0.91
- **`c2pa` 0.90 → 0.91** behind `c2pa_emit`, off by default. It takes `zip` 9, past RUSTSEC-2026-0429 (which `c2pa`

View file

@ -559,6 +559,7 @@ and a role are fixed once registered.
| `wai-route-pricebook` | document-kind | defined | route-record §3.2 |
| `wai-route-record` | document-kind | defined | route-record §5.1 |
| `wai-route-request` | document-kind | defined | route-record §3.1 |
| `wai-route-third-party-hop` | document-kind | defined | route-record §6.2 |
| `wai-scene/1` | scheme | defined | scene-carriage §2.3 |
| `wai-sequence` | document-kind | defined | direction §6 |
| `wai-session` | document-kind | defined | contribution §3.1 |

View file

@ -33,6 +33,10 @@
> the same size whatever it delivered; an object is shown to be one of them by its inclusion
> proof (§6.1). Records of versions 1 and 2 are read and verified exactly as before.
>
> This revision adds the **third-party hop record** (§6.2): what a router sent a third party for
> one hop, what came back and what the provider bills, signed by the router and bound to the
> hop's attempt and receipt. A verification document without one is read exactly as before.
>
> Texts read for this revision: the WAI texts this one cites; RFC 8259; RFC 8785; RFC 8032;
> ISO 4217 (the alphabetic currency code); FIPS 180-4; the BLAKE3 specification.
@ -522,6 +526,80 @@ A receipt states the hop's energy with its grade, and, in minor version 1, its c
unmetered marker; the record states no figure of its own. A reader of the hops' energy reads
it from their receipts, and an invoice bills a job from them (receipt-ledger §5).
### 6.2 A third party's hop
A hop a router sends to a third party's endpoint is work it did not do: the provider did it,
behind an API the router called. Its receipt (§6) is the router's, and says the hop served or
failed; what the router sent, to whom, what came back, when, and what the provider bills, it
states in a **third-party hop record**, a document (§2) of exactly these members:
| member | value |
|---|---|
| `kind` | `"wai-route-third-party-hop"` |
| `version` | `1` |
| `job` | the job's id, as the route record states it |
| `hop` | an integer at least 1: the attempt's `hop` |
| `endpoint` | a token: the attempt's `endpoint` |
| `provider` | a token: the `provider` of that endpoint's candidate |
| `receipt` | the attempt's `receipt`, its hop's OER/2 `receipt_id`, in the form §5.2 gives it |
| `origin` | the provider's API: `https://`, then a host of 1 to 253 lowercase letters, digits, `.` and `-`, then optionally `:` and a port, and nothing else |
| `account` | `"operator"`, the hop ran on the router's operator's own account with the provider, or `"customer"`, on the account of the job's owner |
| `provider_job` | a string of 1 to 256 bytes, the provider's id for the work, or `null` when it gave none |
| `outcome` | `"served"` or `"failed"` |
| `error` | `null` when served; when failed, an object of exactly `class`, an error of §5.2, and `code`, a string of 1 to 256 bytes: the provider's own code or status, as it gave it |
| `exchanges` | 1 to 1024 objects of exactly the members below, in the order they were sent |
| `outputs` | when served, 1 to 1024 lists of exactly two strings, a name of 1 to 255 bytes holding no `/`, and the object's content hash, in the order the hop delivered them, no name and no content hash twice; when failed, `[]` |
| `usage` | what the provider bills for the hop: an object of exactly `unit`, a unit, `quantity`, an integer, and `source`, a string of 1 to 512 bytes saying where the quantity was read; when served, such an object; when failed, `null`, or such an object when the provider bills the failure |
| `charge` | what the provider itself reported charging, or `null` when it reported nothing: an object of exactly `amount`, a string of 1 to 64 bytes, the figure as the provider wrote it, `unit`, a string of 1 to 64 bytes, its currency or unit in its own terms, and `source`, a string of 1 to 512 bytes |
| `start`, `end` | times: when the first exchange was sent, and when the last ended, `start` at most `end` |
| `signer`, `sig` | the router's key and its signature |
An exchange is one HTTP request and what came back:
| member | value |
|---|---|
| `method` | 1 to 16 uppercase ASCII letters |
| `url` | the URL requested, `https://` or `http://` and then 1 to 2,040 bytes holding no `?` and no `#`: its query and its fragment removed |
| `start`, `end` | times: when it was sent, and when its response ended or it failed, `start` at most `end`, both from the record's `start` to its `end`, and `start` at least the previous exchange's |
| `sent` | an object of exactly `sha256`, 64 lowercase hexadecimal digits, the SHA-256 of the request's body, and `bytes`, its length |
| `status` | the response's HTTP status, an integer 100 to 599, or `0` when no response came |
| `received` | the same of the response's body; of no bytes, when no response came |
| `json` | the response's body when it is JSON, read as a document after each number that is not an integer 0 to 2⁵³ − 1 is written as its decimal text and each string that begins `https://` or `http://` is cut at its first `?` or `#`; otherwise `null` |
A record that is not of these members, each of its form, is `third_party_invalid`, and so is
one whose `json` holds a string that begins `https://` or `http://` and holds a `?` or a `#`.
**No secret is in it.** The key the hop ran on is never stated, only whose account it is; an
exchange states no header, since a key travels in one; and every URL is stated without its
query, since a provider's signed download link is a bearer token, its query the key to the
object. What was sent and received is named by digest and length, which a holder of the bytes
checks, and the response is kept as JSON where the provider answered with JSON, so that what it
said about the job (its id, its status, the usage it billed) can be read from the record itself.
The record is signed by the router, over the domain `wai:route-third-party-hop\x01` and the
canonical form of the record without `sig`, under the strict rule of SPEC §7, and named by its
digest (§2), over its canonical form with `sig`. It is bound to its hop: given the route record
and the hop receipts (§7.2 step 9), a record is held to
- the route record's router: its `signer` is the record's, and its `job` the record's;
- its attempt: `hop` names one, whose `endpoint`, `receipt` and `outcome` are the record's
and, when it failed, whose `error` is the record's `error.class`; whose candidate's
`provider` is its `provider`, and whose candidate's `unit` is its `usage.unit` when it states
a usage; and whose `start` is at most the record's `start` and whose `end` at least its
`end`. No two records are of one hop. Otherwise `third_party_mismatch`;
- what the hop delivered: when it served, the commitment of its outputs' content hashes in
their order (oer-receipts §3 *Output*) is its receipt's `subject.output`, to which §6 holds
the attempt's `outputs` or `root`, and in a route record of version 3 their number is the
attempt's `count`, which the receipt does not name. Otherwise
`third_party_output_mismatch`.
So the provider's work is tied to the hop that paid for it: the receipt says the hop served
these objects, the record says which calls produced them and what the provider bills, and the
route record names both. The `charge` is the provider's own figure, in its own terms, beside the
usage and never added to it; a reader who compares what the provider says it charged with what
a job was billed reads both from signed documents. The record states no energy figure: the
hop's receipt states it, or the unmetered marker where the provider gave none
(oer-receipts §3.1).
## 7. Verification
### 7.1 Inputs
@ -529,9 +607,11 @@ it from their receipts, and an invoice bills a job from them (receipt-ledger §5
A verifier is given a **verification document**: a JSON object of exactly `request`,
`pricebook`, `policy` and `record`, each a string holding that document's text, `receipts`,
`null` or a list of strings each holding an OER/2 receipt's text, and `keys`, `null` or an
object of a receipt's `chain.signer` names to keys, as oer-receipts §5 publishes them. The
texts are carried as strings so that each is read as written. A verification document that is
not such an object, or whose `receipts` is a list while its `keys` is `null`, is `malformed`.
object of a receipt's `chain.signer` names to keys, as oer-receipts §5 publishes them. It may
also have `third_party`, a list of strings each holding a third-party hop record's text (§6.2).
The texts are carried as strings so that each is read as written. A verification document that
is not such an object, whose `receipts` is a list while its `keys` is `null`, or that has
`third_party` while its `receipts` is `null`, is `malformed`.
### 7.2 Steps
@ -579,6 +659,13 @@ and where (`at`, a list: the member, then indexes or members within it):
`receipt`: `receipt_missing`, `receipt_invalid`, `receipt_mismatch`,
`receipt_output_mismatch`, the first receipt of the list whose `receipt_id` is the
attempt's being its receipt.
9. **The third-party hop records** (§6.2), when the document has `third_party`, each in turn,
at `third_party` and its index: a text that is not a document, or whose `kind` is not
`wai-route-third-party-hop`, is `malformed`; a `version` that is an integer other than `1`
is `unknown_version`, at `version` within it; one that is not of §6.2's members and forms
is `third_party_invalid`; then its signature, `bad_key`, `bad_signature`, at `sig` within
it; then its binding, `third_party_mismatch`, at the member; then what its hop delivered,
`third_party_output_mismatch`, at `outputs` within it.
### 7.3 Outcome
@ -591,7 +678,8 @@ and where (`at`, a list: the member, then indexes or members within it):
too when no hop served; the order; the decision; each removed candidate's endpoint with the
limits that removed it; whether the hop receipts were `checked` or `not_checked`; and, when
checked, the hops whose receipts state the unmetered marker (oer-receipts §3.1), which a
reader **MUST NOT** present as hops of zero joules.
reader **MUST NOT** present as hops of zero joules. When the document has `third_party`, each
record's hop, digest, `account`, `usage` and `charge`, in the order of their hops.
## 8. Reasons
@ -621,6 +709,9 @@ The codes this extension adds, each with where it is raised:
| `receipt_invalid` | §6, §7.2 step 8 |
| `receipt_mismatch` | §6, §7.2 step 8 |
| `receipt_output_mismatch` | §6, §7.2 step 8 |
| `third_party_invalid` | §6.2, §7.2 step 9 |
| `third_party_mismatch` | §6.2, §7.2 step 9 |
| `third_party_output_mismatch` | §6.2, §7.2 step 9 |
A verifier of this extension also raises `malformed` for a text that is not a document (§2)
or a verification document that is not §7.1's, `unknown_version` for a record of another
@ -678,6 +769,21 @@ verification document with the outcome §7.3 gives it:
version 3 on a price book of version 1 (`pricebook_mismatch`); and a served hop's receipt
moved from another job, and a root not its receipt's (`receipt_output_mismatch`). A record of
version 4 is `unknown_version`.
- **Accepted, third-party hops**, under records of versions 2 and 3: a hop a third party served,
on the operator's account, whose provider reported no charge; one whose provider reported a
charge and a provider's own job id; a failed hop the provider bills beside a served one; a
failed hop it does not bill; and a hop of the customer's account.
- **Refused, third-party hops**: a document with `third_party` and no receipts (`malformed`); a
record of version 2 (`unknown_version`); each form and bound of §6.2 past its limit; a member missing and a member added, a header among
them; a URL with a query, one with a fragment, a response keeping a signed link and an origin
with a path; a served record with no outputs and no usage, a failed one with outputs and with no
error; a name twice and an object twice; exchanges out of order, before the record's start
and past its end (`third_party_invalid`);
a changed record (`bad_signature`); and another router's, another job's, a hop that is not
an attempt, another endpoint's, provider's, receipt's, outcome's and error's, a usage in
another unit, times outside the attempt's and a second record of a hop
(`third_party_mismatch`); and outputs that are not what the receipt names, the same in another
order, and another number than the attempt's count (`third_party_output_mismatch`).
`wai_route_record_vectors verify` and the test `tests/route_record.rs` check that the file
regenerates byte for byte, that `wai-rs` gives every vector its outcome, that every reason of
@ -724,7 +830,16 @@ fail, and requires the corpus to catch the verifier with each of its checks drop
the record states it delivered (§6), so a receipt cannot vouch, in another record, for an
output its hop did not produce; and a holder of the output who finds its content hashes in
the record knows which hop's receipt is about it.
- **A third party's record holds no secret.** A third-party hop record states whose account the
hop ran on, never the key; no header, since a key travels in one; and no URL's query, since a
provider's signed link is a bearer token (§6.2). A record that keeps one is refused, so a
router that signs one has signed something a verifier will not read.
- **What a third-party record proves.** That the router says it made these calls and read these
answers: the provider signs nothing, so the record is the router's statement of what passed
between them, bound by digest to the bytes, which a holder of them checks. A provider's
`charge` is what the provider reported, as the router read it, and is never a price.
- **What a record reveals.** Its job id, its operation and its quantities, every endpoint and
provider that could have served the job with their prices, and when each hop ran; in
version 2, every candidate's measured failures and the content hashes of the job's
outputs. It travels between the router, the job's owner and an auditor they choose.
outputs. A third-party hop record reveals, besides, what the provider answered about the job.
They travel between the router, the job's owner and an auditor they choose.

View file

@ -23,9 +23,11 @@ else is here:
outputs the record states, their commitment computed with ../claim-conformance/_blake3.py
(BLAKE3 written from its specification) as oer-receipts §3 *Output* defines it;
- version 3 of the record (§3.4), on a price book of version 2: what the served hop delivered
stated by its count and root, the served hop's receipt held to the root; and an output's
stated by its count and root, the served hop's receipt held to the root; an output's
inclusion proof (§6.1, `inclusion_proof`), which the receipt ledger's verifier checks a job
source's outputs with.
source's outputs with; and each third-party hop record (§6.2): its members and forms, no key,
header or URL query in it, its signature under its own domain, and its binding to its attempt,
its candidate and what its hop's receipt names.
For every vector it requires the outcome the corpus states; of the corpus, that every reason of
§8 and every code it reuses appears in it, that §8 names every reason it raises, and that no
@ -647,7 +649,11 @@ def verify(doc):
def verify_steps(doc):
if not exactly(doc, ["request", "pricebook", "policy", "record", "receipts", "keys"]) \
has_third = isinstance(doc, dict) and "third_party" in doc
members = ["request", "pricebook", "policy", "record", "receipts", "keys"] + (["third_party"] if has_third else [])
if not exactly(doc, members) \
or (has_third and not (isinstance(doc["third_party"], list) and all(isinstance(x, str) for x in doc["third_party"]))) \
or (has_third and doc["receipts"] is None) \
or not all(isinstance(doc[k], str) for k in ("request", "pricebook", "policy", "record")) \
or not (doc["receipts"] is None or (isinstance(doc["receipts"], list) and all(isinstance(x, str) for x in doc["receipts"]))) \
or not (doc["keys"] is None or isinstance(doc["keys"], dict)) \
@ -728,13 +734,15 @@ def verify_steps(doc):
unmetered = None
if doc["receipts"] is not None:
unmetered = hop_receipts(rec, doc["receipts"], doc["keys"])
# 9. The third-party hop records.
third_party = third_party_records(rec, doc["third_party"], doc["receipts"]) if has_third else None
served_attempt = atts[-1] if last_served else None
billed_hop = next((a["hop"] for a in atts if a["billed"]), None)
if deadline is None or served_attempt is None:
dl = "none"
else:
dl = "missed" if served_attempt["end"] > deadline else "met"
return {
accepted = {
"outcome": "accept",
"digest": digest(rec),
"job": req["job"],
@ -751,6 +759,9 @@ def verify_steps(doc):
"receipts": "checked" if unmetered is not None else "not_checked",
"unmetered": unmetered,
}
if third_party is not None:
accepted["third_party"] = third_party
return accepted
def commitment(outputs):
@ -823,6 +834,231 @@ def hop_receipts(rec, texts, keys):
return unmetered
# ---------------------------------------------------------------- §6.2 third-party hop records
THIRD_DOMAIN = b"wai:route-third-party-hop\x01"
THIRD = ["kind", "version", "job", "hop", "endpoint", "provider", "receipt", "origin", "account", "provider_job", "outcome",
"error", "exchanges", "outputs", "usage", "charge", "start", "end", "signer", "sig"]
EXCHANGE = ["method", "url", "start", "end", "sent", "status", "received", "json"]
ORIGIN = re.compile(r"\Ahttps://[a-z0-9.-]{1,253}(?::[0-9]{1,5})?\Z")
METHOD = re.compile(r"\A[A-Z]{1,16}\Z")
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
def is_text(v, top):
return isinstance(v, str) and 1 <= len(v.encode("utf-8")) <= top
def url_ok(v):
if not isinstance(v, str) or "?" in v or "#" in v:
return False
for scheme in ("https://", "http://"):
if v.startswith(scheme):
return 1 <= len(v[len(scheme):].encode("utf-8")) <= 2040
return False
def is_link(s):
return s.startswith("https://") or s.startswith("http://")
def keeps_a_query(v):
"""A string, a member's name among them, that begins as a URL and holds a `?` or a `#`."""
if isinstance(v, str):
return is_link(v) and ("?" in v or "#" in v)
if isinstance(v, list):
return any(keeps_a_query(x) for x in v)
if isinstance(v, dict):
return any(keeps_a_query(k) or keeps_a_query(x) for k, x in v.items())
return False
def body_ok(v):
return exactly(v, ["sha256", "bytes"]) and is_hex(v["sha256"], 64) and is_int(v["bytes"])
def exchange_ok(x, previous, end):
if not exactly(x, EXCHANGE):
return False
if on("tp-method") and not (isinstance(x["method"], str) and METHOD.match(x["method"])):
return False
if on("tp-url") and not url_ok(x["url"]):
return False
if not (is_int(x["start"]) and is_int(x["end"])):
return False
if on("tp-exchange-times") and x["start"] > x["end"]:
return False
if on("tp-past-end") and x["end"] > end:
return False
if on("tp-exchange-order") and x["start"] < previous:
return False
if on("tp-body") and not (body_ok(x["sent"]) and body_ok(x["received"])):
return False
if on("tp-status") and not (is_int(x["status"]) and (x["status"] == 0 or 100 <= x["status"] <= 599)):
return False
if on("tp-no-response") and x["status"] == 0 and (x["received"] != {"sha256": EMPTY_SHA256, "bytes": 0} or x["json"] is not None):
return False
if on("tp-kept-query") and keeps_a_query(x["json"]):
return False
return True
def usage_ok(u):
if not exactly(u, ["unit", "quantity", "source"]):
return False
return (not on("tp-usage-unit") or is_unit(u["unit"])) and (not on("tp-usage-quantity") or is_int(u["quantity"])) \
and (not on("tp-usage-source") or is_text(u["source"], 512))
def charge_ok(c):
if not exactly(c, ["amount", "unit", "source"]):
return False
return (not on("tp-charge-amount") or is_text(c["amount"], 64)) and (not on("tp-charge-unit") or is_text(c["unit"], 64)) \
and (not on("tp-charge-source") or is_text(c["source"], 512))
def third_outputs_ok(v):
if not isinstance(v, list):
return False
names, hashes = set(), set()
for e in v:
if not (isinstance(e, list) and len(e) == 2 and isinstance(e[0], str) and isinstance(e[1], str)):
return False
if on("tp-name-form") and not is_text(e[0], 255):
return False
if on("tp-name-slash") and "/" in e[0]:
return False
if on("tp-hash-form") and not is_hex(e[1], 64):
return False
if on("tp-name-twice") and e[0] in names:
return False
if on("tp-object-twice") and e[1] in hashes:
return False
names.add(e[0])
hashes.add(e[1])
return True
def third_party_form(r):
if on("tp-members") and not exactly(r, THIRD):
return False
if not exactly(r, THIRD):
return True
if on("tp-version-form") and not (is_int(r["version"]) and r["version"] == 1):
return False
if not (is_int(r["start"]) and is_int(r["end"]) and isinstance(r["exchanges"], list)):
return False
if on("tp-exchanges-number") and not r["exchanges"]:
return False
if on("tp-most-exchanges") and len(r["exchanges"]) > 1024:
return False
previous = r["start"]
for x in r["exchanges"]:
if not exchange_ok(x, previous, r["end"]):
return False
if isinstance(x, dict) and is_int(x.get("start")):
previous = x["start"]
if on("tp-outputs-form") and not third_outputs_ok(r["outputs"]):
return False
if on("tp-outcome-form"):
if r["outcome"] == "served":
if not (r["error"] is None and isinstance(r["outputs"], list) and len(r["outputs"]) >= 1 and usage_ok(r["usage"])):
return False
if on("tp-most-outputs") and len(r["outputs"]) > 1024:
return False
elif r["outcome"] == "failed":
err = r["error"]
if not (exactly(err, ["class", "code"]) and r["outputs"] == [] and (r["usage"] is None or usage_ok(r["usage"]))):
return False
if on("tp-error-class") and err["class"] not in ERRORS:
return False
if on("tp-error-code") and not is_text(err["code"], 256):
return False
else:
return False
if on("tp-hop-form") and not (is_int(r["hop"]) and r["hop"] >= 1):
return False
if on("tp-origin") and not (isinstance(r["origin"], str) and ORIGIN.match(r["origin"])):
return False
if on("tp-account") and r["account"] not in ("operator", "customer"):
return False
if on("tp-charge-form") and not (r["charge"] is None or charge_ok(r["charge"])):
return False
if on("tp-signer-form") and not (is_hex(r["signer"], 64) and is_hex(r["sig"], 128)):
return False
if on("tp-job-form") and not is_token(r["job"]):
return False
if on("tp-endpoint-form") and not is_token(r["endpoint"]):
return False
if on("tp-provider-form") and not is_token(r["provider"]):
return False
if on("tp-receipt-form") and not (isinstance(r["receipt"], str) and RECEIPT_ID.match(r["receipt"])):
return False
if on("tp-provider-job") and not (r["provider_job"] is None or is_text(r["provider_job"], 256)):
return False
return True
def third_party_records(rec, texts, receipt_texts):
"""§7.2 step 9: each record read, its signature checked, and held to its hop."""
receipts = [OER.load(io.StringIO(t)) for t in receipt_texts]
seen = set()
out = []
for i, t in enumerate(texts):
place = ["third_party", i]
r = read_document(t)
if not isinstance(r, dict) or r.get("kind") != "wai-route-third-party-hop":
raise Refuse("malformed", place)
if on("tp-version") and is_int(r.get("version")) and r["version"] != 1:
raise Refuse("unknown_version", place + ["version"])
if on("tp-form") and not third_party_form(r):
raise Refuse("third_party_invalid", place)
if on("tp-signature"):
body = {k: v for k, v in r.items() if k != "sig"}
bad = ed_verify(bytes.fromhex(r["signer"]), THIRD_DOMAIN + canonical(body).encode("utf-8"), bytes.fromhex(r["sig"]))
if bad:
raise Refuse(bad, place + ["sig"])
mismatch = lambda m: Refuse("third_party_mismatch", place + [m])
if on("tp-signer") and r["signer"] != rec["signer"]:
raise mismatch("signer")
if on("tp-job") and r["job"] != rec["job"]:
raise mismatch("job")
atts = rec["attempts"]
if r["hop"] > len(atts):
if on("tp-hop"):
raise mismatch("hop")
continue
if on("tp-one-a-hop") and r["hop"] in seen:
raise mismatch("hop")
seen.add(r["hop"])
a = atts[r["hop"] - 1]
for m in ("endpoint", "receipt", "outcome"):
if on("tp-" + m) and r[m] != a[m]:
raise mismatch(m)
if on("tp-error") and r["outcome"] == "failed" and r["error"]["class"] != a["error"]:
raise mismatch("error")
c = next((x for x in rec["candidates"] if x["endpoint"] == r["endpoint"]), None)
if on("tp-provider") and (c is None or r["provider"] != c["provider"]):
raise mismatch("provider")
if on("tp-unit") and r["usage"] is not None and (c is None or r["usage"]["unit"] != c["unit"]):
raise mismatch("usage")
if on("tp-start") and r["start"] < a["start"]:
raise mismatch("start")
if on("tp-end") and r["end"] > a["end"]:
raise mismatch("end")
if r["outcome"] == "served" and a["outcome"] == "served":
root = commitment([e[1] for e in r["outputs"]])
receipt = next(x for x in receipts if isinstance(x, dict) and x.get("receipt_id") == a["receipt"])
subject = receipt.get("subject") if isinstance(receipt.get("subject"), dict) else {}
if on("tp-root") and subject.get("output") != root:
raise Refuse("third_party_output_mismatch", place + ["outputs"])
if on("tp-count") and "delivered" in a and a["delivered"]["count"] != len(r["outputs"]):
raise Refuse("third_party_output_mismatch", place + ["outputs"])
out.append({"hop": r["hop"], "digest": digest(r), "account": r["account"], "usage": r["usage"], "charge": r["charge"]})
out.sort(key=lambda x: x["hop"])
return out
# ---------------------------------------------------------------- the corpus
def check(doc, quiet=False):
@ -838,6 +1074,8 @@ def check(doc, quiet=False):
print(("PASS" if ok else "FAIL") + " %s: %s" % (v.get("name"), what))
if not ok:
fails.append(v.get("name"))
if quiet: # a mutated copy or a weakened verifier only asks whether anything fails
break
return fails
@ -898,6 +1136,14 @@ def mutations(doc):
copy_with("its stated count one more", k, lambda x: x["outcome"]["served"]["delivered"].__setitem__("count", o["served"]["delivered"]["count"] + 1))
copy_with("its stated root another", k, lambda x: x["outcome"]["served"]["delivered"].__setitem__("root", "00" * 32))
copy_with("its served hop's delivered dropped from the outcome", k, lambda x: x["outcome"]["served"].pop("delivered"))
if "third_party" in o and o["third_party"]:
if o["third_party"][-1]["usage"] is not None:
copy_with("its stated third-party usage dropped", k, lambda x: x["outcome"]["third_party"][-1].__setitem__("usage", None))
copy_with("its stated third-party charge changed", k, lambda x: x["outcome"]["third_party"][0].__setitem__("charge", {"amount": "1", "unit": "x", "source": "y"}))
copy_with("its stated third-party account changed", k, lambda x: x["outcome"]["third_party"][0].__setitem__("account", "someone"))
copy_with("its stated third-party digest changed", k, lambda x: x["outcome"]["third_party"][0].__setitem__("digest", "00" * 32))
copy_with("a record's body named by another digest", k, lambda x: x["document"]["third_party"].__setitem__(
0, x["document"]["third_party"][0].replace('"bytes":', '"bytes":1', 1)))
copy_with("its stated deadline changed", k, lambda x: x["outcome"].__setitem__("deadline", "met" if o["deadline"] != "met" else "missed"))
copy_with("its stated order reversed", k, lambda x: x["outcome"].__setitem__("order", list(reversed(o["order"])) + ["x"]))
copy_with("its stated decision changed", k, lambda x: x["outcome"]["decision"].__setitem__("decided_by", "price" if o["decision"]["decided_by"] != "price" else "ttfb"))
@ -914,6 +1160,14 @@ CHECKS = ["request_invalid", "pricebook_invalid", "policy_invalid", "version", "
"pricebook-version", "failures-relations", "operation-candidates", "thin-basis", "unbounded", "expected-price",
"expected-ttfb", "expected-targets", "record-version", "outputs-form", "receipt-output", "delivered-form",
"receipt-root",
"tp-version", "tp-form", "tp-signature", "tp-members", "tp-version-form", "tp-exchanges-number", "tp-method", "tp-url",
"tp-exchange-times", "tp-past-end", "tp-exchange-order", "tp-body", "tp-status", "tp-no-response",
"tp-kept-query", "tp-outputs-form", "tp-name-twice", "tp-object-twice", "tp-outcome-form", "tp-hop-form", "tp-origin",
"tp-account", "tp-charge-form", "tp-signer-form", "tp-usage-unit", "tp-usage-quantity", "tp-usage-source", "tp-charge-amount",
"tp-charge-unit", "tp-charge-source", "tp-name-form", "tp-name-slash", "tp-hash-form", "tp-error-class", "tp-error-code",
"tp-job-form", "tp-endpoint-form", "tp-provider-form", "tp-receipt-form", "tp-provider-job", "tp-most-outputs",
"tp-most-exchanges", "tp-signer", "tp-job", "tp-hop", "tp-one-a-hop", "tp-endpoint",
"tp-receipt", "tp-outcome", "tp-error", "tp-provider", "tp-unit", "tp-start", "tp-end", "tp-root", "tp-count",
"limit-policy", "limit-unit", "limit-region", "limit-residency", "limit-training", "limit-retention", "limit-ga",
"limit-consent", "limit-unit-price", "limit-spend"]

View file

@ -20,7 +20,9 @@ strings standing for the envelopes a hop delivered; the verifier reads only thei
hashes. Then those of version 3 (§3.4), on version 2's price book, whose names begin `v3_` but
for the control that pairs one with a price book of version 1: the served hop's outputs stated
by their count and root, to which its receipt is held, 5,000 of them in one record the size of
the others.
the others. Then those of the third-party hop record (§6.2), whose names begin `third_party_`:
each document carries the records beside the receipts, under route records of versions 2 and
3, with each rule of §6.2 broken once.
```sh
# wai-rs: regenerates byte for byte, and every vector gets its outcome

File diff suppressed because one or more lines are too long

View file

@ -4032,6 +4032,13 @@
"section": "§3.1",
"status": "defined"
},
{
"name": "wai-route-third-party-hop",
"kind": "document-kind",
"document": "extensions/route-record.md",
"section": "§6.2",
"status": "defined"
},
{
"name": "wai-scene/1",
"kind": "scheme",

View file

@ -856,6 +856,7 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("wai-route-pricebook", WireKind::DocumentKind, ROUTE_RECORD, "§3.2", WireStatus::Defined),
wire("wai-route-policy", WireKind::DocumentKind, ROUTE_RECORD, "§3.3", WireStatus::Defined),
wire("wai-route-record", WireKind::DocumentKind, ROUTE_RECORD, "§5.1", WireStatus::Defined),
wire("wai-route-third-party-hop", WireKind::DocumentKind, ROUTE_RECORD, "§6.2", WireStatus::Defined),
wire("WRC1", WireKind::Magic, REVENUE_SHARE, "§3.1", WireStatus::Defined),
wire("WRS1", WireKind::Magic, REVENUE_SHARE, "§7.1", WireStatus::Defined),
wire("WOR1", WireKind::Magic, OPPORTUNITY, "§7.1", WireStatus::Defined),

View file

@ -36,6 +36,7 @@ use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
pub mod corpus;
pub mod third_party;
/// The record this extension registers.
pub const CAPABILITY: &str = "wai.route.record";
@ -99,6 +100,9 @@ pub enum Reason {
ReceiptInvalid,
ReceiptMismatch,
ReceiptOutputMismatch,
ThirdPartyInvalid,
ThirdPartyMismatch,
ThirdPartyOutputMismatch,
// Reused (§8).
Malformed,
UnknownVersion,
@ -108,7 +112,7 @@ pub enum Reason {
impl Reason {
/// The codes this extension adds, in §8's order.
pub const ALL: [Reason; 22] = [
pub const ALL: [Reason; 25] = [
Reason::RequestInvalid,
Reason::PricebookInvalid,
Reason::PolicyInvalid,
@ -131,6 +135,9 @@ impl Reason {
Reason::ReceiptInvalid,
Reason::ReceiptMismatch,
Reason::ReceiptOutputMismatch,
Reason::ThirdPartyInvalid,
Reason::ThirdPartyMismatch,
Reason::ThirdPartyOutputMismatch,
];
/// The codes it reuses (§8).
@ -160,6 +167,9 @@ impl Reason {
Reason::ReceiptInvalid => "receipt_invalid",
Reason::ReceiptMismatch => "receipt_mismatch",
Reason::ReceiptOutputMismatch => "receipt_output_mismatch",
Reason::ThirdPartyInvalid => "third_party_invalid",
Reason::ThirdPartyMismatch => "third_party_mismatch",
Reason::ThirdPartyOutputMismatch => "third_party_output_mismatch",
Reason::Malformed => "malformed",
Reason::UnknownVersion => "unknown_version",
Reason::BadKey => "bad_key",
@ -1261,6 +1271,9 @@ pub struct Accepted {
/// `None` when the receipts were not given; otherwise the hops whose receipts state the
/// unmetered marker.
pub unmetered: Option<Vec<u64>>,
/// `None` when the document has no `third_party`; otherwise what each third-party hop record
/// states, in the order of their hops (§6.2).
pub third_party: Option<Vec<third_party::Read>>,
}
/// A verification document's texts (§7.1).
@ -1271,13 +1284,19 @@ pub struct Document<'a> {
pub record: &'a str,
pub receipts: Option<Vec<&'a str>>,
pub keys: Option<&'a Map<String, Value>>,
/// The third-party hop records' texts (§6.2), when the document has them.
pub third_party: Option<Vec<&'a str>>,
}
impl<'a> Document<'a> {
/// Read a verification document (§7.1), or `malformed` at the document.
pub fn from_value(v: &'a Value) -> Result<Document<'a>, Refusal> {
let bad = || Refusal::new(Reason::Malformed, vec![]);
let o = exactly(v, &["request", "pricebook", "policy", "record", "receipts", "keys"]).ok_or_else(bad)?;
let mut members = vec!["request", "pricebook", "policy", "record", "receipts", "keys"];
if v.get("third_party").is_some() {
members.push("third_party");
}
let o = exactly(v, &members).ok_or_else(bad)?;
let s = |k: &str| o[k].as_str().ok_or_else(bad);
let receipts = match &o["receipts"] {
Value::Null => None,
@ -1292,7 +1311,12 @@ impl<'a> Document<'a> {
if receipts.is_some() && keys.is_none() {
return Err(bad());
}
Ok(Document { request: s("request")?, pricebook: s("pricebook")?, policy: s("policy")?, record: s("record")?, receipts, keys })
let third_party = match o.get("third_party") {
None => None,
Some(Value::Array(a)) if receipts.is_some() => Some(a.iter().map(|x| x.as_str().ok_or_else(bad)).collect::<Result<Vec<_>, _>>()?),
Some(_) => return Err(bad()),
};
Ok(Document { request: s("request")?, pricebook: s("pricebook")?, policy: s("policy")?, record: s("record")?, receipts, keys, third_party })
}
}
@ -1432,6 +1456,14 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
(Some(texts), Some(keys)) => Some(check_receipts(&rec, texts, keys)?),
_ => None,
};
// 9. The third-party hop records.
let third_party = match (&d.third_party, &d.receipts) {
(Some(texts), Some(receipts)) => {
let read: Vec<Value> = receipts.iter().map(|t| crate::oer::load(t.as_bytes()).expect("step 8 read it")).collect();
Some(third_party::check(&rec, texts, &read)?)
}
_ => None,
};
let served = served_hop.map(|a| (a["endpoint"].as_str().expect("a token").to_string(), a["hop"].as_u64().expect("a hop")));
let outputs = served_hop
.and_then(|a| a.get("outputs"))
@ -1455,6 +1487,7 @@ pub fn verify_document(d: &Document<'_>) -> Result<Accepted, Refusal> {
decision: routed.decision(),
removed: routed.candidates.iter().filter(|c| !c.removed.is_empty()).map(|c| (c.entry.endpoint.clone(), c.removed.iter().map(|(l, _)| *l).collect())).collect(),
unmetered,
third_party,
})
}
@ -1509,7 +1542,8 @@ fn check_receipts(rec: &Value, texts: &[&str], keys: &Map<String, Value>) -> Res
pub fn outcome_json(r: &Result<Accepted, Refusal>) -> Value {
match r {
Err(f) => json!({"outcome": "refuse", "reason": f.reason.code(), "at": f.at}),
Ok(a) => json!({
Ok(a) => {
let mut outcome = json!({
"outcome": "accept",
"digest": hex(&a.digest),
"job": a.job,
@ -1526,7 +1560,12 @@ pub fn outcome_json(r: &Result<Accepted, Refusal>) -> Value {
"removed": a.removed.iter().map(|(e, l)| json!({"endpoint": e, "limits": l})).collect::<Vec<_>>(),
"receipts": if a.unmetered.is_some() { "checked" } else { "not_checked" },
"unmetered": a.unmetered,
}),
});
if let Some(t) = &a.third_party {
outcome["third_party"] = t.iter().map(third_party::read_json).collect();
}
outcome
}
}
}

View file

@ -728,7 +728,7 @@ fn routed(request: Value, pricebook: Value, accepted: u64, attempts: &[Hop], out
/// The corpus, as `vectors.json` holds it.
pub fn generate() -> Result<String, String> {
let mut out = Vec::new();
for vec in vectors().into_iter().chain(vectors_2()).chain(vectors_3()) {
for vec in vectors().into_iter().chain(vectors_2()).chain(vectors_3()).chain(vectors_third_party()) {
let got = outcome_json(&verify(&vec.document));
if let Some((reason, at)) = &vec.expect
&& (got["outcome"] != "refuse" || got["reason"] != *reason || got["at"] != *at)
@ -1136,3 +1136,285 @@ fn vectors_3() -> Vec<Vector> {
v.push(refuse("v3_a_root_not_the_receipts", three.edited(|r| r["attempts"][0]["delivered"]["root"] = json!(two_of_three)).document(), "receipt_output_mismatch", ro(0)));
v
}
// ───────────────────────────── third-party hops (§6.2) ─────────────────────────────
/// What a router states of attempt `hop` of a job, sent to a third party, before it is signed.
fn third_party_body(j: &Job, hop: usize, outputs: &[[u8; 32]]) -> Value {
let a = &j.record["attempts"][hop - 1];
let c = j.record["candidates"].as_array().unwrap().iter().find(|c| c["endpoint"] == a["endpoint"]).unwrap();
let (start, end) = (a["start"].as_u64().unwrap(), a["end"].as_u64().unwrap());
let served = a["outcome"] == "served";
let origin = "https://api.provider.example";
let body = |s: &str| json!({"sha256": hex(&Sha256::digest(s.as_bytes())), "bytes": s.len()});
let mut exchanges = vec![json!({
"method": "POST", "url": format!("{origin}/v1/jobs"), "start": start, "end": start + 100_000, "sent": body("{\"prompt\":\"a\"}"),
"status": if served { 200 } else { 503 }, "received": body("{\"id\":\"task-1\"}"), "json": {"id": "task-1"},
})];
if served {
exchanges.push(json!({
"method": "GET", "url": "https://cdn.provider.example/outputs/task-1", "start": start + 100_000, "end": start + 200_000, "sent": body(""),
"status": 200, "received": body("the bytes it delivered"), "json": null,
}));
}
json!({
"job": j.record["job"], "hop": hop, "endpoint": a["endpoint"], "provider": c["provider"], "receipt": a["receipt"], "origin": origin,
"account": "operator", "provider_job": null, "outcome": a["outcome"],
"error": if served { Value::Null } else { json!({"class": a["error"], "code": "503"}) },
"exchanges": exchanges,
"outputs": outputs.iter().enumerate().map(|(i, h)| json!([format!("output-{i}.bin"), hex(h)])).collect::<Vec<_>>(),
"usage": if served { json!({"unit": c["unit"], "quantity": 34_560_000, "source": "the response's usage"}) } else { Value::Null },
"charge": null, "start": start, "end": end,
})
}
fn third_party_record(j: &Job, hop: usize, outputs: &[[u8; 32]]) -> Value {
super::third_party::sign(third_party_body(j, hop, outputs), &router())
}
/// A signed record edited and signed again.
fn third_party_edited(r: &Value, f: impl FnOnce(&mut Value)) -> Value {
let mut r = r.clone();
f(&mut r);
super::third_party::resign(r, &router())
}
fn with_third_party(j: &Job, records: &[Value]) -> Value {
with_third_party_texts(j, records.iter().map(canonical).collect())
}
fn with_third_party_texts(j: &Job, records: Vec<String>) -> Value {
let mut d = j.document();
d["third_party"] = json!(records);
d
}
fn vectors_third_party() -> Vec<Vector> {
let mut v = Vec::new();
let accept_third = |name: &'static str, document: Value| Vector { name, document, expect: None };
let alpha = || ("im.alpha", None, 1_000, 4_000_000, metered("im.alpha"));
let failover = |job: &str, n: usize| {
Job::new_3(
request_2(job, ENC, no_preference()),
&[("im.alpha", Some("server_error"), 1_000, 2_000_000, failed("im.alpha")), ("im.eta", None, 2_000_100, 9_000_000, model("im.eta"))],
&delivered(job, n),
true,
)
};
// ── accepted ──
let one = Job::new_3(request_2("job-4001", ENC, no_preference()), &[alpha()], &delivered("job-4001", 1), true);
let base = third_party_record(&one, 1, &delivered("job-4001", 1));
v.push(accept_third("third_party_served_on_the_operators_account", with_third_party(&one, std::slice::from_ref(&base))));
let three = Job::new_3(request_2("job-4002", ENC, no_preference()), &[alpha()], &delivered("job-4002", 3), true);
let three_outputs = delivered("job-4002", 3);
let three_record = third_party_record(&three, 1, &three_outputs);
v.push(accept_third(
"third_party_served_with_the_providers_charge_and_its_job",
with_third_party(
&three,
&[third_party_edited(&three_record, |r| {
r["provider_job"] = json!("task-1");
r["charge"] = json!({"amount": "0.0345", "unit": "USD", "source": "the response's cost"});
})],
),
));
let fo = failover("job-4003", 2);
let fo_failed = third_party_record(&fo, 1, &[]);
let fo_served = third_party_record(&fo, 2, &delivered("job-4003", 2));
v.push(accept_third(
"third_party_a_failure_the_provider_bills_beside_a_served_hop",
with_third_party(
&fo,
&[
third_party_edited(&fo_failed, |r| r["usage"] = json!({"unit": "subpixels", "quantity": 1_000, "source": "the provider bills this failure as it bills a success"})),
fo_served.clone(),
],
),
));
v.push(accept_third("third_party_a_failure_it_does_not_bill", with_third_party(&fo, std::slice::from_ref(&fo_failed))));
v.push(accept_third("third_party_records_given_out_of_order", with_third_party(&fo, &[fo_served.clone(), fo_failed.clone()])));
v.push(accept_third("third_party_on_the_customers_account", with_third_party(&one, &[third_party_edited(&base, |r| r["account"] = json!("customer"))])));
let listed = Job::new_2(request_2("job-4004", ENC, no_preference()), pricebook_2(), policy(3), &[alpha()], &delivered("job-4004", 2), true);
v.push(accept_third("third_party_under_a_record_of_version_2", with_third_party(&listed, &[third_party_record(&listed, 1, &delivered("job-4004", 2))])));
v.push(accept_third(
"third_party_a_response_as_a_record_keeps_it",
with_third_party(
&one,
&[third_party_edited(&base, |r| {
r["exchanges"][0]["json"] = super::third_party::kept_response(&json!({"output": ["https://cdn.provider.example/v.mp4?sig=bearer"], "progress": 0.4, "n": 3, "neg": -1}));
})],
),
));
v.push(accept_third("third_party_no_record_given", with_third_party(&one, &[])));
// ── refused: the document ──
let no_receipts = Job::new_3(request_2("job-4001", ENC, no_preference()), &[alpha()], &delivered("job-4001", 1), false);
v.push(refuse("third_party_without_the_receipts", with_third_party(&no_receipts, std::slice::from_ref(&base)), "malformed", json!([])));
let mut not_a_list = one.document();
not_a_list["third_party"] = Value::Null;
v.push(refuse("third_party_not_a_list", not_a_list, "malformed", json!([])));
let mut not_text = one.document();
not_text["third_party"] = json!([1]);
v.push(refuse("third_party_not_text", not_text, "malformed", json!([])));
// ── refused: the record as a document ──
let at0 = |m: &str| json!(["third_party", 0, m]);
v.push(refuse("third_party_not_a_document", with_third_party_texts(&one, vec!["{".into()]), "malformed", json!(["third_party", 0])));
let fraction = canonical(&base).replacen("\"id\":\"task-1\"", "\"progress\":0.4", 1);
v.push(refuse("third_party_a_fraction_left_in_a_response", with_third_party_texts(&one, vec![fraction]), "malformed", json!(["third_party", 0])));
v.push(refuse("third_party_another_kind", with_third_party(&one, &[third_party_edited(&base, |r| r["kind"] = json!("wai-route-record"))]), "malformed", json!(["third_party", 0])));
v.push(refuse("third_party_of_version_2", with_third_party(&one, &[third_party_edited(&base, |r| r["version"] = json!(2))]), "unknown_version", at0("version")));
// ── refused: the record's members and forms ──
let invalid = |name: &'static str, j: &Job, r: &Value, f: &dyn Fn(&mut Value)| refuse(name, with_third_party(j, &[third_party_edited(r, |x| f(x))]), "third_party_invalid", json!(["third_party", 0]));
v.push(invalid("third_party_a_member_missing", &one, &base, &|r| { r.as_object_mut().unwrap().remove("charge"); }));
v.push(invalid("third_party_a_member_added", &one, &base, &|r| r["energy"] = json!("not reported")));
v.push(invalid("third_party_version_as_text", &one, &base, &|r| r["version"] = json!("1")));
v.push(invalid("third_party_a_key_stated", &one, &base, &|r| r["authorization"] = json!("Bearer secret")));
v.push(invalid("third_party_an_exchange_with_headers", &one, &base, &|r| r["exchanges"][0]["headers"] = json!({"authorization": "Bearer secret"})));
v.push(invalid("third_party_a_url_with_its_query", &one, &base, &|r| r["exchanges"][1]["url"] = json!("https://cdn.provider.example/outputs/task-1?sig=bearer")));
v.push(invalid("third_party_a_url_with_its_fragment", &one, &base, &|r| r["exchanges"][1]["url"] = json!("https://cdn.provider.example/outputs/task-1#part")));
v.push(invalid("third_party_a_url_of_another_scheme", &one, &base, &|r| r["exchanges"][1]["url"] = json!("ftp://cdn.provider.example/outputs/task-1")));
v.push(invalid("third_party_a_response_keeping_a_signed_link", &one, &base, &|r| r["exchanges"][0]["json"] = json!({"output": ["https://cdn.provider.example/v.mp4?sig=bearer"]})));
v.push(invalid("third_party_a_response_keeping_one_in_a_name", &one, &base, &|r| r["exchanges"][0]["json"] = json!({"https://cdn.provider.example/v.mp4#sig": 1})));
v.push(invalid("third_party_an_origin_with_a_path", &one, &base, &|r| r["origin"] = json!("https://api.provider.example/v1")));
v.push(invalid("third_party_an_origin_over_http", &one, &base, &|r| r["origin"] = json!("http://api.provider.example")));
v.push(invalid("third_party_an_origin_in_capitals", &one, &base, &|r| r["origin"] = json!("https://API.provider.example")));
v.push(invalid("third_party_a_port_of_no_digits", &one, &base, &|r| r["origin"] = json!("https://api.provider.example:")));
v.push(invalid("third_party_served_with_no_outputs", &one, &base, &|r| r["outputs"] = json!([])));
v.push(invalid("third_party_served_with_no_usage", &one, &base, &|r| r["usage"] = Value::Null));
v.push(invalid("third_party_served_with_an_error", &one, &base, &|r| r["error"] = json!({"class": "timeout", "code": "x"})));
v.push(invalid("third_party_failed_with_outputs", &fo, &fo_failed, &|r| r["outputs"] = json!([["a.bin", "00".repeat(32)]])));
v.push(invalid("third_party_failed_with_no_error", &fo, &fo_failed, &|r| r["error"] = Value::Null));
v.push(invalid("third_party_an_error_of_no_class", &fo, &fo_failed, &|r| r["error"]["class"] = json!("bad_luck")));
v.push(invalid("third_party_an_outcome_of_neither", &one, &base, &|r| r["outcome"] = json!("partly")));
v.push(invalid("third_party_a_name_twice", &three, &three_record, &|r| r["outputs"][1][0] = r["outputs"][0][0].clone()));
v.push(invalid("third_party_an_object_twice", &three, &three_record, &|r| r["outputs"][1][1] = r["outputs"][0][1].clone()));
v.push(invalid("third_party_a_name_with_a_slash", &one, &base, &|r| r["outputs"][0][0] = json!("../a.bin")));
v.push(invalid("third_party_a_content_hash_in_capitals", &one, &base, &|r| r["outputs"][0][1] = json!(r["outputs"][0][1].as_str().unwrap().to_uppercase())));
v.push(invalid("third_party_no_exchange", &one, &base, &|r| r["exchanges"] = json!([])));
v.push(invalid("third_party_exchanges_out_of_order", &one, &base, &|r| {
let s = r["exchanges"][1]["start"].as_u64().unwrap();
r["exchanges"][0]["start"] = json!(s + 50_000);
r["exchanges"][0]["end"] = json!(s + 60_000);
}));
v.push(invalid("third_party_an_exchange_before_the_start", &one, &base, &|r| r["exchanges"][0]["start"] = json!(r["start"].as_u64().unwrap() - 1)));
v.push(invalid("third_party_an_exchange_past_the_end", &one, &base, &|r| r["exchanges"][1]["end"] = json!(r["end"].as_u64().unwrap() + 1)));
v.push(invalid("third_party_an_exchange_ending_before_it_began", &one, &base, &|r| r["exchanges"][0]["end"] = json!(r["exchanges"][0]["start"].as_u64().unwrap() - 1)));
v.push(invalid("third_party_a_status_of_no_response_code", &one, &base, &|r| r["exchanges"][0]["status"] = json!(99)));
v.push(invalid("third_party_no_response_with_bytes", &one, &base, &|r| {
r["exchanges"][1]["status"] = json!(0);
}));
v.push(invalid("third_party_no_response_with_json", &one, &base, &|r| {
r["exchanges"][0]["status"] = json!(0);
r["exchanges"][0]["received"] = json!({"sha256": hex(&Sha256::digest(b"")), "bytes": 0});
}));
v.push(invalid("third_party_a_method_in_lowercase", &one, &base, &|r| r["exchanges"][0]["method"] = json!("post")));
v.push(invalid("third_party_a_body_not_named_by_its_digest", &one, &base, &|r| r["exchanges"][0]["sent"]["sha256"] = json!("00")));
v.push(invalid("third_party_an_account_of_no_one", &one, &base, &|r| r["account"] = json!("someone")));
v.push(invalid("third_party_a_charge_without_where", &one, &base, &|r| r["charge"] = json!({"amount": "1", "unit": "USD"})));
v.push(invalid("third_party_a_usage_of_no_quantity", &one, &base, &|r| { r["usage"].as_object_mut().unwrap().remove("quantity"); }));
v.push(invalid("third_party_a_hop_of_0", &one, &base, &|r| r["hop"] = json!(0)));
v.push(invalid("third_party_a_signer_in_capitals", &one, &base, &|r| r["signer"] = json!(r["signer"].as_str().unwrap().to_uppercase())));
// Each form and bound of §6.2, at its first value past it.
let long = |n: usize| "x".repeat(n);
v.push(invalid("third_party_a_quantity_as_text", &one, &base, &|r| r["usage"]["quantity"] = json!("1")));
v.push(invalid("third_party_a_usage_unit_of_no_form", &one, &base, &|r| r["usage"]["unit"] = json!("Sub pixels")));
v.push(invalid("third_party_a_usage_source_empty", &one, &base, &|r| r["usage"]["source"] = json!("")));
v.push(invalid("third_party_a_usage_source_too_long", &one, &base, &|r| r["usage"]["source"] = json!(long(513))));
v.push(invalid("third_party_a_charge_amount_empty", &one, &base, &|r| r["charge"] = json!({"amount": "", "unit": "USD", "source": "s"})));
v.push(invalid("third_party_a_charge_amount_too_long", &one, &base, &|r| r["charge"] = json!({"amount": long(65), "unit": "USD", "source": "s"})));
v.push(invalid("third_party_a_charge_unit_too_long", &one, &base, &|r| r["charge"] = json!({"amount": "1", "unit": long(65), "source": "s"})));
v.push(invalid("third_party_a_charge_source_too_long", &one, &base, &|r| r["charge"] = json!({"amount": "1", "unit": "USD", "source": long(513)})));
v.push(invalid("third_party_an_error_code_empty", &fo, &fo_failed, &|r| r["error"]["code"] = json!("")));
v.push(invalid("third_party_an_error_code_too_long", &fo, &fo_failed, &|r| r["error"]["code"] = json!(long(257))));
v.push(invalid("third_party_a_provider_job_empty", &one, &base, &|r| r["provider_job"] = json!("")));
v.push(invalid("third_party_a_provider_job_too_long", &one, &base, &|r| r["provider_job"] = json!(long(257))));
v.push(invalid("third_party_a_job_not_a_token", &one, &base, &|r| r["job"] = json!("job 4001")));
v.push(invalid("third_party_an_endpoint_not_a_token", &one, &base, &|r| r["endpoint"] = json!("im alpha")));
v.push(invalid("third_party_a_provider_not_a_token", &one, &base, &|r| r["provider"] = json!("prov alpha")));
v.push(invalid("third_party_a_receipt_not_an_id", &one, &base, &|r| r["receipt"] = json!("ab")));
v.push(invalid("third_party_a_hop_as_text", &one, &base, &|r| r["hop"] = json!("1")));
v.push(invalid("third_party_a_start_as_text", &one, &base, &|r| r["start"] = json!("1")));
v.push(invalid("third_party_a_host_too_long", &one, &base, &|r| r["origin"] = json!(format!("https://{}", long(254)))));
v.push(invalid("third_party_a_port_too_long", &one, &base, &|r| r["origin"] = json!("https://api.provider.example:123456")));
v.push(invalid("third_party_a_method_too_long", &one, &base, &|r| r["exchanges"][0]["method"] = json!("X".repeat(17))));
v.push(invalid("third_party_a_url_of_nothing_after_its_scheme", &one, &base, &|r| r["exchanges"][0]["url"] = json!("https://")));
v.push(invalid("third_party_a_url_too_long", &one, &base, &|r| r["exchanges"][0]["url"] = json!(format!("https://{}", long(2041)))));
v.push(invalid("third_party_a_body_of_bytes_as_text", &one, &base, &|r| r["exchanges"][0]["received"]["bytes"] = json!("1")));
v.push(invalid("third_party_a_body_of_no_digest", &one, &base, &|r| r["exchanges"][0]["received"]["sha256"] = json!("00")));
v.push(invalid("third_party_a_status_as_text", &one, &base, &|r| r["exchanges"][0]["status"] = json!("200")));
v.push(invalid("third_party_a_status_past_the_codes", &one, &base, &|r| r["exchanges"][0]["status"] = json!(600)));
v.push(invalid("third_party_an_exchange_start_as_text", &one, &base, &|r| r["exchanges"][0]["start"] = json!("1")));
v.push(invalid("third_party_a_name_empty", &one, &base, &|r| r["outputs"][0][0] = json!("")));
v.push(invalid("third_party_a_name_too_long", &one, &base, &|r| r["outputs"][0][0] = json!(long(256))));
v.push(invalid("third_party_an_output_of_one_member", &one, &base, &|r| r["outputs"][0] = json!(["a.bin"])));
v.push(invalid("third_party_an_output_of_three_members", &one, &base, &|r| {
let h = r["outputs"][0][1].clone();
r["outputs"][0] = json!(["a.bin", h, "x"]);
}));
v.push(invalid("third_party_more_exchanges_than_it_holds", &one, &base, &|r| {
let x = r["exchanges"][1].clone();
r["exchanges"] = json!(vec![x; 1025]);
}));
v.push(invalid("third_party_more_outputs_than_it_holds", &one, &base, &|r| {
r["outputs"] = json!((0..1025u32).map(|i| json!([format!("{i}"), hex(&super::content_hash(&i.to_be_bytes()))])).collect::<Vec<_>>());
}));
// ── refused: its signature ──
let mut changed = base.clone();
changed["exchanges"][0]["status"] = json!(201);
v.push(refuse("third_party_changed_after_it_was_signed", with_third_party(&one, &[changed]), "bad_signature", at0("sig")));
let mut small = base.clone();
small["signer"] = json!(hex(&crate::sig::IDENTITY));
v.push(refuse("third_party_a_signer_of_small_order", with_third_party(&one, &[small]), "bad_key", at0("sig")));
// Another domain: the route record's own signature over the record's form.
let mut other_domain = base.clone();
other_domain["sig"] = json!("");
let sig = router().sign(&super::signing_bytes(&other_domain));
other_domain["sig"] = json!(hex(&sig.to_bytes()));
v.push(refuse("third_party_signed_under_the_route_records_domain", with_third_party(&one, &[other_domain]), "bad_signature", at0("sig")));
// ── refused: its binding ──
let mismatch = |name: &'static str, j: &Job, r: &Value, m: &str, f: &dyn Fn(&mut Value)| refuse(name, with_third_party(j, &[third_party_edited(r, |x| f(x))]), "third_party_mismatch", at0(m));
v.push(refuse(
"third_party_another_routers",
with_third_party(&one, &[super::third_party::sign(third_party_body(&one, 1, &delivered("job-4001", 1)), &key("another router"))]),
"third_party_mismatch",
at0("signer"),
));
v.push(mismatch("third_party_another_jobs", &one, &base, "job", &|r| r["job"] = json!("job-other")));
v.push(mismatch("third_party_a_hop_that_is_no_attempt", &one, &base, "hop", &|r| r["hop"] = json!(2)));
v.push(refuse("third_party_a_second_record_of_a_hop", with_third_party(&one, &[base.clone(), base.clone()]), "third_party_mismatch", json!(["third_party", 1, "hop"])));
v.push(mismatch("third_party_another_endpoints", &one, &base, "endpoint", &|r| r["endpoint"] = json!("im.beta")));
v.push(mismatch("third_party_another_receipts", &one, &base, "receipt", &|r| r["receipt"] = json!(format!("sha256:{}", "ab".repeat(32)))));
v.push(mismatch("third_party_failed_where_the_attempt_served", &one, &base, "outcome", &|r| {
r["outcome"] = json!("failed");
r["error"] = json!({"class": "timeout", "code": "x"});
r["outputs"] = json!([]);
}));
v.push(mismatch("third_party_served_where_the_attempt_failed", &fo, &fo_failed, "outcome", &|r| {
r["outcome"] = json!("served");
r["error"] = Value::Null;
r["outputs"] = json!([["a.bin", "00".repeat(32)]]);
r["usage"] = json!({"unit": "subpixels", "quantity": 1, "source": "s"});
}));
v.push(mismatch("third_party_another_error", &fo, &fo_failed, "error", &|r| r["error"]["class"] = json!("timeout")));
v.push(mismatch("third_party_another_provider", &one, &base, "provider", &|r| r["provider"] = json!("prov-other")));
v.push(mismatch("third_party_a_usage_in_another_unit", &one, &base, "usage", &|r| r["usage"]["unit"] = json!("seconds")));
v.push(mismatch("third_party_a_failures_usage_in_another_unit", &fo, &fo_failed, "usage", &|r| r["usage"] = json!({"unit": "seconds", "quantity": 1, "source": "s"})));
v.push(mismatch("third_party_begun_before_the_attempt", &one, &base, "start", &|r| r["start"] = json!(r["start"].as_u64().unwrap() - 1)));
v.push(mismatch("third_party_ended_after_the_attempt", &one, &base, "end", &|r| r["end"] = json!(r["end"].as_u64().unwrap() + 1)));
// ── refused: what its hop delivered ──
let output_mismatch = |name: &'static str, d: Value| refuse(name, d, "third_party_output_mismatch", at0("outputs"));
v.push(output_mismatch("third_party_outputs_the_receipt_does_not_name", with_third_party(&one, &[third_party_record(&one, 1, &delivered("job-other", 1))])));
let mut reversed = three_outputs.clone();
reversed.reverse();
v.push(output_mismatch("third_party_outputs_in_another_order", with_third_party(&three, &[third_party_record(&three, 1, &reversed)])));
let counted = three.edited(|r| r["attempts"][0]["delivered"]["count"] = json!(4));
v.push(output_mismatch("third_party_another_number_than_the_attempts_count", with_third_party(&counted, std::slice::from_ref(&three_record))));
v
}

View file

@ -0,0 +1,348 @@
//! The third-party hop record (extensions/route-record.md §6.2): what a router sent a third
//! party for one hop, what came back, and what the provider bills, signed by the router and
//! bound to the hop's attempt and receipt.
//!
//! It holds no secret. The key the hop ran on is never stated, only whose account it is; an
//! exchange has no member for a header, since a key travels in one; and a URL is stated without
//! its query, since a provider's signed download link is a bearer token. [`url_without_query`]
//! and [`kept_response`] make a URL and a response what the record may state.
use ed25519_dalek::{Signer, SigningKey};
use serde_json::{json, Value};
use super::{at, canonical, digest, exactly, hex, int, output_commitment, read_document, receipt_id, tok, unhex, unit, Reason, Refusal, ERRORS, MAX_OUTPUTS};
pub const KIND: &str = "wai-route-third-party-hop";
pub const VERSION: u64 = 1;
/// The domain a record is signed under (§6.2).
pub const SIGN_DOMAIN: &[u8] = b"wai:route-third-party-hop\x01";
/// The most exchanges a record states.
pub const MAX_EXCHANGES: usize = 1024;
/// The hop ran on the router's operator's own account with the provider.
pub const OPERATOR: &str = "operator";
/// The hop ran on the account of the job's owner.
pub const CUSTOMER: &str = "customer";
/// The members of a record (§6.2).
pub const MEMBERS: [&str; 20] = [
"kind", "version", "job", "hop", "endpoint", "provider", "receipt", "origin", "account", "provider_job", "outcome", "error", "exchanges", "outputs", "usage", "charge", "start",
"end", "signer", "sig",
];
/// The members of an exchange (§6.2).
pub const EXCHANGE_MEMBERS: [&str; 8] = ["method", "url", "start", "end", "sent", "status", "received", "json"];
/// What a verification reads of a record that holds (§7.3).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Read {
pub hop: u64,
pub digest: [u8; 32],
/// [`OPERATOR`] or [`CUSTOMER`].
pub account: String,
/// Its `usage`: `null` or the object.
pub usage: Value,
/// Its `charge`: `null` or the object.
pub charge: Value,
}
/// A URL as a record states it: cut at its first `?` or `#`.
pub fn url_without_query(url: &str) -> &str {
url.split(['?', '#']).next().unwrap_or("")
}
fn is_url(s: &str) -> bool {
s.starts_with("https://") || s.starts_with("http://")
}
/// A JSON response as a record keeps it (§6.2): each number that is not an integer 0 to 2⁵³ − 1
/// written as its decimal text, and each string that begins `https://` or `http://` cut at its
/// first `?` or `#`, a member's name among them.
pub fn kept_response(v: &Value) -> Value {
let kept = |s: &str| if is_url(s) { url_without_query(s).to_string() } else { s.to_string() };
match v {
Value::String(s) => Value::String(kept(s)),
Value::Number(n) if n.as_u64().is_none_or(|u| u > crate::jcs::MAX_INTEGER) => Value::String(n.to_string()),
Value::Array(a) => Value::Array(a.iter().map(kept_response).collect()),
Value::Object(o) => Value::Object(o.iter().map(|(k, v)| (kept(k), kept_response(v))).collect()),
other => other.clone(),
}
}
/// The bytes a record is signed over (§6.2): the domain, then the canonical form of the record
/// without `sig`.
pub fn signing_bytes(record: &Value) -> Vec<u8> {
let mut r = record.clone();
if let Some(o) = r.as_object_mut() {
o.remove("sig");
}
let mut m = SIGN_DOMAIN.to_vec();
m.extend_from_slice(canonical(&r).as_bytes());
m
}
/// Sign a record body with the router's `key`: sets `kind`, `version`, `signer` and `sig`.
pub fn sign(mut body: Value, key: &SigningKey) -> Value {
let o = body.as_object_mut().expect("a record is an object");
o.insert("kind".into(), json!(KIND));
o.insert("version".into(), json!(VERSION));
o.insert("signer".into(), json!(hex(&key.verifying_key().to_bytes())));
o.insert("sig".into(), json!(""));
let sig = key.sign(&signing_bytes(&body));
body["sig"] = json!(hex(&sig.to_bytes()));
body
}
/// Sign a record again over whatever it states, keeping its `signer`: for the corpus's controls.
#[doc(hidden)]
pub fn resign(mut body: Value, key: &SigningKey) -> Value {
body["sig"] = json!("");
let sig = key.sign(&signing_bytes(&body));
body["sig"] = json!(hex(&sig.to_bytes()));
body
}
fn text(v: &Value, max: usize) -> bool {
v.as_str().is_some_and(|s| !s.is_empty() && s.len() <= max)
}
fn lowercase_hex(s: &str, digits: usize) -> bool {
s.len() == digits && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}
/// `https://`, then a host, then optionally `:` and a port, and nothing else.
fn origin(s: &str) -> bool {
let Some(rest) = s.strip_prefix("https://") else { return false };
let (host, port) = match rest.split_once(':') {
Some((h, p)) => (h, Some(p)),
None => (rest, None),
};
!host.is_empty()
&& host.len() <= 253
&& host.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'.' || c == b'-')
&& port.is_none_or(|p| (1..=5).contains(&p.len()) && p.bytes().all(|c| c.is_ascii_digit()))
}
fn url(s: &str) -> bool {
let rest = s.strip_prefix("https://").or_else(|| s.strip_prefix("http://"));
rest.is_some_and(|r| !r.is_empty() && r.len() <= 2040) && !s.contains(['?', '#'])
}
/// Whether a kept response holds a link's query: a string, a member's name among them, that begins
/// `https://` or `http://` and holds a `?` or a `#`.
fn keeps_a_query(v: &Value) -> bool {
let bad = |s: &str| is_url(s) && s.contains(['?', '#']);
match v {
Value::String(s) => bad(s),
Value::Array(a) => a.iter().any(keeps_a_query),
Value::Object(o) => o.iter().any(|(k, v)| bad(k) || keeps_a_query(v)),
_ => false,
}
}
/// `sha256` of no bytes.
const NOTHING: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
fn body_form(v: &Value) -> Option<(&str, u64)> {
let o = exactly(v, &["sha256", "bytes"])?;
let d = o["sha256"].as_str().filter(|s| lowercase_hex(s, 64))?;
Some((d, int(&o["bytes"])?))
}
/// An exchange of §6.2, after `previous`, the start of the one before it, or the record's start.
fn exchange_form(x: &Value, previous: u64, to: u64) -> Option<u64> {
let o = exactly(x, &EXCHANGE_MEMBERS)?;
let m = o["method"].as_str()?;
if m.is_empty() || m.len() > 16 || !m.bytes().all(|c| c.is_ascii_uppercase()) || !o["url"].as_str().is_some_and(url) {
return None;
}
let (start, end) = (int(&o["start"])?, int(&o["end"])?);
if start > end || end > to || start < previous {
return None;
}
body_form(&o["sent"])?;
let status = int(&o["status"])?;
if status != 0 && !(100..=599).contains(&status) {
return None;
}
let (received, bytes) = body_form(&o["received"])?;
if status == 0 && (received != NOTHING || bytes != 0 || !o["json"].is_null()) {
return None;
}
if keeps_a_query(&o["json"]) {
return None;
}
Some(start)
}
fn usage_form(v: &Value) -> bool {
exactly(v, &["unit", "quantity", "source"]).is_some_and(|o| o["unit"].as_str().is_some_and(unit) && int(&o["quantity"]).is_some() && text(&o["source"], 512))
}
fn charge_form(v: &Value) -> bool {
exactly(v, &["amount", "unit", "source"]).is_some_and(|o| text(&o["amount"], 64) && text(&o["unit"], 64) && text(&o["source"], 512))
}
fn outputs_form(v: &Value) -> Option<Vec<[u8; 32]>> {
let a = v.as_array()?;
let mut names = std::collections::BTreeSet::new();
let mut hashes = Vec::with_capacity(a.len());
for e in a {
let pair = e.as_array().filter(|p| p.len() == 2)?;
let (name, h) = (pair[0].as_str()?, pair[1].as_str()?);
if name.is_empty() || name.len() > 255 || name.contains('/') || !names.insert(name) {
return None;
}
let h: [u8; 32] = unhex(h)?;
if hashes.contains(&h) {
return None;
}
hashes.push(h);
}
Some(hashes)
}
/// A record of §6.2's members and forms, a kept response holding no link's query.
pub fn form(r: &Value) -> bool {
let Some(o) = exactly(r, &MEMBERS) else { return false };
let (Some(start), Some(end)) = (int(&o["start"]), int(&o["end"])) else { return false };
let Some(outputs) = outputs_form(&o["outputs"]) else { return false };
let shape = match o["outcome"].as_str() {
Some("served") => o["error"].is_null() && (1..=MAX_OUTPUTS).contains(&outputs.len()) && usage_form(&o["usage"]),
Some("failed") => {
exactly(&o["error"], &["class", "code"]).is_some_and(|e| e["class"].as_str().is_some_and(|c| ERRORS.contains(&c)) && text(&e["code"], 256))
&& outputs.is_empty()
&& (o["usage"].is_null() || usage_form(&o["usage"]))
}
_ => false,
};
let Some(exchanges) = o["exchanges"].as_array() else { return false };
let mut previous = start;
for x in exchanges {
match exchange_form(x, previous, end) {
Some(s) => previous = s,
None => return false,
}
}
// `start` at most `end` follows: an exchange lies between them, and there is one.
shape
&& o["version"] == json!(VERSION)
&& (1..=MAX_EXCHANGES).contains(&exchanges.len())
&& tok(&o["job"]).is_some()
&& int(&o["hop"]).is_some_and(|h| h >= 1)
&& tok(&o["endpoint"]).is_some()
&& tok(&o["provider"]).is_some()
&& o["receipt"].as_str().is_some_and(receipt_id)
&& o["origin"].as_str().is_some_and(origin)
&& matches!(o["account"].as_str(), Some(OPERATOR | CUSTOMER))
&& (o["provider_job"].is_null() || text(&o["provider_job"], 256))
&& (o["charge"].is_null() || charge_form(&o["charge"]))
&& o["signer"].as_str().and_then(unhex::<32>).is_some()
&& o["sig"].as_str().and_then(unhex::<64>).is_some()
}
/// §7.2 step 9: each record read, its signature checked and held to its hop. `receipts` are the
/// hop receipts step 8 has already checked.
pub(super) fn check(rec: &Value, texts: &[&str], receipts: &[Value]) -> Result<Vec<Read>, Refusal> {
let mut read = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for (i, t) in texts.iter().enumerate() {
let place = |m: Option<&str>| -> Vec<Value> {
match m {
Some(m) => at(&[json!("third_party"), json!(i), json!(m)]),
None => at(&[json!("third_party"), json!(i)]),
}
};
let r = read_document(t).ok_or_else(|| Refusal::new(Reason::Malformed, place(None)))?;
if r.get("kind").and_then(Value::as_str) != Some(KIND) {
return Err(Refusal::new(Reason::Malformed, place(None)));
}
if r.get("version").and_then(int).is_some_and(|v| v != VERSION) {
return Err(Refusal::new(Reason::UnknownVersion, place(Some("version"))));
}
if !form(&r) {
return Err(Refusal::new(Reason::ThirdPartyInvalid, place(None)));
}
let signer: [u8; 32] = unhex(r["signer"].as_str().expect("a key")).expect("a key");
let sig: [u8; 64] = unhex(r["sig"].as_str().expect("a signature")).expect("a signature");
crate::sig::verify_strict(&signer, &signing_bytes(&r), &sig).map_err(|e| {
let reason = match e {
crate::sig::SigError::BadKey => Reason::BadKey,
crate::sig::SigError::BadSignature => Reason::BadSignature,
};
Refusal::new(reason, place(Some("sig")))
})?;
bind(rec, &r, &mut seen, &place)?;
delivered(rec, &r, receipts).then_some(()).ok_or_else(|| Refusal::new(Reason::ThirdPartyOutputMismatch, place(Some("outputs"))))?;
read.push(Read {
hop: r["hop"].as_u64().expect("a hop"),
digest: digest(&r),
account: r["account"].as_str().expect("an account").to_string(),
usage: r["usage"].clone(),
charge: r["charge"].clone(),
});
}
read.sort_by_key(|x| x.hop);
Ok(read)
}
/// The attempt a record's `hop` names.
fn attempt<'a>(rec: &'a Value, hop: u64) -> Option<&'a Value> {
let i = usize::try_from(hop.checked_sub(1)?).ok()?;
rec["attempts"].as_array().expect("a list").get(i)
}
fn bind(rec: &Value, r: &Value, seen: &mut std::collections::BTreeSet<u64>, place: &dyn Fn(Option<&str>) -> Vec<Value>) -> Result<(), Refusal> {
let refuse = |m: &str| Refusal::new(Reason::ThirdPartyMismatch, place(Some(m)));
for m in ["signer", "job"] {
if r[m] != rec[m] {
return Err(refuse(m));
}
}
let hop = r["hop"].as_u64().expect("a hop");
let a = attempt(rec, hop).ok_or_else(|| refuse("hop"))?;
if !seen.insert(hop) {
return Err(refuse("hop"));
}
for m in ["endpoint", "receipt", "outcome"] {
if r[m] != a[m] {
return Err(refuse(m));
}
}
if r["outcome"] == "failed" && r["error"]["class"] != a["error"] {
return Err(refuse("error"));
}
let c = rec["candidates"].as_array().expect("a list").iter().find(|c| c["endpoint"] == r["endpoint"]).ok_or_else(|| refuse("endpoint"))?;
if r["provider"] != c["provider"] {
return Err(refuse("provider"));
}
if !r["usage"].is_null() && r["usage"]["unit"] != c["unit"] {
return Err(refuse("usage"));
}
if r["start"].as_u64() < a["start"].as_u64() {
return Err(refuse("start"));
}
if r["end"].as_u64() > a["end"].as_u64() {
return Err(refuse("end"));
}
Ok(())
}
/// What a served record names is what its hop delivered (§6.2): its outputs' commitment is its
/// receipt's `subject.output`, to which §6 has held the attempt's `outputs` or `root`, and in a
/// record of version 3 their number is the attempt's `count`, which the receipt does not name. A
/// failed record names nothing.
fn delivered(rec: &Value, r: &Value, receipts: &[Value]) -> bool {
if r["outcome"] != "served" {
return true;
}
let hashes: Vec<[u8; 32]> = outputs_form(&r["outputs"]).expect("its form was read");
let root = hex(&output_commitment(&hashes));
let a = attempt(rec, r["hop"].as_u64().expect("a hop")).expect("its binding was read");
let receipt = receipts.iter().find(|x| x.get("receipt_id") == Some(&a["receipt"])).expect("step 8 found it");
receipt.get("subject").and_then(|s| s.get("output")).and_then(Value::as_str) == Some(root.as_str())
&& a.get("delivered").is_none_or(|d| d["count"].as_u64() == Some(hashes.len() as u64))
}
/// A read record's outcome (§7.3).
pub fn read_json(r: &Read) -> Value {
json!({"hop": r.hop, "digest": hex(&r.digest), "account": r.account, "usage": r.usage, "charge": r.charge})
}

View file

@ -52,6 +52,46 @@ fn the_corpus_refuses_every_reason() {
}
}
/// §10's third-party vectors: what each record states is read, in the order of its hops, and a
/// record is only as good as its signature and its hop.
#[test]
fn the_third_party_vectors_are_the_cases_section_10_names() {
let d = doc();
let outcome = |name: &str| vector(&d, name)["outcome"].clone();
let one = outcome("third_party_served_on_the_operators_account");
assert_eq!(one["outcome"], "accept");
let read = &one["third_party"][0];
assert_eq!((read["hop"].as_u64(), read["account"].as_str(), read["usage"]["unit"].as_str(), &read["charge"]), (Some(1), Some("operator"), Some("subpixels"), &Value::Null));
assert_eq!(outcome("third_party_served_with_the_providers_charge_and_its_job")["third_party"][0]["charge"]["amount"], "0.0345");
let billed = outcome("third_party_a_failure_the_provider_bills_beside_a_served_hop");
assert_eq!(billed["third_party"][0]["usage"]["quantity"], 1_000, "what the provider bills for a failure");
assert_eq!(billed["third_party"].as_array().unwrap().len(), 2);
assert!(outcome("third_party_a_failure_it_does_not_bill")["third_party"][0]["usage"].is_null());
let hops: Vec<u64> = outcome("third_party_records_given_out_of_order")["third_party"].as_array().unwrap().iter().map(|t| t["hop"].as_u64().unwrap()).collect();
assert_eq!(hops, [1, 2], "the order of their hops, not of the document");
assert_eq!(outcome("third_party_on_the_customers_account")["third_party"][0]["account"], "customer");
assert_eq!(outcome("third_party_under_a_record_of_version_2")["outcome"], "accept");
assert_eq!(outcome("third_party_no_record_given")["third_party"], serde_json::json!([]));
// A document with no such member reads as before, its outcome with none.
assert!(outcome("v3_served_one_output").get("third_party").is_none());
// A kept response holds a signed link's URL without its query, and a fraction as its text.
let kept = vector(&d, "third_party_a_response_as_a_record_keeps_it");
let text = kept["document"]["third_party"][0].as_str().unwrap();
assert!(text.contains("https://cdn.provider.example/v.mp4\"") && !text.contains("bearer") && text.contains("\"0.4\""));
// The three reasons, each from a control of one defect.
for (name, reason) in [
("third_party_a_key_stated", "third_party_invalid"),
("third_party_a_url_with_its_query", "third_party_invalid"),
("third_party_a_response_keeping_a_signed_link", "third_party_invalid"),
("third_party_another_routers", "third_party_mismatch"),
("third_party_a_usage_in_another_unit", "third_party_mismatch"),
("third_party_outputs_the_receipt_does_not_name", "third_party_output_mismatch"),
("third_party_another_number_than_the_attempts_count", "third_party_output_mismatch"),
] {
assert_eq!(outcome(name)["reason"], reason, "{name}");
}
}
/// The accepted vectors are §10's: a billed first try, a failover whose failed hop is unmetered
/// and not billed, a hop past the deadline not billed, every allowed survivor failed, no
/// survivor at all, each way a decision is decided, and every limit removing a candidate.