#!/usr/bin/env python3 """Independent verifier of the live-provenance corpus (extensions/live-provenance.md). Written from the extension's text and the texts it reads (C2PA 2.4 §19 and what it relies on, RFC 9052, RFC 9053, RFC 9360, RFC 5280, RFC 8410, jwp-receipts §2 and §3). It shares no code with wai-rs, which generates the corpus, and never runs it. It reads, with the standard library alone: - CBOR in RFC 8949 §4.2.1's core deterministic encoding; - ISO BMFF boxes: a segment's layout (§3.2), its chunks, the initialization segment's `moov`, the C2PA Manifest Box and the VSI `emsg` box, and the BMFF hash over whole-box exclusions (§4.5); - the JUMBF manifest store (§4.1), the claim (§4.2) and its `COSE_Sign1_Tagged` (§4.3); - X.509 DER: the claim signer's certificates, their profile (§4.4), their chain to the trust anchor and their validity (§11.2); - the session keys, their `signerBinding` and rotation (§7), and each segment-info map; - the JWP group and object receipts of each group (§8; jwp-receipts §2.1 to §2.4 and §3). Signatures are verified with ../claim-conformance/_ed25519.py, WAI's strict rule written from RFC 8032, and BLAKE3 is ../claim-conformance/_blake3.py. The codes a refusal reports are read from the extension's §12 table. For every vector it requires the outcome, the reason, the place and the two C2PA codes, and for an accepted stream what §11.6 states; of the corpus, that every reason of §12 refuses a vector and that no other file is there. Then it mutates copies of the good vectors, one defect each, and requires every copy to be refused. python3 c2pa_live_verify.py It also verifies the corpora of c2pa-binding.md (`c2pa-envelope`, and `c2pa-identity`, whose soft bindings, ISCC identifier, identity assertions and trust lists §4.8 to §4.10 and §6.4 define), leaning for the mark on _wai_mark.py, for the robustness evidence on robustness_verify.py and for the ISCC units on iscc_verify.py, each the second implementation of its own extension. Exit status 0 only if every vector verifies and every mutated copy is refused. Python 3.8 or later; standard library only. """ import base64 import calendar import copy import hashlib import json import os import re import struct import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) import _blake3 as B3 # noqa: E402 import _ed25519 as ED # noqa: E402 EXTENSION = os.path.join(HERE, "..", "extensions", "live-provenance.md") C2PA_UUID = bytes.fromhex("D8FEC3D61B0E483C92975828877EC481") # Appendix A.5.1 VSI_SCHEME = "urn:c2pa:verifiable-segment-info" # §19.4.2 STREAM_DOMAIN = b"wai:live-provenance:stream\x01" # §3.4 MANIFEST_DOMAIN = b"wai:live-provenance:manifest\x01" # §4.2 ROLE_INIT, ROLE_SEGMENT, ROLE_ROTATION, ROLE_UPDATE = 0, 1, 2, 3 # §4.2 L_INGREDIENT, L_REPOSITORY = "c2pa.ingredient.v3", "c2pa.repository-receipt" # §4.7 DST_PREFIX = "http://cv.iptc.org/newscodes/digitalsourcetype/" MODEL_URI = "tag:transaction.science,2026:wai/model/" # timed-text §10.3 L_HASH, L_ACTIONS, L_LIVE, L_KEYS, L_AI = ( "c2pa.hash.bmff.v3", "c2pa.actions.v2", "c2pa.livevideo.segment", "c2pa.session-keys", "c2pa.ai-disclosure") L_STREAM, L_GROUP, L_TAKE = "science.transaction.wai.stream", "science.transaction.wai.group", "science.transaction.wai.take" def jtype(prefix): """A JUMBF type UUID: four characters, then 0011-0010-8000-00AA00389B71 (§4.1).""" return prefix + bytes.fromhex("00110010800000AA00389B71") T_STORE, T_MANIFEST, T_UPDATE, T_ASSERTIONS, T_CBOR, T_JSON, T_CLAIM, T_SIG = ( jtype(b"c2pa"), jtype(b"c2ma"), jtype(b"c2um"), jtype(b"c2as"), jtype(b"cbor"), jtype(b"json"), jtype(b"c2cl"), jtype(b"c2cs")) # X.509 object identifiers, as content octets. def oid(dotted): arcs = [int(x) for x in dotted.split(".")] out = bytearray() for v in [arcs[0] * 40 + arcs[1]] + arcs[2:]: tmp = [v & 0x7F] v >>= 7 while v: tmp.append(0x80 | (v & 0x7F)) v >>= 7 out += bytes(reversed(tmp)) return bytes(out) OID_ED25519 = oid("1.3.101.112") OID_BC, OID_KU, OID_EKU, OID_SKI, OID_AKI = (oid("2.5.29.19"), oid("2.5.29.15"), oid("2.5.29.37"), oid("2.5.29.14"), oid("2.5.29.35")) EKU_CLAIM, EKU_TSA, EKU_OCSP, EKU_ANY = (oid("1.3.6.1.4.1.62558.2.1"), oid("1.3.6.1.5.5.7.3.8"), oid("1.3.6.1.5.5.7.3.9"), oid("2.5.29.37.0")) ED25519_ALG = b"\x30\x05\x06\x03" + OID_ED25519 class Refuse(Exception): def __init__(self, reason, at): super().__init__(reason) self.reason, self.at = reason, at def reason_table(): """§12: each reason and its two C2PA codes, from the extension's own table.""" text = open(EXTENSION, encoding="utf-8").read() sec = text[text.index("\n## 12. Reasons"):] sec = sec[:sec.index("\n## 13.")] out = {} for line in sec.splitlines(): if line.startswith("| `"): cells = [c.strip().strip("`") for c in line.split("|")] out[cells[1]] = (None if cells[3] == "—" else cells[3], None if cells[4] == "—" else cells[4]) return out REASONS = reason_table() # ───────────────────────────── CBOR (RFC 8949 §4.2.1) ───────────────────────────── class Tag: def __init__(self, tag, value): self.tag, self.value = tag, value def __eq__(self, o): return isinstance(o, Tag) and (self.tag, self.value) == (o.tag, o.value) class CborError(Exception): pass def cbor_read(b): """One data item in deterministic encoding, with nothing after it.""" pos = [0] def take(n): if len(b) - pos[0] < n: raise CborError("short") s = b[pos[0]:pos[0] + n] pos[0] += n return s def arg(ai): if ai < 24: return ai if ai > 27: raise CborError("indefinite or reserved") n = {24: 1, 25: 2, 26: 4, 27: 8}[ai] v = int.from_bytes(take(n), "big") if v < {1: 24, 2: 256, 4: 65536, 8: 1 << 32}[n]: raise CborError("not the shortest head") return v def item(depth): if depth > 16: raise CborError("too deep") ib = take(1)[0] major, ai = ib >> 5, ib & 31 if major == 7: if ai == 20: return False if ai == 21: return True if ai == 22: return None raise CborError("unsupported simple or float") v = arg(ai) if major == 0: return v if major == 1: return -1 - v if major == 2: return bytes(take(v)) if major == 3: try: return take(v).decode("utf-8") except UnicodeDecodeError: raise CborError("text") if major == 4: return [item(depth + 1) for _ in range(v)] if major == 5: m, prev = {}, None for _ in range(v): s = pos[0] k = item(depth + 1) kb = b[s:pos[0]] if prev is not None and not prev < kb: raise CborError("keys out of order") prev = kb if isinstance(k, (list, dict)): raise CborError("key type") m[k] = item(depth + 1) return m return Tag(v, item(depth + 1)) v = item(1) if pos[0] != len(b): raise CborError("trailing bytes") return v def head(major, v): if v < 24: return bytes([major << 5 | v]) for n, ai in ((1, 24), (2, 25), (4, 26), (8, 27)): if v < 1 << (8 * n): return bytes([major << 5 | ai]) + v.to_bytes(n, "big") def sig_structure(protected, payload): """RFC 9052 §4.4: ["Signature1", protected, h'', payload], encoded.""" return (b"\x84" + head(3, 10) + b"Signature1" + head(2, len(protected)) + protected + head(2, 0) + head(2, len(payload)) + payload) def is_map(m, required, optional=()): return (isinstance(m, dict) and all(isinstance(k, str) and (k in required or k in optional) for k in m) and all(k in m for k in required)) def uint(v): return isinstance(v, int) and not isinstance(v, bool) and v >= 0 def b32(v): return isinstance(v, bytes) and len(v) == 32 # ───────────────────────────── BMFF ───────────────────────────── def boxes(b, start, end): out, p = [], start while p < end: if end - p < 8: return None size = struct.unpack(">I", b[p:p + 4])[0] if size < 8 or end - p < size: return None out.append((b[p + 4:p + 8], p, p + size)) p += size return out def full(b, r): """(version, flags) of a full box.""" if r[2] - r[1] < 12: return None v = struct.unpack(">I", b[r[1] + 8:r[1] + 12])[0] return v >> 24, v & 0xFFFFFF def is_c2pa_uuid(b, r): return r[0] == b"uuid" and b[r[1] + 8:r[1] + 24] == C2PA_UUID def manifest_store_bytes(b, r, purpose=b"manifest"): """The store a C2PA uuid box of `purpose` holds (§4.1, §4.7; Appendix A.5.3).""" body = b[r[1] + 8:r[2]] pre = C2PA_UUID + bytes(4) + purpose + b"\x00" + bytes(8) return body[len(pre):] if body.startswith(pre) else None VSI_MARK = VSI_SCHEME.encode() + b"\x00" def is_vsi(b, r): return r[0] == b"emsg" and b[r[1] + 12:r[1] + 12 + len(VSI_MARK)] == VSI_MARK def read_emsg(b, r): if r[0] != b"emsg" or full(b, r) != (0, 0): return None body = b[r[1] + 12:r[2]] z1 = body.find(b"\x00") if z1 < 0: return None z2 = body.find(b"\x00", z1 + 1) if z2 < 0: return None rest = body[z2 + 1:] if len(rest) < 16: return None try: scheme, value = body[:z1].decode(), body[z1 + 1:z2].decode() except UnicodeDecodeError: return None ts, ptd, dur, eid = struct.unpack(">IIII", rest[:16]) return dict(scheme=scheme, value=value, timescale=ts, ptd=ptd, duration=dur, id=eid, data=rest[16:]) PROFILE_EXCLUSIONS = [ {"xpath": "/uuid", "data": [{"offset": 8, "value": C2PA_UUID}]}, {"xpath": "/ftyp"}, {"xpath": "/mfra"}, {"xpath": "/emsg", "data": [{"offset": 12, "value": VSI_MARK}]}, ] def bmff_hash(b, top): """§4.5: SHA-256 over each top-level box no exclusion matches, as its u64 offset, then its bytes.""" h = hashlib.sha256() for ty, s, e in top: excluded = False for x in PROFILE_EXCLUSIONS: if x["xpath"] != "/" + ty.decode("latin-1"): continue if all(s + d["offset"] + len(d["value"]) <= e and b[s + d["offset"]:s + d["offset"] + len(d["value"])] == d["value"] for d in x.get("data", [])): excluded = True if not excluded: h.update(struct.pack(">Q", s)) h.update(b[s:e]) return h.digest() def children(b, r, skip=0): if r[2] - r[1] < 8 + skip: return None return boxes(b, r[1] + 8 + skip, r[2]) def read_chunk(b, moof, mdat): """§3.2's chunk: moof (mfhd, traf: tfhd, tfdt, trun) then mdat; the trun's data at the mdat's payload.""" if moof[0] != b"moof" or mdat[0] != b"mdat" or mdat[1] != moof[2]: return None mc = children(b, moof) if mc is None or [c[0] for c in mc] != [b"mfhd", b"traf"]: return None mfhd, traf = mc if full(b, mfhd) != (0, 0) or mfhd[2] - mfhd[1] != 16: return None seq = struct.unpack(">I", b[mfhd[1] + 12:mfhd[1] + 16])[0] tc = children(b, traf) if tc is None or [c[0] for c in tc] != [b"tfhd", b"tfdt", b"trun"]: return None tfhd, tfdt, trun = tc if full(b, tfhd) != (0, 0x020000) or tfhd[2] - tfhd[1] != 16: return None track_id = struct.unpack(">I", b[tfhd[1] + 12:tfhd[1] + 16])[0] if full(b, tfdt) != (1, 0) or tfdt[2] - tfdt[1] != 20: return None bmdt = struct.unpack(">Q", b[tfdt[1] + 12:tfdt[1] + 20])[0] if full(b, trun) != (0, 0x000701) or trun[2] - trun[1] < 20: return None count = struct.unpack(">I", b[trun[1] + 12:trun[1] + 16])[0] if trun[2] - trun[1] != 20 + 12 * count: return None offset = struct.unpack(">i", b[trun[1] + 16:trun[1] + 20])[0] if offset != moof[2] - moof[1] + 8: return None dur = size = 0 for i in range(count): d, s, _ = struct.unpack(">III", b[trun[1] + 20 + 12 * i:trun[1] + 32 + 12 * i]) dur, size = dur + d, size + s if size != mdat[2] - mdat[1] - 8: return None return dict(seq=seq, track_id=track_id, bmdt=bmdt, duration=dur) def read_moov(b, moov): mc = children(b, moov) if mc is None: return None traks = [c for c in mc if c[0] == b"trak"] mvex = [c for c in mc if c[0] == b"mvex"] if len(traks) != 1 or not mvex: return None mv = children(b, mvex[0]) if mv is None or not any(c[0] == b"trex" for c in mv): return None tc = children(b, traks[0]) if tc is None: return None tkhd = [c for c in tc if c[0] == b"tkhd"] mdia = [c for c in tc if c[0] == b"mdia"] if not tkhd or not mdia: return None md = children(b, mdia[0]) mdhd = [c for c in (md or []) if c[0] == b"mdhd"] if not mdhd: return None def field(r): vf = full(b, r) if vf is None or vf[0] not in (0, 1): return None at = 20 if vf[0] == 0 else 28 if r[2] - r[1] < at + 4: return None return struct.unpack(">I", b[r[1] + at:r[1] + at + 4])[0] track_id, timescale = field(tkhd[0]), field(mdhd[0]) if track_id is None or not timescale: return None return dict(track_id=track_id, timescale=timescale) # ───────────────────────────── JUMBF (§4.1) ───────────────────────────── def read_super(b, r): if r[0] != b"jumb": return None inner = boxes(b, r[1] + 8, r[2]) if not inner or inner[0][0] != b"jumd": return None d = b[inner[0][1] + 8:inner[0][2]] if len(d) < 18 or d[16] != 0x03 or d[-1] != 0: return None label = d[17:-1] if not label or 0 in label: return None try: label = label.decode("utf-8") except UnicodeDecodeError: return None return dict(type=d[:16], label=label, content=inner[1:]) def one_cbor(b, s, ty=b"cbor"): if len(s["content"]) != 1 or s["content"][0][0] != ty: return None c = s["content"][0] return b[c[1] + 8:c[2]] def read_store(b): top = boxes(b, 0, len(b)) if not top or len(top) != 1: return None store = read_super(b, top[0]) if not store or store["type"] != T_STORE or store["label"] != "c2pa" or len(store["content"]) != 1: return None mbox = store["content"][0] man = read_super(b, mbox) if not man or man["type"] not in (T_MANIFEST, T_UPDATE) or len(man["content"]) != 3: return None parts = [read_super(b, r) for r in man["content"]] want = [(T_ASSERTIONS, "c2pa.assertions"), (T_CLAIM, "c2pa.claim.v2"), (T_SIG, "c2pa.signature")] if any(p is None or (p["type"], p["label"]) != w for p, w in zip(parts, want)): return None claim, sig = one_cbor(b, parts[1]), one_cbor(b, parts[2]) if claim is None or sig is None: return None assertions = [] for r in parts[0]["content"]: a = read_super(b, r) if not a or a["type"] not in (T_CBOR, T_JSON): return None form = "cbor" if a["type"] == T_CBOR else "json" c = one_cbor(b, a, form.encode()) if c is None: return None assertions.append(dict(label=a["label"], form=form, cbor=c, hash=hashlib.sha256(b[r[1] + 8:r[2]]).digest())) sbox = man["content"][2] return dict(type=man["type"], id=man["label"], assertions=assertions, claim=claim, signature=sig, manifest_hash=hashlib.sha256(b[mbox[1] + 8:mbox[2]]).digest(), signature_hash=hashlib.sha256(b[sbox[1] + 8:sbox[2]]).digest()) # ───────────────────────────── COSE (§4.3, §7) ───────────────────────────── def read_sign1(item): """A COSE_Sign1_Tagged item: protected map with alg EdDSA (-8), unprotected map, payload or nil, 64-byte signature.""" if not isinstance(item, Tag) or item.tag != 18 or not isinstance(item.value, list) or len(item.value) != 4: return None p, u, payload, sig = item.value if not isinstance(p, bytes) or not isinstance(u, dict) or not isinstance(sig, bytes) or len(sig) != 64: return None if not (payload is None or isinstance(payload, bytes)): return None try: ph = cbor_read(p) except CborError: return None if not isinstance(ph, dict) or ph.get(1) != -8 or 1 in u: return None return dict(pbytes=p, protected=ph, unprotected=u, payload=payload, sig=sig) def cose_verifies(s, key, signed): return ED.verify(key, sig_structure(s["pbytes"], signed), s["sig"]) is None def read_okp(k): """§7.1's OKP COSE_Key: {1: 1, 2: kid, -1: 6, -2: x}, kid equal to x.""" if not isinstance(k, dict) or len(k) != 4 or set(k) != {1, 2, -1, -2}: return None if k[1] != 1 or k[-1] != 6 or not b32(k[-2]) or k[2] != k[-2]: return None return k[-2] # ───────────────────────────── X.509 (§4.4, §11.2) ───────────────────────────── def der(b): """(tag, content, whole, rest) of the first element, DER lengths only.""" if len(b) < 2 or b[0] & 0x1F == 0x1F: return None l0 = b[1] if l0 < 0x80: n, hdr = l0, 2 else: k = l0 & 0x7F if k == 0 or k > 4 or len(b) < 2 + k or b[2] == 0: return None n = int.from_bytes(b[2:2 + k], "big") if n < 0x80: return None hdr = 2 + k if len(b) < hdr + n: return None return b[0], b[hdr:hdr + n], b[:hdr + n], b[hdr + n:] def der_items(b): out = [] while b: e = der(b) if e is None: return None out.append(e) b = e[3] return out def der_only(b, tag): e = der(b) if e is None or e[3] or e[0] != tag: return None return e def read_time(e): try: s = e[1].decode("ascii") except UnicodeDecodeError: return None if e[0] == 0x17 and len(s) == 13: yy = int(s[:2]) if s[:2].isdigit() else -1 if yy < 0: return None y, rest = (1900 + yy if yy >= 50 else 2000 + yy), s[2:] elif e[0] == 0x18 and len(s) == 15: if not s[:4].isdigit(): return None y, rest = int(s[:4]), s[4:] else: return None if not rest.endswith("Z") or not rest[:-1].isdigit(): return None mo, d, h, mi, se = (int(rest[i:i + 2]) for i in range(0, 10, 2)) if not (1 <= mo <= 12 and 1 <= d <= 31 and h <= 23 and mi <= 59 and se <= 59): return None return unix(y, mo, d, h, mi, se) def unix(y, mo, d, h, mi, se): # Days from the civil date, proleptic Gregorian, as calendar.timegm counts them without range limits. yy = y - 1 if mo <= 2 else y era = yy // 400 yoe = yy - era * 400 doy = (153 * (mo - 3 if mo > 2 else mo + 9) + 2) // 5 + d - 1 doe = yoe * 365 + yoe // 4 - yoe // 100 + doy return (era * 146097 + doe - 719468) * 86400 + h * 3600 + mi * 60 + se def read_cert(b): top = der_only(b, 0x30) if top is None: return None parts = der_items(top[1]) if parts is None or len(parts) != 3: return None tbs, alg, bits = parts if tbs[0] != 0x30 or alg[0] != 0x30 or bits[0] != 0x03: return None t = der_items(tbs[1]) if not t: return None version, i = 0, 0 if t[0][0] == 0xA0: v = der_only(t[0][1], 0x02) if v is None: return None version, i = int.from_bytes(v[1], "big"), 1 if len(t) < i + 6: return None serial, tbs_alg, issuer, validity, subject, spki = t[i:i + 6] if [x[0] for x in (serial, tbs_alg, issuer, validity, subject, spki)] != [0x02, 0x30, 0x30, 0x30, 0x30, 0x30]: return None v = der_items(validity[1]) sp = der_items(spki[1]) if not v or len(v) != 2 or not sp or len(sp) != 2: return None if sp[0][2] != ED25519_ALG or sp[1][0] != 0x03 or len(sp[1][1]) != 33 or sp[1][1][0] != 0: return None unique, exts = False, [] for e in t[i + 6:]: if e[0] in (0x81, 0x82): unique = True elif e[0] == 0xA3: s = der_only(e[1], 0x30) xs = der_items(s[1]) if s else None if xs is None: return None for x in xs: if x[0] != 0x30: return None f = der_items(x[1]) if f and len(f) == 2: o, crit, val = f[0], False, f[1] elif f and len(f) == 3 and f[1][0] == 0x01 and f[1][1] == b"\xff": o, crit, val = f[0], True, f[2] else: return None if o[0] != 0x06 or val[0] != 0x04: return None exts.append((o[1], crit, val[1])) else: return None if len(bits[1]) != 65 or bits[1][0] != 0: return None nb, na = read_time(v[0]), read_time(v[1]) if nb is None or na is None: return None return dict(tbs=tbs[2], version=version, issuer=issuer[2], subject=subject[2], nb=nb, na=na, key=sp[1][1][1:], unique=unique, ed=tbs_alg[2] == ED25519_ALG and alg[2] == ED25519_ALG, exts=exts, sig=bits[1][1:]) def ext(c, o): for x in c["exts"]: if x[0] == o: return x return None def ski(c): x = ext(c, OID_SKI) e = der_only(x[2], 0x04) if x else None return e[1] if e else None def aki(c): x = ext(c, OID_AKI) s = der_only(x[2], 0x30) if x else None f = der_items(s[1]) if s else None if f is None: return None for e in f: if e[0] == 0x80: return e[1] return None def profile_ok(c, ca, purpose=None): """§4.4 and C2PA 2.4 §14.5.1.1, for the end entity (ca False) or an intermediate; `purpose` the end entity's EKU in the place of c2pa-kp-claimSigning (c2pa-binding §4.10).""" known = {OID_BC, OID_KU, OID_EKU, OID_SKI, OID_AKI} seen = set() for o, crit, _ in c["exts"]: if o in seen or (crit and o not in known): return False seen.add(o) if c["version"] != 2 or not c["ed"] or c["unique"] or aki(c) is None: return False bc = ext(c, OID_BC) is_ca = False if bc: s = der_only(bc[2], 0x30) if s is None: return False f = der_items(s[1]) if f is None: return False is_ca = bool(f) and f[0][0] == 0x01 and f[0][1] == b"\xff" ku = ext(c, OID_KU) kb = der_only(ku[2], 0x03) if ku else None if kb is None or not kb[1] or len(kb[1]) > 3 or kb[1][0] > 7: return False first = kb[1][1] if len(kb[1]) > 1 else 0 digital_signature, key_cert_sign = bool(first & 0x80), bool(first & 0x04) if ca: return is_ca and key_cert_sign and ski(c) is not None if is_ca or not digital_signature or key_cert_sign: return False e = ext(c, OID_EKU) s = der_only(e[2], 0x30) if e else None f = der_items(s[1]) if s else None if f is None or any(x[0] != 0x06 for x in f): return False ekus = {x[1] for x in f} return (bool(ekus) and EKU_ANY not in ekus and (purpose or EKU_CLAIM) in ekus and EKU_TSA not in ekus and EKU_OCSP not in ekus) def issued_by(c, issuer): a = aki(c) s = ski(issuer) return (c["issuer"] == issuer["subject"] and a is not None and (s is None or s == a) and ED.verify(issuer["key"], c["tbs"], c["sig"]) is None) # ───────────────────────────── receipts (jwp-receipts §2, §3) ───────────────────────────── def blake3(*parts): return B3.blake3(b"".join(parts)) GROUP_FIELDS = ["kind", "track_alias", "group_id", "root_hash", "leaf_count", "joules_total", "group_start", "group_end", "signer_pubkey", "signer_id", "sig", "parent_receipt_hash"] def group_payload(g): """jwp-receipts §2.3's legacy signing payload.""" p = b"moq-jwp:group-sign\x01" + struct.pack(">QQ", g["track_alias"], g["group_id"]) + bytes.fromhex(g["root_hash"]) p += struct.pack(">QQ", g["leaf_count"], g["joules_total"]) for s in (g["group_start"], g["group_end"]): s = s.encode() p += struct.pack(">I", len(s)) + s sid = g["signer_id"].encode() return p + bytes.fromhex(g["signer_pubkey"]) + struct.pack(">I", len(sid)) + sid def link_hash(g): return blake3(b"moq-jwp:receipt-link\x01", group_payload(g), bytes.fromhex(g["sig"])) def group_ok(g): """§3 steps 1 and 2 for a group receipt of the legacy form, which this profile uses.""" if set(g) != set(GROUP_FIELDS) or g["kind"] != "group": return False return ED.verify(bytes.fromhex(g["signer_pubkey"]), group_payload(g), bytes.fromhex(g["sig"])) is None def leaf(content): return blake3(b"moq-jwp:merkle-leaf\x01", content) def node(left, right): return blake3(b"moq-jwp:merkle-node\x01", left, right) def merkle_root(leaves): if not leaves: return bytes(32) level = list(leaves) while len(level) > 1: if len(level) % 2: level.append(level[-1]) level = [node(level[i], level[i + 1]) for i in range(0, len(level), 2)] return level[0] def object_accepts(o, g, chunk): """jwp-receipts §3 steps 1 to 5: the group verifies, the chunk hashes to the content hash, the object stands at its own position, and its leaf walks to the root.""" if not group_ok(g) or o.get("kind") != "object" or "leaf_version" in o: return False if bytes.fromhex(o["content_hash"]) != blake3(b"moq-jwp:object\x01", chunk): return False p = o["merkle_proof"] n = g["leaf_count"] depth = 0 if n <= 1 else (n - 1).bit_length() if (o["track_alias"], o["group_id"]) != (g["track_alias"], g["group_id"]) or o["object_id"] != p["leaf_index"]: return False if not (p["leaf_index"] < n and len(p["siblings"]) == depth and p["direction_bits"] == p["leaf_index"]): return False cur = leaf(bytes.fromhex(o["content_hash"])) for i, s in enumerate(p["siblings"]): s = bytes.fromhex(s) cur = node(s, cur) if (p["direction_bits"] >> i) & 1 else node(cur, s) return cur == bytes.fromhex(g["root_hash"]) # ───────────────────────────── the assertions ───────────────────────────── def hexs(b): return b.hex() def uuid_text(b): h = b.hex() return "%s-%s-%s-%s-%s" % (h[:8], h[8:12], h[12:16], h[16:20], h[20:]) def uuid_v8(b16): b = bytearray(b16) b[6] = (b[6] & 0x0F) | 0x80 b[8] = (b[8] & 0x3F) | 0x80 return uuid_text(bytes(b)) def manifest_uuid(s): if not isinstance(s, str) or not s.startswith("urn:c2pa:"): return None u = s[len("urn:c2pa:"):] if not re.fullmatch(r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", u): return None return u def track_kind(track, room): """The media kind of `----` (live-rooms §6.1) whose room is `room`.""" if not isinstance(track, str) or "--" not in track: return None ns, layer = track.split("--", 1) if not re.fullmatch(r"0|[1-9][0-9]*", layer): return None f = ns.split("-") if len(f) != 3 or f[0] != room.hex(): return None if not (re.fullmatch(r"[0-9a-f]{64}", f[1]) or re.fullmatch(r"_[a-z0-9_]+", f[1])): return None return f[2] if f[2] in ("audio", "video", "screen") else None def read_origin(m): o = m.get("origin") if not uint(o): return None model = m.get("model") if "model" in m and not b32(model): return None if o == 1 and model is None: return (1, None) if o in (2, 3) and model is not None: return (o, model) return None def read_label(m): if is_map(m, ["inputs"]): v = m["inputs"] if not isinstance(v, list) or not v: return None out = [] for i in v: if not is_map(i, ["streamId", "track", "origin"], ["model"]): return None om = read_origin(i) if om is None or not isinstance(i["streamId"], str) or not isinstance(i["track"], str): return None out.append(dict(streamId=i["streamId"], track=i["track"], origin=om[0], model=om[1])) return ("inputs", out) if not is_map(m, ["origin"], ["model"]): return None om = read_origin(m) return None if om is None else ("own", om) def read_stream(m): """§5.1's stream assertion.""" if not is_map(m, ["room", "track", "trackAlias", "epoch", "firstGroup", "label"]): return None if not (b32(m["room"]) and isinstance(m["track"], str) and uint(m["trackAlias"]) and uint(m["epoch"]) and uint(m["firstGroup"])): return None label = read_label(m["label"]) if label is None or m["trackAlias"] >= 1 << 53 or track_kind(m["track"], m["room"]) is None: return None if label[0] == "inputs" and any(track_kind(i["track"], m["room"]) is None for i in label[1]): return None return dict(m, label=label) def stream_id_of(sa): """§3.4.""" t = sa["track"].encode() h = hashlib.sha256(STREAM_DOMAIN + sa["room"] + struct.pack(">I", len(t)) + t + struct.pack(">Q", sa["epoch"])).digest() return uuid_v8(h[:16]) def read_take(m, init): """§5.3's take assertion: epoch and no commit in the initialization manifest, commit and no epoch later.""" need = ["take", "room", "device", "epoch"] if init else ["take", "room", "device", "commit"] if not is_map(m, need): return None if not all(b32(m[k]) for k in ("take", "room", "device")): return None if init and not uint(m["epoch"]): return None if not init and not b32(m["commit"]): return None return m def disclosure(kind, ident): """§9.1 and §9.2: the IPTC term and the AI disclosures (model, fully autonomous).""" if kind == "take": return "digitalCapture", [] def models(pairs): out = [] for o, m in pairs: if m is None: continue for x in out: if x[0] == m: x[1] = x[1] or o == 2 break else: out.append([m, o == 2]) return [tuple(x) for x in out] label = ident["label"] if label[0] == "own": o, m = label[1] k = track_kind(ident["track"], ident["room"]) term = ({1: "screenCapture" if k == "screen" else "digitalCapture", 2: "trainedAlgorithmicMedia"} .get(o, "compositeWithTrainedAlgorithmicMedia")) return term, models([(o, m)]) inputs = label[1] if all(i["origin"] == 1 and track_kind(i["track"], ident["room"]) in ("audio", "video") for i in inputs): term = "compositeCapture" elif any(i["origin"] != 1 for i in inputs): term = "compositeSynthetic" else: term = "composite" return term, models([(i["origin"], i["model"]) for i in inputs]) def ai_label(i): return L_AI if i == 0 else "%s__%d" % (L_AI, i) def read_actions(m): if not is_map(m, ["actions"]) or not isinstance(m["actions"], list) or len(m["actions"]) != 1: return None a = m["actions"][0] if not is_map(a, ["action", "digitalSourceType"]) or a["action"] != "c2pa.created": return None d = a["digitalSourceType"] if not isinstance(d, str) or not d.startswith(DST_PREFIX): return None return d[len(DST_PREFIX):] def read_ai(m): if not is_map(m, ["modelType", "modelIdentifier", "contentProfile"]) or m["modelType"] != "c2pa.types.model": return None cp = m["contentProfile"] if not is_map(cp, ["humanOversightLevel"]): return None if not isinstance(m["modelIdentifier"], str) or not isinstance(cp["humanOversightLevel"], str): return None return (m["modelIdentifier"], cp["humanOversightLevel"]) def read_hash_item(m, segment): keys = ["exclusions", "alg", "hash"] + (["sequenceNumber"] if segment else []) if not is_map(m, keys) or m["alg"] != "sha256" or m["exclusions"] != PROFILE_EXCLUSIONS or not b32(m["hash"]): return None if segment and not uint(m["sequenceNumber"]): return None return m["hash"], (m["sequenceNumber"] if segment else None) def read_live(m): if not is_map(m, ["sequenceNumber", "streamId"], ["continuityMethod", "previousManifestId"]): return None if not uint(m["sequenceNumber"]) or not isinstance(m["streamId"], str): return None for k in ("continuityMethod", "previousManifestId"): if k in m and not isinstance(m[k], str): return None return m def read_group(m): if not is_map(m, ["groupId", "receipt", "root", "objects"]): return None if not (uint(m["groupId"]) and b32(m["receipt"]) and b32(m["root"]) and uint(m["objects"])): return None return m def shape_of(labels, init, kind): """§11.2 step 7: which list a manifest is held to.""" if len(labels) < 3 or labels[0] != L_HASH or labels[1] != L_ACTIONS: return None rest = labels[2:] if init: shape = "init" if rest[0] == L_KEYS: rest = rest[1:] elif rest[0] == L_LIVE: shape, rest = "per-segment", rest[1:] elif rest[0] == L_KEYS: shape, rest = "rotation", rest[1:] else: return None if not rest: return None ai, last = rest[:-1], rest[-1] if any(l != ai_label(i) for i, l in enumerate(ai)): return None want = L_GROUP if (shape == "per-segment" and kind == "track") else (L_TAKE if kind == "take" else L_STREAM) return shape if last == want else None # ───────────────────────────── §11.2: a manifest ───────────────────────────── def verify_claim(store, anchors, t_us): """§11.2 steps 1 (the claim and its signature's form) to 6. The labels in the claim's order and the end-entity certificate; raises the reason as a string.""" mid = store["id"] u = manifest_uuid(mid) if u is None: raise ValueError("manifest_malformed") try: claim = cbor_read(store["claim"]) except CborError: raise ValueError("manifest_malformed") if (not is_map(claim, ["instanceID", "claim_generator_info", "signature", "created_assertions", "alg"]) or claim["instanceID"] != "xmp:iid:" + u or claim["signature"] != "self#jumbf=/c2pa/%s/c2pa.signature" % mid or claim["alg"] != "sha256"): raise ValueError("manifest_malformed") cgi = claim["claim_generator_info"] if not is_map(cgi, ["name", "specVersion"]) or not isinstance(cgi["name"], str) or not isinstance(cgi["specVersion"], str): raise ValueError("manifest_malformed") refs = claim["created_assertions"] if not isinstance(refs, list): raise ValueError("manifest_malformed") try: s = read_sign1(cbor_read(store["signature"])) except CborError: s = None if s is None or s["payload"] is not None or s["unprotected"] != {} or len(s["protected"]) != 2: raise ValueError("manifest_malformed") x5 = s["protected"].get(33) if isinstance(x5, list) and x5 and all(isinstance(c, bytes) for c in x5): chain_der = x5 elif isinstance(x5, bytes): chain_der = [x5] else: raise ValueError("manifest_malformed") chain = [read_cert(d) for d in chain_der] if any(c is None for c in chain) or not profile_ok(chain[0], False) or not all(profile_ok(c, True) for c in chain[1:]): raise ValueError("credential_invalid") if any(not issued_by(chain[i], chain[i + 1]) for i in range(len(chain) - 1)): raise ValueError("credential_untrusted") anchor = next((a for a in anchors if a is not None and issued_by(chain[-1], a)), None) if anchor is None: raise ValueError("credential_untrusted") if not cose_verifies(s, chain[0]["key"], store["claim"]): raise ValueError("claim_signature_mismatch") if not all(c["nb"] * 1000000 <= t_us <= c["na"] * 1000000 for c in chain + [anchor]): raise ValueError("credential_expired") prefix = "self#jumbf=/c2pa/%s/c2pa.assertions/" % mid labels = [] for i, rf in enumerate(refs): if not is_map(rf, ["url", "hash"]) or not isinstance(rf["url"], str) or not rf["url"].startswith(prefix) or not b32(rf["hash"]): raise ValueError("manifest_malformed") label = rf["url"][len(prefix):] held = [a for a in store["assertions"] if a["label"] == label] if len(held) != 1 or i >= len(store["assertions"]) or store["assertions"][i]["label"] != label: raise ValueError("manifest_malformed") if held[0]["hash"] != rf["hash"]: raise ValueError("assertion_hash_mismatch") labels.append(label) if len(labels) != len(store["assertions"]): raise ValueError("manifest_malformed") return labels, chain_der[0] def verify_manifest(asset, top, store_bytes, init, kind, anchors, t_us): """§11.2 for a standard manifest. Raises the reason as a string; returns the manifest's identifier, shape, assertions, end-entity cert and store.""" store = read_store(store_bytes or b"") if store is None or store["type"] != T_MANIFEST or any(a["form"] != "cbor" for a in store["assertions"]): raise ValueError("manifest_malformed") labels, ee = verify_claim(store, anchors, t_us) shape = shape_of(labels, init, kind) if shape is None: raise ValueError("manifest_malformed") items = [] for a in store["assertions"]: try: items.append((a["label"], cbor_read(a["cbor"]))) except CborError: raise ValueError("manifest_malformed") hb = read_hash_item(items[0][1], not init) if hb is None or read_actions(items[1][1]) is None: raise ValueError("manifest_malformed") for label, item in items[2:]: if label == L_LIVE: ok = read_live(item) is not None elif label == L_KEYS: ok = is_map(item, ["keys"]) and isinstance(item["keys"], list) and bool(item["keys"]) elif label == L_GROUP: ok = read_group(item) is not None elif label in (L_STREAM, L_TAKE): ok = isinstance(item, dict) else: ok = read_ai(item) is not None if not ok: raise ValueError("manifest_malformed") if bmff_hash(asset, top) != hb[0]: raise ValueError("hard_binding_mismatch") return dict(id=store["id"], shape=shape, items=dict(items), order=items, ee=ee, seq=hb[1], store=store) def derived_id(stream_id, role, seq): """§4.2: the identifier of the manifest of `role` for sequence number `seq`.""" sid = stream_id.encode() h = bytearray(hashlib.sha256(MANIFEST_DOMAIN + struct.pack(">I", len(sid)) + sid + bytes([role]) + struct.pack(">Q", seq)).digest()[:16]) h[6] = (h[6] & 0x0F) | 0x40 h[8] = (h[8] & 0x3F) | 0x80 return "urn:c2pa:" + uuid_text(bytes(h)) # ───────────────────────────── the repository receipt (§4.7) ───────────────────────────── def log_leaf(entry): return hashlib.sha256(b"\x00" + entry).digest() def log_node(left, right): return hashlib.sha256(b"\x01" + left + right).digest() def log_root(entry, index, size, path): """RFC 9162 §2.1.3.2.""" if index >= size: return None fn, sn, r = index, size - 1, log_leaf(entry) for p in path: if sn == 0: return None if fn & 1 or fn == sn: r = log_node(p, r) while not fn & 1 and fn != 0: fn >>= 1 sn >>= 1 else: r = log_node(r, p) fn >>= 1 sn >>= 1 return r if sn == 0 else None def verify_inclusion_receipt(receipt, entry, key): """RFC 9942 §5.2.1: protected {1: -8, 395: 1}; unprotected {396: {-1: [proof]}}; detached payload; the proof leads from the entry to a root under which the key's signature verifies. The tree size and the leaf index, or None.""" try: s = read_sign1(cbor_read(receipt)) except CborError: return None if s is None or s["protected"] != {1: -8, 395: 1} or s["payload"] is not None or set(s["unprotected"]) != {396}: return None vdp = s["unprotected"][396] if not isinstance(vdp, dict) or set(vdp) != {-1} or not isinstance(vdp[-1], list) or len(vdp[-1]) != 1: return None if not isinstance(vdp[-1][0], bytes): return None try: c = cbor_read(vdp[-1][0]) except CborError: return None if not isinstance(c, list) or len(c) != 3 or not uint(c[0]) or not uint(c[1]) or not isinstance(c[2], list) or not c[2]: return None if not all(b32(h) for h in c[2]): return None root = log_root(entry, c[1], c[0], c[2]) if root is None or not cose_verifies(s, key, root): return None return c[0], c[1] def verify_update(store_bytes, original, stream_id, anchors, repositories, t_us): """§11.3 step 6: the update manifest over `original` and its repository receipt.""" store = read_store(store_bytes or b"") if store is None or store["type"] != T_UPDATE: raise ValueError("update_manifest_invalid") try: labels, _ = verify_claim(store, anchors, t_us) except ValueError as e: raise ValueError("update_manifest_invalid" if str(e) == "manifest_malformed" else str(e)) if store["id"] != derived_id(stream_id, ROLE_UPDATE, 0): raise ValueError("manifest_id_mismatch") if labels != [L_INGREDIENT, L_REPOSITORY] or [a["form"] for a in store["assertions"]] != ["cbor", "json"]: raise ValueError("update_manifest_invalid") sig = "self#jumbf=/c2pa/%s/c2pa.signature" % original["id"] labels0 = [a["label"] for a in original["assertions"]] success = [{"code": c, "url": sig} for c in ("claimSignature.validated", "signingCredential.trusted", "claimSignature.insideValidity")] success += [{"code": "assertion.hashedURI.match", "url": "self#jumbf=/c2pa/%s/c2pa.assertions/%s" % (original["id"], l)} for l in labels0] success += [{"code": "assertion.bmffHash.match", "url": "self#jumbf=/c2pa/%s/c2pa.assertions/%s" % (original["id"], labels0[0])}] want = { "relationship": "parentOf", "validationResults": {"activeManifest": {"success": success, "informational": [], "failure": []}, "ingredientDeltas": [], "specVersion": "2.4.0"}, "activeManifest": {"url": "self#jumbf=/c2pa/" + original["id"], "hash": original["manifest_hash"]}, "claimSignature": {"url": "self#jumbf=/c2pa/%s/c2pa.signature" % original["id"], "hash": original["signature_hash"]}, } try: if cbor_read(store["assertions"][0]["cbor"]) != want: raise ValueError("update_manifest_invalid") v = json.loads(store["assertions"][1]["cbor"]) except (CborError, ValueError): raise ValueError("update_manifest_invalid") def jkeys(o, required, optional=()): return isinstance(o, dict) and all(k in required or k in optional for k in o) and all(k in o for k in required) ok = (jkeys(v, ["@context", "@type", "repository", "anchor"]) and isinstance(v["@context"], (dict, str)) and v["@type"] == "org.c2pa.repository-receipt" and jkeys(v["repository"], ["uri", "manifestId"]) and jkeys(v["anchor"], ["uri", "proof"], ["parameters"]) and jkeys(v["anchor"]["proof"], ["profile", "entry", "receipt"]) and isinstance(v["anchor"]["uri"], str) and v["repository"]["manifestId"] == original["id"] and v["anchor"]["proof"]["profile"] == "urn:ietf:rfc:9942" and v["anchor"]["proof"]["entry"] == original["manifest_hash"].hex() and isinstance(v["repository"]["uri"], str) and isinstance(v["anchor"]["proof"]["receipt"], str)) if not ok: raise ValueError("update_manifest_invalid") r64 = v["anchor"]["proof"]["receipt"] if not re.fullmatch(r"[A-Za-z0-9_-]*", r64) or len(r64) % 4 == 1: raise ValueError("update_manifest_invalid") receipt = base64.urlsafe_b64decode(r64 + "=" * (-len(r64) % 4)) key = repositories.get(v["repository"]["uri"]) if key is None: raise ValueError("repository_receipt_invalid") got = verify_inclusion_receipt(receipt, original["manifest_hash"], key) if got is None: raise ValueError("repository_receipt_invalid") return {"repository": v["repository"]["uri"], "treeSize": got[0], "leafIndex": got[1]} def check_label(m, kind, ident): """§9.3.""" term, models = disclosure(kind, ident) if read_actions(m["items"][L_ACTIONS]) != term: raise ValueError("source_type_mismatch") held = [read_ai(i) for l, i in m["order"] if l.startswith(L_AI)] want = [(MODEL_URI + mm.hex(), "fully_autonomous" if auto else "human_validated") for mm, auto in models] if held != want: raise ValueError("ai_disclosure_missing") def rfc3339(s): if not isinstance(s, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", s): return None y, mo, d, h, mi, se = int(s[0:4]), int(s[5:7]), int(s[8:10]), int(s[11:13]), int(s[14:16]), int(s[17:19]) if not (1 <= mo <= 12 and 1 <= d <= 31 and h <= 23 and mi <= 59 and se <= 59): return None try: t = calendar.timegm((y, mo, d, h, mi, se, 0, 0, 0)) except (OverflowError, ValueError): return None back = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t)) return t if back == s and t >= 0 else None def read_keys(m, device): """§11.3 step 5: the session keys a manifest delivers.""" item = m["items"].get(L_KEYS) if item is None: return [] out = [] for k in item["keys"]: if not is_map(k, ["key", "minSequenceNumber", "createdAt", "validityPeriod", "signerBinding"]): raise ValueError("session_key_invalid") x = read_okp(k["key"]) ca = k["createdAt"] created = rfc3339(ca.value) if isinstance(ca, Tag) and ca.tag == 0 else None if x is None or not uint(k["minSequenceNumber"]) or created is None or not uint(k["validityPeriod"]): raise ValueError("session_key_invalid") b = read_sign1(k["signerBinding"]) if (b is None or len(b["protected"]) != 1 or b["payload"] is not None or b["unprotected"] != {} or not cose_verifies(b, x, m["ee"])): raise ValueError("session_key_invalid") if (device is not None and device != x) or any(h["x"] == x for h in out): raise ValueError("session_key_invalid") out.append(dict(x=x, min=k["minSequenceNumber"], frm=created * 1000000, to=(created + k["validityPeriod"]) * 1000000)) return out # ───────────────────────────── §11: a stream ───────────────────────────── def verify_stream(kind, init, segments, groups, objects, anchors, repositories, t_us): """Returns §11.6's statement, or raises Refuse.""" def refuse(reason, at): raise Refuse(reason, at) top = boxes(init, 0, len(init)) if top is None: refuse("segment_malformed", "init") if any(r[0] == b"mdat" for r in top): refuse("init_invalid", "init") if (len(top) not in (3, 4) or top[0][0] != b"ftyp" or not is_c2pa_uuid(init, top[1]) or top[2][0] != b"moov" or (len(top) == 4 and not is_c2pa_uuid(init, top[3]))): refuse("init_invalid", "init") update = top[3] if len(top) == 4 else None track = read_moov(init, top[2]) if track is None: refuse("init_invalid", "init") store0 = manifest_store_bytes(init, top[1], b"original" if update else b"manifest") if store0 is None: refuse("manifest_malformed", "init") try: m0 = verify_manifest(init, top, store0, True, kind, anchors, t_us) except ValueError as e: refuse(str(e), "init") if kind == "track": ident = read_stream(m0["items"][L_STREAM]) if ident is None: refuse("stream_assertion_invalid", "init") stream_id, epoch, first_group, device = stream_id_of(ident), ident["epoch"], ident["firstGroup"], None else: ident = read_take(m0["items"][L_TAKE], True) if ident is None: refuse("stream_assertion_invalid", "init") stream_id, epoch, first_group, device = uuid_v8(ident["take"][:16]), ident["epoch"], 0, ident["device"] if m0["id"] != derived_id(stream_id, ROLE_INIT, 0): refuse("manifest_id_mismatch", "init") registration = None try: check_label(m0, kind, ident) held_keys = read_keys(m0, device) if update: ustore = manifest_store_bytes(init, update, b"update") if ustore is None: raise ValueError("update_manifest_invalid") registration = verify_update(ustore, m0["store"], stream_id, anchors, repositories, t_us) except ValueError as e: refuse(str(e), "init") prev_seq = prev_group = None prev_last = 0 prev_manifest = active = m0["id"] seen, out = [], [] for k, (b, g) in enumerate(segments): at = "segment %d" % k top = boxes(b, 0, len(b)) if top is None: refuse("segment_malformed", at) i = 0 mbox = top[0] if top and is_c2pa_uuid(b, top[0]) else None if mbox: i += 1 vbox = top[i] if len(top) > i and is_vsi(b, top[i]) else None if vbox: i += 1 rest = top[i:] if not rest or len(rest) % 2: refuse("segment_malformed", at) chunks, infos = [], [] for j in range(0, len(rest), 2): info = read_chunk(b, rest[j], rest[j + 1]) if info is None: refuse("segment_malformed", at) infos.append(info) chunks.append(b[rest[j][1]:rest[j + 1][2]]) if i == 0: refuse("provenance_missing", at) if any(x["track_id"] != track["track_id"] for x in infos) or any( infos[j + 1]["seq"] != infos[j]["seq"] + 1 for j in range(len(infos) - 1)): refuse("segment_malformed", at) seq, last, n = infos[0]["seq"], infos[-1]["seq"], len(chunks) if (prev_seq is not None and seq <= prev_seq) or ( kind == "track" and prev_group is not None and (g is None or g <= prev_group)): refuse("sequence_not_increasing", at) # Step 3. per = None if mbox: try: m = verify_manifest(b, top, manifest_store_bytes(b, mbox), False, kind, anchors, t_us) except ValueError as e: refuse(str(e), at) role = ROLE_SEGMENT if m["shape"] == "per-segment" else ROLE_ROTATION if m["id"] != derived_id(stream_id, role, seq): refuse("manifest_id_mismatch", at) if m["seq"] != seq: refuse("sequence_mismatch", at) if m["shape"] == "per-segment": lv = m["items"][L_LIVE] if lv["sequenceNumber"] != seq: refuse("sequence_mismatch", at) if lv["streamId"] != stream_id: refuse("stream_id_mismatch", at) if lv.get("continuityMethod") != "c2pa.manifestId" or "previousManifestId" not in lv: refuse("continuity_invalid", at) if lv["previousManifestId"] != prev_manifest: refuse("continuity_broken", at) if kind == "take": t = read_take(m["items"][L_TAKE], False) if t is None or (t["take"], t["room"], t["device"]) != (ident["take"], ident["room"], ident["device"]): refuse("stream_assertion_invalid", at) else: if vbox is None: refuse("provenance_missing", at) if kind == "track": same = read_stream(m["items"][L_STREAM]) == ident else: same = read_take(m["items"][L_TAKE], True) == ident if not same: refuse("stream_assertion_invalid", at) try: new = read_keys(m, device) except ValueError as e: refuse(str(e), at) if any(h["min"] < seq or any(o["x"] == h["x"] for o in held_keys) for h in new): refuse("session_key_invalid", at) held_keys = held_keys + new try: check_label(m, kind, ident) except ValueError as e: refuse(str(e), at) prev_manifest = active = m["id"] if m["shape"] == "per-segment": per = m methods = {(True, True): "manifest+vsi", (True, False): "manifest"}.get((per is not None, vbox is not None), "vsi") # Step 4. signer = None if vbox: e = read_emsg(b, vbox) dur = sum(x["duration"] for x in infos) if (e is None or e["scheme"] != VSI_SCHEME or e["value"] != "fseg" or e["timescale"] != track["timescale"] or e["ptd"] != 0 or e["duration"] != dur or e["id"] != seq): refuse("segment_info_invalid", at) try: cs = read_sign1(cbor_read(e["data"])) except CborError: cs = None if (cs is None or len(cs["protected"]) != 1 or len(cs["unprotected"]) != 1 or not isinstance(cs["unprotected"].get(4), bytes) or cs["payload"] is None): refuse("segment_info_invalid", at) try: info = cbor_read(cs["payload"]) except CborError: refuse("segment_info_invalid", at) if not is_map(info, ["sequenceNumber", "bmffHash", "manifestId"]): refuse("segment_info_invalid", at) vh = read_hash_item(info["bmffHash"], True) if vh is None or not uint(info["sequenceNumber"]) or not isinstance(info["manifestId"], str): refuse("segment_info_invalid", at) key = next((h for h in held_keys if h["x"] == cs["unprotected"][4]), None) if key is None: refuse("session_key_unknown", at) if not cose_verifies(cs, key["x"], cs["payload"]): refuse("segment_info_signature_failed", at) pres = epoch + infos[0]["bmdt"] * 1000000 // track["timescale"] superseded = any(key["min"] < o["min"] <= seq for o in held_keys) if key["min"] > seq or superseded or not key["frm"] <= pres < key["to"]: refuse("session_key_out_of_range", at) if info["sequenceNumber"] != seq or vh[1] != seq: refuse("sequence_mismatch", at) if info["manifestId"] != active: refuse("segment_info_invalid", at) if bmff_hash(b, top) != vh[0]: refuse("segment_hash_mismatch", at) signer = key["x"] # Steps 5 and 6. if kind == "track": if g is None: refuse("segment_malformed", at) expect = 1 for h in range(first_group, g): rh = [r for r in groups if r["group_id"] == h] if not rh: refuse("receipt_missing", "group %d" % h) expect += rh[0]["leaf_count"] held = [r for r in groups if r["group_id"] == g] if len(held) != 1: refuse("receipt_missing" if not held else "receipt_invalid", "group %d" % g) rg = held[0] if seq != expect: refuse("sequence_mismatch", at) atg = "group %d" % g prev = next((r for r in groups if r["group_id"] == g - 1), None) if g > first_group else None parent = rg["parent_receipt_hash"] if prev is not None and prev["signer_pubkey"] == rg["signer_pubkey"]: parent_ok = parent == link_hash(prev).hex() else: parent_ok = parent is None if g < first_group or not group_ok(rg) or rg["track_alias"] != ident["trackAlias"] or not parent_ok: refuse("receipt_invalid", atg) leaves = [leaf(blake3(b"moq-jwp:object\x01", c)) for c in chunks] named = True if per is not None: ga = per["items"][L_GROUP] named = (ga["groupId"] == g and ga["receipt"] == link_hash(rg) and ga["root"].hex() == rg["root_hash"] and ga["objects"] == rg["leaf_count"]) if rg["leaf_count"] != n or rg["root_hash"] != merkle_root(leaves).hex() or not named: refuse("receipt_mismatch", atg) if signer is not None and signer.hex() != rg["signer_pubkey"]: refuse("receipt_signer_mismatch", atg) objs = [o for o in objects if o["group_id"] == g] if len(objs) != n: refuse("object_receipt_invalid", atg) for j, c in enumerate(chunks): mine = [o for o in objs if o["object_id"] == j] if len(mine) != 1 or not object_accepts(mine[0], rg, c): refuse("object_receipt_invalid", atg) seen.append(g) prev_group = g index, digest, dkey, ikey = g, rg["root_hash"], "root", "group" else: if seq != prev_last + 1: refuse("sequence_mismatch", at) index = k digest = hashlib.sha256((init + b) if k == 0 else b).hexdigest() dkey, ikey = "chunkHash", "chunk" prev_seq, prev_last = seq, last out.append({"sequenceNumber": seq, ikey: index, "methods": methods, "manifestId": active, "chunks": n, dkey: digest}) if kind == "track": gs = [r["group_id"] for r in groups if r["group_id"] >= first_group] if gs: for h in range(first_group, max(gs) + 1): if h not in seen: refuse("segment_missing", "group %d" % h) return {"outcome": "accept", "streamId": stream_id, "sourceType": DST_PREFIX + disclosure(kind, ident)[0], "registration": registration, "segments": out} # ───────────────────────────── the corpus ───────────────────────────── def run(v, doc, files): anchors = [read_cert(files[a]) for a in doc["anchors"]] t_us = rfc3339(doc["time"]) * 1000000 segs = [(files[s["file"]], s.get("group")) for s in v["segments"]] groups, objects = [], [] if v["receipts"] is not None: r = json.loads(files[v["receipts"]]) groups, objects = r["groups"], r["objects"] repositories = {r["uri"]: bytes.fromhex(r["key"]) for r in doc["repositories"]} try: return verify_stream(v["kind"], files[v["init"]], segs, groups, objects, anchors, repositories, t_us) except Refuse as e: c2pa, live = REASONS[e.reason] return {"outcome": "refuse", "reason": e.reason, "at": e.at, "c2pa": c2pa, "livevideo": live} def load(root): files = {} for dirpath, _, names in os.walk(root): for n in names: p = os.path.join(dirpath, n) rel = os.path.relpath(p, root).replace(os.sep, "/") if rel != "README.md": files[rel] = open(p, "rb").read() return files def flip_last_mdat(b): top = boxes(b, 0, len(b)) m = [r for r in top if r[0] == b"mdat"][-1] return b[:m[2] - 1] + bytes([b[m[2] - 1] ^ 1]) + b[m[2]:] def mutations(doc, files): """One defect each, on copies of the good vectors; every copy must be refused.""" out = [] for v in doc["vectors"]: if v["expect"]["outcome"] != "accept": continue mid = len(v["segments"]) // 2 def segment_bytes(fs, vv, j, f): path = "mutated/" + vv["segments"][j]["file"] fs[path] = f(fs[vv["segments"][j]["file"]]) vv["segments"][j]["file"] = path def m_mdat(vv, fs): segment_bytes(fs, vv, mid, flip_last_mdat) def m_drop(vv, fs): del vv["segments"][mid] def m_swap(vv, fs): vv["segments"][0], vv["segments"][1] = vv["segments"][1], vv["segments"][0] def m_moov(vv, fs): b = fs[vv["init"]] top = boxes(b, 0, len(b)) at = top[2][2] - 1 fs["mutated/" + vv["init"]] = b[:at] + bytes([b[at] ^ 1]) + b[at + 1:] vv["init"] = "mutated/" + vv["init"] def m_claim_sig(vv, fs): b = fs[vv["init"]] top = boxes(b, 0, len(b)) sig = read_store(manifest_store_bytes(b, top[1]) or manifest_store_bytes(b, top[1], b"original"))["signature"][-64:] at = b.index(sig) + 10 fs["mutated/" + vv["init"]] = b[:at] + bytes([b[at] ^ 1]) + b[at + 1:] vv["init"] = "mutated/" + vv["init"] def m_last_byte_of_c2pa_box(vv, fs): def f(b): top = boxes(b, 0, len(b)) r = top[0] return b[:r[2] - 1] + bytes([b[r[2] - 1] ^ 1]) + b[r[2]:] segment_bytes(fs, vv, mid, f) def m_receipt(vv, fs): r = json.loads(fs[vv["receipts"]]) g = r["groups"][mid] g["sig"] = ("0" if g["sig"][0] != "0" else "1") + g["sig"][1:] fs["mutated/receipts.json"] = json.dumps(r).encode() vv["receipts"] = "mutated/receipts.json" def m_object(vv, fs): r = json.loads(fs[vv["receipts"]]) o = [x for x in r["objects"] if x["group_id"] == vv["segments"][mid]["group"]][0] o["content_hash"] = ("0" if o["content_hash"][0] != "0" else "1") + o["content_hash"][1:] fs["mutated/receipts.json"] = json.dumps(r).encode() vv["receipts"] = "mutated/receipts.json" def m_time(vv, fs, d): d["time"] = "2027-06-01T00:00:00Z" ms = [("a byte of the middle segment's media", m_mdat), ("the middle segment dropped", m_drop), ("the first two segments swapped", m_swap), ("a byte of the initialization segment's moov", m_moov), ("a byte of the initialization manifest's claim signature", m_claim_sig), ("the last byte of the middle segment's first C2PA box", m_last_byte_of_c2pa_box)] def m_update(vv, fs): b = fs[vv["init"]] at = len(b) - 1 fs["mutated/" + vv["init"]] = b[:at] + bytes([b[at] ^ 1]) vv["init"] = "mutated/" + vv["init"] if len(boxes(files[v["init"]], 0, len(files[v["init"]]))) == 4: ms.append(("the last byte of the update manifest's store", m_update)) if v["kind"] == "track": ms += [("a byte of the middle group's receipt signature", m_receipt), ("a byte of an object receipt's content hash", m_object)] for label, f in ms: vv, fs = copy.deepcopy(v), dict(files) f(vv, fs) out.append(("%s: %s" % (v["name"], label), vv, doc, fs)) vv, fs, dd = copy.deepcopy(v), dict(files), copy.deepcopy(doc) m_time(vv, fs, dd) out.append(("%s: verified after the end-entity certificate expired" % v["name"], vv, dd, fs)) return out # ═══════════════════ the C2PA binding of a stored envelope (extensions/c2pa-binding.md) ═══════════════════ # # The second corpus this verifier reads, `live-conformance/c2pa-envelope/`: a C2PA Manifest Store # beside a WAI1 or WAI2 envelope (§3, §4), its claim verified as live-provenance §11.2 steps 1 to 6 # have it (verify_claim above), tied to the envelope by a data hash and to the envelope's JWP group # and object receipts by its WAI assertion (§6). Written from that extension's text. BINDING = os.path.join(HERE, "..", "extensions", "c2pa-binding.md") BINDING_DOMAIN = b"wai:c2pa-binding:manifest\x01" # c2pa-binding §4.2 L_DATA_HASH, L_ENVELOPE = "c2pa.hash.data", "science.transaction.wai.envelope" MAX_STORE = 1 << 20 # §5.1, §6.2 step 2 def binding_reasons(sub=None): """§7: each reason and its code, from the extension's own tables; with `sub` ("7.1" or "7.2"), that table's alone.""" text = open(BINDING, encoding="utf-8").read() sec = text[text.index("\n## 7. Reasons"):] sec = sec[:sec.index("\n## 8.")] if sub is not None: sec = sec[sec.index("\n### %s " % sub):] nxt = sec.find("\n### ", 1) sec = sec if nxt < 0 else sec[:nxt] out = {} for line in sec.splitlines(): if line.startswith("| `"): cells = [c.strip().strip("`") for c in line.split("|")] out[cells[1]] = None if cells[3] == "—" else cells[3] return out BINDING_REASONS = binding_reasons() # A weakened run drops one of these checks (WEAKENINGS); None checks everything. DROPPED = None # The checks of §4.8 to §4.10 and §6.4, which the corpus c2pa-identity catches when one is dropped. PROFILE_WEAKENINGS = ["trust list pin", "trust list form", "trust list in force", "anchor status", "anchor start", "detection label", "soft binding mark", "soft binding evidence", "iscc identifier", "iscc binding form", "iscc binding last", "iscc binding sequence", "iscc binding claim", "iscc binding units", "iscc binding by equality", "iscc binding featureless", "iscc binding data code", "identity form", "identity reference", "identity duplicate", "identity hard binding", "identity sig type", "identity pads", "identity algorithm", "identity profile", "identity chain", "identity signature", "identity signed bytes", "identity schema order", "identity deterministic encoding", "identity validity"] WEAKENINGS = ["envelope framing", "store present", "store form", "assertion list", "data hash form", "envelope assertion form", "manifest identifier", "hard binding", "content hash", "capabilities", "receipt held", "receipt payload form", "receipt link", "receipt signature", "receipt signer", "object content hash", "object proof"] class BindingRefuse(Exception): def __init__(self, reason): super().__init__(reason) self.reason = reason def need(name, ok, reason): """A check: refuse with `reason` when it fails, unless it is the one dropped.""" if not ok and DROPPED != name: raise BindingRefuse(reason) def unique_members(pairs): out = {} for k, v in pairs: if k in out: raise ValueError("a member named twice") out[k] = v return out def read_json(b): try: return json.loads(b.decode("utf-8"), object_pairs_hook=unique_members) except (UnicodeDecodeError, ValueError): return None def manifest_heads(m): """SPEC §3's `wai`, `media` and `intent`, each a string.""" return all(isinstance(m.get(k), str) for k in ("wai", "media", "intent")) def read_envelope(b): """c2pa-binding §3: (form, capabilities) of exactly one envelope, framed as SPEC §2 or §7.1 frames it with nothing after it, or None.""" if len(b) < 8: return None magic, ml = b[:4], struct.unpack("IQ", c["ppm"], c["window"]) + bytes([FILTERING_BYTE[c["filtering"]]]) elif c["label"] == "CalibratedInstrument": ref = c["ref"].encode() out += struct.pack(">IQI", c["ppm"], c["window"], len(ref)) + ref return out def class_as_label_ok(figure, c): """energy-measurement §2.8: never HwShunt, never beside a zero figure, at the resolution floor (figure × relative_ppm at least 288 676) where it declares an uncertainty.""" return c["label"] != "HwShunt" and figure != 0 and ("ppm" not in c or figure * c["ppm"] >= 288676) def read_labelled_group(g): """A group receipt in the labelled payload, its signing payload and link hash; None where it does not read so.""" if not isinstance(g, dict) or g.get("kind") != "group": return None if any(k not in g for k in GROUP_FIELDS) or any(k not in GROUP_FIELDS and k not in LABEL_FIELDS for k in g): return None if not (all(u64(g[k]) for k in ("track_alias", "group_id", "leaf_count", "joules_total")) and all(isinstance(g[k], str) for k in ("group_start", "group_end", "signer_id")) and lower_hex(g["root_hash"], 32) and lower_hex(g["signer_pubkey"], 32) and lower_hex(g["sig"], 64) and (g["parent_receipt_hash"] is None or lower_hex(g["parent_receipt_hash"], 32))): return None sv = g.get("sign_version") if not (isinstance(sv, int) and not isinstance(sv, bool) and sv == 2): return None try: c = read_class(g) except ValueError: return None cov = g.get("energy_coverage") if c is None and cov is not None or c is not None and cov not in ("Whole", "Partial"): return None p = b"moq-jwp:group-sign\x02" + struct.pack(">QQ", g["track_alias"], g["group_id"]) + bytes.fromhex(g["root_hash"]) p += struct.pack(">QQ", g["leaf_count"], g["joules_total"]) for s in (g["group_start"], g["group_end"]): s = s.encode() p += struct.pack(">I", len(s)) + s sid = g["signer_id"].encode() p += bytes.fromhex(g["signer_pubkey"]) + struct.pack(">I", len(sid)) + sid p += class_bytes(c) + (b"\x01" if cov == "Partial" else b"\x00") parent = g["parent_receipt_hash"] p += b"\x01" + bytes.fromhex(parent) if parent is not None else b"\x00" + bytes(32) sig = bytes.fromhex(g["sig"]) label_ok = g["joules_total"] == 0 if c is None else class_as_label_ok(g["joules_total"], c) return dict(payload=p, sig=sig, link=blake3(b"moq-jwp:receipt-link\x01", p, sig), signer=bytes.fromhex(g["signer_pubkey"]), root=bytes.fromhex(g["root_hash"]), leaf_count=g["leaf_count"], track_alias=g["track_alias"], group_id=g["group_id"], label_ok=label_ok) def read_object(o): """jwp-receipts §2.4.2's JSON form of an object receipt, its leaf computed (§2.2); None where it does not read.""" if not isinstance(o, dict) or o.get("kind") != "object": return None if any(k not in o for k in OBJECT_FIELDS) or any(k not in OBJECT_FIELDS + LABEL_FIELDS[1:5] + ["leaf_version"] for k in o): return None p = o["merkle_proof"] if not (all(u64(o[k]) for k in ("track_alias", "group_id", "object_id", "joules_micro")) and lower_hex(o["content_hash"], 32) and isinstance(o["origin"], str) and isinstance(p, dict) and set(p) == {"siblings", "direction_bits", "leaf_index"} and u64(p["direction_bits"]) and u64(p["leaf_index"]) and isinstance(p["siblings"], list) and all(lower_hex(s, 32) for s in p["siblings"])): return None content = bytes.fromhex(o["content_hash"]) lv = o.get("leaf_version") if lv is None: if any(k in o for k in LABEL_FIELDS[1:5]): return None the_leaf = blake3(b"moq-jwp:merkle-leaf\x01", content) else: if not (isinstance(lv, int) and not isinstance(lv, bool) and lv == 2): return None try: c = read_class(o) except ValueError: return None if (c is None and o["joules_micro"] != 0) or (c is not None and not class_as_label_ok(o["joules_micro"], c)): return None origin = o["origin"].encode() the_leaf = blake3(b"moq-jwp:merkle-leaf\x02", content, struct.pack(">Q", o["joules_micro"]), class_bytes(c), struct.pack(">I", len(origin)), origin) return dict(content=content, leaf=the_leaf, track_alias=o["track_alias"], group_id=o["group_id"], object_id=o["object_id"], siblings=[bytes.fromhex(s) for s in p["siblings"]], bits=p["direction_bits"], index=p["leaf_index"]) def binding_manifest_id(cid, receipt, ee): """c2pa-binding §4.2.""" b = bytearray(hashlib.sha256(BINDING_DOMAIN + cid + receipt + hashlib.sha256(ee).digest()).digest()[:16]) b[6] = (b[6] & 0x0F) | 0x40 b[8] = (b[8] & 0x3F) | 0x80 return "urn:c2pa:" + uuid_text(bytes(b)) def read_data_hash(m): """§4.3: {alg: sha256, hash, pad: h''} and nothing else.""" if not is_map(m, ["alg", "hash", "pad"]) or m["alg"] != "sha256" or m["pad"] != b"" or not b32(m["hash"]): return None return m["hash"] def read_envelope_assertion(m): """§4.4: exactly contentHash, capabilities (one or more texts), receipt and publisher.""" if not is_map(m, ["contentHash", "capabilities", "receipt", "publisher"]): return None caps = m["capabilities"] if not (b32(m["contentHash"]) and b32(m["receipt"]) and b32(m["publisher"]) and isinstance(caps, list) and caps and all(isinstance(c, str) for c in caps)): return None return m # ───────────────────────────── §4.7 to §4.10 and §6.4 ───────────────────────────── L_ISCC, L_DETECTION = "science.transaction.wai.iscc", "science.transaction.wai.detection" # §4.9, §4.1 L_SOFT, L_IDENTITY = "c2pa.soft-binding", "cawg.identity" # §4.8, §4.10 SOFT_ALGS = {"science.transaction.wai.tent64": "wai-mark:tent64"} # §4.8 META_CLAIM = "science.transaction.wai.robustnessClaim" ISCC_ALG = "io.iscc.v0" # §4.9, IEP-0020 META_GENERATOR, META_URL = "io.iscc.generator", "informationalUrl" ISCC_URL = "https://ieps.iscc.codes/iep-0020/" TRUSTED = "http://c2pa.org/conformance/trust-list/trusted" # §6.4 EKU_DOCUMENT = oid("1.3.6.1.5.5.7.3.36") def helper(name): """A second implementation this one leans on, written from its own extension's text.""" if HERE not in sys.path: sys.path.insert(0, HERE) return __import__(name) def numbered(label, n): """C2PA 2.4 §6.4's labels: `label`, then `label__1`, …""" return label if n == 0 else "%s__%d" % (label, n) def tail_shape(tail): """§4.1 items 4 to 6: (soft bindings, an identifier, identities), or None.""" i = 0 while i < len(tail) and tail[i] == numbered(L_SOFT, i): i += 1 softs = i iscc = i < len(tail) and tail[i] == L_ISCC if iscc: i += 1 n = 0 while i < len(tail) and tail[i] == numbered(L_IDENTITY, n): i += 1 n += 1 return (softs, iscc, n) if i == len(tail) else None def read_soft(m): """§4.8: exactly alg, blocks (one, an empty scope and an 8-byte value of class at most 3) and bindingMetadata (exactly the robustness claim).""" if not is_map(m, ["alg", "blocks", "bindingMetadata"]) or m["alg"] not in SOFT_ALGS: return None blocks = m["blocks"] if not isinstance(blocks, list) or len(blocks) != 1 or not is_map(blocks[0], ["scope", "value"]): return None v = blocks[0]["value"] if blocks[0]["scope"] != {} or not isinstance(v, bytes) or len(v) != 8 or v[0] > 3: return None meta = m["bindingMetadata"] if not is_map(meta, [META_CLAIM]) or not b32(meta[META_CLAIM]): return None return dict(alg=m["alg"], scheme=SOFT_ALGS[m["alg"]], value=v, claim=meta[META_CLAIM]) def is_iscc_binding(m): return isinstance(m, dict) and m.get("alg") == ISCC_ALG def read_iscc_binding(m): """§4.9: exactly alg, blocks (one, an empty scope and a byte string that reads as an ISCC-SEQ) and bindingMetadata (the algorithm's text and the generator's name and version, and a robustness claim where one is named, for step 10 to refuse).""" if not is_map(m, ["alg", "blocks", "bindingMetadata"]) or m["alg"] != ISCC_ALG: return None blocks = m["blocks"] if not isinstance(blocks, list) or len(blocks) != 1 or not is_map(blocks[0], ["scope", "value"]): return None v = blocks[0]["value"] if blocks[0]["scope"] != {} or not isinstance(v, bytes): return None try: units = [bytes(u) for u in helper("iscc_verify").decode_seq(v)] except ValueError: if DROPPED != "iscc binding sequence": return None units = [] meta = m["bindingMetadata"] if not is_map(meta, [META_URL, META_GENERATOR], [META_CLAIM]) or meta[META_URL] != ISCC_URL: return None g = meta[META_GENERATOR] if not is_map(g, ["name", "version"]) or not all(isinstance(g[k], str) and g[k] for k in ("name", "version")): return None claim = meta.get(META_CLAIM) if claim is not None and not b32(claim): return None return dict(units=units, name=g["name"], version=g["version"], claim=claim, value=v) def envelope_picture(b): """§4.7: the first wai.image.png payload's samples as RGB8, (rgb, w, h), or None.""" env = read_envelope(b) if env is None: return None ml = struct.unpack("= 1 and issued is not None and nxt is not None for e in doc["LoTE"]["TrustedEntitiesList"]: for sv in e["TrustedEntityServices"]: si = sv["ServiceInformation"] certs = [b64(c["val"]) for c in si["ServiceDigitalIdentity"]["X509Certificates"]] frm = rfc3339(si["StatusStartingTime"]) ok = ok and all(c is not None for c in certs) and isinstance(si["ServiceStatus"], str) and frm is not None services.append((certs, si["ServiceStatus"], frm)) except (TypeError, KeyError): ok = False need("trust list form", ok, "trust_list_malformed") if not ok: return [], None need("trust list in force", issued <= t <= nxt, "trust_list_stale") anchors = [] for certs, status, frm in services: if DROPPED != "anchor status" and status != TRUSTED: continue if DROPPED != "anchor start" and frm > t: continue anchors += [c for c in (read_cert(d) for d in certs) if c is not None] used = {"uri": pinned["uri"], "type": pinned["type"], "identifier": str(seq), "digest": pinned["digest"]} return anchors, used def chain_of(store): try: s = read_sign1(cbor_read(store["signature"])) except CborError: return [] x5 = s["protected"].get(33) if s else None ders = x5 if isinstance(x5, list) else ([x5] if isinstance(x5, bytes) else []) return [c for c in (read_cert(d) for d in ders if isinstance(d, bytes)) if c is not None] def reaches(chain, anchors): return bool(chain) and all(issued_by(chain[i], chain[i + 1]) for i in range(len(chain) - 1)) and any( issued_by(chain[-1], a) for a in anchors) def protected_alg(sig): try: item = cbor_read(sig) except CborError: return None if not isinstance(item, Tag) or item.tag != 18 or not isinstance(item.value, list) or not item.value or not isinstance(item.value[0], bytes): return None try: ph = cbor_read(item.value[0]) except CborError: return None return ph.get(1) if isinstance(ph, dict) else None def encode_cbor(v): """RFC 8949 §4.2.1's core deterministic encoding of a read item.""" if isinstance(v, bool) or v is None: return {False: b"\xf4", True: b"\xf5", None: b"\xf6"}[v] if isinstance(v, int): return head(0, v) if v >= 0 else head(1, -1 - v) if isinstance(v, bytes): return head(2, len(v)) + v if isinstance(v, str): e = v.encode("utf-8") return head(3, len(e)) + e if isinstance(v, list): return head(4, len(v)) + b"".join(encode_cbor(x) for x in v) if isinstance(v, dict): es = sorted((encode_cbor(k), encode_cbor(x)) for k, x in v.items()) return head(5, len(es)) + b"".join(k + x for k, x in es) if isinstance(v, Tag): return head(6, v.tag) + encode_cbor(v.value) raise ValueError("an item") def schema_order(p): """§4.10's schema order of a signer payload: `referenced_assertions`, `sig_type`, `role`, each hashed URI `url`, `alg`, `hash`, each member where the payload holds it, and no other member. Everything under a member is in the core deterministic encoding.""" def members(m, keys, value): held = [k for k in keys if k in m] return head(5, len(held)) + b"".join(encode_cbor(k) + value(k, m[k]) for k in held) def value(k, v): if k == "referenced_assertions" and isinstance(v, list): return head(4, len(v)) + b"".join( members(r, ("url", "alg", "hash"), lambda _, x: encode_cbor(x)) if isinstance(r, dict) else encode_cbor(r) for r in v) return encode_cbor(v) return members(p, ("referenced_assertions", "sig_type", "role"), value) def signed_forms(p): """The serializations of a signer payload a holder's signature may be over (§4.10), each with the name §6.3 states it by: the schema's order, then the core deterministic encoding. A verifier weakened to take the members in any order adds the other orders; one that reads only one of the two is a reader of half of §4.10, and the corpus catches it too.""" forms = [("schema", schema_order(p)), ("deterministic", encode_cbor(p))] forms = [f for f in forms if DROPPED != {"schema": "identity schema order", "deterministic": "identity deterministic encoding"}[f[0]]] if DROPPED == "identity signed bytes": import itertools keys = [k for k in ("referenced_assertions", "sig_type", "role") if k in p] for order in itertools.permutations(keys): forms.append(("other", head(5, len(order)) + b"".join(encode_cbor(k) + encode_cbor(p[k]) for k in order))) return forms def verify_identity(label, m, created, prefix, anchors, lists, t_us): """§6.2 step 12, in its order: what §6.3 states of the assertion, or raises BindingRefuse.""" ok = (isinstance(m, dict) and isinstance(m.get("signer_payload"), dict) and isinstance(m.get("signature"), bytes) and isinstance(m.get("pad1"), bytes) and isinstance(m.get("pad2", b""), bytes)) p = m.get("signer_payload") if ok else {} refs = p.get("referenced_assertions") roles = p.get("role", None) ok = ok and isinstance(refs, list) and refs and isinstance(p.get("sig_type"), str) and p["sig_type"] != "" ok = ok and (roles is None or (isinstance(roles, list) and roles and all(isinstance(r, str) and r for r in roles))) ok = ok and all(isinstance(r, dict) and isinstance(r.get("url"), str) and isinstance(r.get("hash"), bytes) and isinstance(r.get("alg", ""), str) for r in refs) need("identity form", ok, "identity_malformed") if not ok: return None held = [(r["url"], r["hash"]) for r in refs] need("identity reference", all(r.get("alg", "sha256") == "sha256" and (r["url"], r["hash"]) in created for r in refs), "identity_assertion_mismatch") need("identity duplicate", len({u for u, _ in held}) == len(held), "identity_assertion_duplicate") need("identity hard binding", any(u == prefix + L_DATA_HASH for u, _ in held), "identity_hard_binding_missing") need("identity sig type", p["sig_type"] == "cawg.x509.cose", "identity_sig_type_unknown") need("identity pads", not any(m["pad1"]) and not any(m.get("pad2", b"")), "identity_pad_invalid") alg = protected_alg(m["signature"]) if alg is None: raise BindingRefuse("identity_malformed") need("identity algorithm", alg == -8, "identity_algorithm_unsupported") try: s = read_sign1(cbor_read(m["signature"])) except CborError: s = None if s is None or s["payload"] is not None or s["unprotected"] != {}: raise BindingRefuse("identity_malformed") x5 = s["protected"].get(33) ders = x5 if isinstance(x5, list) and x5 and all(isinstance(c, bytes) for c in x5) else ([x5] if isinstance(x5, bytes) else None) if ders is None: raise BindingRefuse("identity_malformed") chain = [read_cert(d) for d in ders] need("identity profile", all(c is not None for c in chain) and profile_ok(chain[0], False, EKU_DOCUMENT) and all(profile_ok(c, True) for c in chain[1:]), "identity_credential_invalid") chain = [c for c in chain if c is not None] anchor = next((a for a in anchors if reaches(chain, [a])), None) need("identity chain", anchor is not None, "identity_credential_untrusted") signed = next((name for name, signed_bytes in signed_forms(p) if chain and cose_verifies(s, chain[0]["key"], signed_bytes)), None) need("identity signature", signed is not None, "identity_signature_mismatch") need("identity validity", anchor is None or all(c["nb"] * 1000000 <= t_us <= c["na"] * 1000000 for c in chain + [anchor]), "identity_credential_expired") lst = next((u for u, a in lists if anchor is not None and any(x["tbs"] == anchor["tbs"] for x in a)), None) return {"label": label, "roles": roles or [], "actor": hashlib.sha256(ders[0]).hexdigest(), "referenced": [u[len(prefix):] if u.startswith(prefix) else u for u, _ in held], "signed": signed, "trustList": lst} def verify_binding(envelope, store_bytes, group, obj, anchors, t_us, x=None): """c2pa-binding §6.2, in order; raises BindingRefuse, returns what §6.3 states.""" x = x or {"claimLists": [], "identityLists": [], "robustness": []} # Step 0 (§6.4): the trust lists. claim_lists, identity_lists = [], [] for key, out in (("claimLists", claim_lists), ("identityLists", identity_lists)): for pinned in x[key]: a, used = list_anchors(pinned, t_us // 1000000) out.append((used, a)) anchors = list(anchors) + [c for _, a in claim_lists for c in a] env = read_envelope(envelope) need("envelope framing", env is not None, "envelope_malformed") env = env or ("?", []) cid = blake3(b"moq-jwp:object\x01", envelope) need("store present", store_bytes is not None, "store_missing") store_bytes = store_bytes or b"" need("store size", len(store_bytes) <= MAX_STORE, "manifest_malformed") store = read_store(store_bytes) need("store form", store is not None and store["type"] == T_MANIFEST and all(a["form"] == "cbor" for a in store["assertions"]), "manifest_malformed") try: labels, ee = verify_claim(store, anchors, t_us) except ValueError as e: raise BindingRefuse(str(e)) need("detection label", L_DETECTION not in labels, "detection_as_provenance") shape = tail_shape(labels[3:]) if labels[:3] == [L_DATA_HASH, L_ACTIONS, L_ENVELOPE] else None need("assertion list", shape is not None, "manifest_malformed") shape = shape or (0, False, 0) try: items = {a["label"]: cbor_read(a["cbor"]) for a in store["assertions"]} except CborError: raise BindingRefuse("manifest_malformed") # The soft bindings: WAI's watermarks, then at most one of ISCC, the last. soft_items = [items.get(numbered(L_SOFT, k)) for k in range(shape[0])] binding = None if DROPPED == "iscc binding last": at = [k for k, m in enumerate(soft_items) if is_iscc_binding(m)] if at: binding = soft_items.pop(at[-1]) elif soft_items and is_iscc_binding(soft_items[-1]): binding = soft_items.pop() softs = [read_soft(m) for m in soft_items] if any(b is None for b in softs): raise BindingRefuse("manifest_malformed") if binding is not None: binding = read_iscc_binding(binding) need("iscc binding form", binding is not None, "manifest_malformed") seq = None if shape[1]: m = items.get(L_ISCC) if not is_map(m, ["iscc"]) or not isinstance(m["iscc"], bytes): raise BindingRefuse("manifest_malformed") try: seq = helper("iscc_verify").decode_seq(m["iscc"]) except ValueError: raise BindingRefuse("manifest_malformed") data_hash = read_data_hash(items.get(L_DATA_HASH)) need("data hash form", data_hash is not None, "manifest_malformed") term = read_actions(items.get(L_ACTIONS)) if term is None or not re.fullmatch(r"[A-Za-z]+", term): raise BindingRefuse("manifest_malformed") a = read_envelope_assertion(items.get(L_ENVELOPE)) need("envelope assertion form", a is not None, "manifest_malformed") need("manifest identifier", store["id"] == binding_manifest_id(a["contentHash"], a["receipt"], ee), "manifest_id_mismatch") need("hard binding", hashlib.sha256(envelope).digest() == data_hash, "hard_binding_mismatch") need("content hash", a["contentHash"] == cid, "content_hash_mismatch") need("capabilities", a["capabilities"] == env[1], "capabilities_mismatch") need("receipt held", group is not None, "receipt_missing") g = read_labelled_group(group) need("receipt payload form", g is not None, "receipt_invalid") need("receipt link", g["link"] == a["receipt"], "receipt_mismatch") need("receipt signature", ED.verify(g["signer"], g["payload"], g["sig"]) is None and g["label_ok"], "receipt_invalid") need("receipt signer", g["signer"] == a["publisher"], "receipt_signer_mismatch") need("object held", obj is not None, "object_receipt_invalid") o = read_object(obj) if o is None: raise BindingRefuse("object_receipt_invalid") need("object content hash", o["content"] == cid, "object_receipt_invalid") n = g["leaf_count"] depth = 0 if n <= 1 else (n - 1).bit_length() if ((o["track_alias"], o["group_id"]) != (g["track_alias"], g["group_id"]) or o["object_id"] != o["index"] or not o["index"] < n or len(o["siblings"]) != depth or o["bits"] != o["index"]): raise BindingRefuse("object_receipt_invalid") cur = o["leaf"] for i, s in enumerate(o["siblings"]): cur = node(s, cur) if (o["bits"] >> i) & 1 else node(cur, s) need("object proof", cur == g["root"], "object_receipt_invalid") # Step 10: the soft bindings. if softs: pic = envelope_picture(envelope) for b in softs: need("soft binding mark", pic is not None and detected(*pic) == b["value"], "soft_binding_mismatch") st = robustness_held(x["robustness"]) need("soft binding evidence", st is not None and all( any(z["kind"] == "wai-robustness-claim" and z["link"] == b["claim"].hex() and z["algorithm"] == b["scheme"] for z in st) for b in softs), "soft_binding_unevidenced") # A robustness record measures a watermark's detector; none is about io.iscc.v0. if binding is not None: need("iscc binding claim", binding["claim"] is None, "soft_binding_unevidenced") # Step 11: the ISCC soft binding and the ISCC identifier, each the units computed again. if binding is not None: if DROPPED == "iscc binding by equality": need("iscc binding matched", iscc_binding_matches(binding["units"], envelope), "iscc_mismatch") else: need("iscc binding units", binding["units"] == iscc_binding_units(envelope), "iscc_mismatch") if seq is not None: need("iscc identifier", [bytes(u) for u in seq] == iscc_units(envelope), "iscc_mismatch") # Step 12: the identities. prefix = "self#jumbf=/c2pa/%s/c2pa.assertions/" % store["id"] created = [(prefix + a["label"], a["hash"]) for a in store["assertions"]] id_anchors = [c for _, a in identity_lists for c in a] identities = [] for k in range(shape[2]): lab = numbered(L_IDENTITY, k) identities.append(verify_identity(lab, items.get(lab), created, prefix, id_anchors, identity_lists, t_us)) out = {"outcome": "accept", "form": env[0], "contentHash": cid.hex(), "capabilities": env[1], "manifestId": store["id"], "sourceType": DST_PREFIX + term, "signer": hashlib.sha256(ee).hexdigest(), "receipt": a["receipt"].hex(), "publisher": a["publisher"].hex(), "trackAlias": g["track_alias"], "groupId": g["group_id"], "objectId": o["object_id"]} chain = chain_of(store) used = [u for u, a in claim_lists if reaches(chain, a)] if used: out["trustLists"] = used if softs: out["softBindings"] = [{"alg": b["alg"], "scheme": b["scheme"], "value": b["value"].hex(), "robustnessClaim": b["claim"].hex()} for b in softs] if binding is not None: out["isccBinding"] = {"alg": ISCC_ALG, "iscc": helper("iscc_verify").canonical(binding["value"]), "generator": {"name": binding["name"], "version": binding["version"]}} if seq is not None: out["iscc"] = helper("iscc_verify").canonical(b"".join(bytes(u) for u in seq)) if identities: out["identities"] = identities return out def extras_of(v, files): """§6.4's inputs of a vector of the corpus c2pa-identity: its pinned lists and its robustness texts.""" def lists(key): return [{"uri": l["uri"], "type": l["type"], "digest": l["digest"], "bytes": files[l["file"]]} for l in v.get(key, [])] return {"claimLists": lists("claimLists"), "identityLists": lists("identityLists"), "robustness": [files[p].decode("utf-8") for p in v.get("robustness", [])]} def run_binding(v, doc, files): anchors = [read_cert(files[a]) for a in doc["anchors"]] t_us = rfc3339(doc["time"]) * 1000000 r = json.loads(files[v["receipts"]]) store = files[v["store"]] if v["store"] is not None else None try: return verify_binding(files[v["envelope"]], store, r["group"], r["object"], anchors, t_us, extras_of(v, files)) except BindingRefuse as e: return {"outcome": "refuse", "reason": e.reason, "c2pa": BINDING_REASONS[e.reason]} def binding_mutations(doc, files): """One defect each, on copies of the good vectors; every copy must be refused.""" out = [] for v in doc["vectors"]: if v["expect"]["outcome"] != "accept": continue def put(vv, fs, key, b): path = "mutated/" + vv[key] fs[path] = b vv[key] = path def receipts(vv, fs, f): r = json.loads(fs[vv["receipts"]]) f(r) fs["mutated/receipts.json"] = json.dumps(r).encode() vv["receipts"] = "mutated/receipts.json" def bump(h): return ("0" if h[0] != "0" else "1") + h[1:] env, store = files[v["envelope"]], files[v["store"]] ms = [ ("the envelope's last byte", lambda vv, fs: put(vv, fs, "envelope", env[:-1] + bytes([env[-1] ^ 1]))), ("a byte of the envelope's manifest", lambda vv, fs: put(vv, fs, "envelope", env[:12] + bytes([env[12] ^ 1]) + env[13:])), ("a byte after the envelope", lambda vv, fs: put(vv, fs, "envelope", env + b"\x00")), ("the store's last byte", lambda vv, fs: put(vv, fs, "store", store[:-1] + bytes([store[-1] ^ 1]))), ("a byte in the middle of the store", lambda vv, fs: put(vv, fs, "store", store[:len(store) // 2] + bytes([store[len(store) // 2] ^ 1]) + store[len(store) // 2 + 1:])), ("the store left out", lambda vv, fs: vv.update(store=None)), ("the group receipt's signature", lambda vv, fs: receipts(vv, fs, lambda r: r["group"].update(sig=bump(r["group"]["sig"])))), ("the group receipt's group id", lambda vv, fs: receipts(vv, fs, lambda r: r["group"].update(group_id=r["group"]["group_id"] + 1))), ("the group receipt left out", lambda vv, fs: receipts(vv, fs, lambda r: r.update(group=None))), ("the object receipt's content hash", lambda vv, fs: receipts(vv, fs, lambda r: r["object"].update(content_hash=bump(r["object"]["content_hash"])))), ("the object receipt's position", lambda vv, fs: receipts(vv, fs, lambda r: r["object"].update(object_id=r["object"]["object_id"] + 1))), ] for label, f in ms: vv, fs = copy.deepcopy(v), dict(files) f(vv, fs) out.append(("%s: %s" % (v["name"], label), vv, doc, fs)) late = copy.deepcopy(doc) late["time"] = "2027-06-01T00:00:00Z" out.append(("%s: verified after the end-entity certificate expired" % v["name"], copy.deepcopy(v), late, dict(files))) if doc.get("corpus") != "c2pa-identity": bare = copy.deepcopy(doc) bare["anchors"] = [] out.append(("%s: verified with no trust anchor" % v["name"], copy.deepcopy(v), bare, dict(files))) continue # §6.4 and §4.8 to §4.10: the lists, the evidence and the identities. def no_lists(vv, fs): vv["claimLists"] = [] def no_identity_lists(vv, fs): vv["identityLists"] = [] def list_byte(vv, fs): l = vv["claimLists"][0] fs["mutated/list.json"] = fs[l["file"]] + b" " l["file"] = "mutated/list.json" def no_robustness(vv, fs): vv["robustness"] = vv["robustness"][:1] ms = [("no trust list of claim signers", no_lists), ("a byte added to the pinned list", list_byte)] if v["identityLists"] and "identities" in v["expect"]: ms.append(("no trust list of identities", no_identity_lists)) if "softBindings" in v["expect"]: ms.append(("the robustness claim not given", no_robustness)) for label, f in ms: vv, fs = copy.deepcopy(v), dict(files) f(vv, fs) out.append(("%s: %s" % (v["name"], label), vv, doc, fs)) return out def binding_outcomes(doc, files): """Each vector's verdict, a crash recorded as one.""" got = {} for v in doc["vectors"]: try: got[v["name"]] = run_binding(v, doc, files) except Exception as e: # a crash is a verdict a weakened run may give got[v["name"]] = {"error": repr(e)} return got def main_binding(root, files, doc): global DROPPED fails, reasons = [], set() used = {"vectors.json"} | set(doc["anchors"]) identity = doc.get("corpus") == "c2pa-identity" for v in doc["vectors"]: used |= {v["envelope"], v["receipts"]} | ({v["store"]} if v["store"] else set()) used |= {l["file"] for l in v.get("claimLists", []) + v.get("identityLists", [])} | set(v.get("robustness", [])) got = binding_outcomes(doc, files) for v in doc["vectors"]: g = got[v["name"]] if g.get("outcome") == "refuse": reasons.add(g["reason"]) if g != v["expect"]: fails.append("%s: expected %s, got %s" % (v["name"], json.dumps(v["expect"]), json.dumps(g))) else: print("PASS %s: %s%s" % (v["name"], g["outcome"], (" " + g["reason"]) if g["outcome"] == "refuse" else "")) for r in binding_reasons("7.2" if identity else "7.1"): if r not in reasons: fails.append("no vector is refused %s" % r) extra = sorted(set(files) - used) if extra: fails.append("files no vector names: %s" % extra) caught = total = 0 for label, vv, dd, fs in binding_mutations(doc, files): total += 1 try: g = run_binding(vv, dd, fs) except Exception as e: # a crash on a mutated copy is a failure to read it, which is a refusal g = {"outcome": "refuse", "reason": "crash: %r" % e} if g.get("outcome") == "accept": fails.append("mutation not caught: " + label) else: caught += 1 print("%d mutated copies, %d refused" % (total, caught)) weak = 0 weakenings = PROFILE_WEAKENINGS if identity else WEAKENINGS for w in weakenings: DROPPED = w if binding_outcomes(doc, files) != {v["name"]: v["expect"] for v in doc["vectors"]}: weak += 1 else: fails.append("the corpus does not catch a verifier without its check '%s'" % w) DROPPED = None print("%d of %d weakened verifiers are caught" % (weak, len(weakenings))) for f in fails: print("FAIL " + f) print("PASS" if not fails else "FAIL", "every vector of", root) return 1 if fails else 0 def main(root): files = load(root) doc = json.loads(files["vectors.json"]) if doc.get("extension") == "c2pa-binding": return main_binding(root, files, doc) fails, reasons = [], set() used = {"vectors.json"} | set(doc["anchors"]) for v in doc["vectors"]: used |= {v["init"]} | {s["file"] for s in v["segments"]} | ({v["receipts"]} if v["receipts"] else set()) try: got = run(v, doc, files) except Exception as e: # a crash is a failure got = {"error": repr(e)} if got.get("outcome") == "refuse": reasons.add(got["reason"]) if got != v["expect"]: fails.append("%s: expected %s, got %s" % (v["name"], json.dumps(v["expect"]), json.dumps(got))) else: print("PASS %s: %s%s" % (v["name"], got["outcome"], (" " + got["reason"] + " at " + got["at"]) if got["outcome"] == "refuse" else "")) for r in REASONS: if r not in reasons: fails.append("no vector is refused %s" % r) extra = sorted(set(files) - used) if extra: fails.append("files no vector names: %s" % extra) caught = 0 for label, vv, dd, fs in mutations(doc, files): try: got = run(vv, dd, fs) except Exception as e: # a crash on a mutated copy is a failure to read it, which is a refusal got = {"outcome": "refuse", "reason": "crash: %r" % e} if got.get("outcome") == "accept": fails.append("mutation not caught: " + label) else: caught += 1 print("%d mutated copies, %d refused" % (caught + sum(1 for f in fails if f.startswith("mutation")), caught)) for f in fails: print("FAIL " + f) print("PASS" if not fails else "FAIL", "every vector of", root) return 1 if fails else 0 if __name__ == "__main__": if len(sys.argv) != 2: print("usage: c2pa_live_verify.py ", file=sys.stderr) sys.exit(2) sys.exit(main(sys.argv[1]))