open-standards/wai/live-conformance/c2pa_live_verify.py
Transaction Science ae4ce533d7
Some checks failed
wai-quantum energy-landscape jobs / does this change touch it (push) Successful in 7s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 18s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Failing after 1m2s
wai-quantum energy-landscape jobs / the module under wasm32-wasip2 (wasmtime) (push) Successful in 2m23s
wai-quantum energy-landscape jobs / the same bits · macos (push) Successful in 3m34s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Failing after 5m9s
wai-quantum energy-landscape jobs / native against WebAssembly, over many seeds (push) Successful in 5m7s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 5m20s
wai-quantum energy-landscape jobs / the same bits · linux-arm64 (push) Successful in 7m46s
wai-quantum energy-landscape jobs / reader, kernels, answers and the stated result · linux, release (push) Successful in 8m20s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Has been cancelled
joule-moq-room room service / joule-moq-room · does this change touch it (push) Has been cancelled
joule-moq-room room service / joule-moq-room, every target (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Has been cancelled
wai-app and Create / wai-app · does this change touch it (push) Has been cancelled
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Has been cancelled
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Has been cancelled
wai-audit collector and player integration / does this change touch it (push) Has been cancelled
wai-audit collector and player integration / collector · linux (push) Has been cancelled
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Has been cancelled
WAI operation catalogue / does this change touch it (push) Has been cancelled
WAI operation catalogue / the catalogue and every door · linux (push) Has been cancelled
wai-fleet service / does this change touch it (push) Has been cancelled
wai-fleet service / service · linux (push) Has been cancelled
wai-fleet service / service · macos (push) Has been cancelled
wai-ledger service / does this change touch it (push) Has been cancelled
wai-ledger service / service · linux (push) Has been cancelled
wai-ledger service / service · macos (push) Has been cancelled
wai-quantum for JavaScript / does this change touch it (push) Has been cancelled
wai-quantum for JavaScript / javascript · linux (push) Has been cancelled
wai-quantum studio and desktop / does this change touch it (push) Has been cancelled
wai-quantum studio and desktop / studio · browser (push) Has been cancelled
wai-quantum studio and desktop / desktop app · macos (push) Has been cancelled
wai-quantum studio and desktop / desktop app · linux (push) Has been cancelled
wai-quantum studio and desktop / desktop app · windows (push) Has been cancelled
wai-sink-android native sink library / does this change touch it (push) Has been cancelled
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Has been cancelled
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Has been cancelled
wai-sink-swift native sink package / does this change touch it (push) Has been cancelled
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Has been cancelled
WAI Studio / does this change touch it (push) Has been cancelled
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Has been cancelled
WAI Studio / Studio in Chromium and Firefox · macos (push) Has been cancelled
WAI Studio / Studio in WebKit · linux (push) Has been cancelled
wai-key-proxy / does this change touch it (push) Has been cancelled
wai-key-proxy / proxy · linux (push) Has been cancelled
wai: a C2PA Manifest Store bound to a stored WAI1 or WAI2 envelope, and to the receipt that delivered it
A .wai file can now be a C2PA asset without changing an envelope byte. extensions/c2pa-binding.md
registers the record `wai.asset.c2pa`: a C2PA Manifest Store served beside the object at
<cid>.c2pa (application/c2pa, C2PA 2.4 §11.4 and §15.5.3.1; an object's response may name it with
Link rel="c2pa-manifest", §15.5.3.2), never inside it, so the object's content hash, its receipts
and its head proof stay as they were. http-delivery §8.9 gives the location.

The store is live provenance's profile (JUMBF layout, claim, EdDSA COSE claim signature, X.509
chain) with three assertions: c2pa.hash.data over every byte of the envelope with no exclusions
and an empty pad (§18.5); c2pa.actions.v2 with the source type the signer states; and
science.transaction.wai.envelope, which names the envelope's content hash, its capabilities (a
WAI2's in table order) and the JWP group receipt it was delivered under, by link hash (also the
name of its rcpt/ file), with that receipt's publisher key. The receipt must be in the labelled
payload, whose link hash covers its parent. The manifest identifier is derived from the envelope,
the receipt and the claim signer's certificate. A verifier checks the claim and chain
(live-provenance §11.2 steps 1-6), the assertions, the identifier, the data hash, the content hash
and capabilities, the receipt's link hash, signature and publisher, and the envelope's object
receipt under it: seventeen reasons, each with its C2PA §15 code where C2PA gives one.

The reference is the c2pa_envelope module of wai-rs (feature c2pa_envelope; writer
c2pa_envelope::sign); the corpus is live-conformance/c2pa-envelope/ (stores bound to a WAI1 and to
a WAI2 of one picture at two sizes, 27 negatives covering every reason), written from fixed seeds
by wai_c2pa_envelope_vectors; c2pa_live_verify.py, the second implementation, now also verifies
it: 29 vectors agree, 26 of 26 mutated copies are refused and 17 of 17 weakened verifiers are
caught. Fourteen faults planted in the reference (each check of the verification skipped in turn,
a legacy receipt, bytes after the envelope, a padded data hash, the identifier without its
signer) each fail its tests. The C2PA Rust library (c2pa 0.91.7) reads both good stores beside
their envelopes as Trusted with assertion.dataHash.match, and reports assertion.dataHash.mismatch
when one envelope byte changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:50:25 -05:00

