Some checks failed
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 30s
wai-audit collector and player integration / does this change touch it (push) Successful in 24s
wai-app and Create / wai-app · does this change touch it (push) Successful in 21s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 28s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 33s
wai-key-proxy / does this change touch it (push) Successful in 27s
wai-fleet service / does this change touch it (push) Successful in 16s
WAI operation catalogue / does this change touch it (push) Successful in 24s
wai-ledger service / does this change touch it (push) Successful in 26s
wai-quantum for JavaScript / does this change touch it (push) Successful in 26s
wai-sink-android native sink library / does this change touch it (push) Successful in 32s
WAI Studio / does this change touch it (push) Successful in 32s
wai-quantum studio and desktop / does this change touch it (push) Successful in 44s
wai-sink-swift native sink package / does this change touch it (push) Successful in 34s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 2m16s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Failing after 7m31s
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Successful in 6m13s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 9m1s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 23s
wai-quantum energy-landscape jobs / does this change touch it (push) Successful in 17s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 57s
wai-audit collector and player integration / collector · linux (push) Successful in 2m24s
wai-key-proxy / proxy · linux (push) Successful in 2m32s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 1m42s
wai-ledger service / service · linux (push) Successful in 3m21s
wai-fleet service / service · linux (push) Successful in 5m24s
wai-quantum for JavaScript / javascript · linux (push) Successful in 5m33s
wai-quantum studio and desktop / studio · browser (push) Successful in 5m13s
wai-quantum studio and desktop / desktop app · windows (push) Successful in 9m58s
wai-quantum energy-landscape jobs / the same bits · linux-arm64 (push) Successful in 12m36s
WAI operation catalogue / the catalogue and every door · linux (push) Successful in 13m27s
wai-quantum studio and desktop / desktop app · linux (push) Successful in 11m10s
WAI Studio / Studio in WebKit · linux (push) Successful in 13m50s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 47s
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Successful in 1m55s
wai-fleet service / service · macos (push) Successful in 1m5s
wai-ledger service / service · macos (push) Successful in 1m38s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 8m20s
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Successful in 8m5s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 16m45s
wai-quantum studio and desktop / desktop app · macos (push) Failing after 12m13s
WAI Studio / Studio in Chromium and Firefox · macos (push) Failing after 12m25s
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Successful in 17m39s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 2m42s
wai-quantum energy-landscape jobs / native against WebAssembly, over many seeds (push) Successful in 7m51s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 8m29s
wai-quantum energy-landscape jobs / reader, kernels, answers and the stated result · linux, release (push) Successful in 9m9s
wai-quantum energy-landscape jobs / the module under wasm32-wasip2 (wasmtime) (push) Successful in 18m57s
wai-quantum energy-landscape jobs / the same bits · macos (push) Successful in 7m7s
c2pa-binding §4.8 to §4.10 and §6.4: after its three assertions a store of wai.asset.c2pa may carry wai-mark:tent64 as a C2PA soft-binding assertion naming its robustness claim, the envelope's ISCC identifier, and the Creator Assertions Working Group's identity assertions (1.3, cawg.x509.cose); a verifier may hold its anchors as trust lists in the C2PA Trust List's form, pinned by the SHA-256 of their bytes. The algorithm-list entries for tent64 and band64 are written in extensions/c2pa-soft-binding-entries.json. extensions/iscc.md: ISO 24138's codec, ISCC-SEQ, Instance-, Image- and Audio-Codes and the ISCC-CODE from the IEP texts, replaying the public test vectors; WAI's integer pre-processing. extensions/detection.md: wai.media.detection, a detector's signed verdict at an operating point named by digest, its evidence a robustness record of binding type detector; refused as provenance, never a change to an origin claim's class. Corpora c2pa-identity/, iscc/ and detection/, each with a second implementation in Python. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1056 lines
45 KiB
Python
1056 lines
45 KiB
Python
#!/usr/bin/env python3
|
||
"""Second implementation of extensions/robustness-evidence.md (`wai.media.robustness`), over the
|
||
corpus `live-conformance/robustness/`.
|
||
|
||
Written from the extension's text. It shares no code with wai-rs: it reads every record as §2
|
||
has it read (strict JSON, the encodings of jwp-receipts §2.4.3, rates read last as exact
|
||
fractions), applies the transforms of §4, runs the harness of §6 with `wai-mark:tent64` as the
|
||
synthesis corpus's second implementation has it (_wai_mark.py, written from synthesis §7.5),
|
||
and rerun end to end on the samples the corpus holds, requires the conformance record's table
|
||
bit for bit. It verifies every signature with WAI's strict Ed25519 rule
|
||
(claim-conformance/_ed25519.py) and every link hash with BLAKE3 written from its specification
|
||
(claim-conformance/_blake3.py). It reads §12's codes from the extension, checks the records of
|
||
`wai-mark:tent64` against the synthesis corpus's battery.json — a measurement another program
|
||
wrote — and the claim against synthesis §7.5.8's text.
|
||
|
||
python3 -I robustness_verify.py <live-conformance/robustness>
|
||
|
||
It gives every case the outcome, the reason, the record and the statements the corpus states,
|
||
and checks that every reason of §12 refuses a case. Then it mutates copies of the corpus and
|
||
requires every copy to fail, and drops each of its own checks in turn and requires the corpus
|
||
to catch the weakened verifier. Exit status 0 only if all of that holds. Python 3.8 or later;
|
||
standard library only.
|
||
"""
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, HERE)
|
||
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
|
||
import _wai_mark as M # noqa: E402 (synthesis §7.5, written from the extension's text)
|
||
import _wai_audio_mark as AM # noqa: E402 (synthesis §7.10, written from the extension's text)
|
||
import _ed25519 as ED # noqa: E402
|
||
from _blake3 import blake3 # noqa: E402
|
||
|
||
WAI = os.path.join(HERE, "..")
|
||
EXTENSION = os.path.join(WAI, "extensions", "robustness-evidence.md")
|
||
SYNTHESIS = os.path.join(WAI, "extensions", "synthesis.md")
|
||
|
||
SIGN_DOMAIN = b"wai:robustness\x01" # §2
|
||
LINK_DOMAIN = b"wai:robustness-link\x01" # §2
|
||
RECORD, CLAIM, CORPUS = "wai-robustness", "wai-robustness-claim", "wai-robustness-corpus"
|
||
HARNESS = "wai-robustness-harness:1" # §6
|
||
MAX_INT = (1 << 53) - 1
|
||
FRAME_CAP, ITEM_CAP = 1 << 26, 1 << 30 # §9.1
|
||
RECORD_MEMBERS = ["kind", "algorithm", "algorithm_digest", "binding_type", "media", "corpus", "values", "battery",
|
||
"harness", "table", "issued_at", "signer_pubkey", "sig"]
|
||
ROW_MEMBERS = ["attack", "trials", "survived", "wrong", "negatives", "false_positives", "survival",
|
||
"false_positive_rate", "balanced_accuracy", "tpr_at_1pct_fpr"]
|
||
RATES = ["survival", "false_positive_rate", "balanced_accuracy", "tpr_at_1pct_fpr"]
|
||
CLAIM_MEMBERS = ["kind", "algorithm", "algorithm_digest", "survives", "evidence", "issued_at", "signer_pubkey", "sig"]
|
||
WHITE_SPACE = set("\t\n\x0b\x0c\r \x85\xa0 "
|
||
"
")
|
||
|
||
# A check named here is left out: the verifier weakened on purpose, to show the corpus catches it.
|
||
SKIP = set()
|
||
|
||
|
||
class Refuse(Exception):
|
||
def __init__(self, reason):
|
||
super().__init__(reason)
|
||
self.reason = reason
|
||
|
||
|
||
class Bad(Exception):
|
||
"""A record that does not read: `record_malformed`."""
|
||
|
||
|
||
class Number:
|
||
"""A JSON number that is not an unsigned integer of §2: kept, so that a rate member holding
|
||
one is `rate_not_exact` and any other member holding one is `record_malformed`."""
|
||
|
||
|
||
def need(c, reason=None):
|
||
if not c:
|
||
raise Refuse(reason) if reason else Bad()
|
||
|
||
|
||
def sha(b):
|
||
return hashlib.sha256(b).digest()
|
||
|
||
|
||
# ---------------------------------------------------------------- §2 reading
|
||
|
||
def strict_json(text):
|
||
"""RFC 8259, no member named twice, no boolean, no unpaired surrogate, at most 32 levels;
|
||
a number that is not an unsigned integer of at most 2^53 − 1 is kept as a Number."""
|
||
def pairs(ps):
|
||
d = {}
|
||
for k, x in ps:
|
||
if k in d:
|
||
raise Bad()
|
||
d[k] = x
|
||
return d
|
||
|
||
def no(s):
|
||
raise Bad()
|
||
|
||
def integer(s):
|
||
if s.startswith("-"):
|
||
return Number()
|
||
n = int(s)
|
||
return n if n <= MAX_INT else Number()
|
||
|
||
try:
|
||
v = json.loads(text, object_pairs_hook=pairs, parse_float=lambda s: Number(), parse_int=integer, parse_constant=no)
|
||
except (ValueError, RecursionError):
|
||
raise Bad()
|
||
|
||
def walk(x, depth):
|
||
if isinstance(x, (dict, list)):
|
||
need(depth <= 32)
|
||
for y in (x.values() if isinstance(x, dict) else x):
|
||
walk(y, depth + 1)
|
||
elif isinstance(x, str):
|
||
need(not any(0xD800 <= ord(c) <= 0xDFFF for c in x))
|
||
elif isinstance(x, bool):
|
||
raise Bad()
|
||
walk(v, 1)
|
||
return v
|
||
|
||
|
||
def subset(x):
|
||
"""§2's values: strings, unsigned integers, null, and arrays and objects of them."""
|
||
if x is None or isinstance(x, str):
|
||
return True
|
||
if isinstance(x, bool) or isinstance(x, Number):
|
||
return False
|
||
if isinstance(x, int):
|
||
return 0 <= x <= MAX_INT
|
||
if isinstance(x, list):
|
||
return all(subset(y) for y in x)
|
||
if isinstance(x, dict):
|
||
return all(subset(y) for y in x.values())
|
||
return False
|
||
|
||
|
||
def jstr(s):
|
||
out = ['"']
|
||
for c in s:
|
||
o = ord(c)
|
||
if c == '"':
|
||
out.append('\\"')
|
||
elif c == "\\":
|
||
out.append("\\\\")
|
||
elif c == "\b":
|
||
out.append("\\b")
|
||
elif c == "\f":
|
||
out.append("\\f")
|
||
elif c == "\n":
|
||
out.append("\\n")
|
||
elif c == "\r":
|
||
out.append("\\r")
|
||
elif c == "\t":
|
||
out.append("\\t")
|
||
elif o < 0x20:
|
||
out.append("\\u%04x" % o)
|
||
else:
|
||
out.append(c)
|
||
out.append('"')
|
||
return "".join(out)
|
||
|
||
|
||
def jcs(v):
|
||
"""RFC 8785 over strings, unsigned integers, null, arrays and objects."""
|
||
if v is None:
|
||
return "null"
|
||
if isinstance(v, bool) or isinstance(v, Number):
|
||
raise Bad()
|
||
if isinstance(v, int):
|
||
return str(v)
|
||
if isinstance(v, str):
|
||
return jstr(v)
|
||
if isinstance(v, list):
|
||
return "[" + ",".join(jcs(x) for x in v) + "]"
|
||
keys = sorted(v, key=lambda k: k.encode("utf-16-be"))
|
||
return "{" + ",".join(jstr(k) + ":" + jcs(v[k]) for k in keys) + "}"
|
||
|
||
|
||
def link_hash(v):
|
||
return blake3(LINK_DOMAIN + jcs(v).encode("utf-8"))
|
||
|
||
|
||
def signing_bytes(v):
|
||
return SIGN_DOMAIN + jcs({k: x for k, x in v.items() if k != "sig"}).encode("utf-8")
|
||
|
||
|
||
def is_uint(x):
|
||
return isinstance(x, int) and not isinstance(x, bool) and 0 <= x <= MAX_INT
|
||
|
||
|
||
def is_hex(x, n=None):
|
||
return (isinstance(x, str) and len(x) % 2 == 0 and all(c in "0123456789abcdef" for c in x)
|
||
and (n is None or len(x) == 2 * n))
|
||
|
||
|
||
def is_term(s):
|
||
return (isinstance(s, str) and 1 <= len(s.encode("utf-8")) <= 2048
|
||
and not any(c in WHITE_SPACE or ord(c) < 0x20 or 0x7F <= ord(c) <= 0x9F for c in s))
|
||
|
||
|
||
def date_time(s):
|
||
"""RFC 3339 date-time, the day within its month (jwp-receipts §2.4.3)."""
|
||
if not (isinstance(s, str) and len(s) >= 20 and s.isascii()):
|
||
return False
|
||
if not all(s[i].isdigit() for i in (0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18)):
|
||
return False
|
||
if not (s[4] == "-" and s[7] == "-" and s[10] in "Tt" and s[13] == ":" and s[16] == ":"):
|
||
return False
|
||
yr, mo, da, hh, mi, ss = int(s[0:4]), int(s[5:7]), int(s[8:10]), int(s[11:13]), int(s[14:16]), int(s[17:19])
|
||
leap = yr % 4 == 0 and (yr % 100 != 0 or yr % 400 == 0)
|
||
days = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||
if not (1 <= mo <= 12 and 1 <= da <= days[mo - 1] and hh <= 23 and mi <= 59 and ss <= 60):
|
||
return False
|
||
i = 19
|
||
if i < len(s) and s[i] == ".":
|
||
j = i + 1
|
||
while j < len(s) and s[j].isdigit():
|
||
j += 1
|
||
if j == i + 1:
|
||
return False
|
||
i = j
|
||
rest = s[i:]
|
||
if rest in ("Z", "z"):
|
||
return True
|
||
return (len(rest) == 6 and rest[0] in "+-" and rest[1:3].isdigit() and rest[3] == ":" and rest[4:6].isdigit()
|
||
and int(rest[1:3]) <= 23 and int(rest[4:6]) <= 59)
|
||
|
||
|
||
def exact(v, names):
|
||
need(isinstance(v, dict) and set(v) == set(names))
|
||
return v
|
||
|
||
|
||
def read_frame(text, kind, names):
|
||
v = strict_json(text)
|
||
exact(v, names)
|
||
need(v["kind"] == kind)
|
||
need(is_hex(v["signer_pubkey"], 32) and is_hex(v["sig"], 64))
|
||
need(date_time(v["issued_at"]))
|
||
return v
|
||
|
||
|
||
def check_signature(v):
|
||
if "signature" in SKIP:
|
||
return
|
||
r = ED.verify(bytes.fromhex(v["signer_pubkey"]), signing_bytes(v), bytes.fromhex(v["sig"]))
|
||
if r:
|
||
raise Refuse(r)
|
||
|
||
|
||
def rate(x):
|
||
"""§7: [n, d], 1 <= d, n <= d, in lowest terms."""
|
||
ok = (isinstance(x, list) and len(x) == 2 and all(is_uint(y) for y in x) and x[1] >= 1 and x[0] <= x[1]
|
||
and math.gcd(x[0], x[1]) == 1)
|
||
if not ok and "exact-rates" not in SKIP:
|
||
raise Refuse("rate_not_exact")
|
||
return tuple(x) if isinstance(x, list) else x
|
||
|
||
|
||
def read_evidence(text):
|
||
"""§2 and §5: the record, every member but the rates, then the rates, then the signature."""
|
||
v = read_frame(text, RECORD, RECORD_MEMBERS)
|
||
rest = copy.deepcopy(v)
|
||
if isinstance(rest["table"], list):
|
||
for r in rest["table"]:
|
||
if isinstance(r, dict):
|
||
for k in RATES:
|
||
r.pop(k, None)
|
||
need(subset(rest))
|
||
need(v["binding_type"] in ("watermark", "fingerprint", "detector")) # §5; detection §4
|
||
need(isinstance(v["values"], list) and all(is_hex(x) and x != "" for x in v["values"]))
|
||
need(isinstance(v["battery"], list))
|
||
for a in v["battery"]:
|
||
exact(a, ["attack", "transform", "params", "seed"])
|
||
need(is_term(a["attack"]) and is_term(a["transform"]) and isinstance(a["params"], list)
|
||
and all(is_uint(p) for p in a["params"]) and is_uint(a["seed"]))
|
||
need(isinstance(v["table"], list))
|
||
for r in v["table"]:
|
||
exact(r, ROW_MEMBERS)
|
||
need(is_term(r["attack"]) and all(is_uint(r[k]) for k in ROW_MEMBERS[1:6]))
|
||
need(is_term(v["algorithm"]) and is_hex(v["algorithm_digest"], 32) and v["media"] in ("image", "video", "audio")
|
||
and is_hex(v["corpus"], 32) and is_term(v["harness"]))
|
||
rates = []
|
||
for r in v["table"]:
|
||
rates.append(tuple(None if (k == "tpr_at_1pct_fpr" and r[k] is None) else rate(r[k]) for k in RATES))
|
||
check_signature(v)
|
||
return v, rates
|
||
|
||
|
||
def read_claim(text):
|
||
v = read_frame(text, CLAIM, CLAIM_MEMBERS)
|
||
need(subset(v))
|
||
need(isinstance(v["survives"], list) and len(v["survives"]) >= 1)
|
||
for c in v["survives"]:
|
||
exact(c, ["media", "transform", "params"])
|
||
need(c["media"] in ("image", "video", "audio") and is_term(c["transform"]) and isinstance(c["params"], list)
|
||
and all(is_uint(p) for p in c["params"]))
|
||
need(len({jcs(c) for c in v["survives"]}) == len(v["survives"]))
|
||
need(isinstance(v["evidence"], list) and all(is_hex(e, 32) for e in v["evidence"]))
|
||
need(len(set(v["evidence"])) == len(v["evidence"]))
|
||
need(is_term(v["algorithm"]) and is_hex(v["algorithm_digest"], 32))
|
||
check_signature(v)
|
||
return v
|
||
|
||
|
||
# ---------------------------------------------------------------- §3 the corpus
|
||
|
||
def read_corpus(text):
|
||
"""§3: the document, or `corpus_malformed`."""
|
||
try:
|
||
v = strict_json(text)
|
||
exact(v, ["kind", "name", "media", "items", "negatives"])
|
||
need(subset(v) and v["kind"] == CORPUS and is_term(v["name"]) and v["media"] in ("image", "video", "audio"))
|
||
for k in ("items", "negatives"):
|
||
need(isinstance(v[k], list) and len(v[k]) >= 1)
|
||
for i in v[k]:
|
||
if v["media"] == "audio":
|
||
exact(i, ["name", "rate", "channels", "length", "samples"])
|
||
need(is_term(i["name"]) and all(is_uint(i[x]) and i[x] >= 1 for x in ("rate", "channels", "length"))
|
||
and is_hex(i["samples"], 32))
|
||
need(i["length"] * i["channels"] * 2 <= MAX_INT)
|
||
else:
|
||
exact(i, ["name", "frames", "width", "height", "samples"])
|
||
need(is_term(i["name"]) and all(is_uint(i[x]) and i[x] >= 1 for x in ("frames", "width", "height"))
|
||
and is_hex(i["samples"], 32))
|
||
need(v["media"] == "video" or i["frames"] == 1)
|
||
need(i["frames"] * i["width"] * i["height"] * 3 <= MAX_INT)
|
||
need(len({i["name"] for i in v[k]}) == len(v[k]))
|
||
except Bad:
|
||
raise Refuse("corpus_malformed")
|
||
return v
|
||
|
||
|
||
# ---------------------------------------------------------------- §4 the transforms
|
||
|
||
def r(lo, hi):
|
||
return lambda x: lo <= x <= hi
|
||
|
||
|
||
# term: (parameter checks, media); a check per parameter, then any relation across them.
|
||
TRANSFORMS = {
|
||
"wai-attack:none": ([], None),
|
||
"wai-attack:int-motion": ([r(1, 255)], None),
|
||
"wai-attack:staged-base": ([r(1, 255)], None),
|
||
"wai-attack:denoise3": ([r(1, 16)], None),
|
||
"wai-attack:scale": ([r(1, 16), r(1, 16)], lambda p: p[0] <= 2 * p[1]),
|
||
"wai-attack:aspect": ([r(1, 16), r(1, 16)], lambda p: p[0] <= 2 * p[1]),
|
||
"wai-attack:upscale2": ([], None),
|
||
"wai-attack:half-then-int-motion": ([r(1, 255)], None),
|
||
"wai-attack:crop": ([r(0, 99)], None),
|
||
"wai-attack:mirror": ([], None),
|
||
"wai-attack:rotate-90": ([], None),
|
||
"wai-attack:rotate": ([r(0, 65536), r(0, 65536), r(0, 1)], None),
|
||
"wai-attack:noise": ([r(0, 255)], None),
|
||
"wai-attack:brightness": ([r(0, 255), r(0, 1)], None),
|
||
"wai-attack:contrast": ([r(1, 16), r(1, 16)], None),
|
||
"wai-attack:gamma": ([r(1, 6), r(1, 6)], None),
|
||
"wai-attack:local-blur": ([], None),
|
||
"wai-attack:clip-int-motion": ([r(1, 255)], None),
|
||
"wai-attack:frame-drop": ([], None),
|
||
"wai-attack:one-frame": ([], None),
|
||
}
|
||
VIDEO_ONLY = {"wai-attack:clip-int-motion", "wai-attack:frame-drop", "wai-attack:one-frame"}
|
||
PICTURE = set(TRANSFORMS) - {"wai-attack:none"}
|
||
# §4.1: the pinned audio transforms.
|
||
_RATE = lambda x: x in AM.RATES # noqa: E731
|
||
AUDIO = {
|
||
"wai-attack:audio-gain": ([r(1, 16), r(1, 16)], None),
|
||
"wai-attack:audio-trim": ([r(0, 60000), r(0, 60000)], None),
|
||
"wai-attack:audio-resample": ([_RATE], None),
|
||
"wai-attack:audio-resample-trip": ([_RATE], None),
|
||
"wai-attack:audio-noise": ([r(0, 32767)], None),
|
||
"wai-attack:audio-mix-noise": ([r(1, 1000), r(1, 1000)], None),
|
||
"wai-attack:audio-mulaw": ([], None),
|
||
"wai-attack:audio-requantize": ([r(1, 16)], None),
|
||
"wai-attack:audio-echo": ([r(1, 1000), r(1, 16), r(1, 16)], lambda p: p[1] <= p[2]),
|
||
"wai-attack:audio-mono": ([], None),
|
||
}
|
||
# §4.2: the measured transforms, registered, never run here.
|
||
MEASURED = {
|
||
"wai-attack:opus": ([r(6000, 510000)], None),
|
||
"wai-attack:mp3": ([r(8000, 320000)], None),
|
||
"wai-attack:aac": ([r(8000, 320000)], None),
|
||
"wai-attack:g722": ([], None),
|
||
"wai-attack:g726": ([lambda x: x in (16000, 24000, 32000, 40000)], None),
|
||
"wai-attack:g723-1": ([], None),
|
||
"wai-attack:resample-band": ([_RATE], None),
|
||
}
|
||
TRANSFORMS.update(AUDIO)
|
||
TRANSFORMS.update(MEASURED)
|
||
|
||
|
||
def transform_ok(term, params):
|
||
"""§4: True for a registered transform whose parameters fit, None for an unregistered term;
|
||
`transform_invalid` otherwise."""
|
||
if term not in TRANSFORMS:
|
||
return None
|
||
checks, rel = TRANSFORMS[term]
|
||
ok = len(params) == len(checks) and all(c(p) for c, p in zip(checks, params)) and (rel is None or rel(params))
|
||
if not ok and "transform-ranges" not in SKIP:
|
||
raise Refuse("transform_invalid")
|
||
return True
|
||
|
||
|
||
def gamma_table(n, d):
|
||
"""§4 Gamma: the greatest y with y = 0 or (2y - 1)^d * 255^n <= 2^d * v^n * 255^d."""
|
||
t = []
|
||
for v in range(256):
|
||
rhs = 2 ** d * v ** n * 255 ** d
|
||
y = 0
|
||
while y < 255 and (2 * (y + 1) - 1) ** d * 255 ** n <= rhs:
|
||
y += 1
|
||
t.append(y)
|
||
return t
|
||
|
||
|
||
def per_frame(px, w, h, n, f):
|
||
"""Apply f(frame) -> (frame', w', h') to each of n frames."""
|
||
fb = w * h * 3
|
||
out, nw, nh = [], w, h
|
||
for i in range(n):
|
||
g, nw, nh = f(px[i * fb:(i + 1) * fb])
|
||
out.append(g)
|
||
return b"".join(out), nw, nh, n
|
||
|
||
|
||
def apply(term, p, px, w, h, n, seed):
|
||
"""§4: (samples, width, height, frames) of a registered transform applied at `seed`."""
|
||
empty = (b"", 0, 0, n)
|
||
if term == "wai-attack:none":
|
||
return px, w, h, n
|
||
if term == "wai-attack:int-motion":
|
||
return per_frame(px, w, h, n, lambda fr: (M.int_motion(fr, p[0]), w, h))
|
||
if term in ("wai-attack:staged-base", "wai-attack:half-then-int-motion") and (w < 2 or h < 2):
|
||
return empty
|
||
if term == "wai-attack:staged-base":
|
||
def staged(fr):
|
||
hw, hh = w // 2, h // 2
|
||
half = bytearray(hw * hh * 3)
|
||
for y in range(hh):
|
||
for x in range(hw):
|
||
for c in range(3):
|
||
s = sum(fr[((2 * y + dy) * w + 2 * x + dx) * 3 + c] for dy in (0, 1) for dx in (0, 1))
|
||
half[(y * hw + x) * 3 + c] = (s + 2) >> 2
|
||
return M.upscale2(M.int_motion(bytes(half), p[0]), hw, hh), 2 * hw, 2 * hh
|
||
return per_frame(px, w, h, n, staged)
|
||
if term == "wai-attack:denoise3":
|
||
def dn(fr):
|
||
for _ in range(p[0]):
|
||
fr = M.denoise3(fr, w, h)
|
||
return fr, w, h
|
||
return per_frame(px, w, h, n, dn)
|
||
if term == "wai-attack:scale":
|
||
nw, nh = w * p[0] // p[1], h * p[0] // p[1]
|
||
if nw == 0 or nh == 0:
|
||
return empty
|
||
return per_frame(px, w, h, n, lambda fr: (M.area(fr, w, h, nw, nh), nw, nh))
|
||
if term == "wai-attack:aspect":
|
||
nw = w * p[0] // p[1]
|
||
if nw == 0:
|
||
return empty
|
||
return per_frame(px, w, h, n, lambda fr: (M.area(fr, w, h, nw, h), nw, h))
|
||
if term == "wai-attack:upscale2":
|
||
return per_frame(px, w, h, n, lambda fr: (M.upscale2(fr, w, h), 2 * w, 2 * h))
|
||
if term == "wai-attack:half-then-int-motion":
|
||
nw, nh = w // 2, h // 2
|
||
return per_frame(px, w, h, n, lambda fr: (M.int_motion(M.area(fr, w, h, nw, nh), p[0]), nw, nh))
|
||
if term == "wai-attack:crop":
|
||
dx, dy = w * p[0] // 200, h * p[0] // 200
|
||
nw, nh = w - 2 * dx, h - 2 * dy
|
||
return per_frame(px, w, h, n, lambda fr: (b"".join(fr[(y * w + dx) * 3:(y * w + dx + nw) * 3] for y in range(dy, dy + nh)), nw, nh))
|
||
if term == "wai-attack:mirror":
|
||
return per_frame(px, w, h, n, lambda fr: (b"".join(fr[(y * w + x) * 3:(y * w + x) * 3 + 3]
|
||
for y in range(h) for x in range(w - 1, -1, -1)), w, h))
|
||
if term == "wai-attack:rotate-90":
|
||
return per_frame(px, w, h, n, lambda fr: (b"".join(fr[((h - 1 - xp) * w + yp) * 3:((h - 1 - xp) * w + yp) * 3 + 3]
|
||
for yp in range(w) for xp in range(h)), h, w))
|
||
if term == "wai-attack:rotate":
|
||
s = -p[1] if p[2] == 1 else p[1]
|
||
return per_frame(px, w, h, n, lambda fr: (M.rotate(fr, w, h, p[0], s), w, h))
|
||
if term == "wai-attack:noise":
|
||
st = M.stream(b"wai-mark:battery noise", seed)
|
||
return bytes(M.clamp(v + next(st) % (2 * p[0] + 1) - p[0]) for v in px), w, h, n
|
||
if term == "wai-attack:brightness":
|
||
b = -p[0] if p[1] == 1 else p[0]
|
||
return bytes(M.clamp(v + b) for v in px), w, h, n
|
||
if term == "wai-attack:contrast":
|
||
return bytes(M.clamp(128 + M.rnd((v - 128) * p[0], p[1])) for v in px), w, h, n
|
||
if term == "wai-attack:gamma":
|
||
t = gamma_table(p[0], p[1])
|
||
return bytes(t[v] for v in px), w, h, n
|
||
if term == "wai-attack:local-blur":
|
||
return per_frame(px, w, h, n, lambda fr: M.attack("local-blur", fr, w, h, seed))
|
||
if term == "wai-attack:frame-drop":
|
||
fb = w * h * 3
|
||
kept = list(range(0, n, 2))
|
||
return b"".join(px[i * fb:(i + 1) * fb] for i in kept), w, h, len(kept)
|
||
if term == "wai-attack:one-frame":
|
||
return px[:w * h * 3], w, h, 1
|
||
raise NotImplementedError(term)
|
||
|
||
|
||
# ---------------------------------------------------------------- the algorithm and §6
|
||
|
||
def tent64_digest():
|
||
"""synthesis §7.4: the definition's digest, as its text states it, which is the SHA-256 of
|
||
the synthesis corpus's scheme.json."""
|
||
s = " ".join(open(SYNTHESIS, encoding="utf-8").read().split())
|
||
m = re.search(r"and its digest is `([0-9a-f]{64})`", s)
|
||
stated = m.group(1) if m else None
|
||
with open(os.path.join(HERE, "synthesis-mark", "scheme.json"), "rb") as f:
|
||
computed = hashlib.sha256(f.read()).hexdigest()
|
||
return stated if stated == computed else None
|
||
|
||
|
||
TENT64 = M.SCHEME
|
||
TENT64_DIGEST = tent64_digest()
|
||
|
||
|
||
def value_ok(v):
|
||
"""§5 check 5: a payload of synthesis §7.2."""
|
||
b = bytes.fromhex(v)
|
||
return len(b) == 8 and b[0] <= 3
|
||
|
||
|
||
HARNESS_MEMO = {}
|
||
|
||
|
||
def band64_digest():
|
||
"""synthesis §7.10.1: the definition's digest, as its text states it, which is the SHA-256 of
|
||
the audio corpus's scheme-band64.json."""
|
||
s = open(SYNTHESIS, encoding="utf-8").read()
|
||
at = s.find("\n#### 7.10.1 ")
|
||
m = re.search(r"its digest is\s+`([0-9a-f]{64})`", s[at:at + 4000]) if at >= 0 else None
|
||
stated = m.group(1) if m else None
|
||
try:
|
||
with open(os.path.join(HERE, "audio-mark", "scheme-band64.json"), "rb") as f:
|
||
computed = hashlib.sha256(f.read()).hexdigest()
|
||
except OSError:
|
||
return None
|
||
return stated if stated == computed else None
|
||
|
||
|
||
BAND64_DIGEST = band64_digest()
|
||
|
||
|
||
def run_audio_harness(doc, samples, values, battery):
|
||
"""§6 for wai-mark:band64 over audio items whose samples are held."""
|
||
key = ("audio", tuple(i["samples"] for i in doc["items"]), tuple(i["samples"] for i in doc["negatives"]), tuple(values),
|
||
jcs([[a["transform"], a["params"], a["seed"]] for a in battery]))
|
||
if key in HARNESS_MEMO:
|
||
return HARNESS_MEMO[key]
|
||
|
||
def chans(i):
|
||
return i["rate"], AM.unpack(samples[i["samples"]], i["channels"])
|
||
marked = []
|
||
for i, val in zip(doc["items"], values):
|
||
rate, c = chans(i)
|
||
if AM.admits(rate, len(c), len(c[0])):
|
||
c = AM.embed(rate, c, bytes.fromhex(val))[0]
|
||
marked.append((rate, c))
|
||
negs = [chans(i) for i in doc["negatives"]]
|
||
rows = []
|
||
for a in battery:
|
||
name = a["transform"].split(":", 1)[1]
|
||
survived = wrong = fp = 0
|
||
for (rate, c), val in zip(marked, values):
|
||
r2, c2 = AM.attack(name, a["params"], rate, c, a["seed"])
|
||
got = AM.detect(r2, len(c2), AM.pack(c2))
|
||
if got is not None:
|
||
if got[0].hex() == val:
|
||
survived += 1
|
||
else:
|
||
wrong += 1
|
||
for rate, c in negs:
|
||
r2, c2 = AM.attack(name, a["params"], rate, c, a["seed"])
|
||
if AM.detect(r2, len(c2), AM.pack(c2)) is not None:
|
||
fp += 1
|
||
rows.append((len(marked), survived, wrong, len(negs), fp))
|
||
HARNESS_MEMO[key] = rows
|
||
return rows
|
||
|
||
|
||
def run_harness(items, negatives, values, battery):
|
||
"""§6 for wai-mark:tent64: the counts of each attack, in order. `items` and `negatives` are
|
||
(samples, width, height, frames)."""
|
||
key = (tuple(sha(i[0]) for i in items), tuple(sha(i[0]) for i in negatives), tuple(values),
|
||
jcs([[a["transform"], a["params"], a["seed"]] for a in battery]))
|
||
if key in HARNESS_MEMO:
|
||
return HARNESS_MEMO[key]
|
||
marked = []
|
||
for (px, w, h, n), v in zip(items, values):
|
||
payload = bytes.fromhex(v)
|
||
marked.append((M.embed(px, w, h, n, payload)[0] if M.admits(w, h, n) else px, w, h, n))
|
||
rows = []
|
||
for a in battery:
|
||
t, p, s = a["transform"], a["params"], a["seed"]
|
||
survived = wrong = fp = 0
|
||
for (px, w, h, n), v in zip(marked, values):
|
||
got = M.detect(*apply(t, p, px, w, h, n, s))
|
||
if got is not None:
|
||
if got[0].hex() == v:
|
||
survived += 1
|
||
else:
|
||
wrong += 1
|
||
for px, w, h, n in negatives:
|
||
if M.detect(*apply(t, p, px, w, h, n, s)) is not None:
|
||
fp += 1
|
||
rows.append((len(items), survived, wrong, len(negatives), fp))
|
||
HARNESS_MEMO[key] = rows
|
||
return rows
|
||
|
||
|
||
# ---------------------------------------------------------------- §7 rates
|
||
|
||
def reduce_(n, d):
|
||
g = math.gcd(n, d) or 1
|
||
return (n // g, d // g)
|
||
|
||
|
||
def rates_of(trials, survived, negatives, fp):
|
||
sv = reduce_(survived, trials)
|
||
return (sv, reduce_(fp, negatives), reduce_(survived * negatives + (negatives - fp) * trials, 2 * trials * negatives),
|
||
sv if 100 * fp <= negatives else None)
|
||
|
||
|
||
# ---------------------------------------------------------------- §5 and §8 checks
|
||
|
||
def check_evidence(v, rates, corpus_text, case):
|
||
"""§5's checks 1 to 8: the record's status."""
|
||
battery, table = v["battery"], v["table"]
|
||
# 1.
|
||
need(len(battery) >= 1 and len({a["attack"] for a in battery}) == len(battery)
|
||
and all(a["seed"] < 2 ** 32 for a in battery), "battery_malformed")
|
||
reg = [transform_ok(a["transform"], a["params"]) for a in battery]
|
||
if "media" not in SKIP:
|
||
need(not any(ok and v["media"] == "image" and a["transform"] in VIDEO_ONLY for ok, a in zip(reg, battery)),
|
||
"battery_not_applicable")
|
||
need(not any(ok and v["media"] == "audio" and a["transform"] in PICTURE for ok, a in zip(reg, battery)),
|
||
"battery_not_applicable")
|
||
need(not any(ok and v["media"] != "audio" and (a["transform"] in AUDIO or a["transform"] in MEASURED)
|
||
for ok, a in zip(reg, battery)), "battery_not_applicable")
|
||
# 2.
|
||
need(len(table) == len(battery) and all(r["attack"] == a["attack"] for r, a in zip(table, battery)), "table_malformed")
|
||
# 3.
|
||
neg = table[0]["negatives"]
|
||
if "counts" not in SKIP:
|
||
for row in table:
|
||
need(row["trials"] == len(v["values"]) and row["trials"] >= 1 and row["survived"] + row["wrong"] <= row["trials"]
|
||
and row["negatives"] >= 1 and row["negatives"] == neg and row["false_positives"] <= row["negatives"],
|
||
"table_inconsistent")
|
||
# 4.
|
||
if "rates" not in SKIP:
|
||
for row, rt in zip(table, rates):
|
||
need(rates_of(row["trials"], row["survived"], row["negatives"], row["false_positives"]) == rt, "rate_mismatch")
|
||
# 5.
|
||
if v["algorithm"] == AM.SCHEME:
|
||
need(v["algorithm_digest"] == BAND64_DIGEST and v["binding_type"] == "watermark", "algorithm_mismatch")
|
||
if "values" not in SKIP:
|
||
need(all(value_ok(x) for x in v["values"]), "values_invalid")
|
||
registered = v["algorithm"] == TENT64
|
||
if registered:
|
||
need(v["algorithm_digest"] == TENT64_DIGEST and v["binding_type"] == "watermark", "algorithm_mismatch")
|
||
if "values" not in SKIP:
|
||
need(all(value_ok(x) for x in v["values"]), "values_invalid")
|
||
# 6.
|
||
if corpus_text is None:
|
||
return "stated"
|
||
doc = read_corpus(corpus_text)
|
||
if "corpus-digest" not in SKIP:
|
||
need(hashlib.sha256(jcs(doc).encode("utf-8")).hexdigest() == v["corpus"] and doc["media"] == v["media"], "corpus_mismatch")
|
||
need(len(doc["items"]) == len(v["values"]) and len(doc["negatives"]) == neg, "table_inconsistent")
|
||
# 7.
|
||
every = doc["items"] + doc["negatives"]
|
||
if "sizes" not in SKIP:
|
||
for i in every:
|
||
s = case["samples"].get(i["samples"])
|
||
size = i["length"] * i["channels"] * 2 if doc["media"] == "audio" else i["frames"] * i["width"] * i["height"] * 3
|
||
need(s is None or len(s) == size, "samples_mismatch")
|
||
# 8.
|
||
held = all(i["samples"] in case["samples"] for i in every)
|
||
if doc["media"] == "audio":
|
||
pinned = all(a["transform"] == "wai-attack:none" or a["transform"] in AUDIO for a in battery)
|
||
bounded = all(i["length"] * i["channels"] * 2 <= ITEM_CAP for i in every)
|
||
if not (v["algorithm"] == AM.SCHEME and AM.SCHEME in case["implements"] and held and bounded
|
||
and v["harness"] == HARNESS and pinned) or "rerun" in SKIP:
|
||
return "stated"
|
||
got = run_audio_harness(doc, case["samples"], v["values"], battery)
|
||
want = [(r["trials"], r["survived"], r["wrong"], r["negatives"], r["false_positives"]) for r in table]
|
||
need(got == want, "table_irreproducible")
|
||
return "reproduced"
|
||
bounded = all(i["width"] * i["height"] * 3 <= FRAME_CAP and i["frames"] * i["width"] * i["height"] * 3 <= ITEM_CAP
|
||
for i in every)
|
||
runnable = registered and TENT64 in case["implements"]
|
||
if not (runnable and held and bounded and v["harness"] == HARNESS and all(reg)) or "rerun" in SKIP:
|
||
return "stated"
|
||
frames = lambda i: (case["samples"][i["samples"]], i["width"], i["height"], i["frames"]) # noqa: E731
|
||
got = run_harness([frames(i) for i in doc["items"]], [frames(i) for i in doc["negatives"]], v["values"], battery)
|
||
want = [(r["trials"], r["survived"], r["wrong"], r["negatives"], r["false_positives"]) for r in table]
|
||
need(got == want, "table_irreproducible")
|
||
return "reproduced"
|
||
|
||
|
||
def check_claim(v, held, require_reproduced):
|
||
"""§8: the claim's status."""
|
||
cited = []
|
||
for link in v["evidence"]:
|
||
need(link in held, "evidence_not_held")
|
||
h = held[link]
|
||
need(h["v"]["algorithm"] == v["algorithm"] and h["v"]["algorithm_digest"] == v["algorithm_digest"], "evidence_mismatch")
|
||
cited.append(h)
|
||
if require_reproduced:
|
||
need(all(h["status"] == "reproduced" for h in cited), "evidence_not_reproduced")
|
||
for c in v["survives"]:
|
||
measured = False
|
||
for h in cited:
|
||
if h["v"]["media"] != c["media"]:
|
||
continue
|
||
for a, row in zip(h["v"]["battery"], h["v"]["table"]):
|
||
if a["transform"] == c["transform"] and a["params"] == c["params"]:
|
||
measured = True
|
||
if "survival" not in SKIP:
|
||
need(row["survived"] == row["trials"] and row["wrong"] == 0 and row["false_positives"] == 0,
|
||
"claim_exceeds_evidence")
|
||
need(measured, "claim_unevidenced")
|
||
return "reproduced" if all(h["status"] == "reproduced" for h in cited) else "stated"
|
||
|
||
|
||
def verify_case(case):
|
||
"""§9.2: ("accept", statements) or ("refuse", reason, position)."""
|
||
held, out = {}, []
|
||
for i, text in enumerate(case["records"]):
|
||
try:
|
||
try:
|
||
kind = strict_json(text).get("kind")
|
||
except (Bad, AttributeError):
|
||
kind = None
|
||
if kind == RECORD:
|
||
v, rates = read_evidence(text)
|
||
status = check_evidence(v, rates, case["corpora"].get(i), case)
|
||
link = link_hash(v).hex()
|
||
held[link] = {"v": v, "status": status}
|
||
out.append({"kind": RECORD, "link": link, "status": status, "algorithm": v["algorithm"], "corpus": v["corpus"],
|
||
"attacks": len(v["battery"])})
|
||
elif kind == CLAIM:
|
||
v = read_claim(text)
|
||
status = check_claim(v, held, case["require_reproduced"])
|
||
out.append({"kind": CLAIM, "link": link_hash(v).hex(), "status": status, "algorithm": v["algorithm"],
|
||
"attacks": len(v["survives"])})
|
||
else:
|
||
raise Bad()
|
||
except Bad:
|
||
return ("refuse", "record_malformed", i)
|
||
except Refuse as e:
|
||
return ("refuse", e.reason, i)
|
||
return ("accept", out)
|
||
|
||
|
||
# ---------------------------------------------------------------- the corpus's cases
|
||
|
||
def load(corpus_dir):
|
||
files = {}
|
||
for root, _, names in os.walk(corpus_dir):
|
||
for n in names:
|
||
p = os.path.join(root, n)
|
||
rel = os.path.relpath(p, corpus_dir).replace(os.sep, "/")
|
||
if rel != "README.md":
|
||
with open(p, "rb") as f:
|
||
files[rel] = f.read()
|
||
return files
|
||
|
||
|
||
def case_of(c, files):
|
||
corpora = {}
|
||
for g in c["corpora"]:
|
||
corpora[g["record"]] = files[g["file"]].decode("utf-8") if "file" in g else g["text"]
|
||
samples = {}
|
||
for h in c["samples"]:
|
||
b = files.get("frames/%s.rgb" % h)
|
||
if b is not None and hashlib.sha256(b).hexdigest() == h:
|
||
samples[h] = b
|
||
return {"records": c["records"], "corpora": corpora, "samples": samples, "implements": c["implements"],
|
||
"require_reproduced": c["policy"]["evidence"] == "reproduced"}
|
||
|
||
|
||
def outcome(r):
|
||
if r[0] == "accept":
|
||
return {"outcome": "accept", "statements": r[1]}
|
||
return {"outcome": "refuse", "reason": r[1], "record": r[2]}
|
||
|
||
|
||
def check(doc, files, quiet=False, only=None):
|
||
fails = []
|
||
for c in doc["cases"]:
|
||
if only is not None and c["name"] not in only:
|
||
continue
|
||
got = outcome(verify_case(case_of(c, files)))
|
||
ok = got == c["expected"]
|
||
if not quiet:
|
||
print(("PASS" if ok else "FAIL") + " %s" % c["name"])
|
||
if not ok:
|
||
fails.append("%s: expected %s, got %s" % (c["name"], json.dumps(c["expected"])[:160], json.dumps(got)[:160]))
|
||
return fails
|
||
|
||
|
||
# ---------------------------------------------------------------- the corpus's files and texts
|
||
|
||
def reasons_of_extension():
|
||
s = open(EXTENSION, encoding="utf-8").read()
|
||
at = s.index("\n## 12. Reasons")
|
||
end = s.index("\n## 13.", at)
|
||
return re.findall(r"^\| `([a-z_]+)` \|", s[at:end], re.M)
|
||
|
||
|
||
def transforms_of_extension():
|
||
s = open(EXTENSION, encoding="utf-8").read()
|
||
at = s.index("\n## 4. The transforms")
|
||
end = s.index("\n## 5.", at)
|
||
return re.findall(r"^\| `(wai-attack:[a-z0-9-]+)` \|", s[at:end], re.M) # §4, §4.1 and §4.2, in order
|
||
|
||
|
||
def synthesis_claims():
|
||
"""synthesis §7.5.8's claims: (pictures, clips), by name."""
|
||
s = open(SYNTHESIS, encoding="utf-8").read()
|
||
at = s.index("\n#### 7.5.8 ")
|
||
end = s.index("\n### 7.6", at)
|
||
t = " ".join(s[at:end].split())
|
||
m = re.search(r"For pictures, these attacks, each with the parameter measured: (.*?)\. For clips: (.*?)\.", t)
|
||
return re.findall(r"`([^`]+)`", m.group(1)), re.findall(r"`([^`]+)`", m.group(2))
|
||
|
||
|
||
ROTATIONS = {"rotate-0.5": [65534, 572, 0], "rotate-1": [65526, 1144, 0], "rotate-2": [65496, 2287, 0]}
|
||
|
||
|
||
def transform_of_name(name):
|
||
"""§10's table: a name of synthesis §7.5.7 as a transform and its parameters."""
|
||
m = re.fullmatch(r"(clip-int-motion|int-motion|staged-base|half-then-int-motion)-q(\d+)", name)
|
||
if m:
|
||
return "wai-attack:" + m.group(1), [int(m.group(2))]
|
||
m = re.fullmatch(r"(scale|aspect|contrast)-(\d+)/(\d+)", name)
|
||
if m:
|
||
return "wai-attack:" + m.group(1), [int(m.group(2)), int(m.group(3))]
|
||
m = re.fullmatch(r"crop-(\d+)%", name)
|
||
if m:
|
||
return "wai-attack:crop", [int(m.group(1))]
|
||
m = re.fullmatch(r"noise-(\d+)", name)
|
||
if m:
|
||
return "wai-attack:noise", [int(m.group(1))]
|
||
if name in ROTATIONS:
|
||
return "wai-attack:rotate", ROTATIONS[name]
|
||
if name == "denoise3":
|
||
return "wai-attack:denoise3", [1]
|
||
if name == "denoise3-x4":
|
||
return "wai-attack:denoise3", [4]
|
||
if name in ("brightness+32", "brightness-32"):
|
||
return "wai-attack:brightness", [32, 1 if name.endswith("-32") else 0]
|
||
if name in ("none", "upscale2", "mirror", "rotate-90", "local-blur", "frame-drop", "one-frame"):
|
||
return "wai-attack:" + name, []
|
||
raise ValueError(name)
|
||
|
||
|
||
def check_tent64(files):
|
||
"""§10: the records' batteries are synthesis §7.5.7's lists by §10's table and their seeds the
|
||
index; their tables and values are battery.json's; the claim is synthesis §7.5.8's list."""
|
||
fails = []
|
||
with open(os.path.join(HERE, "synthesis-mark", "battery.json"), encoding="utf-8") as f:
|
||
battery = json.load(f)
|
||
payload = {p["name"]: p["payload"] for p in battery["pictures"]}
|
||
lists = {"image": battery["image_attacks"], "video": battery["clip_attacks"]}
|
||
links = []
|
||
for name in ("tent64-spectral", "tent64-leaves", "tent64-clips"):
|
||
v = strict_json(files["records/%s.json" % name].decode("utf-8"))
|
||
links.append(link_hash(v).hex())
|
||
doc = strict_json(files["corpus/%s.json" % name].decode("utf-8"))
|
||
st = name[len("tent64-"):]
|
||
want_b = [{"attack": n, "transform": transform_of_name(n)[0], "params": transform_of_name(n)[1], "seed": i}
|
||
for i, n in enumerate(lists[v["media"]])]
|
||
if v["battery"] != want_b:
|
||
fails.append("%s: its battery is not synthesis 7.5.7's list" % name)
|
||
if v["values"] != [payload[i["name"]] for i in doc["items"]]:
|
||
fails.append("%s: its values are not battery.json's payloads" % name)
|
||
if hashlib.sha256(jcs(doc).encode()).hexdigest() != v["corpus"]:
|
||
fails.append("%s: its corpus file is not its corpus" % name)
|
||
for row in v["table"]:
|
||
t = next(x for x in battery["table"] if x["attack"] == row["attack"] and x["set"] == st)
|
||
u = next(x for x in battery["unmarked"] if x["attack"] == row["attack"] and x["set"] == st)
|
||
if (row["trials"], row["survived"], row["wrong"], row["negatives"], row["false_positives"]) != \
|
||
(t["trials"], t["detected"], t["wrong"], u["trials"], u["detections"]):
|
||
fails.append("%s %s: the row is not battery.json's" % (name, row["attack"]))
|
||
claim = strict_json(files["records/tent64-claim.json"].decode("utf-8"))
|
||
pics, clips = synthesis_claims()
|
||
want = ([{"media": "image", "transform": transform_of_name(n)[0], "params": transform_of_name(n)[1]} for n in pics]
|
||
+ [{"media": "video", "transform": transform_of_name(n)[0], "params": transform_of_name(n)[1]} for n in clips])
|
||
if claim["survives"] != want:
|
||
fails.append("the tent64 claim is not synthesis 7.5.8's list")
|
||
if claim["evidence"] != links:
|
||
fails.append("the tent64 claim does not cite the three records")
|
||
return fails
|
||
|
||
|
||
def check_files(doc, files):
|
||
"""The corpus documents are canonical, each frame file is named by its SHA-256, the records'
|
||
files are the texts the accepted cases verify, and nothing else is there."""
|
||
fails = []
|
||
for k, b in files.items():
|
||
if k.startswith("corpus/"):
|
||
if jcs(strict_json(b.decode("utf-8"))).encode("utf-8") != b:
|
||
fails.append("%s is not its JCS" % k)
|
||
elif k.startswith("frames/"):
|
||
if k != "frames/%s.rgb" % hashlib.sha256(b).hexdigest():
|
||
fails.append("%s is not named by its SHA-256" % k)
|
||
elif k.startswith("records/"):
|
||
pass
|
||
elif k != "vectors.json":
|
||
fails.append("an extra file: %s" % k)
|
||
by_name = {c["name"]: c for c in doc["cases"]}
|
||
want = {
|
||
"records/conformance.json": by_name["conformance_reproduced"]["records"][0],
|
||
"records/conformance-claim.json": by_name["conformance_claim_reproduced"]["records"][1],
|
||
"records/tent64-spectral.json": by_name["tent64_records_and_claim"]["records"][0],
|
||
"records/tent64-leaves.json": by_name["tent64_records_and_claim"]["records"][1],
|
||
"records/tent64-clips.json": by_name["tent64_records_and_claim"]["records"][2],
|
||
"records/tent64-claim.json": by_name["tent64_records_and_claim"]["records"][3],
|
||
}
|
||
for k, t in want.items():
|
||
if files.get(k) != t.encode("utf-8"):
|
||
fails.append("%s is not the text its accepted case verifies" % k)
|
||
for k in files:
|
||
if k.startswith("records/") and k not in want:
|
||
fails.append("an extra record: %s" % k)
|
||
return fails
|
||
|
||
|
||
# ---------------------------------------------------------------- mutations and weakened checks
|
||
|
||
def edit_case(doc, name, f):
|
||
d = copy.deepcopy(doc)
|
||
c = next(c for c in d["cases"] if c["name"] == name)
|
||
f(c)
|
||
return d, name
|
||
|
||
|
||
def edit_record(doc, name, i, f):
|
||
"""A case's record edited by f and left with its signature, which then fails, or with
|
||
rates and counts that no longer agree."""
|
||
def g(c):
|
||
v = json.loads(c["records"][i])
|
||
f(v)
|
||
c["records"][i] = json.dumps(v, separators=(",", ":"))
|
||
return edit_case(doc, name, g)
|
||
|
||
|
||
def mutations(doc, files):
|
||
"""Copies of the corpus, or of its files, with one defect each: (label, doc, files, case)."""
|
||
out = []
|
||
|
||
def m(label, pair, fs=None):
|
||
out.append((label, pair[0], fs if fs is not None else files, pair[1]))
|
||
|
||
m("a reproduced record's survived count changed", edit_record(doc, "conformance_reproduced", 0,
|
||
lambda v: v["table"][0].__setitem__("survived", 2)))
|
||
m("a reproduced record's seed changed", edit_record(doc, "conformance_reproduced", 0,
|
||
lambda v: v["battery"][7].__setitem__("seed", 99)))
|
||
m("a stated record's rate written as a decimal", edit_case(doc, "tent64_records_and_claim",
|
||
lambda c: c["records"].__setitem__(0, c["records"][0].replace('"survival":[1,1]', '"survival":1.0', 1))))
|
||
m("the claim given a stated record's corpus file from another set", edit_case(doc, "tent64_records_and_claim",
|
||
lambda c: c["corpora"][0].__setitem__("file", "corpus/tent64-leaves.json")))
|
||
m("the policy switched to require reproduced evidence", edit_case(doc, "tent64_records_and_claim",
|
||
lambda c: c["policy"].__setitem__("evidence", "reproduced")))
|
||
m("the samples dropped from a reproduced case", edit_case(doc, "conformance_reproduced", lambda c: c.__setitem__("samples", [])))
|
||
m("an accepted statement's status changed", edit_case(doc, "conformance_stated_without_samples",
|
||
lambda c: c["expected"]["statements"][0].__setitem__("status", "reproduced")))
|
||
m("a refusal's reason changed", edit_case(doc, "claim_crop_10_exceeds",
|
||
lambda c: c["expected"].__setitem__("reason", "claim_unevidenced")))
|
||
m("the claim's records reordered after it", edit_case(doc, "tent64_records_and_claim",
|
||
lambda c: c["records"].insert(0, c["records"].pop())))
|
||
frame = next(k for k in files if k.startswith("frames/"))
|
||
bad = dict(files)
|
||
bad[frame] = bytes([files[frame][0] ^ 1]) + files[frame][1:]
|
||
m("a frame file's first byte changed", (doc, "conformance_reproduced"), bad)
|
||
return out
|
||
|
||
|
||
CHECKS = {
|
||
"signature": "§2 the signature",
|
||
"exact-rates": "§7 rates as exact fractions",
|
||
"transform-ranges": "§4 the parameters' ranges",
|
||
"media": "§4 the transforms' media",
|
||
"counts": "§5 check 3 the counts",
|
||
"rates": "§5 check 4 the rates",
|
||
"values": "§5 check 5 the values",
|
||
"corpus-digest": "§5 check 6 the corpus digest",
|
||
"sizes": "§5 check 7 the samples' sizes",
|
||
"rerun": "§5 check 8 the rerun",
|
||
"survival": "§8 step 3 survival",
|
||
}
|
||
|
||
|
||
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 []
|
||
print(("PASS" if TENT64_DIGEST else "FAIL") + " wai-mark:tent64's digest, as synthesis 7.4 states it, is scheme.json's")
|
||
fails += [] if TENT64_DIGEST else ["the tent64 digest"]
|
||
reasons = reasons_of_extension()
|
||
print(("PASS" if len(reasons) == 21 else "FAIL") + " §12 read from the extension: %d reasons" % len(reasons))
|
||
fails += [] if len(reasons) == 21 else ["§12 does not read"]
|
||
listed = transforms_of_extension()
|
||
ok = listed == list(TRANSFORMS)
|
||
print(("PASS" if ok else "FAIL") + " §4 registers exactly the transforms this verifier implements")
|
||
fails += [] if ok else ["§4's transforms"]
|
||
files = load(corpus_dir)
|
||
doc = json.loads(files["vectors.json"])
|
||
fails += check(doc, files)
|
||
f = check_files(doc, files)
|
||
print(("PASS" if not f else "FAIL") + " the corpus documents, frames and records are what the cases verify, and nothing else is there")
|
||
fails += f
|
||
f = check_tent64(files)
|
||
print(("PASS" if not f else "FAIL") + " the tent64 records are synthesis 7.5.7's battery over battery.json's tallies, and the claim is synthesis 7.5.8's list")
|
||
fails += f
|
||
given = {c["expected"]["reason"] for c in doc["cases"] if c["expected"]["outcome"] == "refuse"}
|
||
missing = [x for x in reasons if x not in given]
|
||
unknown = sorted(given - set(reasons))
|
||
print(("PASS" if not missing and not unknown else "FAIL") + " every reason of §12 refuses a case, and no other reason does")
|
||
fails += ["no case gives %s" % x for x in missing] + ["a reason §12 does not list: %s" % x for x in unknown]
|
||
for label_, d, fs, case in mutations(doc, files):
|
||
caught = check(d, fs, quiet=True, only={case})
|
||
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, files, 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") + " robustness corpus: %d cases" % len(doc["cases"]))
|
||
return 1 if fails else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
if len(sys.argv) != 2:
|
||
print("usage: robustness_verify.py <live-conformance/robustness>", file=sys.stderr)
|
||
sys.exit(2)
|
||
sys.exit(main(sys.argv[1]))
|