open-standards/wai/live-conformance/telemetry_aggregate_verify.py
Transaction Science 793ed1f2dc
All checks were successful
wai-app and Create / wai-app · does this change touch it (pull_request) Successful in 7s
wai-audit collector and player integration / does this change touch it (pull_request) Successful in 10s
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 12s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (pull_request) Successful in 14s
WAI operation catalogue / does this change touch it (pull_request) Successful in 13s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (pull_request) Successful in 13s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 14s
WAI examples / WAI examples · does this change touch them (pull_request) Successful in 7s
WAI HTTP delivery / WAI HTTP · does this change touch it (pull_request) Successful in 6s
wai-fleet service / does this change touch it (pull_request) Successful in 14s
WAI neural sink gates / does this change touch it (pull_request) Successful in 13s
wai-pack / wai-pack · does this change touch it (pull_request) Successful in 12s
wai-quantum for JavaScript / does this change touch it (pull_request) Successful in 9s
wai-ledger service / does this change touch it (pull_request) Successful in 14s
wai-ops-gpu integer operators on GPUs / does this change touch it (pull_request) Successful in 14s
wai-key-proxy / does this change touch it (pull_request) Successful in 16s
WAI conformance / does this change touch it (pull_request) Successful in 31s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 32s
wai-quantum studio and desktop / does this change touch it (pull_request) Successful in 6s
WAI energy receipts / does this change touch it (pull_request) Successful in 33s
wai-sink-android native sink library / does this change touch it (pull_request) Successful in 8s
wai-sink-swift native sink package / does this change touch it (pull_request) Successful in 7s
WAI Studio / does this change touch it (pull_request) Successful in 8s
wai-web reach builds / wai-web reach · does this change touch it (pull_request) Successful in 9s
wai-web wasm32 / does this change touch it (pull_request) Successful in 25s
WAI HTTP delivery / HTTP corpus · every patch and mutation in exactly one shard (pull_request) Successful in 5s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (pull_request) Successful in 55s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (pull_request) Successful in 1m11s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 1m58s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (pull_request) Successful in 1m22s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (pull_request) Successful in 1m51s
wai-audit collector and player integration / collector · linux (pull_request) Successful in 1m32s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (pull_request) Successful in 2m31s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 1m51s
WAI neural sink gates / the exact JPEG-AI decoder reports its path (pinned inputs, required) (pull_request) Successful in 2m2s
WAI conformance / fast integer kernels, every x86_64 kernel set (AVX-512 silicon) (pull_request) Successful in 3m36s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 4m32s
wai-fleet service / service · linux (pull_request) Successful in 2m42s
wai-key-proxy / proxy · linux (pull_request) Successful in 57s
wai-pack / wai-pack · linux (pull_request) Successful in 3m19s
WAI conformance / faces from keypoints under wasm32-wasip2 (wasmtime) (pull_request) Successful in 1m30s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 27s
WAI examples / WAI examples · android-app · linux-arm64 (pull_request) Successful in 5m59s
wai-ops-gpu integer operators on GPUs / builds · no adapter is not a pass (linux) (pull_request) Successful in 4m11s
WAI operation catalogue / the catalogue and every door · linux (pull_request) Successful in 5m56s
wai-ledger service / service · linux (pull_request) Successful in 1m50s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (pull_request) Successful in 7m18s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (pull_request) Successful in 1m7s
WAI conformance / quantum core and corpus under wasm32-wasip2 (wasmtime) (pull_request) Successful in 3m48s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (pull_request) Successful in 7m43s
wai-quantum for JavaScript / javascript · linux (pull_request) Successful in 3m41s
wai-fleet service / service · macos (pull_request) Successful in 48s
wai-quantum studio and desktop / studio · browser (pull_request) Successful in 3m39s
wai-quantum studio and desktop / desktop app · linux (pull_request) Successful in 4m54s
wai-web reach builds / wai-web reach builds · the package of record, every output as recorded (pull_request) Successful in 3m2s
wai-pack / wai-pack · macos (pull_request) Successful in 3m27s
wai-web reach builds / wai-web reach builds · this tree's wai-web, every rung on its engine line (pull_request) Successful in 5m55s
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (pull_request) Successful in 13m52s
wai-web wasm32 / wai-web wasm32 build · corpus, menu and writers (pull_request) Successful in 5m28s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 16m34s
WAI Studio / Studio in WebKit · linux (pull_request) Successful in 11m38s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 27s
WAI conformance / codec wrappers · fuzz smoke, text decode limits, C entry points, provenance, the semantic path (pull_request) Successful in 1m7s
wai-ledger service / service · macos (pull_request) Successful in 1m6s
wai-quantum studio and desktop / desktop app · windows (pull_request) Successful in 9m35s
wai-ops-gpu integer operators on GPUs / Metal GPU and browser WebGPU (macos) (pull_request) Successful in 10m56s
WAI energy receipts / energy receipts · linux (pull_request) Successful in 17m20s
WAI HTTP delivery / HTTP corpus · falsify patches 0/3 (pull_request) Successful in 18m48s
WAI examples / WAI examples · portable · linux (pull_request) Successful in 24m50s
WAI HTTP delivery / HTTP corpus · falsify patches 1/3 (pull_request) Successful in 18m38s
WAI HTTP delivery / HTTP corpus · verified both ways, and the differential (pull_request) Successful in 2m31s
wai-quantum studio and desktop / desktop app · macos (pull_request) Successful in 7m3s
WAI HTTP delivery / cache rules · controls and labelled caches (pull_request) Successful in 1m46s
WAI energy receipts / energy receipts · macos (pull_request) Successful in 8m4s
wai-pack / wai-pack · linux-arm64 (pull_request) Successful in 1m47s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 17m16s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (pull_request) Successful in 7m47s
WAI HTTP delivery / HTTP corpus · falsify mutations 0/3 (pull_request) Successful in 34m23s
wai-pack / wai-pack · the floor through a media prober (pull_request) Successful in 21s
wai-pack / wai-pack · dependency licences (pull_request) Successful in 16s
wai-pack / wai-pack · independent verifier (Python) (pull_request) Successful in 7m5s
WAI conformance / v2 vocabulary · manifests validate as stated (pull_request) Successful in 18s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 37m20s
WAI Studio / Studio in Chromium and Firefox · macos (pull_request) Successful in 10m52s
WAI HTTP delivery / HTTP corpus · falsify patches 2/3 (pull_request) Successful in 18m17s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 11m18s
WAI HTTP delivery / HTTP corpus · falsify mutations 1/3 (pull_request) Successful in 39m59s
WAI conformance / delivery telemetry · CMCD and CMSD against an independent library (pull_request) Successful in 15s
WAI HTTP delivery / HTTP corpus · falsify mutations 2/3 (pull_request) Successful in 40m12s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 50s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 2m42s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (pull_request) Successful in 3m47s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (pull_request) Successful in 17m30s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 18m34s
WAI conformance / each feature of the independent verifiers' steps compiles alone (pull_request) Successful in 14m28s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 14m15s
WAI energy receipts / energy receipts · linux-arm64 (pull_request) Successful in 30m4s
WAI conformance / independent verifiers · claims, JWP, key discovery and integer payloads (Python) (pull_request) Successful in 1h18m5s
wai/one-way-delivery, converted to the layout of the fix: no shared append point
The change of wai/one-way-delivery (f1258784d..987625394, 28 commits), on the fix branch: its
changelog entries as files of wai/changes/, its wai-conformance.yml edits as step files,
corpus files and test tags, and every other file as the branch has it.

- 25240c4f0 wai: timeliness — when each object of a group is of use, and whether each holder held it in time
- c84992362 wai: timeliness_verify.py — the timeliness corpus's second implementation
- 44036fcdc wai: timeliness in the registry, SPEC §5, the changelog and CI; http_receipts.py checks its claims
- 27ce6754c wai: claim_verify.py lists not-held-by-deadline, and the claim corpus holds a session that switches for it
- 73d8d8eb9 wai: timeliness corpus — each holder's own line in key order, a holder's earliest fallback, and §7.2–§7.3's unreached plan rules
- 64c735f0c wai: the files timeliness changes are clippy-clean — a let chain, and the fetch callback's type allowed
- b72dc36bf wai: rung price — what delivering a rung costs, by bearer and direction, and which option a comparer takes
- 4e905e213 wai: rung_price_verify.py — the rung price corpus's second implementation
- 711fa6126 wai: rung price in the registry, SPEC §5, the changelog and CI
- 8ab70654e wai: rung price corpus — a refusal for every form rule 1 and §6.1 set, and the neighbour order a plan gives
- 1bc465959 wai: rung price ranks energy by checked evidence — a class counts only on the seller's own per-hop records, current and under a confirmed key
- 9e97997d9 wai: rung_price — the evidence text bound at its exact sizes, and a key confirmed for another class confirming nothing
- 8f1168683 wai: rung price corpus — a key confirmed for another class, the uncertainty product, each edge and order of the evidence verdict, and the evidence a plan read
- 7330e26ff wai: rung price — §6.1 links jwp-receipts at its first mention, and §10 says a record's age is its room's word
- 12c099457 wai: cache authority — who served what a node holds, and on whose authority
- f8f5d3e09 wai: cache_authority_verify.py — the cache authority corpus's second implementation
- 354146619 wai: cache authority in the registry, SPEC §5, the changelog and CI; its corpus holds what §12 states
- 55c7f3031 wai: timeliness §6 reads holdings records alone; cache-authority §9 derives the lines that read every kind
- b8fc6f02b wai: cache authority's lines read records as timeliness does, its deadline claim meets the envelopes held, and its corpus fixes each order and instant
- 91c5291cf wai: the files cache authority changes are clippy-clean — a boxed envelope, the base's names named once, slices borrowed
- 8e479daff wai: a held envelope boxes both its forms, so neither outweighs the other
- 3ec8da807 wai: impression aggregates — ad presentations counted over a bearer with no return path
- 0433a35c3 wai: impression_aggregate_verify.py — the impression aggregates corpus's second implementation
- 7d8e150e4 wai: impression aggregates in the registry, SPEC §5, the changelog and CI; its corpus states each receipt whole and a control for every malformed file
- d5e8ace02 wai: impression aggregates' size bounds are checked at their edge, and §4.3's decides no outcome
- 4bc8e13bd wai: impression aggregates' table totals are exact, and a receipt given twice is checked when either occurrence was
- 3c9f20524 wai: key_log's two result enums keep their public form, and say so to clippy
- 987625394 wai: Item::with_authority and Count::instance_received build only with the feature that calls each

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 01:45:32 -05:00

1836 lines
72 KiB
Python

