1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum SoftBindingDisclosureConsent {
22 UploadAsset,
24 DiscloseReference,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(tag = "route", rename_all = "snake_case", deny_unknown_fields)]
34pub enum SoftBindingQuery {
35 BindingGet { alg: String, value: String },
37 BindingPost { alg: String, value: String },
39 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 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
164#[serde(default, deny_unknown_fields)]
165pub struct SoftBindingResolverOptions {
166 pub allowed_origins: Vec<String>,
168 pub allow_private_networks: bool,
171 pub max_results: u16,
173 pub fetch_status: bool,
175 pub fetch_receipts: bool,
177}
178
179#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct SoftBindingAlgorithm {
202 pub alg: String,
203}
204
205#[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#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum SoftBindingHttpMethod {
266 Get,
267 Post,
268}
269
270pub 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
316pub 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
793pub 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
928pub 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
957pub 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 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}