use-dkim 0.1.0

DKIM signature metadata primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

/// Error returned when DKIM metadata is invalid.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DkimError {
    /// The supplied value was empty.
    Empty,
    /// The supplied value was invalid for this primitive.
    InvalidValue,
    /// The supplied label was not recognized.
    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 {
            /// Creates validated DKIM metadata text.
            pub fn new(value: impl AsRef<str>) -> Result<Self, DkimError> {
                validate_dkim_text(value.as_ref()).map(|value| Self(value.to_owned()))
            }

            /// Returns the stored text.
            #[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.");

/// DKIM signature algorithm label.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DkimAlgorithm {
    /// `rsa-sha256`.
    #[default]
    RsaSha256,
    /// `ed25519-sha256`.
    Ed25519Sha256,
    /// `rsa-sha1`, retained as vocabulary metadata for older signatures.
    RsaSha1,
}

impl DkimAlgorithm {
    /// Returns the algorithm label.
    #[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),
        }
    }
}

/// DKIM canonicalization mode.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DkimCanonicalization {
    /// `simple` canonicalization.
    Simple,
    /// `relaxed` canonicalization.
    #[default]
    Relaxed,
}

impl DkimCanonicalization {
    /// Returns the canonicalization label.
    #[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),
        }
    }
}

/// Ordered list of DKIM signed header names.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct DkimSignedHeaders {
    headers: Vec<DkimHeaderTag>,
}

impl DkimSignedHeaders {
    /// Creates an empty signed-header list.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            headers: Vec::new(),
        }
    }

    /// Creates a signed-header list from header names.
    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)
    }

    /// Returns stored header tags.
    #[must_use]
    pub fn as_slice(&self) -> &[DkimHeaderTag] {
        &self.headers
    }

    /// Returns true when no signed headers are stored.
    #[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(())
    }
}

/// DKIM signature metadata. This type does not sign or verify data.
#[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 {
    /// Creates DKIM signature metadata for a selector and domain.
    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,
        })
    }

    /// Sets the algorithm.
    #[must_use]
    pub const fn with_algorithm(mut self, algorithm: DkimAlgorithm) -> Self {
        self.algorithm = algorithm;
        self
    }

    /// Sets the canonicalization mode.
    #[must_use]
    pub const fn with_canonicalization(mut self, canonicalization: DkimCanonicalization) -> Self {
        self.canonicalization = canonicalization;
        self
    }

    /// Sets the signed header list.
    #[must_use]
    pub fn with_signed_headers(mut self, signed_headers: DkimSignedHeaders) -> Self {
        self.signed_headers = signed_headers;
        self
    }

    /// Sets the body hash metadata.
    #[must_use]
    pub fn with_body_hash(mut self, body_hash: DkimBodyHash) -> Self {
        self.body_hash = Some(body_hash);
        self
    }

    /// Returns the selector.
    #[must_use]
    pub const fn selector(&self) -> &DkimSelector {
        &self.selector
    }

    /// Returns the signing domain.
    #[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(())
    }
}