Skip to content

feat(c2pa): VOD Merkle validation for fragmented MP4#53

Open
valentinamgiusti wants to merge 31 commits into
mainfrom
c2pa-merkle-validation
Open

feat(c2pa): VOD Merkle validation for fragmented MP4#53
valentinamgiusti wants to merge 31 commits into
mainfrom
c2pa-merkle-validation

Conversation

@valentinamgiusti

Copy link
Copy Markdown

Summary

Adds VOD Merkle validation (C2PA 2.4 §15.12.2.2 / §18.6) to @svta/cml-c2pa, released as 1.1.0:

  • validateC2paMerkleSegment — verifies fragmented MP4 media segments against the merkle maps committed in the init manifest: parses the auxiliary uuid box (both the spec §A.5.4 format and the c2pa-rs ContentProvenanceBox variant), recomputes the offset-prefixed leaf hash, walks the Merkle proof up to the committed row, and enforces location continuity via caller-held state.
  • validateC2paInitSegment now extracts merkleMaps from the c2pa.hash.bmff.v3 assertion and validates each entry's initHash binding against the raw init bytes. VOD Merkle streams no longer report SESSIONKEY_INVALID.
  • New standard status codes: assertion.bmffHash.malformed / assertion.bmffHash.mismatch.
  • Shared helpers extracted first (parseExclusions, uuid/byte utils) with no behavior change for the existing VSI/PSM paths.

Structured as 4 commits (helpers refactor → merkle module → init integration → release chore); each commit passes the full test suite on its own.

Testing

  • 146 tests pass (npm test --workspace=libs/c2pa), including new unit + proof-path suites under test/merkle/.
  • Validated end-to-end against a real WDR-sourced VOD stream re-signed with c2patool (all segment hashes match; per-segment proofs verify against the init manifest row).

valentinamgiusti added a commit to qualabs/c2pa-live-video-toolkit that referenced this pull request Jul 13, 2026
CI installs @svta/cml-c2pa from npm, where the latest release (1.0.1)
predates the VOD Merkle API, so the integration tests validated
nothing and failed. Vendor the build of
qualabs/common-media-library#53 as a tarball and install it after
npm ci. Delete the step and vendor/ once upstream publishes 1.1.0.
valentinamgiusti added a commit to qualabs/c2pa-live-video-toolkit that referenced this pull request Jul 13, 2026
CI installs @svta/cml-c2pa from npm, where the latest release (1.0.1)
predates the VOD Merkle API, so the integration tests validated
nothing and failed. Vendor the build of
qualabs/common-media-library#53 as a tarball and install it after
npm ci. Delete the step and vendor/ once upstream publishes 1.1.0.
@valentinamgiusti
valentinamgiusti force-pushed the c2pa-merkle-validation branch from 671c700 to a728de9 Compare July 16, 2026 14:29
Move parseExclusions to bmff/, and toUint8Array, asInteger, matchesUuid,
JUMBF_UUID to utils so upcoming Merkle validation can reuse them.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
validateC2paMerkleSegment verifies fragmented MP4 media segments
against the init manifest merkle maps (C2PA §15.12.2 / §18.6):
parses the auxiliary uuid box (spec 2.4 and c2pa-rs formats),
recomputes the leaf hash, walks the Merkle proof, and enforces
location continuity via caller-held state. Adds status codes
assertion.bmffHash.malformed / .mismatch.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
validateC2paInitSegment reads the merkle field of the
c2pa.hash.bmff.v3 assertion, validates each entry's initHash binding
against the raw init bytes, and returns the maps as merkleMaps.
VOD Merkle streams no longer report SESSIONKEY_INVALID.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
Changelog, version bump, and API report for VOD Merkle validation.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
Signed-off-by: Valentina <valentina.giusti@qualabs.com>
Signed-off-by: Valentina <valentina.giusti@qualabs.com>
Signed-off-by: Valentina <valentina.giusti@qualabs.com>
@valentinamgiusti
valentinamgiusti force-pushed the c2pa-merkle-validation branch from 6acec11 to 5fd7854 Compare July 17, 2026 15:41
…consumed hashes

