use base64::Engine as _;
use sha2::{Digest, Sha256};
use crate::canon::{canonicalize_body, canonicalize_header};
use crate::crypto::{CryptoSigningKey, RsaSigningKey, sign_signature};
use crate::error::DkimError;
use crate::header::{Algorithm, Canon};
use crate::headers::{body_offset_minus_blank, find_body_offset};
#[allow(
clippy::large_enum_variant,
reason = "ed25519_dalek::SigningKey holds a cached scalar (~64-96 bytes); \
RsaSigningKey is an Arc + usize (~16 bytes). Boxing the larger \
variant would mean a heap alloc per DkimSigningKey::Ed25519 \
construction. The enum is created once per outbound mail at \
most and not stored in large collections, so the size penalty \
is benign."
)]
pub enum DkimSigningKey {
Rsa(RsaSigningKey),
Ed25519(ed25519_dalek::SigningKey),
}
impl DkimSigningKey {
pub fn algorithm(&self) -> Algorithm {
match self {
Self::Rsa(_) => Algorithm::RsaSha256,
Self::Ed25519(_) => Algorithm::Ed25519Sha256,
}
}
}
#[derive(Debug, Clone)]
pub struct SignOpts {
pub domain: String,
pub selector: String,
pub signed_headers: Vec<String>,
pub canon_header: Canon,
pub canon_body: Canon,
pub identity: Option<String>,
pub timestamp: Option<u64>,
pub expiration: Option<u64>,
pub body_length: Option<u64>,
}
impl SignOpts {
pub fn new(domain: impl Into<String>, selector: impl Into<String>) -> Self {
Self {
domain: domain.into(),
selector: selector.into(),
signed_headers: Vec::new(),
canon_header: Canon::Relaxed,
canon_body: Canon::Relaxed,
identity: None,
timestamp: None,
expiration: None,
body_length: None,
}
}
pub fn add_signed_header(mut self, name: impl Into<String>) -> Self {
self.signed_headers.push(name.into());
self
}
pub fn signed_headers<I: IntoIterator<Item = S>, S: Into<String>>(
mut self,
headers: I,
) -> Self {
self.signed_headers = headers.into_iter().map(Into::into).collect();
self
}
pub fn timestamp(mut self, t: u64) -> Self {
self.timestamp = Some(t);
self
}
pub fn expiration(mut self, x: u64) -> Self {
self.expiration = Some(x);
self
}
}
pub fn sign(
raw_message: &[u8],
key: &DkimSigningKey,
opts: &SignOpts,
) -> Result<String, DkimError> {
let body_offset = find_body_offset(raw_message).ok_or(DkimError::MissingHeader)?;
let body = &raw_message[body_offset..];
let canon_body_bytes = canonicalize_body(body, opts.canon_body, opts.body_length);
let mut body_hasher = Sha256::new();
body_hasher.update(&canon_body_bytes);
let bh = base64::engine::general_purpose::STANDARD.encode(body_hasher.finalize());
let alg_str = match key.algorithm() {
Algorithm::RsaSha256 => "rsa-sha256",
Algorithm::Ed25519Sha256 => "ed25519-sha256",
};
let canon_str = canon_pair(opts.canon_header, opts.canon_body);
let h_list = opts.signed_headers.join(":");
let mut tags = format!(
"v=1; a={alg_str}; c={canon_str}; d={d}; s={s}; h={h}; bh={bh}",
d = opts.domain,
s = opts.selector,
h = h_list,
);
if let Some(i) = &opts.identity {
tags = format!("{tags}; i={i}");
}
if let Some(t) = opts.timestamp {
tags = format!("{tags}; t={t}");
}
if let Some(x) = opts.expiration {
tags = format!("{tags}; x={x}");
}
if let Some(l) = opts.body_length {
tags = format!("{tags}; l={l}");
}
tags = format!("{tags}; b=");
let headers_region = &raw_message[..body_offset_minus_blank(body_offset, raw_message)];
let mut signed_block = Vec::with_capacity(512);
let collected = crate::headers::collect_signed_headers(headers_region, &opts.signed_headers);
for (name, value_opt) in &collected {
let Some(value) = value_opt else { continue };
let canon_name = name.to_ascii_lowercase();
signed_block.extend_from_slice(&canonicalize_header(
&canon_name,
value,
opts.canon_header,
));
}
let signed_value = format!(" {tags}");
let canon_dkim = canonicalize_header("DKIM-Signature", &signed_value, opts.canon_header);
let canon_dkim_trimmed = if canon_dkim.ends_with(b"\r\n") {
&canon_dkim[..canon_dkim.len() - 2]
} else {
&canon_dkim
};
signed_block.extend_from_slice(canon_dkim_trimmed);
let crypto_key = match key {
DkimSigningKey::Rsa(k) => CryptoSigningKey::Rsa(k),
DkimSigningKey::Ed25519(k) => CryptoSigningKey::Ed25519(k),
};
let sig = sign_signature(&crypto_key, &signed_block)?;
let sig_b64 = base64::engine::general_purpose::STANDARD.encode(&sig);
Ok(format!("DKIM-Signature: {tags}{sig_b64}\r\n"))
}
fn canon_pair(h: Canon, b: Canon) -> &'static str {
match (h, b) {
(Canon::Simple, Canon::Simple) => "simple/simple",
(Canon::Simple, Canon::Relaxed) => "simple/relaxed",
(Canon::Relaxed, Canon::Simple) => "relaxed/simple",
(Canon::Relaxed, Canon::Relaxed) => "relaxed/relaxed",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canon_pair_emits_spec_strings() {
assert_eq!(canon_pair(Canon::Simple, Canon::Simple), "simple/simple");
assert_eq!(canon_pair(Canon::Simple, Canon::Relaxed), "simple/relaxed");
assert_eq!(canon_pair(Canon::Relaxed, Canon::Simple), "relaxed/simple");
assert_eq!(
canon_pair(Canon::Relaxed, Canon::Relaxed),
"relaxed/relaxed"
);
}
#[test]
fn signing_key_algorithm_matches_variant() {
let secret = [42u8; 32];
let sk = ed25519_dalek::SigningKey::from_bytes(&secret);
let key = DkimSigningKey::Ed25519(sk);
assert_eq!(key.algorithm(), Algorithm::Ed25519Sha256);
}
#[test]
fn sign_opts_builder_chain() {
let opts = SignOpts::new("example.com", "s1")
.add_signed_header("From")
.add_signed_header("Subject")
.timestamp(1_700_000_000)
.expiration(1_700_086_400);
assert_eq!(opts.domain, "example.com");
assert_eq!(opts.selector, "s1");
assert_eq!(opts.signed_headers, vec!["From", "Subject"]);
assert_eq!(opts.timestamp, Some(1_700_000_000));
assert_eq!(opts.expiration, Some(1_700_086_400));
}
#[test]
fn sign_errors_on_missing_body_separator() {
let secret = [1u8; 32];
let sk = ed25519_dalek::SigningKey::from_bytes(&secret);
let key = DkimSigningKey::Ed25519(sk);
let opts = SignOpts::new("example.com", "s1").add_signed_header("From");
let msg = b"From: a@b.c\r\nSubject: hi\r\n";
assert!(matches!(
sign(msg, &key, &opts),
Err(DkimError::MissingHeader)
));
}
}