Skip to main content

dig_urn_resolver/
ladder.rs

1//! The §5.3 node-first connection ladder — the first reusable packaging of the
2//! canonical resolution order for third-party embedding.
3//!
4//! Order (first that responds wins): **explicit override > `dig.local` >
5//! `localhost:9778` > `rpc.dig.net`**.
6//!
7//! # Node trust is LOOPBACK-ONLY (security invariant)
8//!
9//! The node `/s/` path returns bytes the *server* decrypted + verified, with NO
10//! client-side crypto. That is safe ONLY because the node is the user's OWN machine
11//! (a loopback trust boundary). Therefore a host is granted [`EndpointKind::Node`]
12//! trust ONLY when it is an **asserted-loopback host**: a `127.0.0.0/8` / `::1`
13//! literal, the reserved name `localhost`, or `dig.local` *iff it resolves to a
14//! loopback address*. EVERY other host — including an explicit override pointed at a
15//! remote host — MUST use the client-VERIFIED [`EndpointKind::Rpc`] path (blind
16//! fetch → merkle-verify against the chain-anchored root → decrypt). This defeats
17//! (a) an override aimed at an attacker host and (b) a LAN mDNS spoof of the
18//! `.local` name — neither can serve unverified bytes as trusted content.
19
20use crate::transport::HttpTransport;
21
22/// The canonical DIG node port (`dig_constants::DIG_NODE_PORT`). Both local tiers
23/// probe this port.
24pub const DIG_NODE_PORT: u16 = 9778;
25
26/// The installed local node's hosts-registered name (§5.3 tier 1). Granted node
27/// trust ONLY if it resolves to loopback (see the module security invariant).
28pub const DIG_LOCAL_BASE: &str = "http://dig.local:9778";
29/// The loopback fallback for a node not registered in hosts (§5.3 tier 2).
30pub const LOCALHOST_BASE: &str = "http://localhost:9778";
31/// The public gateway — the FINAL fallback only (§5.3 tier 3).
32pub const RPC_DEFAULT_BASE: &str = "https://rpc.dig.net";
33
34/// Which read surface a base URL speaks: a dig-node (`/s/` server-side decrypt,
35/// loopback-trusted) or the rpc gateway (`dig.getContent` blind fetch → client
36/// verify+decrypt).
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum EndpointKind {
39    /// A dig-node local serve surface. ONLY ever an asserted-loopback host.
40    Node,
41    /// The rpc.dig.net-style JSON-RPC gateway (client-verified).
42    Rpc,
43}
44
45/// A resolved endpoint: a base URL and the surface it speaks.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Endpoint {
48    /// The base URL (no trailing slash).
49    pub base: String,
50    /// The read surface at `base`.
51    pub kind: EndpointKind,
52}
53
54impl Endpoint {
55    /// A node endpoint at `base`.
56    pub fn node(base: impl Into<String>) -> Self {
57        Endpoint {
58            base: base.into(),
59            kind: EndpointKind::Node,
60        }
61    }
62
63    /// An rpc endpoint at `base`.
64    pub fn rpc(base: impl Into<String>) -> Self {
65        Endpoint {
66            base: base.into(),
67            kind: EndpointKind::Rpc,
68        }
69    }
70}
71
72/// Parse the HOST of a base URL with the SAME WHATWG URL parser the transport dials
73/// with, so the classifier's host is byte-identical to the real connect target. This
74/// is the security-critical fix for URL confusion: a hand-rolled splitter diverges
75/// from WHATWG on userinfo (`@`), backslash (`\`→`/` for special schemes), fragment
76/// (`#`), query (`?`) and percent-encoding — each of which could let a loopback-looking
77/// authority mask a REMOTE dial target and steal crypto-free node trust.
78///
79/// Returns `None` for a URL that does not parse or is not http(s) — such a base is
80/// NEVER granted node trust. Native uses the `url` crate (reqwest's parser); wasm uses
81/// the browser's WHATWG `URL` (what `fetch` uses).
82fn parsed_host(base: &str) -> Option<String> {
83    #[cfg(feature = "native")]
84    {
85        let url = url::Url::parse(base).ok()?;
86        match url.scheme() {
87            "http" | "https" => url.host_str().map(|h| h.to_string()),
88            _ => None,
89        }
90    }
91    #[cfg(all(not(feature = "native"), feature = "wasm"))]
92    {
93        let url = web_sys::Url::new(base).ok()?;
94        match url.protocol().as_str() {
95            "http:" | "https:" => Some(url.hostname()),
96            _ => None,
97        }
98    }
99    #[cfg(all(not(feature = "native"), not(feature = "wasm")))]
100    {
101        let _ = base;
102        None
103    }
104}
105
106/// Whether the base URL's PARSED host is an asserted-loopback host (→ node trust).
107/// `false` for a URL that does not parse / is not http(s) / is not loopback.
108fn is_loopback_base(base: &str) -> bool {
109    parsed_host(base)
110        .map(|h| is_loopback_host(&h))
111        .unwrap_or(false)
112}
113
114/// Whether `host` resolves (via the OS resolver / hosts file) to loopback addresses
115/// ONLY. Native performs the real lookup; on wasm (no DNS in the browser) it is
116/// conservatively `false` — a non-literal name is never granted node trust there.
117fn resolves_to_loopback(host: &str) -> bool {
118    #[cfg(feature = "native")]
119    {
120        use std::net::ToSocketAddrs;
121        match (host, 0u16).to_socket_addrs() {
122            Ok(addrs) => {
123                let addrs: Vec<std::net::SocketAddr> = addrs.collect();
124                !addrs.is_empty() && addrs.iter().all(|a| a.ip().is_loopback())
125            }
126            Err(_) => false,
127        }
128    }
129    #[cfg(not(feature = "native"))]
130    {
131        let _ = host;
132        false
133    }
134}
135
136/// Is `host` an ASSERTED-LOOPBACK host eligible for node trust?
137///
138/// `true` for the reserved name `localhost`, any `127.0.0.0/8` / `::1` literal, or
139/// `dig.local` when it resolves to loopback. `false` for every other host (remote
140/// hosts, `rpc.dig.net`, a spoofable non-loopback `.local`).
141pub fn is_loopback_host(host: &str) -> bool {
142    let h = host.trim().trim_start_matches('[').trim_end_matches(']');
143    let h = h.to_ascii_lowercase();
144    if h == "localhost" {
145        return true;
146    }
147    if let Ok(ip) = h.parse::<std::net::IpAddr>() {
148        return ip.is_loopback();
149    }
150    if h == "dig.local" {
151        return resolves_to_loopback("dig.local");
152    }
153    false
154}
155
156/// Classify a base URL into a trust-correct [`Endpoint`]: node ONLY for an
157/// asserted-loopback host, rpc (client-verified) for everything else.
158pub fn classify(base: &str) -> Endpoint {
159    let trimmed = base.trim_end_matches('/').to_string();
160    if is_loopback_base(&trimmed) {
161        Endpoint::node(trimmed)
162    } else {
163        Endpoint::rpc(trimmed)
164    }
165}
166
167/// Cheaply probe a node's `/health`. `true` iff it responds with a success status
168/// within the transport's timeout; any transport error or non-2xx is `false`.
169async fn health_ok<T: HttpTransport + ?Sized>(transport: &T, base: &str) -> bool {
170    match transport.get(&format!("{base}/health")).await {
171        Ok(resp) => resp.is_success(),
172        Err(_) => false,
173    }
174}
175
176/// Build the ordered try-plan for a resolve.
177///
178/// * `override_endpoint` set — the override WINS and skips the ladder. It is
179///   [`classify`]d by HOST: a loopback host → node, ANY other host (a remote
180///   override) → the client-verified rpc path. No public fallback is appended (an
181///   explicit endpoint is authoritative — it never silently leaks to the gateway).
182/// * otherwise — try `dig.local` then `localhost:9778`, each granted node trust ONLY
183///   when it is an asserted-loopback host AND its `/health` answers; the first such
184///   yields `[Node(tier), Rpc(rpc.dig.net)]`. If neither qualifies, the plan is just
185///   `[Rpc(rpc.dig.net)]`.
186pub async fn build_plan<T: HttpTransport + ?Sized>(
187    transport: &T,
188    override_endpoint: Option<&str>,
189) -> Vec<Endpoint> {
190    if let Some(base) = override_endpoint {
191        return vec![classify(base)];
192    }
193
194    for tier in [DIG_LOCAL_BASE, LOCALHOST_BASE] {
195        // Node trust requires BOTH loopback assertion AND a live /health.
196        if is_loopback_base(tier) && health_ok(transport, tier).await {
197            return vec![Endpoint::node(tier), Endpoint::rpc(RPC_DEFAULT_BASE)];
198        }
199    }
200    vec![Endpoint::rpc(RPC_DEFAULT_BASE)]
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn parsed_host_matches_the_whatwg_dial_target() {
209        assert_eq!(
210            parsed_host("http://dig.local:9778").as_deref(),
211            Some("dig.local")
212        );
213        assert_eq!(
214            parsed_host("http://localhost:9778").as_deref(),
215            Some("localhost")
216        );
217        assert_eq!(
218            parsed_host("http://127.0.0.1:9778").as_deref(),
219            Some("127.0.0.1")
220        );
221        assert_eq!(parsed_host("http://[::1]:9778").as_deref(), Some("[::1]"));
222        assert_eq!(
223            parsed_host("https://rpc.dig.net").as_deref(),
224            Some("rpc.dig.net")
225        );
226        assert_eq!(
227            parsed_host("http://evil.example.com/path").as_deref(),
228            Some("evil.example.com")
229        );
230        // Non-http(s) or unparseable → no host → never node-trusted.
231        assert_eq!(parsed_host("ftp://localhost").as_deref(), None);
232        assert_eq!(parsed_host("not a url").as_deref(), None);
233    }
234
235    #[test]
236    fn url_confusion_urls_are_never_node_trusted() {
237        // F1 (complete): userinfo (@), backslash (\ → / for special schemes), fragment
238        // (#), query (?), and percent-encoding MUST NOT mask a remote dial target as a
239        // loopback "host". The classifier parses with the transport's parser, so its
240        // host equals the real connect target — all of these → verified Rpc, not Node.
241        for base in [
242            "http://127.0.0.1:9778@evil.com",
243            "http://localhost:9778@evil.com",
244            "http://[::1]@evil.com",
245            "http://user:pass@evil.com:1234/x",
246            r"http://evil.com\@localhost",
247            r"http://evil.com\@127.0.0.1",
248            "http://evil.com#@localhost",
249            "http://evil.com?x=@localhost",
250            "http://127.0.0.1%40evil.com",
251        ] {
252            assert_eq!(
253                classify(base).kind,
254                EndpointKind::Rpc,
255                "URL-confusion base must classify Rpc (verified), not Node: {base}"
256            );
257        }
258    }
259
260    #[test]
261    fn genuine_loopback_still_node_trusted() {
262        // Legitimate userinfo in front of a real loopback host still resolves to it.
263        assert_eq!(
264            classify("http://user:pass@127.0.0.1:9778").kind,
265            EndpointKind::Node
266        );
267        assert_eq!(classify("http://localhost:9778").kind, EndpointKind::Node);
268        assert_eq!(classify("http://127.0.0.1").kind, EndpointKind::Node);
269    }
270
271    #[test]
272    fn loopback_hosts_only() {
273        assert!(is_loopback_host("localhost"));
274        assert!(is_loopback_host("127.0.0.1"));
275        assert!(is_loopback_host("127.5.6.7"));
276        assert!(is_loopback_host("::1"));
277        assert!(is_loopback_host("[::1]"));
278        // Remote + gateway hosts are NEVER loopback.
279        assert!(!is_loopback_host("evil.example.com"));
280        assert!(!is_loopback_host("rpc.dig.net"));
281        assert!(!is_loopback_host("10.0.0.5"));
282        assert!(!is_loopback_host("192.168.1.9"));
283    }
284
285    #[test]
286    fn classify_grants_node_only_to_loopback() {
287        assert_eq!(classify("http://127.0.0.1:9778").kind, EndpointKind::Node);
288        assert_eq!(classify("http://localhost:9778").kind, EndpointKind::Node);
289        assert_eq!(classify("http://[::1]:9778").kind, EndpointKind::Node);
290        // A remote override + the gateway are the VERIFIED rpc path.
291        assert_eq!(
292            classify("http://evil.example.com:9778").kind,
293            EndpointKind::Rpc
294        );
295        assert_eq!(classify("http://10.0.0.5:9778").kind, EndpointKind::Rpc);
296        assert_eq!(classify("https://rpc.dig.net").kind, EndpointKind::Rpc);
297    }
298}