use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::error::ConfigError;
pub const HTTP_WELL_KNOWN_PREFIX: &str = "/.well-known/boatramp-domain-verification/";
pub const DNS_RECORD_PREFIX: &str = "_boatramp-verify";
pub const CHALLENGE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VerificationMethod {
Dns,
Http,
}
impl VerificationMethod {
pub fn as_str(self) -> &'static str {
match self {
Self::Dns => "dns",
Self::Http => "http",
}
}
}
impl std::str::FromStr for VerificationMethod {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"dns" | "txt" | "dns-txt" => Ok(Self::Dns),
"http" | "http-token" | "token" => Ok(Self::Http),
other => Err(ConfigError::parse(format!(
"unknown verification method `{other}` (expected `dns` or `http`)"
))),
}
}
}
impl std::fmt::Display for VerificationMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DomainVerification {
pub version: u32,
pub host: String,
pub token: String,
pub method: VerificationMethod,
pub verified: bool,
pub created_at_unix: u64,
}
impl Default for DomainVerification {
fn default() -> Self {
Self {
version: crate::SCHEMA_VERSION,
host: String::new(),
token: String::new(),
method: VerificationMethod::Dns,
verified: false,
created_at_unix: 0,
}
}
}
impl DomainVerification {
pub fn new(host: &str, method: VerificationMethod, now_unix: u64) -> Self {
Self {
version: crate::SCHEMA_VERSION,
host: normalize_host(host),
token: generate_token(),
method,
verified: false,
created_at_unix: now_unix,
}
}
pub fn dns_record_name(&self) -> String {
dns_record_name(&self.host)
}
pub fn http_challenge_path(&self) -> String {
http_challenge_path(&self.token)
}
pub fn http_challenge_url(&self) -> String {
format!("http://{}{}", self.host, self.http_challenge_path())
}
pub fn matches(&self, value: &str) -> bool {
value.trim() == self.token
}
pub fn matches_any<I, S>(&self, values: I) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
values.into_iter().any(|v| self.matches(v.as_ref()))
}
pub fn is_expired(&self, now_unix: u64) -> bool {
if self.verified {
return false;
}
if self.created_at_unix == 0 {
return true;
}
now_unix.saturating_sub(self.created_at_unix) > CHALLENGE_TTL_SECS
}
pub fn instructions(&self) -> String {
match self.method {
VerificationMethod::Dns => format!(
"Add this DNS record, then run `boatramp domain verify {host}`:\n \
{name} TXT \"{token}\"",
host = self.host,
name = self.dns_record_name(),
token = self.token,
),
VerificationMethod::Http => format!(
"Serve this token, then run `boatramp domain verify {host}`:\n \
GET {url}\n body: {token}",
host = self.host,
url = self.http_challenge_url(),
token = self.token,
),
}
}
pub fn from_json(bytes: &[u8]) -> Result<Self, ConfigError> {
serde_json::from_slice(bytes).map_err(|err| ConfigError::parse(err.to_string()))
}
pub fn to_json(&self) -> Result<Vec<u8>, ConfigError> {
serde_json::to_vec(self).map_err(|err| ConfigError::parse(err.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CheckResult {
pub verification: DomainVerification,
pub passed: bool,
pub attached: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
pub fn normalize_host(host: &str) -> String {
crate::host::Host::new(host).verification()
}
pub fn dns_record_name(host: &str) -> String {
crate::host::Host::new(host).dns_record_name()
}
pub fn http_challenge_path(token: &str) -> String {
format!("{HTTP_WELL_KNOWN_PREFIX}{token}")
}
fn generate_token() -> String {
let mut bytes = [0u8; 16];
getrandom::getrandom(&mut bytes).expect("system RNG");
hex::encode(bytes)
}
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("ownership probe failed: {0}")]
Probe(String),
#[error("verification method `{0}` is not supported by this build")]
Unsupported(VerificationMethod),
}
#[async_trait]
pub trait DomainProbe: Send + Sync {
async fn lookup_txt(&self, name: &str) -> Result<Vec<String>, VerifyError>;
async fn fetch_http(&self, url: &str) -> Result<String, VerifyError>;
}
pub async fn check_ownership(
probe: &dyn DomainProbe,
verification: &DomainVerification,
) -> Result<bool, VerifyError> {
match verification.method {
VerificationMethod::Dns => {
let values = probe.lookup_txt(&verification.dns_record_name()).await?;
Ok(verification.matches_any(values))
}
VerificationMethod::Http => {
let body = probe.fetch_http(&verification.http_challenge_url()).await?;
Ok(verification.matches(&body))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
fn fixed(method: VerificationMethod) -> DomainVerification {
DomainVerification {
version: crate::SCHEMA_VERSION,
host: "example.com".into(),
token: "deadbeefdeadbeefdeadbeefdeadbeef".into(),
method,
verified: false,
created_at_unix: 100,
}
}
struct FakeProbe {
txt: Vec<String>,
http: String,
queried: Mutex<Vec<String>>,
}
#[async_trait]
impl DomainProbe for FakeProbe {
async fn lookup_txt(&self, name: &str) -> Result<Vec<String>, VerifyError> {
self.queried.lock().unwrap().push(name.to_string());
Ok(self.txt.clone())
}
async fn fetch_http(&self, url: &str) -> Result<String, VerifyError> {
self.queried.lock().unwrap().push(url.to_string());
Ok(self.http.clone())
}
}
#[test]
fn normalize_strips_wildcard_dot_and_case() {
assert_eq!(normalize_host("*.Example.COM."), "example.com");
assert_eq!(normalize_host(" www.example.com "), "www.example.com");
}
#[test]
fn challenge_naming_is_stable() {
let v = fixed(VerificationMethod::Dns);
assert_eq!(v.dns_record_name(), "_boatramp-verify.example.com");
assert_eq!(
v.http_challenge_url(),
"http://example.com/.well-known/boatramp-domain-verification/\
deadbeefdeadbeefdeadbeefdeadbeef"
);
}
#[test]
fn method_parse_roundtrips() {
assert_eq!(
"dns".parse::<VerificationMethod>().unwrap(),
VerificationMethod::Dns
);
assert_eq!(
"HTTP".parse::<VerificationMethod>().unwrap(),
VerificationMethod::Http
);
assert_eq!(
"txt".parse::<VerificationMethod>().unwrap(),
VerificationMethod::Dns
);
assert!("smtp".parse::<VerificationMethod>().is_err());
}
#[test]
fn new_generates_distinct_unverified_tokens() {
let a = DomainVerification::new("*.Example.com", VerificationMethod::Dns, 7);
let b = DomainVerification::new("example.com", VerificationMethod::Dns, 7);
assert_eq!(a.host, "example.com");
assert!(!a.verified);
assert_eq!(a.token.len(), 32);
assert_ne!(a.token, b.token, "tokens must be random per challenge");
}
#[test]
fn json_roundtrip() {
let v = fixed(VerificationMethod::Http);
let bytes = v.to_json().unwrap();
assert_eq!(DomainVerification::from_json(&bytes).unwrap(), v);
}
#[test]
fn pending_challenge_expires_after_ttl() {
let v = fixed(VerificationMethod::Http); assert!(!v.is_expired(100));
assert!(!v.is_expired(100 + CHALLENGE_TTL_SECS));
assert!(v.is_expired(100 + CHALLENGE_TTL_SECS + 1));
let mut verified = v.clone();
verified.verified = true;
assert!(!verified.is_expired(100 + CHALLENGE_TTL_SECS + 10_000));
let mut unstamped_pending = v.clone();
unstamped_pending.created_at_unix = 0;
assert!(unstamped_pending.is_expired(0));
let mut unstamped_verified = unstamped_pending.clone();
unstamped_verified.verified = true;
assert!(!unstamped_verified.is_expired(u64::MAX));
}
#[tokio::test]
async fn dns_check_passes_when_a_txt_value_matches() {
let v = fixed(VerificationMethod::Dns);
let probe = FakeProbe {
txt: vec!["unrelated".into(), v.token.clone()],
http: String::new(),
queried: Mutex::new(Vec::new()),
};
assert!(check_ownership(&probe, &v).await.unwrap());
assert_eq!(
probe.queried.lock().unwrap()[0],
"_boatramp-verify.example.com"
);
}
#[tokio::test]
async fn dns_check_fails_when_no_value_matches() {
let v = fixed(VerificationMethod::Dns);
let probe = FakeProbe {
txt: vec!["nope".into()],
http: String::new(),
queried: Mutex::new(Vec::new()),
};
assert!(!check_ownership(&probe, &v).await.unwrap());
}
#[tokio::test]
async fn http_check_trims_and_matches_body() {
let v = fixed(VerificationMethod::Http);
let probe = FakeProbe {
txt: Vec::new(),
http: format!(" {}\n", v.token),
queried: Mutex::new(Vec::new()),
};
assert!(check_ownership(&probe, &v).await.unwrap());
assert!(probe.queried.lock().unwrap()[0].ends_with(&v.token));
}
#[tokio::test]
async fn http_check_rejects_wrong_body() {
let v = fixed(VerificationMethod::Http);
let probe = FakeProbe {
txt: Vec::new(),
http: "something else".into(),
queried: Mutex::new(Vec::new()),
};
assert!(!check_ownership(&probe, &v).await.unwrap());
}
}