open-standards/wai/live-conformance/impression_aggregate_verify.py
Transaction Science 61320c8641 A report an aggregator committed and was sent again is not rejected, and a reader takes one statement of no count
telemetry-aggregation §6.1's rule that `rejected` counts no report the aggregator aggregated had no
test, and impression_aggregate_verify.py did not apply it. The flight flow now sends the helper a
report it committed, and the Python flow filters as the Rust does. impression-aggregates §7.3 says
what a reader does with a statement of version 1 beside one of version 2, and the corpus adds a
receipt and a table of each.

The ledger's corpus takes these receipts, so it is regenerated by the commit after this one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 08:35:01 -05:00

1554 lines
71 KiB
Python

#!/usr/bin/env python3
"""Independent verifier of the impression aggregates corpus (extensions/impression-aggregates.md).
Written from the extension's text and the texts it cites. It shares no code with wai-rs, which
generates the corpus, and never runs it. From the second implementations of the texts this
extension builds on it takes what those texts define, by reference as the extension does:
- telemetry_aggregate_verify.py (telemetry-aggregation, DAP, VDAF, HPKE): Prio3Histogram with its
field, XOF and FLP; X25519, HKDF-SHA256, AES-128-GCM and HPKE open; DAP's messages; a task's
`task_info`, its id and its §4.3 rule 1; the statement and the aggregate receipt; and §8.1
steps 1 to 11, step 2 given as §5.3 here;
- stitched_presentation_verify.py: a pod-input record under stitched-presentation §7.2 steps 1
to 3 and the claims under §7.3 steps 1 to 10, with each presentation's quartile (§7.4);
- ad_presentation_verify.py: a token's reading (§7.1), its TokenChallenge (§7.2), the issuer
key's form and RSASSA-PSS (§7.3);
- ../signer-conformance/signer_verify.py: key-log revisions, anchors and confirmation
(jwp-receipts §7.3, §7.4), `aggregate/task` among its pinned-only authorities (§7.5);
- rung_price_verify.py: a region code and *holds* (rung-price §2);
- ../claim-conformance/_ed25519.py: Ed25519 as SPEC §7 *Signatures* states it.
Everything this extension adds is written here: the catalogue, its columns and cells (§4); the
task's rules and the task file with its HpkeConfigLists (§5); the contribution, the upload file,
the upload plan and the settlement (§6); the aggregators' report rule, token check and hold
(§7); a receipt's §8.1 steps 12 and 13, and the table (§8.2). Each flow is re-run end to end:
every report's two input shares opened with the aggregators' test keys, its extensions and token
checked as each aggregator checks its own share, each presentation's contribution recomputed
and its measurement recombined and required to be one at its cell, both aggregators' Prio3
verification, each upload's response and each relay's merge, the aggregation job's request and
response byte for byte, every bucket, collection (held, refused or completed), aggregate share,
statement, result and receipt.
For every case it requires the outcome the corpus states, field for field; that the corpus
lists exactly §10's codes (read from the extension), that each of them and each code §10 says
the extension reuses appears in an outcome, and that no outcome gives a code the extension does
not name; and it checks the size bounds no vector carries. Then it mutates copies of the corpus, one defect each, and requires every copy to
fail; and it drops each of its own checks in turn and requires the corpus to catch the weakened
verifier.
python3 -I impression_aggregate_verify.py <live-conformance/impressions>
Exit status 0 only if every case verifies, every mutated copy fails and every weakened verifier
is caught. Python 3.8 or later, the standard library and the signer verifier's own requirement
(the `cryptography` package).
"""
import copy
import hashlib
import json
import math
import os
import re
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
for _d in ("claim-conformance", "signer-conformance"):
sys.path.insert(0, os.path.join(HERE, "..", _d))
sys.path.insert(0, HERE)
import _ed25519 as ED # noqa: E402
import ad_presentation_verify as APV # noqa: E402
import rung_price_verify as RP # noqa: E402
import signer_verify as SG # noqa: E402
import stitched_presentation_verify as SPV # noqa: E402
import telemetry_aggregate_verify as TAV # noqa: E402
EXTENSION = os.path.join(HERE, "..", "extensions", "impression-aggregates.md")
JWP = os.path.join(HERE, "..", "extensions", "jwp-receipts.md")
Refuse = TAV.Refuse
CAT_MAGIC, TASK_MAGIC, UPLOAD_MAGIC = b"WIC1", b"WIT1", b"WIU1"
TASK_DOMAIN = b"wai:impression-task\x01" # §5.4
ATTEST_DOMAIN = b"wai:impression-report-attest\x01" # §7.2
TOKEN_EXT = 0x5749 # §7.2 `wai_impression_token`
MAX_CATALOGUE, MAX_TASK_FILE, MAX_UPLOAD = 1 << 20, 1 << 20, 1 << 24
MAX_CELLS = 4096 # §4.3 rule 7
FLOOR = 50 # §5.3 step 5, §8.2 `floor_met`
MIN_PRECISION = 3600 # §5.3 step 3
LAST_S = 253402300799 # §5.3 step 3: the last instant, in seconds
HEADER = 41 # §6.5: a WIU1's bytes before its reports
SUITE = (0x0020, 0x0001, 0x0001) # DAP §7: X25519, HKDF-SHA256, AES-128-GCM
CLAIM, ATTEST = 1, 2 # ad-presentation §8
REPORT_ERRORS = {1: "batch_collected", 2: "report_replayed", 3: "report_dropped", 4: "hpke_unknown_config_id",
5: "hpke_decrypt_error", 6: "vdaf_verify_error", 7: "invalid_message", 8: "report_too_early",
9: "unknown_verification_key_id", 10: "unsupported_extension"} # DAP §4.1
CODE = {v: k for k, v in REPORT_ERRORS.items()}
KEEP = 8 # §6.6: report_too_early is kept
PROBLEMS = ["invalidBatchSize", "batchMismatch", "batchOverlap", "batchInvalid", "invalidAggregationParameter",
"invalidMessage"] # DAP §3.6, as the collections meet them
# A weakened run drops one of these checks (see `WEAKENINGS`); empty checks everything.
SKIP = set()
def drops(name):
return name in SKIP
def sha(*parts):
h = hashlib.sha256()
for x in parts:
h.update(x)
return h.digest()
def u32(n):
return n.to_bytes(4, "big")
def section_codes(path, heading, column):
"""The codes of a reasons table: the `column`-th cell of each row that starts with a code."""
with open(path, encoding="utf-8") as fh:
text = fh.read()
at = text.index(heading)
end = text.find("\n## ", at + 1)
rows = text[at:end if end >= 0 else len(text)].splitlines()
out = []
for r in rows:
cells = [c.strip() for c in r.split("|")]
if len(cells) > column + 1 and cells[column].startswith("`"):
out.append(cells[column].strip("`"))
return out
REASONS = section_codes(EXTENSION, "\n## 10. Reasons", 1)
JWP_REASONS = section_codes(JWP, "\n### 7.7. Reasons", 3)
# Each of these is a pure function of its inputs, so each is computed once for a set of inputs.
MEMO = {}
def memo(key, fn):
if key not in MEMO:
try:
MEMO[key] = (True, fn())
except (ValueError, Refuse) as e:
MEMO[key] = (False, e)
ok, v = MEMO[key]
if not ok:
raise v
return v
# ------------------------------------------------------------------ §4 the catalogue
def read_terms(r):
"""stitched-presentation §4.1's measurement terms, little-endian there: (bytes, fields)."""
start = r.i
q = {"require": r.u(1), "rule": r.u(1), "token_type": int.from_bytes(r.take(2), "little")}
q["issuer"] = r.take(int.from_bytes(r.take(2), "little"))
return r.b[start:r.i], q
def terms_bytes(q):
return struct.pack("<BBHH", q["require"], q["rule"], q["token_type"], len(q["issuer"])) + q["issuer"]
def terms_ok(q):
"""§4.3 rule 2: ad-presentation §8's rules, bit 0 (claim) set, and attest by token type 2 only."""
r, rule, tt, iss = q["require"], q["rule"], q["token_type"], q["issuer"]
claim = bool(r & CLAIM)
if r > 7 or (r & 6 and not claim) or rule > 2 or (rule == 0) == claim:
return False
if r & ATTEST:
if tt not in (1, 2) or not iss:
return False
elif tt != 0 or iss:
return False
return claim and (not r & ATTEST or tt == 2)
def ascending(xs):
return all(a < b for a, b in zip(xs, xs[1:]))
def ceil_sqrt(n):
s = math.isqrt(n)
return s if s * s >= n else s + 1
def rsa_key_ok(k):
try:
APV.rsa_key(k)
return True
except ValueError:
return False
def read_catalogue(b):
"""§4.3: the catalogue, or `catalogue_malformed`."""
bad = Refuse("catalogue_malformed", "catalogue")
if len(b) > MAX_CATALOGUE:
raise bad
r = TAV.Rd(b)
try:
magic, version = r.take(4), r.u(1)
if magic != CAT_MAGIC or (version != 1 and not drops("cat-version")): # 1
raise bad
raw, q = read_terms(r)
hold = r.u(4)
signers = [r.take(32) for _ in range(r.u(1))]
creatives = [r.take(32) for _ in range(r.u(1))]
regions = [r.opaque(1) for _ in range(r.u(1))]
keys = [r.opaque(2) for _ in range(r.u(1))]
r.end()
except ValueError:
raise bad
attest = bool(q["require"] & ATTEST)
cells = len(creatives) * (len(regions) + 1) * 5
rules = [
("cat-terms", not terms_ok(q)), # 2
("cat-hold", hold == 0), # 3
("cat-signers", not 1 <= len(signers) <= 16), # 4
("cat-order", not ascending(signers)),
("cat-creatives", not creatives), # 5
("cat-order", not ascending(creatives)),
("cat-region", not all(RP.region_code(x.decode("latin-1")) for x in regions)), # 6
("cat-order", not ascending(regions)),
("cat-overlap", any(len(x) == 2 and any(y.startswith(x + b"-") for y in regions) for x in regions)),
("cat-cells", cells > MAX_CELLS), # 7
("cat-keys", (not attest and keys) or (attest and not 1 <= len(keys) <= 4)), # 8
("cat-key-form", not all(rsa_key_ok(k) for k in keys)),
("cat-order", not ascending(keys)),
]
if any(broken and not drops(name) for name, broken in rules):
raise bad
return {"bytes": b, "digest": sha(b), "terms_raw": raw, "terms": q, "attest": attest, "hold_s": hold,
"signers": signers, "creatives": creatives, "regions": regions, "keys": keys, "cells": cells,
"chunk": ceil_sqrt(cells) if cells else 0}
def column(cat, g):
"""§4.2: the column of a receiver's region `g` (bytes or None); `r` is `other`."""
other = len(cat["regions"])
if g is None:
return other
s = g.decode("latin-1")
if not RP.region_code(s):
return other
for j, x in enumerate(cat["regions"]):
if (x == g) if drops("region-subdivision") else RP.holds([x.decode("ascii")], s):
return j
return other
def column_name(cat, j):
return cat["regions"][j].decode("ascii") if j < len(cat["regions"]) else "other"
def cell_of(cat, i, j, q):
return (i * (len(cat["regions"]) + 1) + j) * 5 + (q - 1)
def catalogue_outcome(b):
try:
c = read_catalogue(b)
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason}
return {"outcome": "read", "digest": c["digest"].hex(), "cells": c["cells"], "chunk": c["chunk"]}
# ------------------------------------------------------------------ §5 the task
def task_rules(cfg, cat):
"""§5.3 against a catalogue that reads: the task's fields, Prio3, id, flight and retention."""
t, info = TAV.task_fields(cfg) # 1
if not drops("catalogue-digest") and (info["measure"] != 5 or info["scope"] != cat["digest"]):
raise Refuse("catalogue_mismatch", "task") # 2
got = {}
def steps_3_4(t, info):
ex = t["extensions"]
if t["time_precision"] < MIN_PRECISION and not drops("task-precision"):
raise Refuse("task_invalid", "task")
if len(ex) != 1 or ex[0][0] != 1 or len(ex[0][1]) != 16:
raise Refuse("task_invalid", "task")
iv = TAV.interval_of(ex[0][1])
if iv[1] < 1 and not drops("task-interval"):
raise Refuse("task_invalid", "task")
retention = (iv[0] + iv[1]) * t["time_precision"] + cat["hold_s"]
if retention > LAST_S and not drops("task-retention"): # a u64 overflow is past it too
raise Refuse("task_invalid", "task")
config = u32(cat["cells"]) + (b"" if drops("chunk-length") else u32(cat["chunk"]))
given = t["vdaf_config"][:4] if drops("chunk-length") else t["vdaf_config"]
if t["vdaf_type"] != 4 or given != config: # 4: DAP Appendix B.4
raise Refuse("measure_mismatch", "task")
got.update(interval=iv, retention_s=retention)
return TAV.Prio3(TAV.Histogram(cat["cells"], cat["chunk"]))
t, info, p, tid = TAV.check_task(cfg, measure5=steps_3_4) # 3, 4; then 5, 6
if "interval" not in got:
raise ValueError("not a task of measure 5")
return {"cfg": cfg, "t": t, "info": info, "p": p, "tid": tid, "interval": got["interval"],
"retention_s": got["retention_s"]}
def task_outcome(cfg, cat_bytes):
try:
k = task_rules(cfg, read_catalogue(cat_bytes))
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason}
return {"outcome": "accept", "task_id": k["tid"].hex(), "retention_s": k["retention_s"]}
def suite_config(configs):
"""§6.3: the first configuration of DAP §7's suite with a 32-byte key."""
if drops("hpke-first"):
return configs[0]
return next((c for c in configs if (c["kem"], c["kdf"], c["aead"]) == SUITE and len(c["pk"]) == 32), None)
def read_hpke_list(r):
"""DAP §4.4.1's HpkeConfigList<10..2^16-1>, with its own u16 length first (§5.4 step 1)."""
body = TAV.Rd(r.opaque(2))
if len(body.b) < 10:
raise ValueError("short list")
out = []
while not body.done():
c = {"id": body.u(1), "kem": body.u(2), "kdf": body.u(2), "aead": body.u(2), "pk": body.opaque(2)}
if not c["pk"]:
raise ValueError("empty key")
out.append(c)
ids = [c["id"] for c in out]
if len(set(ids)) != len(ids) and not drops("tf-hpke-ids"):
raise ValueError("two configurations of one id")
suite = [c for c in out if (c["kem"], c["kdf"], c["aead"]) == SUITE and len(c["pk"]) == 32]
if not suite and not drops("tf-hpke-suite"):
raise ValueError("no configuration of DAP's suite")
return out
def read_task_file(b):
"""§5.4 step 1: the file, or `task_file_malformed`."""
bad = Refuse("task_file_malformed", "task_file")
if len(b) > MAX_TASK_FILE and not drops("tf-bound"):
raise bad
r = TAV.Rd(b)
try:
magic, version = r.take(4), r.u(1)
if magic != TASK_MAGIC or (version != 1 and not drops("tf-version")):
raise bad
tf = {"task": r.opaque(2), "catalogue": r.opaque(4), "leader_hpke": read_hpke_list(r),
"helper_hpke": read_hpke_list(r), "signer": r.take(32)}
sid = r.opaque(2)
tf["body"] = b[:r.i]
tf["sig"] = r.take(64)
r.end()
except ValueError:
raise bad
if not sid or (any(c > 0x7F for c in sid) and not drops("tf-signer-ascii")):
raise bad
tf["signer_id"] = sid.decode("latin-1")
tf["digest"] = sha(tf["body"])
return tf
def confirmation(tf, revision_texts, anchors):
"""§5.4 step 5: jwp-receipts §7.4 for `aggregate/task`, the item untimed."""
held = SG.held_revisions(revision_texts)
if drops("tf-revocation"): # a receiver that takes in no revision the bearer brings
named = {a["revision"] for a in anchors}
held = [x for x in held if SG.rev_hash(x) in named]
if drops("tf-pinned"):
anchors = [dict(a, path="pinned", pinned_revision=a["revision"]) for a in anchors]
authorities = [] if drops("tf-authority") else ["aggregate/task"]
return SG.confirm(tf["signer"], tf["signer_id"], authorities, None, held, anchors, None)
def accept_task_file(b, revision_texts, anchors):
"""§5.4, in order: (file, catalogue, task, confirmation), or the refusal."""
tf = read_task_file(b) # 1
cat = read_catalogue(tf["catalogue"]) # 2
task = task_rules(tf["task"], cat) # 3
if not drops("tf-signature"): # 4
why = ED.verify(tf["signer"], TASK_DOMAIN + tf["body"], tf["sig"])
if why:
raise Refuse(why, "task_file")
v = confirmation(tf, revision_texts, anchors) # 5
if not v.startswith("confirmed"):
raise Refuse(re.search(r"reason=(\S+)", v).group(1), "task_file")
return tf, cat, task, v
def task_file_outcome(doc, c):
fx = doc["fixtures"]
try:
tf, _, task, v = accept_task_file(bytes.fromhex(c["bytes"]), [fx["key_logs"][n] for n in c["revisions"]],
[fx["anchors"][n] for n in c["anchors"]])
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason}
return {"outcome": "accept", "digest": tf["digest"].hex(), "task_id": task["tid"].hex(), "confirmation": v}
def receiver_task_file(doc, name):
"""A task file of the fixtures, accepted under what `receiver` holds (§6.1)."""
fx = doc["fixtures"]
rv = fx["receiver"]
return accept_task_file(bytes.fromhex(fx["task_files"][name]), [fx["key_logs"][n] for n in rv["revisions"]],
[fx["anchors"][n] for n in rv["anchors"]])
# ------------------------------------------------------------------ §7.2 the token
def pss(key, msg, sig):
return memo(("pss", key, msg, sig), lambda: APV.pss_verify(key, msg, sig))
def token_check(cat, tid, rid, token):
"""§7.2's token: None when it is type 0x0002, answers the challenge of `tid` and `rid`, names one
of the catalogue's keys and verifies under it; otherwise `attestation_invalid`."""
t = APV.read_token(token)
if t is None or t["type"] != 2:
return "attestation_invalid"
ctx = sha(ATTEST_DOMAIN, tid, rid)
if not drops("token-challenge") and t["digest"] != sha(APV.token_challenge(2, cat["terms"]["issuer"], ctx)):
return "attestation_invalid"
key = next((k for k in cat["keys"] if sha(k) == t["key_id"]), None)
if key is None or not pss(key, t["input"], t["auth"]):
return "attestation_invalid"
return None
def report_rule(cat, tid, rid, public, private, role):
"""§7.2 at DAP §4.5.3.4, an aggregator's own input share: None, or (error, WAI reason)."""
if public and not drops("public-extension"):
return "invalid_message", None
if not cat["attest"]:
return ("invalid_message", None) if private else None
if role == 3 and drops("helper-token"):
return None
if len(private) != 1 or private[0][0] != TOKEN_EXT:
return "invalid_message", "attestation_absent"
if token_check(cat, tid, rid, private[0][1]):
return "invalid_message", "attestation_invalid"
return None
# ------------------------------------------------------------------ §6.2 the contribution
def held_fixtures(doc):
fx = doc["fixtures"]
manifests = [fx["manifests"][n].encode("utf-8") for n in sorted(fx["manifests"])]
creatives = [[{"content_hash": bytes.fromhex(o["content_hash"]), "duration_ms": o["duration_ms"]}
for o in fx["creatives"][n]["objects"]] for n in sorted(fx["creatives"])]
return manifests, creatives
def judge(doc, record, claims, signers):
"""stitched-presentation §7.2 steps 1 to 3 for the record (no other record, no response held)
and §7.3 steps 1 to 10 for the claims, in order."""
manifests, creatives = held_fixtures(doc)
key = ("judge", record, tuple(claims), tuple(signers), tuple(manifests),
json.dumps(doc["fixtures"]["creatives"], sort_keys=True))
inp = {"records": [record], "claims": claims, "manifests": manifests, "responses": [], "receipts": [],
"creatives": creatives, "issuers": [], "signers": signers}
return memo(key, lambda: SPV.verify(inp, through_attestation=False))
def no_report(reason, discarded=()):
return {"outcome": "no_report", "reason": reason, "discarded": list(discarded)}
def contribute(doc, cat, task, record, claims, region, at_ms, pool):
"""§6.2, in order."""
v = judge(doc, record, claims, cat["signers"])
verdict = v["record_facts"][0]["verdict"]
if verdict != "accepted": # 1
return no_report(verdict)
rec = SPV.read_record(record)
if rec["session"] != bytes(16) and not drops("shared"): # 2
return no_report("record_not_shared")
if terms_bytes(rec["terms"]) != cat["terms_raw"] and not drops("terms-equal"): # 3
return no_report("record_terms_differ")
cf = v["claim_facts"][-1]
if cf["verdict"] != "presented": # 4
return no_report(cf["verdict"])
ad = rec["ads"][cf["slot"]]
if ad["creative_hash"] not in cat["creatives"] and not drops("creative-listed"): # 5
return no_report("creative_not_listed")
tp = task["t"]["time_precision"]
if at_ms is None and not drops("instant"): # 6
return no_report("instant_unknown")
time = task["interval"][0] if at_ms is None else at_ms // (1000 * tp)
iv = task["interval"]
if not iv[0] <= time < iv[0] + iv[1] and not drops("flight-window"): # 7
return no_report("outside_task")
discarded, rid = [], "fresh"
if cat["attest"]: # 8
chosen = None
for pair in sorted(pool, key=lambda x: bytes.fromhex(x["id"])):
if drops("token-pool") or token_check(cat, task["tid"], bytes.fromhex(pair["id"]),
bytes.fromhex(pair["token"])) is None:
chosen = pair
break
discarded.append(pair["id"])
if chosen is None:
return no_report("token_exhausted", discarded)
rid = chosen["id"]
q = cf["quartile"]
j = column(cat, region)
i = cat["creatives"].index(ad["creative_hash"]) if ad["creative_hash"] in cat["creatives"] else None
return {"outcome": "report", "cell": None if i is None else cell_of(cat, i, j, q), "creative": i,
"column": column_name(cat, j), "quartile": q, "time": time, "id": rid, "discarded": discarded}
def contribution_outcome(doc, c):
tf, cat, task, _ = receiver_task_file(doc, c["task_file"])
record = bytes.fromhex(doc["fixtures"]["records"][c["record"]]["bytes"])
region = None if c["region"] is None else c["region"].encode("utf-8")
return contribute(doc, cat, task, record, [bytes.fromhex(x) for x in c["claims"]], region, c["at_ms"], c["pool"])
# ------------------------------------------------------------------ §6.4 to §6.6
def read_upload(b):
"""§6.4: (task_id, reports), each report with its bytes; or `upload_malformed`."""
bad = Refuse("upload_malformed", "upload")
if len(b) > MAX_UPLOAD and not drops("upload-bound"):
raise bad
r = TAV.Rd(b)
try:
magic, version = r.take(4), r.u(1)
if magic != UPLOAD_MAGIC or (version != 1 and not drops("upload-version")):
raise bad
tid = r.take(32)
n = r.u(4)
up = TAV.Rd(r.take(min(n, len(b) - r.i) if drops("upload-length") else n))
r.end()
reports = []
while not up.done():
start = up.i
rep = TAV.read_report(up)
rep["raw"] = up.b[start:up.i]
reports.append(rep)
except ValueError:
raise bad
ids = [x["m"]["id"] for x in reports]
if not reports or (not ascending(ids) and not drops("upload-order")):
raise bad
return tid, reports
def write_upload(tid, raws):
body = b"".join(raws)
return UPLOAD_MAGIC + b"\x01" + tid + u32(len(body)) + body
def upload_outcome(b):
try:
tid, reports = read_upload(b)
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason}
return {"outcome": "read", "task_id": tid.hex(), "ids": [x["m"]["id"].hex() for x in reports]}
def read_one_report(b):
r = TAV.Rd(b)
rep = TAV.read_report(r)
r.end()
rep["raw"] = b
return rep
def plan(task, cat, stored, t_ms, budget):
"""§6.5."""
tp, iv = task["t"]["time_precision"], task["interval"]
timely, late, drop = [], [], []
for b in stored:
rep = read_one_report(b)
rid, tau = rep["m"]["id"], rep["m"]["time"]
if not iv[0] <= tau < iv[0] + iv[1] or (t_ms >= task["retention_s"] * 1000 and not drops("plan-retention")):
drop.append(rid)
continue
deadline = ((tau + 1) * tp + cat["hold_s"]) * 1000
(timely if t_ms < deadline or drops("plan-deadline") else late).append((deadline, rid, b))
send, defer, total, taking = [], [], HEADER, True
for cls, group in (("timely", sorted(timely)), ("late", sorted(late))):
for _, rid, b in group:
if taking and (budget is None or total + len(b) <= budget or drops("plan-budget")):
send.append((rid, cls, b))
total += len(b)
else:
taking = False
defer.append(rid)
raws = [b for _, _, b in sorted(send)]
return {"send": [{"id": rid.hex(), "class": cls} for rid, cls, _ in send], "defer": [x.hex() for x in defer],
"drop": [x.hex() for x in sorted(drop)], "file": write_upload(task["tid"], raws).hex() if send else None}
def plan_outcome(doc, c):
_, cat, task, _ = receiver_task_file(doc, c["task_file"])
return plan(task, cat, [bytes.fromhex(x) for x in c["stored"]], c["t_ms"], c["budget"])
DELETE = {1, 2, 3, 4, 5, 6, 7, 9, 10} # §6.6: DAP §4.1's errors but report_too_early
def settle(sent, response):
"""§6.6."""
_, reports = read_upload(sent)
ids = [x["m"]["id"] for x in reports]
entries = None
if len(response) % 17 == 0:
es = [(response[k:k + 16], response[k + 16]) for k in range(0, len(response), 17)]
at, ok = 0, True
for rid, _ in es:
if drops("settle-subsequence"):
continue
while at < len(ids) and ids[at] != rid:
at += 1
if at == len(ids):
ok = False
break
at += 1
entries = es if ok else None
if entries is None:
return {"settles": False, "delete": [], "keep": [x.hex() for x in ids]}
errors = dict(entries)
delete, keep = [], []
for rid in ids:
e = errors.get(rid)
if e is None or e in DELETE or (e == KEEP and drops("settle-too-early")) or \
(e not in REPORT_ERRORS and drops("settle-unknown-value")):
delete.append(rid.hex())
else:
keep.append(rid.hex())
return {"settles": True, "delete": delete, "keep": keep}
# ------------------------------------------------------------------ §8 verification
def verify_receipt(receipt, cat_bytes, reports):
"""§8.1, in order: the outcome as accepted, or the refusal."""
cat = read_catalogue(cat_bytes)
task = {}
def step2(cfg):
k = task_rules(cfg, cat)
task.update(k)
return k["t"], k["info"], k["p"], k["tid"]
out = TAV.verify_receipt(receipt, reports, task_check=step2) # 1 to 11
batch = (out["batch"]["start"], out["batch"]["duration"])
if not TAV.within(batch, task["interval"]) and not drops("batch-in-task"): # 12
raise Refuse("batch_invalid", "leader")
res, n = out["result"], out["report_count"] # 13
total = sum(res) % TAV.Field128.P if drops("count-integer") else sum(res)
elements = drops("count-integer") or all(x <= n for x in res)
if not elements or (total != n and not drops("count-sum")):
raise Refuse("count_mismatch", "result")
out["reports_checked"] = reports is not None
rc = TAV.read_receipt(receipt) # §8.2: each aggregator's own
out["rejected"] = {"leader": TAV.read_statement(rc["leader"])["rejected"],
"helper": TAV.read_statement(rc["helper"])["rejected"]}
return out, cat, task
def receipt_outcome(c):
try:
out, _, _ = verify_receipt(bytes.fromhex(c["receipt"]), bytes.fromhex(c["catalogue"]), c["reports"])
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
return out
def rows(cat, vec):
out = []
for i in range(len(cat["creatives"])):
for j in range(len(cat["regions"]) + 1):
c = vec[cell_of(cat, i, j, 1):cell_of(cat, i, j, 1) + 5]
out.append({"creative": i, "column": column_name(cat, j), "presentations": sum(c),
"quartiles": [sum(c[k:]) for k in range(5)], "floor_met": sum(c) >= FLOOR})
return out
def table(cat_bytes, task_bytes, given):
"""§8.2."""
try:
cat = read_catalogue(cat_bytes) # 1
task = task_rules(task_bytes, cat)
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason}
tp = task["t"]["time_precision"]
entries = []
for g in given: # 2
rb = bytes.fromhex(g["receipt"])
e = {"digest": None}
try:
rc = TAV.read_receipt(rb)
e["digest"] = sha(rc["body"]).hex()
out, _, _ = verify_receipt(rb, cat_bytes, g["reports"])
if rc["task"] != task_bytes and not drops("task-equal"):
raise Refuse("task_mismatch", "task")
e["out"] = out
except Refuse as x:
e["verdict"] = "refuse reason=%s at=%s" % (x.reason, x.at)
entries.append(e)
one = {} # 3
for k, e in enumerate(entries):
if "out" in e:
one.setdefault(k if drops("dedupe") else e["digest"], e["out"]["batch"])
over = set()
for a, x in one.items():
for b, y in one.items():
if a != b and x["start"] < y["start"] + y["duration"] and y["start"] < x["start"] + x["duration"]:
over.add(a)
if drops("overlap"):
over = set()
stated, standing, totals = [], [], None
for k, e in enumerate(entries):
if "out" not in e:
stated.append({"digest": e["digest"], "verdict": e["verdict"]})
continue
key = k if drops("dedupe") else e["digest"]
if key in over:
stated.append({"digest": e["digest"], "verdict": "set-aside reason=receipts_overlap"})
continue
out = e["out"]
b = out["batch"]
checked = out["reports_checked"] or drops("reports-checked")
end = b["start"] + b["duration"]
stated.append({"digest": e["digest"], "verdict": "stands",
"batch": {"start": b["start"], "duration": b["duration"], "from_ms": b["start"] * tp * 1000,
"to_ms": end * tp * 1000},
"collectable_from_ms": (end * tp + cat["hold_s"]) * 1000, "report_count": out["report_count"],
"rejected": out["rejected"], "reports_checked": checked, "rows": rows(cat, out["result"])})
standing.append((key, out, checked)) # 4
# Each receipt counted once, its rows summed as exact integers; one given more than once is
# checked when any of its occurrences was.
counted, batches, vec = set(), [], [0] * cat["cells"]
rejected = {"leader": 0 if standing else None, "helper": 0 if standing else None}
for key, out, _ in standing:
if key in counted:
continue
counted.add(key)
batches.append({"start": out["batch"]["start"], "duration": out["batch"]["duration"]})
vec = [a + b for a, b in zip(vec, out["result"])]
for who in rejected: # §8.2: none when one states none
n = out["rejected"][who]
if drops("rejected-none"):
rejected[who] = (rejected[who] or 0) + (n or 0)
else:
rejected[who] = None if rejected[who] is None or n is None else rejected[who] + n
checked = {key for key, _, c in standing if c}
every = all(c for _, _, c in standing) if drops("checked-any") else all(k in checked for k in counted)
totals = {"batches": batches, "reports_checked": every, "rejected": rejected, "rows": rows(cat, vec) if standing else []}
return {"outcome": "table", "receipts": stated, "totals": totals}
# ------------------------------------------------------------------ §6.3 and §7: a flow, re-run
def open_input_share(sk, role, aad, ct):
"""DAP §4.4.2.1: the PlaintextInputShare's private extensions and payload."""
def go():
pt = TAV.hpke_open(ct["enc"], sk, b"dap-20 input share" + bytes([1, role]), aad, ct["payload"])
r = TAV.Rd(pt)
exts = TAV.read_exts(r)
payload = r.opaque(4)
r.end()
if not payload:
raise ValueError("empty payload")
return exts, payload
return memo(("open", sk, role, aad, ct["config_id"], ct["enc"], ct["payload"]), go)
def verify_init(p, vk, ctx, agg, nonce, public, share):
return memo(("init", p.v.length, p.v.chunk, vk, ctx, agg, nonce, public, share),
lambda: p.verify_init(vk, ctx, agg, nonce, public, share))
def meas_share(p, ctx, agg, share):
return memo(("meas", p.v.length, p.v.chunk, ctx, agg, share), lambda: p.meas_share(ctx, agg, share))
def in_interval(x, iv):
return iv[0] <= x < iv[0] + iv[1]
def run_flow(doc, f):
"""One flow of §11, re-run from its reports: the failures."""
fails = []
K = doc["keys"]
def bad(what):
fails.append("flow %s: %s" % (f["name"], what))
try:
tf, cat, task, _ = receiver_task_file(doc, f["task_file"])
except Refuse as e:
bad("its task file is refused %s" % e.reason)
return fails
t, p, tid, iv, cfg = task["t"], task["p"], task["tid"], task["interval"], task["cfg"]
tp, hold, floor = t["time_precision"], cat["hold_s"], t["min_batch_size"]
if tid.hex() != f["task_id"]:
bad("task id")
vk = TAV.hkdf_expand(TAV.hkdf_extract(b"verify_key", bytes.fromhex(K["verify_key_seed"])), tid, 32)
if vk.hex() != f["verify_key"]:
bad("verification key (telemetry-aggregation §4.4)")
ctx = b"dap-20" + tid
lsk, hsk, csk = (bytes.fromhex(K[k]["private_key"]) for k in ("leader_hpke", "helper_hpke", "collector_hpke"))
lconf, hconf = suite_config(tf["leader_hpke"]), suite_config(tf["helper_hpke"])
if lconf["pk"] != TAV.x25519(lsk, TAV.BASE) or hconf["pk"] != TAV.x25519(hsk, TAV.BASE):
bad("the configuration the reports are sealed to is not the aggregator's key (§6.3)")
leader_ids = [c["id"] for c in tf["leader_hpke"]]
listed = {}
for x in f["reports"] + f.get("late", []):
listed[x["id"]] = x
late = {x["id"] for x in f.get("late", [])}
seen, collected, pending, first, inits = set(), [], [], {}, {}
rejected_by = {"leader": {}, "helper": {}} # by time: the ids each rejected
fate, buckets, outcomes_stated = {}, {}, None
def merged(q):
out = {"l": [0] * p.v.OUTPUT_LEN, "h": [0] * p.v.OUTPUT_LEN, "n": 0, "ck": bytes(32), "times": [], "ids": []}
for tm in range(q[0], q[0] + q[1]):
if tm in buckets:
b = buckets[tm]
out["l"], out["h"] = TAV.vadd(p.f, out["l"], b["l"]), TAV.vadd(p.f, out["h"], b["h"])
out["n"] += b["n"]
out["ck"] = bytes(a ^ c for a, c in zip(out["ck"], b["ck"]))
out["times"].append(tm)
out["ids"] += b["ids"]
return out
def collected_at(tm):
return any(in_interval(tm, q) for q in collected)
for s in f["steps"]:
if s["step"] == "upload": # §6.4; DAP §4.4.2.2
try:
utid, reps = read_upload(bytes.fromhex(s["file"]))
except Refuse:
bad("upload %s does not read" % s["name"])
continue
if utid != tid:
bad("upload %s names another task" % s["name"])
if "sources" in s:
byid = {}
for src in s["sources"]:
try:
stid, srep = read_upload(bytes.fromhex(src))
except Refuse:
bad("a source of %s does not read" % s["name"])
continue
if stid != tid:
bad("a source of %s names another task" % s["name"])
for x in srep:
if byid.setdefault(x["m"]["id"], x["raw"]) != x["raw"]:
bad("two reports of one id with other bytes merged into one file")
if write_upload(tid, [byid[k] for k in sorted(byid)]).hex() != s["file"]:
bad("upload %s is not its sources merged, ascending by id (§6.4)" % s["name"])
resp, errs = b"", []
for rep in reps:
m, rid = rep["m"], rep["m"]["id"]
first.setdefault(rid.hex(), rep)
if rep["leader"]["config_id"] not in leader_ids:
e = "hpke_unknown_config_id"
elif not in_interval(m["time"], iv) and not drops("upload-dropped"):
e = "report_dropped"
elif (rid in seen or collected_at(m["time"])) and not drops("upload-replay"):
e = "report_replayed"
else:
e = None
seen.add(rid)
pending.append(rep)
if e:
resp += rid + bytes([CODE[e]])
errs.append({"id": rid.hex(), "error": e})
if rid not in seen:
fate[rid.hex()] = (e, None)
if resp.hex() != s["response"] or errs != s["errors"]:
bad("upload %s: the leader's UploadErrors" % s["name"])
if "shares" in s:
srcs = [[x["m"]["id"] for x in read_upload(bytes.fromhex(src))[1]] for src in s["sources"]]
got = [b"".join(resp[k:k + 17] for k in range(0, len(resp), 17) if resp[k:k + 16] in ids).hex()
for ids in srcs]
if got != s["shares"]:
bad("upload %s: each source's share of the response (§6.4)" % s["name"])
elif s["step"] == "aggregate": # DAP §4.5; §7.2
init = bytes([0]) + u32(0) + (0).to_bytes(2, "big")
resp, refused, job = b"", [], []
for rep in pending:
m, rid = rep["m"], rep["m"]["id"]
aad = TAV.input_share_aad(tid, cfg, m, rep["public"])
try:
lexts, lpay = open_input_share(lsk, 2, aad, rep["leader"])
except ValueError:
refused.append((rid, "hpke_decrypt_error", None))
continue
if not in_interval(m["time"], iv):
refused.append((rid, "report_dropped", None))
continue
why = report_rule(cat, tid, rid, m["exts"], lexts, 2) if not drops("token-check") else None
if why:
refused.append((rid,) + why)
continue
try:
lstate, lvs = verify_init(p, vk, ctx, 0, rid, rep["public"], lpay)
except ValueError:
refused.append((rid, "vdaf_verify_error", None))
continue
out = b"\x00" + u32(len(lvs)) + lvs
inits[rid] = m["raw"] + u32(len(rep["public"])) + rep["public"] + TAV.write_ct(rep["helper"]) + \
u32(len(out)) + out
init += inits[rid]
try:
hexts, hpay = open_input_share(hsk, 3, aad, rep["helper"])
except ValueError:
resp += rid + b"\x02" + bytes([CODE["hpke_decrypt_error"]])
job.append((rid, "hpke_decrypt_error", None))
continue
why = report_rule(cat, tid, rid, m["exts"], hexts, 3) if not drops("token-check") else None
if why:
resp += rid + b"\x02" + bytes([CODE[why[0]]])
job.append((rid,) + why)
continue
try:
hstate, hvs = verify_init(p, vk, ctx, 1, rid, rep["public"], hpay)
msg = p.message(ctx, [lvs, hvs])
hout = p.verify_next(hstate, msg)
except ValueError:
resp += rid + b"\x02" + bytes([CODE["vdaf_verify_error"]])
job.append((rid, "vdaf_verify_error", None))
continue
if collected_at(m["time"]): # DAP §4.5.4.3
resp += rid + b"\x02" + bytes([CODE["batch_collected"]])
job.append((rid, "batch_collected", None))
continue
fin = b"\x02" + u32(len(msg)) + msg
resp += rid + b"\x00" + u32(len(fin)) + fin
lout = p.verify_next(lstate, msg)
b = buckets.setdefault(m["time"], {"l": [0] * p.v.OUTPUT_LEN, "h": [0] * p.v.OUTPUT_LEN, "n": 0,
"ck": bytes(32), "ids": []})
b["l"] = TAV.vadd(p.f, b["l"], TAV.decode_vec(p.f, lout))
b["h"] = TAV.vadd(p.f, b["h"], TAV.decode_vec(p.f, hout))
b["n"] += 1
b["ck"] = bytes(a ^ c for a, c in zip(b["ck"], sha(rid)))
b["ids"].append((rid, m["time"]))
job.append((rid, "committed", None))
pending = []
times = {rep["m"]["id"]: rep["m"]["time"] for rep in first.values()}
for rid, o, why in refused + job:
fate[rid.hex()] = o if o == "committed" else (o, why)
if o != "committed": # telemetry-aggregation §6.1
rejected_by["leader"].setdefault(times[rid], set()).add(rid)
for rid, o, _ in (refused + job if drops("rejected-own") else job):
if o != "committed": # the helper never sees what the leader refused
rejected_by["helper"].setdefault(times[rid], set()).add(rid)
outcomes = [{"id": rid.hex(), "outcome": o, "reason": why} for rid, o, why in refused + job]
if init.hex() != s["init_req"]:
bad("the AggregationJobInitReq is not DAP §4.5.3.1's")
if resp.hex() != s["resp"]:
bad("the AggregationJobResp is not DAP §4.5.3.2's")
if outcomes != s["outcomes"]:
bad("the reports' outcomes at aggregation")
outcomes_stated = s["outcomes"]
elif s["step"] == "replay": # DAP §4.5.4.3: sent to the helper again
rid = bytes.fromhex(s["id"])
if rid not in inits or not any(rid == i for b in buckets.values() for i, _ in b["ids"]):
bad("the report sent again is no report the helper committed")
continue
if (bytes([0]) + u32(0) + (0).to_bytes(2, "big") + inits[rid]).hex() != s["init_req"]:
bad("the AggregationJobInitReq of the report sent again")
if (rid + b"\x02" + bytes([CODE["report_replayed"]])).hex() != s["resp"]:
bad("the helper's answer to the report sent again")
rejected_by["helper"].setdefault(first[rid.hex()]["m"]["time"], set()).add(rid)
elif s["step"] == "collect": # DAP §4.6; §7.1
req = bytes.fromhex(s["request"])
try:
c = TAV.read_collection(req)
q = TAV.interval_of(c["config"])
except ValueError:
bad("collection %s does not read" % s["name"])
continue
if s["batch"] != {"start": q[0], "duration": q[1]}:
bad("collection %s: its batch" % s["name"])
mg = merged(q)
due = (q[0] + q[1]) * tp + hold
if s["now_s"] < due and not drops("hold"):
got = "processing"
elif c["agg_param"]:
got = "invalidAggregationParameter"
elif q[1] == 0:
got = "batchInvalid"
elif any(q[0] < x[0] + x[1] and x[0] < q[0] + q[1] for x in collected):
got = "batchOverlap"
elif mg["n"] < floor:
got = "invalidBatchSize"
else:
got = "completed"
if got != s["outcome"]:
bad("collection %s: stated %s, the leader gives %s" % (s["name"], s["outcome"], got))
if "helper_request" in s: # DAP §4.6.4
try:
r = TAV.Rd(bytes.fromhex(s["helper_request"]))
hc = {"batch_mode": r.u(1), "config": r.opaque(2), "agg_param": r.opaque(4),
"extensions": TAV.read_exts(r)}
mode, sel, n, ck = r.u(1), r.opaque(2), r.u(8), r.take(32)
r.end()
hq = TAV.interval_of(sel)
ok = mode == 1 and hc["batch_mode"] == 1 and TAV.interval_of(hc["config"]) == q and hq == q and \
(n, ck) == (mg["n"], mg["ck"])
if mg["n"] < floor:
hgot = "invalidBatchSize"
else:
hgot = "batchMismatch" if (n, ck) != (mg["n"], mg["ck"]) else "accept"
except ValueError:
ok, hgot = False, "invalidMessage"
if not ok or hgot != s["helper_outcome"]:
bad("collection %s: the helper's request or its refusal" % s["name"])
if got == "completed" and s["outcome"] == "completed":
collected.append(q)
aggregated = set() if drops("rejected-aggregated") else {i for b in buckets.values() for i, _ in b["ids"]}
stated = {who: sum(len(v - aggregated) for t, v in by.items() if q[0] <= t < q[0] + q[1]) # §6.1: none of them aggregated
for who, by in rejected_by.items()}
check_collection(doc, s, cfg, tid, req, q, mg, csk, task, bad, stated)
for rid, x in listed.items():
rep = first.get(rid)
if rep is None:
bad("report %s is never uploaded" % rid)
continue
if rep["m"]["time"] != x["time"]:
bad("report %s: its time" % rid)
if rep["leader"]["config_id"] != lconf["id"] or rep["helper"]["config_id"] != hconf["id"]:
bad("report %s is not sealed to each list's first configuration of DAP's suite (§6.3)" % rid)
if rid in late:
want = ("report_replayed", None)
elif x["kind"] == "presentation":
want = "committed"
else:
want = (x["error"], x["reason"])
if fate.get(rid) != want:
bad("report %s (%s): stated %s, the run gives %s" % (rid, x["kind"], want, fate.get(rid)))
if x["kind"] == "presentation":
check_presentation(doc, f, x, rep, tf, cat, task, lsk, hsk, ctx, bad)
for rid, rep in first.items():
if rid not in listed:
bad("an uploaded report %s is not listed" % rid)
if outcomes_stated is None:
bad("no aggregation job")
return fails
def check_presentation(doc, f, x, rep, tf, cat, task, lsk, hsk, ctx, bad):
"""A presentation's report: §6.2's contribution, §6.3's extensions and its one-hot measurement."""
p, tid, m = task["p"], task["tid"], rep["m"]
aad = TAV.input_share_aad(tid, task["cfg"], m, rep["public"])
try:
lexts, lpay = open_input_share(lsk, 2, aad, rep["leader"])
hexts, hpay = open_input_share(hsk, 3, aad, rep["helper"])
except ValueError:
bad("presentation %s does not open" % x["id"])
return
if m["exts"] or lexts != hexts or (cat["attest"] and [e[0] for e in lexts] != [TOKEN_EXT]) or \
(not cat["attest"] and lexts):
bad("presentation %s: its extensions are not §6.3's" % x["id"])
return
pool = [{"id": x["id"], "token": lexts[0][1].hex()}] if cat["attest"] else []
record = bytes.fromhex(doc["fixtures"]["records"][x["record"]]["bytes"])
region = None if x["region"] is None else x["region"].encode("utf-8")
got = contribute(doc, cat, task, record, [bytes.fromhex(x["claim"])], region, x["at_ms"], pool)
want = {"outcome": "report", "cell": x["cell"], "creative": x["creative"], "column": x["column"],
"quartile": x["quartile"], "time": x["time"], "id": x["id"] if cat["attest"] else "fresh", "discarded": []}
if got != want:
bad("presentation %s: its contribution (§6.2) is %s" % (x["id"], json.dumps(got)))
meas = TAV.vadd(p.f, meas_share(p, ctx, 0, lpay), meas_share(p, ctx, 1, hpay))
if meas != [1 if k == x["cell"] else 0 for k in range(cat["cells"])]:
bad("presentation %s: its measurement is not one at its cell (§6.3)" % x["id"])
def check_collection(doc, s, cfg, tid, req, q, mg, csk, task, bad, stated):
"""A completed collection: the shares, the statements, the result and the receipt."""
p, info = task["p"], task["info"]
share_req = req + b"\x01" + (16).to_bytes(2, "big") + q[0].to_bytes(8, "big") + q[1].to_bytes(8, "big") + \
mg["n"].to_bytes(8, "big") + mg["ck"]
if share_req.hex() != s["share_req"]:
bad("collection %s: the AggregateShareReq is not DAP §4.6.4's" % s["name"])
aad = TAV.agg_share_aad(tid, cfg, req)
try:
hct = TAV.read_ct(TAV.Rd(bytes.fromhex(s["helper_share"])))
hshare = TAV.hpke_open(hct["enc"], csk, b"dap-20 aggregate share" + bytes([3, 0]), aad, hct["payload"])
r = TAV.Rd(bytes.fromhex(s["resp"]))
n, iv = r.u(8), (r.u(8), r.u(8))
lct, hct2 = TAV.read_ct(r), TAV.read_ct(r)
r.end()
lshare = TAV.hpke_open(lct["enc"], csk, b"dap-20 aggregate share" + bytes([2, 0]), aad, lct["payload"])
sts = []
for k, role in (("leader_statement", 2), ("helper_statement", 3)):
sct = TAV.read_ct(TAV.Rd(bytes.fromhex(s[k])))
info_s = b"wai-telemetry-aggregate statement" + bytes([role, 0])
sts.append(TAV.read_statement(TAV.hpke_open(sct["enc"], csk, info_s, aad, sct["payload"])))
except (ValueError, Refuse):
bad("collection %s does not open with the collector's key" % s["name"])
return
times = mg["times"]
if n != mg["n"] or iv != (min(times), max(times) - min(times) + 1) or hct2 != hct:
bad("collection %s: the CollectionJobResp's count, interval or helper share" % s["name"])
if TAV.decode_vec(p.f, lshare) != mg["l"] or TAV.decode_vec(p.f, hshare) != mg["h"]:
bad("collection %s: an aggregate share is not the batch's" % s["name"])
for st, role, share, key, who in ((sts[0], 2, lshare, info["leader"], "leader"), (sts[1], 3, hshare, info["helper"], "helper")):
if (st["role"], st["task_id"], st["collection"], st["batch"], st["count"], st["checksum"], st["agg_share"],
st["signer"]) != (role, tid, sha(req), q, mg["n"], mg["ck"], share, key):
bad("collection %s: a statement is not its share's (telemetry-aggregation §6.2)" % s["name"])
if st["rejected"] != stated[who]: # §7.3: what it rejected, of this batch
bad("collection %s: the %s states %s rejected, the run gives %s" % (s["name"], who, st["rejected"], stated[who]))
if ED.verify(key, b"wai:telemetry-aggregate/share\x01" + st["body"], st["sig"]) is not None:
bad("collection %s: a statement's signature" % s["name"])
result = TAV.vadd(p.f, mg["l"], mg["h"])
if result != s["result"]:
bad("collection %s: the result" % s["name"])
rc = TAV.read_receipt(bytes.fromhex(s["receipt"]))
if (rc["leader"], rc["helper"], rc["result"], rc["task"], rc["collection"], rc["interval"]) != \
(sts[0]["body"] + sts[0]["sig"], sts[1]["body"] + sts[1]["sig"], result, cfg, req, iv):
bad("collection %s: the receipt is not the collection's" % s["name"])
given = sorted(json.dumps({"id": rid.hex(), "time": tm, "task": tid.hex()}, sort_keys=True)
for rid, tm in mg["ids"])
holders = [x for x in doc["receipts"] if x["receipt"] == s["receipt"]]
with_reports = [x for x in holders if x["reports"] is not None]
if not holders or (with_reports and not any(sorted(json.dumps(g, sort_keys=True) for g in x["reports"]) == given
for x in with_reports)):
bad("collection %s: the receipts section does not hold its receipt with its reports" % s["name"])
# ------------------------------------------------------------------ the corpus
KINDS = ("catalogues", "tasks", "task_files", "contributions", "uploads", "plans", "settles", "receipts", "tables")
def run(doc, kind, c):
if kind == "catalogues":
return catalogue_outcome(bytes.fromhex(c["bytes"]))
if kind == "tasks":
return task_outcome(bytes.fromhex(c["task"]), bytes.fromhex(c["catalogue"]))
if kind == "task_files":
return task_file_outcome(doc, c)
if kind == "contributions":
return contribution_outcome(doc, c)
if kind == "uploads":
return upload_outcome(bytes.fromhex(c["bytes"]))
if kind == "plans":
return plan_outcome(doc, c)
if kind == "settles":
return settle(bytes.fromhex(c["sent"]), bytes.fromhex(c["response"]))
if kind == "receipts":
return receipt_outcome(c)
return table(bytes.fromhex(c["catalogue"]), bytes.fromhex(c["task"]), c["receipts"])
def check_fixtures(doc):
"""What the fixtures say of themselves: each creative's digest (ad-presentation §4), each catalogue,
task and task file the cases name by fixture."""
fails = []
fx = doc["fixtures"]
for n, c in fx["creatives"].items():
objs = [{"content_hash": bytes.fromhex(o["content_hash"]), "duration_ms": o["duration_ms"]}
for o in c["objects"]]
if APV.creative_digest(objs).hex() != c["digest"]:
fails.append("creative %s: its digest" % n)
for n, h in fx["task_files"].items():
try:
tf, cat, task, _ = receiver_task_file(doc, n)
if tf["catalogue"].hex() != fx["catalogues"].get(n, tf["catalogue"].hex()) or \
tf["task"].hex() != fx["tasks"].get(n, tf["task"].hex()):
fails.append("task file %s: not its fixtures' catalogue and task" % n)
except Refuse as e:
fails.append("task file %s is refused %s under what the receiver holds" % (n, e.reason))
return fails
def strings(v, out):
if isinstance(v, str):
out.append(v)
elif isinstance(v, list):
for x in v:
strings(x, out)
elif isinstance(v, dict):
for x in v.values():
strings(x, out)
def stated_reasons(doc):
"""Every reason an outcome of the corpus gives, flows included."""
out = []
for kind in KINDS:
for c in doc[kind]:
o = c["outcome"]
if isinstance(o, dict) and o.get("reason"):
out.append(o["reason"])
for s in [] if kind != "tables" or o.get("outcome") != "table" else o["receipts"]:
m = re.search(r"reason=(\S+)", s["verdict"])
if m:
out.append(m.group(1))
for f in doc["flows"]:
for x in f["reports"]:
out += [y for y in (x.get("error"), x.get("reason")) if y]
for s in f["steps"]:
out += [e["error"] for e in s.get("errors", [])]
out += [y for o in s.get("outcomes", []) for y in (o["outcome"], o["reason"]) if y and y != "committed"]
if s["step"] == "collect":
out += [y for y in (s["outcome"], s.get("helper_outcome"))
if y not in ("processing", "completed", "accept", None)]
return out
ELSEWHERE = set(TAV.REASONS) | set(SPV.REASONS) | set(SPV.OTHER) | set(APV.REASONS) | set(JWP_REASONS) | \
set(REPORT_ERRORS.values()) | set(PROBLEMS) | {"count_mismatch", "truncated", "malformed", "unknown_version",
"bad_key", "bad_signature"}
REUSED = ["count_mismatch", "attestation_absent", "attestation_invalid", "task_invalid", "measure_mismatch",
"floor_too_low", "aggregator_alone", "batch_invalid", "task_mismatch", "batch_too_small", "batch_mismatch",
"bad_key", "bad_signature", "pod_signer_unknown", "claim_not_viewable", "claim_duplicate",
"claim_marker_absent", "claim_manifest_unknown", "signer_id_not_origin_bound", "origin_not_anchored",
"authority_not_granted", "anchor_not_pinned", "key_revoked", "report_replayed", "report_dropped",
"invalid_message", "hpke_decrypt_error", "vdaf_verify_error", "invalidBatchSize"]
def check(doc, quiet=False):
"""The failures of a corpus document."""
fails = check_fixtures(doc)
for kind in KINDS:
for c in doc[kind]:
try:
got = run(doc, kind, c)
except (Refuse, KeyError, TypeError, ValueError, IndexError, AttributeError) as e:
got = {"error": repr(e)}
if got != c["outcome"]:
fails.append("%s %s" % (kind, c["name"]))
if not quiet:
print("FAIL %s %s\n stated %s\n got %s" % (kind, c["name"], json.dumps(c["outcome"])[:600],
json.dumps(got)[:600]))
for f in doc["flows"]:
try:
ff = run_flow(doc, f)
except (Refuse, KeyError, TypeError, ValueError, IndexError, AttributeError) as e:
ff = ["flow %s: %r" % (f["name"], e)]
fails += ff
if not quiet:
for x in ff:
print("FAIL", x)
if not ff:
n = len(f["reports"]) + len(f.get("late", []))
print("PASS flow %s: %d reports re-run through %d steps" % (f["name"], n, len(f["steps"])))
if doc.get("codes") != REASONS:
fails.append("the corpus's codes are not §10's")
given = stated_reasons(doc)
for code in REASONS + REUSED:
if code not in given:
fails.append("no case gives " + code)
for code in sorted(set(given) - set(REASONS) - ELSEWHERE):
fails.append("a case gives %s, a code the extension does not name" % code)
return fails
def bounds():
"""§5.4's and §6.4's size bounds, which no vector carries: a file of exactly the bound that
reads in every other respect is read (a task file passes step 1 and is refused at step 2, its
catalogue of zeros not reading), and the same file one byte longer is refused at step 1.
§4.3's bound decides no outcome: no catalogue that meets §4.3 comes near it."""
fails = []
suite = struct.pack(">BHHHH", 1, *SUITE, 32) + b"\x09" * 32
def hpke_list(cid):
body = bytes([cid]) + suite[1:]
return len(body).to_bytes(2, "big") + body
def task_file(n):
body = (TASK_MAGIC + b"\x01" + (0).to_bytes(2, "big") + u32(n) + b"\x00" * n + hpke_list(1) + hpke_list(2)
+ b"\x00" * 32 + (1).to_bytes(2, "big") + b"x")
return body + b"\x00" * 64
ct = b"\x01" + (1).to_bytes(2, "big") + b"\x01" + u32(1) + b"\x01"
def upload(n):
report = b"\x00" * 16 + (0).to_bytes(8, "big") + (0).to_bytes(2, "big") + u32(n) + b"\x00" * n + ct + ct
return write_upload(b"\x00" * 32, [report])
def outcome(fn, b):
try:
fn(b)
return "read"
except Refuse as e:
return e.reason
for what, make, bound, fn, inside, past in (
("task file", task_file, MAX_TASK_FILE, lambda b: accept_task_file(b, [], []), "catalogue_malformed",
"task_file_malformed"),
("upload file", upload, MAX_UPLOAD, read_upload, "read", "upload_malformed")):
at = bound - len(make(0))
for b, want in ((make(at), inside), (make(at + 1), past)):
got = outcome(fn, b)
if got != want:
fails.append("a %s of %d bytes: %s, not %s" % (what, len(b), got, want))
return fails
# ------------------------------------------------------------------ mutations and weakenings
def flip(h, at):
b = bytearray(bytes.fromhex(h))
b[at % len(b)] ^= 0x01
return b.hex()
def case(d, kind, name):
return next(c for c in d[kind] if c["name"] == name)
def flow(d, name):
return next(f for f in d["flows"] if f["name"] == name)
def step(f, name):
return next(s for s in f["steps"] if s.get("name") == name or s["step"] == name)
def mutations():
"""Copies of the corpus, one defect each, every one of which must fail."""
def m_catalogue_hold(d):
c = case(d, "catalogues", "flight")
b = bytearray.fromhex(c["bytes"])
b[5 + 4 + 2 + 14 + 3] ^= 0x01 # the last byte of hold_s
c["bytes"] = b.hex()
def m_region_column(d):
case(d, "contributions", "region_subdivision_of_listed_country")["outcome"].update(column="other", cell=14)
def m_bucket_edge(d):
case(d, "contributions", "bucket_first_ms")["at_ms"] -= 1
def m_quartile(d):
case(d, "contributions", "q4_three_quarters")["outcome"].update(quartile=5, cell=4)
def m_pool_discarded(d):
case(d, "contributions", "token_pool_skips_bad")["outcome"]["discarded"] = []
def m_task_retention(d):
case(d, "tasks", "flight")["outcome"]["retention_s"] += 1
def m_task_file_bit(d):
c = case(d, "task_files", "flight")
c["bytes"] = flip(c["bytes"], 40)
def m_anchor(d):
case(d, "task_files", "flight")["anchors"] = ["fetched"]
def m_floor_flag(d):
case(d, "tables", "flight")["outcome"]["totals"]["rows"][0]["floor_met"] = False
def m_row_count(d):
case(d, "tables", "flight")["outcome"]["receipts"][1]["rows"][5]["presentations"] = 50
def m_receipt_byte(d):
c = case(d, "receipts", "r0")
c["receipt"] = flip(c["receipt"], len(c["receipt"]) // 4)
def m_receipt_reports(d):
case(d, "receipts", "r0")["reports"].pop()
def m_hold(d):
s = step(flow(d, "flight"), "collect_b0_held")
s["now_s"] += 1
def m_late_error(d):
step(flow(d, "flight"), "late_b0")["errors"][0]["error"] = "batch_collected"
def m_token_outcome(d):
o = step(flow(d, "flight"), "aggregate")["outcomes"][0]
o.update(outcome="committed", reason=None)
def m_init_byte(d):
s = step(flow(d, "flight"), "aggregate")
s["init_req"] = flip(s["init_req"], 400)
def m_merge_share(d):
s = step(flow(d, "flight"), "relay_merged")
s["shares"].reverse()
def m_presentation_cell(d):
x = next(r for r in flow(d, "open")["reports"] if r["kind"] == "presentation")
x["cell"] = (x["cell"] + 1) % 5
def m_budget(d):
case(d, "plans", "budget_exact")["budget"] -= 1
def m_plan_retention(d):
case(d, "plans", "retention_exact")["t_ms"] -= 1
def m_upload_order(d):
c = case(d, "uploads", "three_reports")
tid, reps = read_upload(bytes.fromhex(c["bytes"]))
c["bytes"] = write_upload(tid, [reps[1]["raw"], reps[0]["raw"], reps[2]["raw"]]).hex()
def m_settle_too_early(d):
o = case(d, "settles", "each_error")["outcome"]
o["delete"] = sorted(o["delete"] + o["keep"])
o["keep"] = []
def m_rejected_outcome(d):
case(d, "receipts", "r0")["outcome"]["rejected"]["leader"] += 1
def m_rejected_no_count(d):
case(d, "receipts", "r0_of_version_1")["outcome"]["rejected"]["helper"] = 0
def m_rejected_row(d):
e = case(d, "tables", "flight")["outcome"]["receipts"][0]["rejected"]
e["leader"], e["helper"] = e["helper"], e["leader"]
def m_rejected_total(d):
case(d, "tables", "flight")["outcome"]["totals"]["rejected"]["helper"] += 1
def m_rejected_total_of_none(d):
case(d, "tables", "flight_a_statement_of_no_count")["outcome"]["totals"]["rejected"]["leader"] = 0
def m_rejected_of_both_versions(d):
case(d, "tables", "flight_one_statement_of_no_count")["outcome"]["totals"]["rejected"]["helper"] = 3
def m_replay_answer(d):
s = next(x for x in case(d, "flows", "flight")["steps"] if x["step"] == "replay")
s["resp"] = s["resp"][:-2] + "00"
def m_overlap_gone(d):
d["tables"].remove(case(d, "tables", "flight_overlap"))
def m_codes(d):
d["codes"].reverse()
def m_creative_fixture(d):
o = d["fixtures"]["creatives"]["A"]["objects"][0]
o["duration_ms"] += 1
return [m_catalogue_hold, m_region_column, m_bucket_edge, m_quartile, m_pool_discarded, m_task_retention,
m_task_file_bit, m_anchor, m_floor_flag, m_row_count, m_receipt_byte, m_receipt_reports, m_hold,
m_late_error, m_token_outcome, m_init_byte, m_merge_share, m_presentation_cell, m_budget,
m_plan_retention, m_upload_order, m_settle_too_early, m_overlap_gone, m_codes, m_creative_fixture,
m_rejected_outcome, m_rejected_no_count, m_rejected_row, m_rejected_total, m_rejected_total_of_none,
m_rejected_of_both_versions, m_replay_answer]
WEAKENINGS = {
"cat-version": "§4.3 rule 1: version 1",
"cat-terms": "§4.3 rule 2: the terms",
"cat-hold": "§4.3 rule 3: a hold above zero",
"cat-signers": "§4.3 rule 4: one to sixteen signers",
"cat-creatives": "§4.3 rule 5: a creative",
"cat-order": "§4.3 rules 4 to 8: strictly ascending lists",
"cat-region": "§4.3 rule 6: each region a code",
"cat-overlap": "§4.3 rule 6: no country beside its subdivision",
"cat-cells": "§4.3 rule 7: at most 4,096 cells",
"cat-keys": "§4.3 rule 8: the keys' count",
"cat-key-form": "§4.3 rule 8: each key's form",
"catalogue-digest": "§5.3 step 2: measure 5 and the catalogue's digest",
"task-precision": "§5.3 step 3: an hour's precision",
"task-interval": "§5.3 step 3: a flight of a duration",
"task-retention": "§5.3 step 3: the retention instant's bound",
"chunk-length": "§5.3 step 4: the chunk length",
"tf-version": "§5.4 step 1: version 1",
"tf-signer-ascii": "§5.4 step 1: an ASCII signer_id",
"tf-hpke-ids": "§5.4 step 1: distinct configuration ids",
"tf-hpke-suite": "§5.4 step 1: DAP's suite in each list",
"tf-signature": "§5.4 step 4: the file's signature",
"tf-authority": "§5.4 step 5: the authority aggregate/task",
"tf-pinned": "§5.4 step 5: a pinned anchor",
"tf-revocation": "§5.4 step 5: revisions the bearer brings",
"shared": "§6.2 step 2: a shared record",
"terms-equal": "§6.2 step 3: the catalogue's terms",
"creative-listed": "§6.2 step 5: a listed creative",
"instant": "§6.2 step 6: a known instant",
"flight-window": "§6.2 step 7: a time within the flight",
"token-pool": "§6.2 step 8: each pair's token",
"region-subdivision": "§4.2: a subdivision's column is its listed country's",
"hpke-first": "§6.3: the first configuration of DAP's suite",
"token-check": "§7.2: the aggregators' report rule",
"token-challenge": "§7.2: a token's challenge",
"helper-token": "§7.2: the helper checks its own share",
"public-extension": "§7.2: no public extension",
"hold": "§7.1: the hold",
"upload-dropped": "DAP §4.4.2.2: a report outside the flight dropped at upload",
"upload-replay": "DAP §4.4.2.2: a report replayed at upload",
"batch-in-task": "§8.1 step 12: the batch within the flight",
"count-sum": "§8.1 step 13: the elements sum to the count",
"count-integer": "§8.1 step 13: integers, each at most the count",
"task-equal": "§8.2 step 2: the given task",
"dedupe": "§8.2 step 3: equal receipts are one",
"overlap": "§8.2 step 3: overlapping receipts set aside",
"reports-checked": "§8.2: whether a receipt's reports were checked",
"rejected-own": "§7.3: each aggregator states its own count",
"rejected-aggregated": "telemetry-aggregation §6.1: no report it aggregated counts as rejected",
"rejected-none": "§8.2: a total of none where a statement states no count",
"checked-any": "§8.2: a receipt given more than once checked when any occurrence was",
"tf-bound": "§5.4 step 1: at most 1,048,576 bytes",
"upload-bound": "§6.4: at most 16,777,216 bytes",
"upload-version": "§6.4: version 1",
"upload-length": "§6.4: a file that ends before its upload's length",
"upload-order": "§6.4: reports in ascending order of id",
"plan-deadline": "§6.5: timely and late",
"plan-retention": "§6.5: dropped at the retention instant",
"plan-budget": "§6.5: the budget",
"settle-subsequence": "§6.6: a response of the file's ids in order",
"settle-too-early": "§6.6: report_too_early kept",
"settle-unknown-value": "§6.6: a value DAP does not define kept",
}
def main(argv):
if len(argv) != 2:
print(__doc__)
return 2
fails = []
st = ED._self_test()
ct = TAV.crypto_self_test()
print(("PASS" if not st and not ct else "FAIL") + " Ed25519, TurboSHAKE128, X25519 and HPKE self-tests")
fails += (["ed25519 self-test"] if st else []) + ct
print(("PASS" if len(REASONS) == 11 else "FAIL") + " §10 read from the extension: %d codes" % len(REASONS))
with open(os.path.join(argv[1], "vectors.json"), encoding="utf-8") as fh:
doc = json.load(fh)
got = check(doc)
fails += got
sizes = ", ".join("%d %s" % (len(doc[k]), k) for k in KINDS + ("flows",))
print("%s %s: %d failure(s)" % ("PASS" if not got else "FAIL", sizes, len(got)))
extra = sorted(set(os.listdir(argv[1])) - {"vectors.json", "README.md"})
if extra:
fails.append("files the corpus does not hold: %s" % extra)
b = bounds()
print(("PASS" if not b else "FAIL") + " the size bounds of §5.4 and §6.4, at their edge")
fails += b
caught = 0
for m in mutations():
d = copy.deepcopy(doc)
m(d)
if check(d, quiet=True):
caught += 1
else:
fails.append("mutation %s was not caught" % m.__name__)
n = len(mutations())
print("%s %d of %d mutated copies fail" % ("PASS" if caught == n else "FAIL", caught, n))
weak = 0
for name, what in WEAKENINGS.items():
SKIP.clear()
SKIP.add(name)
if check(doc, quiet=True) or bounds():
weak += 1
else:
fails.append("a verifier without %s (%s) passes the corpus" % (what, name))
SKIP.clear()
n = len(WEAKENINGS)
print("%s %d of %d weakened verifiers are caught" % ("PASS" if weak == n else "FAIL", weak, n))
for x in fails:
print("FAIL", x)
print(("PASS" if not fails else "FAIL") + " impression aggregates corpus")
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))