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");
}
}