Skip to content

feat(c2pa): add native C2PA §19.3/§19.4 provenance validation for live streams - #164

Open
valentinamgiusti wants to merge 36 commits into
developmentfrom
feature/c2pa-cml-validation
Open

feat(c2pa): add native C2PA §19.3/§19.4 provenance validation for live streams#164
valentinamgiusti wants to merge 36 commits into
developmentfrom
feature/c2pa-cml-validation

Conversation

@valentinamgiusti

@valentinamgiusti valentinamgiusti commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds native, opt-in C2PA provenance validation for live CMAF/DASH streams, per C2PA 2.4 §19, "Live Video". A live stream isn't one complete file you can hash and sign upfront: segments go out continuously as the broadcast happens. §19 solves this by making each segment provable on its own, and gives two ways to do it. We support both, plus auto-detection between them.

  • §19.3, Manifest Box method: every media segment carries a full C2PA manifest in a uuid box, hashed with c2pa.hash.bmff.v3 and chained to the previous segment (previousManifestId, continuityMethod).
  • §19.4, Verifiable Segment Info method: session keys are set up once in the init segment, then each media segment just needs a lightweight COSE_Sign1 payload against those keys, carried in an emsg box.

Both bind the init segment to its media segments (hash covers the moov box) to stop replay/substitution attacks, so our per-track state (session keys, sequence/continuity baseline) is scoped to that binding and gets reset on source change.

Off by default behind streaming.c2pa.enabled, it's brand new. While disabled nothing gets parsed and @svta/cml-c2pa (the actual validation engine) is never imported: it's a dynamic import(), code-split out of both bundles.

player.updateSettings({
    streaming: {
        c2pa: { enabled: true, method: 'auto' }  // 'auto' | '19.3' | '19.4'
    }
});

auto mode classifies each track from its init segment, falling back to per-segment ISO-BMFF box inspection if the init was missed or ambiguous. Results come through three new events: C2PA_INIT_PROCESSED, C2PA_SEGMENT_VALIDATED, C2PA_ERROR.

Each C2PA_SEGMENT_VALIDATED carries a status: valid/invalid for the manifest/hash check, replayed/reordered/missing from cross-checking the manifest's signed sequence number (independent per representation, so an ABR switch or a seek doesn't read as an attack), plus two §19.3-only continuity statuses for a broken link vs. a continuityMethod we just don't recognize. Anything we can't evaluate degrades to unverified instead of being dropped or treated as valid. Full docs in src/streaming/c2pa/README.md; samples/c2pa/index.html has a working ✅ / ❌ / ⚠ grid to try against a real stream.

Testing

Tested against real signed streams for both methods, plus the full unit suite (~1950 tests). All cert/signature validation is delegated to @svta/cml-c2pa, we don't reimplement any C2PA crypto here.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

* Add a check to verify that the npm bundle works in a sample application

* Fix broken default export in modern ESM bundles.

