Skip to main content

qualia_client_core/
qdp_resolver.rs

1//! **QDP front-door resolver over DNS-over-HTTPS (DoH)** — QDP §3.6.
2//!
3//! The front-door anchor for a domain is a DNS `TXT` record at `_qdp.<domain>` (see
4//! [`crate::front_door`]). Rather than depend on the host's stub resolver (which often cannot
5//! return `TXT`, and leaks the lookup to the local network), we resolve it over **DoH** against a
6//! public resolver that speaks the JSON DoH format (Cloudflare / Google, RFC 8484 + the JSON
7//! profile). The wire format is a small JSON document with an `"Answer"` array; each answer's
8//! `"data"` field is the (possibly quote-wrapped, possibly split) TXT string.
9//!
10//! Two pieces:
11//! - [`parse_doh_txt_answers`] — **pure**: turns a DoH JSON body into one concatenated TXT string.
12//!   A TXT record longer than 255 bytes is transmitted as multiple `"..." "..."` character-strings;
13//!   this joins them (and strips the surrounding / boundary `"` quotes) into a single string.
14//! - [`resolve_front_door`] — **host-only** (`not(wasm32)`): performs the blocking HTTPS GET and
15//!   feeds the joined text to [`crate::front_door::FrontDoorRecord::from_dns_txt`].
16//!
17//! No private keys are ever fetched (QDP §5) — the front-door TXT carries only the Front Door DID
18//! and public peering material.
19
20use crate::front_door::{dns_record_name, FrontDoorRecord};
21
22/// PURE. From a Cloudflare/Google DoH JSON response, read the `"Answer"` array and concatenate the
23/// `"data"` field of every answer into one string.
24///
25/// DNS `TXT` values are transmitted as one or more length-prefixed character-strings (≤255 bytes
26/// each); DoH resolvers surface a multi-part TXT as `"part-a" "part-b"` inside a single `"data"`
27/// field, and split records may also appear as multiple `Answer` elements. In both cases the
28/// logical value is the pieces concatenated with no separator, so we:
29/// 1. take each answer's `"data"` string,
30/// 2. strip the surrounding double-quotes and any internal `" "` string-boundary quotes, and
31/// 3. concatenate everything into one string (which is then a valid `_qdp.<domain>` TXT value).
32///
33/// Missing / malformed answers are skipped; a body with no usable answers yields `""`.
34pub fn parse_doh_txt_answers(json: &serde_json::Value) -> String {
35    let Some(answers) = json.get("Answer").and_then(|a| a.as_array()) else {
36        return String::new();
37    };
38    let mut joined = String::new();
39    for ans in answers {
40        let Some(data) = ans.get("data").and_then(|d| d.as_str()) else {
41            continue;
42        };
43        // Strip the surrounding double-quotes the resolver wraps the record in, then remove any
44        // internal `"` string-boundary quotes that separate the multi-part `"a" "b"` pieces.
45        let trimmed = data.trim().trim_matches('"');
46        for piece in trimmed.split('"') {
47            joined.push_str(piece);
48        }
49    }
50    joined
51}
52
53/// Resolve a domain's QDP front-door record via DoH (Cloudflare), host targets only.
54///
55/// Builds the query name with [`dns_record_name`] (`_qdp.<domain>`), performs a blocking HTTPS
56/// `GET https://cloudflare-dns.com/dns-query?name=<name>&type=TXT` with the
57/// `accept: application/dns-json` header (8 s timeout), parses the JSON body, joins the TXT answers
58/// via [`parse_doh_txt_answers`], and decodes them with
59/// [`FrontDoorRecord::from_dns_txt`]. All errors are mapped to `String`.
60#[cfg(not(target_arch = "wasm32"))]
61pub fn resolve_front_door(domain: &str) -> Result<FrontDoorRecord, String> {
62    let name = dns_record_name(domain);
63    let url = format!("https://cloudflare-dns.com/dns-query?name={name}&type=TXT");
64
65    let client = reqwest::blocking::Client::builder()
66        .timeout(std::time::Duration::from_secs(8))
67        .build()
68        .map_err(|e| format!("failed to build DoH client: {e}"))?;
69
70    let resp = client
71        .get(&url)
72        .header("accept", "application/dns-json")
73        .send()
74        .map_err(|e| format!("DoH request to {url} failed: {e}"))?;
75
76    let json: serde_json::Value = resp
77        .json()
78        .map_err(|e| format!("DoH response for {domain} was not valid JSON: {e}"))?;
79
80    let joined = parse_doh_txt_answers(&json);
81    if joined.is_empty() {
82        return Err(format!("no _qdp TXT answer for {domain}"));
83    }
84    FrontDoorRecord::from_dns_txt(domain, &joined)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use serde_json::json;
91
92    #[test]
93    fn parse_doh_txt_answers_joins_and_strips_quotes() {
94        // A Cloudflare/Google DoH body: the TXT record is wrapped in `"..."` and its internal
95        // string-boundary quotes are escaped in the JSON `data` field.
96        let body = json!({
97            "Answer": [
98                { "data": "\"qdp:signer <did:qdp:x> ; qdp:agentType \\\"person\\\"\"" }
99            ]
100        });
101        let joined = parse_doh_txt_answers(&body);
102        assert!(
103            joined.contains("qdp:signer <did:qdp:x>"),
104            "expected the signer clause in the joined TXT, got: {joined:?}"
105        );
106
107        // And the joined text must decode into a real front-door record.
108        let rec = FrontDoorRecord::from_dns_txt("x.example", &joined)
109            .expect("joined TXT should parse into a FrontDoorRecord");
110        assert_eq!(rec.front_door_did, "did:qdp:x");
111        assert_eq!(rec.domain, "x.example");
112    }
113
114    #[test]
115    fn parse_doh_txt_answers_concatenates_split_records() {
116        // A single TXT value delivered as two `Answer` elements (a long record split across
117        // multiple character-strings) — quote-stripped, the pieces concatenate with no separator.
118        let body = json!({
119            "Answer": [
120                { "data": "\"qdp:signer <did:qdp:sp\"" },
121                { "data": "\"lit> ; qdp:agentType \\\"org\\\"\"" }
122            ]
123        });
124        let joined = parse_doh_txt_answers(&body);
125        assert!(
126            joined.contains("qdp:signer <did:qdp:split>"),
127            "split TXT pieces should concatenate, got: {joined:?}"
128        );
129        let rec = FrontDoorRecord::from_dns_txt("split.example", &joined).unwrap();
130        assert_eq!(rec.front_door_did, "did:qdp:split");
131    }
132
133    #[test]
134    fn parse_doh_txt_answers_empty_on_missing_answer() {
135        assert_eq!(parse_doh_txt_answers(&json!({ "Status": 0 })), "");
136        assert_eq!(parse_doh_txt_answers(&json!({ "Answer": [] })), "");
137    }
138}