#!/usr/bin/env python3
"""Independent verifier of the QoE aggregation corpus (extensions/telemetry-aggregation.md).
Written from the extension's text and from the texts it cites: draft-ietf-ppm-dap-20 (DAP),
draft-irtf-cfrg-vdaf-22 (the VDAF draft, whose algorithms are written in Python and are
followed here), RFC 9180 (HPKE), RFC 9861 (TurboSHAKE), RFC 7748 (X25519), RFC 5869 (HKDF),
FIPS 197 (AES) and NIST SP 800-38D (GCM). It shares no code with wai-rs, which generates the
corpus, and never runs it. Ed25519 signatures are checked with
../claim-conformance/_ed25519.py, WAI's strict rule (SPEC §7 *Signatures*). Everything else
is here:
- Keccak-p[1600, 12], TurboSHAKE128 and the VDAF draft's XofTurboShake128 (§6.2.1);
- Field64 and Field128, the Lagrange-basis polynomials, the gadgets and circuits of Prio3Count,
Prio3Sum, Prio3SumVec and Prio3Histogram, the FLP and Prio3 itself (§6, §7, Appendix A),
checked first against the draft's own test vectors in vdaf-22/;
- X25519, HKDF-SHA256, AES-128-GCM and HPKE base mode, checked against RFC 9180 A.1.1;
- DAP's messages, and each flow of the corpus re-run from its reports: every input share
opened with the aggregators' test keys, each measurement recombined and required to be its
session's (extension §3.1), both aggregators' verification, the aggregation job's request
and response required byte for byte, the batch buckets, every refusal, the collection,
every aggregate share and statement opened with the collector's key, and the result;
- the statement and the aggregate receipt (§6, §7), verification in §8.1's order with each
refusal's place, and a room's cell (§9.2);
- delivery-telemetry §3.4's key classes, read from that text, and §4.3's delivery-only reports.
It requires the outcome the corpus states for everything, that every reason of §11 (read from
the extension) and every code it reuses appears, then 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 telemetry_aggregate_verify.py <live-conformance/aggregate>
Exit status 0 only if every case verifies, every mutated copy fails and every weakened verifier
fails. Python 3.8 or later; standard library only.
"""
import copy
import hashlib
import hmac
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
import _ed25519 as ED # noqa: E402
EXTENSION = os.path.join(HERE, "..", "extensions", "telemetry-aggregation.md")
TELEMETRY = os.path.join(HERE, "..", "extensions", "delivery-telemetry.md")
SKIP = set()
class Refuse(Exception):
def __init__(self, reason, at=None):
super().__init__(reason)
self.reason, self.at = reason, at
def sha256(b):
return hashlib.sha256(b).digest()
# ---------------------------------------------------------------------------------------
# Keccak-p[1600, 12] and TurboSHAKE128 (RFC 9861 §2).
ROUND_CONSTANTS = [
0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000,
0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009,
0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A,
0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003,
0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A,
0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008,
]
ROTATIONS = [[0, 36, 3, 41, 18], [1, 44, 10, 45, 2], [62, 6, 43, 15, 61], [28, 55, 25, 21, 56], [27, 20, 39, 8, 14]]
M64 = (1 << 64) - 1
PI = [(y + 5 * ((2 * x + 3 * y) % 5), x + 5 * y, ROTATIONS[x][y]) for x in range(5) for y in range(5)]
def keccak_p12(a):
for rc in ROUND_CONSTANTS[12:]:
c = [a[x] ^ a[x + 5] ^ a[x + 10] ^ a[x + 15] ^ a[x + 20] for x in range(5)]
d = [c[(x - 1) % 5] ^ (((c[(x + 1) % 5] << 1) | (c[(x + 1) % 5] >> 63)) & M64) for x in range(5)]
a = [a[i] ^ d[i % 5] for i in range(25)]
b = [0] * 25
for dst, src, r in PI:
v = a[src]
b[dst] = ((v << r) | (v >> (64 - r))) & M64 if r else v
a = [b[i] ^ ((~b[(i + 1) % 5 + 5 * (i // 5)]) & b[(i + 2) % 5 + 5 * (i // 5)]) for i in range(25)]
a[0] ^= rc
return a
class TurboShake128:
RATE = 168
def __init__(self, m, d):
a = [0] * 25
p = bytearray(m) + bytes([d])
p += bytes((-len(p)) % self.RATE)
p[-1] ^= 0x80
for off in range(0, len(p), self.RATE):
blk = p[off:off + self.RATE]
for i in range(21):
a[i] ^= int.from_bytes(blk[8 * i:8 * i + 8], "little")
a = keccak_p12(a)
self.a, self.buf = a, b""
def read(self, n):
while len(self.buf) < n:
self.buf += b"".join(x.to_bytes(8, "little") for x in self.a[:21])
self.a = keccak_p12(self.a)
out, self.buf = self.buf[:n], self.buf[n:]
return out
def turboshake128(m, d, n):
return TurboShake128(m, d).read(n)
VERSION = 18 # the VDAF draft's §2
def format_dst(algo_class, algo, usage):
return bytes([VERSION, algo_class]) + algo.to_bytes(4, "big") + usage.to_bytes(2, "big")
class Xof:
"""XofTurboShake128 (VDAF §6.2.1)."""
SEED_SIZE = 32
def __init__(self, seed, dst, binder):
self.t = TurboShake128(len(dst).to_bytes(2, "little") + dst + bytes([len(seed)]) + seed + binder, 1)
def next(self, n):
return self.t.read(n)
def next_vec(self, field, length):
mask = (1 << (8 * field.SIZE)) - 1 # next_power_of_2(MODULUS) - 1, every bit of the encoding
out = []
while len(out) < length:
x = int.from_bytes(self.next(field.SIZE), "little") & mask
if x < field.P:
out.append(x)
return out
@classmethod
def derive_seed(cls, seed, dst, binder):
return cls(seed, dst, binder).next(cls.SEED_SIZE)
@classmethod
def expand_into_vec(cls, field, seed, dst, binder, length):
return cls(seed, dst, binder).next_vec(field, length)
# ---------------------------------------------------------------------------------------
# Fields (VDAF §6.1); elements are plain integers below P.
class Field64:
P = 2 ** 32 * 4294967295 + 1
SIZE = 8
GEN_ORDER = 2 ** 32
GEN = pow(7, 4294967295, P)
class Field128:
P = 2 ** 66 * 4611686018427387897 + 1
SIZE = 16
GEN_ORDER = 2 ** 66
GEN = pow(7, 4611686018427387897, P)
def inv(f, x):
return pow(x, f.P - 2, f.P)
def nth_root(f, n):
return pow(f.GEN, f.GEN_ORDER // n, f.P)
def nth_root_powers(f, n):
w = nth_root(f, n)
out, x = [], 1
for _ in range(n):
out.append(x)
x = x * w % f.P
return out
def encode_vec(f, v):
return b"".join(x.to_bytes(f.SIZE, "little") for x in v)
def decode_vec(f, b):
if len(b) % f.SIZE:
raise ValueError("length")
v = [int.from_bytes(b[i:i + f.SIZE], "little") for i in range(0, len(b), f.SIZE)]
if any(x >= f.P for x in v):
raise ValueError("modulus overflow")
return v
def vadd(f, a, b):
if len(a) != len(b):
raise ValueError("mismatched vector sizes")
return [(x + y) % f.P for x, y in zip(a, b)]
def vsub(f, a, b):
if len(a) != len(b):
raise ValueError("mismatched vector sizes")
return [(x - y) % f.P for x, y in zip(a, b)]
def next_power_of_2(x):
n = 1
while n < x:
n *= 2
return n
def poly_eval_mono(f, p, x):
acc = 0
for c in reversed(p):
acc = (acc * x + c) % f.P
return acc
def ntt(f, p, n, set_s=False):
s = nth_root(f, 2 * n) if set_s else 1
return [poly_eval_mono(f, p, s * w % f.P) for w in nth_root_powers(f, n)]
def inv_ntt(f, v, n):
wi = inv(f, nth_root(f, n))
ninv = inv(f, n)
out = []
for j in range(n):
step = pow(wi, j, f.P)
acc, x = 0, 1
for vi in v:
acc = (acc + vi * x) % f.P
x = x * step % f.P
out.append(acc * ninv % f.P)
return out
def double_evaluations(f, p):
n = len(p)
odd = ntt(f, inv_ntt(f, p, n), n, True)
return [x for pair in zip(p, odd) for x in pair]
def poly_mul(f, p, q):
a, b = double_evaluations(f, p), double_evaluations(f, q)
return [x * y % f.P for x, y in zip(a, b)]
def poly_eval_batched(f, polys, x):
"""VDAF §6.1.3.2, as the draft writes it."""
n = len(polys[0])
nodes = nth_root_powers(f, n)
k = 1
u = [p[0] for p in polys]
d = (nodes[0] - x) % f.P
for i in range(1, n):
k = k * d % f.P
d = (nodes[i] - x) % f.P
t = k * nodes[i] % f.P
for j, p in enumerate(polys):
u[j] = u[j] * d % f.P
if i < len(p):
u[j] = (u[j] + t * p[i]) % f.P
factor = pow(f.P - 1, n - 1, f.P) * inv(f, n) % f.P
return [v * factor % f.P for v in u]
def extend_values_to_power_of_2(f, p, n):
"""VDAF §6.1.3.2, as the draft writes it (in place)."""
x = nth_root_powers(f, n)
w = [0] * n
for i in range(len(p)):
acc = 1
for j in range(len(p)):
if i != j:
acc = acc * (x[i] - x[j]) % f.P
w[i] = acc
for k in range(len(p), n):
for i in range(k):
w[i] = w[i] * (x[i] - x[k]) % f.P
y_num, y_den = 0, 1
for i, v in enumerate(p):
y_num = (y_num * w[i] + y_den * v) % f.P
y_den = y_den * w[i] % f.P
acc = 1
for j in range(k):
acc = acc * (x[k] - x[j]) % f.P
w[k] = acc
p.append((-w[k] * y_num * inv(f, y_den)) % f.P)
def wire_poly_len(calls):
return next_power_of_2(1 + calls)
def gadget_poly_len(degree, wire_len):
return degree * (wire_len - 1) + 1
# Gadgets (Appendix A).
class Mul:
ARITY, DEGREE = 2, 2
def eval(self, f, inp):
return inp[0] * inp[1] % f.P
def eval_poly(self, f, inp_poly):
return poly_mul(f, inp_poly[0], inp_poly[1])
class PolyEval:
ARITY = 1
def __init__(self, p, num_calls):
while len(p) > 1 and p[-1] == 0:
p = p[:-1]
self.p = p
self.DEGREE = len(p) - 1
self.n = next_power_of_2(gadget_poly_len(self.DEGREE, wire_poly_len(num_calls)))
def eval(self, f, inp):
return poly_eval_mono(f, [c % f.P for c in self.p], inp[0])
def eval_poly(self, f, inp_poly):
m = inv_ntt(f, inp_poly[0], len(inp_poly[0]))
c = [x % f.P for x in self.p]
return [poly_eval_mono(f, c, x) for x in ntt(f, m, self.n)]
class ParallelSum:
def __init__(self, sub, count):
self.sub, self.count = sub, count
self.ARITY, self.DEGREE = sub.ARITY * count, sub.DEGREE
def eval(self, f, inp):
a = self.sub.ARITY
return sum(self.sub.eval(f, inp[i * a:(i + 1) * a]) for i in range(self.count)) % f.P
def eval_poly(self, f, inp_poly):
out_len = next_power_of_2(gadget_poly_len(self.DEGREE, len(inp_poly[0])))
out = [0] * out_len
a = self.sub.ARITY
for i in range(self.count):
cur = self.sub.eval_poly(f, inp_poly[i * a:(i + 1) * a])
for j in range(out_len):
out[j] = (out[j] + cur[j]) % f.P
return out
# Validity circuits (§7.4). Each has one gadget; `g` is the gadget call.
def range_encode(f, value, maxm):
if value > maxm:
raise ValueError("measurement is too large")
bits = maxm.bit_length()
rest_ones = 2 ** (bits - 1) - 1
last_weight = maxm - rest_ones
rest, last = (value, 0) if value <= rest_ones else (value - last_weight, 1)
return [(rest >> l) & 1 for l in range(bits - 1)] + [last]
def range_decode(f, enc, maxm):
bits = maxm.bit_length()
last_weight = maxm - (2 ** (bits - 1) - 1)
d = sum((1 << l) * b for l, b in enumerate(enc[:bits - 1]))
return (d + last_weight * enc[bits - 1]) % f.P
class Count:
FIELD, ID = Field64, 1
def __init__(self):
self.GADGET, self.CALLS = Mul(), 1
self.MEAS_LEN, self.JOINT_RAND_LEN, self.OUTPUT_LEN, self.EVAL_OUTPUT_LEN = 1, 0, 1, 1
def encode(self, m):
if m not in (0, 1):
raise ValueError("count")
return [m]
def eval(self, meas, jr, num_shares, g):
return [(g([meas[0], meas[0]]) - meas[0]) % self.FIELD.P]
def truncate(self, meas):
return list(meas)
class Sum:
FIELD, ID = Field64, 2
def __init__(self, maxm):
self.max = maxm
bits = maxm.bit_length()
self.GADGET, self.CALLS = PolyEval([0, -1, 1], bits), bits
self.MEAS_LEN, self.JOINT_RAND_LEN, self.OUTPUT_LEN, self.EVAL_OUTPUT_LEN = bits, 0, 1, bits
def encode(self, m):
return range_encode(self.FIELD, m, self.max)
def eval(self, meas, jr, num_shares, g):
return [g([b]) for b in meas]
def truncate(self, meas):
return [range_decode(self.FIELD, meas, self.max)]
def chunked_range_check(f, meas, jr, num_shares, calls, chunk, g):
shares_inv = inv(f, num_shares)
out = 0
for i in range(calls):
r = jr[i]
rp = r
inputs = []
for j in range(chunk):
idx = i * chunk + j
m = meas[idx] if idx < len(meas) else 0
inputs += [rp * m % f.P, (m - shares_inv) % f.P]
rp = rp * r % f.P
out = (out + g(inputs)) % f.P
return out
class SumVec:
FIELD, ID = Field128, 3
def __init__(self, length, maxm, chunk):
self.length, self.max, self.chunk = length, maxm, chunk
self.bits = maxm.bit_length()
self.CALLS = (length * self.bits + chunk - 1) // chunk
self.GADGET = ParallelSum(Mul(), chunk)
self.MEAS_LEN, self.JOINT_RAND_LEN, self.OUTPUT_LEN, self.EVAL_OUTPUT_LEN = length * self.bits, self.CALLS, length, 1
def encode(self, m):
if len(m) != self.length:
raise ValueError("length")
return [b for v in m for b in range_encode(self.FIELD, v, self.max)]
def eval(self, meas, jr, num_shares, g):
return [chunked_range_check(self.FIELD, meas, jr, num_shares, self.CALLS, self.chunk, g)]
def truncate(self, meas):
return [range_decode(self.FIELD, meas[i * self.bits:(i + 1) * self.bits], self.max) for i in range(self.length)]
class Histogram:
FIELD, ID = Field128, 4
def __init__(self, length, chunk):
self.length, self.chunk = length, chunk
self.CALLS = (length + chunk - 1) // chunk
self.GADGET = ParallelSum(Mul(), chunk)
self.MEAS_LEN, self.JOINT_RAND_LEN, self.OUTPUT_LEN, self.EVAL_OUTPUT_LEN = length, self.CALLS, length, 2
def encode(self, m):
if not 0 <= m < self.length:
raise ValueError("bucket")
v = [0] * self.length
v[m] = 1
return v
def eval(self, meas, jr, num_shares, g):
f = self.FIELD
rc = chunked_range_check(f, meas, jr, num_shares, self.CALLS, self.chunk, g)
sc = (sum(meas) - inv(f, num_shares)) % f.P
return [rc, sc]
def truncate(self, meas):
return list(meas)
# The FLP (§7.3).
def flp_lens(v):
g = v.GADGET
p = wire_poly_len(v.CALLS)
return {
"prove_rand": g.ARITY,
"query_rand": 1 + (v.EVAL_OUTPUT_LEN if v.EVAL_OUTPUT_LEN > 1 else 0),
"proof": g.ARITY + gadget_poly_len(g.DEGREE, p),
"verifier": 1 + g.ARITY + 1,
}
def prove(v, meas, prove_rand, joint_rand):
f, g = v.FIELD, v.GADGET
p = wire_poly_len(v.CALLS)
wires = [[prove_rand[j]] + [0] * (p - 1) for j in range(g.ARITY)]
k = [0]
def call(inp):
k[0] += 1
for j, x in enumerate(inp):
wires[j][k[0]] = x
return g.eval(f, inp)
v.eval(meas, joint_rand, 1, call)
return prove_rand[:g.ARITY] + g.eval_poly(f, wires)[:gadget_poly_len(g.DEGREE, p)]
def query(v, meas, proof, query_rand, joint_rand, num_shares):
f, g = v.FIELD, v.GADGET
p = wire_poly_len(v.CALLS)
gp_len = gadget_poly_len(g.DEGREE, p)
seeds, poly = proof[:g.ARITY], list(proof[g.ARITY:g.ARITY + gp_len])
extend_values_to_power_of_2(f, poly, next_power_of_2(len(poly)))
size = next_power_of_2(gp_len)
while len(poly) < size:
poly = double_evaluations(f, poly)
step = size // p
wires = [[s] + [0] * (p - 1) for s in seeds]
k = [0]
def call(inp):
k[0] += 1
for j, x in enumerate(inp):
wires[j][k[0]] = x
return poly[k[0] * step]
out = v.eval(meas, joint_rand, num_shares, call)
if v.EVAL_OUTPUT_LEN > 1:
r = query_rand[:v.EVAL_OUTPUT_LEN]
val = sum(a * b for a, b in zip(r, out)) % f.P
t = query_rand[v.EVAL_OUTPUT_LEN]
else:
val, t = out[0], query_rand[0]
if pow(t, p, f.P) == 1:
raise ValueError("test point is a root of unity")
return [val] + poly_eval_batched(f, wires, t) + poly_eval_batched(f, [poly], t)
def decide(v, verifier):
if verifier[0] != 0:
return False
a = v.GADGET.ARITY
return v.GADGET.eval(v.FIELD, verifier[1:1 + a]) == verifier[1 + a]
# Prio3 (§7.2), one proof, any number of shares.
class Prio3:
def __init__(self, valid, shares=2):
self.v, self.shares, self.f = valid, shares, valid.FIELD
self.L = flp_lens(valid)
self.jr = valid.JOINT_RAND_LEN > 0
self.rand_size = 32 * shares * (2 if self.jr else 1)
def dst(self, usage, ctx):
return format_dst(0, self.v.ID, usage) + ctx
def helper_meas_share(self, ctx, agg_id, share):
return Xof.expand_into_vec(self.f, share, self.dst(1, ctx), bytes([agg_id]), self.v.MEAS_LEN)
def helper_proofs_share(self, ctx, agg_id, share):
return Xof.expand_into_vec(self.f, share, self.dst(2, ctx), bytes([1, agg_id]), self.L["proof"])
def joint_rand_part(self, ctx, agg_id, blind, meas_share, nonce):
return Xof.derive_seed(blind, self.dst(7, ctx), bytes([agg_id]) + nonce + encode_vec(self.f, meas_share))
def joint_rand_seed(self, ctx, parts):
return Xof.derive_seed(bytes(32), self.dst(6, ctx), b"".join(parts))
def joint_rands(self, ctx, seed):
return Xof.expand_into_vec(self.f, seed, self.dst(3, ctx), bytes([1]), self.v.JOINT_RAND_LEN)
def shard(self, ctx, meas, nonce, rand):
"""shard (§7.2.1) of an encoded measurement: (public share, input shares), encoded."""
seeds = [rand[i:i + 32] for i in range(0, len(rand), 32)]
n = self.shares
if self.jr:
hs, hb = [seeds[2 * i] for i in range(n - 1)], [seeds[2 * i + 1] for i in range(n - 1)]
lb, ps = seeds[2 * (n - 1)], seeds[2 * (n - 1) + 1]
else:
hs, hb, lb, ps = seeds[:n - 1], [None] * (n - 1), None, seeds[n - 1]
lm, parts = list(meas), []
for j in range(n - 1):
hm = self.helper_meas_share(ctx, j + 1, hs[j])
lm = vsub(self.f, lm, hm)
if self.jr:
parts.append(self.joint_rand_part(ctx, j + 1, hb[j], hm, nonce))
jrs = []
if self.jr:
parts.insert(0, self.joint_rand_part(ctx, 0, lb, lm, nonce))
jrs = self.joint_rands(ctx, self.joint_rand_seed(ctx, parts))
pr = Xof.expand_into_vec(self.f, ps, self.dst(4, ctx), bytes([1]), self.L["prove_rand"])
proof = prove(self.v, meas, pr, jrs)
for j in range(n - 1):
proof = vsub(self.f, proof, self.helper_proofs_share(ctx, j + 1, hs[j]))
shares = [encode_vec(self.f, lm) + encode_vec(self.f, proof) + (lb if self.jr else b"")]
shares += [hs[j] + (hb[j] if self.jr else b"") for j in range(n - 1)]
return (b"".join(parts) if self.jr else b""), shares
def decode_input_share(self, agg_id, b):
bl = 32 if self.jr else 0
if agg_id == 0:
ml, pl = self.v.MEAS_LEN * self.f.SIZE, self.L["proof"] * self.f.SIZE
if len(b) != ml + pl + bl:
raise ValueError("input share")
return decode_vec(self.f, b[:ml]), decode_vec(self.f, b[ml:ml + pl]), b[ml + pl:] or None
if len(b) != 32 + bl:
raise ValueError("input share")
return b[:32], b[32:] or None
def meas_share(self, ctx, agg_id, input_share):
s = self.decode_input_share(agg_id, input_share)
return s[0] if agg_id == 0 else self.helper_meas_share(ctx, agg_id, s[0])
def verify_init(self, vk, ctx, agg_id, nonce, public, input_share):
"""verify_init (§7.2.2): (state, encoded verifier share)."""
s = self.decode_input_share(agg_id, input_share)
if agg_id == 0:
meas, proofs, blind = s
else:
meas, proofs, blind = self.helper_meas_share(ctx, agg_id, s[0]), self.helper_proofs_share(ctx, agg_id, s[0]), s[1]
out = self.v.truncate(meas)
jrs, corrected, part = [], None, None
if self.jr:
if len(public) != 32 * self.shares:
raise ValueError("public share")
parts = [public[i:i + 32] for i in range(0, len(public), 32)]
part = self.joint_rand_part(ctx, agg_id, blind, meas, nonce)
parts[agg_id] = part
corrected = self.joint_rand_seed(ctx, parts)
jrs = self.joint_rands(ctx, corrected)
elif public:
raise ValueError("public share")
qr = Xof.expand_into_vec(self.f, vk, self.dst(5, ctx), bytes([1]) + nonce, self.L["query_rand"])
ver = query(self.v, meas, proofs, qr, jrs, self.shares)
return (out, corrected), encode_vec(self.f, ver) + (part if self.jr else b"")
def message(self, ctx, shares):
"""verifier_shares_to_message (§7.2.2): the encoded verifier message."""
vl = self.L["verifier"] * self.f.SIZE
total = [0] * self.L["verifier"]
parts = []
for s in shares:
if len(s) != vl + (32 if self.jr else 0):
raise ValueError("verifier share")
total = vadd(self.f, total, decode_vec(self.f, s[:vl]))
parts.append(s[vl:])
if "proof" not in SKIP and not decide(self.v, total):
raise ValueError("proof verifier check failed")
return self.joint_rand_seed(ctx, parts) if self.jr else b""
def verify_next(self, state, msg):
out, corrected = state
if (msg if self.jr else None) != corrected or (not self.jr and msg):
raise ValueError("joint randomness check failed")
return encode_vec(self.f, out)
def agg(self, shares):
acc = [0] * self.v.OUTPUT_LEN
for s in shares:
v = decode_vec(self.f, s)
if len(v) != self.v.OUTPUT_LEN:
raise ValueError("aggregate share")
acc = vadd(self.f, acc, v)
return acc
def prio3_of(name, doc):
s = doc["shares"]
if name.startswith("Prio3Count_"):
return Prio3(Count(), s)
if name.startswith("Prio3SumVec_"):
return Prio3(SumVec(doc["length"], doc["max_measurement"], doc["chunk_length"]), s)
if name.startswith("Prio3Sum_"):
return Prio3(Sum(doc["max_measurement"]), s)
if name.startswith("Prio3Histogram_"):
return Prio3(Histogram(doc["length"], doc["chunk_length"]), s)
return None
def run_vdaf_file(name, doc):
"""One of the draft's vector files (Appendix C.1): the failures."""
fails = []
p = prio3_of(name, doc)
if p is None:
return ["%s: not a variant this verifier implements" % name]
ctx, vk = bytes.fromhex(doc["ctx"]), bytes.fromhex(doc["verify_key"])
reps = doc["reports"]
states = {}
for op in doc["operations"]:
kind, ok = op["operation"], op["success"]
i, j = op.get("report_index"), op.get("aggregator_id")
try:
if kind == "shard":
r = reps[i]
ps, iss = p.shard(ctx, p.v.encode(r["measurement"]), bytes.fromhex(r["nonce"]), bytes.fromhex(r["rand"]))
got = ps.hex() == r["public_share"] and [x.hex() for x in iss] == r["input_shares"]
elif kind == "verify_init":
r = reps[i]
st, vs = p.verify_init(vk, ctx, j, bytes.fromhex(r["nonce"]), bytes.fromhex(r["public_share"]), bytes.fromhex(r["input_shares"][j]))
states[(i, j)] = st
got = vs.hex() == r["verifier_shares"][0][j]
elif kind == "verifier_shares_to_message":
r = reps[i]
got = p.message(ctx, [bytes.fromhex(x) for x in r["verifier_shares"][0]]).hex() == r["verifier_messages"][0]
elif kind == "verify_next":
r = reps[i]
got = p.verify_next(states[(i, j)], bytes.fromhex(r["verifier_messages"][0])).hex() == r["out_shares"][j]
elif kind == "aggregate":
got = encode_vec(p.f, p.agg([bytes.fromhex(r["out_shares"][j]) for r in reps])).hex() == doc["agg_shares"][j]
elif kind == "unshard":
total = p.agg([bytes.fromhex(x) for x in doc["agg_shares"]])
res = total[0] if isinstance(doc["agg_result"], int) else total
got = res == doc["agg_result"]
else:
got = False
if not (ok and got):
fails.append("%s: %s %s %s gives other bytes" % (name, kind, i, j))
except (ValueError, KeyError, IndexError):
if ok:
fails.append("%s: %s %s %s refused" % (name, kind, i, j))
return fails
def run_xof(doc):
seed, dst, binder = (bytes.fromhex(doc[k]) for k in ("seed", "dst", "binder"))
fails = []
if Xof.derive_seed(seed, dst, binder).hex() != doc["derived_seed"]:
fails.append("XofTurboShake128: derived seed")
if encode_vec(Field128, Xof.expand_into_vec(Field128, seed, dst, binder, doc["length"])).hex() != doc["expanded_vec_field128"]:
fails.append("XofTurboShake128: expansion")
return fails
# ---------------------------------------------------------------------------------------
# X25519 (RFC 7748 §5), HKDF-SHA256 (RFC 5869), AES-128 (FIPS 197), GCM (SP 800-38D), HPKE
# base mode (RFC 9180).
P25519 = 2 ** 255 - 19
def x25519(k, u):
k = bytearray(k)
k[0] &= 248
k[31] &= 127
k[31] |= 64
k = int.from_bytes(k, "little")
x1 = int.from_bytes(u, "little") & ((1 << 255) - 1)
x2, z2, x3, z3, swap = 1, 0, x1, 1, 0
for t in reversed(range(255)):
kt = (k >> t) & 1
swap ^= kt
if swap:
x2, x3, z2, z3 = x3, x2, z3, z2
swap = kt
a, b, c, d = x2 + z2, x2 - z2, x3 + z3, x3 - z3
aa, bb = a * a % P25519, b * b % P25519
e = (aa - bb) % P25519
da, cb = d * a % P25519, c * b % P25519
x3 = (da + cb) ** 2 % P25519
z3 = x1 * (da - cb) ** 2 % P25519
x2 = aa * bb % P25519
z2 = e * (aa + 121665 * e) % P25519
if swap:
x2, z2 = x3, z3
return (x2 * pow(z2, P25519 - 2, P25519) % P25519).to_bytes(32, "little")
BASE = (9).to_bytes(32, "little")
def hkdf_extract(salt, ikm):
return hmac.new(salt, ikm, hashlib.sha256).digest()
def hkdf_expand(prk, info, n):
out, t, i = b"", b"", 1
while len(out) < n:
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
out += t
i += 1
return out[:n]
def _xt(a):
return ((a << 1) ^ 0x1B) & 0xFF if a & 0x80 else a << 1
def _mul(a, b):
r = 0
while b:
if b & 1:
r ^= a
a, b = _xt(a), b >> 1
return r
def _sbox():
inv_ = [0] * 256
for a in range(1, 256):
for b in range(1, 256):
if _mul(a, b) == 1:
inv_[a] = b
break
s = []
for a in range(256):
x = inv_[a]
y = x
for _ in range(4):
x = ((x << 1) | (x >> 7)) & 0xFF
y ^= x
s.append(y ^ 0x63)
return s
SBOX = _sbox()
def aes_keys(key):
w = [list(key[4 * i:4 * i + 4]) for i in range(4)]
rcon = 1
for i in range(4, 44):
t = list(w[i - 1])
if i % 4 == 0:
t = [SBOX[x] for x in t[1:] + t[:1]]
t[0] ^= rcon
rcon = _xt(rcon)
w.append([a ^ b for a, b in zip(w[i - 4], t)])
return [sum(w[4 * r:4 * r + 4], []) for r in range(11)]
def aes_block(rk, blk):
s = [a ^ b for a, b in zip(blk, rk[0])]
for r in range(1, 11):
s = [SBOX[x] for x in s]
s = [s[(i + 4 * (i % 4)) % 16] for i in range(16)] # ShiftRows, column-major state
if r < 10:
m = []
for c in range(4):
a = s[4 * c:4 * c + 4]
m += [_mul(a[0], 2) ^ _mul(a[1], 3) ^ a[2] ^ a[3], a[0] ^ _mul(a[1], 2) ^ _mul(a[2], 3) ^ a[3],
a[0] ^ a[1] ^ _mul(a[2], 2) ^ _mul(a[3], 3), _mul(a[0], 3) ^ a[1] ^ a[2] ^ _mul(a[3], 2)]
s = m
s = [a ^ b for a, b in zip(s, rk[r])]
return bytes(s)
R_GCM = 0xE1 << 120
def gf_mul(x, y):
z, v = 0, y
for i in range(127, -1, -1):
if (x >> i) & 1:
z ^= v
v = (v >> 1) ^ R_GCM if v & 1 else v >> 1
return z
def ghash(h, data):
y = 0
for i in range(0, len(data), 16):
y = gf_mul(y ^ int.from_bytes(data[i:i + 16], "big"), h)
return y
def _pad16(b):
return b + bytes((-len(b)) % 16)
def gcm_open(key, nonce, aad, ct):
if len(ct) < 16:
raise ValueError("short")
rk = aes_keys(key)
h = int.from_bytes(aes_block(rk, bytes(16)), "big")
j0 = nonce + b"\x00\x00\x00\x01"
c, tag = ct[:-16], ct[-16:]
s = ghash(h, _pad16(aad) + _pad16(c) + (8 * len(aad)).to_bytes(8, "big") + (8 * len(c)).to_bytes(8, "big"))
want = (int.from_bytes(aes_block(rk, j0), "big") ^ s).to_bytes(16, "big")
if not hmac.compare_digest(want, tag):
raise ValueError("tag")
out = bytearray()
ctr = int.from_bytes(j0[12:], "big")
for i in range(0, len(c), 16):
ctr = (ctr + 1) & 0xFFFFFFFF
ks = aes_block(rk, nonce + ctr.to_bytes(4, "big"))
out += bytes(a ^ b for a, b in zip(c[i:i + 16], ks))
return bytes(out)
KEM_SUITE = b"KEM" + (0x0020).to_bytes(2, "big")
HPKE_SUITE = b"HPKE" + (0x0020).to_bytes(2, "big") + (1).to_bytes(2, "big") + (1).to_bytes(2, "big")
def labeled_extract(suite, salt, label, ikm):
return hkdf_extract(salt, b"HPKE-v1" + suite + label + ikm)
def labeled_expand(suite, prk, label, info, n):
return hkdf_expand(prk, n.to_bytes(2, "big") + b"HPKE-v1" + suite + label + info, n)
def hpke_open(enc, sk, info, aad, ct):
dh = x25519(sk, enc)
if dh == bytes(32):
raise ValueError("zero")
ctx = enc + x25519(sk, BASE)
shared = labeled_expand(KEM_SUITE, labeled_extract(KEM_SUITE, b"", b"eae_prk", dh), b"shared_secret", ctx, 32)
ksc = b"\x00" + labeled_extract(HPKE_SUITE, b"", b"psk_id_hash", b"") + labeled_extract(HPKE_SUITE, b"", b"info_hash", info)
secret = labeled_extract(HPKE_SUITE, shared, b"secret", b"")
key = labeled_expand(HPKE_SUITE, secret, b"key", ksc, 16)
nonce = labeled_expand(HPKE_SUITE, secret, b"base_nonce", ksc, 12)
return gcm_open(key, nonce, aad, ct)
def crypto_self_test():
"""RFC 9861 §5's TurboSHAKE128 vectors, RFC 9180 A.1.1 (DeriveKeyPair, OpenBase), RFC
7748 §5.2's first X25519 vector: the checks that fail."""
fails = []
if turboshake128(b"", 0x1F, 32).hex() != "1e415f1c5983aff2169217277d17bb538cd945a397ddec541f1ce41af2c1b74c":
fails.append("TurboSHAKE128 empty")
if turboshake128(bytes(i % 251 for i in range(17 ** 3)), 0x1F, 32).hex() != "d4976eb56bcf118520582b709f73e1d6853e001fdaf80e1b13e0d0599d5fb372":
fails.append("TurboSHAKE128 ptn(17^3)")
if turboshake128(b"\xff" * 3, 0x01, 32).hex() != "bf323f940494e88ee1c540fe660be8a0c93f43d15ec006998462fa994eed5dab":
fails.append("TurboSHAKE128 D=01")
k = bytes.fromhex("a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4")
u = bytes.fromhex("e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c")
if x25519(k, u).hex() != "c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552":
fails.append("X25519")
ikm_r = bytes.fromhex("6db9df30aa07dd42ee5e8181afdb977e538f5e1fec8a06223f33f7013e525037")
sk_r = labeled_expand(KEM_SUITE, labeled_extract(KEM_SUITE, b"", b"dkp_prk", ikm_r), b"sk", b"", 32)
if sk_r.hex() != "4612c550263fc8ad58375df3f557aac531d26850903e55a9f23f21d8534e8ac8":
fails.append("HPKE DeriveKeyPair")
try:
pt = hpke_open(bytes.fromhex("37fda3567bdbd628e88668c3c8d7e97d1d1253b6d4ea6d44c150f741f1bf4431"), sk_r,
bytes.fromhex("4f6465206f6e2061204772656369616e2055726e"), bytes.fromhex("436f756e742d30"),
bytes.fromhex("f938558b5d72f1a23810b4be2ab4f84331acc02fc97babc53a52ae8218a355a96d8770ac83d07bea87e13c512a"))
if pt.hex() != "4265617574792069732074727574682c20747275746820626561757479":
fails.append("HPKE OpenBase plaintext")
except ValueError:
fails.append("HPKE OpenBase")
return fails
# ---------------------------------------------------------------------------------------
# DAP's messages (draft-ietf-ppm-dap-20 §4), read strictly.
class Rd:
def __init__(self, b):
self.b, self.i = b, 0
def take(self, n):
if self.i + n > len(self.b):
raise ValueError("truncated")
x = self.b[self.i:self.i + n]
self.i += n
return x
def u(self, n):
return int.from_bytes(self.take(n), "big")
def opaque(self, n):
return self.take(self.u(n))
def done(self):
return self.i == len(self.b)
def end(self):
if not self.done():
raise ValueError("trailing")
def read_exts(r):
body = Rd(r.opaque(2))
out = []
while not body.done():
out.append((body.u(2), body.opaque(2)))
return out
def read_task(r):
t = {"task_info": r.opaque(1), "leader": r.opaque(2), "helper": r.opaque(2), "time_precision": r.u(8),
"min_batch_size": r.u(8), "batch_mode": r.u(1), "batch_config": r.opaque(2), "vdaf_type": r.u(4),
"vdaf_config": r.opaque(2), "extensions": read_exts(r)}
if not t["leader"] or not t["helper"]:
raise ValueError("empty URL")
return t
def read_ct(r):
c = {"config_id": r.u(1), "enc": r.opaque(2), "payload": r.opaque(4)}
if not c["enc"] or not c["payload"]:
raise ValueError("empty")
return c
def write_ct(c):
return bytes([c["config_id"]]) + len(c["enc"]).to_bytes(2, "big") + c["enc"] + len(c["payload"]).to_bytes(4, "big") + c["payload"]
def read_metadata(r):
start = r.i
m = {"id": r.take(16), "time": r.u(8), "exts": read_exts(r)}
m["raw"] = r.b[start:r.i]
return m
def read_report(r):
m = read_metadata(r)
return {"m": m, "public": r.opaque(4), "leader": read_ct(r), "helper": read_ct(r)}
def read_collection(b):
r = Rd(b)
c = {"batch_mode": r.u(1), "config": r.opaque(2), "agg_param": r.opaque(4), "extensions": read_exts(r)}
r.end()
return c
def interval_of(b):
r = Rd(b)
i = (r.u(8), r.u(8))
r.end()
return i
def within(a, b):
return a[0] >= b[0] and a[0] + a[1] <= b[0] + b[1]
def u64(x):
return x.to_bytes(8, "big")
# ---------------------------------------------------------------------------------------
# The extension: measures (§3), tasks (§4), statements (§6), receipts (§7), verification (§8),
# cells (§9.2).
FLOOR = 50
# Measure 5's configuration is impression-aggregates §5.3's, against a catalogue (§4.3 rule 3).
MEASURES = {1: "outcomes", 2: "stalls", 3: "durations", 4: "start_time", 5: "impressions"}
START_EDGES = [250, 500, 1000, 2000, 4000, 8000]
def measure_vdaf(code):
return {1: lambda: Prio3(SumVec(4, 1, 2)), 2: lambda: Prio3(Sum(65535)),
3: lambda: Prio3(SumVec(3, 4294967295, 10)), 4: lambda: Prio3(Histogram(8, 3))}[code]()
def dap_config(p):
v = p.v
if isinstance(v, Count):
return b""
if isinstance(v, Sum):
return u64(v.max)
if isinstance(v, SumVec):
return v.length.to_bytes(4, "big") + u64(v.max) + v.chunk.to_bytes(4, "big")
return v.length.to_bytes(4, "big") + v.chunk.to_bytes(4, "big")
def measurement(code, m):
"""§3.1: a session's measurement, before the VDAF encodes it."""
if code == 1:
return [int(m["started"]), int(m["start_failed"]), int(m["exit_before_start"]), int(m["vst_ms"] is not None)]
if code == 2:
return [min(m["stalls"], 65535)]
if code == 3:
return [min(x, 4294967295) for x in (m["stall_ms"], m["engaged_ms"], m["vst_ms"] or 0)]
v = m["vst_ms"]
if v is None:
return [7]
return [next((i for i, e in enumerate(START_EDGES) if v < e), 6)]
def vdaf_encode(code, p, meas):
return p.v.encode(meas if code in (1, 3) else meas[0])
def task_info(b):
if len(b) != 153 or b[:8] != b"wai-qoe1":
return None
return {"measure": b[8], "scope": b[9:41], "salt": b[41:57], "leader": b[57:89], "helper": b[89:121], "collector": b[121:153]}
def task_id(cfg):
return sha256(b"wai:telemetry-aggregate/task\x00" + cfg)
def task_fields(cfg_bytes):
"""§4.3 rule 1: the task's fields and its info."""
try:
r = Rd(cfg_bytes)
t = read_task(r)
r.end()
except ValueError:
raise Refuse("task_invalid", "task")
info = task_info(t["task_info"])
if "task-rules" not in SKIP:
if info is None or t["time_precision"] == 0 or t["batch_mode"] != 1 or t["batch_config"]:
raise Refuse("task_invalid", "task")
types = [e[0] for e in t["extensions"]]
if any(e[0] != 1 or len(e[1]) != 16 for e in t["extensions"]) or types != sorted(set(types)):
raise Refuse("task_invalid", "task")
return t, info
def check_task(cfg_bytes, measure5=None):
"""§4.3, in order: the task's fields, its info and its Prio3. For measure 5, rule 3 is
impression-aggregates §5.3 steps 2 to 4, given as `measure5(t, info)`, which returns the
task's Prio3 or refuses; given none, the task is refused `catalogue_mismatch`."""
t, info = task_fields(cfg_bytes)
if info is None or info["measure"] not in MEASURES:
raise Refuse("measure_unknown", "task")
if info["measure"] == 5:
if measure5 is None:
raise Refuse("catalogue_mismatch", "task")
p = measure5(t, info)
else:
p = measure_vdaf(info["measure"])
if "measure" not in SKIP and (t["vdaf_type"] != p.v.ID or t["vdaf_config"] != dap_config(p)):
raise Refuse("measure_mismatch", "task")
if "floor-rule" not in SKIP and t["min_batch_size"] < FLOOR:
raise Refuse("floor_too_low", "task")
if "one-key" not in SKIP and info["leader"] == info["helper"]:
raise Refuse("aggregator_alone", "task")
return t, info, p, task_id(cfg_bytes)
def read_statement(b):
try:
r = Rd(b)
if r.take(4) != b"WQS1" or r.u(1) != 1:
raise ValueError("magic")
s = {"role": r.u(1), "task_id": r.take(32), "collection": r.take(32), "batch": (r.u(8), r.u(8)),
"count": r.u(8), "checksum": r.take(32), "agg_share": r.opaque(4), "signer": r.take(32)}
s["body"] = b[:r.i]
s["sig"] = r.take(64)
r.end()
except ValueError:
raise Refuse("statement_invalid")
if s["role"] not in (2, 3):
raise Refuse("statement_invalid")
return s
def read_receipt(b):
r = Rd(b)
try:
if r.take(4) != b"WQA1":
raise Refuse("malformed", "receipt")
if r.u(1) != 1:
raise Refuse("unknown_version", "receipt")
rc = {"task": r.opaque(2), "collection": r.opaque(4), "interval": (r.u(8), r.u(8)),
"leader": r.opaque(4), "helper": r.opaque(4)}
rc["result"] = [int.from_bytes(r.take(16), "big") for _ in range(r.u(2))]
rc["signer"] = r.take(32)
rc["body"] = b[:r.i]
rc["sig"] = r.take(64)
except ValueError:
raise Refuse("truncated", "receipt")
if not r.done():
raise Refuse("malformed", "receipt")
return rc
def sig_check(key, domain, body, sig, at):
e = ED.verify(key, domain + body, sig)
if e is not None:
raise Refuse(e, at)
def verify_receipt(b, reports, task_check=check_task):
"""§8.1, in order; step 2 is `task_check` (impression-aggregates §8.1 gives its own)."""
rc = read_receipt(b)
t, info, p, tid = task_check(rc["task"])
if "collector-signer" not in SKIP and rc["signer"] != info["collector"]:
raise Refuse("wrong_signer", "receipt")
if "collector-sig" not in SKIP:
sig_check(rc["signer"], b"wai:telemetry-aggregate/receipt\x01", rc["body"], rc["sig"], "receipt")
try:
c = read_collection(rc["collection"])
q = interval_of(c["config"]) if c["batch_mode"] == 1 else None
except ValueError:
raise Refuse("collection_invalid", "collection")
if "collection" not in SKIP and (q is None or q[1] == 0 or c["agg_param"] or c["extensions"]):
raise Refuse("collection_invalid", "collection")
if not rc["leader"] or not rc["helper"]:
raise Refuse("aggregator_alone", "statements")
sts = []
for raw, at in ((rc["leader"], "leader"), (rc["helper"], "helper")):
try:
sts.append(read_statement(raw))
except Refuse as e:
raise Refuse(e.reason, at)
l, h = sts
if "two-aggregators" not in SKIP and (l["signer"] == h["signer"] or l["role"] == h["role"]):
raise Refuse("aggregator_alone", "statements")
for s, role, key, at in ((l, 2, info["leader"], "leader"), (h, 3, info["helper"], "helper")):
if "statement-signer" not in SKIP and (s["role"] != role or s["signer"] != key):
raise Refuse("wrong_signer", at)
if "statement-sig" not in SKIP:
sig_check(s["signer"], b"wai:telemetry-aggregate/share\x01", s["body"], s["sig"], at)
for s, at in ((l, "leader"), (h, "helper")):
if "binding" not in SKIP and (s["task_id"] != tid or s["collection"] != sha256(rc["collection"])):
raise Refuse("task_mismatch", at)
for s, at in ((l, "leader"), (h, "helper")):
if "batch" not in SKIP and (s["batch"][1] == 0 or not within(s["batch"], q)):
raise Refuse("batch_invalid", at)
if "batch" not in SKIP and (rc["interval"][1] == 0 or not within(rc["interval"], l["batch"])):
raise Refuse("batch_invalid", "interval")
if "agreement" not in SKIP and (l["batch"], l["count"], l["checksum"]) != (h["batch"], h["count"], h["checksum"]):
raise Refuse("statements_disagree", "statements")
if "floor" not in SKIP and l["count"] < t["min_batch_size"]:
raise Refuse("batch_too_small", "statements")
shares = []
for s, at in ((l, "leader"), (h, "helper")):
try:
shares.append(p.agg([s["agg_share"]]))
except ValueError:
if "share" not in SKIP:
raise Refuse("share_invalid", at)
shares.append([0] * p.v.OUTPUT_LEN)
result = vadd(p.f, shares[0], shares[1])
if "result" not in SKIP and result != rc["result"]:
raise Refuse("result_mismatch", "result")
if reports is not None and "reports" not in SKIP:
ids, ck = set(), bytes(32)
for g in reports:
gid = bytes.fromhex(g["id"])
if bytes.fromhex(g["task"]) != tid or not rc["interval"][0] <= g["time"] < sum(rc["interval"]) or gid in ids:
raise Refuse("batch_mismatch", "reports")
ids.add(gid)
ck = bytes(a ^ b for a, b in zip(ck, sha256(gid)))
if len(reports) != l["count"] or ck != l["checksum"]:
raise Refuse("batch_mismatch", "reports")
return {
"outcome": "accept", "digest": sha256(rc["body"]).hex(), "task_id": tid.hex(), "measure": MEASURES[info["measure"]],
"vdaf": {1: "Prio3Count", 2: "Prio3Sum", 3: "Prio3SumVec", 4: "Prio3Histogram"}[p.v.ID], "vdaf_config": t["vdaf_config"].hex(),
"scope": info["scope"].hex(), "time_precision": t["time_precision"], "min_batch_size": t["min_batch_size"],
"batch": {"start": l["batch"][0], "duration": l["batch"][1]},
"interval": {"start": rc["interval"][0], "duration": rc["interval"][1]}, "report_count": l["count"],
"result": rc["result"], "leader": info["leader"].hex(), "helper": info["helper"].hex(), "collector": info["collector"].hex(),
}
def receipt_outcome(v):
try:
return verify_receipt(bytes.fromhex(v["receipt"]), v["reports"])
except Refuse as e:
return {"outcome": "refuse", "reason": e.reason, "at": e.at}
CELL_FIELDS = [("started", "outcomes", 0), ("start_failed", "outcomes", 1), ("exit_before_start", "outcomes", 2),
("stalls", "stalls", 0), ("stall_ms", "durations", 0), ("engaged_ms", "durations", 1),
("vst_count", "outcomes", 3), ("vst_ms", "durations", 2)]
def cell_outcome(doc, v):
acc = []
for n in v["receipts"]:
r = next((x for x in doc["receipts"] if x["name"] == n), None)
if r is None:
return {"outcome": "refuse", "reason": "cell_mismatch"}
o = receipt_outcome(r)
if o["outcome"] != "accept":
return {"outcome": "refuse", "reason": o["reason"]}
acc.append(o)
by = {}
for a in acc:
by.setdefault(a["measure"], []).append(a)
if "cell" not in SKIP:
if len(acc) != 3 or sorted(by) != ["durations", "outcomes", "stalls"] or any(len(x) != 1 for x in by.values()):
return {"outcome": "refuse", "reason": "cell_mismatch"}
if len({a["scope"] for a in acc}) != 1 or acc[0]["scope"] == "00" * 32 or len({json.dumps(a["batch"]) for a in acc}) != 1:
return {"outcome": "refuse", "reason": "cell_mismatch"}
def pick(m):
return (by.get(m) or acc)[0]
try:
return {"outcome": "accept", "scope": pick("outcomes")["scope"], "batch": pick("outcomes")["batch"],
"counts": [pick(m)["report_count"] for m in ("outcomes", "stalls", "durations")],
"cell": {f: pick(m)["result"][i] for f, m, i in CELL_FIELDS}}
except IndexError:
return {"outcome": "refuse", "reason": "cell_mismatch"}
def plaintext_cell(sessions):
"""live-metering §4.7 over the sessions' metrics."""
ms = [s["metrics"] for s in sessions]
return {"started": sum(m["started"] for m in ms), "start_failed": sum(m["start_failed"] for m in ms),
"exit_before_start": sum(m["exit_before_start"] for m in ms), "stalls": sum(m["stalls"] for m in ms),
"stall_ms": sum(m["stall_ms"] for m in ms), "engaged_ms": sum(m["engaged_ms"] for m in ms),
"vst_count": sum(m["vst_ms"] is not None for m in ms), "vst_ms": sum(m["vst_ms"] or 0 for m in ms)}
# ---------------------------------------------------------------------------------------
# A flow, re-run (§5, DAP §4.4 to §4.6).
def input_share_aad(tid, cfg, m, public):
return tid + cfg + m["raw"] + len(public).to_bytes(4, "big") + public
def agg_share_aad(tid, cfg, coll):
return tid + cfg + coll
def open_share(sk, role, aad, ct):
pt = hpke_open(ct["enc"], sk, b"dap-20 input share" + bytes([1, role]), aad, ct["payload"])
r = Rd(pt)
exts = read_exts(r)
payload = r.opaque(4)
r.end()
if exts or not payload:
raise ValueError("extensions")
return payload
def check_flow(doc, f, fails, quiet):
name = f["name"]
K = doc["keys"]
def bad(what):
fails.append("flow %s: %s" % (name, what))
cfg = bytes.fromhex(f["task"])
try:
t, info, p, tid = check_task(cfg)
except Refuse as e:
return bad("its task is refused %s" % e.reason)
code = info["measure"]
if tid.hex() != f["task_id"] or MEASURES[code] != name:
bad("task id or measure")
vk = hkdf_expand(hkdf_extract(b"verify_key", bytes.fromhex(K["verify_key_seed"])), tid, 32)
if vk.hex() != f["verify_key"]:
bad("verification key (DAP §8.6.2)")
ctx = b"dap-20" + tid
lsk, hsk, csk = (bytes.fromhex(K[k]["private_key"]) for k in ("leader_hpke", "helper_hpke", "collector_hpke"))
cfgs = {}
for k, sk in (("leader_hpke", lsk), ("helper_hpke", hsk), ("collector_hpke", csk)):
r = Rd(bytes.fromhex(K[k]["config"]))
c = {"id": r.u(1), "kem": r.u(2), "kdf": r.u(2), "aead": r.u(2), "pk": r.opaque(2)}
r.end()
if (c["kem"], c["kdf"], c["aead"]) != (0x20, 1, 1) or c["pk"] != x25519(sk, BASE):
bad("%s is not DAP §7's suite with its key" % k)
cfgs[k] = c
sessions = {s["session"]: s for s in doc["sessions"]}
up = Rd(bytes.fromhex(f["upload"]))
reports = []
try:
while not up.done():
reports.append(read_report(up))
except ValueError:
return bad("the upload does not read")
listed = f["reports"]
if [r["m"]["id"].hex() for r in reports] != [x["id"] for x in listed] or [r["m"]["time"] for r in reports] != [x["time"] for x in listed]:
return bad("the upload is not the listed reports")
seen, pending, upload_errors = set(), [], []
for r, x in zip(reports, listed):
if r["leader"]["config_id"] != cfgs["leader_hpke"]["id"]:
upload_errors.append({"id": x["id"], "error": "hpke_unknown_config_id"})
elif r["m"]["id"] in seen:
upload_errors.append({"id": x["id"], "error": "report_replayed"})
else:
seen.add(r["m"]["id"])
pending.append((r, x))
if upload_errors != f["upload_errors"]:
bad("the reports the leader refused at upload")
init = bytes([0]) + (0).to_bytes(4, "big") + (0).to_bytes(2, "big")
resp, outcomes, committed = b"", [], []
for r, x in pending:
m = r["m"]
laad = input_share_aad(tid, cfg, m, r["public"])
try:
lpay = open_share(lsk, 2, laad, r["leader"])
except ValueError:
outcomes.append({"id": x["id"], "outcome": "hpke_decrypt_error"})
continue
try:
lstate, lvs = p.verify_init(vk, ctx, 0, m["id"], r["public"], lpay)
except ValueError:
outcomes.append({"id": x["id"], "outcome": "vdaf_verify_error"})
continue
outbound = b"\x00" + len(lvs).to_bytes(4, "big") + lvs
init += m["raw"] + len(r["public"]).to_bytes(4, "big") + r["public"] + write_ct(r["helper"]) + len(outbound).to_bytes(4, "big") + outbound
try:
hpay = open_share(hsk, 3, laad, r["helper"])
except ValueError:
resp += m["id"] + b"\x02" + bytes([5])
outcomes.append({"id": x["id"], "outcome": "hpke_decrypt_error"})
if x["kind"] != "corrupt":
bad("report %s does not open" % x["id"])
continue
if True:
enc = vadd(p.f, p.meas_share(ctx, 0, lpay), p.meas_share(ctx, 1, hpay))
if x["kind"] in ("session",):
meas = measurement(code, sessions[x["session"]]["metrics"])
if meas != x["measurement"] or enc != vdaf_encode(code, p, meas):
bad("report %s is not its session's measurement (§3.1)" % x["id"])
elif x["kind"] == "out_of_range":
if enc != x["encoded"]:
bad("the out-of-range report's encoding")
try:
hstate, hvs = p.verify_init(vk, ctx, 1, m["id"], r["public"], hpay)
msg = p.message(ctx, [lvs, hvs])
hout = p.verify_next(hstate, msg)
except ValueError:
resp += m["id"] + b"\x02" + bytes([6])
outcomes.append({"id": x["id"], "outcome": "vdaf_verify_error"})
continue
fin = b"\x02" + len(msg).to_bytes(4, "big") + msg
resp += m["id"] + b"\x00" + len(fin).to_bytes(4, "big") + fin
lout = p.verify_next(lstate, msg)
committed.append((m["id"], m["time"], lout, hout))
outcomes.append({"id": x["id"], "outcome": "committed"})
if init.hex() != f["job"]["init_req"]:
bad("the AggregationJobInitReq is not DAP §4.5.3.1's")
if resp.hex() != f["job"]["resp"]:
bad("the AggregationJobResp is not DAP §4.5.3.2's")
if outcomes != f["job"]["outcomes"]:
bad("the reports' outcomes")
if "proof" not in SKIP:
for x in listed:
o = next((y["outcome"] for y in outcomes if y["id"] == x["id"]), None)
if x["kind"] == "out_of_range" and o != "vdaf_verify_error":
bad("the out-of-range report is not refused vdaf_verify_error")
buckets = {}
for rid, tm, lo, ho in committed:
b = buckets.setdefault(tm, {"l": [0] * p.v.OUTPUT_LEN, "h": [0] * p.v.OUTPUT_LEN, "n": 0, "ck": bytes(32), "ids": []})
b["l"], b["h"] = vadd(p.f, b["l"], decode_vec(p.f, lo)), vadd(p.f, b["h"], decode_vec(p.f, ho))
b["n"] += 1
b["ck"] = bytes(a ^ c for a, c in zip(b["ck"], sha256(rid)))
b["ids"].append((rid, tm))
def merged(iv):
out = {"l": [0] * p.v.OUTPUT_LEN, "h": [0] * p.v.OUTPUT_LEN, "n": 0, "ck": bytes(32), "times": [], "ids": []}
for tm in range(iv[0], iv[0] + iv[1]):
if tm in buckets:
b = buckets[tm]
out["l"], out["h"] = vadd(p.f, out["l"], b["l"]), 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
coll_raw = bytes.fromhex(f["collection"]["request"])
query = interval_of(read_collection(coll_raw)["config"])
for v in f["refusals"]:
collected = query if v["phase"] == "after" else None
req = bytes.fromhex(v["request"])
def overlaps(iv):
return collected is not None and iv[0] < collected[0] + collected[1] and collected[0] < iv[0] + iv[1]
try:
if v["actor"] == "leader": # DAP §4.6.1
c = read_collection(req)
q = interval_of(c["config"])
if c["agg_param"]:
got = "invalidAggregationParameter"
elif q[1] == 0:
got = "batchInvalid"
elif overlaps(q):
got = "batchOverlap"
elif merged(q)["n"] < t["min_batch_size"]:
got = "invalidBatchSize"
else:
got = "accept"
else: # DAP §4.6.4
r = Rd(req)
c = {"batch_mode": r.u(1), "config": r.opaque(2), "agg_param": r.opaque(4), "extensions": read_exts(r)}
mode, sel, n, ck = r.u(1), r.opaque(2), r.u(8), r.take(32)
r.end()
q, b = interval_of(c["config"]), interval_of(sel)
mg = merged(b)
if mode != 1 or c["batch_mode"] != 1:
got = "invalidMessage"
elif b[1] == 0 or not within(b, q):
got = "batchInvalid"
elif overlaps(b):
got = "batchOverlap"
elif mg["n"] < t["min_batch_size"]:
got = "invalidBatchSize"
elif c["agg_param"]:
got = "invalidMessage"
elif (n, ck) != (mg["n"], mg["ck"]):
got = "batchMismatch"
else:
got = "accept"
except ValueError:
got = "invalidMessage"
if got != v["outcome"]:
bad("refusal %s: stated %s, DAP gives %s" % (v["name"], v["outcome"], got))
# The collection (DAP §4.6).
mg = merged(query)
if mg["n"] < t["min_batch_size"] and "floor" not in SKIP:
bad("a batch below the minimum was collected")
col = f["collection"]
share_req = coll_raw + b"\x01" + (16).to_bytes(2, "big") + u64(query[0]) + u64(query[1]) + u64(mg["n"]) + mg["ck"]
if share_req.hex() != col["share_req"]:
bad("the AggregateShareReq is not DAP §4.6.4's")
aad = agg_share_aad(tid, cfg, coll_raw)
try:
hct = read_ct(Rd(bytes.fromhex(col["helper_share"])))
hshare = hpke_open(hct["enc"], csk, b"dap-20 aggregate share" + bytes([3, 0]), aad, hct["payload"])
r = Rd(bytes.fromhex(col["resp"]))
n, iv = r.u(8), (r.u(8), r.u(8))
lct, hct2 = read_ct(r), read_ct(r)
r.end()
lshare = 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 = read_ct(Rd(bytes.fromhex(col[k])))
sts.append(read_statement(hpke_open(sct["enc"], csk, b"wai-telemetry-aggregate statement" + bytes([role, 0]), aad, sct["payload"])))
except (ValueError, Refuse):
return bad("the collection does not open with the collector's key")
times = mg["times"]
if n != mg["n"] or iv != (min(times), max(times) - min(times) + 1) or hct2 != hct:
bad("the CollectionJobResp's count, interval or helper share")
if decode_vec(p.f, lshare) != mg["l"] or decode_vec(p.f, hshare) != mg["h"]:
bad("an aggregate share is not the batch's")
for s, role, share, key in ((sts[0], 2, lshare, info["leader"]), (sts[1], 3, hshare, info["helper"])):
if (s["role"], s["task_id"], s["collection"], s["batch"], s["count"], s["checksum"], s["agg_share"], s["signer"]) != \
(role, tid, sha256(coll_raw), query, mg["n"], mg["ck"], share, key):
bad("the %s statement is not its share's (§6.2)" % ("leader" if role == 2 else "helper"))
if ED.verify(key, b"wai:telemetry-aggregate/share\x01" + s["body"], s["sig"]) is not None:
bad("a statement's signature")
result = vadd(p.f, mg["l"], mg["h"])
if result != f["result"]:
bad("the result")
rc = read_receipt(bytes.fromhex(f["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, coll_raw, iv):
bad("the receipt is not the collection's")
given = [{"id": rid.hex(), "time": tm, "task": tid.hex()} for rid, tm in mg["ids"]]
named = next((x for x in doc["receipts"] if x["name"] == name), None)
if named is None or named["receipt"] != f["receipt"] or sorted(named["reports"] or [], key=json.dumps) != sorted(given, key=json.dumps):
bad("the receipts section does not hold the flow's receipt with its reports")
if not quiet:
print("PASS flow %s: %d reports re-run, %d committed, result recomputed" % (name, len(reports), len(committed)))
# ---------------------------------------------------------------------------------------
# delivery-telemetry §3.4 and §4.3.
def key_classes():
with open(TELEMETRY, encoding="utf-8") as fh:
text = fh.read()
at = text.index("\n§3.4")
end = text.index("\n## ", at)
out = {}
for line in text[at:end].splitlines():
if line.startswith("| `"):
cells = [c.strip() for c in line.split("|")]
for k in cells[1].split(", "):
out[k.strip("`")] = cells[2]
return out
CLASSES = key_classes()
def sf_dict_members(s):
"""Top-level members of an RFC 9651 Dictionary as (key, raw value), keeping strings whole."""
out, i = [], 0
while i < len(s):
j = i
while j < len(s) and s[j] not in "=,":
j += 1
key = s[i:j]
if j < len(s) and s[j] == "=":
k = j + 1
if s[k] == '"':
k += 1
while s[k] != '"':
k += 2 if s[k] == "\\" else 1
k += 1
elif s[k] == "(":
k = s.index(")", k) + 1
else:
while k < len(s) and s[k] != ",":
k += 1
out.append((key, s[j + 1:k]))
i = k
else:
out.append((key, "?1"))
i = j
if i < len(s):
if s[i] != ",":
raise ValueError("dictionary")
i += 1
return out
def shard_of_text():
with open(TELEMETRY, encoding="utf-8") as fh:
text = fh.read()
at = text.index("§4.2 In Request mode")
para = text[at:text.index("\n\n", at)]
m = {}
for group, shard in re.findall(r"((?:`[a-z]+`(?:, | and )?)+) to `(CMCD-[A-Za-z]+)`", para.replace("\n", " ")):
for k in re.findall(r"`([a-z]+)`", group):
m[k] = shard
t41 = text[text.index("§4.1 Custom keys"):text.index("§4.2 In Request mode")]
for line in t41.splitlines():
if line.startswith("| `science"):
cells = [c.strip() for c in line.split("|")]
keys = [cells[1].split("`")[1]]
rest = re.findall(r"`(-[a-z]+)`", cells[1])
base = keys[0].rsplit("-", 1)[0]
keys += [base + x for x in rest]
if cells[4].startswith("`CMCD"):
for k in keys:
m[k] = cells[4].strip("`")
return m
def delivery_only_outcome(v):
try:
mem = sf_dict_members(v["delivery_only"])
except (ValueError, IndexError):
return {"outcome": "refuse", "reason": "measurement_key_in_delivery", "key": "?"}
for k, _ in mem:
if CLASSES.get(k) != "delivery":
return {"outcome": "refuse", "reason": "measurement_key_in_delivery", "key": k}
return {"outcome": "accept"}
def check_delivery_only(v, fails):
got = delivery_only_outcome(v)
if got != v["outcome"]:
fails.append("delivery-only %s: stated %s, got %s" % (v["name"], v["outcome"], got))
if "report" in v:
full = sf_dict_members(v["report"])
want = ",".join("%s=%s" % kv for kv in full if CLASSES.get(kv[0]) == "delivery")
if want != v["delivery_only"]:
fails.append("delivery-only %s: not the report's delivery-class members" % v["name"])
shards = {}
sm = shard_of_text()
for k, x in sf_dict_members(want):
shards.setdefault(sm.get(k, "?"), []).append("%s=%s" % (k, x))
if {h: ",".join(ms) for h, ms in shards.items()} != v["shards"]:
fails.append("delivery-only %s: header shards" % v["name"])
# ---------------------------------------------------------------------------------------
def read_reasons():
with open(EXTENSION, encoding="utf-8") as fh:
text = fh.read()
at = text.index("\n## 11. Reasons")
end = text.index("\n## ", at + 1)
return [line.split("`")[1] for line in text[at:end].splitlines() if line.startswith("| `")]
REASONS = read_reasons()
REUSED = ["truncated", "malformed", "unknown_version", "bad_signature", "vdaf_verify_error", "hpke_decrypt_error",
"report_replayed", "invalidBatchSize", "batchMismatch", "batchOverlap", "batchInvalid"]
MEMO = {}
def memo(key, fn):
"""A part's failures, computed once per input: a mutated copy re-runs only what it changed."""
k = hashlib.sha256(json.dumps(key, sort_keys=True).encode()).hexdigest()
if k not in MEMO:
MEMO[k] = fn()
return list(MEMO[k])
def check(doc, vdaf_files, quiet=False):
fails = []
for n, d in sorted(vdaf_files.items()):
fails += memo(["vdaf", n, d, sorted(SKIP)], lambda: run_xof(d) if n == "XofTurboShake128.json" else run_vdaf_file(n[:-5], d))
if not quiet:
print(("PASS" if not fails else "FAIL") + " the draft's Prio3 and XofTurboShake128 vectors (%d files)" % len(vdaf_files))
for f in doc["flows"]:
named = [x for x in doc["receipts"] if x["name"] == f["name"]]
def run(f=f):
out = []
check_flow(doc, f, out, quiet)
return out
fails += run() if not quiet else memo(["flow", f, doc["keys"], doc["sessions"], named, sorted(SKIP & {"proof", "floor"})], run)
for v in doc["receipts"]:
got = receipt_outcome(v)
if got != v["outcome"]:
fails.append("receipt %s: stated %s, got %s" % (v["name"], v["outcome"], got))
for v in doc["cells"]:
got = cell_outcome(doc, v)
if got != v["outcome"]:
fails.append("cell %s: stated %s, got %s" % (v["name"], v["outcome"], got))
if got.get("outcome") == "accept" and (v.get("plaintext") != plaintext_cell(doc["sessions"]) or got["cell"] != v["plaintext"]):
fails.append("cell %s: not the plaintext cell of live-metering §4.7" % v["name"])
for v in doc["delivery_only"]:
check_delivery_only(v, fails)
if doc.get("codes") != REASONS:
fails.append("the corpus's codes are not §11's")
if not quiet:
print(("PASS" if not fails else "FAIL") + " %d receipts, %d cells, %d delivery-only reports" % (len(doc["receipts"]), len(doc["cells"]), len(doc["delivery_only"])))
return fails
def reasons_given(doc):
out = set()
for sec in ("receipts", "cells", "delivery_only"):
out |= {v["outcome"].get("reason") for v in doc[sec]}
for f in doc["flows"]:
out |= {o["outcome"] for o in f["job"]["outcomes"]} | {e["error"] for e in f["upload_errors"]} | {r["outcome"] for r in f["refusals"]}
return out
def flip_hex(h, i):
b = bytearray(bytes.fromhex(h))
b[i] ^= 0x01
return b.hex()
def mutations(doc, vdaf_files):
def named(d, sec, name):
return next(x for x in d[sec] if x["name"] == name)
def m(label, fn, vf=None):
d = copy.deepcopy(doc)
v = copy.deepcopy(vdaf_files)
(fn or (lambda _: None))(d)
if vf:
vf(v)
return label, d, v
yield m("a report's leader share", lambda d: d["flows"][0].update(upload=flip_hex(d["flows"][0]["upload"], 300)))
yield m("a VerifyInit's payload", lambda d: d["flows"][1]["job"].update(init_req=flip_hex(d["flows"][1]["job"]["init_req"], len(d["flows"][1]["job"]["init_req"]) // 2 - 1)))
yield m("a VerifyResp", lambda d: d["flows"][2]["job"].update(resp=flip_hex(d["flows"][2]["job"]["resp"], 40)))
yield m("a session's stalls", lambda d: d["sessions"][3]["metrics"].update(stalls=d["sessions"][3]["metrics"]["stalls"] + 1))
yield m("a session's start time", lambda d: d["sessions"][5]["metrics"].update(vst_ms=d["sessions"][5]["metrics"]["vst_ms"] + 4000))
yield m("the out-of-range report stated committed", lambda d: d["flows"][3]["job"]["outcomes"][50].update(outcome="committed"))
yield m("a listed measurement", lambda d: d["flows"][0]["reports"][7].update(measurement=[1, 0, 0, 0]))
yield m("a refusal's outcome", lambda d: d["flows"][0]["refusals"][0].update(outcome="batchMismatch"))
yield m("a refusal's batch interval", lambda d: d["flows"][0]["refusals"][0].update(request=flip_hex(d["flows"][0]["refusals"][0]["request"], 18)))
yield m("a flow's result", lambda d: d["flows"][2]["result"].__setitem__(1, d["flows"][2]["result"][1] + 1))
yield m("the helper's sealed statement", lambda d: d["flows"][1]["collection"].update(helper_statement=flip_hex(d["flows"][1]["collection"]["helper_statement"], 60)))
yield m("the collector's private key", lambda d: d["keys"]["collector_hpke"].update(private_key=flip_hex(d["keys"]["collector_hpke"]["private_key"], 5)))
yield m("the verification key seed", lambda d: d["keys"].update(verify_key_seed=flip_hex(d["keys"]["verify_key_seed"], 0)))
yield m("an accepted receipt's signature", lambda d: named(d, "receipts", "stalls").update(receipt=flip_hex(named(d, "receipts", "stalls")["receipt"], -3 + len(named(d, "receipts", "stalls")["receipt"]) // 2)))
yield m("an accepted receipt's stated count", lambda d: named(d, "receipts", "durations")["outcome"].update(report_count=51))
yield m("an accepted receipt's reports", lambda d: named(d, "receipts", "start_time")["reports"].pop())
yield m("a refused receipt stated accepted", lambda d: named(d, "receipts", "batch_below_the_minimum").update(outcome={"outcome": "accept"}))
yield m("a refusal's place", lambda d: named(d, "receipts", "helper_statement_by_another")["outcome"].update(at="leader"))
yield m("a cell's plaintext", lambda d: named(d, "cells", "room_lesson")["plaintext"].update(stalls=0))
yield m("a delivery-only report given a measurement key", lambda d: named(d, "delivery_only", "audio_request").update(
delivery_only=named(d, "delivery_only", "audio_request")["delivery_only"] + ",sn=4"))
yield m("a delivery-only refusal's key", lambda d: named(d, "delivery_only", "cohort_served_as_delivery")["outcome"].update(key="d"))
yield m("the codes reordered", lambda d: d["codes"].reverse())
yield m("a draft vector's result", None, lambda v: v["Prio3Sum_2.json"].update(agg_result=1522))
yield m("a draft vector's verifier share", None, lambda v: v["Prio3Histogram_0.json"]["reports"][0]["verifier_shares"][0].__setitem__(
0, flip_hex(v["Prio3Histogram_0.json"]["reports"][0]["verifier_shares"][0][0], 3)))
yield m("a draft vector's helper seed", None, lambda v: v["Prio3SumVec_1.json"]["reports"][0]["input_shares"].__setitem__(
1, flip_hex(v["Prio3SumVec_1.json"]["reports"][0]["input_shares"][1], 0)))
CHECKS = {
"task-rules": "§4.3 rule 1: the task's fields",
"measure": "§4.3 rule 3: the measure's VDAF",
"floor-rule": "§4.3 rule 4: a minimum batch size of at least fifty",
"one-key": "§4.3 rule 5: two aggregator keys",
"collector-signer": "§8.1 step 3: the collector's key",
"collector-sig": "§8.1 step 3: the receipt's signature",
"collection": "§8.1 step 4: the collection job",
"two-aggregators": "§8.1 step 5: two signers in two roles",
"statement-signer": "§8.1 step 5: each statement's key",
"statement-sig": "§8.1 step 5: each statement's signature",
"binding": "§8.1 step 6: the task and collection bound",
"batch": "§8.1 step 7: the batch within the query",
"agreement": "§8.1 step 8: the statements agree",
"floor": "§8.1 step 9: the batch at least the minimum",
"share": "§8.1 step 10: each aggregate share",
"result": "§8.1 step 10: the result unsharded",
"reports": "§8.1 step 11: the batch's reports",
"cell": "§9.2: one receipt of each measure, one scope, one batch",
"proof": "§3.3: an out-of-range report is refused",
}
def main(corpus_dir):
fails = []
st = ED._self_test()
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
fails += ["ed25519 self-test"] if st else []
ct = crypto_self_test()
print(("PASS" if not ct else "FAIL") + " TurboSHAKE128, X25519 and HPKE self-tests " + ",".join(ct))
fails += ct
print(("PASS" if len(REASONS) == 17 else "FAIL") + " §11 read from the extension: %d reasons" % len(REASONS))
fails += [] if len(REASONS) == 17 else ["§11 does not read"]
print(("PASS" if len(CLASSES) == 42 else "FAIL") + " delivery-telemetry §3.4 read: %d keys classed" % len(CLASSES))
fails += [] if len(CLASSES) == 42 else ["§3.4 does not read"]
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as fh:
doc = json.load(fh)
vdir = os.path.join(corpus_dir, "vdaf-22")
vdaf_files = {}
for n in sorted(os.listdir(vdir)):
with open(os.path.join(vdir, n), encoding="utf-8") as fh:
vdaf_files[n] = json.load(fh)
fails += check(doc, vdaf_files)
extra = sorted(set(os.listdir(corpus_dir)) - {"vectors.json", "README.md", "vdaf-22"})
print(("PASS" if not extra else "FAIL") + " no file but vectors.json, README.md and vdaf-22/")
fails += ["an extra file: %s" % n for n in extra]
missing = [r for r in REASONS + REUSED if r not in reasons_given(doc)]
print(("PASS" if not missing else "FAIL") + " every reason of §11, and every code it reuses, appears in the corpus")
fails += ["no case gives %s" % r for r in missing]
for label, d, v in mutations(doc, vdaf_files):
caught = check(d, v, quiet=True)
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:90] if caught else ""))
if not caught:
fails.append("a mutated copy verifies: %s" % label)
for name, what in CHECKS.items():
SKIP.clear()
SKIP.add(name)
caught = check(doc, {}, quiet=True)
SKIP.clear()
print(("PASS" if caught else "FAIL") + " a verifier without %s fails the corpus%s" % (what, " (%s)" % caught[0][:70] if caught else ""))
if not caught:
fails.append("the corpus does not catch a verifier without %s" % what)
for x in fails:
print("FAIL " + x)
print(("PASS" if not fails else "FAIL") + " QoE aggregation corpus")
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: telemetry_aggregate_verify.py <live-conformance/aggregate>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))