Bound the proof path against the committed manifest row instead of
the full tree depth, and reject inputs that leave unconsumed proof
hashes. Addresses review feedback on streaming-video-technology-alliance#378/streaming-video-technology-alliance#379.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
Only advance per-track location state when the location continuity
check also passes, not just the Merkle proof. Addresses review
feedback on streaming-video-technology-alliance#379.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
@valentinamgiusti
valentinamgiusti force-pushed the c2pa-merkle-validation branch from 31a2ac5 to 838effc Compare July 20, 2026 14:27
valentinamgiusti and others added 14 commits July 20, 2026 13:51
Per §A.5.1.2/A.5.4.1.4, the auxiliary 'uuid' C2PA box for merkle data
is the same ContentProvenanceBox used for the manifest box (version/
flags + null-terminated box_purpose + raw data), not a second box
type with a bare CBOR map. Remove the fabricated MERKLE_AUX_UUID and
its bare-CBOR-map parser, and the invented integer-key path in
bmff-merkle-map (the CDDL only defines string keys). Addresses review
feedback on streaming-video-technology-alliance#378.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
§A.5.4.1.4 requires padding auxiliary uuid boxes to a fixed size when
a Merkle tree has more than one. cbor-x's decode() throws on trailing
bytes, so a spec-conforming padded box was flagged malformed. Decode
via decodeMultiple and stop after the first item instead. Addresses
review feedback on streaming-video-technology-alliance#378.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
…ssing

Per the merkle-map CDDL, initHash is required for fragmented assets
(the only case validateMerkleMaps handles), and alg has no default
when absent from both the entry and the enclosing assertion. Both
were previously tolerated silently: a missing initHash left the init
segment unbound, and a missing alg defaulted to SHA-256. Addresses
review feedback on streaming-video-technology-alliance#378.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
BmffHashExclusion/BmffHashConstraint were already @public but not
exported from the barrel, so MerkleMap.exclusions duplicated their
shape inline. Export them and reuse the type to avoid drift. No
runtime or shape change. Addresses review feedback on streaming-video-technology-alliance#378.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
Rename MerkleSegmentState.lastLocation to lastLocations (it's a map
of per-track locations) and document the uniqueId:localId key format
and the leniency on the first location seen per track. Also document
that VOD Merkle reuses livevideo.assertion.invalid for per-track
location discontinuity, which its §19.7.2 doc comment didn't cover.
Addresses review feedback on streaming-video-technology-alliance#378.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
C2PA 2.3, which this package targets, has no Annex D — the failure
code table lives at §15.2.2.3. Fix the citation on both BMFF hash
codes, add them to results-and-error-codes.md, and note the
SESSIONKEY_INVALID behavior change for VOD Merkle streams. Addresses
review feedback on streaming-video-technology-alliance#378.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
The only caller of this validator (SegmentRouter in the toolkit)
already gates on merkleMaps.length > 0 before invoking it, so the
null return for the empty case was dead weight at every call site.
Document the precondition instead. Addresses review feedback on streaming-video-technology-alliance#378.

BREAKING CHANGE: validateC2paMerkleSegment no longer returns null.
Callers that branched on a null result should remove that check.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
Freezing the location baseline on any discontinuity (to stop a
replay from laundering itself as the new baseline) had a worse side
effect: one out-of-order or dropped segment left every later segment
flagged relative to a stale baseline for the rest of the session,
even when those later segments were sequential among themselves.

Advance the baseline on any segment whose Merkle proof verifies,
discontinuous or not — it's genuinely signed content, so a forward
gap resyncs immediately instead of cascading. A replayed/reordered
segment is still flagged invalid at the point it occurs.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
…reaming-video-technology-alliance#380)

C2PA 2.4 §18.6.2 defines the v2/v3 flat hash input as offset || data
for every non-excluded root box; the offset is only omitted for Merkle
tree hashes when the bmff-hash-map carries both hash and merkle. c2pa-rs
verifies strictly this way, and both repo fixtures were signed with
prefixed hashes, so the unprefixed fallback in the init path only
admitted non-compliant signers and dropped the box-position binding the
spec calls out. validateBmffHashAssertion now computes the hash with
offsetPrefixSize: 8 instead of trying both modes.

