1use std::collections::{BTreeMap, HashMap};
13
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256, Sha384, Sha512};
16
17use crate::c2pa_cbor::{decode, Value};
18use crate::c2pa_core::{EngineProfile, SpecVersion};
19use crate::c2pa_crypto::{verify_with_cose_key_with, ClaimAlgorithmList};
20use crate::c2pa_formats::StreamMethod as KernelStreamMethod;
21use crate::c2pa_validate::live_video::{
22 validate_session_keys_with, LiveVideoApplicability, ValidatedSessionKey,
23};
24use crate::c2pa_validate::ValidationResults as KernelResults;
25use crate::{
26 parse_validation_time, resolve_profile, verify_with_manifest_store, Error, VerificationReport,
27 VerifyOptions, REPORT_SCHEMA_VERSION,
28};
29
30const C2PA_CAPABILITY: u64 = 0x10;
31const MAX_EVENTS: usize = 1_000_000;
32const MAX_TRACKS: usize = 256;
33const MAX_FRAMES_PER_SEGMENT: usize = 1_000_000;
34const MAX_SEGMENT_INFOS: usize = 64;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum ErtmpMediaType {
40 Video,
42 Audio,
44}
45
46impl ErtmpMediaType {
47 fn token(self) -> &'static str {
48 match self {
49 Self::Video => "video",
50 Self::Audio => "audio",
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
57pub struct ErtmpTrack {
58 pub media_type: ErtmpMediaType,
60 #[serde(default)]
62 pub track_id: u8,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
68pub enum ErtmpEvent {
69 CodecConfig {
71 track: ErtmpTrack,
73 chunk_stream_id: u32,
75 payload: Vec<u8>,
78 },
79 Frame {
82 track: ErtmpTrack,
84 chunk_stream_id: u32,
86 timestamp: u32,
88 composition_time_offset: i32,
90 coded_data: Vec<u8>,
92 },
93 Metadata {
95 track: ErtmpTrack,
97 chunk_stream_id: u32,
99 timestamp: u32,
101 name: String,
103 value: Vec<u8>,
105 },
106 Reconnect,
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct ErtmpInput {
115 pub client_caps_ex: u64,
117 pub server_caps_ex: u64,
119 pub object_encoding: u8,
121 pub events: Vec<ErtmpEvent>,
123}
124
125#[derive(Debug, Clone, Serialize)]
127pub struct ErtmpSegmentReport {
128 pub track: ErtmpTrack,
130 pub sequence_number: Option<u64>,
132 pub manifest_id: Option<String>,
134 pub key_id: Option<String>,
136 pub valid: bool,
138 pub failures: Vec<String>,
140}
141
142#[derive(Debug, Clone, Serialize)]
144pub struct ErtmpManifestReport {
145 pub manifest_id: String,
147 pub report: VerificationReport,
150}
151
152#[derive(Debug, Clone, Serialize)]
154pub struct ErtmpVerificationReport {
155 pub schema_version: String,
157 pub integrity: String,
160 pub manifests: Vec<ErtmpManifestReport>,
162 pub segments: Vec<ErtmpSegmentReport>,
164 pub failures: Vec<String>,
166}
167
168impl ErtmpVerificationReport {
169 pub fn to_json(&self) -> Result<String, Error> {
171 serde_json::to_string(self).map_err(Error::Serialize)
172 }
173
174 pub fn to_pretty_json(&self) -> Result<String, Error> {
176 serde_json::to_string_pretty(self).map_err(Error::Serialize)
177 }
178}
179
180pub fn verify_ertmp_with_options(
182 input: &ErtmpInput,
183 options: &VerifyOptions,
184) -> Result<ErtmpVerificationReport, Error> {
185 let profile = resolve_profile(options.spec_version.as_deref(), options.strict_conformance)?;
186 if profile.version != SpecVersion::V2_5 {
187 return Err(Error::UnsupportedSpecVersion(
188 "E-RTMP verification requires spec_version 2.5".into(),
189 ));
190 }
191 if input.events.len() > MAX_EVENTS {
192 return Err(Error::Verification(format!(
193 "E-RTMP event count exceeds {MAX_EVENTS}"
194 )));
195 }
196
197 let validation_time = parse_validation_time(options.validation_time.as_deref())?;
198 let mut verifier = Verifier::new(profile, options, validation_time);
199 if input.object_encoding != 3
200 || input.client_caps_ex & C2PA_CAPABILITY == 0
201 || input.server_caps_ex & C2PA_CAPABILITY == 0
202 {
203 verifier.fail(
204 "E-RTMP C2PA carriage was used without bilateral capsEx bit 0x10 and objectEncoding 3",
205 );
206 }
207 for event in &input.events {
208 verifier.event(event)?;
209 }
210 verifier.finish()
211}
212
213pub fn verify_ertmp(input: &ErtmpInput) -> Result<ErtmpVerificationReport, Error> {
215 let options = VerifyOptions {
216 spec_version: Some("2.5".into()),
217 ..VerifyOptions::default()
218 };
219 verify_ertmp_with_options(input, &options)
220}
221
222struct Config<'a> {
223 chunk_stream_id: u32,
224 payload: &'a [u8],
225 revision: u64,
226}
227
228struct Frame<'a> {
229 chunk_stream_id: u32,
230 timestamp: u32,
231 composition_time_offset: i32,
232 coded_data: &'a [u8],
233 config_revision: u64,
234}
235
236#[derive(Default)]
237struct TrackState<'a> {
238 frames: Vec<Frame<'a>>,
239 pending: Option<Pending>,
240 last_sequence: Option<u64>,
241}
242
243struct Pending {
244 track: ErtmpTrack,
245 candidates: usize,
246 accepted: Option<Accepted>,
247 failures: Vec<String>,
248}
249
250struct Accepted {
251 sequence_number: u64,
252 manifest_id: String,
253 key_id: Vec<u8>,
254}
255
256struct ManifestDelivery {
257 store: Vec<u8>,
258 keys: Vec<ValidatedSessionKey>,
259 delivered_config_revision: u64,
260 bound_initialization_sha256: Option<[u8; 32]>,
261 report: Option<VerificationReport>,
262}
263
264struct ActiveManifest {
265 manifest_id: String,
266 config_revision: u64,
267 delivered_since_reconnect: bool,
268}
269
270struct Verifier<'a> {
271 profile: EngineProfile,
272 options: &'a VerifyOptions,
273 validation_time: time::OffsetDateTime,
274 configs: BTreeMap<ErtmpTrack, Config<'a>>,
275 tracks: BTreeMap<ErtmpTrack, TrackState<'a>>,
276 manifests: HashMap<String, ManifestDelivery>,
277 key_tracks: HashMap<Vec<u8>, ErtmpTrack>,
278 next_config_revision: u64,
279 active_manifest: Option<ActiveManifest>,
280 segment_reports: Vec<ErtmpSegmentReport>,
281 failures: Vec<String>,
282}
283
284impl<'a> Verifier<'a> {
285 fn new(
286 profile: EngineProfile,
287 options: &'a VerifyOptions,
288 validation_time: time::OffsetDateTime,
289 ) -> Self {
290 Self {
291 profile,
292 options,
293 validation_time,
294 configs: BTreeMap::new(),
295 tracks: BTreeMap::new(),
296 manifests: HashMap::new(),
297 key_tracks: HashMap::new(),
298 next_config_revision: 1,
299 active_manifest: None,
300 segment_reports: Vec::new(),
301 failures: Vec::new(),
302 }
303 }
304
305 fn fail(&mut self, detail: impl Into<String>) {
306 self.failures.push(detail.into());
307 }
308
309 fn event(&mut self, event: &'a ErtmpEvent) -> Result<(), Error> {
310 match event {
311 ErtmpEvent::CodecConfig {
312 track,
313 chunk_stream_id,
314 payload,
315 } => {
316 if !self.configs.contains_key(track) && self.configs.len() >= MAX_TRACKS {
317 return Err(Error::Verification(format!(
318 "E-RTMP track count exceeds {MAX_TRACKS}"
319 )));
320 }
321 let revision = self.next_config_revision;
322 self.next_config_revision = self.next_config_revision.saturating_add(1);
323 self.configs.insert(
324 *track,
325 Config {
326 chunk_stream_id: *chunk_stream_id,
327 payload,
328 revision,
329 },
330 );
331 }
332 ErtmpEvent::Frame {
333 track,
334 chunk_stream_id,
335 timestamp,
336 composition_time_offset,
337 coded_data,
338 } => {
339 self.finalize_pending(*track);
340 if !(-8_388_608..=8_388_607).contains(composition_time_offset) {
341 self.fail(format!(
342 "{}:{} composition time offset is outside signed 24-bit range",
343 track.media_type.token(),
344 track.track_id
345 ));
346 return Ok(());
347 }
348 let current_revision = self.next_config_revision.saturating_sub(1);
349 if !self.active_manifest.as_ref().is_some_and(|active| {
350 active.config_revision == current_revision && active.delivered_since_reconnect
351 }) {
352 self.fail(
353 "coded frame arrived before a manifest was delivered for the active codec configuration",
354 );
355 }
356 let (config_chunk_stream_id, config_revision) = match self.configs.get(track) {
357 Some(config) => (config.chunk_stream_id, config.revision),
358 None => {
359 self.fail(format!(
360 "{}:{} coded frame arrived before codec configuration",
361 track.media_type.token(),
362 track.track_id
363 ));
364 return Ok(());
365 }
366 };
367 if config_chunk_stream_id != *chunk_stream_id {
368 self.fail(format!(
369 "{}:{} codec configuration and frame use different chunk streams",
370 track.media_type.token(),
371 track.track_id
372 ));
373 }
374 let state = self.tracks.entry(*track).or_default();
375 if state.frames.len() >= MAX_FRAMES_PER_SEGMENT {
376 return Err(Error::Verification(format!(
377 "E-RTMP segment frame count exceeds {MAX_FRAMES_PER_SEGMENT}"
378 )));
379 }
380 state.frames.push(Frame {
381 chunk_stream_id: *chunk_stream_id,
382 timestamp: *timestamp,
383 composition_time_offset: *composition_time_offset,
384 coded_data,
385 config_revision,
386 });
387 }
388 ErtmpEvent::Metadata {
389 track,
390 chunk_stream_id,
391 timestamp,
392 name,
393 value,
394 } if name == "c2paManifest" => {
395 let revision = self.next_config_revision.saturating_sub(1);
396 let (manifest_id, delivery) = manifest_delivery(value, self.profile, revision)?;
397 self.manifests.insert(manifest_id.clone(), delivery);
398 self.active_manifest = Some(ActiveManifest {
399 manifest_id,
400 config_revision: revision,
401 delivered_since_reconnect: true,
402 });
403 if let Some(config) = self.configs.get(track) {
404 if config.chunk_stream_id != *chunk_stream_id {
405 self.fail(format!(
406 "{}:{} manifest Metadata uses a different chunk stream",
407 track.media_type.token(),
408 track.track_id
409 ));
410 }
411 }
412 let _ = timestamp;
413 }
414 ErtmpEvent::Metadata {
415 track,
416 chunk_stream_id,
417 timestamp,
418 name,
419 value,
420 } if name == "c2paSegmentInfo" => {
421 self.segment_info(*track, *chunk_stream_id, *timestamp, value)?;
422 }
423 ErtmpEvent::Metadata { .. } => {
424 }
427 ErtmpEvent::Reconnect => self.reconnect(),
428 }
429 Ok(())
430 }
431
432 fn reconnect(&mut self) {
433 let tracks: Vec<ErtmpTrack> = self.tracks.keys().copied().collect();
434 for track in tracks {
435 self.finalize_pending(track);
436 if self
437 .tracks
438 .get(&track)
439 .is_some_and(|state| !state.frames.is_empty())
440 {
441 self.fail(format!(
442 "{}:{} logical segment was not closed before reconnect",
443 track.media_type.token(),
444 track.track_id
445 ));
446 if let Some(state) = self.tracks.get_mut(&track) {
447 state.frames.clear();
448 }
449 }
450 }
451 self.configs.clear();
452 if let Some(active) = self.active_manifest.as_mut() {
453 active.delivered_since_reconnect = false;
454 }
455 }
456
457 fn segment_info(
458 &mut self,
459 track: ErtmpTrack,
460 chunk_stream_id: u32,
461 timestamp: u32,
462 cose_sign1: &[u8],
463 ) -> Result<(), Error> {
464 let has_pending = self
465 .tracks
466 .get(&track)
467 .and_then(|state| state.pending.as_ref())
468 .is_some();
469 let has_frames = self
470 .tracks
471 .get(&track)
472 .is_some_and(|state| !state.frames.is_empty());
473 if !has_frames && !has_pending {
474 self.fail(format!(
475 "{}:{} SegmentInfo closes no coded frames",
476 track.media_type.token(),
477 track.track_id
478 ));
479 return Ok(());
480 }
481 if !has_pending {
482 self.tracks.entry(track).or_default().pending = Some(Pending {
483 track,
484 candidates: 0,
485 accepted: None,
486 failures: Vec::new(),
487 });
488 }
489 let candidates = self
490 .tracks
491 .get(&track)
492 .and_then(|state| state.pending.as_ref())
493 .map_or(0, |pending| pending.candidates);
494 if candidates >= MAX_SEGMENT_INFOS {
495 return Err(Error::Verification(format!(
496 "E-RTMP segment carries more than {MAX_SEGMENT_INFOS} SegmentInfo candidates"
497 )));
498 }
499
500 let result = self.authenticate_candidate(track, chunk_stream_id, timestamp, cose_sign1);
501 let pending = self
502 .tracks
503 .get_mut(&track)
504 .and_then(|state| state.pending.as_mut())
505 .expect("pending segment was created above");
506 pending.candidates += 1;
507 match result {
508 Ok(accepted) if pending.accepted.is_none() => pending.accepted = Some(accepted),
509 Ok(_) => {}
510 Err(detail) => pending.failures.push(detail),
511 }
512 Ok(())
513 }
514
515 fn authenticate_candidate(
516 &mut self,
517 track: ErtmpTrack,
518 chunk_stream_id: u32,
519 metadata_timestamp: u32,
520 cose_sign1: &[u8],
521 ) -> Result<Accepted, String> {
522 let info = untrusted_segment_info(cose_sign1)?;
523 if info.track != track {
524 return Err("SegmentInfo mediaType/trackId does not match its carrying track".into());
525 }
526 let state = self
527 .tracks
528 .get(&track)
529 .ok_or_else(|| "SegmentInfo track has no state".to_string())?;
530 let frames = &state.frames;
531 if frames.is_empty() {
532 return Err("consecutive SegmentInfo candidate has no retained segment frames".into());
533 }
534 if frames
535 .iter()
536 .any(|frame| frame.chunk_stream_id != chunk_stream_id)
537 {
538 return Err("SegmentInfo Metadata is not on the coded frames' chunk stream".into());
539 }
540 if frames.last().map(|frame| frame.timestamp) != Some(metadata_timestamp) {
541 return Err(
542 "SegmentInfo Metadata timestamp does not equal the last frame timestamp".into(),
543 );
544 }
545 let config = self
546 .configs
547 .get(&track)
548 .ok_or_else(|| "SegmentInfo track has no codec configuration".to_string())?;
549 if frames
550 .iter()
551 .any(|frame| frame.config_revision != config.revision)
552 {
553 return Err(
554 "codec configuration changed after a segment frame and before closure".into(),
555 );
556 }
557 if segment_digest(&info.algorithm, frames).as_deref() != Some(info.hash.as_slice()) {
558 return Err("segmentHash does not match the coded frames".into());
559 }
560
561 let expected_sequence = state.last_sequence.and_then(|value| value.checked_add(1));
562 if state.last_sequence.is_some() && expected_sequence != Some(info.sequence_number) {
563 return Err("sequenceNumber does not increase by exactly one for this track".into());
564 }
565
566 let active = self
567 .active_manifest
568 .as_ref()
569 .ok_or_else(|| "no active manifest has been delivered".to_string())?;
570 if info.manifest_id != active.manifest_id {
571 return Err("SegmentInfo manifestId does not match the active manifest".into());
572 }
573 if !active.delivered_since_reconnect {
574 return Err("active manifest was not resent after reconnect".into());
575 }
576 let required_manifest_revision = self.next_config_revision.saturating_sub(1);
577 if active.config_revision != required_manifest_revision {
578 return Err("active manifest was not delivered after the codec configuration".into());
579 }
580 let initialization = initialization_data(&self.configs);
581 let initialization_sha256: [u8; 32] = Sha256::digest(&initialization).into();
582 let delivery = self.manifests.get_mut(&info.manifest_id).ok_or_else(|| {
583 "manifestId has not been delivered in c2paManifest Metadata".to_string()
584 })?;
585 if delivery.delivered_config_revision != required_manifest_revision {
586 return Err("manifestId was not delivered after the active codec configuration".into());
587 }
588 match delivery.bound_initialization_sha256 {
589 Some(bound) if bound != initialization_sha256 => {
590 return Err(
591 "codec configuration differs from the configuration bound by manifestId".into(),
592 )
593 }
594 None => {
595 let report = verify_with_manifest_store(
596 &initialization,
597 &delivery.store,
598 "application/octet-stream",
599 self.options,
600 )
601 .map_err(|error| format!("manifest validation failed: {error}"))?;
602 if report.integrity != "valid" || report.hard_binding != "match" {
603 return Err(
604 "manifest failed validation against E-RTMP initialization data".into(),
605 );
606 }
607 delivery.bound_initialization_sha256 = Some(initialization_sha256);
608 delivery.report = Some(report);
609 }
610 Some(_) => {}
611 }
612
613 let validation_time = self.validation_time;
614 let mut accepted_key = None;
615 for key in &delivery.keys {
616 let Ok(verified) =
617 verify_with_cose_key_with(cose_sign1, &key.cose_key, ClaimAlgorithmList::V2_5)
618 else {
619 continue;
620 };
621 if verified.key_id.as_deref() != Some(key.key_id.as_slice())
622 || verified.payload.as_slice() != info.payload.as_slice()
623 || info.sequence_number < key.minimum_sequence_number
624 || validation_time < key.created_at
625 || validation_time > key.expires_at
626 {
627 continue;
628 }
629 if self
630 .key_tracks
631 .get(&key.key_id)
632 .is_some_and(|owner| *owner != track)
633 {
634 continue;
635 }
636 accepted_key = Some(key.key_id.clone());
637 break;
638 }
639 let key_id = accepted_key.ok_or_else(|| {
640 "no delivered session key authenticated SegmentInfo for this track and time".to_string()
641 })?;
642 self.key_tracks.entry(key_id.clone()).or_insert(track);
643 Ok(Accepted {
644 sequence_number: info.sequence_number,
645 manifest_id: info.manifest_id,
646 key_id,
647 })
648 }
649
650 fn finalize_pending(&mut self, track: ErtmpTrack) {
651 let Some(state) = self.tracks.get_mut(&track) else {
652 return;
653 };
654 let Some(mut pending) = state.pending.take() else {
655 return;
656 };
657 state.frames.clear();
658 let report = if let Some(accepted) = pending.accepted.take() {
659 state.last_sequence = Some(accepted.sequence_number);
660 ErtmpSegmentReport {
661 track: pending.track,
662 sequence_number: Some(accepted.sequence_number),
663 manifest_id: Some(accepted.manifest_id),
664 key_id: Some(hex::encode(accepted.key_id)),
665 valid: true,
666 failures: Vec::new(),
667 }
668 } else {
669 if pending.failures.is_empty() {
670 pending
671 .failures
672 .push("no SegmentInfo candidate authenticated".into());
673 }
674 ErtmpSegmentReport {
675 track: pending.track,
676 sequence_number: None,
677 manifest_id: None,
678 key_id: None,
679 valid: false,
680 failures: pending.failures,
681 }
682 };
683 self.segment_reports.push(report);
684 }
685
686 fn finish(mut self) -> Result<ErtmpVerificationReport, Error> {
687 let tracks: Vec<ErtmpTrack> = self.tracks.keys().copied().collect();
688 for track in tracks {
689 self.finalize_pending(track);
690 if self
691 .tracks
692 .get(&track)
693 .is_some_and(|state| !state.frames.is_empty())
694 {
695 self.fail(format!(
696 "{}:{} final logical segment has no closing SegmentInfo",
697 track.media_type.token(),
698 track.track_id
699 ));
700 }
701 }
702 if self.segment_reports.is_empty() {
703 self.fail("E-RTMP input contains no closed C2PA segment");
704 }
705 let mut manifests: Vec<ErtmpManifestReport> = self
706 .manifests
707 .into_iter()
708 .filter_map(|(manifest_id, delivery)| {
709 delivery.report.map(|report| ErtmpManifestReport {
710 manifest_id,
711 report,
712 })
713 })
714 .collect();
715 manifests.sort_by(|left, right| left.manifest_id.cmp(&right.manifest_id));
716 let valid = self.failures.is_empty()
717 && self.segment_reports.iter().all(|segment| segment.valid)
718 && manifests
719 .iter()
720 .all(|manifest| manifest.report.integrity == "valid");
721 Ok(ErtmpVerificationReport {
722 schema_version: REPORT_SCHEMA_VERSION.into(),
723 integrity: if valid { "valid" } else { "invalid" }.into(),
724 manifests,
725 segments: self.segment_reports,
726 failures: self.failures,
727 })
728 }
729}
730
731struct SegmentInfo {
732 track: ErtmpTrack,
733 sequence_number: u64,
734 algorithm: String,
735 hash: Vec<u8>,
736 manifest_id: String,
737 payload: Vec<u8>,
738}
739
740fn untrusted_segment_info(cose_sign1: &[u8]) -> Result<SegmentInfo, String> {
741 let decoded = decode(cose_sign1).map_err(|_| "SegmentInfo is not CBOR".to_string())?;
742 let Value::Tag(18, inner) = decoded else {
743 return Err("SegmentInfo is not tagged COSE_Sign1".into());
744 };
745 let Value::Array(mut array) = *inner else {
746 return Err("SegmentInfo COSE_Sign1 shape is invalid".into());
747 };
748 if array.len() != 4 {
749 return Err("SegmentInfo COSE_Sign1 shape is invalid".into());
750 }
751 let Value::Bytes(payload) = array.swap_remove(2) else {
752 return Err("SegmentInfo payload is detached".into());
753 };
754 let Value::Map(map) =
755 decode(&payload).map_err(|_| "rtmp-segment-info-map is not CBOR".to_string())?
756 else {
757 return Err("rtmp-segment-info-map is not a map".into());
758 };
759 if !has_exact_keys(
760 &map,
761 &[
762 "mediaType",
763 "trackId",
764 "sequenceNumber",
765 "segmentHash",
766 "manifestId",
767 "manifestUri",
768 ],
769 ) {
770 return Err("rtmp-segment-info-map contains an unknown or duplicate field".into());
771 }
772 let media_type = match unique(&map, "mediaType").and_then(Value::as_text) {
773 Some("video") => ErtmpMediaType::Video,
774 Some("audio") => ErtmpMediaType::Audio,
775 _ => return Err("rtmp-segment-info-map mediaType is invalid".into()),
776 };
777 let track_id = match unique(&map, "trackId") {
778 None => 0,
779 Some(Value::Integer(value)) if (0..=255).contains(value) => *value as u8,
780 _ => return Err("rtmp-segment-info-map trackId is invalid".into()),
781 };
782 let sequence_number = unsigned(unique(&map, "sequenceNumber"))
783 .ok_or_else(|| "rtmp-segment-info-map sequenceNumber is invalid".to_string())?;
784 let hash_map = unique(&map, "segmentHash")
785 .and_then(Value::as_map)
786 .ok_or_else(|| "rtmp-segment-info-map segmentHash is invalid".to_string())?;
787 if !has_exact_keys(hash_map, &["alg", "hash"]) || hash_map.len() != 2 {
788 return Err("rtmp-segment-info-map segmentHash fields are invalid".into());
789 }
790 let algorithm = unique(hash_map, "alg")
791 .and_then(Value::as_text)
792 .filter(|value| matches!(*value, "sha256" | "sha384" | "sha512"))
793 .ok_or_else(|| "rtmp segment hash algorithm is invalid".to_string())?
794 .to_string();
795 let hash = unique(hash_map, "hash")
796 .and_then(Value::as_bytes)
797 .filter(|value| {
798 value.len()
799 == match algorithm.as_str() {
800 "sha256" => 32,
801 "sha384" => 48,
802 "sha512" => 64,
803 _ => unreachable!("algorithm was validated above"),
804 }
805 })
806 .ok_or_else(|| "rtmp segment hash length is invalid".to_string())?
807 .to_vec();
808 let manifest_id = unique(&map, "manifestId")
809 .and_then(Value::as_text)
810 .filter(|value| !value.is_empty())
811 .ok_or_else(|| "rtmp-segment-info-map manifestId is invalid".to_string())?
812 .to_string();
813 if let Some(manifest_uri) = unique(&map, "manifestUri") {
814 let uri_map = manifest_uri
815 .as_map()
816 .ok_or_else(|| "rtmp-segment-info-map manifestUri is invalid".to_string())?;
817 let uri_algorithm = unique(uri_map, "alg")
818 .and_then(Value::as_text)
819 .filter(|value| matches!(*value, "sha256" | "sha384" | "sha512"));
820 let uri_hash = unique(uri_map, "hash").and_then(Value::as_bytes);
821 if !has_exact_keys(uri_map, &["url", "alg", "hash"])
822 || uri_map.len() != 3
823 || unique(uri_map, "url")
824 .and_then(Value::as_text)
825 .is_none_or(str::is_empty)
826 || uri_algorithm.is_none()
827 || uri_hash.is_none_or(|value| {
828 value.len()
829 != match uri_algorithm {
830 Some("sha256") => 32,
831 Some("sha384") => 48,
832 Some("sha512") => 64,
833 _ => 0,
834 }
835 })
836 {
837 return Err("rtmp-segment-info-map manifestUri is invalid".into());
838 }
839 }
840 Ok(SegmentInfo {
841 track: ErtmpTrack {
842 media_type,
843 track_id,
844 },
845 sequence_number,
846 algorithm,
847 hash,
848 manifest_id,
849 payload,
850 })
851}
852
853fn manifest_delivery(
854 manifest_store: &[u8],
855 profile: EngineProfile,
856 delivered_config_revision: u64,
857) -> Result<(String, ManifestDelivery), Error> {
858 let expanded = crate::c2pa_core::jumbf::expand_store(manifest_store)
859 .map_err(|error| Error::Verification(error.to_string()))?;
860 let parsed = crate::c2pa_core::jumbf::parse_manifest_store(expanded.bytes())
861 .map_err(|error| Error::Verification(error.to_string()))?;
862 let active = parsed
863 .manifests
864 .last()
865 .ok_or_else(|| Error::Verification("c2paManifest store has no active manifest".into()))?;
866 let found: Vec<_> = active
867 .assertions
868 .iter()
869 .filter(|(label, _)| label.as_str() == "c2pa.session-keys")
870 .collect();
871 let [(label, assertion_cbor)] = found.as_slice() else {
872 return Err(Error::Verification(
873 "active E-RTMP manifest must carry exactly one c2pa.session-keys assertion".into(),
874 ));
875 };
876 if !claim_references(active, label) {
877 return Err(Error::Verification(
878 "E-RTMP session-keys assertion is not claim-bound".into(),
879 ));
880 }
881 let signer_chain = active
882 .signature_cose
883 .and_then(|cose| crate::c2pa_crypto::extract_x5chain(cose).ok())
884 .unwrap_or_default();
885 let mut results = KernelResults::default();
886 let keys = validate_session_keys_with(
887 LiveVideoApplicability::new(KernelStreamMethod::VerifiableSegmentInfo),
888 assertion_cbor,
889 &signer_chain,
890 ClaimAlgorithmList::V2_5,
891 &mut results,
892 &format!(
893 "self#jumbf=/c2pa/{}/c2pa.assertions/c2pa.session-keys",
894 active.label
895 ),
896 );
897 if !results.failure.is_empty() || keys.is_empty() {
898 return Err(Error::Verification(
899 results
900 .failure
901 .first()
902 .map(|status| status.explanation.clone())
903 .unwrap_or_else(|| "E-RTMP manifest has no valid session key".into()),
904 ));
905 }
906 require_unexcluded_data_binding(manifest_store, expanded.bytes(), &parsed, profile)?;
907 Ok((
908 active.label.clone(),
909 ManifestDelivery {
910 store: manifest_store.to_vec(),
911 keys,
912 delivered_config_revision,
913 bound_initialization_sha256: None,
914 report: None,
915 },
916 ))
917}
918
919fn require_unexcluded_data_binding(
920 original_store: &[u8],
921 _expanded_store: &[u8],
922 parsed: &crate::c2pa_core::jumbf::ParsedStore<'_>,
923 profile: EngineProfile,
924) -> Result<(), Error> {
925 let target = crate::c2pa_validate::binding_target(original_store, false, profile)
926 .map_err(crate::map_validate_error)?
927 .ok_or_else(|| Error::Verification("E-RTMP manifest has no unique hard binding".into()))?;
928 if target.binding_assertion_label != "c2pa.hash.data"
929 && !target
930 .binding_assertion_label
931 .starts_with("c2pa.hash.data__")
932 {
933 return Err(Error::Verification(
934 "E-RTMP manifest hard binding is not c2pa.hash.data".into(),
935 ));
936 }
937 let manifest = parsed
938 .manifests
939 .iter()
940 .find(|manifest| manifest.label == target.binding_manifest_label)
941 .ok_or_else(|| Error::Verification("binding manifest is absent".into()))?;
942 let assertion = manifest
943 .assertions
944 .iter()
945 .find(|(label, _)| label.as_str() == target.binding_assertion_label.as_str())
946 .and_then(|(_, bytes)| decode(bytes).ok())
947 .ok_or_else(|| Error::Verification("E-RTMP data hash is not decodable".into()))?;
948 let map = assertion
949 .as_map()
950 .ok_or_else(|| Error::Verification("E-RTMP data hash is not a map".into()))?;
951 if unique(map, "exclusions").is_some() {
952 return Err(Error::Verification(
953 "E-RTMP initialization data hash must not contain an exclusions field".into(),
954 ));
955 }
956 Ok(())
957}
958
959fn claim_references(manifest: &crate::c2pa_core::jumbf::ParsedManifest<'_>, label: &str) -> bool {
960 let Some(claim) = manifest.claim_cbor.and_then(|bytes| decode(bytes).ok()) else {
961 return false;
962 };
963 ["assertions", "created_assertions", "gathered_assertions"]
964 .iter()
965 .filter_map(|field| match claim.get(field) {
966 Some(Value::Array(entries)) => Some(entries.as_slice()),
967 _ => None,
968 })
969 .flatten()
970 .filter_map(|entry| entry.get("url").and_then(Value::as_text))
971 .any(|url| url.rsplit('/').next() == Some(label))
972}
973
974fn initialization_data(configs: &BTreeMap<ErtmpTrack, Config<'_>>) -> Vec<u8> {
975 let total = configs.values().map(|config| config.payload.len()).sum();
976 let mut bytes = Vec::with_capacity(total);
977 for config in configs.values() {
978 bytes.extend_from_slice(config.payload);
979 }
980 bytes
981}
982
983fn segment_digest(algorithm: &str, frames: &[Frame<'_>]) -> Option<Vec<u8>> {
984 macro_rules! digest {
985 ($hasher:ty) => {{
986 let mut hasher = <$hasher>::new();
987 for frame in frames {
988 hasher.update(frame.timestamp.to_be_bytes());
989 let offset = frame.composition_time_offset as u32;
990 hasher.update([
991 ((offset >> 16) & 0xff) as u8,
992 ((offset >> 8) & 0xff) as u8,
993 (offset & 0xff) as u8,
994 ]);
995 hasher.update(frame.coded_data);
996 }
997 Some(hasher.finalize().to_vec())
998 }};
999 }
1000 match algorithm {
1001 "sha256" => digest!(Sha256),
1002 "sha384" => digest!(Sha384),
1003 "sha512" => digest!(Sha512),
1004 _ => None,
1005 }
1006}
1007
1008fn has_exact_keys(map: &[(Value, Value)], allowed: &[&str]) -> bool {
1009 map.iter().enumerate().all(|(index, (key, _))| {
1010 let Some(key) = key.as_text() else {
1011 return false;
1012 };
1013 allowed.contains(&key)
1014 && !map[..index]
1015 .iter()
1016 .any(|(prior, _)| prior.as_text() == Some(key))
1017 })
1018}
1019
1020fn unique<'a>(map: &'a [(Value, Value)], name: &str) -> Option<&'a Value> {
1021 let mut values = map
1022 .iter()
1023 .filter(|(key, _)| key.as_text() == Some(name))
1024 .map(|(_, value)| value);
1025 let value = values.next()?;
1026 values.next().is_none().then_some(value)
1027}
1028
1029fn unsigned(value: Option<&Value>) -> Option<u64> {
1030 match value {
1031 Some(Value::Integer(value)) if *value >= 0 => u64::try_from(*value).ok(),
1032 _ => None,
1033 }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038 use super::*;
1039
1040 fn mark_active_manifest(verifier: &mut Verifier<'_>, revision: u64) {
1041 verifier.active_manifest = Some(ActiveManifest {
1042 manifest_id: "urn:c2pa:test".into(),
1043 config_revision: revision,
1044 delivered_since_reconnect: true,
1045 });
1046 }
1047
1048 fn text(value: &str) -> Value {
1049 Value::Text(value.into())
1050 }
1051
1052 fn fake_segment_info(map: Value) -> Vec<u8> {
1053 let payload = crate::c2pa_cbor::encode(
1054 &map,
1055 crate::c2pa_cbor::Profile::CanonicalForHashedSubstructures,
1056 )
1057 .unwrap();
1058 crate::c2pa_cbor::encode(
1059 &Value::Tag(
1060 18,
1061 Box::new(Value::Array(vec![
1062 Value::Bytes(Vec::new()),
1063 Value::Map(Vec::new()),
1064 Value::Bytes(payload),
1065 Value::Bytes(Vec::new()),
1066 ])),
1067 ),
1068 crate::c2pa_cbor::Profile::CanonicalForHashedSubstructures,
1069 )
1070 .unwrap()
1071 }
1072
1073 fn signed_segment_info(map: Value) -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1074 use p256::ecdsa::{signature::Signer as _, Signature, SigningKey};
1075
1076 let profile = crate::c2pa_cbor::Profile::CanonicalForHashedSubstructures;
1077 let key = SigningKey::from_slice(&[0x5e; 32]).unwrap();
1078 let point = key.verifying_key().to_sec1_point(false);
1079 let key_id = b"stale-key".to_vec();
1080 let cose_key = crate::c2pa_cbor::encode(
1081 &Value::Map(vec![
1082 (Value::Integer(1), Value::Integer(2)),
1083 (Value::Integer(2), Value::Bytes(key_id.clone())),
1084 (Value::Integer(3), Value::Integer(-9)),
1085 (Value::Integer(-1), Value::Integer(1)),
1086 (
1087 Value::Integer(-2),
1088 Value::Bytes(point.x().unwrap().to_vec()),
1089 ),
1090 (
1091 Value::Integer(-3),
1092 Value::Bytes(point.y().unwrap().to_vec()),
1093 ),
1094 ]),
1095 profile,
1096 )
1097 .unwrap();
1098 let protected = crate::c2pa_cbor::encode(
1099 &Value::Map(vec![(Value::Integer(1), Value::Integer(-9))]),
1100 profile,
1101 )
1102 .unwrap();
1103 let payload = crate::c2pa_cbor::encode(&map, profile).unwrap();
1104 let to_sign = crate::c2pa_cbor::encode(
1105 &Value::Array(vec![
1106 text("Signature1"),
1107 Value::Bytes(protected.clone()),
1108 Value::Bytes(Vec::new()),
1109 Value::Bytes(payload.clone()),
1110 ]),
1111 profile,
1112 )
1113 .unwrap();
1114 let signature: Signature = key.sign(&to_sign);
1115 let cose_sign1 = crate::c2pa_cbor::encode(
1116 &Value::Tag(
1117 18,
1118 Box::new(Value::Array(vec![
1119 Value::Bytes(protected),
1120 Value::Map(vec![(Value::Integer(4), Value::Bytes(key_id.clone()))]),
1121 Value::Bytes(payload),
1122 Value::Bytes(signature.to_bytes().to_vec()),
1123 ])),
1124 ),
1125 profile,
1126 )
1127 .unwrap();
1128 (cose_sign1, cose_key, key_id)
1129 }
1130
1131 fn segment_info_map(manifest_id: &str, hash: Vec<u8>) -> Vec<(Value, Value)> {
1132 vec![
1133 (text("mediaType"), text("video")),
1134 (text("sequenceNumber"), Value::Integer(0)),
1135 (
1136 text("segmentHash"),
1137 Value::Map(vec![
1138 (text("alg"), text("sha256")),
1139 (text("hash"), Value::Bytes(hash)),
1140 ]),
1141 ),
1142 (text("manifestId"), text(manifest_id)),
1143 ]
1144 }
1145
1146 #[test]
1147 fn initialization_data_is_video_then_audio_in_ascending_track_order() {
1148 let mut configs = BTreeMap::new();
1149 let entries = [
1150 (
1151 ErtmpTrack {
1152 media_type: ErtmpMediaType::Audio,
1153 track_id: 2,
1154 },
1155 b"a2".as_slice(),
1156 ),
1157 (
1158 ErtmpTrack {
1159 media_type: ErtmpMediaType::Video,
1160 track_id: 1,
1161 },
1162 b"v1".as_slice(),
1163 ),
1164 (
1165 ErtmpTrack {
1166 media_type: ErtmpMediaType::Audio,
1167 track_id: 0,
1168 },
1169 b"a0".as_slice(),
1170 ),
1171 (
1172 ErtmpTrack {
1173 media_type: ErtmpMediaType::Video,
1174 track_id: 0,
1175 },
1176 b"v0".as_slice(),
1177 ),
1178 ];
1179 for (revision, (track, payload)) in entries.into_iter().enumerate() {
1180 configs.insert(
1181 track,
1182 Config {
1183 chunk_stream_id: 4,
1184 payload,
1185 revision: revision as u64,
1186 },
1187 );
1188 }
1189 assert_eq!(initialization_data(&configs), b"v0v1a0a2");
1190 }
1191
1192 #[test]
1193 fn present_empty_opus_configuration_remains_distinct_from_absent_configuration() {
1194 let options = VerifyOptions {
1195 spec_version: Some("2.5".into()),
1196 ..VerifyOptions::default()
1197 };
1198 let profile =
1199 resolve_profile(options.spec_version.as_deref(), options.strict_conformance).unwrap();
1200 let validation_time = parse_validation_time(None).unwrap();
1201 let event = ErtmpEvent::CodecConfig {
1202 track: ErtmpTrack {
1203 media_type: ErtmpMediaType::Audio,
1204 track_id: 0,
1205 },
1206 chunk_stream_id: 4,
1207 payload: Vec::new(),
1208 };
1209 let mut verifier = Verifier::new(profile, &options, validation_time);
1210 verifier.event(&event).unwrap();
1211 let config = verifier.configs.get(&ErtmpTrack {
1212 media_type: ErtmpMediaType::Audio,
1213 track_id: 0,
1214 });
1215 assert!(config.is_some_and(|config| config.payload.is_empty()));
1216 }
1217
1218 #[test]
1219 fn frames_require_manifest_delivery_after_the_active_configuration() {
1220 let options = VerifyOptions {
1221 spec_version: Some("2.5".into()),
1222 ..VerifyOptions::default()
1223 };
1224 let profile =
1225 resolve_profile(options.spec_version.as_deref(), options.strict_conformance).unwrap();
1226 let validation_time = parse_validation_time(None).unwrap();
1227 let track = ErtmpTrack {
1228 media_type: ErtmpMediaType::Video,
1229 track_id: 0,
1230 };
1231 let config = ErtmpEvent::CodecConfig {
1232 track,
1233 chunk_stream_id: 4,
1234 payload: b"config".to_vec(),
1235 };
1236 let frame = ErtmpEvent::Frame {
1237 track,
1238 chunk_stream_id: 4,
1239 timestamp: 1,
1240 composition_time_offset: 0,
1241 coded_data: b"frame".to_vec(),
1242 };
1243
1244 let mut valid = Verifier::new(profile, &options, validation_time);
1245 valid.event(&config).unwrap();
1246 let revision = valid.next_config_revision.saturating_sub(1);
1247 mark_active_manifest(&mut valid, revision);
1248 valid.event(&frame).unwrap();
1249 assert!(valid.failures.is_empty());
1250
1251 let mut reordered = Verifier::new(profile, &options, validation_time);
1252 mark_active_manifest(&mut reordered, 0);
1253 reordered.event(&config).unwrap();
1254 reordered.event(&frame).unwrap();
1255 assert!(reordered.failures.iter().any(|failure| failure
1256 == "coded frame arrived before a manifest was delivered for the active codec configuration"));
1257 }
1258
1259 #[test]
1260 fn reconnect_requires_config_then_manifest_before_frames_resume() {
1261 let options = VerifyOptions {
1262 spec_version: Some("2.5".into()),
1263 ..VerifyOptions::default()
1264 };
1265 let profile =
1266 resolve_profile(options.spec_version.as_deref(), options.strict_conformance).unwrap();
1267 let validation_time = parse_validation_time(None).unwrap();
1268 let track = ErtmpTrack {
1269 media_type: ErtmpMediaType::Video,
1270 track_id: 0,
1271 };
1272 let config = ErtmpEvent::CodecConfig {
1273 track,
1274 chunk_stream_id: 4,
1275 payload: b"config".to_vec(),
1276 };
1277 let frame = ErtmpEvent::Frame {
1278 track,
1279 chunk_stream_id: 4,
1280 timestamp: 1,
1281 composition_time_offset: 0,
1282 coded_data: b"frame".to_vec(),
1283 };
1284 let reconnect = ErtmpEvent::Reconnect;
1285
1286 let mut valid = Verifier::new(profile, &options, validation_time);
1287 valid.event(&config).unwrap();
1288 let revision = valid.next_config_revision.saturating_sub(1);
1289 mark_active_manifest(&mut valid, revision);
1290 valid.event(&reconnect).unwrap();
1291 valid.event(&config).unwrap();
1292 let revision = valid.next_config_revision.saturating_sub(1);
1293 mark_active_manifest(&mut valid, revision);
1294 valid.event(&frame).unwrap();
1295 assert!(valid.failures.is_empty());
1296
1297 let mut reordered = Verifier::new(profile, &options, validation_time);
1298 reordered.event(&config).unwrap();
1299 let revision = reordered.next_config_revision.saturating_sub(1);
1300 mark_active_manifest(&mut reordered, revision);
1301 reordered.event(&reconnect).unwrap();
1302 let revision = reordered.next_config_revision.saturating_sub(1);
1303 mark_active_manifest(&mut reordered, revision);
1304 reordered.event(&config).unwrap();
1305 reordered.event(&frame).unwrap();
1306 assert!(reordered.failures.iter().any(|failure| failure
1307 == "coded frame arrived before a manifest was delivered for the active codec configuration"));
1308 }
1309
1310 #[test]
1311 fn segment_info_schema_rejects_unknown_fields_aliases_and_wrong_digest_lengths() {
1312 let valid = segment_info_map("urn:c2pa:a", vec![0; 32]);
1313 assert!(untrusted_segment_info(&fake_segment_info(Value::Map(valid.clone()))).is_ok());
1314
1315 let mut unknown = valid.clone();
1316 unknown.push((text("extra"), Value::Integer(1)));
1317 assert!(
1318 untrusted_segment_info(&fake_segment_info(Value::Map(unknown)))
1319 .err()
1320 .expect("unknown field must fail")
1321 .contains("unknown or duplicate")
1322 );
1323
1324 let mut nested_unknown = valid.clone();
1325 let Value::Map(hash) = &mut nested_unknown[2].1 else {
1326 unreachable!("fixture segmentHash is a map");
1327 };
1328 hash.push((text("extra"), Value::Integer(1)));
1329 assert!(
1330 untrusted_segment_info(&fake_segment_info(Value::Map(nested_unknown)))
1331 .err()
1332 .expect("unknown hash field must fail")
1333 .contains("segmentHash fields")
1334 );
1335
1336 let mut alias = valid.clone();
1337 let Value::Map(hash) = &mut alias[2].1 else {
1338 unreachable!("fixture segmentHash is a map");
1339 };
1340 hash[0].1 = text("sha-256");
1341 assert!(
1342 untrusted_segment_info(&fake_segment_info(Value::Map(alias)))
1343 .err()
1344 .expect("unregistered algorithm must fail")
1345 .contains("algorithm is invalid")
1346 );
1347
1348 let wrong_length = segment_info_map("urn:c2pa:a", vec![0; 31]);
1349 assert!(
1350 untrusted_segment_info(&fake_segment_info(Value::Map(wrong_length)))
1351 .err()
1352 .expect("wrong digest length must fail")
1353 .contains("hash length")
1354 );
1355 }
1356
1357 #[test]
1358 fn signed_historical_manifest_key_cannot_authorize_a_new_segment() {
1359 let options = VerifyOptions {
1360 spec_version: Some("2.5".into()),
1361 ..VerifyOptions::default()
1362 };
1363 let profile =
1364 resolve_profile(options.spec_version.as_deref(), options.strict_conformance).unwrap();
1365 let validation_time = parse_validation_time(None).unwrap();
1366 let track = ErtmpTrack {
1367 media_type: ErtmpMediaType::Video,
1368 track_id: 0,
1369 };
1370 let frames = vec![Frame {
1371 chunk_stream_id: 4,
1372 timestamp: 1,
1373 composition_time_offset: 0,
1374 coded_data: b"frame",
1375 config_revision: 1,
1376 }];
1377 let hash = segment_digest("sha256", &frames).unwrap();
1378 let (cose_sign1, cose_key, key_id) =
1379 signed_segment_info(Value::Map(segment_info_map("manifest-a", hash)));
1380
1381 let mut verifier = Verifier::new(profile, &options, validation_time);
1382 verifier.configs.insert(
1383 track,
1384 Config {
1385 chunk_stream_id: 4,
1386 payload: b"config",
1387 revision: 1,
1388 },
1389 );
1390 verifier.next_config_revision = 2;
1391 verifier.tracks.insert(
1392 track,
1393 TrackState {
1394 frames,
1395 ..Default::default()
1396 },
1397 );
1398 verifier.manifests.insert(
1399 "manifest-a".into(),
1400 ManifestDelivery {
1401 store: Vec::new(),
1402 keys: vec![ValidatedSessionKey {
1403 cose_key,
1404 key_id: key_id.clone(),
1405 minimum_sequence_number: 0,
1406 created_at: validation_time - time::Duration::days(1),
1407 expires_at: validation_time + time::Duration::days(1),
1408 }],
1409 delivered_config_revision: 1,
1410 bound_initialization_sha256: Some(Sha256::digest(b"config").into()),
1411 report: None,
1412 },
1413 );
1414 verifier.active_manifest = Some(ActiveManifest {
1415 manifest_id: "manifest-a".into(),
1416 config_revision: 1,
1417 delivered_since_reconnect: true,
1418 });
1419 let accepted = verifier
1420 .authenticate_candidate(track, 4, 1, &cose_sign1)
1421 .unwrap();
1422 assert_eq!(accepted.key_id, key_id);
1423
1424 verifier.active_manifest = Some(ActiveManifest {
1425 manifest_id: "manifest-b".into(),
1426 config_revision: 1,
1427 delivered_since_reconnect: true,
1428 });
1429
1430 assert_eq!(
1431 verifier
1432 .authenticate_candidate(track, 4, 1, &cose_sign1)
1433 .err()
1434 .expect("historical manifest must fail"),
1435 "SegmentInfo manifestId does not match the active manifest"
1436 );
1437 }
1438
1439 #[test]
1440 fn segment_hash_covers_timestamp_signed_cto_and_coded_bytes() {
1441 let frame = Frame {
1442 chunk_stream_id: 6,
1443 timestamp: 0x0102_0304,
1444 composition_time_offset: -2,
1445 coded_data: b"frame",
1446 config_revision: 1,
1447 };
1448 let expected =
1449 Sha256::digest([&[0x01, 0x02, 0x03, 0x04, 0xff, 0xff, 0xfe][..], b"frame"].concat())
1450 .to_vec();
1451 assert_eq!(
1452 segment_digest("sha256", std::slice::from_ref(&frame)),
1453 Some(expected)
1454 );
1455 assert!(segment_digest("sha-256", std::slice::from_ref(&frame)).is_none());
1456 }
1457
1458 #[test]
1459 fn ertmp_refuses_non_25_policy_before_event_processing() {
1460 let input = ErtmpInput {
1461 client_caps_ex: C2PA_CAPABILITY,
1462 server_caps_ex: C2PA_CAPABILITY,
1463 object_encoding: 3,
1464 events: Vec::new(),
1465 };
1466 let options = VerifyOptions {
1467 spec_version: Some("2.2".into()),
1468 ..VerifyOptions::default()
1469 };
1470 assert!(matches!(
1471 verify_ertmp_with_options(&input, &options),
1472 Err(Error::UnsupportedSpecVersion(_))
1473 ));
1474 }
1475}