Skip to main content

encypher_c2pa/
ertmp.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4//! Verification of C2PA live-media carriage over Enhanced RTMP.
5//!
6//! Callers may supply normalized events directly or use the bounded FLV and RTMP
7//! wire adapters re-exported by this crate. Direct event callers remain
8//! responsible for normalized Aggregate timestamps and selecting individual
9//! Multitrack packets. No audio Metadata packet number is assumed here: the
10//! pinned E-RTMP revision does not define one.
11
12use 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/// E-RTMP media type. Audio and video track-id namespaces are independent.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum ErtmpMediaType {
40    /// A video track.
41    Video,
42    /// An audio track.
43    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/// One E-RTMP track. An absent wire `trackId` is represented as zero.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
57pub struct ErtmpTrack {
58    /// Audio or video.
59    pub media_type: ErtmpMediaType,
60    /// E-RTMP multitrack id, or zero for a message without multitrack signaling.
61    #[serde(default)]
62    pub track_id: u8,
63}
64
65/// One caller-parsed E-RTMP event, in transmission order.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
68pub enum ErtmpEvent {
69    /// The payload of a SequenceStart packet (or equivalent legacy sequence header).
70    CodecConfig {
71        /// Track carrying the configuration.
72        track: ErtmpTrack,
73        /// RTMP chunk stream carrying this track.
74        chunk_stream_id: u32,
75        /// Codec configuration payload only, without E-RTMP packet framing. A
76        /// present Opus SequenceStart may have a zero-byte payload.
77        payload: Vec<u8>,
78    },
79    /// One coded-frame packet. Protocol, configuration, metadata and control bytes
80    /// must not be included. Aggregate timestamps must already be normalized.
81    Frame {
82        /// Track carrying the frame. For a Multitrack message this is the selected packet.
83        track: ErtmpTrack,
84        /// RTMP chunk stream carrying the frame.
85        chunk_stream_id: u32,
86        /// RTMP message timestamp.
87        timestamp: u32,
88        /// Signed 24-bit composition time offset, or zero when absent.
89        composition_time_offset: i32,
90        /// Coded media bytes only.
91        coded_data: Vec<u8>,
92    },
93    /// One native AMF3 ByteArray [name, value] pair from a Metadata message.
94    Metadata {
95        /// Track on whose Metadata packet this pair arrived.
96        track: ErtmpTrack,
97        /// RTMP chunk stream carrying that track and this Metadata message.
98        chunk_stream_id: u32,
99        /// RTMP message timestamp of the Metadata message.
100        timestamp: u32,
101        /// Pair name. Recognized values are `c2paManifest` and `c2paSegmentInfo`.
102        name: String,
103        /// Native AMF3 ByteArray value, already removed from AMF framing.
104        value: Vec<u8>,
105    },
106    /// A transport reconnection. C2PA manifest, key and sequence state survives,
107    /// but no logical segment may span this boundary.
108    Reconnect,
109}
110
111/// Ordered input to E-RTMP verification.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(deny_unknown_fields)]
114pub struct ErtmpInput {
115    /// `capsEx` sent by the client.
116    pub client_caps_ex: u64,
117    /// `capsEx` returned by the server.
118    pub server_caps_ex: u64,
119    /// AMF object encoding selected by the connection. Must be 3.
120    pub object_encoding: u8,
121    /// Caller-parsed events in exact transmission order.
122    pub events: Vec<ErtmpEvent>,
123}
124
125/// Result for one closed logical E-RTMP segment.
126#[derive(Debug, Clone, Serialize)]
127pub struct ErtmpSegmentReport {
128    /// Track the segment belongs to.
129    pub track: ErtmpTrack,
130    /// Signed per-track sequence number, when one candidate authenticated.
131    pub sequence_number: Option<u64>,
132    /// Manifest identifier authenticated by the accepted candidate.
133    pub manifest_id: Option<String>,
134    /// Lowercase hexadecimal session-key id used by the accepted candidate.
135    pub key_id: Option<String>,
136    /// Whether at least one consecutive rotated-key candidate validated.
137    pub valid: bool,
138    /// Candidate and carriage defects. Empty for a valid segment.
139    pub failures: Vec<String>,
140}
141
142/// One manifest first exercised by an E-RTMP segment.
143#[derive(Debug, Clone, Serialize)]
144pub struct ErtmpManifestReport {
145    /// Active manifest label.
146    pub manifest_id: String,
147    /// Ordinary C2PA verification against the initialization-data bytes that
148    /// existed immediately before the first referencing SegmentInfo.
149    pub report: VerificationReport,
150}
151
152/// Result of [`verify_ertmp_with_options`].
153#[derive(Debug, Clone, Serialize)]
154pub struct ErtmpVerificationReport {
155    /// Public report schema version.
156    pub schema_version: String,
157    /// `valid` only when capability negotiation, every closed segment, every
158    /// referenced manifest and every reconnection rule validated.
159    pub integrity: String,
160    /// Manifests validated at first use.
161    pub manifests: Vec<ErtmpManifestReport>,
162    /// Logical segments, in closure order.
163    pub segments: Vec<ErtmpSegmentReport>,
164    /// Stream-wide transport/state failures.
165    pub failures: Vec<String>,
166}
167
168impl ErtmpVerificationReport {
169    /// Serialize as compact JSON.
170    pub fn to_json(&self) -> Result<String, Error> {
171        serde_json::to_string(self).map_err(Error::Serialize)
172    }
173
174    /// Serialize as indented JSON.
175    pub fn to_pretty_json(&self) -> Result<String, Error> {
176        serde_json::to_string_pretty(self).map_err(Error::Serialize)
177    }
178}
179
180/// Verify ordered E-RTMP events under the explicitly selected C2PA 2.5 policy.
181pub 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
213/// [`verify_ertmp_with_options`] with 2.5 selected and all other defaults kept.
214pub 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                // E-RTMP requires unknown pairs to be ignored while processing
425                // the remaining pairs in the Metadata message.
426            }
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}