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