#!/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 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): """§4.4 and C2PA 2.4 §14.5.1.1, for the end entity (ca False) or an intermediate.""" 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 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 def main(root): files = load(root) doc = json.loads(files["vectors.json"]) 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]))