The dual-mode validateBmffHash helper is unchanged for the VSI (§19.7.3)
and manifest-box (§19.7.2) live paths, where the draft live workflow is
the documented reason for the leniency.

Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…hash (streaming-video-technology-alliance#381)

validateC2paManifestBoxSegment verified the flat c2pa.hash.bmff.v3
assertion hash of a §19.7.2 live segment through the lenient dual-mode
validateBmffHash helper, which accepts either the 8-byte box-offset
prefix or no prefix. C2PA 2.4 §18.6.2 mandates the offset prefix for
flat v2/v3 assertion hashes; only Merkle tree hashes may omit it.

Compute the hash strictly with offsetPrefixSize: 8 and compare, matching
c2pa-rs and the init-segment change from PR streaming-video-technology-alliance#380. The dual-mode helper's
interoperability rationale applies to VSI-map-sourced hashes (§19.7.3),
not §18.6.2 assertion hashes, so the VSI path keeps dual-mode validation
and the helper doc now scopes the leniency accordingly.

Regression tests mirror PR streaming-video-technology-alliance#380: prefixed flat hash accepted, unprefixed
flat hash rejected (fails on the previous code), and the real signed
test-segment.m4s fixture end-to-end, which confirms the fixture hash is
offset-prefixed.

Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Resolves conflicts with the flat v3 offset-prefix fixes (streaming-video-technology-alliance#380, streaming-video-technology-alliance#381):
strict offsetPrefixSize 8 flat-hash validation is kept alongside the
merkle integration, and the two Fixed entries are folded into the 1.1.0
changelog section this PR cuts.

Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>
…tion

readAuxPayload can only return a payload or 'other' since 5046af2
removed the bare-CBOR-map parser that used to signal 'malformed';
the flag was always false. Simplify extractMerkleAuxBoxes to return
just the payloads array, and note the unplumbed claim-level alg
fallback in validateMerkleMaps. Addresses review feedback on streaming-video-technology-alliance#378.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
…o-technology-alliance#383)

Extending a bare web platform global (TransformStream, ReadableStream)
in a class heritage clause reads it at module evaluation. Bundlers treat
unknown global reads as potential side effects, so consumers importing
only unrelated exports still shipped the class (Rollup default
treeshake.unknownGlobalSideEffects retains it; TransformStream is not in
Rollup's known-globals list). After downstream transpile + minify the
retained class collapses to a bare 'TransformStream;' statement that
throws ReferenceError at script evaluation on runtimes without the
global: Chrome < 67, Safari < 14.1, Firefox < 102, Node < 18.

Resolve the base classes through guarded local bindings instead: typeof
on an undeclared identifier never throws, and a /* @__PURE__ */ IIFE
lets bundlers drop the statement entirely when the stream class is
unused. The guard deliberately avoids globalThis, which is itself
missing on Chrome < 71. On runtimes without the Web Streams API, module
import now succeeds and instantiation throws a descriptive error.
Subclass semantics and the declared type surface are unchanged.

Apply the same treatment to the other module-scope platform-global
reads found by a monorepo sweep (bare-import Rollup probe per package):

- webvtt: WebVttTransformStream extends TransformStream (tree-shaking
  break and legacy import crash; reported by a downstream consumer)
- iso-bmff: IsoBoxReadableStream extends ReadableStream (legacy import
  crash; ReadableStream happens to be in Rollup's known-globals list)
- c2pa: top-level new TextDecoder() x3 and new TextEncoder().encode()
  retained in consumer bundles (annotation only)
- drm: top-level guarded MediaKeys.isTypeSupported probe retained
  (annotation only)

Verified with Rollup 4.52.4 at default treeshake settings: importing
only parseWebVtt from the built webvtt dist retains zero TransformStream
references (previously the full class chain), and a bare import of the
webvtt and iso-bmff bundles retains nothing. New environment tests cover
import and instantiation without the streams globals.

Fixes streaming-video-technology-alliance#382

Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…reaming-video-technology-alliance#384)

* fix(cmcd): mark module-scope key tables side-effect free

Top-level `new Set(...)`/`new Map(...)` with arguments and the
CmcdReporter state-field tables (`Array.from` + `new Map(...map())`)
run at module evaluation. Bundlers cannot prove argful collection
constructors pure (Rollup's default treeshake only models the argless
forms), so a consumer importing anything from the package retained
~4.9KB of eagerly-built key tables plus the constants they reference.

Annotate the initializers with /* @__PURE__ */ so bundlers drop them
when the functions using them are unused. STATE_FIELDS_BY_EVENT's inner
`.map()` call needs its own annotation: a pure annotation covers the
call itself, not argument evaluation, and the unannotated argument
anchored the whole state-field chain in the bundle.

Verified with Rollup 4.52.5 at default treeshake settings: a bare
import of the built dist now retains nothing beyond bare external
import lines (previously 4,882 bytes). All 341 cmcd tests pass.

Follow-up to the module-scope side-effect audit in streaming-video-technology-alliance#382.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>

* fix(c2pa): mark bytesToHex's hex lookup table side-effect free

The module-scope HEX_TABLE (`Array.from({ length: 256 }, ...)`) runs at
module evaluation, so bundlers retained the call in every consumer
bundle regardless of whether bytesToHex is used. Annotate the
initializer with /* @__PURE__ */ so it is dropped when unused.

Verified with Rollup 4.52.5 at default treeshake settings: a bare
import of the built dist no longer retains the Array.from call. The
remaining TextDecoder/TextEncoder retentions in that probe are
addressed separately by streaming-video-technology-alliance#383. All 127 c2pa tests pass.

Follow-up to the module-scope side-effect audit in streaming-video-technology-alliance#382.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>

* fix(drm): build sample-entry tables without import-time side effects

ensureEncryptedInit built its reader/writer tables with module-scope
forEach loops that call the @svta/cml-iso-bmff sample-entry factories
at import time. Bundlers must retain calls into an external module, so
every drm consumer shipped the audio/visual sample-entry factory chain
even when ensureEncryptedInit was unused.

createAudioSampleEntryReader and createVisualSampleEntryReader are pure
factories (each closes over the type and returns a reader; no shared
registry mutation), so the tables can be built inside /* @__PURE__ */
IIFEs instead: same objects and iteration order, but droppable when
unused.

Verified with Rollup 4.52.5 at default treeshake settings: a bare
import of the built dist retains no ensureEncryptedInit code and no
named iso-bmff factory imports. The remaining MediaKeys probe retention
is addressed separately by streaming-video-technology-alliance#383. All 47 drm tests pass.

Follow-up to the module-scope side-effect audit in streaming-video-technology-alliance#382.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>

* docs(drm): scope changelog entry to bundler-facing effect

The entry implied importing the package no longer builds the
sample-entry tables. The IIFEs still execute at module evaluation for
non-bundled consumers; the improvement is that bundlers can now drop
the pure-annotated initializers (and their iso-bmff factory imports)
when ensureEncryptedInit is unused. Say only that.

Addresses PR streaming-video-technology-alliance#384 review feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>

---------

Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
littlespex and others added 8 commits July 21, 2026 09:18
Removed unreleased version section and added details for version 1.1.0.

Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>
Version bumps go through a separate release script, not feature
PRs. Addresses review feedback on streaming-video-technology-alliance#378/streaming-video-technology-alliance#379.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
…n permissions (streaming-video-technology-alliance#386)

Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
The README still described only the two segment-level validation
methods and didn't link a dedicated guide for VOD Merkle, unlike VSI
and Manifest Box. Add docs/merkle-validation.md following the same
structure, and update the README's method list and doc links.
Addresses review feedback on streaming-video-technology-alliance#378.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
…echnology-alliance#387)

Bump @microsoft/api-extractor 7.53.1 -> 7.58.12 (drops lodash entirely,
pins patched minimatch 10.2.3) and eslint-plugin-tsdoc 0.4.0 -> 0.5.2
(moves to @microsoft/tsdoc-config 0.18.1 with patched ajv). All other
fixes are lockfile-only updates within existing semver ranges.

Fixes Dependabot alerts (all development-scope, package-lock.json):
- js-yaml 4.1.0 -> 4.3.0 (streaming-video-technology-alliance#72 high, streaming-video-technology-alliance#69 medium, #31 medium)
- brace-expansion 1.1.12 -> 1.1.16, 2.0.2 -> 2.1.2 (streaming-video-technology-alliance#70, streaming-video-technology-alliance#71 high)
- ws 7.5.10 -> 7.5.13 (streaming-video-technology-alliance#66 high)
- markdown-it 14.1.0 -> 14.3.0 (streaming-video-technology-alliance#65 medium)
- lodash removed from tree (streaming-video-technology-alliance#63 high, streaming-video-technology-alliance#62 medium, #34 medium)
- defu 6.1.4 -> 6.1.7 (streaming-video-technology-alliance#61 high)
- picomatch 2.3.1 -> 2.3.2, 4.0.3 -> 4.0.5 (streaming-video-technology-alliance#58, streaming-video-technology-alliance#56 medium)
- flatted 3.3.3 -> 3.4.2 (#54 high)
- minimatch 3.1.2 -> 3.1.5, 9.0.5 -> 9.0.9, 10.0.3 -> 10.2.3 (#51, #49, #46 high)
- rollup 4.52.5 -> 4.62.2 (#48 high)
- koa 2.16.3 -> 2.16.4 (#47 high)
- min-document 2.19.0 -> 2.19.2 (#29 low)

Also clears remaining npm audit findings not yet surfaced as alerts:
ajv ReDoS (GHSA-2g4f-4pwh-qvx6), diff DoS (GHSA-73rr-hh4g-fpgx),
yaml stack overflow (GHSA-48c2-rrv3-qjmp). npm audit now reports 0
vulnerabilities.

Signed-off-by: Casey Occhialini <1508707+littlespex@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
valentinamgiusti added a commit to qualabs/c2pa-live-video-toolkit that referenced this pull request Jul 22, 2026
@svta/cml-c2pa 1.1.0 is now published upstream, so the CI workaround
from qualabs/common-media-library#53 is no longer needed. Bump the
dependency floor to ^1.1.0 in the three packages that need it,
remove the vendored tarball and its CI install step, and drop the
now-unused .gitignore exception.

Signed-off-by: Valentina <valentina.giusti@qualabs.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants