use std::{ borrow::Cow, error::Error as StdError, fmt::{self, Display}, time::SystemTime, }; use ed25519_dalek::Signer; use rsa::{pkcs1::DecodeRsaPrivateKey, pkcs1v15::Pkcs1v15Sign, RsaPrivateKey}; use sha2::{Digest, Sha256}; use crate::message::{ header::{HeaderName, HeaderValue}, Headers, Message, }; /// Describe Dkim Canonicalization to apply to either body or headers #[derive(Copy, Clone, Debug)] pub enum DkimCanonicalizationType { Simple, Relaxed, } impl Display for DkimCanonicalizationType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { DkimCanonicalizationType::Simple => "simple", DkimCanonicalizationType::Relaxed => "relaxed", }) } } /// Describe Canonicalization to be applied before signing #[derive(Copy, Clone, Debug)] pub struct DkimCanonicalization { pub header: DkimCanonicalizationType, pub body: DkimCanonicalizationType, } impl Default for DkimCanonicalization { fn default() -> Self { DkimCanonicalization { header: DkimCanonicalizationType::Simple, body: DkimCanonicalizationType::Relaxed, } } } /// Format canonicalization to be shown in Dkim header impl Display for DkimCanonicalization { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}/{}", self.header, self.body) } } /// Describe the algorithm used for signing the message #[derive(Copy, Clone, Debug)] pub enum DkimSigningAlgorithm { Rsa, Ed25519, } impl Display for DkimSigningAlgorithm { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { DkimSigningAlgorithm::Rsa => "rsa", DkimSigningAlgorithm::Ed25519 => "ed25519", }) } } /// Describe DkimSigning key error #[derive(Debug)] pub struct DkimSigningKeyError(InnerDkimSigningKeyError); #[derive(Debug)] enum InnerDkimSigningKeyError { Base64(base64::DecodeError), Rsa(rsa::pkcs1::Error), Ed25519(ed25519_dalek::ed25519::Error), } impl Display for DkimSigningKeyError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match &self.0 { InnerDkimSigningKeyError::Base64(_err) => "base64 decode error", InnerDkimSigningKeyError::Rsa(_err) => "rsa decode error", InnerDkimSigningKeyError::Ed25519(_err) => "ed25519 decode error", }) } } impl StdError for DkimSigningKeyError { fn source(&self) -> Option<&(dyn StdError + 'static)> { Some(match &self.0 { InnerDkimSigningKeyError::Base64(err) => err, InnerDkimSigningKeyError::Rsa(err) => err, InnerDkimSigningKeyError::Ed25519(err) => err, }) } } /// Describe a signing key to be carried by DkimConfig struct #[derive(Debug)] pub struct DkimSigningKey(InnerDkimSigningKey); #[derive(Debug)] enum InnerDkimSigningKey { Rsa(RsaPrivateKey), Ed25519(ed25519_dalek::SigningKey), } impl DkimSigningKey { pub fn new( private_key: &str, algorithm: DkimSigningAlgorithm, ) -> Result { Ok(Self(match algorithm { DkimSigningAlgorithm::Rsa => InnerDkimSigningKey::Rsa( RsaPrivateKey::from_pkcs1_pem(private_key) .map_err(|err| DkimSigningKeyError(InnerDkimSigningKeyError::Rsa(err)))?, ), DkimSigningAlgorithm::Ed25519 => { InnerDkimSigningKey::Ed25519(ed25519_dalek::SigningKey::from_bytes( &crate::base64::decode(private_key) .map_err(|err| DkimSigningKeyError(InnerDkimSigningKeyError::Base64(err)))? .try_into() .map_err(|_| { DkimSigningKeyError(InnerDkimSigningKeyError::Ed25519( ed25519_dalek::ed25519::Error::new(), )) })?, )) } })) } fn get_signing_algorithm(&self) -> DkimSigningAlgorithm { match self.0 { InnerDkimSigningKey::Rsa(_) => DkimSigningAlgorithm::Rsa, InnerDkimSigningKey::Ed25519(_) => DkimSigningAlgorithm::Ed25519, } } } /// A struct to describe Dkim configuration applied when signing a message #[derive(Debug)] pub struct DkimConfig { /// The name of the key published in DNS selector: String, /// The domain for which we sign the message domain: String, /// The private key in PKCS1 string format private_key: DkimSigningKey, /// A list of header names to be included in the signature. Signing of more than one /// header with the same name is not supported headers: Vec, /// The signing algorithm to be used when signing canonicalization: DkimCanonicalization, } impl DkimConfig { /// Create a default signature configuration with a set of headers and "simple/relaxed" /// canonicalization pub fn default_config( selector: String, domain: String, private_key: DkimSigningKey, ) -> DkimConfig { DkimConfig { selector, domain, private_key, headers: vec![ HeaderName::new_from_ascii_str("From"), HeaderName::new_from_ascii_str("Subject"), HeaderName::new_from_ascii_str("To"), HeaderName::new_from_ascii_str("Date"), ], canonicalization: DkimCanonicalization { header: DkimCanonicalizationType::Simple, body: DkimCanonicalizationType::Relaxed, }, } } /// Create a DkimConfig pub fn new( selector: String, domain: String, private_key: DkimSigningKey, headers: Vec, canonicalization: DkimCanonicalization, ) -> DkimConfig { DkimConfig { selector, domain, private_key, headers, canonicalization, } } } /// Create a Headers struct with a Dkim-Signature Header created from given parameters fn dkim_header_format( config: &DkimConfig, timestamp: u64, headers_list: &str, body_hash: &str, signature: &str, ) -> Headers { let mut headers = Headers::new(); let header_name = dkim_canonicalize_header_tag("DKIM-Signature", config.canonicalization.header); let header_name = HeaderName::new_from_ascii(header_name.into()).unwrap(); headers.insert_raw(HeaderValue::new(header_name, format!("v=1; a={signing_algorithm}-sha256; d={domain}; s={selector}; c={canon}; q=dns/txt; t={timestamp}; h={headers_list}; bh={body_hash}; b={signature}",domain=config.domain, selector=config.selector,canon=config.canonicalization,timestamp=timestamp,headers_list=headers_list,body_hash=body_hash,signature=signature,signing_algorithm=config.private_key.get_signing_algorithm()))); headers } /// Canonicalize the body of an email fn dkim_canonicalize_body( mut body: &[u8], canonicalization: DkimCanonicalizationType, ) -> Cow<'_, [u8]> { match canonicalization { DkimCanonicalizationType::Simple => { // Remove empty lines at end while body.ends_with(b"\r\n\r\n") { body = &body[..body.len() - 2]; } Cow::Borrowed(body) } DkimCanonicalizationType::Relaxed => { let mut out = Vec::with_capacity(body.len()); loop { match body { [b' ' | b'\t', b'\r', b'\n', ..] => {} [b' ' | b'\t', b' ' | b'\t', ..] => {} [b' ' | b'\t', ..] => out.push(b' '), [c, ..] => out.push(*c), [] => break, } body = &body[1..]; } // Remove empty lines at end while out.ends_with(b"\r\n\r\n") { out.truncate(out.len() - 2); } Cow::Owned(out) } } } fn dkim_canonicalize_headers_relaxed(headers: &str) -> String { let mut r = String::with_capacity(headers.len()); fn skip_whitespace(h: &str) -> &str { match h.as_bytes().first() { Some(b' ' | b'\t') => skip_whitespace(&h[1..]), _ => h, } } fn name(h: &str, out: &mut String) { if let Some(name_end) = h.bytes().position(|c| c == b':') { let (name, rest) = h.split_at(name_end + 1); *out += name; // Space after header colon is stripped. value(skip_whitespace(rest), out); } else { // This should never happen. *out += h; } } fn value(h: &str, out: &mut String) { match h.as_bytes() { // Continuation lines. [b'\r', b'\n', b' ' | b'\t', ..] => { out.push(' '); value(skip_whitespace(&h[2..]), out); } // End of header. [b'\r', b'\n', ..] => { *out += "\r\n"; name(&h[2..], out) } // Sequential whitespace. [b' ' | b'\t', b' ' | b'\t' | b'\r', ..] => value(&h[1..], out), // All whitespace becomes spaces. [b'\t', ..] => { out.push(' '); value(&h[1..], out) } [_, ..] => { let mut chars = h.chars(); out.push(chars.next().unwrap()); value(chars.as_str(), out) } [] => {} } } name(headers, &mut r); r } /// Canonicalize header tag fn dkim_canonicalize_header_tag( name: &str, canonicalization: DkimCanonicalizationType, ) -> Cow<'_, str> { match canonicalization { DkimCanonicalizationType::Simple => Cow::Borrowed(name), DkimCanonicalizationType::Relaxed => Cow::Owned(name.to_lowercase()), } } /// Canonicalize signed headers passed as headers_list among mail_headers using canonicalization fn dkim_canonicalize_headers<'a>( headers_list: impl IntoIterator, mail_headers: &Headers, canonicalization: DkimCanonicalizationType, ) -> String { let mut covered_headers = Headers::new(); for name in headers_list { if let Some(h) = mail_headers.find_header(name) { let name = dkim_canonicalize_header_tag(name, canonicalization); covered_headers.insert_raw(HeaderValue::dangerous_new_pre_encoded( HeaderName::new_from_ascii(name.into()).unwrap(), h.get_raw().into(), h.get_encoded().into(), )); } } let serialized = covered_headers.to_string(); match canonicalization { DkimCanonicalizationType::Simple => serialized, DkimCanonicalizationType::Relaxed => dkim_canonicalize_headers_relaxed(&serialized), } } /// Sign with Dkim a message by adding Dkim-Signature header created with configuration expressed by /// dkim_config pub fn dkim_sign(message: &mut Message, dkim_config: &DkimConfig) { dkim_sign_fixed_time(message, dkim_config, SystemTime::now()) } fn dkim_sign_fixed_time(message: &mut Message, dkim_config: &DkimConfig, timestamp: SystemTime) { let timestamp = timestamp .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_secs(); let headers = message.headers(); let body_hash = Sha256::digest(dkim_canonicalize_body( &message.body_raw(), dkim_config.canonicalization.body, )); let bh = crate::base64::encode(body_hash); let mut signed_headers_list = dkim_config .headers .iter() .fold(String::new(), |mut list, header| { if !list.is_empty() { list.push(':'); } list.push_str(header); list }); if let DkimCanonicalizationType::Relaxed = dkim_config.canonicalization.header { signed_headers_list.make_ascii_lowercase(); } let dkim_header = dkim_header_format(dkim_config, timestamp, &signed_headers_list, &bh, ""); let signed_headers = dkim_canonicalize_headers( dkim_config.headers.iter().map(|h| h.as_ref()), headers, dkim_config.canonicalization.header, ); let canonicalized_dkim_header = dkim_canonicalize_headers( ["DKIM-Signature"], &dkim_header, dkim_config.canonicalization.header, ); let mut hashed_headers = Sha256::new(); hashed_headers.update(signed_headers.as_bytes()); hashed_headers.update(canonicalized_dkim_header.trim_end().as_bytes()); let hashed_headers = hashed_headers.finalize(); let signature = match &dkim_config.private_key.0 { InnerDkimSigningKey::Rsa(private_key) => crate::base64::encode( private_key .sign(Pkcs1v15Sign::new::(), &hashed_headers) .unwrap(), ), InnerDkimSigningKey::Ed25519(private_key) => { crate::base64::encode(private_key.sign(&hashed_headers).to_bytes()) } }; let dkim_header = dkim_header_format( dkim_config, timestamp, &signed_headers_list, &bh, &signature, ); message.headers.insert_raw(HeaderValue::new( HeaderName::new_from_ascii_str("DKIM-Signature"), dkim_header.get_raw("DKIM-Signature").unwrap().to_owned(), )); } #[cfg(test)] mod test { use pretty_assertions::assert_eq; use super::{ super::{ header::{HeaderName, HeaderValue}, Header, Message, }, dkim_canonicalize_body, dkim_canonicalize_headers, dkim_sign_fixed_time, DkimCanonicalization, DkimCanonicalizationType, DkimConfig, DkimSigningAlgorithm, DkimSigningKey, }; use crate::StdError; const KEY_RSA: &str = "-----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAwOsW7UFcWn1ch3UM8Mll5qZH5hVHKJQ8Z0tUlebUECq0vjw6 VcsIucZ/B70VpCN63whyi7oApdCIS1o0zad7f0UaW/BfxXADqdcFL36uMaG0RHer uSASjQGnsl9Kozt/dXiDZX5ngjr/arLJhNZSNR4/9VSwqbE2OPXaSaQ9BsqneD0P 8dCVSfkkDZCcfC2864z7hvC01lFzWQKF36ZAoGBERHScHtFMAzUOgGuqqPiP5khw DQB3Ffccf+BsWLU2OOteshUwTGjpoangbPCYj6kckwNm440lQwuqTinpC92yyIE5 Ol8psNMW49DLowAeZb6JrjLhD+wY9bghTaOkcwIDAQABAoIBAHTZ8LkkrdvhsvoZ XA088AwVC9fBa6iYoT2v0zw45JomQ/Q2Zt8wa8ibAradQU56byJI65jWwS2ucd+y c+ldWOBt6tllb50XjCCDrRBnmvtVBuux0MIBOztNlVXlgj/8+ecdZ/lB51Bqi+sF ACsF5iVmfTcMZTVjsYQu5llUseI6Lwgqpx6ktaXD2PVsVo9Gf01ssZ4GCy69wB/3 20CsOz4LEpSYkq1oE98lMMGCfD7py3L9kWHYNNisam78GM+1ynRxRGwEDUbz6pxs fGPIAwHLaZsOmibPkBB0PJTW742w86qQ8KAqC6ZbRYOF19rSMj3oTfRnPMHn9Uu5 N8eQcoECgYEA97SMUrz2hqII5i8igKylO9kV8pjcIWKI0rdt8MKj4FXTNYjjO9I+ 41ONOjhUOpFci/G3YRKi8UiwbKxIRTvIxNMh2xj6Ws3iO9gQHK1j8xTWxJdjEBEz EuZI59Mi5H7fxSL1W+n8nS8JVsaH93rvQErngqTUAsihAzjxHWdFwm0CgYEAx2Dh claESJP2cOKgYp+SUNwc26qMaqnl1f37Yn+AflrQOfgQqJe5TRbicEC+nFlm6XUt 3st1Nj29H0uOMmMZDmDCO+cOs5Qv5A9pG6jSC6wM+2KNHQDtrxlakBFygePEPVVy GXaY9DRa9Q4/4ataxDR2/VvIAWfEEtMTJIBDtl8CgYAIXEuwLziS6r0qJ8UeWrVp A7a97XLgnZbIpfBMBAXL+JmcYPZqenos6hEGOgh9wZJCFvJ9kEd3pWBvCpGV5KKu IgIuhvVMQ06zfmNs1F1fQwDMud9aF3qF1Mf5KyMuWynqWXe2lns0QvYpu6GzNK8G mICf5DhTr7nfhfh9aZLtMQKBgCxKsmqzG5n//MxhHB4sstVxwJtwDNeZPKzISnM8 PfBT/lQSbqj1Y73japRjXbTgC4Ore3A2JKjTGFN+dm1tJGDUT/H8x4BPWEBCyCfT 3i2noA6sewrJbQPsDvlYVubSEYNKmxlbBmmhw98StlBMv9I8kX6BSDI/uggwid0e /WvjAoGBAKpZ0UOKQyrl9reBiUfrpRCvIMakBMd79kNiH+5y0Soq/wCAnAuABayj XEIBhFv+HxeLEnT7YV+Zzqp5L9kKw/EU4ik3JX/XsEihdSxEuGX00ZYOw05FEfpW cJ5Ku0OTwRtSMaseRPX+T4EfG1Caa/eunPPN4rh+CSup2BVVarOT -----END RSA PRIVATE KEY-----"; #[derive(Clone)] struct TestHeader(String); impl Header for TestHeader { fn name() -> HeaderName { HeaderName::new_from_ascii_str("Test") } fn parse(s: &str) -> Result> { Ok(Self(s.into())) } fn display(&self) -> HeaderValue { HeaderValue::new(Self::name(), self.0.clone()) } } fn test_message() -> Message { Message::builder() .from("Test O'Leary ".parse().unwrap()) .to("Test2 ".parse().unwrap()) .date(std::time::UNIX_EPOCH) .header(TestHeader("test test very very long with spaces and extra spaces \twill be folded to several lines ".to_owned())) .subject("Test with utf-8 ë") .body("test\r\n\r\ntest \ttest\r\n\r\n\r\n".to_owned()).unwrap() } #[test] fn test_headers_simple_canonicalize() { let message = test_message(); dbg!(message.headers.to_string()); assert_eq!(dkim_canonicalize_headers(["From", "Test"], &message.headers, DkimCanonicalizationType::Simple), "From: =?utf-8?b?VGVzdCBPJ0xlYXJ5?= \r\nTest: test test very very long with spaces and extra spaces \twill be\r\n folded to several lines \r\n") } #[test] fn test_headers_relaxed_canonicalize() { let message = test_message(); dbg!(message.headers.to_string()); assert_eq!(dkim_canonicalize_headers(["From", "Test"], &message.headers, DkimCanonicalizationType::Relaxed),"from:=?utf-8?b?VGVzdCBPJ0xlYXJ5?= \r\ntest:test test very very long with spaces and extra spaces will be folded to several lines\r\n") } #[test] fn test_body_simple_canonicalize() { let body = b" C \r\nD \t E\r\n\r\n\r\n"; assert_eq!( dkim_canonicalize_body(body, DkimCanonicalizationType::Simple).into_owned(), b" C \r\nD \t E\r\n" ); } #[test] fn test_body_relaxed_canonicalize() { let body = b" C \r\nD \t E\r\n\tF\r\n\t\r\n\r\n\r\n"; assert_eq!( dkim_canonicalize_body(body, DkimCanonicalizationType::Relaxed).into_owned(), b" C\r\nD E\r\n F\r\n" ); } #[test] fn test_signature_rsa_simple() { let mut message = test_message(); let signing_key = DkimSigningKey::new(KEY_RSA, DkimSigningAlgorithm::Rsa).unwrap(); dkim_sign_fixed_time( &mut message, &DkimConfig::new( "dkimtest".to_owned(), "example.org".to_owned(), signing_key, vec![ HeaderName::new_from_ascii_str("Date"), HeaderName::new_from_ascii_str("From"), HeaderName::new_from_ascii_str("Subject"), HeaderName::new_from_ascii_str("To"), ], DkimCanonicalization { header: DkimCanonicalizationType::Simple, body: DkimCanonicalizationType::Simple, }, ), std::time::UNIX_EPOCH, ); let signed = message.formatted(); let signed = std::str::from_utf8(&signed).unwrap(); assert_eq!( signed, std::concat!( "From: =?utf-8?b?VGVzdCBPJ0xlYXJ5?= \r\n", "To: Test2 \r\n", "Date: Thu, 01 Jan 1970 00:00:00 +0000\r\n", "Test: test test very very long with spaces and extra spaces \twill be\r\n", " folded to several lines \r\n", "Subject: Test with utf-8 =?utf-8?b?w6s=?=\r\n", "Content-Transfer-Encoding: 7bit\r\n", "DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=dkimtest;\r\n", " c=simple/simple; q=dns/txt; t=0; h=Date:From:Subject:To;\r\n", " bh=f3Zksdcjqa/xRBwdyFzIXWCcgP7XTgxjCgYsXOMKQl4=;\r\n", " b=NhoIMMAALoSgu5lKAR0+MUQunOWnU7wpF9ORUFtpxq9sGZDo9AX43AMhFemyM5W204jpFwMU6pm7AMR1nOYBdSYye4yUALtvT2nqbJBwSh7JeYu+z22t1RFKp7qQR1il8aSrkbZuNMFHYuSEwW76QtKwcNqP4bQOzS9CzgQp0ABu8qwYPBr/EypykPTfqjtyN+ywrfdqjjGOzTpRGolH0hc3CrAETNjjHbNBgKgucXmXTN7hMRdzqWjeFPxizXwouwNAavFClPG0l33gXVArFWn+CkgA84G/s4zuJiF7QPZR87Pu4pw/vIlSXxH4a42W3tT19v9iBTH7X7ldYegtmQ==\r\n", "\r\n", "test\r\n", "\r\n", "test \ttest\r\n", "\r\n", "\r\n", ) ); } #[test] fn test_signature_rsa_relaxed() { let mut message = test_message(); let signing_key = DkimSigningKey::new(KEY_RSA, DkimSigningAlgorithm::Rsa).unwrap(); dkim_sign_fixed_time( &mut message, &DkimConfig::new( "dkimtest".to_owned(), "example.org".to_owned(), signing_key, vec![ HeaderName::new_from_ascii_str("Date"), HeaderName::new_from_ascii_str("From"), HeaderName::new_from_ascii_str("Subject"), HeaderName::new_from_ascii_str("To"), ], DkimCanonicalization { header: DkimCanonicalizationType::Relaxed, body: DkimCanonicalizationType::Relaxed, }, ), std::time::UNIX_EPOCH, ); let signed = message.formatted(); let signed = std::str::from_utf8(&signed).unwrap(); println!("{signed}"); assert_eq!( signed, std::concat!( "From: =?utf-8?b?VGVzdCBPJ0xlYXJ5?= \r\n", "To: Test2 \r\n", "Date: Thu, 01 Jan 1970 00:00:00 +0000\r\n", "Test: test test very very long with spaces and extra spaces \twill be\r\n", " folded to several lines \r\n","Subject: Test with utf-8 =?utf-8?b?w6s=?=\r\n", "Content-Transfer-Encoding: 7bit\r\n", "DKIM-Signature: v=1; a=rsa-sha256; d=example.org; s=dkimtest;\r\n", " c=relaxed/relaxed; q=dns/txt; t=0; h=date:from:subject:to;\r\n", " bh=qN8je6qJgWFGSnN2MycC/XKPbN6BOrMJyAX2h4m19Ss=;\r\n", " b=YaVfmH8dbGEywoLJ4uhbvYqDyQG1UGKFH3PE7zXGgk+YFxUgkwWjoA3aQupDNQtfTjfUsNe0dnrjyZP+ylnESpZBpbCIf5/n3FEh6j3RQthqNbQblcfH/U8mazTuRbVjYBbTZQDaQCMPTz+8D+ZQfXo2oq6dGzTuGvmuYft0CVsq/BIp/EkhZHqiphDeVJSHD4iKW8+L2XwEWThoY92xOYc1G0TtBwz2UJgtiHX2YulH/kRBHeK3dKn9RTNVL3VZ+9ZrnFwIhET9TPGtU2I+q0EMSWF9H9bTrASMgW/U+E0VM2btqJlrTU6rQ7wlQeHdwecLnzXcyhCUInF1+veMNw==\r\n", "\r\n", "test\r\n", "\r\n", "test \ttest\r\n", "\r\n", "\r\n", ) ); } }