2150 lines
86 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <live-conformance/c2pa-live>
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 `<room>-<owner>-<kind>--<layer>` (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():
"""§7: each reason and its C2PA code, from the extension's own table."""
text = open(BINDING, encoding="utf-8").read()
sec = text[text.index("\n## 7. Reasons"):]
sec = sec[:sec.index("\n## 8.")]
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
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("<I", b[4:8])[0]
if magic == b"WAI1":
if len(b) < 12 + ml:
return None
pl = struct.unpack("<I", b[8 + ml:12 + ml])[0]
m = read_json(b[8:8 + ml])
if 12 + ml + pl != len(b) or not isinstance(m, dict) or not isinstance(m.get("model_requirement"), dict):
return None
cap = m["model_requirement"].get("capability")
cond = m.get("conditioning")
if not (manifest_heads(m) and isinstance(cap, str) and isinstance(cond, dict) and isinstance(cond.get("kind"), str)):
return None
return "WAI1", [cap]
if magic == b"WAI2":
if len(b) < 10 + ml:
return None
n = struct.unpack("<H", b[8 + ml:10 + ml])[0]
head_len = 10 + ml + 8 * n
if len(b) < head_len:
return None
at = 0
for i in range(n):
off, ln = struct.unpack("<II", b[10 + ml + 8 * i:18 + ml + 8 * i])
if off != at:
return None
at += ln
m = read_json(b[8:8 + ml])
if head_len + at != len(b) or not isinstance(m, dict) or not isinstance(m.get("renditions"), list):
return None
rs = m["renditions"]
if not (manifest_heads(m) and len(rs) == n and all(isinstance(r, dict) and isinstance(r.get("capability"), str)
and isinstance(r.get("kind"), str) for r in rs)):
return None
return "WAI2", [r["capability"] for r in rs]
return None
# jwp-receipts §2.4.1 and §2.3: a group receipt's JSON form in the labelled payload, and the
# class of energy-measurement §2.7 its total and an object's figure may carry.
U64 = (1 << 64) - 1
LABEL_FIELDS = ["sign_version", "energy_provenance", "energy_uncertainty", "energy_counter_filtering",
"energy_calibration_ref", "energy_coverage"]
CLASS_TAG = {"HwShunt": 1, "ModelBased": 2, "Estimator": 3, "OnChipCounter": 4, "CalibratedInstrument": 5}
FILTERING_BYTE = {"Unknown": 0, "Off": 1, "On": 2}
OBJECT_FIELDS = ["kind", "track_alias", "group_id", "object_id", "content_hash", "joules_micro", "origin", "merkle_proof"]
def u64(v):
return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= U64
def lower_hex(v, n):
return isinstance(v, str) and len(v) == 2 * n and all(c in "0123456789abcdef" for c in v)
def read_class(m):
"""energy-measurement §2.7: the class a receipt's members name, None for none; raises
ValueError where they make no class."""
label = m.get("energy_provenance")
u, f, r = m.get("energy_uncertainty"), m.get("energy_counter_filtering"), m.get("energy_calibration_ref")
if label is None:
if (u, f, r) != (None, None, None):
raise ValueError("evidence without a class")
return None
if label not in CLASS_TAG:
raise ValueError("no such class")
if label in ("HwShunt", "ModelBased", "Estimator"):
if (u, f, r) != (None, None, None):
raise ValueError("evidence the class does not carry")
return {"label": label}
if not isinstance(u, dict) or set(u) != {"relative_ppm", "window_us"} or not all(u64(u[k]) for k in u):
raise ValueError("uncertainty")
if not 0 < u["relative_ppm"] < 1 << 32 or u["window_us"] == 0:
raise ValueError("uncertainty")
c = {"label": label, "ppm": u["relative_ppm"], "window": u["window_us"]}
if label == "OnChipCounter":
if r is not None or (f is not None and f not in FILTERING_BYTE):
raise ValueError("filtering")
c["filtering"] = f or "Unknown"
else:
if f is not None or not isinstance(r, str) or not r.strip():
raise ValueError("calibration reference")
c["ref"] = r
return c
def class_bytes(c):
"""The class's signed encoding (energy-measurement §2.7): tag 0 for the unmetered marker."""
if c is None:
return b"\x00"
out = bytes([CLASS_TAG[c["label"]]])
if c["label"] == "OnChipCounter":
out += struct.pack(">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
def verify_binding(envelope, store_bytes, group, obj, anchors, t_us):
"""c2pa-binding §6.2, in order; raises BindingRefuse, returns what §6.3 states."""
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("assertion list", labels == [L_DATA_HASH, L_ACTIONS, L_ENVELOPE], "manifest_malformed")
try:
items = {a["label"]: cbor_read(a["cbor"]) for a in store["assertions"]}
except CborError:
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")
return {"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"]}
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)
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)))
bare = copy.deepcopy(doc)
bare["anchors"] = []
out.append(("%s: verified with no trust anchor" % v["name"], copy.deepcopy(v), bare, dict(files)))
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"])
for v in doc["vectors"]:
used |= {v["envelope"], v["receipts"]} | ({v["store"]} if v["store"] else set())
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:
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
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 <live-conformance/c2pa-live | live-conformance/c2pa-envelope>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))