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
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]>
2150 lines
86 KiB
Python
2150 lines
86 KiB
Python
#!/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]))
|