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}