1use std::time::Duration;
23
24use anyhow::{Context, Result, bail};
25use serde_json::Value;
26
27use crate::org_policy::FileOrgPolicy;
28use crate::pair_decision::InboundMode;
29use crate::relay_client::{WireOrgTxtDid, WireOrgTxtRecord, parse_wire_org_txt_record};
30
31pub const DEFAULT_DOH_URL: &str = "https://cloudflare-dns.com/dns-query";
35pub const DOH_URL_ENV: &str = "WIRE_DOH_URL";
36
37pub trait TxtResolver {
40 fn resolve_txt(&self, fqdn: &str) -> Result<Vec<String>>;
41}
42
43pub struct DohResolver {
46 endpoint: String,
47}
48
49impl DohResolver {
50 pub fn new() -> Self {
51 let endpoint = std::env::var(DOH_URL_ENV).unwrap_or_else(|_| DEFAULT_DOH_URL.to_string());
52 Self { endpoint }
53 }
54}
55
56impl Default for DohResolver {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62impl TxtResolver for DohResolver {
63 fn resolve_txt(&self, fqdn: &str) -> Result<Vec<String>> {
64 let client = reqwest::blocking::Client::builder()
65 .timeout(Duration::from_secs(10))
66 .build()
67 .context("building DoH HTTP client")?;
68 let resp = client
69 .get(&self.endpoint)
70 .query(&[("name", fqdn), ("type", "TXT")])
71 .header("accept", "application/dns-json")
72 .send()
73 .with_context(|| format!("DoH query for {fqdn} via {}", self.endpoint))?;
74 if !resp.status().is_success() {
75 bail!("DoH resolver returned HTTP {} for {fqdn}", resp.status());
76 }
77 let body: Value = resp.json().context("parsing DoH JSON response")?;
78 Ok(extract_txt_answers(&body))
79 }
80}
81
82fn extract_txt_answers(body: &Value) -> Vec<String> {
85 let mut out = Vec::new();
86 if let Some(answers) = body.get("Answer").and_then(Value::as_array) {
87 for a in answers {
88 if a.get("type").and_then(Value::as_u64) != Some(16) {
89 continue; }
91 if let Some(data) = a.get("data").and_then(Value::as_str) {
92 out.push(unquote_txt(data));
93 }
94 }
95 }
96 out
97}
98
99fn unquote_txt(data: &str) -> String {
103 let trimmed = data.trim();
104 if !trimmed.contains('"') {
105 return trimmed.to_string();
106 }
107 let mut out = String::new();
108 let mut in_quote = false;
109 let mut prev_backslash = false;
110 for c in trimmed.chars() {
111 match c {
112 '"' if !prev_backslash => in_quote = !in_quote,
113 _ if in_quote => out.push(c),
114 _ => {}
115 }
116 prev_backslash = c == '\\' && !prev_backslash;
117 }
118 out
119}
120
121pub fn org_record_for_domain(resolver: &dyn TxtResolver, domain: &str) -> Result<WireOrgTxtRecord> {
124 let domain = domain.trim().trim_end_matches('.');
125 if domain.is_empty() {
126 bail!("empty domain");
127 }
128 let fqdn = format!("_wire-org.{domain}");
129 let records = resolver.resolve_txt(&fqdn)?;
130 let found = records.len();
131 for r in records {
132 if let Ok(parsed) = parse_wire_org_txt_record(&r) {
133 return Ok(parsed);
134 }
135 }
136 bail!(
137 "no valid wire-org TXT record at {fqdn} ({found} TXT record(s) resolved, \
138 none parseable as `did=did:wire:org:…; v=1`). Confirm the org published \
139 `_wire-org.{domain}`."
140 )
141}
142
143pub fn bind_org(
150 resolver: &dyn TxtResolver,
151 domain: &str,
152 mode: InboundMode,
153) -> Result<(String, WireOrgTxtRecord)> {
154 let record = org_record_for_domain(resolver, domain)?;
155 let org_did = match &record.did {
156 WireOrgTxtDid::Org(did) => did.clone(),
157 WireOrgTxtDid::Op(did) => bail!(
158 "`_wire-org.{}` binds a personal operator DID ({did}), not an organization. \
159 `wire org bind` trusts an org's members; it is not for personal-tier domains.",
160 domain.trim().trim_end_matches('.')
161 ),
162 };
163 let mut policy = FileOrgPolicy::load();
164 policy.set(&org_did, mode);
165 policy.save()?;
166 Ok((org_did, record))
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use std::collections::HashMap;
173
174 struct FakeResolver(HashMap<String, Vec<String>>);
176 impl FakeResolver {
177 fn with(fqdn: &str, records: &[&str]) -> Self {
178 let mut m = HashMap::new();
179 m.insert(
180 fqdn.to_string(),
181 records.iter().map(|s| s.to_string()).collect(),
182 );
183 Self(m)
184 }
185 }
186 impl TxtResolver for FakeResolver {
187 fn resolve_txt(&self, fqdn: &str) -> Result<Vec<String>> {
188 Ok(self.0.get(fqdn).cloned().unwrap_or_default())
189 }
190 }
191
192 const ORG_DID: &str = "did:wire:org:acme-0123456789abcdef0123456789abcdef";
194 const OP_DID: &str = "did:wire:op:darby-0123456789abcdef0123456789abcdef";
195
196 #[test]
197 fn unquote_joins_chunked_txt() {
198 assert_eq!(unquote_txt("\"did=x; \" \"v=1\""), "did=x; v=1");
199 assert_eq!(unquote_txt("\"plain\""), "plain");
200 assert_eq!(unquote_txt("unquoted"), "unquoted");
201 }
202
203 #[test]
204 fn extract_txt_answers_filters_non_txt() {
205 let body = serde_json::json!({
206 "Answer": [
207 { "type": 5, "data": "cname.example." }, { "type": 16, "data": "\"did=hi; v=1\"" },
209 ]
210 });
211 assert_eq!(extract_txt_answers(&body), vec!["did=hi; v=1".to_string()]);
212 }
213
214 #[test]
215 fn org_record_for_domain_picks_the_wire_record() {
216 let fqdn = "_wire-org.acme.com";
217 let resolver = FakeResolver::with(
218 fqdn,
219 &[
220 "v=spf1 include:_spf.google.com ~all", &format!("did={ORG_DID}; v=1"),
222 ],
223 );
224 let rec = org_record_for_domain(&resolver, "acme.com").unwrap();
225 assert_eq!(rec.did.as_str(), ORG_DID);
226 }
227
228 #[test]
229 fn org_record_for_domain_errors_when_none_resolve() {
230 let resolver = FakeResolver::with("_wire-org.empty.com", &[]);
231 assert!(org_record_for_domain(&resolver, "empty.com").is_err());
232 }
233
234 #[test]
235 fn bind_org_writes_policy_for_org_domain() {
236 crate::config::test_support::with_temp_home(|| {
237 let resolver =
238 FakeResolver::with("_wire-org.acme.com", &[&format!("did={ORG_DID}; v=1")]);
239 let (org_did, _rec) = bind_org(&resolver, "acme.com", InboundMode::Notify).unwrap();
240 assert_eq!(org_did, ORG_DID);
241 let pol = FileOrgPolicy::load();
243 assert_eq!(
244 crate::pair_decision::OrgPolicy::inbound_mode(&pol, ORG_DID),
245 Some(InboundMode::Notify)
246 );
247 });
248 }
249
250 #[test]
251 fn bind_org_rejects_personal_operator_did() {
252 crate::config::test_support::with_temp_home(|| {
253 let resolver =
254 FakeResolver::with("_wire-org.darby.dev", &[&format!("did={OP_DID}; v=1")]);
255 let err = bind_org(&resolver, "darby.dev", InboundMode::Notify).unwrap_err();
256 assert!(
257 format!("{err:#}").contains("personal operator DID"),
258 "got: {err:#}"
259 );
260 assert!(FileOrgPolicy::load().is_empty());
262 });
263 }
264}