Skip to main content

encypher_c2pa/
stream.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4//! Fragmented and live-stream verification.
5//!
6//! [`verify_fragmented`](crate::verify_fragmented) answers one question: does
7//! this Merkle-bound stream's init segment bind the fragments I was given? It
8//! now fails closed when the answer is "this manifest binds no fragments at
9//! all". [`verify_stream`] is the entry a live pipeline wants: it takes the
10//! encapsulation and protection method the packager declared, gates the
11//! declared brands, and then reads the INIT MANIFEST - never the caller - to
12//! decide whether the segments are authenticated by C2PA 2.4 session keys, by a
13//! Merkle tree, or one manifest at a time.
14
15use serde::{Deserialize, Serialize};
16use time::OffsetDateTime;
17
18use crate::c2pa_formats::{
19    StreamEncapsulation as KernelEncapsulation, StreamMethod as KernelMethod,
20};
21use crate::c2pa_validate::stream::{verify_stream_safe_with_expected_seeks, StreamVerifyOutput};
22use crate::online::{self, NetworkNeed};
23use crate::{
24    map_validate_error, parse_stream_presentation_times, report_from_output, resolve_mime, Error,
25    NetworkReport, ResolvedOptions, VerificationReport, VerifyOptions, REPORT_SCHEMA_VERSION,
26};
27
28/// How a fragmented stream is packaged.
29///
30/// The serialized form is the C2PA Conformance Program 0.2 Generator Product
31/// spelling, so a declaration round-trips through the conforming-products list
32/// unchanged. [`Self::from_token`] is ASCII case-insensitive, so a lowercase
33/// CLI flag or JSON value resolves to the same value.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35pub enum StreamEncapsulation {
36    /// Fragmented MP4: an ISOBMFF init segment plus movie-fragment media
37    /// segments (DASH/HLS packaging).
38    #[serde(rename = "fMP4")]
39    Fmp4,
40    /// Common Media Application Format, a strict profile of fragmented MP4.
41    #[serde(rename = "CMAF")]
42    Cmaf,
43}
44
45impl StreamEncapsulation {
46    /// The declaration token (`"fMP4"`, `"CMAF"`).
47    pub const fn token(self) -> &'static str {
48        self.kernel().token()
49    }
50
51    /// Resolve a declaration token, ASCII case-insensitively.
52    pub fn from_token(token: &str) -> Option<Self> {
53        KernelEncapsulation::from_token(token).map(|value| match value {
54            KernelEncapsulation::Fmp4 => Self::Fmp4,
55            KernelEncapsulation::Cmaf => Self::Cmaf,
56        })
57    }
58
59    const fn kernel(self) -> KernelEncapsulation {
60        match self {
61            Self::Fmp4 => KernelEncapsulation::Fmp4,
62            Self::Cmaf => KernelEncapsulation::Cmaf,
63        }
64    }
65}
66
67impl std::fmt::Display for StreamEncapsulation {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.write_str(self.token())
70    }
71}
72
73/// The C2PA protection method applied to a fragmented stream.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75pub enum StreamMethod {
76    /// One manifest in the init segment binds every media segment. Which
77    /// binding it used - a C2PA 2.4 `c2pa.session-keys` delivery whose segments
78    /// carry signed `emsg` verifiable segment information, or a
79    /// `c2pa.hash.bmff.v3` Merkle tree whose segments carry an auxiliary
80    /// `merkle` box - is read off that manifest, not declared by the caller.
81    #[serde(rename = "verifiable-segment-info")]
82    VerifiableSegmentInfo,
83    /// Every segment is an independently signed BMFF asset carrying its own
84    /// manifest, linked to its predecessor by a `parentOf` ingredient.
85    #[serde(rename = "per-segment")]
86    PerSegment,
87}
88
89impl StreamMethod {
90    /// The declaration token (`"verifiable-segment-info"`, `"per-segment"`).
91    pub const fn token(self) -> &'static str {
92        self.kernel().token()
93    }
94
95    /// Resolve a declaration token, ASCII case-insensitively.
96    pub fn from_token(token: &str) -> Option<Self> {
97        KernelMethod::from_token(token).map(|value| match value {
98            KernelMethod::VerifiableSegmentInfo => Self::VerifiableSegmentInfo,
99            KernelMethod::PerSegment => Self::PerSegment,
100        })
101    }
102
103    const fn kernel(self) -> KernelMethod {
104        match self {
105            Self::VerifiableSegmentInfo => KernelMethod::VerifiableSegmentInfo,
106            Self::PerSegment => KernelMethod::PerSegment,
107        }
108    }
109}
110
111impl std::fmt::Display for StreamMethod {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.write_str(self.token())
114    }
115}
116
117/// One per-segment stream position, verified as a standalone asset.
118#[derive(Debug, Clone, Serialize)]
119pub struct SegmentReport {
120    /// Zero-based position in the presented stream. The init segment is 0.
121    pub sequence_number: usize,
122    /// Active manifest label read from this segment, empty when it carries none.
123    pub manifest_label: String,
124    /// The predecessor this segment names, when it declares one.
125    pub previous_manifest_label: Option<String>,
126    /// What a consumer that received only this segment would see.
127    pub report: VerificationReport,
128}
129
130/// Result of [`verify_stream`].
131///
132/// `schema_version` tracks [`VerificationReport`]'s, and each embedded report
133/// is an ordinary report with the same schema, so an existing consumer can read
134/// them without change.
135#[derive(Debug, Clone, Serialize)]
136pub struct StreamVerificationReport {
137    /// Report schema version, matching [`VerificationReport::schema_version`].
138    pub schema_version: String,
139    /// The encapsulation the stream was verified under.
140    pub encapsulation: StreamEncapsulation,
141    /// The method the stream was verified under.
142    pub method: StreamMethod,
143    /// `"valid"` only when every presented byte was bound: for
144    /// [`StreamMethod::VerifiableSegmentInfo`] the init manifest's own
145    /// integrity (which now carries every segment's result); for
146    /// [`StreamMethod::PerSegment`] every segment's integrity AND the
147    /// recomputed chain.
148    pub integrity: String,
149    /// The init manifest's report, for [`StreamMethod::VerifiableSegmentInfo`].
150    pub stream: Option<VerificationReport>,
151    /// Per-segment reports, for [`StreamMethod::PerSegment`].
152    pub segments: Vec<SegmentReport>,
153    /// Whether the prior-manifest chain recomputed, for
154    /// [`StreamMethod::PerSegment`]. `None` for the other method, which has no
155    /// chain.
156    pub chain_valid: Option<bool>,
157    /// Why `chain_valid` is false, in stream order.
158    pub chain_failures: Vec<String>,
159    /// What this verification did, or could have done, on the network.
160    pub network: NetworkReport,
161}
162
163impl StreamVerificationReport {
164    /// Serialize as compact JSON.
165    pub fn to_json(&self) -> Result<String, Error> {
166        serde_json::to_string(self).map_err(Error::Serialize)
167    }
168
169    /// Serialize as indented JSON.
170    pub fn to_pretty_json(&self) -> Result<String, Error> {
171        serde_json::to_string_pretty(self).map_err(Error::Serialize)
172    }
173}
174
175/// Verify a fragmented ISO BMFF stream under a declared encapsulation and
176/// protection method, with default options.
177pub fn verify_stream(
178    init_segment: &[u8],
179    segments: &[&[u8]],
180    mime_type: &str,
181    encapsulation: StreamEncapsulation,
182    method: StreamMethod,
183) -> Result<StreamVerificationReport, Error> {
184    verify_stream_with_options(
185        init_segment,
186        segments,
187        mime_type,
188        encapsulation,
189        method,
190        &VerifyOptions::default(),
191    )
192}
193
194/// Verify a fragmented ISO BMFF stream with explicit trust, validation-time,
195/// and CAWG options.
196///
197/// `init_segment` is the stream's initialization segment. For
198/// [`StreamMethod::VerifiableSegmentInfo`] it carries the one manifest that
199/// binds the whole stream and `segments` are its media segments; for
200/// [`StreamMethod::PerSegment`] it is chain position 0 and `segments` follow it
201/// in playback order.
202///
203/// Both files' declared brands are gated against `encapsulation` first, so a
204/// stream presented as CMAF whose bytes are plain fMP4 is refused rather than
205/// verified under a declaration its bytes do not support. The brand gate is a
206/// C2PA Conformance Program 0.2 requirement, not a C2PA 2.4 rule.
207///
208/// [`VerifyOptions::expected_seek_positions`] marks intentional discontinuities
209/// by their zero-based indexes into `segments`.
210///
211/// Each session-key segment's key window is judged at
212/// [`VerifyOptions::validation_time`] (default now), or at the matching entry
213/// of [`VerifyOptions::stream_presentation_times`] when the caller supplies a
214/// trusted presentation instant for every segment. Those instants are parsed
215/// once here and must number exactly `segments.len()`.
216pub fn verify_stream_with_options(
217    init_segment: &[u8],
218    segments: &[&[u8]],
219    mime_type: &str,
220    encapsulation: StreamEncapsulation,
221    method: StreamMethod,
222    options: &VerifyOptions,
223) -> Result<StreamVerificationReport, Error> {
224    verify_stream_with_options_and_expected_seeks(
225        init_segment,
226        segments,
227        &options.expected_seek_positions,
228        mime_type,
229        encapsulation,
230        method,
231        options,
232    )
233}
234
235#[allow(clippy::too_many_arguments)]
236fn verify_stream_with_options_and_expected_seeks(
237    init_segment: &[u8],
238    segments: &[&[u8]],
239    expected_seek_positions: &[usize],
240    mime_type: &str,
241    encapsulation: StreamEncapsulation,
242    method: StreamMethod,
243    options: &VerifyOptions,
244) -> Result<StreamVerificationReport, Error> {
245    // Every BMFF type is in the 2.4 registry, so the policy-independent
246    // registry check is the right one; the BMFF test below narrows it.
247    let mime = resolve_mime(mime_type, crate::c2pa_core::EngineProfile::GENEROUS)?;
248    if crate::c2pa_formats::AssetFormat::from_canonical_mime(&mime)
249        != Some(crate::c2pa_formats::AssetFormat::Bmff)
250    {
251        return Err(Error::UnsupportedMime(mime));
252    }
253    let presentation_times =
254        parse_stream_presentation_times(options.stream_presentation_times.as_deref())?;
255    if let Some(times) = &presentation_times {
256        if times.len() != segments.len() {
257            return Err(Error::InvalidValidationTime(format!(
258                "stream_presentation_times: {} entries for {} supplied segment(s); \
259                 supply exactly one trusted presentation time per segment",
260                times.len(),
261                segments.len()
262            )));
263        }
264    }
265    let (mut report, needs) = stream_pass(
266        init_segment,
267        segments,
268        presentation_times.as_deref(),
269        expected_seek_positions,
270        &mime,
271        encapsulation,
272        method,
273        options,
274    )?;
275    let (network, evidence) = online::gather(options, &needs);
276    if evidence.is_empty() {
277        report.network = network;
278        return Ok(report);
279    }
280    // A stream carries its manifest in the init segment, so a remote manifest
281    // declaration does not apply here; the rest of the evidence does.
282    let next = online::apply_evidence(options, &evidence);
283    let (mut report, _) = stream_pass(
284        init_segment,
285        segments,
286        presentation_times.as_deref(),
287        expected_seek_positions,
288        &mime,
289        encapsulation,
290        method,
291        &next,
292    )?;
293    report.network = network;
294    Ok(report)
295}
296
297/// One offline stream verification, with the network needs it recorded.
298#[allow(clippy::too_many_arguments)]
299fn stream_pass(
300    init_segment: &[u8],
301    segments: &[&[u8]],
302    presentation_times: Option<&[OffsetDateTime]>,
303    expected_seek_positions: &[usize],
304    mime: &str,
305    encapsulation: StreamEncapsulation,
306    method: StreamMethod,
307    options: &VerifyOptions,
308) -> Result<(StreamVerificationReport, Vec<NetworkNeed>), Error> {
309    let mime = mime.to_string();
310    let resolved = ResolvedOptions::resolve(options)?;
311    let output = verify_stream_safe_with_expected_seeks(
312        &resolved.input(init_segment, &mime),
313        init_segment,
314        segments,
315        presentation_times,
316        expected_seek_positions,
317        encapsulation.kernel(),
318        method.kernel(),
319        resolved.cawg_trust(),
320        resolved.cawg_allowed_certs(),
321        resolved.cawg_document_signing_require_anchor,
322        options.cawg_did_documents.as_ref(),
323        options.cawg_ica_trusted_issuers.as_deref(),
324        options.cawg_ica_trust_anchors.as_deref(),
325        options.cawg_ica_status_lists.as_ref(),
326    )
327    .map_err(map_validate_error)?;
328
329    Ok(match output {
330        StreamVerifyOutput::VerifiableSegmentInfo(stream) => {
331            let needs = online::network_needs(&stream);
332            let report = report_from_output(*stream, mime, &resolved);
333            (
334                StreamVerificationReport {
335                    schema_version: REPORT_SCHEMA_VERSION.to_string(),
336                    encapsulation,
337                    method,
338                    integrity: report.integrity.clone(),
339                    stream: Some(report),
340                    segments: Vec::new(),
341                    chain_valid: None,
342                    chain_failures: Vec::new(),
343                    network: NetworkReport::default(),
344                },
345                needs,
346            )
347        }
348        StreamVerifyOutput::PerSegment(per_segment) => {
349            let mut needs = Vec::new();
350            let segments: Vec<SegmentReport> = per_segment
351                .segments
352                .into_iter()
353                .map(|segment| {
354                    needs.extend(online::network_needs(&segment.output));
355                    SegmentReport {
356                        sequence_number: segment.sequence_number,
357                        manifest_label: segment.manifest_label,
358                        previous_manifest_label: segment.previous_manifest_label,
359                        report: report_from_output(segment.output, mime.clone(), &resolved),
360                    }
361                })
362                .collect();
363            // A per-segment stream is only as good as its weakest link: one
364            // segment that does not verify, or one broken chain link, means the
365            // stream as presented is not the stream that was signed.
366            let integrity = if per_segment.chain_valid
367                && segments
368                    .iter()
369                    .all(|segment| segment.report.integrity == "valid")
370            {
371                "valid"
372            } else {
373                "invalid"
374            };
375            (
376                StreamVerificationReport {
377                    schema_version: REPORT_SCHEMA_VERSION.to_string(),
378                    encapsulation,
379                    method,
380                    integrity: integrity.to_string(),
381                    stream: None,
382                    segments,
383                    chain_valid: Some(per_segment.chain_valid),
384                    chain_failures: per_segment.chain_failures,
385                    network: NetworkReport::default(),
386                },
387                needs,
388            )
389        }
390    })
391}