Written from the extension's text and the drafts it cites, sharing no code with wai-rs: Keccak-p[1600,12], TurboSHAKE128 and the VDAF XOF; Field64 and Field128, the Lagrange-basis FLP and Prio3 (passing the draft's vectors); X25519, HKDF, AES-128-GCM and HPKE (RFC 9180 A.1.1); every flow re-run from its reports with the test keys, byte for byte; receipts and cells in §8.1's order; delivery-telemetry §3.4's classes read from the text. Every mutated copy of the corpus fails and every weakened verifier fails the corpus. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1822 lines
71 KiB
Python
1822 lines
71 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
|
|
MEASURES = {1: "outcomes", 2: "stalls", 3: "durations", 4: "start_time"}
|
|
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 check_task(cfg_bytes):
|
|
"""§4.3, in order: the task's fields, its info and its Prio3."""
|
|
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")
|
|
if info is None or info["measure"] not in MEASURES:
|
|
raise Refuse("measure_unknown", "task")
|
|
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):
|
|
"""§8.1, in order."""
|
|
rc = read_receipt(b)
|
|
t, info, p, tid = check_task(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]))
|