Skip to main content

wire/
org_bind.rs

1//! RFC-001 §2 / amendment-sso §A — DNS-TXT org binding (the domain-rooted
2//! trust floor).
3//!
4//! An org proves control of a domain by publishing
5//! `_wire-org.<domain> TXT "did=did:wire:org:<id>; v=1"`. A receiver who runs
6//! `wire org bind <domain>` resolves that record, extracts the `org_did`, and
7//! records a per-org pairing policy (`org_policies.json`). From then on, a
8//! peer presenting a verified `member_cert` for that org reaches `ORG_VERIFIED`
9//! under the receiver's chosen inbound mode — the org identity is now rooted in
10//! a domain the org demonstrably controls, not just a bare keypair.
11//!
12//! This is **policy-setup-time** resolution, not a per-pairing dependency: the
13//! pairing hot path stays fully offline (`org_membership::evaluate_card_membership`
14//! verifies the inline cert chain). DNS is consulted once, here, to translate a
15//! human domain into the `org_did` the offline chain already verifies against.
16//!
17//! Resolution is **DNS-over-HTTPS** (no extra DNS crate; works behind the
18//! TLS-terminating proxies and split-horizon resolvers wire already tolerates
19//! for federation). The resolver is a trait so the resolve→pin logic is
20//! unit-testable without a network.
21
22use 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
31/// Default DNS-over-HTTPS endpoint. Cloudflare's resolver speaks the
32/// `application/dns-json` shape this module parses. Override with `WIRE_DOH_URL`
33/// (e.g. an internal resolver, or Google's `https://dns.google/resolve`).
34pub const DEFAULT_DOH_URL: &str = "https://cloudflare-dns.com/dns-query";
35pub const DOH_URL_ENV: &str = "WIRE_DOH_URL";
36
37/// Resolver seam: return every TXT string at `fqdn` (already unquoted + chunk-
38/// joined). Implemented over DoH in production, faked in tests.
39pub trait TxtResolver {
40    fn resolve_txt(&self, fqdn: &str) -> Result<Vec<String>>;
41}
42
43/// DNS-over-HTTPS resolver. No extra crate — reuses the `reqwest::blocking`
44/// client wire already depends on.
45pub 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
82/// Pull TXT (`type == 16`) answers out of a DoH `application/dns-json` body,
83/// unquoting + joining the per-string chunks DNS splits long records into.
84fn 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; // not a TXT record
90            }
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
99/// DoH returns TXT `data` as the raw RDATA presentation form: one or more
100/// double-quoted character-strings (DNS splits >255-byte records into chunks),
101/// e.g. `"\"did=...; \" \"v=1\""`. Strip the quotes and concatenate the chunks.
102fn 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
121/// Resolve `_wire-org.<domain>` and return the first TXT record that parses as
122/// a valid wire-org binding. Errors if none resolve or none parse.
123pub 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
143/// Resolve a domain's `org_did` and pin a per-org inbound policy for it
144/// (RFC-001 §2 floor). Returns the bound `org_did` + the resolved record.
145///
146/// Rejects a record that binds a personal-tier operator DID (`did:wire:op:…`):
147/// `wire org bind` is the *organization* floor; a personal domain is a
148/// different (single-operator) relationship.
149pub 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    /// Canned resolver: fqdn → TXT strings.
175    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    // A well-formed org DID (did:wire:org:<handle>-<32 hex>).
193    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." },          // CNAME — ignored
208                { "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", // unrelated TXT — skipped
221                &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            // The policy file now trusts that org_did at `notify`.
242            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            // And nothing was written to policy.
261            assert!(FileOrgPolicy::load().is_empty());
262        });
263    }
264}