Skip to main content

dig_urn_resolver/
node.rs

1//! The node read path — `GET {base}/s/<storeId>[:<root>]/<resourceKey>`.
2//!
3//! A loopback dig-node can answer in one of TWO shapes, distinguished
4//! DETERMINISTICALLY by headers (never by assuming "node ⇒ plaintext"):
5//!
6//! 1. **Verified PLAINTEXT** — the node decrypted + verified server-side and attests
7//!    `X-Dig-Verified: true`. Trusted directly (no client crypto), sound because:
8//!    - **Loopback only** — this path is reached ONLY for an asserted-loopback host
9//!      (the ladder's [`crate::ladder::classify`] guard); a remote/override host is
10//!      routed to the client-verified rpc path instead.
11//!    - **Attested** — a missing/false `X-Dig-Verified` is rejected fail-closed.
12//! 2. **CIPHERTEXT** — the node relayed opaque ciphertext (marked by
13//!    `X-Dig-Encrypted: true` or an `X-Dig-Inclusion-Proof` header). This is
14//!    client-side VERIFIED + DECRYPTED exactly like the rpc path (merkle proof +
15//!    AES-256-GCM-SIV via `digstore-core`, URN salt threaded in) — a node returning
16//!    ciphertext is NOT blindly trusted.
17//!
18//! A response that is neither attested plaintext nor decryptable ciphertext fails
19//! closed.
20
21use crate::cache::DiskArtifacts;
22use crate::content_type;
23use crate::crypto;
24use crate::error::{ResolveError, Result};
25use crate::resolver::{Fetched, ResolvedData};
26use crate::transport::HttpTransport;
27use crate::urn::ParsedUrn;
28
29/// Build the node serve URL for a parsed URN: root-pinned when the URN carries a
30/// root, else the root-independent form the node resolves to its current tip.
31fn serve_url(base: &str, parsed: &ParsedUrn) -> String {
32    let store = match parsed.root_hex() {
33        Some(root) => format!("{}:{}", parsed.store_id_hex(), root),
34        None => parsed.store_id_hex(),
35    };
36    format!("{base}/s/{store}/{}", parsed.resource_key())
37}
38
39/// Fetch a resource from a loopback dig-node. Returns decrypted bytes + content
40/// type. A `404` is a fail-closed [`ResolveError::NotFound`]; a transport failure
41/// (or any other non-2xx) is [`ResolveError::Transport`] so the ladder can fall
42/// through. Bytes served WITHOUT `X-Dig-Verified: true` are rejected fail-closed as
43/// [`ResolveError::VerifyFailed`] — the node did not attest verification.
44pub(crate) async fn fetch<T: HttpTransport + ?Sized>(
45    transport: &T,
46    base: &str,
47    parsed: &ParsedUrn,
48) -> Result<Fetched> {
49    let url = serve_url(base, parsed);
50    let resp = transport
51        .get(&url)
52        .await
53        .map_err(|e| ResolveError::Transport(e.0))?;
54
55    if resp.status == 404 {
56        return Err(ResolveError::NotFound);
57    }
58    if !resp.is_success() {
59        return Err(ResolveError::Transport(format!(
60            "node returned HTTP {}",
61            resp.status
62        )));
63    }
64
65    // The CONCRETE root the node served, so a rootless URN can still be cached under
66    // an immutable identity. Prefer the URN's pinned root, else the node's header.
67    let node_root = parsed.root_hex().or_else(|| {
68        resp.header("x-dig-root")
69            .map(str::trim)
70            .filter(|r| !r.is_empty())
71            .map(str::to_string)
72    });
73
74    // (1) PLAINTEXT, loopback-trusted: the node decrypted + verified server-side and
75    // ATTESTED it with `X-Dig-Verified: true`. Trust it (this path is loopback-only).
76    let verified_plaintext = resp
77        .header("x-dig-verified")
78        .map(|v| v.trim().eq_ignore_ascii_case("true"))
79        .unwrap_or(false);
80    if verified_plaintext {
81        let content_type = header_content_type(&resp)
82            .unwrap_or_else(|| content_type::derive(parsed.resource_key(), &resp.body));
83        return Ok(Fetched {
84            data: ResolvedData::new(resp.body, content_type),
85            root: node_root,
86            artifacts: None, // already-decrypted plaintext — not disk-re-verifiable
87        });
88    }
89
90    // (2) CIPHERTEXT node path: the node relayed opaque ciphertext (blind path). We
91    // MUST client-side verify+decrypt it exactly like the rpc path — reusing
92    // digstore-core, threading the URN salt via `parsed`. Detected deterministically
93    // by an explicit `X-Dig-Encrypted: true` marker OR the presence of the inclusion
94    // proof header (never by assuming "node ⇒ plaintext").
95    let is_ciphertext = resp
96        .header("x-dig-encrypted")
97        .map(|v| v.trim().eq_ignore_ascii_case("true"))
98        .unwrap_or(false)
99        || resp.header("x-dig-inclusion-proof").is_some();
100    if is_ciphertext {
101        let proof_b64 = resp
102            .header("x-dig-inclusion-proof")
103            .map(str::to_string)
104            .ok_or_else(|| {
105                ResolveError::VerifyFailed(
106                    "ciphertext node response missing X-Dig-Inclusion-Proof".into(),
107                )
108            })?;
109        // The trust root: the URN's pinned root, else the (loopback) node's X-Dig-Root.
110        let root = node_root.ok_or_else(|| {
111            ResolveError::VerifyFailed("ciphertext node response missing a root".into())
112        })?;
113        let chunk_lens = parse_chunk_lens(resp.header("x-dig-chunk-lens"))?;
114
115        // Gate-then-decrypt against the root (salt threaded via `parsed`). Tamper /
116        // wrong-or-absent salt → VerifyFailed/DecryptFailed → IntegrityFailure.
117        let bytes = crypto::verify_and_decrypt(parsed, &resp.body, &proof_b64, &root, &chunk_lens)?;
118        let content_type = content_type::derive(parsed.resource_key(), &bytes);
119        return Ok(Fetched {
120            data: ResolvedData::new(bytes, content_type),
121            root: Some(root),
122            // A node ciphertext response IS re-verifiable → disk-cacheable.
123            artifacts: Some(DiskArtifacts {
124                ciphertext: resp.body,
125                proof_b64,
126                chunk_lens,
127            }),
128        });
129    }
130
131    // (3) Neither attested plaintext nor decryptable ciphertext → fail closed.
132    Err(ResolveError::VerifyFailed(
133        "node response was neither X-Dig-Verified plaintext nor decryptable ciphertext".into(),
134    ))
135}
136
137/// The response `Content-Type` (bare type, no params), if non-empty.
138fn header_content_type(resp: &crate::transport::HttpResponse) -> Option<String> {
139    resp.header("content-type")
140        .map(|c| c.split(';').next().unwrap_or(c).trim().to_string())
141        .filter(|c| !c.is_empty())
142}
143
144/// Parse the `X-Dig-Chunk-Lens` header (comma-separated per-chunk ciphertext byte
145/// lengths). Absent/empty ⇒ a single chunk (`[]`). A malformed value fails closed.
146fn parse_chunk_lens(header: Option<&str>) -> Result<Vec<u32>> {
147    let Some(raw) = header.map(str::trim).filter(|s| !s.is_empty()) else {
148        return Ok(Vec::new());
149    };
150    raw.split(',')
151        .map(|n| n.trim().parse::<u32>())
152        .collect::<core::result::Result<Vec<u32>, _>>()
153        .map_err(|_| ResolveError::VerifyFailed("invalid X-Dig-Chunk-Lens header".into()))
154}