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}