Skip to main content

encypher_c2pa/
online.rs

1// Copyright 2026 Encypher Corporation
2// SPDX-License-Identifier: Apache-2.0
3
4//! Opt-in online checks.
5//!
6//! The verification kernel stays pure and offline. When a file references
7//! something only a server can settle - a manifest store held elsewhere, the
8//! revocation status of a signing certificate, the DID document of an identity
9//! issuer, content stored outside the asset - the kernel records the need and
10//! reports it. Nothing is fetched unless the caller said yes.
11//!
12//! A verification that fetches runs twice. Pass 1 is the ordinary offline
13//! verification and produces the list of needs. The fetcher retrieves what was
14//! allowed. Pass 2 is another ordinary offline verification, with the fetched
15//! material handed in as caller-supplied evidence. The same bytes and the same
16//! evidence therefore always produce the same verdict, and the network can
17//! only ever add evidence, never change how it is judged.
18//!
19//! The fetcher is deliberately narrow. It speaks https only, except to OCSP
20//! responders, whose answers are signed and verified before they count. Every
21//! hostname is resolved through a filter that rejects loopback, private,
22//! link-local, carrier-NAT, unique-local, multicast and documentation
23//! addresses, and the connection uses the address that was vetted, so a name
24//! that changes its answer between the check and the connection cannot reach
25//! an internal service. Asset bytes never leave the machine: an OCSP request
26//! carries a certificate serial number and issuer hashes, nothing else.
27
28use std::collections::HashMap;
29
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33use crate::online_consent::{
34    environment_online, interactive_online_consent_allowed, prompt_for_online_consent,
35    saved_online_preference, OnlinePreference,
36};
37use crate::VerifyOptions;
38
39/// Largest manifest store this SDK will accept from a server, matching the
40/// limit the offline entry points already apply to a caller-supplied store.
41const MAX_MANIFEST_BYTES: usize = 64 * 1024 * 1024;
42/// An OCSP response is a signed status for one certificate. Anything larger is
43/// not one.
44const MAX_OCSP_BYTES: usize = 64 * 1024;
45/// A DID document is a small JSON object.
46const MAX_DID_DOCUMENT_BYTES: usize = 256 * 1024;
47/// Externally stored assertion content, bounded like a manifest store.
48const MAX_EXTERNAL_DATA_BYTES: usize = 64 * 1024 * 1024;
49/// Total HTTP requests one verification may make.
50const MAX_REQUESTS: usize = 16;
51/// Aggregate response bytes one verification may receive across every
52/// frontier. A response that is later rejected or fails while being read still
53/// spends this budget.
54const MAX_RECEIVED_BYTES: usize = 64 * 1024 * 1024;
55
56pub(crate) use crate::c2pa_validate::{NetworkNeed, OcspPurpose};
57
58/// What a verification did, or could have done, on the network.
59///
60/// `needed` lists what a fetch could settle and is filled in whether or not
61/// online checks were allowed, so an offline caller can see exactly what
62/// turning them on would do. Its entries are the kernel's own description of
63/// each need. `requests` records every attempt, including the ones that were
64/// refused.
65#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
66#[serde(default)]
67pub struct NetworkReport {
68    /// Whether this verification was allowed to fetch.
69    pub enabled: bool,
70    /// Every network resource this asset references.
71    pub needed: Vec<Value>,
72    /// Every request that was attempted.
73    pub requests: Vec<NetworkRequest>,
74}
75
76impl NetworkReport {
77    /// Record that a body arrived but could not be used for what it was
78    /// fetched for. The request happened, so it is not removed; its outcome
79    /// stops being a success.
80    pub(crate) fn mark_unusable(&mut self, purpose: &str, detail: String) {
81        if let Some(request) = self
82            .requests
83            .iter_mut()
84            .find(|request| request.purpose == purpose && request.outcome == "fetched")
85        {
86            request.outcome = "failed".to_string();
87            request.detail = detail;
88        }
89    }
90}
91
92/// One attempted fetch.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct NetworkRequest {
95    /// `remote_manifest`, `ocsp.claim_signer`, `ocsp.cawg_identity`,
96    /// `did_document`, or `external_data`.
97    pub purpose: String,
98    /// The URL that was requested.
99    pub url: String,
100    /// `fetched`, `failed`, `blocked`, or `skipped`.
101    ///
102    /// `blocked` means this SDK refused: a forbidden address, plaintext URL,
103    /// redirect response, or oversized body. `failed` means the server or the
104    /// network did not deliver. `skipped` means the request was never made.
105    pub outcome: String,
106    /// Why, in one line.
107    pub detail: String,
108}
109
110/// Material fetched for pass 2, in the shape the offline entry points already
111/// accept from a caller who fetched it themselves.
112#[derive(Debug, Clone, Default, PartialEq, Eq)]
113pub(crate) struct OnlineEvidence {
114    /// A manifest store retrieved from the URI the asset declares.
115    pub(crate) manifest_store: Option<Vec<u8>>,
116    /// Certificate SHA-256 (lowercase hex) -> base64 DER OCSPResponse. An
117    /// empty value is the pass-two sentinel for a body that arrived but was
118    /// discarded under the size limit; it evaluates as unusable evidence.
119    pub(crate) ocsp_responses: HashMap<String, String>,
120    /// Certificate SHA-256 (lowercase hex) whose responder was contacted but
121    /// delivered no response. SDK policy refusals are not unreachable.
122    pub(crate) ocsp_unreachable: Vec<String>,
123    /// Need URI -> base64 content bytes.
124    pub(crate) external_data: HashMap<String, String>,
125    /// Need URI -> response media type (without parameters).
126    pub(crate) external_data_media_types: HashMap<String, String>,
127    /// External-data URIs whose retrieval was attempted without a usable body.
128    pub(crate) external_data_unreachable: Vec<String>,
129    /// Primary DID -> DID document.
130    pub(crate) did_documents: HashMap<String, Value>,
131}
132
133impl OnlineEvidence {
134    pub(crate) fn is_empty(&self) -> bool {
135        self.manifest_store.is_none()
136            && self.ocsp_responses.is_empty()
137            && self.ocsp_unreachable.is_empty()
138            && self.external_data.is_empty()
139            && self.external_data_media_types.is_empty()
140            && self.external_data_unreachable.is_empty()
141            && self.did_documents.is_empty()
142    }
143}
144
145impl NetworkNeed {
146    /// The token used in [`NetworkRequest::purpose`].
147    pub(crate) fn purpose_token(&self) -> &'static str {
148        match self {
149            Self::RemoteManifest { .. } => "remote_manifest",
150            Self::Ocsp {
151                purpose: OcspPurpose::ClaimSigner,
152                ..
153            } => "ocsp.claim_signer",
154            Self::Ocsp { .. } => "ocsp.cawg_identity",
155            Self::DidDocument { .. } => "did_document",
156            Self::ExternalData { .. } => "external_data",
157        }
158    }
159
160    /// The URL that would be contacted.
161    pub(crate) fn url(&self) -> &str {
162        match self {
163            Self::RemoteManifest { uri } => uri,
164            Self::Ocsp { responder_url, .. } => responder_url,
165            Self::DidDocument { url, .. } => url,
166            Self::ExternalData { uri, .. } => uri,
167        }
168    }
169
170    /// One prompt line: what this is for, and who would learn about the file.
171    fn consent_line(&self) -> String {
172        let what = match self {
173            Self::RemoteManifest { .. } => "the manifest store this file points to".to_string(),
174            Self::Ocsp {
175                purpose: OcspPurpose::ClaimSigner,
176                ..
177            } => "whether the signing certificate has been revoked".to_string(),
178            Self::Ocsp {
179                purpose: OcspPurpose::CawgIdentity { assertion_label },
180                ..
181            } => format!("whether the identity certificate in {assertion_label} has been revoked"),
182            Self::DidDocument { did, .. } => format!("the issuer document for {did}"),
183            Self::ExternalData {
184                assertion_label, ..
185            } => format!("content stored outside the file, for {assertion_label}"),
186        };
187        format!("{what}, from {}", host_of(self.url()))
188    }
189}
190
191/// The needs recorded by one kernel verification.
192pub(crate) fn network_needs(output: &crate::c2pa_validate::VerifyOutput) -> Vec<NetworkNeed> {
193    output.network_needs.clone()
194}
195
196/// Decide whether this verification may fetch, fetch what it may, and report.
197///
198/// The decision order is explicit option, then the `ENCYPHER_C2PA_ONLINE`
199/// operator override, then - on a terminal surface only - the saved choice and
200/// a prompt. A library call stops after the environment variable: it may be
201/// verifying an untrusted file on a server, where a choice somebody made at a
202/// laptop must not switch fetching on.
203pub(crate) fn gather(
204    options: &VerifyOptions,
205    needs: &[NetworkNeed],
206) -> (NetworkReport, OnlineEvidence) {
207    let needed: Vec<Value> = needs.iter().map(NetworkNeed::to_json).collect();
208    let enabled = resolve_online(options, needs);
209    if !enabled || needs.is_empty() {
210        return (
211            NetworkReport {
212                enabled,
213                needed,
214                requests: Vec::new(),
215            },
216            OnlineEvidence::default(),
217        );
218    }
219    let fetcher = new_fetcher(options.online_allow_private_networks);
220    let (requests, evidence) = fetch_all(fetcher.as_ref(), needs);
221    (
222        NetworkReport {
223            enabled: true,
224            needed,
225            requests,
226        },
227        evidence,
228    )
229}
230
231/// Whether an explicitly networked operation may start.
232///
233/// Library surfaces honor only the per-call option and operator environment
234/// override. Terminal prompting requires concrete resource needs and remains
235/// in [`gather`].
236pub(crate) fn online_enabled(options: &VerifyOptions) -> bool {
237    resolve_online(options, &[])
238}
239
240pub(crate) fn resolve_online(options: &VerifyOptions, needs: &[NetworkNeed]) -> bool {
241    if let Some(explicit) = options.online {
242        return explicit;
243    }
244    match environment_online() {
245        Ok(Some(enabled)) => return enabled,
246        Ok(None) => {}
247        // An operator typed something this SDK does not understand. Fetching is
248        // the side with consequences, so an unreadable override stays off.
249        Err(_) => return false,
250    }
251    if !interactive_online_consent_allowed() || needs.is_empty() {
252        return false;
253    }
254    match saved_online_preference() {
255        Ok(Some(OnlinePreference::On)) => true,
256        Ok(Some(OnlinePreference::Off)) => false,
257        Ok(Some(OnlinePreference::Ask)) | Ok(None) => {
258            let lines: Vec<String> = needs.iter().map(NetworkNeed::consent_line).collect();
259            prompt_for_online_consent(&lines)
260                .ok()
261                .flatten()
262                .unwrap_or(false)
263        }
264        Err(_) => false,
265    }
266}
267
268/// Build the options for pass 2 from what was fetched.
269pub(crate) fn apply_evidence(options: &VerifyOptions, evidence: &OnlineEvidence) -> VerifyOptions {
270    let mut next = options.clone();
271    // Pass 2 verifies with what pass 1 obtained. It never fetches again, so a
272    // server that answers differently on a second request cannot extend the
273    // work one verification does.
274    next.online = Some(false);
275    if !evidence.did_documents.is_empty() {
276        let mut documents = next.cawg_did_documents.unwrap_or_default();
277        documents.extend(
278            evidence
279                .did_documents
280                .iter()
281                .map(|(did, document)| (did.clone(), document.clone())),
282        );
283        next.cawg_did_documents = Some(documents);
284    }
285    if !evidence.ocsp_responses.is_empty() {
286        let mut responses = next.ocsp_responses.unwrap_or_default();
287        responses.extend(evidence.ocsp_responses.clone());
288        next.ocsp_responses = Some(responses);
289    }
290    if !evidence.ocsp_unreachable.is_empty() {
291        let mut unreachable = next.ocsp_unreachable.unwrap_or_default();
292        unreachable.extend(evidence.ocsp_unreachable.iter().cloned());
293        next.ocsp_unreachable = Some(unreachable);
294    }
295    if !evidence.external_data.is_empty() {
296        let mut data = next.external_data.unwrap_or_default();
297        data.extend(evidence.external_data.clone());
298        next.external_data = Some(data);
299    }
300    if !evidence.external_data_media_types.is_empty() {
301        let mut media_types = next.external_data_media_types.unwrap_or_default();
302        media_types.extend(evidence.external_data_media_types.clone());
303        next.external_data_media_types = Some(media_types);
304    }
305    if !evidence.external_data_unreachable.is_empty() {
306        let mut unreachable = next.external_data_unreachable.unwrap_or_default();
307        unreachable.extend(evidence.external_data_unreachable.iter().cloned());
308        next.external_data_unreachable = Some(unreachable);
309    }
310    next
311}
312
313// ---------------------------------------------------------------------------
314// Fetching
315// ---------------------------------------------------------------------------
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318pub(crate) enum FetchMethod {
319    Get,
320    Post,
321}
322
323/// One outbound request, fully described by policy rather than by the caller.
324#[cfg_attr(
325    not(feature = "online"),
326    allow(dead_code, reason = "only the native fetcher reads a request")
327)]
328pub(crate) struct FetchRequest<'a> {
329    pub(crate) url: &'a str,
330    pub(crate) method: FetchMethod,
331    pub(crate) body: Option<&'a [u8]>,
332    pub(crate) content_type: Option<&'a str>,
333    pub(crate) accept: Option<&'a str>,
334    /// Maximum body this request may accept.
335    pub(crate) limit: usize,
336    /// True only for OCSP, whose responses are signed and verified.
337    pub(crate) allow_http: bool,
338    /// HTTP requests still available to this operation, including the initial
339    /// request. A transport that follows redirects must refuse a hop before it
340    /// would exceed this number.
341    pub(crate) remaining_requests: usize,
342}
343
344#[derive(Debug)]
345pub(crate) struct FetchOk {
346    pub(crate) bytes: Vec<u8>,
347    pub(crate) media_type: Option<String>,
348    pub(crate) requests_used: usize,
349}
350
351#[derive(Debug)]
352pub(crate) struct FetchErr {
353    /// True when this SDK refused, false when the network or server did.
354    pub(crate) blocked: bool,
355    /// True only for an oversized response body that arrived but was discarded
356    /// before it could be carried to legacy pass-two verification.
357    pub(crate) received_response: bool,
358    pub(crate) detail: String,
359    pub(crate) requests_used: usize,
360    /// Body bytes actually read before this failure, including the sentinel
361    /// byte used to detect an oversized body.
362    pub(crate) received_bytes: usize,
363}
364
365pub(crate) type FetchResult = Result<FetchOk, FetchErr>;
366
367pub(crate) trait Fetcher {
368    fn fetch(&self, request: &FetchRequest<'_>) -> FetchResult;
369}
370
371fn blocked(detail: impl Into<String>, requests_used: usize) -> FetchErr {
372    FetchErr {
373        blocked: true,
374        received_response: false,
375        detail: detail.into(),
376        requests_used,
377        received_bytes: 0,
378    }
379}
380
381#[derive(Debug)]
382struct FetchBudget {
383    requests: usize,
384    received_bytes: usize,
385}
386
387impl Default for FetchBudget {
388    fn default() -> Self {
389        Self {
390            requests: MAX_REQUESTS,
391            received_bytes: MAX_RECEIVED_BYTES,
392        }
393    }
394}
395
396/// A verification-scoped native fetch session.
397///
398/// The session persists the HTTP-request and received-body budgets across
399/// discovery frontiers. It deliberately does not deduplicate needs: the
400/// facade owns resource identity and reuse because OCSP identity is not the
401/// responder URL alone.
402pub(crate) struct FetchSession {
403    /// Constructing a native HTTP agent allocates connection-pool and TLS
404    /// state. Keep it absent until an allowed need actually reaches transport.
405    fetcher: Option<Box<dyn Fetcher>>,
406    allow_private_networks: bool,
407    budget: FetchBudget,
408}
409
410impl FetchSession {
411    pub(crate) fn new(allow_private_networks: bool) -> Self {
412        Self {
413            fetcher: None,
414            allow_private_networks,
415            budget: FetchBudget::default(),
416        }
417    }
418
419    #[cfg(test)]
420    pub(crate) fn with_fetcher(fetcher: Box<dyn Fetcher>) -> Self {
421        Self {
422            fetcher: Some(fetcher),
423            allow_private_networks: false,
424            budget: FetchBudget::default(),
425        }
426    }
427
428    pub(crate) fn fetch(&mut self, need: &NetworkNeed) -> (NetworkRequest, FetchResult) {
429        if let Some(exhausted) = budget_exhaustion(&self.budget, need) {
430            return exhausted;
431        }
432        let allow_private_networks = self.allow_private_networks;
433        let fetcher = self
434            .fetcher
435            .get_or_insert_with(|| new_fetcher(allow_private_networks));
436        fetch_one(fetcher.as_ref(), &mut self.budget, need)
437    }
438}
439
440fn request_for_need<'a>(
441    need: &'a NetworkNeed,
442    remaining_requests: usize,
443    remaining_bytes: usize,
444) -> FetchRequest<'a> {
445    match need {
446        NetworkNeed::RemoteManifest { uri } => FetchRequest {
447            url: uri,
448            method: FetchMethod::Get,
449            body: None,
450            content_type: None,
451            accept: Some("application/c2pa"),
452            limit: MAX_MANIFEST_BYTES.min(remaining_bytes),
453            allow_http: false,
454            remaining_requests,
455        },
456        NetworkNeed::Ocsp {
457            responder_url,
458            request_der,
459            ..
460        } => FetchRequest {
461            url: responder_url,
462            method: FetchMethod::Post,
463            body: Some(request_der),
464            content_type: Some("application/ocsp-request"),
465            accept: Some("application/ocsp-response"),
466            limit: MAX_OCSP_BYTES.min(remaining_bytes),
467            // RFC 6960 responders are reached over plain HTTP by design: the
468            // response is signed and verified before the answer counts.
469            allow_http: true,
470            remaining_requests,
471        },
472        NetworkNeed::DidDocument { url, .. } => FetchRequest {
473            url,
474            method: FetchMethod::Get,
475            body: None,
476            content_type: None,
477            accept: Some("application/did+json, application/json"),
478            limit: MAX_DID_DOCUMENT_BYTES.min(remaining_bytes),
479            allow_http: false,
480            remaining_requests,
481        },
482        NetworkNeed::ExternalData { uri, .. } => FetchRequest {
483            url: uri,
484            method: FetchMethod::Get,
485            body: None,
486            content_type: None,
487            accept: None,
488            limit: MAX_EXTERNAL_DATA_BYTES.min(remaining_bytes),
489            allow_http: false,
490            remaining_requests,
491        },
492    }
493}
494
495fn budget_exhaustion(
496    budget: &FetchBudget,
497    need: &NetworkNeed,
498) -> Option<(NetworkRequest, FetchResult)> {
499    let detail = if budget.requests == 0 {
500        format!("the {MAX_REQUESTS} request budget for one verification is spent")
501    } else if budget.received_bytes == 0 {
502        format!("the {MAX_RECEIVED_BYTES} byte response budget for one verification is spent")
503    } else {
504        return None;
505    };
506    Some((
507        NetworkRequest {
508            purpose: need.purpose_token().to_string(),
509            url: need.url().to_string(),
510            outcome: "skipped".to_string(),
511            detail: detail.clone(),
512        },
513        Err(blocked(detail, 0)),
514    ))
515}
516
517fn fetch_one(
518    fetcher: &dyn Fetcher,
519    budget: &mut FetchBudget,
520    need: &NetworkNeed,
521) -> (NetworkRequest, FetchResult) {
522    if let Some(exhausted) = budget_exhaustion(budget, need) {
523        return exhausted;
524    }
525    let purpose = need.purpose_token().to_string();
526    let url = need.url().to_string();
527
528    let request = request_for_need(need, budget.requests, budget.received_bytes);
529    let result = fetcher.fetch(&request);
530    let (requests_used, received_bytes, outcome, detail) = match &result {
531        Ok(response) => (
532            response.requests_used,
533            response.bytes.len(),
534            "fetched",
535            format!("{} bytes", response.bytes.len()),
536        ),
537        Err(error) => (
538            error.requests_used,
539            error.received_bytes,
540            if error.blocked { "blocked" } else { "failed" },
541            error.detail.clone(),
542        ),
543    };
544    budget.requests = budget.requests.saturating_sub(requests_used);
545    budget.received_bytes = budget.received_bytes.saturating_sub(received_bytes);
546
547    (
548        NetworkRequest {
549            purpose,
550            url,
551            outcome: outcome.to_string(),
552            detail,
553        },
554        result,
555    )
556}
557
558/// Run every need through `fetcher`, within the request budget.
559pub(crate) fn fetch_all(
560    fetcher: &dyn Fetcher,
561    needs: &[NetworkNeed],
562) -> (Vec<NetworkRequest>, OnlineEvidence) {
563    let mut requests = Vec::with_capacity(needs.len());
564    let mut evidence = OnlineEvidence::default();
565    let mut budget = MAX_REQUESTS;
566
567    for need in needs {
568        let purpose = need.purpose_token().to_string();
569        let url = need.url().to_string();
570        if budget == 0 {
571            requests.push(NetworkRequest {
572                purpose,
573                url,
574                outcome: "skipped".to_string(),
575                detail: format!("the {MAX_REQUESTS} request budget for one verification is spent"),
576            });
577            continue;
578        }
579
580        let request = match need {
581            NetworkNeed::RemoteManifest { uri } => FetchRequest {
582                url: uri,
583                method: FetchMethod::Get,
584                body: None,
585                content_type: None,
586                accept: Some("application/c2pa"),
587                limit: MAX_MANIFEST_BYTES,
588                allow_http: false,
589                remaining_requests: usize::MAX,
590            },
591            NetworkNeed::Ocsp {
592                responder_url,
593                request_der,
594                ..
595            } => FetchRequest {
596                url: responder_url,
597                method: FetchMethod::Post,
598                body: Some(request_der),
599                content_type: Some("application/ocsp-request"),
600                accept: Some("application/ocsp-response"),
601                limit: MAX_OCSP_BYTES,
602                // RFC 6960 responders are reached over plain HTTP by design:
603                // the response is signed, and this SDK verifies that signature
604                // before the answer counts for anything.
605                allow_http: true,
606                remaining_requests: usize::MAX,
607            },
608            NetworkNeed::DidDocument { url, .. } => FetchRequest {
609                url,
610                method: FetchMethod::Get,
611                body: None,
612                content_type: None,
613                accept: Some("application/did+json, application/json"),
614                limit: MAX_DID_DOCUMENT_BYTES,
615                allow_http: false,
616                remaining_requests: usize::MAX,
617            },
618            NetworkNeed::ExternalData { uri, .. } => FetchRequest {
619                url: uri,
620                method: FetchMethod::Get,
621                body: None,
622                content_type: None,
623                accept: None,
624                limit: MAX_EXTERNAL_DATA_BYTES,
625                allow_http: false,
626                remaining_requests: usize::MAX,
627            },
628        };
629
630        let (outcome, detail) = match fetcher.fetch(&request) {
631            Ok(FetchOk {
632                bytes,
633                media_type,
634                requests_used,
635            }) => {
636                budget = budget.saturating_sub(requests_used.max(1));
637                accept_evidence(&mut evidence, need, bytes, media_type)
638            }
639            Err(FetchErr {
640                blocked,
641                received_response,
642                detail,
643                requests_used,
644                ..
645            }) => {
646                budget = budget.saturating_sub(requests_used.max(1));
647                if let NetworkNeed::Ocsp {
648                    certificate_sha256_hex,
649                    ..
650                } = need
651                {
652                    if received_response {
653                        evidence
654                            .ocsp_responses
655                            .insert(certificate_sha256_hex.clone(), String::new());
656                    } else if !blocked {
657                        push_unreachable(&mut evidence, certificate_sha256_hex);
658                    }
659                }
660                if let NetworkNeed::ExternalData { uri, .. } = need {
661                    if !evidence
662                        .external_data_unreachable
663                        .iter()
664                        .any(|existing| existing == uri)
665                    {
666                        evidence.external_data_unreachable.push(uri.clone());
667                    }
668                }
669                (
670                    if blocked { "blocked" } else { "failed" }.to_string(),
671                    detail,
672                )
673            }
674        };
675        requests.push(NetworkRequest {
676            purpose,
677            url,
678            outcome,
679            detail,
680        });
681    }
682
683    (requests, evidence)
684}
685
686fn push_unreachable(evidence: &mut OnlineEvidence, certificate_sha256_hex: &str) {
687    if !evidence
688        .ocsp_unreachable
689        .iter()
690        .any(|existing| existing == certificate_sha256_hex)
691    {
692        evidence
693            .ocsp_unreachable
694            .push(certificate_sha256_hex.to_string());
695    }
696}
697
698/// File one fetched body under the evidence it is, and say what happened.
699fn accept_evidence(
700    evidence: &mut OnlineEvidence,
701    need: &NetworkNeed,
702    bytes: Vec<u8>,
703    media_type: Option<String>,
704) -> (String, String) {
705    let length = bytes.len();
706    match need {
707        NetworkNeed::RemoteManifest { .. } => {
708            evidence.manifest_store = Some(bytes);
709            ("fetched".to_string(), format!("{length} bytes"))
710        }
711        NetworkNeed::Ocsp {
712            certificate_sha256_hex,
713            ..
714        } => {
715            evidence
716                .ocsp_responses
717                .insert(certificate_sha256_hex.clone(), base64_encode(&bytes));
718            ("fetched".to_string(), format!("{length} bytes"))
719        }
720        NetworkNeed::DidDocument { did, .. } => match serde_json::from_slice::<Value>(&bytes) {
721            Ok(document) => {
722                evidence.did_documents.insert(did.clone(), document);
723                ("fetched".to_string(), format!("{length} bytes"))
724            }
725            Err(error) => (
726                "failed".to_string(),
727                format!("the DID document is not JSON: {error}"),
728            ),
729        },
730        NetworkNeed::ExternalData { uri, .. } => {
731            evidence
732                .external_data
733                .insert(uri.clone(), base64_encode(&bytes));
734            if let Some(media_type) = media_type {
735                evidence
736                    .external_data_media_types
737                    .insert(uri.clone(), media_type);
738            }
739            ("fetched".to_string(), format!("{length} bytes"))
740        }
741    }
742}
743
744// ---------------------------------------------------------------------------
745// URL handling
746// ---------------------------------------------------------------------------
747
748/// Split a URL into scheme, authority, and the rest. `None` when it is not an
749/// absolute URL this SDK can reason about.
750fn split_url(url: &str) -> Option<(&str, &str, &str)> {
751    let (scheme, remainder) = url.split_once("://")?;
752    if scheme.is_empty() || remainder.is_empty() {
753        return None;
754    }
755    let end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len());
756    let (authority, rest) = remainder.split_at(end);
757    if authority.is_empty() {
758        return None;
759    }
760    Some((scheme, authority, rest))
761}
762
763/// The hostname a URL would contact, for the consent prompt.
764fn host_of(url: &str) -> String {
765    let Some((_, authority, _)) = split_url(url) else {
766        return url.to_string();
767    };
768    let authority = authority
769        .rsplit_once('@')
770        .map_or(authority, |(_, host)| host);
771    if let Some(end) = authority.find(']') {
772        return authority[..=end].to_string();
773    }
774    authority
775        .split_once(':')
776        .map_or(authority, |(host, _)| host)
777        .to_string()
778}
779
780// ---------------------------------------------------------------------------
781// Base64
782// ---------------------------------------------------------------------------
783
784/// Standard base64 (RFC 4648) with padding, the encoding the caller-supplied
785/// evidence options already use.
786fn base64_encode(input: &[u8]) -> String {
787    const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
788    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
789    for chunk in input.chunks(3) {
790        let b0 = u32::from(chunk[0]);
791        let b1 = chunk.get(1).copied().map_or(0, u32::from);
792        let b2 = chunk.get(2).copied().map_or(0, u32::from);
793        let triple = (b0 << 16) | (b1 << 8) | b2;
794        out.push(ALPHABET[(triple >> 18) as usize & 0x3f] as char);
795        out.push(ALPHABET[(triple >> 12) as usize & 0x3f] as char);
796        out.push(if chunk.len() > 1 {
797            ALPHABET[(triple >> 6) as usize & 0x3f] as char
798        } else {
799            '='
800        });
801        out.push(if chunk.len() > 2 {
802            ALPHABET[triple as usize & 0x3f] as char
803        } else {
804            '='
805        });
806    }
807    out
808}
809
810// ---------------------------------------------------------------------------
811// The native fetcher
812// ---------------------------------------------------------------------------
813
814#[cfg(feature = "online")]
815pub(crate) fn new_fetcher(allow_private_networks: bool) -> Box<dyn Fetcher> {
816    Box::new(native::NativeFetcher::new(allow_private_networks))
817}
818
819#[cfg(not(feature = "online"))]
820pub(crate) fn new_fetcher(_allow_private_networks: bool) -> Box<dyn Fetcher> {
821    Box::new(DisabledFetcher)
822}
823
824/// The fetcher of a build compiled without the `online` feature. It refuses
825/// every request rather than pretending none was needed.
826#[cfg(not(feature = "online"))]
827struct DisabledFetcher;
828
829#[cfg(not(feature = "online"))]
830impl Fetcher for DisabledFetcher {
831    fn fetch(&self, _request: &FetchRequest<'_>) -> FetchResult {
832        Err(blocked(
833            "this build was compiled without the online feature",
834            0,
835        ))
836    }
837}
838
839#[cfg(feature = "online")]
840pub(crate) mod native {
841    use std::io::Read;
842    use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
843    use std::time::Duration;
844    use std::{io, net};
845
846    use super::{
847        blocked, split_url, FetchErr, FetchMethod, FetchOk, FetchRequest, FetchResult, Fetcher,
848    };
849
850    const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
851    const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
852    const USER_AGENT: &str = concat!("encypher-c2pa/", env!("CARGO_PKG_VERSION"));
853    /// Marker planted in the resolver's error so the fetcher can tell a refusal
854    /// by policy from a name that simply does not resolve.
855    const BLOCKED_ADDRESS_MARKER: &str = "address is not permitted";
856
857    fn failed(detail: impl Into<String>, requests_used: usize, received_bytes: usize) -> FetchErr {
858        FetchErr {
859            blocked: false,
860            // Preserve legacy OCSP classification: a failed read is
861            // unreachable, while only an oversized body is unusable.
862            received_response: false,
863            detail: detail.into(),
864            requests_used,
865            received_bytes,
866        }
867    }
868
869    fn oversized(
870        detail: impl Into<String>,
871        requests_used: usize,
872        received_bytes: usize,
873    ) -> FetchErr {
874        FetchErr {
875            blocked: true,
876            received_response: true,
877            detail: detail.into(),
878            requests_used,
879            received_bytes,
880        }
881    }
882
883    /// Why an address may not be contacted, or `None` when it may.
884    ///
885    /// This is the whole address policy in one readable function, so it can be
886    /// tested one range at a time.
887    pub(crate) fn blocked_reason(address: IpAddr) -> Option<&'static str> {
888        match address {
889            IpAddr::V4(address) => blocked_reason_v4(address),
890            IpAddr::V6(address) => blocked_reason_v6(address),
891        }
892    }
893
894    fn blocked_reason_v4(address: Ipv4Addr) -> Option<&'static str> {
895        let [a, b, c, _] = address.octets();
896        Some(match (a, b, c) {
897            (0, _, _) => "unspecified network",
898            (10, _, _) => "private network",
899            (100, 64..=127, _) => "carrier-grade NAT",
900            (127, _, _) => "loopback",
901            (169, 254, _) => "link-local, including the cloud metadata service",
902            (172, 16..=31, _) => "private network",
903            (192, 0, 0) => "IETF protocol assignment",
904            (192, 0, 2) => "documentation",
905            (192, 168, _) => "private network",
906            (198, 18..=19, _) => "benchmarking",
907            (198, 51, 100) => "documentation",
908            (203, 0, 113) => "documentation",
909            (224..=239, _, _) => "multicast",
910            (240..=255, _, _) => "reserved or broadcast",
911            _ => return None,
912        })
913    }
914
915    fn blocked_reason_v6(address: Ipv6Addr) -> Option<&'static str> {
916        // An address that carries an IPv4 one - mapped, compatible, 6to4,
917        // NAT64 - reaches the IPv4 host it names, so it is judged as that host.
918        if let Some(embedded) = embedded_ipv4(address) {
919            if let Some(reason) = blocked_reason_v4(embedded) {
920                return Some(reason);
921            }
922        }
923        let segments = address.segments();
924        if address.is_unspecified() {
925            return Some("unspecified address");
926        }
927        if address.is_loopback() {
928            return Some("loopback");
929        }
930        if address.is_multicast() {
931            return Some("multicast");
932        }
933        Some(match segments[0] {
934            first if first & 0xfe00 == 0xfc00 => "unique local",
935            first if first & 0xffc0 == 0xfe80 => "link-local",
936            0x2001 if segments[1] == 0x0db8 => "documentation",
937            0x0100 if segments[1] == 0 && segments[2] == 0 && segments[3] == 0 => "discard-only",
938            _ => return None,
939        })
940    }
941
942    fn embedded_ipv4(address: Ipv6Addr) -> Option<Ipv4Addr> {
943        let segments = address.segments();
944        let octets = address.octets();
945        let tail = Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]);
946        // ::ffff:a.b.c.d (mapped) and ::a.b.c.d (compatible, deprecated).
947        if segments[0..5] == [0, 0, 0, 0, 0] && (segments[5] == 0xffff || segments[5] == 0) {
948            return Some(tail);
949        }
950        // 2002:a.b.c.d::/16, the 6to4 prefix.
951        if segments[0] == 0x2002 {
952            return Some(Ipv4Addr::new(octets[2], octets[3], octets[4], octets[5]));
953        }
954        // 64:ff9b::/96 and 64:ff9b:1::/48, the NAT64 prefixes.
955        if segments[0] == 0x0064 && segments[1] == 0xff9b {
956            return Some(tail);
957        }
958        None
959    }
960
961    /// Resolves names and drops every address this SDK must not contact.
962    ///
963    /// The filter sits in the resolver rather than in front of it, so the
964    /// connection uses an address that was vetted. Checking a hostname and then
965    /// letting the stack resolve it again leaves a window in which the answer
966    /// can change to an internal one.
967    pub(crate) struct FilteringResolver {
968        pub(crate) allow_private_networks: bool,
969    }
970
971    impl ureq::Resolver for FilteringResolver {
972        fn resolve(&self, netloc: &str) -> io::Result<Vec<SocketAddr>> {
973            let resolved: Vec<SocketAddr> = netloc.to_socket_addrs()?.collect();
974            if self.allow_private_networks {
975                return Ok(resolved);
976            }
977            let mut refused: Option<(net::IpAddr, &'static str)> = None;
978            let permitted: Vec<SocketAddr> = resolved
979                .into_iter()
980                .filter(|address| match blocked_reason(address.ip()) {
981                    Some(reason) => {
982                        refused.get_or_insert((address.ip(), reason));
983                        false
984                    }
985                    None => true,
986                })
987                .collect();
988            if permitted.is_empty() {
989                let detail = match refused {
990                    Some((address, reason)) => format!(
991                        "{netloc} resolves to {address}, a {reason} {BLOCKED_ADDRESS_MARKER}"
992                    ),
993                    None => format!("{netloc} resolves to no address"),
994                };
995                return Err(io::Error::new(io::ErrorKind::PermissionDenied, detail));
996            }
997            Ok(permitted)
998        }
999    }
1000
1001    pub(crate) struct NativeFetcher {
1002        agent: ureq::Agent,
1003        /// Intranet mode: the address filter is off, and plaintext http is
1004        /// allowed for every purpose rather than for OCSP alone. An operator
1005        /// who sets it has declared this deployment's own network trusted; a
1006        /// manifest repository on an internal host is routinely plain http.
1007        intranet: bool,
1008    }
1009
1010    impl NativeFetcher {
1011        pub(crate) fn new(allow_private_networks: bool) -> Self {
1012            Self {
1013                agent: ureq::AgentBuilder::new()
1014                    .timeout_connect(CONNECT_TIMEOUT)
1015                    .timeout(REQUEST_TIMEOUT)
1016                    // Redirects are refused. A manifest controls every URL in
1017                    // this fetcher; following Location would expand that
1018                    // authority to a second endpoint.
1019                    .redirects(0)
1020                    .user_agent(USER_AGENT)
1021                    .resolver(FilteringResolver {
1022                        allow_private_networks,
1023                    })
1024                    .build(),
1025                intranet: allow_private_networks,
1026            }
1027        }
1028    }
1029
1030    impl Fetcher for NativeFetcher {
1031        fn fetch(&self, request: &FetchRequest<'_>) -> FetchResult {
1032            if request.remaining_requests == 0 {
1033                return Err(blocked(
1034                    "the request budget is spent before this request",
1035                    0,
1036                ));
1037            }
1038            let url = request.url;
1039            match split_url(url) {
1040                None => return Err(blocked(format!("{url} is not an absolute URL"), 0)),
1041                Some((scheme, _, _)) if scheme.eq_ignore_ascii_case("https") => {}
1042                Some((scheme, _, _))
1043                    if (request.allow_http || self.intranet)
1044                        && scheme.eq_ignore_ascii_case("http") => {}
1045                Some((scheme, _, _)) => {
1046                    return Err(blocked(
1047                        format!("{scheme} is not allowed for this request; https is required"),
1048                        0,
1049                    ))
1050                }
1051            }
1052
1053            let used = 1;
1054            let mut call = match request.method {
1055                FetchMethod::Get => self.agent.get(url),
1056                FetchMethod::Post => self.agent.post(url),
1057            };
1058            if let Some(content_type) = request.content_type {
1059                call = call.set("content-type", content_type);
1060            }
1061            if let Some(accept) = request.accept {
1062                call = call.set("accept", accept);
1063            }
1064            let response = match request.body {
1065                Some(body) => call.send_bytes(body),
1066                None => call.call(),
1067            };
1068            let response = match response {
1069                Ok(response) => response,
1070                Err(ureq::Error::Status(status, response)) if (300..400).contains(&status) => {
1071                    let location = response.header("location").unwrap_or("<missing>");
1072                    let detail = if request.remaining_requests <= used {
1073                        format!("redirect to {location} refused: the request budget is spent")
1074                    } else {
1075                        format!("redirect to {location} refused")
1076                    };
1077                    return Err(blocked(detail, used));
1078                }
1079                Err(ureq::Error::Status(status, _)) => {
1080                    return Err(failed(
1081                        format!("the server answered HTTP {status}"),
1082                        used,
1083                        0,
1084                    ))
1085                }
1086                Err(ureq::Error::Transport(transport)) => {
1087                    let detail = transport.to_string();
1088                    return Err(if detail.contains(BLOCKED_ADDRESS_MARKER) {
1089                        blocked(detail, used)
1090                    } else {
1091                        failed(detail, used, 0)
1092                    });
1093                }
1094            };
1095            if (300..400).contains(&response.status()) {
1096                let location = response.header("location").unwrap_or("<missing>");
1097                let detail = if request.remaining_requests <= used {
1098                    format!("redirect to {location} refused: the request budget is spent")
1099                } else {
1100                    format!("redirect to {location} refused")
1101                };
1102                return Err(blocked(detail, used));
1103            }
1104            read_body(response, request.limit, used)
1105        }
1106    }
1107
1108    fn read_body(response: ureq::Response, limit: usize, used: usize) -> FetchResult {
1109        let media_type = response
1110            .header("content-type")
1111            .and_then(|value| value.split(';').next())
1112            .map(str::trim)
1113            .filter(|value| !value.is_empty())
1114            .map(str::to_ascii_lowercase);
1115        let mut bytes = Vec::new();
1116        let capped = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
1117        if let Err(error) = response.into_reader().take(capped).read_to_end(&mut bytes) {
1118            let received_bytes = bytes.len();
1119            return Err(failed(error.to_string(), used, received_bytes));
1120        }
1121        if bytes.len() > limit {
1122            let received_bytes = bytes.len();
1123            return Err(oversized(
1124                format!("the response is larger than the {limit} byte limit"),
1125                used,
1126                received_bytes,
1127            ));
1128        }
1129        Ok(FetchOk {
1130            bytes,
1131            media_type,
1132            requests_used: used,
1133        })
1134    }
1135}
1136
1137// Test-only: `online/tests.rs` carries `#![cfg(test)]` itself.
1138mod tests;