#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DkimError {
Empty,
InvalidValue,
UnknownLabel,
}
impl fmt::Display for DkimError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("DKIM value cannot be empty"),
Self::InvalidValue => formatter.write_str("invalid DKIM value"),
Self::UnknownLabel => formatter.write_str("unknown DKIM label"),
}
}
}
impl Error for DkimError {}
macro_rules! dkim_text_newtype {
($name:ident, $doc:literal) => {
#[doc = $doc]
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct $name(String);
impl $name {
pub fn new(value: impl AsRef<str>) -> Result<Self, DkimError> {
validate_dkim_text(value.as_ref()).map(|value| Self(value.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for $name {
type Err = DkimError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::new(value)
}
}
};
}
dkim_text_newtype!(DkimSelector, "DKIM selector metadata.");
dkim_text_newtype!(DkimDomain, "DKIM signing domain metadata.");
dkim_text_newtype!(DkimHeaderTag, "DKIM signed header tag metadata.");
dkim_text_newtype!(DkimBodyHash, "DKIM body hash metadata.");
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DkimAlgorithm {
#[default]
RsaSha256,
Ed25519Sha256,
RsaSha1,
}
impl DkimAlgorithm {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::RsaSha256 => "rsa-sha256",
Self::Ed25519Sha256 => "ed25519-sha256",
Self::RsaSha1 => "rsa-sha1",
}
}
}
impl fmt::Display for DkimAlgorithm {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for DkimAlgorithm {
type Err = DkimError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"rsa-sha256" => Ok(Self::RsaSha256),
"ed25519-sha256" => Ok(Self::Ed25519Sha256),
"rsa-sha1" => Ok(Self::RsaSha1),
"" => Err(DkimError::Empty),
_ => Err(DkimError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DkimCanonicalization {
Simple,
#[default]
Relaxed,
}
impl DkimCanonicalization {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Simple => "simple",
Self::Relaxed => "relaxed",
}
}
}
impl fmt::Display for DkimCanonicalization {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for DkimCanonicalization {
type Err = DkimError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"simple" => Ok(Self::Simple),
"relaxed" => Ok(Self::Relaxed),
"" => Err(DkimError::Empty),
_ => Err(DkimError::UnknownLabel),
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct DkimSignedHeaders {
headers: Vec<DkimHeaderTag>,
}
impl DkimSignedHeaders {
#[must_use]
pub const fn new() -> Self {
Self {
headers: Vec::new(),
}
}
pub fn from_names<I, S>(names: I) -> Result<Self, DkimError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut headers = Self::new();
for name in names {
headers.headers.push(DkimHeaderTag::new(name)?);
}
Ok(headers)
}
#[must_use]
pub fn as_slice(&self) -> &[DkimHeaderTag] {
&self.headers
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.headers.is_empty()
}
}
impl fmt::Display for DkimSignedHeaders {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, header) in self.headers.iter().enumerate() {
if index > 0 {
formatter.write_str(":")?;
}
write!(formatter, "{header}")?;
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DkimSignature {
selector: DkimSelector,
domain: DkimDomain,
algorithm: DkimAlgorithm,
canonicalization: DkimCanonicalization,
signed_headers: DkimSignedHeaders,
body_hash: Option<DkimBodyHash>,
}
impl DkimSignature {
pub fn new(selector: DkimSelector, domain: impl AsRef<str>) -> Result<Self, DkimError> {
Ok(Self {
selector,
domain: DkimDomain::new(domain)?,
algorithm: DkimAlgorithm::default(),
canonicalization: DkimCanonicalization::default(),
signed_headers: DkimSignedHeaders::new(),
body_hash: None,
})
}
#[must_use]
pub const fn with_algorithm(mut self, algorithm: DkimAlgorithm) -> Self {
self.algorithm = algorithm;
self
}
#[must_use]
pub const fn with_canonicalization(mut self, canonicalization: DkimCanonicalization) -> Self {
self.canonicalization = canonicalization;
self
}
#[must_use]
pub fn with_signed_headers(mut self, signed_headers: DkimSignedHeaders) -> Self {
self.signed_headers = signed_headers;
self
}
#[must_use]
pub fn with_body_hash(mut self, body_hash: DkimBodyHash) -> Self {
self.body_hash = Some(body_hash);
self
}
#[must_use]
pub const fn selector(&self) -> &DkimSelector {
&self.selector
}
#[must_use]
pub const fn domain(&self) -> &DkimDomain {
&self.domain
}
}
impl fmt::Display for DkimSignature {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"v=1; a={}; c={}; d={}; s={}",
self.algorithm, self.canonicalization, self.domain, self.selector
)?;
if !self.signed_headers.is_empty() {
write!(formatter, "; h={}", self.signed_headers)?;
}
if let Some(body_hash) = &self.body_hash {
write!(formatter, "; bh={body_hash}")?;
}
Ok(())
}
}
fn validate_dkim_text(value: &str) -> Result<&str, DkimError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(DkimError::Empty);
}
if trimmed.chars().any(|character| {
character.is_control() || character.is_whitespace() || matches!(character, ';' | '=')
}) {
return Err(DkimError::InvalidValue);
}
Ok(trimmed)
}
#[cfg(test)]
mod tests {
use super::{
DkimAlgorithm, DkimBodyHash, DkimCanonicalization, DkimError, DkimSelector, DkimSignature,
DkimSignedHeaders,
};
#[test]
fn builds_signature_metadata() -> Result<(), DkimError> {
let signature = DkimSignature::new(DkimSelector::new("mail")?, "example.com")?
.with_algorithm(DkimAlgorithm::RsaSha256)
.with_canonicalization(DkimCanonicalization::Relaxed)
.with_signed_headers(DkimSignedHeaders::from_names(["from", "subject"])?)
.with_body_hash(DkimBodyHash::new("abc123")?);
assert_eq!(signature.selector().as_str(), "mail");
assert!(signature.to_string().contains("h=from:subject"));
assert!(signature.to_string().contains("bh=abc123"));
Ok(())
}
#[test]
fn parses_labels() -> Result<(), DkimError> {
assert_eq!(
"ed25519-sha256".parse::<DkimAlgorithm>()?,
DkimAlgorithm::Ed25519Sha256
);
assert_eq!(
"simple".parse::<DkimCanonicalization>()?,
DkimCanonicalization::Simple
);
Ok(())
}
}