Skip to main content

encypher_c2pa/
soft_binding.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4//! Read-only client for the C2PA Soft Binding Resolution API.
5
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value as Json};
8use url::Url;
9
10use crate::online::{new_fetcher, FetchMethod, FetchRequest, Fetcher};
11use crate::{verify_with_manifest_store, Error, VerificationReport, VerifyOptions};
12
13const MAX_SERVICE_JSON_BYTES: usize = 1024 * 1024;
14const MAX_MANIFEST_BYTES: usize = 64 * 1024 * 1024;
15const DEFAULT_MAX_RESULTS: u16 = 10;
16const MAX_RESULTS: u16 = 100;
17
18/// Explicit consent carried by query variants that disclose asset material.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum SoftBindingDisclosureConsent {
22    /// Upload the asset supplied for local verification to the resolution service.
23    UploadAsset,
24    /// Disclose the supplied asset reference to the resolution service.
25    DiscloseReference,
26}
27
28/// One explicitly selected Soft Binding Resolution API query route.
29///
30/// Routes never fall back to one another. In particular, the resolver never
31/// uploads `asset` unless `Content` is selected with `UploadAsset` consent.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(tag = "route", rename_all = "snake_case", deny_unknown_fields)]
34pub enum SoftBindingQuery {
35    /// `GET /matches/byBinding`, preferred for values that fit in a URL.
36    BindingGet { alg: String, value: String },
37    /// `POST /matches/byBinding`, for large values.
38    BindingPost { alg: String, value: String },
39    /// `POST /matches/byContent`, uploading the asset bytes.
40    Content {
41        #[serde(default)]
42        alg: Option<String>,
43        #[serde(default)]
44        hint_alg: Option<String>,
45        #[serde(default)]
46        hint_value: Option<String>,
47        consent: SoftBindingDisclosureConsent,
48    },
49    /// Optional `POST /matches/byReference`, disclosing an HTTPS asset URL.
50    Reference {
51        #[serde(default)]
52        alg: Option<String>,
53        #[serde(default)]
54        hint_alg: Option<String>,
55        #[serde(default)]
56        hint_value: Option<String>,
57        reference_url: String,
58        asset_length: u64,
59        #[serde(default)]
60        asset_type: Option<String>,
61        #[serde(default)]
62        region: Option<Json>,
63        consent: SoftBindingDisclosureConsent,
64    },
65}
66
67impl SoftBindingQuery {
68    fn algorithm(&self) -> Option<&str> {
69        match self {
70            Self::BindingGet { alg, .. } | Self::BindingPost { alg, .. } => Some(alg),
71            Self::Content { alg, .. } | Self::Reference { alg, .. } => alg.as_deref(),
72        }
73    }
74
75    fn hint_algorithm(&self) -> Option<&str> {
76        match self {
77            Self::Content { hint_alg, .. } | Self::Reference { hint_alg, .. } => {
78                hint_alg.as_deref()
79            }
80            Self::BindingGet { .. } | Self::BindingPost { .. } => None,
81        }
82    }
83    fn validate(&self) -> Result<(), Error> {
84        let nonempty = |value: &str, name: &str| {
85            if value.trim().is_empty() {
86                Err(Error::Verification(format!(
87                    "soft-binding query requires non-empty {name}"
88                )))
89            } else {
90                Ok(())
91            }
92        };
93        match self {
94            Self::BindingGet { alg, value } | Self::BindingPost { alg, value } => {
95                nonempty(alg, "alg")?;
96                nonempty(value, "value")?;
97            }
98            Self::Content {
99                alg,
100                hint_alg,
101                hint_value,
102                consent,
103            } => {
104                if *consent != SoftBindingDisclosureConsent::UploadAsset {
105                    return Err(Error::Verification(
106                        "byContent requires upload_asset consent".into(),
107                    ));
108                }
109                validate_optional_query_fields(alg, hint_alg, hint_value)?;
110            }
111            Self::Reference {
112                alg,
113                hint_alg,
114                hint_value,
115                reference_url,
116                asset_length,
117                consent,
118                ..
119            } => {
120                if *consent != SoftBindingDisclosureConsent::DiscloseReference {
121                    return Err(Error::Verification(
122                        "byReference requires disclose_reference consent".into(),
123                    ));
124                }
125                validate_optional_query_fields(alg, hint_alg, hint_value)?;
126                if *asset_length == 0 {
127                    return Err(Error::Verification(
128                        "byReference asset_length must be positive".into(),
129                    ));
130                }
131                let reference = Url::parse(reference_url).map_err(|_| {
132                    Error::Verification("byReference reference_url is not an absolute URL".into())
133                })?;
134                if reference.scheme() != "https" || reference.host_str().is_none() {
135                    return Err(Error::Verification(
136                        "byReference reference_url must be HTTPS".into(),
137                    ));
138                }
139            }
140        }
141        Ok(())
142    }
143}
144
145fn validate_optional_query_fields(
146    alg: &Option<String>,
147    hint_alg: &Option<String>,
148    hint_value: &Option<String>,
149) -> Result<(), Error> {
150    if alg.as_ref().is_some_and(|value| value.trim().is_empty()) {
151        return Err(Error::Verification("soft-binding alg is empty".into()));
152    }
153    match (hint_alg, hint_value) {
154        (None, None) => Ok(()),
155        (Some(alg), Some(value)) if !alg.trim().is_empty() && !value.trim().is_empty() => Ok(()),
156        _ => Err(Error::Verification(
157            "soft-binding hint_alg and hint_value must be supplied together and non-empty".into(),
158        )),
159    }
160}
161
162/// Network and optional read-only evidence policy for soft-binding recovery.
163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
164#[serde(default, deny_unknown_fields)]
165pub struct SoftBindingResolverOptions {
166    /// Exact allowed origins (`scheme://host[:port]`).
167    pub allowed_origins: Vec<String>,
168    /// Permit private/link-local destinations and plaintext HTTP for an
169    /// explicitly trusted intranet deployment.
170    pub allow_private_networks: bool,
171    /// Maximum matches requested, from 1 through 100. Zero uses 10.
172    pub max_results: u16,
173    /// Fetch the service status document when discovery advertises one.
174    pub fetch_status: bool,
175    /// Fetch each recovered manifest's repository-reported receipt result.
176    pub fetch_receipts: bool,
177}
178
179/// Service discovery document.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct SoftBindingDiscovery {
183    pub api_endpoint: String,
184    pub c2pa_specification_version: String,
185    #[serde(default)]
186    pub capabilities_endpoint: Option<String>,
187    #[serde(default)]
188    pub status_endpoint: Option<String>,
189}
190
191/// Service capability document. Unknown capability names remain visible.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct SoftBindingCapabilities {
195    pub c2pa_specification_version: String,
196    pub supported_capabilities: Vec<String>,
197}
198
199/// One registered algorithm returned by a service.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct SoftBindingAlgorithm {
202    pub alg: String,
203}
204
205/// Algorithms a service accepts for query operations.
206#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207pub struct SoftBindingAlgorithms {
208    #[serde(default)]
209    pub watermarks: Vec<SoftBindingAlgorithm>,
210    #[serde(default)]
211    pub fingerprints: Vec<SoftBindingAlgorithm>,
212}
213
214/// One query match from a local or federated repository.
215#[derive(Debug, Clone, Serialize, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct SoftBindingMatch {
218    pub manifest_id: String,
219    #[serde(default)]
220    pub endpoint: Option<String>,
221    #[serde(default)]
222    pub similarity_score: Option<u8>,
223}
224
225#[derive(Debug, Deserialize)]
226struct QueryResult {
227    #[serde(default)]
228    matches: Vec<SoftBindingMatch>,
229}
230
231/// Repository-reported receipt evidence. `server_reported_verified` is not a
232/// local cryptographic receipt verdict.
233#[derive(Debug, Clone, Serialize)]
234pub struct SoftBindingReceiptEvidence {
235    pub receipt: Json,
236    pub server_reported_verified: bool,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub server_error: Option<String>,
239}
240
241/// A fetched manifest store and its ordinary local C2PA verification report.
242#[derive(Debug, Serialize)]
243pub struct RecoveredSoftBindingManifest {
244    pub matched: SoftBindingMatch,
245    #[serde(skip_serializing)]
246    pub manifest_store: Vec<u8>,
247    pub report: VerificationReport,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub receipt: Option<SoftBindingReceiptEvidence>,
250}
251
252/// Complete evidence from one read-only resolution flow.
253#[derive(Debug, Serialize)]
254pub struct SoftBindingResolution {
255    pub discovery: SoftBindingDiscovery,
256    pub capabilities: SoftBindingCapabilities,
257    pub algorithms: SoftBindingAlgorithms,
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub status: Option<Json>,
260    pub recovered: Vec<RecoveredSoftBindingManifest>,
261}
262
263/// HTTP method emitted by the transport-independent resolver.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum SoftBindingHttpMethod {
266    Get,
267    Post,
268}
269
270/// One bounded read-only request emitted by [`SoftBindingResolverSession`].
271pub struct SoftBindingFetchRequest<'a> {
272    pub method: SoftBindingHttpMethod,
273    pub url: &'a str,
274    pub content_type: Option<&'a str>,
275    pub accept: &'static str,
276    pub body: Option<&'a [u8]>,
277    pub max_response_bytes: usize,
278}
279
280enum RequestBody<'a> {
281    None,
282    Borrowed(&'a [u8]),
283    Owned(Vec<u8>),
284}
285
286impl RequestBody<'_> {
287    fn as_slice(&self) -> Option<&[u8]> {
288        match self {
289            Self::None => None,
290            Self::Borrowed(bytes) => Some(bytes),
291            Self::Owned(bytes) => Some(bytes),
292        }
293    }
294}
295
296enum PendingKind {
297    Discovery,
298    Capabilities,
299    Status,
300    Algorithms,
301    Query,
302    Manifest(SoftBindingMatch, Url),
303    Receipt(usize),
304}
305
306struct PendingRequest<'a> {
307    method: SoftBindingHttpMethod,
308    url: Url,
309    content_type: Option<&'a str>,
310    accept: &'static str,
311    body: RequestBody<'a>,
312    max_response_bytes: usize,
313    kind: PendingKind,
314}
315
316/// Transport-independent Soft Binding Resolution API state machine.
317///
318/// A transport MUST issue the exact method, URL, headers, and body returned by
319/// `next_request`, refuse redirects, omit ambient credentials, enforce its own
320/// egress policy, and pass the response's final URL to `accept_response`.
321pub struct SoftBindingResolverSession<'a> {
322    asset: &'a [u8],
323    mime_type: &'a str,
324    query: &'a SoftBindingQuery,
325    verify_options: &'a VerifyOptions,
326    options: &'a SoftBindingResolverOptions,
327    root: Url,
328    allowed: Vec<String>,
329    max_results: u16,
330    api: Option<Url>,
331    discovery: Option<SoftBindingDiscovery>,
332    capabilities: Option<SoftBindingCapabilities>,
333    algorithms: Option<SoftBindingAlgorithms>,
334    status: Option<Json>,
335    matches: std::vec::IntoIter<SoftBindingMatch>,
336    recovered: Vec<RecoveredSoftBindingManifest>,
337    pending: Option<PendingRequest<'a>>,
338}
339
340impl<'a> SoftBindingResolverSession<'a> {
341    pub fn new(
342        asset: &'a [u8],
343        mime_type: &'a str,
344        service_root: &str,
345        query: &'a SoftBindingQuery,
346        verify_options: &'a VerifyOptions,
347        options: &'a SoftBindingResolverOptions,
348    ) -> Result<Self, Error> {
349        verify_options.validate()?;
350        if !crate::online::online_enabled(verify_options) {
351            return Err(Error::Verification(
352                "soft-binding resolution requires online=true or ENCYPHER_C2PA_ONLINE=on".into(),
353            ));
354        }
355        if mime_type.trim().is_empty() {
356            return Err(Error::Verification(
357                "soft-binding MIME type is empty".into(),
358            ));
359        }
360        query.validate()?;
361        let allowed = allowed_origins(&options.allowed_origins)?;
362        if allowed.is_empty() {
363            return Err(Error::Verification(
364                "soft-binding resolution requires at least one allowed origin".into(),
365            ));
366        }
367        let root = Url::parse(service_root).map_err(|error| {
368            Error::Verification(format!("invalid soft-binding service root: {error}"))
369        })?;
370        require_allowed(&root, &allowed)?;
371        let max_results = match options.max_results {
372            0 => DEFAULT_MAX_RESULTS,
373            value @ 1..=MAX_RESULTS => value,
374            value => {
375                return Err(Error::Verification(format!(
376                    "soft-binding max_results {value} exceeds {MAX_RESULTS}"
377                )))
378            }
379        };
380        let mut well_known = root.clone();
381        well_known.set_path("/.well-known/c2pa-soft-binding-resolution");
382        well_known.set_query(None);
383        well_known.set_fragment(None);
384        Ok(Self {
385            asset,
386            mime_type,
387            query,
388            verify_options,
389            options,
390            root,
391            allowed,
392            max_results,
393            api: None,
394            discovery: None,
395            capabilities: None,
396            algorithms: None,
397            status: None,
398            matches: Vec::new().into_iter(),
399            recovered: Vec::new(),
400            pending: Some(PendingRequest {
401                method: SoftBindingHttpMethod::Get,
402                url: well_known,
403                content_type: None,
404                accept: "application/json",
405                body: RequestBody::None,
406                max_response_bytes: MAX_SERVICE_JSON_BYTES,
407                kind: PendingKind::Discovery,
408            }),
409        })
410    }
411
412    pub fn next_request(&self) -> Option<SoftBindingFetchRequest<'_>> {
413        self.pending
414            .as_ref()
415            .map(|request| SoftBindingFetchRequest {
416                method: request.method,
417                url: request.url.as_str(),
418                content_type: request.content_type,
419                accept: request.accept,
420                body: request.body.as_slice(),
421                max_response_bytes: request.max_response_bytes,
422            })
423    }
424
425    pub fn accept_response(&mut self, final_url: &str, bytes: Vec<u8>) -> Result<(), Error> {
426        let pending = self.pending.take().ok_or_else(|| {
427            Error::Verification("soft-binding session has no pending request".into())
428        })?;
429        let final_url = Url::parse(final_url).map_err(|_| {
430            Error::Verification("soft-binding transport returned an invalid final URL".into())
431        })?;
432        if final_url != pending.url {
433            return Err(Error::Verification(
434                "soft-binding redirect was refused".into(),
435            ));
436        }
437        if bytes.len() > pending.max_response_bytes {
438            return Err(Error::Verification(format!(
439                "soft-binding response exceeds {} bytes",
440                pending.max_response_bytes
441            )));
442        }
443        match pending.kind {
444            PendingKind::Discovery => self.accept_discovery(&bytes),
445            PendingKind::Capabilities => self.accept_capabilities(&bytes),
446            PendingKind::Status => self.accept_status(&bytes),
447            PendingKind::Algorithms => self.accept_algorithms(&bytes),
448            PendingKind::Query => self.accept_matches(&bytes),
449            PendingKind::Manifest(matched, repository) => {
450                self.accept_manifest(matched, repository, bytes)
451            }
452            PendingKind::Receipt(index) => self.accept_receipt(index, &bytes),
453        }
454    }
455
456    pub fn finish(self) -> Result<SoftBindingResolution, Error> {
457        if self.pending.is_some() {
458            return Err(Error::Verification(
459                "soft-binding session still has a pending request".into(),
460            ));
461        }
462        Ok(SoftBindingResolution {
463            discovery: self.discovery.ok_or_else(|| {
464                Error::Verification("soft-binding discovery did not complete".into())
465            })?,
466            capabilities: self.capabilities.ok_or_else(|| {
467                Error::Verification("soft-binding capabilities did not complete".into())
468            })?,
469            algorithms: self.algorithms.ok_or_else(|| {
470                Error::Verification("soft-binding algorithm discovery did not complete".into())
471            })?,
472            status: self.status,
473            recovered: self.recovered,
474        })
475    }
476
477    fn accept_discovery(&mut self, bytes: &[u8]) -> Result<(), Error> {
478        let discovery: SoftBindingDiscovery = parse_json(bytes, "soft-binding discovery")?;
479        let api = directory_endpoint(resolve_endpoint(
480            &self.root,
481            &discovery.api_endpoint,
482            &self.allowed,
483        )?);
484        let capabilities_url = match discovery.capabilities_endpoint.as_deref() {
485            Some(endpoint) => resolve_endpoint(&self.root, endpoint, &self.allowed)?,
486            None => api.join("services/capabilities").map_err(url_error)?,
487        };
488        require_allowed(&capabilities_url, &self.allowed)?;
489        self.api = Some(api);
490        self.discovery = Some(discovery);
491        self.pending = Some(json_get(capabilities_url, PendingKind::Capabilities));
492        Ok(())
493    }
494
495    fn accept_capabilities(&mut self, bytes: &[u8]) -> Result<(), Error> {
496        let capabilities: SoftBindingCapabilities = parse_json(bytes, "soft-binding capabilities")?;
497        match self.query {
498            SoftBindingQuery::Content { .. }
499                if !capabilities
500                    .supported_capabilities
501                    .iter()
502                    .any(|value| value == "queryByContent") =>
503            {
504                return Err(Error::Verification(
505                    "soft-binding service does not advertise queryByContent".into(),
506                ));
507            }
508            SoftBindingQuery::Reference { .. }
509                if !capabilities
510                    .supported_capabilities
511                    .iter()
512                    .any(|value| value == "queryByReference") =>
513            {
514                return Err(Error::Verification(
515                    "soft-binding service does not advertise queryByReference".into(),
516                ));
517            }
518            _ => {}
519        }
520        self.capabilities = Some(capabilities);
521        if self.options.fetch_status {
522            let endpoint = self
523                .discovery
524                .as_ref()
525                .and_then(|value| value.status_endpoint.as_deref())
526                .ok_or_else(|| {
527                    Error::Verification(
528                        "soft-binding status was requested but no endpoint was advertised".into(),
529                    )
530                })?;
531            let url = resolve_endpoint(&self.root, endpoint, &self.allowed)?;
532            self.pending = Some(json_get(url, PendingKind::Status));
533        } else {
534            self.schedule_algorithms()?;
535        }
536        Ok(())
537    }
538
539    fn accept_status(&mut self, bytes: &[u8]) -> Result<(), Error> {
540        let status: Json = parse_json(bytes, "soft-binding status")?;
541        if status.get("status").and_then(Json::as_str).is_none()
542            || status.get("timestamp").and_then(Json::as_str).is_none()
543        {
544            return Err(Error::Verification(
545                "soft-binding status response is malformed".into(),
546            ));
547        }
548        self.status = Some(status);
549        self.schedule_algorithms()
550    }
551
552    fn schedule_algorithms(&mut self) -> Result<(), Error> {
553        let url = self
554            .api
555            .as_ref()
556            .ok_or_else(|| Error::Verification("soft-binding API endpoint is absent".into()))?
557            .join("services/supportedAlgorithms")
558            .map_err(url_error)?;
559        require_allowed(&url, &self.allowed)?;
560        self.pending = Some(json_get(url, PendingKind::Algorithms));
561        Ok(())
562    }
563
564    fn accept_algorithms(&mut self, bytes: &[u8]) -> Result<(), Error> {
565        let algorithms: SoftBindingAlgorithms =
566            parse_json(bytes, "soft-binding supported algorithms")?;
567        for selected in [self.query.algorithm(), self.query.hint_algorithm()]
568            .into_iter()
569            .flatten()
570        {
571            if !algorithms
572                .watermarks
573                .iter()
574                .chain(&algorithms.fingerprints)
575                .any(|entry| entry.alg == selected)
576            {
577                return Err(Error::Verification(format!(
578                    "soft-binding service does not advertise algorithm {selected:?}"
579                )));
580            }
581        }
582        self.algorithms = Some(algorithms);
583        self.pending = Some(self.query_request()?);
584        Ok(())
585    }
586
587    fn query_request(&self) -> Result<PendingRequest<'a>, Error> {
588        let api = self
589            .api
590            .as_ref()
591            .ok_or_else(|| Error::Verification("soft-binding API endpoint is absent".into()))?;
592        let mut url;
593        let (method, content_type, body) = match self.query {
594            SoftBindingQuery::BindingGet { alg, value } => {
595                url = api.join("matches/byBinding").map_err(url_error)?;
596                url.query_pairs_mut()
597                    .append_pair("value", value)
598                    .append_pair("alg", alg)
599                    .append_pair("maxResults", &self.max_results.to_string());
600                (SoftBindingHttpMethod::Get, None, RequestBody::None)
601            }
602            SoftBindingQuery::BindingPost { alg, value } => {
603                url = api.join("matches/byBinding").map_err(url_error)?;
604                url.query_pairs_mut()
605                    .append_pair("maxResults", &self.max_results.to_string());
606                let body = serde_json::to_vec(&json!({"alg": alg, "value": value}))
607                    .map_err(Error::Serialize)?;
608                (
609                    SoftBindingHttpMethod::Post,
610                    Some("application/json"),
611                    RequestBody::Owned(body),
612                )
613            }
614            SoftBindingQuery::Content {
615                alg,
616                hint_alg,
617                hint_value,
618                ..
619            } => {
620                url = api.join("matches/byContent").map_err(url_error)?;
621                append_optional_query(&mut url, "alg", alg.as_deref());
622                append_optional_query(&mut url, "hintAlg", hint_alg.as_deref());
623                append_optional_query(&mut url, "hintValue", hint_value.as_deref());
624                url.query_pairs_mut()
625                    .append_pair("maxResults", &self.max_results.to_string());
626                (
627                    SoftBindingHttpMethod::Post,
628                    Some(self.mime_type),
629                    RequestBody::Borrowed(self.asset),
630                )
631            }
632            SoftBindingQuery::Reference {
633                alg,
634                hint_alg,
635                hint_value,
636                reference_url,
637                asset_length,
638                asset_type,
639                region,
640                ..
641            } => {
642                url = api.join("matches/byReference").map_err(url_error)?;
643                append_optional_query(&mut url, "alg", alg.as_deref());
644                append_optional_query(&mut url, "hintAlg", hint_alg.as_deref());
645                append_optional_query(&mut url, "hintValue", hint_value.as_deref());
646                url.query_pairs_mut()
647                    .append_pair("maxResults", &self.max_results.to_string());
648                let mut value = json!({
649                    "referenceUrl": reference_url,
650                    "assetLength": asset_length,
651                });
652                if let Some(asset_type) = asset_type {
653                    value["assetType"] = Json::String(asset_type.clone());
654                }
655                if let Some(region) = region {
656                    value["region"] = region.clone();
657                }
658                let body = serde_json::to_vec(&value).map_err(Error::Serialize)?;
659                (
660                    SoftBindingHttpMethod::Post,
661                    Some("application/json"),
662                    RequestBody::Owned(body),
663                )
664            }
665        };
666        require_allowed(&url, &self.allowed)?;
667        Ok(PendingRequest {
668            method,
669            url,
670            content_type,
671            accept: "application/json",
672            body,
673            max_response_bytes: MAX_SERVICE_JSON_BYTES,
674            kind: PendingKind::Query,
675        })
676    }
677
678    fn accept_matches(&mut self, bytes: &[u8]) -> Result<(), Error> {
679        let result: QueryResult = parse_json(bytes, "soft-binding query")?;
680        if result.matches.len() > usize::from(self.max_results) {
681            return Err(Error::Verification(format!(
682                "soft-binding service returned {} matches after a limit of {}",
683                result.matches.len(),
684                self.max_results
685            )));
686        }
687        for matched in &result.matches {
688            if matched.manifest_id.is_empty() {
689                return Err(Error::Verification(
690                    "soft-binding service returned an empty manifestId".into(),
691                ));
692            }
693            if matched.similarity_score.is_some_and(|score| score > 100) {
694                return Err(Error::Verification(
695                    "soft-binding service returned similarityScore above 100".into(),
696                ));
697            }
698        }
699        self.matches = result.matches.into_iter();
700        self.schedule_next_manifest()
701    }
702
703    fn schedule_next_manifest(&mut self) -> Result<(), Error> {
704        let Some(matched) = self.matches.next() else {
705            self.pending = None;
706            return Ok(());
707        };
708        let api = self
709            .api
710            .as_ref()
711            .ok_or_else(|| Error::Verification("soft-binding API endpoint is absent".into()))?;
712        let repository = directory_endpoint(match matched.endpoint.as_deref() {
713            Some(endpoint) => resolve_endpoint(api, endpoint, &self.allowed)?,
714            None => api.clone(),
715        });
716        let mut url = manifest_endpoint(&repository, &matched.manifest_id)?;
717        url.query_pairs_mut()
718            .append_pair("returnActiveManifest", "false");
719        require_allowed(&url, &self.allowed)?;
720        self.pending = Some(PendingRequest {
721            method: SoftBindingHttpMethod::Get,
722            url,
723            content_type: None,
724            accept: "application/c2pa",
725            body: RequestBody::None,
726            max_response_bytes: MAX_MANIFEST_BYTES,
727            kind: PendingKind::Manifest(matched, repository),
728        });
729        Ok(())
730    }
731
732    fn accept_manifest(
733        &mut self,
734        matched: SoftBindingMatch,
735        repository: Url,
736        manifest_store: Vec<u8>,
737    ) -> Result<(), Error> {
738        let report = verify_with_manifest_store(
739            self.asset,
740            &manifest_store,
741            self.mime_type,
742            self.verify_options,
743        )?;
744        self.recovered.push(RecoveredSoftBindingManifest {
745            matched,
746            manifest_store,
747            report,
748            receipt: None,
749        });
750        if self.options.fetch_receipts {
751            let index = self.recovered.len() - 1;
752            let manifest_id = &self.recovered[index].matched.manifest_id;
753            let url = receipt_endpoint(&repository, manifest_id)?;
754            require_allowed(&url, &self.allowed)?;
755            self.pending = Some(json_get(url, PendingKind::Receipt(index)));
756            Ok(())
757        } else {
758            self.schedule_next_manifest()
759        }
760    }
761
762    fn accept_receipt(&mut self, index: usize, bytes: &[u8]) -> Result<(), Error> {
763        let receipt: Json = parse_json(bytes, "soft-binding receipt")?;
764        self.recovered[index].receipt = Some(receipt_evidence(receipt)?);
765        self.schedule_next_manifest()
766    }
767}
768
769fn json_get<'a>(url: Url, kind: PendingKind) -> PendingRequest<'a> {
770    PendingRequest {
771        method: SoftBindingHttpMethod::Get,
772        url,
773        content_type: None,
774        accept: "application/json",
775        body: RequestBody::None,
776        max_response_bytes: MAX_SERVICE_JSON_BYTES,
777        kind,
778    }
779}
780
781enum ReceiptPendingKind {
782    Discovery,
783    Verification,
784}
785
786struct ReceiptPending {
787    method: SoftBindingHttpMethod,
788    url: Url,
789    body: Option<Vec<u8>>,
790    kind: ReceiptPendingKind,
791}
792
793/// Transport-independent session for the repository's explicit receipt POST.
794///
795/// The returned result is the repository's report. This session does not
796/// locally prove or cryptographically verify the receipt.
797pub struct SoftBindingReceiptVerificationSession {
798    manifest_id: String,
799    receipt: Json,
800    root: Url,
801    allowed: Vec<String>,
802    pending: Option<ReceiptPending>,
803    result: Option<SoftBindingReceiptEvidence>,
804}
805
806impl SoftBindingReceiptVerificationSession {
807    pub fn new(
808        service_root: &str,
809        manifest_id: &str,
810        receipt: Json,
811        verify_options: &VerifyOptions,
812        resolver_options: &SoftBindingResolverOptions,
813    ) -> Result<Self, Error> {
814        verify_options.validate()?;
815        if !crate::online::online_enabled(verify_options) {
816            return Err(Error::Verification(
817                "soft-binding receipt submission requires online=true or ENCYPHER_C2PA_ONLINE=on"
818                    .into(),
819            ));
820        }
821        if manifest_id.trim().is_empty() {
822            return Err(Error::Verification(
823                "soft-binding receipt manifest id is empty".into(),
824            ));
825        }
826        if !receipt.is_object() {
827            return Err(Error::Verification(
828                "soft-binding receipt must be a JSON object".into(),
829            ));
830        }
831        let allowed = allowed_origins(&resolver_options.allowed_origins)?;
832        if allowed.is_empty() {
833            return Err(Error::Verification(
834                "soft-binding receipt submission requires at least one allowed origin".into(),
835            ));
836        }
837        let root = Url::parse(service_root).map_err(|error| {
838            Error::Verification(format!("invalid soft-binding service root: {error}"))
839        })?;
840        require_allowed(&root, &allowed)?;
841        let mut well_known = root.clone();
842        well_known.set_path("/.well-known/c2pa-soft-binding-resolution");
843        well_known.set_query(None);
844        well_known.set_fragment(None);
845        Ok(Self {
846            manifest_id: manifest_id.to_owned(),
847            receipt,
848            root,
849            allowed,
850            pending: Some(ReceiptPending {
851                method: SoftBindingHttpMethod::Get,
852                url: well_known,
853                body: None,
854                kind: ReceiptPendingKind::Discovery,
855            }),
856            result: None,
857        })
858    }
859
860    pub fn next_request(&self) -> Option<SoftBindingFetchRequest<'_>> {
861        self.pending
862            .as_ref()
863            .map(|request| SoftBindingFetchRequest {
864                method: request.method,
865                url: request.url.as_str(),
866                content_type: request.body.as_ref().map(|_| "application/json"),
867                accept: "application/json",
868                body: request.body.as_deref(),
869                max_response_bytes: MAX_SERVICE_JSON_BYTES,
870            })
871    }
872
873    pub fn accept_response(&mut self, final_url: &str, bytes: Vec<u8>) -> Result<(), Error> {
874        let pending = self.pending.take().ok_or_else(|| {
875            Error::Verification("soft-binding receipt session has no pending request".into())
876        })?;
877        let final_url = Url::parse(final_url).map_err(|_| {
878            Error::Verification("soft-binding transport returned an invalid final URL".into())
879        })?;
880        if final_url != pending.url {
881            return Err(Error::Verification(
882                "soft-binding redirect was refused".into(),
883            ));
884        }
885        if bytes.len() > MAX_SERVICE_JSON_BYTES {
886            return Err(Error::Verification(format!(
887                "soft-binding response exceeds {MAX_SERVICE_JSON_BYTES} bytes"
888            )));
889        }
890        match pending.kind {
891            ReceiptPendingKind::Discovery => {
892                let discovery: SoftBindingDiscovery = parse_json(&bytes, "soft-binding discovery")?;
893                let repository = directory_endpoint(resolve_endpoint(
894                    &self.root,
895                    &discovery.api_endpoint,
896                    &self.allowed,
897                )?);
898                let url = receipt_endpoint(&repository, &self.manifest_id)?;
899                require_allowed(&url, &self.allowed)?;
900                let body = serde_json::to_vec(&self.receipt).map_err(Error::Serialize)?;
901                self.pending = Some(ReceiptPending {
902                    method: SoftBindingHttpMethod::Post,
903                    url,
904                    body: Some(body),
905                    kind: ReceiptPendingKind::Verification,
906                });
907            }
908            ReceiptPendingKind::Verification => {
909                let receipt: Json = parse_json(&bytes, "soft-binding receipt verification")?;
910                self.result = Some(receipt_evidence(receipt)?);
911            }
912        }
913        Ok(())
914    }
915
916    pub fn finish(self) -> Result<SoftBindingReceiptEvidence, Error> {
917        if self.pending.is_some() {
918            return Err(Error::Verification(
919                "soft-binding receipt session still has a pending request".into(),
920            ));
921        }
922        self.result.ok_or_else(|| {
923            Error::Verification("soft-binding receipt verification did not complete".into())
924        })
925    }
926}
927
928/// Ask a repository to verify a caller-supplied receipt. The result remains
929/// server-reported evidence and is not a local cryptographic receipt verdict.
930pub fn request_soft_binding_receipt_verification(
931    service_root: &str,
932    manifest_id: &str,
933    receipt: Json,
934    verify_options: &VerifyOptions,
935    resolver_options: &SoftBindingResolverOptions,
936) -> Result<SoftBindingReceiptEvidence, Error> {
937    let fetcher = new_fetcher(resolver_options.allow_private_networks);
938    let mut session = SoftBindingReceiptVerificationSession::new(
939        service_root,
940        manifest_id,
941        receipt,
942        verify_options,
943        resolver_options,
944    )?;
945    while let Some(request) = session.next_request() {
946        let requested_url = request.url.to_string();
947        let bytes = fetch_transport(
948            fetcher.as_ref(),
949            &request,
950            resolver_options.allow_private_networks,
951        )?;
952        session.accept_response(&requested_url, bytes)?;
953    }
954    session.finish()
955}
956
957/// Discover, query, fetch and locally verify matching manifest stores through
958/// the native no-redirect transport.
959pub fn resolve_soft_binding(
960    asset: &[u8],
961    mime_type: &str,
962    service_root: &str,
963    query: &SoftBindingQuery,
964    verify_options: &VerifyOptions,
965    resolver_options: &SoftBindingResolverOptions,
966) -> Result<SoftBindingResolution, Error> {
967    let fetcher = new_fetcher(resolver_options.allow_private_networks);
968    let mut session = SoftBindingResolverSession::new(
969        asset,
970        mime_type,
971        service_root,
972        query,
973        verify_options,
974        resolver_options,
975    )?;
976    while let Some(request) = session.next_request() {
977        let requested_url = request.url.to_string();
978        let bytes = fetch_transport(
979            fetcher.as_ref(),
980            &request,
981            resolver_options.allow_private_networks,
982        )?;
983        session.accept_response(&requested_url, bytes)?;
984    }
985    session.finish()
986}
987
988fn receipt_evidence(mut response: Json) -> Result<SoftBindingReceiptEvidence, Error> {
989    let verified = response
990        .get("verified")
991        .and_then(Json::as_bool)
992        .ok_or_else(|| Error::Verification("soft-binding receipt result is malformed".into()))?;
993    let server_error = response
994        .get("error")
995        .and_then(Json::as_str)
996        .map(str::to_string);
997    let receipt = response
998        .as_object_mut()
999        .map(|object| {
1000            object.remove("verified");
1001            object.remove("error");
1002            Json::Object(std::mem::take(object))
1003        })
1004        .ok_or_else(|| Error::Verification("soft-binding receipt result is malformed".into()))?;
1005    Ok(SoftBindingReceiptEvidence {
1006        receipt,
1007        server_reported_verified: verified,
1008        server_error,
1009    })
1010}
1011
1012fn fetch_transport(
1013    fetcher: &dyn Fetcher,
1014    request: &SoftBindingFetchRequest<'_>,
1015    allow_http: bool,
1016) -> Result<Vec<u8>, Error> {
1017    let method = match request.method {
1018        SoftBindingHttpMethod::Get => FetchMethod::Get,
1019        SoftBindingHttpMethod::Post => FetchMethod::Post,
1020    };
1021    fetcher
1022        .fetch(&FetchRequest {
1023            url: request.url,
1024            method,
1025            body: request.body,
1026            content_type: request.content_type,
1027            accept: Some(request.accept),
1028            limit: request.max_response_bytes,
1029            allow_http,
1030            // This legacy transport refuses redirects independently of the
1031            // exact-2.5 graph session's cumulative request budget.
1032            remaining_requests: usize::MAX,
1033        })
1034        .map(|response| response.bytes)
1035        .map_err(|error| {
1036            let detail = if error.blocked {
1037                "request blocked by network policy"
1038            } else {
1039                "network request failed"
1040            };
1041            Error::Verification(format!("soft-binding request failed: {detail}"))
1042        })
1043}
1044
1045fn parse_json<T: for<'de> Deserialize<'de>>(bytes: &[u8], purpose: &str) -> Result<T, Error> {
1046    serde_json::from_slice(bytes)
1047        .map_err(|error| Error::Verification(format!("{purpose} returned invalid JSON: {error}")))
1048}
1049
1050fn append_optional_query(url: &mut Url, name: &str, value: Option<&str>) {
1051    if let Some(value) = value {
1052        url.query_pairs_mut().append_pair(name, value);
1053    }
1054}
1055
1056fn allowed_origins(values: &[String]) -> Result<Vec<String>, Error> {
1057    let mut origins = Vec::with_capacity(values.len());
1058    for value in values {
1059        let url = Url::parse(value).map_err(|error| {
1060            Error::Verification(format!("invalid allowed origin {value:?}: {error}"))
1061        })?;
1062        if url.cannot_be_a_base()
1063            || url.host_str().is_none()
1064            || !matches!(url.scheme(), "https" | "http")
1065        {
1066            return Err(Error::Verification(format!(
1067                "allowed origin {value:?} is not an HTTP network origin"
1068            )));
1069        }
1070        let origin = url.origin().ascii_serialization();
1071        let canonical = format!("{origin}/");
1072        if origin == "null" || (url.as_str() != origin && url.as_str() != canonical) {
1073            return Err(Error::Verification(format!(
1074                "allowed origin {value:?} must contain only scheme, host and optional port"
1075            )));
1076        }
1077        if !origins.contains(&origin) {
1078            origins.push(origin);
1079        }
1080    }
1081    Ok(origins)
1082}
1083
1084fn require_allowed(url: &Url, allowed: &[String]) -> Result<(), Error> {
1085    let origin = url.origin().ascii_serialization();
1086    if url.username().is_empty()
1087        && url.password().is_none()
1088        && allowed.iter().any(|entry| entry == &origin)
1089    {
1090        Ok(())
1091    } else {
1092        Err(Error::Verification(format!(
1093            "soft-binding endpoint origin {origin:?} is not allowlisted"
1094        )))
1095    }
1096}
1097
1098fn resolve_endpoint(base: &Url, endpoint: &str, allowed: &[String]) -> Result<Url, Error> {
1099    let resolved = base.join(endpoint).map_err(url_error)?;
1100    require_allowed(&resolved, allowed)?;
1101    Ok(resolved)
1102}
1103
1104fn directory_endpoint(mut endpoint: Url) -> Url {
1105    if !endpoint.path().ends_with('/') {
1106        let mut path = endpoint.path().to_owned();
1107        path.push('/');
1108        endpoint.set_path(&path);
1109    }
1110    endpoint.set_query(None);
1111    endpoint.set_fragment(None);
1112    endpoint
1113}
1114
1115fn manifest_endpoint(repository: &Url, manifest_id: &str) -> Result<Url, Error> {
1116    let mut endpoint = repository.clone();
1117    endpoint
1118        .path_segments_mut()
1119        .map_err(|_| {
1120            Error::Verification("soft-binding endpoint cannot carry path segments".into())
1121        })?
1122        .pop_if_empty()
1123        .push("manifests")
1124        .push(manifest_id);
1125    Ok(endpoint)
1126}
1127
1128fn receipt_endpoint(repository: &Url, manifest_id: &str) -> Result<Url, Error> {
1129    let mut endpoint = manifest_endpoint(repository, manifest_id)?;
1130    endpoint
1131        .path_segments_mut()
1132        .map_err(|_| {
1133            Error::Verification("soft-binding endpoint cannot carry path segments".into())
1134        })?
1135        .push("receipts");
1136    Ok(endpoint)
1137}
1138
1139fn url_error(error: url::ParseError) -> Error {
1140    Error::Verification(format!("invalid soft-binding endpoint: {error}"))
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146
1147    #[test]
1148    fn allowed_origins_require_exact_origin_syntax() {
1149        assert_eq!(
1150            allowed_origins(&["https://resolver.example".into()]).unwrap(),
1151            ["https://resolver.example"]
1152        );
1153        assert!(allowed_origins(&["https://resolver.example/api".into()]).is_err());
1154        assert!(allowed_origins(&["https://resolver.example////".into()]).is_err());
1155        assert!(allowed_origins(&["ftp://resolver.example".into()]).is_err());
1156        assert!(allowed_origins(&["file:///tmp/repository".into()]).is_err());
1157    }
1158
1159    #[test]
1160    fn directory_endpoints_preserve_the_advertised_base_path() {
1161        let base = directory_endpoint(Url::parse("https://resolver.example/v1").unwrap());
1162        assert_eq!(
1163            base.join("matches/byBinding").unwrap().as_str(),
1164            "https://resolver.example/v1/matches/byBinding"
1165        );
1166    }
1167
1168    #[test]
1169    fn every_resolved_endpoint_must_be_allowlisted() {
1170        let base = Url::parse("https://resolver.example/v1/").unwrap();
1171        let allowed = vec!["https://resolver.example".into()];
1172        assert!(resolve_endpoint(&base, "https://federated.example/v1", &allowed).is_err());
1173        let credentialed = Url::parse("https://user:secret@resolver.example/v1").unwrap();
1174        assert!(require_allowed(&credentialed, &allowed).is_err());
1175        assert!(resolve_endpoint(&base, "matches/byBinding", &allowed).is_ok());
1176    }
1177
1178    #[test]
1179    fn disclosure_routes_require_matching_explicit_consent() {
1180        let query = SoftBindingQuery::Content {
1181            alg: None,
1182            hint_alg: None,
1183            hint_value: None,
1184            consent: SoftBindingDisclosureConsent::DiscloseReference,
1185        };
1186        assert!(query.validate().is_err());
1187    }
1188
1189    fn online_options() -> VerifyOptions {
1190        VerifyOptions {
1191            online: Some(true),
1192            ..VerifyOptions::default()
1193        }
1194    }
1195
1196    fn resolver_options() -> SoftBindingResolverOptions {
1197        SoftBindingResolverOptions {
1198            allowed_origins: vec!["https://192.0.2.1".into()],
1199            ..SoftBindingResolverOptions::default()
1200        }
1201    }
1202
1203    fn accept_current(
1204        session: &mut SoftBindingResolverSession<'_>,
1205        json: &str,
1206    ) -> Result<(), Error> {
1207        let url = session.next_request().unwrap().url.to_owned();
1208        session.accept_response(&url, json.as_bytes().to_vec())
1209    }
1210
1211    #[test]
1212    fn binding_get_session_uses_exact_selected_route_and_default_limit() {
1213        let query = SoftBindingQuery::BindingGet {
1214            alg: "c2pa.soft-binding.example".into(),
1215            value: "registered value".into(),
1216        };
1217        let verify = online_options();
1218        let resolver = resolver_options();
1219        let mut session = SoftBindingResolverSession::new(
1220            b"asset",
1221            "image/jpeg",
1222            "https://192.0.2.1",
1223            &query,
1224            &verify,
1225            &resolver,
1226        )
1227        .unwrap();
1228        accept_current(
1229            &mut session,
1230            r#"{"apiEndpoint":"/v1","c2paSpecificationVersion":"2.5"}"#,
1231        )
1232        .unwrap();
1233        accept_current(
1234            &mut session,
1235            r#"{"c2paSpecificationVersion":"2.5","supportedCapabilities":[]}"#,
1236        )
1237        .unwrap();
1238        accept_current(
1239            &mut session,
1240            r#"{"watermarks":[{"alg":"c2pa.soft-binding.example"}],"fingerprints":[]}"#,
1241        )
1242        .unwrap();
1243        let request = session.next_request().unwrap();
1244        assert_eq!(request.method, SoftBindingHttpMethod::Get);
1245        assert!(request.url.contains("/v1/matches/byBinding?"));
1246        assert!(request.url.contains("maxResults=10"));
1247        assert!(request.url.contains("registered+value"));
1248        let url = request.url.to_owned();
1249        session
1250            .accept_response(&url, br#"{"matches":[]}"#.to_vec())
1251            .unwrap();
1252        assert!(session.finish().unwrap().recovered.is_empty());
1253    }
1254
1255    #[test]
1256    fn content_session_requires_capability_and_emits_asset_body() {
1257        let query = SoftBindingQuery::Content {
1258            alg: None,
1259            hint_alg: None,
1260            hint_value: None,
1261            consent: SoftBindingDisclosureConsent::UploadAsset,
1262        };
1263        let verify = online_options();
1264        let resolver = resolver_options();
1265        let mut session = SoftBindingResolverSession::new(
1266            b"exact asset",
1267            "image/jpeg",
1268            "https://192.0.2.1",
1269            &query,
1270            &verify,
1271            &resolver,
1272        )
1273        .unwrap();
1274        accept_current(
1275            &mut session,
1276            r#"{"apiEndpoint":"/v1/","c2paSpecificationVersion":"2.5"}"#,
1277        )
1278        .unwrap();
1279        accept_current(
1280            &mut session,
1281            r#"{"c2paSpecificationVersion":"2.5","supportedCapabilities":["queryByContent"]}"#,
1282        )
1283        .unwrap();
1284        accept_current(&mut session, r#"{"watermarks":[],"fingerprints":[]}"#).unwrap();
1285        let request = session.next_request().unwrap();
1286        assert_eq!(request.method, SoftBindingHttpMethod::Post);
1287        assert_eq!(request.content_type, Some("image/jpeg"));
1288        assert_eq!(request.body, Some(&b"exact asset"[..]));
1289        assert!(request.url.contains("/v1/matches/byContent?"));
1290    }
1291
1292    #[test]
1293    fn receipt_result_stays_server_reported_and_manifest_ids_cannot_escape_repository() {
1294        let verify = online_options();
1295        let resolver = resolver_options();
1296        let manifest_id = "../other?escape#fragment";
1297        let receipt = json!({
1298            "@context": "https://c2pa.org/ns/",
1299            "@type": "org.c2pa.manifest-receipt",
1300            "repository": {"uri": "https://192.0.2.1", "manifestId": manifest_id},
1301            "anchor": {"uri": "https://192.0.2.1/a", "proof": {}}
1302        });
1303        let mut session = SoftBindingReceiptVerificationSession::new(
1304            "https://192.0.2.1",
1305            manifest_id,
1306            receipt,
1307            &verify,
1308            &resolver,
1309        )
1310        .unwrap();
1311        let discovery_url = session.next_request().unwrap().url.to_owned();
1312        session
1313            .accept_response(
1314                &discovery_url,
1315                br#"{"apiEndpoint":"/v1/","c2paSpecificationVersion":"2.5"}"#.to_vec(),
1316            )
1317            .unwrap();
1318        let request = session.next_request().unwrap();
1319        let endpoint = Url::parse(request.url).unwrap();
1320        assert_eq!(endpoint.origin().ascii_serialization(), "https://192.0.2.1");
1321        let segments = endpoint.path_segments().unwrap().collect::<Vec<_>>();
1322        assert_eq!(segments.len(), 4);
1323        assert_eq!(segments[0], "v1");
1324        assert_eq!(segments[1], "manifests");
1325        assert!(!segments[2].is_empty());
1326        assert_eq!(segments[3], "receipts");
1327        assert!(!segments.iter().any(|segment| segment.is_empty()));
1328        assert!(endpoint.query().is_none());
1329        assert!(endpoint.fragment().is_none());
1330        let url = request.url.to_owned();
1331        session
1332            .accept_response(
1333                &url,
1334                br#"{"@context":"x","@type":"org.c2pa.manifest-receipt","repository":{},"anchor":{},"verified":false,"error":"no match"}"#.to_vec(),
1335            )
1336            .unwrap();
1337        let evidence = session.finish().unwrap();
1338        assert!(!evidence.server_reported_verified);
1339        assert_eq!(evidence.server_error.as_deref(), Some("no match"));
1340    }
1341}