use compact_str::CompactString;
use crate::error::DkimError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Algorithm {
RsaSha256,
Ed25519Sha256,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Canon {
Simple,
Relaxed,
}
#[derive(Debug, Clone)]
pub struct DkimHeader {
pub version: u32,
pub algorithm: Algorithm,
pub signature_b64: String,
pub body_hash_b64: String,
pub canon_header: Canon,
pub canon_body: Canon,
pub domain: CompactString,
pub selector: CompactString,
pub signed_headers: Vec<String>,
pub body_length: Option<u64>,
pub timestamp: Option<u64>,
pub expiration: Option<u64>,
pub identity: Option<CompactString>,
pub query_method: CompactString,
}
impl DkimHeader {
pub fn parse(value: &str) -> Result<Self, DkimError> {
let bytes = value.as_bytes();
let n = bytes.len();
let mut i = 0;
let mut version: Option<u32> = None;
let mut algorithm: Option<Algorithm> = None;
let mut signature_b64: Option<String> = None;
let mut body_hash_b64: Option<String> = None;
let mut canon_header = Canon::Simple;
let mut canon_body = Canon::Simple;
let mut domain: Option<CompactString> = None;
let mut selector: Option<CompactString> = None;
let mut signed_headers: Option<Vec<String>> = None;
let mut body_length: Option<u64> = None;
let mut timestamp: Option<u64> = None;
let mut expiration: Option<u64> = None;
let mut identity: Option<CompactString> = None;
let mut query_method: Option<CompactString> = None;
while i < n {
while i < n && matches!(bytes[i], b' ' | b'\t' | b'\r' | b'\n' | b';') {
i += 1;
}
if i >= n {
break;
}
let name_start = i;
while i < n && !matches!(bytes[i], b'=' | b' ' | b'\t' | b'\r' | b'\n' | b';') {
i += 1;
}
let name = &value[name_start..i];
if name.is_empty() {
return Err(DkimError::InvalidTag(format!(
"no tag name at offset {name_start}"
)));
}
while i < n && matches!(bytes[i], b' ' | b'\t') {
i += 1;
}
if i >= n || bytes[i] != b'=' {
return Err(DkimError::InvalidTag(format!("no `=` after tag {name:?}")));
}
i += 1;
let val_start = i;
while i < n && bytes[i] != b';' {
i += 1;
}
let raw_val = &value[val_start..i];
let lower_storage: String;
let name_bytes: &[u8] = if name.bytes().any(|b| b.is_ascii_uppercase()) {
lower_storage = name.to_ascii_lowercase();
lower_storage.as_bytes()
} else {
name.as_bytes()
};
match name_bytes {
b"v" => {
let trimmed = raw_val.trim();
let parsed: u32 = trimmed
.parse()
.map_err(|_| DkimError::InvalidTag(format!("v={trimmed}")))?;
if parsed != 1 {
return Err(DkimError::InvalidTag(format!("v={parsed}, expected 1")));
}
version = Some(parsed);
}
b"a" => {
algorithm = Some(match raw_val.trim() {
"rsa-sha256" => Algorithm::RsaSha256,
"ed25519-sha256" => Algorithm::Ed25519Sha256,
other => return Err(DkimError::UnsupportedAlgorithm(other.to_string())),
});
}
b"b" => signature_b64 = Some(strip_wsp(raw_val)),
b"bh" => body_hash_b64 = Some(strip_wsp(raw_val)),
b"d" => domain = Some(CompactString::new(raw_val.trim())),
b"s" => selector = Some(CompactString::new(raw_val.trim())),
b"h" => signed_headers = Some(parse_signed_headers(raw_val)?),
b"c" => {
let (h, b) = parse_canon(raw_val)?;
canon_header = h;
canon_body = b;
}
b"l" => body_length = Some(parse_u64_tag("l", raw_val)?),
b"t" => timestamp = Some(parse_u64_tag("t", raw_val)?),
b"x" => expiration = Some(parse_u64_tag("x", raw_val)?),
b"i" => identity = Some(CompactString::new(raw_val.trim())),
b"q" => query_method = Some(CompactString::new(raw_val.trim())),
_ => {} }
}
let version = version.ok_or_else(|| DkimError::MissingTag("v".into()))?;
let algorithm = algorithm.ok_or_else(|| DkimError::MissingTag("a".into()))?;
let signature_b64 = signature_b64.ok_or_else(|| DkimError::MissingTag("b".into()))?;
let body_hash_b64 = body_hash_b64.ok_or_else(|| DkimError::MissingTag("bh".into()))?;
let domain = domain.ok_or_else(|| DkimError::MissingTag("d".into()))?;
let selector = selector.ok_or_else(|| DkimError::MissingTag("s".into()))?;
let signed_headers = signed_headers.ok_or_else(|| DkimError::MissingTag("h".into()))?;
let query_method =
query_method.unwrap_or_else(|| CompactString::const_new("dns/txt"));
if !query_method.eq_ignore_ascii_case("dns/txt") {
return Err(DkimError::UnsupportedAlgorithm(format!("q={query_method}")));
}
Ok(DkimHeader {
version,
algorithm,
signature_b64,
body_hash_b64,
canon_header,
canon_body,
domain,
selector,
signed_headers,
body_length,
timestamp,
expiration,
identity,
query_method,
})
}
}
fn parse_u64_tag(name: &str, raw_val: &str) -> Result<u64, DkimError> {
let trimmed = raw_val.trim();
trimmed
.parse()
.map_err(|_| DkimError::InvalidTag(format!("{name}={trimmed}")))
}
fn parse_signed_headers(raw_val: &str) -> Result<Vec<String>, DkimError> {
let mut list: Vec<String> = Vec::with_capacity(8);
let mut cur: Vec<u8> = Vec::with_capacity(20);
for &b in raw_val.as_bytes() {
match b {
b' ' | b'\t' | b'\r' | b'\n' => {} b':' => {
if !cur.is_empty() {
let s = unsafe { String::from_utf8_unchecked(std::mem::take(&mut cur)) };
list.push(s);
cur.reserve(20);
}
}
_ => cur.push(b.to_ascii_lowercase()),
}
}
if !cur.is_empty() {
let s = unsafe { String::from_utf8_unchecked(cur) };
list.push(s);
}
if list.is_empty() {
return Err(DkimError::InvalidTag("h= empty".into()));
}
Ok(list)
}
fn parse_canon(c: &str) -> Result<(Canon, Canon), DkimError> {
let c = c.trim();
let (hdr, body) = match c.split_once('/') {
Some((h, b)) => (h.trim(), b.trim()),
None => (c, "simple"),
};
let h = match hdr {
"simple" => Canon::Simple,
"relaxed" => Canon::Relaxed,
other => return Err(DkimError::UnsupportedCanon(format!("header={other}"))),
};
let b = match body {
"simple" => Canon::Simple,
"relaxed" => Canon::Relaxed,
other => return Err(DkimError::UnsupportedCanon(format!("body={other}"))),
};
Ok((h, b))
}
fn strip_wsp(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
if !matches!(b, b' ' | b'\t' | b'\r' | b'\n') {
out.push(b as char);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_header() -> &'static str {
" v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=mail;\r\n\
\th=From:To:Subject:Date:Message-ID;\r\n\
\tbh=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=;\r\n\
\tb=SignatureValueGoesHere"
}
#[test]
fn parse_full_header() {
let h = DkimHeader::parse(sample_header()).unwrap();
assert_eq!(h.version, 1);
assert_eq!(h.algorithm, Algorithm::RsaSha256);
assert_eq!(h.canon_header, Canon::Relaxed);
assert_eq!(h.canon_body, Canon::Relaxed);
assert_eq!(h.domain, "example.com");
assert_eq!(h.selector, "mail");
assert_eq!(
h.signed_headers,
vec!["from", "to", "subject", "date", "message-id"]
);
assert_eq!(
h.body_hash_b64,
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
);
assert_eq!(h.signature_b64, "SignatureValueGoesHere");
assert!(h.body_length.is_none());
assert_eq!(h.query_method, "dns/txt");
}
#[test]
fn parse_simple_canon_default() {
let r =
DkimHeader::parse("v=1; a=rsa-sha256; d=e.com; s=s; h=From; bh=AAAA; b=BBBB").unwrap();
assert_eq!(r.canon_header, Canon::Simple);
assert_eq!(r.canon_body, Canon::Simple);
}
#[test]
fn parse_canon_relaxed_simple() {
let r = DkimHeader::parse(
"v=1; a=rsa-sha256; c=relaxed/simple; d=e.com; s=s; h=From; bh=A; b=B",
)
.unwrap();
assert_eq!(r.canon_header, Canon::Relaxed);
assert_eq!(r.canon_body, Canon::Simple);
}
#[test]
fn parse_canon_header_only_defaults_body() {
let r = DkimHeader::parse("v=1; a=rsa-sha256; c=relaxed; d=e.com; s=s; h=From; bh=A; b=B")
.unwrap();
assert_eq!(r.canon_header, Canon::Relaxed);
assert_eq!(r.canon_body, Canon::Simple);
}
#[test]
fn parse_signed_headers_lowercased() {
let r = DkimHeader::parse("v=1; a=rsa-sha256; d=e.com; s=s; h=From:TO:SuBjEcT; bh=A; b=B")
.unwrap();
assert_eq!(r.signed_headers, vec!["from", "to", "subject"]);
}
#[test]
fn parse_optional_l_t_x() {
let r = DkimHeader::parse(
"v=1; a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=B; l=1024; t=1000; x=2000",
)
.unwrap();
assert_eq!(r.body_length, Some(1024));
assert_eq!(r.timestamp, Some(1000));
assert_eq!(r.expiration, Some(2000));
}
#[test]
fn parse_rejects_missing_required() {
let r = DkimHeader::parse("a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=B");
assert!(matches!(r, Err(DkimError::MissingTag(_))));
}
#[test]
fn parse_rejects_wrong_version() {
let r = DkimHeader::parse("v=2; a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=B");
assert!(matches!(r, Err(DkimError::InvalidTag(_))));
}
#[test]
fn parse_rejects_unsupported_algo() {
let r = DkimHeader::parse("v=1; a=rsa-sha1; d=e.com; s=s; h=From; bh=A; b=B");
assert!(matches!(r, Err(DkimError::UnsupportedAlgorithm(_))));
}
#[test]
fn parse_ed25519_sha256_algorithm() {
let r =
DkimHeader::parse("v=1; a=ed25519-sha256; d=e.com; s=s; h=From; bh=A; b=B").unwrap();
assert_eq!(r.algorithm, Algorithm::Ed25519Sha256);
}
#[test]
fn parse_rejects_empty_h() {
let r = DkimHeader::parse("v=1; a=rsa-sha256; d=e.com; s=s; h=; bh=A; b=B");
assert!(matches!(r, Err(DkimError::InvalidTag(_))));
}
#[test]
fn parse_b_strips_wsp() {
let r = DkimHeader::parse("v=1; a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=A B\tC\r\n D")
.unwrap();
assert_eq!(r.signature_b64, "ABCD");
}
#[test]
fn parse_default_query_dns_txt() {
let r = DkimHeader::parse("v=1; a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=B").unwrap();
assert_eq!(r.query_method, "dns/txt");
}
#[test]
fn parse_rejects_non_dns_query() {
let r = DkimHeader::parse("v=1; a=rsa-sha256; q=https; d=e.com; s=s; h=From; bh=A; b=B");
assert!(matches!(r, Err(DkimError::UnsupportedAlgorithm(_))));
}
#[test]
fn parse_with_i_identity() {
let r =
DkimHeader::parse("v=1; a=rsa-sha256; d=e.com; s=s; h=From; bh=A; b=B; i=user@e.com")
.unwrap();
assert_eq!(r.identity.as_deref(), Some("user@e.com"));
}
}