route-record: a third party's hop has a signed record of what was sent, what came back, and what the provider bills #572
12 changed files with 4387 additions and 16 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
348
wai/wai-rs/src/route_record/third_party.rs
Normal file
348
wai/wai-rs/src/route_record/third_party.rs
Normal 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})
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue