Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-identity-sso/src/domains.rs

//! **An e-mail domain leads to an account only after a DNS proof** (A34-5).
//!
//! An account admin names its company's domains; none of them routes a
//! sign-in (Discover), counts for a claim or a JIT employee, or is held against
//! another account until the admin publishes a TXT record under it and the
//! server reads it back. The record binds the ACCOUNT ([`proof_value`]), so one
//! account's record never proves the domain for another, and needs no secret:
//! publishing it is the proof of controlling the domain. Public mail domains
//! (gmail.com, …) are refused outright: they lead to no company.
//!
//! The record is read over DNS-over-HTTPS JSON (`application/dns-json`, as
//! dns.google/resolve and Cloudflare answer it) through the product's own
//! [`crate::egress::Egress`], so the operator's egress switch and guard apply,
//! from the resolver the operator NAMED: none is a default (RA5-24), so with
//! none fed a proof is refused by name ([`NO_RESOLVER`]) and nobody is asked.

use crate::egress::Egress;
use crate::refusal::Refusal;

/// Google's DNS-over-HTTPS JSON. NOT a default any more (RA5-24): a domain
/// proof asks only the resolver the operator named. Kept so 0.1 callers build.
#[deprecated(since = "0.1.2", note = "no resolver is a default (RA5-24): the operator names one; unnamed is refused")]
pub const DEFAULT_RESOLVER: &str = "https://dns.google/resolve";

/// Why a domain proof with no resolver named is refused (RA5-24), after the
/// `[sso-domain-resolver]` code.
pub const NO_RESOLVER: &str =
    "no DNS-over-HTTPS resolver is named: the operator feeds one (sso_dns_resolver); none is a default";

/// The label the TXT record is published under (`_sso-proof.<domain>`).
pub const PROOF_LABEL: &str = "_sso-proof";

/// Public mail providers: anybody can have an address there, so a domain of
/// theirs leads to no company and proves nobody.
pub const PUBLIC_MAIL: &[&str] = &[
    "gmail.com",
    "googlemail.com",
    "outlook.com",
    "hotmail.com",
    "live.com",
    "msn.com",
    "yahoo.com",
    "ymail.com",
    "aol.com",
    "icloud.com",
    "me.com",
    "mac.com",
    "proton.me",
    "protonmail.com",
    "pm.me",
    "gmx.com",
    "gmx.net",
    "gmx.de",
    "web.de",
    "mail.com",
    "mail.ru",
    "yandex.ru",
    "yandex.com",
    "zoho.com",
    "fastmail.com",
    "hey.com",
    "tutanota.com",
    "tuta.io",
    "qq.com",
    "163.com",
    "126.com",
];

/// Is `domain` a public mail provider's (or under one)?
pub fn is_public_mail(domain: &str) -> bool {
    let d = domain.trim().trim_end_matches('.').to_ascii_lowercase();
    PUBLIC_MAIL
        .iter()
        .any(|p| d == *p || d.ends_with(&format!(".{p}")))
}

/// Refused when `domain` is a public mail provider's.
pub fn admissible(domain: &str) -> Result<(), Refusal> {
    if is_public_mail(domain) {
        Err(Refusal::DomainPublic)
    } else {
        Ok(())
    }
}

/// The name the TXT record lives at: `_sso-proof.<domain>`.
pub fn txt_name(domain: &str) -> String {
    format!(
        "{PROOF_LABEL}.{}",
        domain.trim().trim_end_matches('.').to_ascii_lowercase()
    )
}

/// The TXT record's value for `account` and `domain`: `sso-proof=<hex>`, the
/// first 32 hex digits of SHA-256(`nordisk-sso-domain ␟ account ␟ domain`).
pub fn proof_value(account: &str, domain: &str) -> String {
    let text = format!(
        "nordisk-sso-domain\u{1f}{}\u{1f}{}",
        account.trim(),
        domain.trim().trim_end_matches('.').to_ascii_lowercase()
    );
    format!("sso-proof={}", &crate::sha256_hex(text.as_bytes())[..32])
}

/// Do the TXT `records` carry `account`'s proof of `domain`?
pub fn proof_holds(records: &[String], account: &str, domain: &str) -> bool {
    let want = proof_value(account, domain);
    records.iter().any(|r| r.trim() == want)
}

/// The DNS-over-HTTPS JSON question for `name`'s TXT records to `resolver`
/// (the operator's; a blank one names no host, and [`verify_with`] refuses it
/// before asking).
pub fn doh_url(resolver: &str, name: &str) -> String {
    let resolver = resolver.trim();
    let sep = if resolver.contains('?') { '&' } else { '?' };
    format!("{resolver}{sep}name={name}&type=TXT")
}

/// The TXT strings of a DNS-over-HTTPS JSON answer (type 16), each record's
/// quoted chunks joined. A name that does not exist (NXDOMAIN) is no records.
pub fn txt_from_doh_json(body: &str) -> Result<Vec<String>, String> {
    let v: serde_json::Value = serde_json::from_str(body)
        .map_err(|e| format!("the resolver's answer is not JSON: {e}"))?;
    match v.get("Status").and_then(serde_json::Value::as_u64) {
        Some(0) => {}
        Some(3) => return Ok(Vec::new()),
        Some(other) => return Err(format!("the resolver answered DNS status {other}")),
        None => return Err("the resolver's answer has no Status".to_owned()),
    }
    let mut out = Vec::new();
    for answer in v
        .get("Answer")
        .and_then(serde_json::Value::as_array)
        .into_iter()
        .flatten()
    {
        if answer.get("type").and_then(serde_json::Value::as_u64) != Some(16) {
            continue;
        }
        let Some(data) = answer.get("data").and_then(serde_json::Value::as_str) else {
            continue;
        };
        out.push(join_chunks(data));
    }
    Ok(out)
}

/// `"a" "b"` → `ab`; an unquoted string as it is.
fn join_chunks(data: &str) -> String {
    let data = data.trim();
    if !data.starts_with('"') {
        return data.to_owned();
    }
    let mut out = String::new();
    let mut inside = false;
    let mut escaped = false;
    for c in data.chars() {
        match (inside, escaped, c) {
            (true, true, c) => {
                out.push(c);
                escaped = false;
            }
            (true, false, '\\') => escaped = true,
            (_, false, '"') => inside = !inside,
            (true, false, c) => out.push(c),
            _ => {}
        }
    }
    out
}

/// **Read `account`'s proof of `domain`** from the operator's resolver
/// ([`crate::state::SsoState::dns_resolver`]) through the operator-switched
/// egress ([`crate::state::SsoState::egress`]; the guard and the loopback test
/// door as for an IdP). Blocking. Ok when the record is there; the refusal by
/// code otherwise.
pub fn verify(state: &crate::state::SsoState, account: &str, domain: &str) -> Result<(), String> {
    admissible(domain).map_err(|r| r.message())?;
    let egress = state.egress()?;
    verify_with(
        &*egress,
        &state.dns_resolver(),
        state.allow_loopback_http(),
        &state.private_nets(),
        account,
        domain,
    )
}

/// [`verify`] through `egress` from `resolver` (blank: refused by name,
/// [`NO_RESOLVER`], nobody asked).
pub fn verify_with(
    egress: &dyn Egress,
    resolver: &str,
    allow_loopback_http: bool,
    nets: &crate::guard::PrivateNets,
    account: &str,
    domain: &str,
) -> Result<(), String> {
    admissible(domain).map_err(|r| r.message())?;
    if resolver.trim().is_empty() {
        return Err(Refusal::DomainResolver.with(NO_RESOLVER));
    }
    let url = doh_url(resolver, &txt_name(domain));
    crate::guard::url_allowed_in(&url, allow_loopback_http, nets)
        .map_err(|why| Refusal::DomainResolver.with(&why))?;
    let body = crate::egress::checked(egress.get(&url), "the DNS resolver")
        .map_err(|why| Refusal::DomainResolver.with(&why))?;
    let records = txt_from_doh_json(&body).map_err(|why| Refusal::DomainResolver.with(&why))?;
    if proof_holds(&records, account, domain) {
        Ok(())
    } else {
        Err(Refusal::DomainUnproven.with(&format!(
            "publish TXT {} = {}",
            txt_name(domain),
            proof_value(account, domain)
        )))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    struct Resolver {
        answer: String,
        asked: Mutex<Vec<String>>,
    }
    impl Egress for Resolver {
        fn get(&self, url: &str) -> Result<(u16, String), String> {
            self.asked.lock().unwrap().push(url.to_owned());
            Ok((200, self.answer.clone()))
        }
        fn post_form(&self, _: &str, _: &str, _: &str) -> Result<(u16, String), String> {
            Err("no".into())
        }
    }

    fn answering(records: &[&str]) -> Resolver {
        let answers: Vec<serde_json::Value> = records
            .iter()
            .map(|r| serde_json::json!({ "name": "_sso-proof.acme.example.", "type": 16, "TTL": 300, "data": format!("\"{r}\"") }))
            .collect();
        Resolver {
            answer: serde_json::json!({ "Status": 0, "Answer": answers }).to_string(),
            asked: Mutex::new(Vec::new()),
        }
    }

    /// ★ **A domain routes only after its TXT proof, and the proof is the
    /// account's** (A34-5): another account's record proves nothing, a missing
    /// one is refused by code, and a public mail domain is refused before any
    /// question is asked.
    #[test]
    fn a_domain_is_proven_by_the_accounts_own_txt_record() {
        let value = proof_value("acme", "Acme.Example");
        assert!(
            value.starts_with("sso-proof=") && value.len() == "sso-proof=".len() + 32,
            "{value}"
        );
        assert_eq!(
            value,
            proof_value("acme", "acme.example."),
            "the domain's spelling is one"
        );
        assert_ne!(
            value,
            proof_value("globex", "acme.example"),
            "one account's proof is not another's"
        );
        assert_eq!(txt_name("Acme.Example"), "_sso-proof.acme.example");

        let good = answering(&["v=spf1 -all", &value]);
        assert_eq!(
            verify_with(
                &good,
                "https://dns.google/resolve",
                false,
                &crate::guard::PrivateNets::default(),
                "acme",
                "acme.example"
            ),
            Ok(())
        );
        assert_eq!(
            good.asked.lock().unwrap()[0],
            "https://dns.google/resolve?name=_sso-proof.acme.example&type=TXT"
        );

        let squatter = answering(&[&proof_value("globex", "acme.example")]);
        let refused = verify_with(
            &squatter,
            "https://dns.google/resolve",
            false,
            &crate::guard::PrivateNets::default(),
            "acme",
            "acme.example",
        )
        .unwrap_err();
        assert_eq!(
            crate::refusal::code_of(&refused),
            Some("sso-domain-unproven"),
            "{refused}"
        );
        assert!(
            refused.contains(&value),
            "the admin is told what to publish: {refused}"
        );

        let never = answering(&[]);
        for public in ["gmail.com", "GMAIL.com", "mail.proton.me", "outlook.com"] {
            let refused = verify_with(
                &never,
                "",
                false,
                &crate::guard::PrivateNets::default(),
                "acme",
                public,
            )
            .unwrap_err();
            assert_eq!(
                crate::refusal::code_of(&refused),
                Some("sso-domain-public"),
                "{public}"
            );
        }
        assert!(
            never.asked.lock().unwrap().is_empty(),
            "a public domain was asked about"
        );
        let inside = verify_with(
            &never,
            "http://10.0.0.1/resolve",
            false,
            &crate::guard::PrivateNets::default(),
            "acme",
            "acme.example",
        )
        .unwrap_err();
        assert_eq!(
            crate::refusal::code_of(&inside),
            Some("sso-domain-resolver"),
            "{inside}"
        );
    }

    /// ★ RA5-24: **no resolver is a default.** With nothing fed, a domain
    /// proof is refused by name and nobody is asked: the customer's domain and
    /// its account's proof name never go to a resolver the operator did not
    /// name (owner rule: outbound needs explicit config). A fed one is asked.
    #[test]
    fn an_unfed_resolver_refuses_by_name_and_asks_nobody() {
        let value = proof_value("acme", "acme.example");
        let never = answering(&[&value]);
        for blank in ["", "  "] {
            let refused = verify_with(
                &never,
                blank,
                false,
                &crate::guard::PrivateNets::default(),
                "acme",
                "acme.example",
            )
            .unwrap_err();
            assert_eq!(
                crate::refusal::code_of(&refused),
                Some("sso-domain-resolver"),
                "{refused}"
            );
            assert!(refused.contains("sso_dns_resolver"), "the operator is told what to feed: {refused}");
        }
        assert!(never.asked.lock().unwrap().is_empty(), "nobody was asked: {:?}", never.asked.lock().unwrap());
        let state = crate::state::SsoState::default();
        assert_eq!(state.dns_resolver(), "", "an unfed state names no resolver");
        assert!(!format!("{:?}", doh_url("", "n")).contains("dns.google"));
        let fed = "https://doh.mock.invalid/resolve";
        assert_eq!(
            verify_with(&never, fed, false, &crate::guard::PrivateNets::default(), "acme", "acme.example"),
            Ok(())
        );
        assert_eq!(never.asked.lock().unwrap()[0], format!("{fed}?name=_sso-proof.acme.example&type=TXT"));
    }

    #[test]
    fn doh_answers_are_read_as_txt_strings() {
        let body = r#"{"Status":0,"Answer":[{"type":5,"data":"x."},{"type":16,"data":"\"sso-proof=\" \"abc\""},{"type":16,"data":"plain"}]}"#;
        assert_eq!(
            txt_from_doh_json(body).unwrap(),
            vec!["sso-proof=abc".to_owned(), "plain".to_owned()]
        );
        assert_eq!(
            txt_from_doh_json(r#"{"Status":3}"#).unwrap(),
            Vec::<String>::new(),
            "NXDOMAIN is no records"
        );
        assert!(txt_from_doh_json(r#"{"Status":2}"#).is_err());
        assert!(txt_from_doh_json("<html>").is_err());
        assert_eq!(
            doh_url(
                "https://cloudflare-dns.com/dns-query?ct=application/dns-json",
                "n"
            ),
            "https://cloudflare-dns.com/dns-query?ct=application/dns-json&name=n&type=TXT"
        );
        assert_eq!(join_chunks(r#""a\"b" "c""#), "a\"bc");
    }
}