use base64::Engine;
use rsa::pkcs1::DecodeRsaPrivateKey;
use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::{DecodePrivateKey, EncodePrivateKey, EncodePublicKey, LineEnding};
use rsa::signature::{SignatureEncoding, Signer};
use rsa::{RsaPrivateKey, RsaPublicKey};
use sha2::{Digest, Sha256};
use std::sync::OnceLock;
use crate::state::{SentEmail, SesState};
const DEFAULT_KEY_BITS: usize = 2048;
const SIGNED_HEADERS: &[&str] = &["from", "to", "subject", "date", "message-id"];
fn cached_easy_dkim_keypair() -> &'static (String, String) {
static CACHE: OnceLock<(String, String)> = OnceLock::new();
CACHE.get_or_init(generate_easy_dkim_keypair_uncached)
}
fn generate_easy_dkim_keypair_uncached() -> (String, String) {
let mut rng = rand::thread_rng();
let priv_key = RsaPrivateKey::new(&mut rng, DEFAULT_KEY_BITS).expect("rsa keypair generation");
let pub_key = RsaPublicKey::from(&priv_key);
let priv_pem = priv_key
.to_pkcs8_pem(LineEnding::LF)
.expect("encode pkcs8 pem")
.to_string();
let pub_der = pub_key
.to_public_key_der()
.expect("encode spki der")
.as_bytes()
.to_vec();
let pub_b64 = base64::engine::general_purpose::STANDARD.encode(pub_der);
(priv_pem, pub_b64)
}
pub fn generate_easy_dkim_keypair() -> (String, String) {
cached_easy_dkim_keypair().clone()
}
pub fn sign_message(
private_key_pem: &str,
domain: &str,
selector: &str,
headers: &[(String, String)],
body: &str,
) -> Option<String> {
let priv_key = parse_private_key(private_key_pem)?;
let signing_key = SigningKey::<Sha256>::new(priv_key);
let canonical_body = canonicalize_body_relaxed(body);
let mut body_hasher = Sha256::new();
body_hasher.update(canonical_body.as_bytes());
let bh = base64::engine::general_purpose::STANDARD.encode(body_hasher.finalize());
let signed_headers: Vec<&(String, String)> = SIGNED_HEADERS
.iter()
.filter_map(|name| headers.iter().find(|(h, _)| h.eq_ignore_ascii_case(name)))
.collect();
let header_list = signed_headers
.iter()
.map(|(h, _)| h.to_lowercase())
.collect::<Vec<_>>()
.join(":");
let mut header_block = String::new();
for (h, v) in &signed_headers {
header_block.push_str(&canonicalize_header_relaxed(h, v));
}
let dkim_unsigned = format!(
"v=1; a=rsa-sha256; c=relaxed/relaxed; d={}; s={}; h={}; bh={}; b=",
domain, selector, header_list, bh
);
header_block.push_str(&canonicalize_dkim_signature_for_signing(&dkim_unsigned));
let signature = signing_key.sign(header_block.as_bytes());
let b = base64::engine::general_purpose::STANDARD.encode(signature.to_bytes());
Some(format!("{}{}", dkim_unsigned, b))
}
pub fn signature_for_sent_email(state: &SesState, sent: &SentEmail) -> Option<String> {
signed_headers_for_sent_email(state, sent).map(|(sig, _)| sig)
}
pub fn signed_headers_for_sent_email(
state: &SesState,
sent: &SentEmail,
) -> Option<(String, Vec<(String, String)>)> {
let address = address_part(&sent.from);
let domain = address.split('@').nth(1)?;
let identity = state
.identities
.get(&address)
.or_else(|| state.identities.get(domain))?;
if !identity.dkim_signing_enabled {
return None;
}
let private_key = identity.dkim_domain_signing_private_key.as_deref()?;
let selector = identity
.dkim_domain_signing_selector
.as_deref()
.unwrap_or("fakecloudses");
let body_text = sent
.raw_data
.clone()
.or_else(|| sent.html_body.clone())
.or_else(|| sent.text_body.clone())
.unwrap_or_default();
let to_header = sent.to.join(", ");
let date = sent
.timestamp
.format("%a, %d %b %Y %H:%M:%S +0000")
.to_string();
let signed_headers = vec![
("From".to_string(), sent.from.clone()),
("To".to_string(), to_header),
(
"Subject".to_string(),
sent.subject.clone().unwrap_or_default(),
),
("Date".to_string(), date),
(
"Message-ID".to_string(),
format!("<{}@fakecloud.local>", sent.message_id),
),
];
let signature = sign_message(private_key, domain, selector, &signed_headers, &body_text)?;
let mut all_headers = Vec::with_capacity(signed_headers.len() + 1);
all_headers.push(("DKIM-Signature".to_string(), signature.clone()));
all_headers.extend(signed_headers);
Some((signature, all_headers))
}
fn address_part(from: &str) -> String {
if let (Some(open), Some(close)) = (from.find('<'), from.rfind('>')) {
if open < close {
return from[open + 1..close].trim().to_lowercase();
}
}
from.trim().to_lowercase()
}
fn parse_private_key(pem: &str) -> Option<RsaPrivateKey> {
RsaPrivateKey::from_pkcs8_pem(pem)
.ok()
.or_else(|| RsaPrivateKey::from_pkcs1_pem(pem).ok())
}
fn canonicalize_body_relaxed(body: &str) -> String {
let normalized = body.replace("\r\n", "\n").replace('\r', "\n");
let mut lines: Vec<String> = normalized
.split('\n')
.map(|line| {
let mut out = String::with_capacity(line.len());
let mut prev_ws = false;
for c in line.chars() {
if c == ' ' || c == '\t' {
if !prev_ws {
out.push(' ');
}
prev_ws = true;
} else {
out.push(c);
prev_ws = false;
}
}
out.trim_end().to_string()
})
.collect();
while lines.last().is_some_and(|l| l.is_empty()) {
lines.pop();
}
if lines.is_empty() {
String::new()
} else {
let mut out = lines.join("\r\n");
out.push_str("\r\n");
out
}
}
fn canonicalize_header_relaxed(name: &str, value: &str) -> String {
let canonical_value = canonicalize_header_value_relaxed(value);
format!("{}:{}\r\n", name.to_lowercase(), canonical_value)
}
fn canonicalize_header_value_relaxed(value: &str) -> String {
let unfolded = value.replace("\r\n", "\n");
let mut out = String::with_capacity(unfolded.len());
let mut prev_ws = false;
for c in unfolded.chars() {
if c == ' ' || c == '\t' || c == '\n' {
if !prev_ws {
out.push(' ');
}
prev_ws = true;
} else {
out.push(c);
prev_ws = false;
}
}
out.trim().to_string()
}
fn canonicalize_dkim_signature_for_signing(unsigned_value: &str) -> String {
let canonical_value = canonicalize_header_value_relaxed(unsigned_value);
format!("dkim-signature:{}", canonical_value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_returns_parseable_pem_and_b64_pubkey() {
let (pem, pub_b64) = generate_easy_dkim_keypair();
assert!(pem.starts_with("-----BEGIN PRIVATE KEY-----"));
assert!(parse_private_key(&pem).is_some());
let bytes = base64::engine::general_purpose::STANDARD
.decode(pub_b64.as_bytes())
.unwrap();
assert!(!bytes.is_empty());
}
#[test]
fn sign_message_emits_dkim_signature_header_value() {
let (pem, _) = generate_easy_dkim_keypair();
let headers = vec![
("From".to_string(), "alice@example.com".to_string()),
("To".to_string(), "bob@example.com".to_string()),
("Subject".to_string(), "hi".to_string()),
(
"Date".to_string(),
"Mon, 01 Jan 2024 00:00:00 +0000".to_string(),
),
("Message-ID".to_string(), "<x@example.com>".to_string()),
];
let sig = sign_message(&pem, "example.com", "sel1", &headers, "hello world").unwrap();
assert!(sig.contains("v=1"));
assert!(sig.contains("a=rsa-sha256"));
assert!(sig.contains("c=relaxed/relaxed"));
assert!(sig.contains("d=example.com"));
assert!(sig.contains("s=sel1"));
assert!(sig.contains("h=from:to:subject:date:message-id"));
assert!(sig.contains("bh="));
assert!(sig.contains("b="));
}
#[test]
fn sign_returns_none_for_garbage_pem() {
let headers = vec![("From".to_string(), "x".to_string())];
assert!(sign_message("not a key", "d", "s", &headers, "body").is_none());
}
#[test]
fn canonicalize_body_relaxed_normalizes_whitespace_and_endings() {
assert_eq!(canonicalize_body_relaxed(""), "");
assert_eq!(canonicalize_body_relaxed("a"), "a\r\n");
assert_eq!(canonicalize_body_relaxed("a\nb\n\n\n"), "a\r\nb\r\n");
assert_eq!(canonicalize_body_relaxed("a b\tc"), "a b c\r\n");
assert_eq!(canonicalize_body_relaxed("a \nb"), "a\r\nb\r\n");
}
#[test]
fn canonicalize_header_relaxed_lowercases_and_collapses() {
assert_eq!(
canonicalize_header_relaxed("From", " Alice <a@b.com> "),
"from:Alice <a@b.com>\r\n"
);
assert_eq!(
canonicalize_header_relaxed("Subject", " hello world "),
"subject:hello world\r\n"
);
}
#[test]
fn signed_signature_verifies_against_generated_public_key() {
use rsa::pkcs1v15::{Signature, VerifyingKey};
use rsa::pkcs8::DecodePublicKey;
use rsa::signature::Verifier;
let (pem, pub_b64) = generate_easy_dkim_keypair();
let headers = vec![
("From".to_string(), "alice@example.com".to_string()),
("To".to_string(), "bob@example.com".to_string()),
("Subject".to_string(), "hello".to_string()),
(
"Date".to_string(),
"Mon, 01 Jan 2024 00:00:00 +0000".to_string(),
),
("Message-ID".to_string(), "<x@example.com>".to_string()),
];
let body = "hello world\r\n";
let sig_value = sign_message(&pem, "example.com", "sel1", &headers, body).unwrap();
let mut block = String::new();
for (h, v) in &headers {
block.push_str(&canonicalize_header_relaxed(h, v));
}
let b_idx = sig_value.rfind("b=").unwrap();
let unsigned = &sig_value[..b_idx + 2];
block.push_str(&canonicalize_dkim_signature_for_signing(unsigned));
let raw_b = sig_value[b_idx + 2..].to_string();
let sig_bytes = base64::engine::general_purpose::STANDARD
.decode(raw_b.as_bytes())
.unwrap();
let signature = Signature::try_from(sig_bytes.as_slice()).unwrap();
let pub_der = base64::engine::general_purpose::STANDARD
.decode(pub_b64.as_bytes())
.unwrap();
let pub_key = RsaPublicKey::from_public_key_der(&pub_der).unwrap();
let verifying_key: VerifyingKey<Sha256> = VerifyingKey::new(pub_key);
verifying_key.verify(block.as_bytes(), &signature).unwrap();
}
}