libraryExport: 'default'` made webpack emit the ESM bundle's default export as `dashjs.default`, which does not exist — so `import dashjs from 'dashjs'` was `undefined` for all ESM consumers; only named imports worked. Remove the option from the ESM config so the entry namespace is exported as-is. UMD configs keep it, where it is correct.

* Fix findings identified by Fable5 code review

* Do not test npm package for each PR

* Fix parsing error

* Revert prepack command
* Use other free port if the default debugging port is taken

* Pass timestampOffset in Textsourcebuffer to fix wrong position of text cues in multiperiod content

* Calculate right cue start and endtime

* Add unit tests
* Migration to vitepress for the docs, initial commit

* Additional documentation changes

* Adjust index of docs

* Update feature list

* Update CMCD and DRM info

* Add information on server certificates

* Update landing page of the docs

* Rebuild docs when source folder is updated
* Fix JSDoc errors

* Upgrade node version and test GH actions

* Use right branch

* Fix URL to API docs

* Revert action trigger to development branch
* Pin all GitHub actions to specific package versions for security reasons

* Refine the workflow to publish to NPM

* Fix zizmor warnings

@N1Knight N1Knight left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against the dash.js contribution rules (CONTRIBUTING.md, AGENTS.md, FactoryMaker/context DI, Settings.js + index.d.ts parity, samples.json registration, BSD-3 headers) and against clean-code/SOLID criteria. I did not review the C2PA cryptography itself — that is correctly delegated to @svta/cml-c2pa.

What is already right: every new file carries the BSD-3 header, all modules follow the closure-factory + context pattern, the settings are added to both Settings.js and index.d.ts, the sample is registered in samples.json, the test files follow the streaming.c2pa.* mirror naming, npx eslint src/streaming/c2pa test/unit/test/streaming/streaming.c2pa.*.js is clean, and the 58 new unit tests pass. Nice use of the public response-interceptor API instead of patching the fetch-to-MSE path, and the C2paDetector strategy seam is the right call.

Detailed comments inline. Grouping them by weight:

Must fix before merge

  1. _checkSequence builds an unbounded array from a sequence number taken from a segment that may have failed validation — a forged sequenceNumber freezes the tab. This is the one finding I'd call a blocker, because it is triggerable by exactly the attacker this feature exists to detect.
  2. C2PA state is never reset on source change, and trackKey is derived from the URL filename, so two streams whose segments are both named chunk-stream0-*.m4s share state (stale session keys, stale lastManifestId, stale sequence number).
  3. Two docstrings describe wiring that does not exist (reset() "on source change", resetSequenceForTrack() "on seek / period change").

Should fix
4. Silent failure modes: a failed engine import, a swallowed interceptor error and a swallowed box-parse error all produce zero diagnostics, and none of the five new modules uses the dash.js Debug/logger that 14 of 20 streaming/controllers/ modules use.
5. optionalDependencies + a literal specifier in import() — please confirm npm ci --omit=optional && npm run build still succeeds.
6. Dead code: C2paEvents.js has no importers anywhere, and C2paOptions' normalization API has no production callers (only tests), while its stated job is duplicated inline in two modules.

Design / nits
7. C2paValidationCoordinator at 614 lines carries six responsibilities; extracting a sequence tracker and a record factory would also remove five near-duplicate record literals and the parameter mutation.
8. Segment bytes are copied two to three times per media segment.
9. ADR-0002 and 12 AC#NN references point at documents that are not in this repo.

None of this is structural rework — the module boundaries are sound.

Comment thread src/streaming/c2pa/C2paValidationCoordinator.js Outdated
Comment thread src/streaming/c2pa/C2paValidationCoordinator.js Outdated
Comment thread src/streaming/c2pa/C2paValidationCoordinator.js
Comment thread src/streaming/c2pa/C2paValidationCoordinator.js
Comment thread src/streaming/c2pa/C2paValidationCoordinator.js
Comment thread src/streaming/MediaPlayer.js
Comment thread package.json Outdated
Comment thread samples/c2pa/index.html
Comment thread samples/c2pa/index.html
Comment thread src/streaming/vo/IsoBox.js
@valentinamgiusti

valentinamgiusti commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed all three must-fix items, all three should-fix items, and all the design/nits, across several commits.

Must fix

  • Blocker (unbounded gap loop / OOM): fixed. Sequence-checking now only runs on a valid record, and the reported gap is capped at 100 (a larger gap emits one bounded record with missingCount instead of enumerating it). Added a regression test with a forged sequenceNumber: 1_000_000_000 on a failed record.
  • Record mutation / status precedence: fixed. No longer mutates the record in place, and since sequence-checking now only runs on valid records, invalid can no longer be silently overwritten by replayed/reordered.
  • State leak on attachSource() / trackKey collision: fixed. Added C2paController.resetForNewSource(), wired into MediaPlayer._resetPlaybackControllers(). Also fixed initPromises not being cleared in reset().

Should fix

  • Stale docstrings: fixed for real. PLAYBACK_SEEKED/PERIOD_SWITCH_COMPLETED are now wired to reset active tracks' sequence state, matching what the docstrings already claimed.
  • Silent failures: added Debug/logger to the three previously-silent catches, and a one-time C2PA_ERROR (c2pa.engineUnavailable) when the engine fails to load.
  • Absent vs malformed init: confirmed the engine throws different messages for each case but exposes no stable/documented way to tell them apart. Documented it as a known threat-model limitation in README.md instead of matching an undocumented exception string.
  • optionalDependencies build break: confirmed it for real (removed the package from node_modules, ran the build, got Module not found). Moved @svta/cml-c2pa to dependencies.
  • Dead code: wired normalizeC2paOptions as the actual single normalization point. Trimmed C2paEvents.js to its typedefs.

Design / nits

  • Extracted C2paSequenceTracker out of the coordinator into its own module with its own test file (9 tests): it's where the DoS fix lives, so it's worth testing in isolation.
  • Consolidated the five duplicated record literals into one _createSegmentRecord helper with an injectable clock.
  • Fixed the double/triple segment-byte copy in the scanner and detector.
  • Removed the dangling AC#NN/ADR-0002 references.
  • Moved SEGMENT_KIND_INIT/MEDIA to C2paOptions.js so both sides share one definition.
  • Dropped the typeof settings.get guards and switched to Constants.VIDEO/AUDIO.
  • Fixed _resolveMediaMethod: it was always consulting the detector even with a known init classification, contradicting its own docstring and the PR description. Now trusts the init classification first.
  • protectionController/c2paController null-symmetry in MediaPlayer.js: nulled it too, matching the other two controllers.
  • Sample: capped the chip grid at 300 nodes and split it into separate video/audio rows so an interleaved track reads clearly. Kept the §19.3 bucket URL button; it's live (segments near the live edge respond 200; my earlier read of it as dead was from testing stale, already-rotated-out segment numbers).

On the IsoBox.js comment (no test verifying usertype survives the real parse path): I don't think this needs a new test. test/unit/test/streaming/streaming.c2pa.detection.BoxParsingDetector.js already uses the real BoxParser (not stubbed) and builds a real uuid box byte-by-byte; the assertion that detect() classifies it as §19.3 only passes if usertype survived that real parse. Happy to add a dedicated IsoBox.js test too if you'd still like one, but the seam itself is exercised today.

N1Knight and others added 19 commits August 11, 2026 14:42
…for ManifestBox segments

An unrecognized continuityMethod and a broken manifest-id chain both
surfaced as continuityInvalid, even though the CML engine always pairs
CONTINUITY_METHOD_UNSUPPORTED with CONTINUITY_METHOD_INVALID for the
former. Split them so the sample can render unsupported as a distinct
warning instead of an error.
…essage

- Empty URL field by default, with two live example buttons (§19.4 VSI /
  §19.3 Manifest Box) that fill the URL and load immediately
- Match the repo's sample-side-panel convention instead of custom CSS
- Clarify the continuityUnsupported details message
Must-fix:
- Cap the sequence gap loop and only sequence-check valid records, so a
  segment that fails validation can't claim a forged sequenceNumber and
  trigger an unbounded loop/OOM
- Stop mutating the emitted record in place
- Reset per-track C2PA state on attachSource() via a new
  C2paController.resetForNewSource(), and clear initPromises in reset()
  (two different streams can otherwise share a filename-derived trackKey)
- Wire PLAYBACK_SEEKED/PERIOD_SWITCH_COMPLETED to reset active tracks'
  sequence state, matching what the docstrings already claimed

Should-fix:
- Add Debug/logger to the three previously-silent catches
- Emit C2PA_ERROR (c2pa.engineUnavailable) once per session if the
  validation engine fails to load, instead of degrading silently forever
- Document as a known limitation that a malformed init segment reads the
  same as a never-signed one (the engine has no stable, documented way to
  tell them apart, only an unstable exception message)
- Move @svta/cml-c2pa from optionalDependencies to dependencies: the
  dynamic import() uses a literal specifier, so webpack resolves it at
  build time regardless, and npm ci --omit=optional broke the build
- Wire normalizeC2paOptions as the actual single normalization point
  instead of three ad-hoc reimplementations; drop the now-dead
  isValidC2paMethod/getDefaultC2paOptions non-usage; trim C2paEvents.js
  to its typedefs (the runtime object had no importers)

Nits:
- Fix _copySegmentBytes and BoxParsingDetector._toArrayBuffer to copy the
  segment bytes once instead of two or three times
- Remove dangling AC#NN / ADR-0002 references to documents not in this repo
- Move SEGMENT_KIND_INIT/MEDIA to C2paOptions.js so the scanner and
  coordinator share one definition of their contract
- Drop defensive typeof settings.get checks not used anywhere else in the
  codebase; use Constants.VIDEO/AUDIO instead of raw string literals
- Fix _resolveMediaMethod: a track already classified from its init now
  trusts that classification instead of re-parsing every media segment
  through the detector, matching the documented "auto" behavior
A chip is appended per segment and never pruned; on a long-running live
stream that's thousands of nodes each holding a closure over its record.
…ilding

The coordinator owned six responsibilities in one 614-line closure; pull
out the two pieces that are genuinely independent of validation:

- C2paSequenceTracker: the signed-sequence-number bookkeeping (replay /
  reorder / gap detection), with its own test file. This is where the
  DoS fix lives, so it's worth testing directly rather than only through
  the coordinator.
- _createSegmentRecord: a single default SegmentRecord shape used by all
  five record builders instead of each repeating the same 11 fields, with
  an injectable clock (config.now) so tests can assert timestamps.
Matches protectionController/metricsReportingController, which are both
reset and nulled; c2paController was only reset. Harmless in practice
(it's a singleton re-fetched on the next initialize()), but the
asymmetry was worth removing.
@valentinamgiusti
valentinamgiusti force-pushed the feature/c2pa-cml-validation branch from 9366b8a to 1988f6c Compare August 11, 2026 17:45
Video and audio chips were interleaved in one row with the media type
only in the tooltip, so a track validating both read as a jumbled
sequence.
@valentinamgiusti
valentinamgiusti force-pushed the feature/c2pa-cml-validation branch from 1988f6c to e30444c Compare August 11, 2026 18:07
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.

3 participants