mirror of
https://github.com/mailscope/kumomta.git
synced 2026-09-21 17:15:31 +00:00
first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+1111
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "cfdkim"
|
||||
version = "0.1.0"
|
||||
authors = ["Sven Sauleau <sven@cloudflare.com>"]
|
||||
edition = "2021"
|
||||
description = "DKIM (RFC6376) implementation"
|
||||
repository = "https://github.com/cloudflare/dkim"
|
||||
documentation = "https://docs.rs/cfdkim"
|
||||
categories = ["email"]
|
||||
keywords = ["email", "dkim", "authentification"]
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
mailparse = "0.13.7"
|
||||
quick-error = "2.0.1"
|
||||
nom = "7.1.0"
|
||||
chrono = "0.4.19"
|
||||
trust-dns-resolver = "0.20.3"
|
||||
futures = "0.3.18"
|
||||
sha-1 = "0.9"
|
||||
sha2 = "0.9"
|
||||
base64 = "0.13.0"
|
||||
rsa = "0.5.0"
|
||||
slog = "2.7.0"
|
||||
indexmap = "1.8.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.14.0", features = ["macros"] }
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Cloudflare
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,32 @@
|
||||
# cfdkim
|
||||
|
||||
> DKIM ([RFC6376]) implementation
|
||||
|
||||
## Features
|
||||
|
||||
### Verifying email signatures
|
||||
|
||||
Example:
|
||||
```rust
|
||||
let res: DKIMResult = cfdkim::verify_email(&logger, &from_domain, &parsed_email).await?;
|
||||
|
||||
if let Some(err) = &res.error() {
|
||||
error!(logger, "dkim verify fail: {}", err);
|
||||
}
|
||||
|
||||
println!("dkim={}", res.with_detail());
|
||||
```
|
||||
|
||||
The `verify_email` arguments are the following:
|
||||
- `logger`: [slog]::Logger
|
||||
- `from_domain`: &str ([RFC5322].From's domain)
|
||||
- `parsed_email`: [mailparse]::ParsedMail
|
||||
|
||||
### Signing an email
|
||||
|
||||
Work in progress.
|
||||
|
||||
[RFC5322]: https://datatracker.ietf.org/doc/html/rfc5322
|
||||
[RFC6376]: https://datatracker.ietf.org/doc/html/rfc6376
|
||||
[slog]: https://crates.io/crates/slog
|
||||
[mailparse]: https://crates.io/crates/mailparse
|
||||
@@ -0,0 +1,62 @@
|
||||
///! Various utility functions to operate on bytes
|
||||
|
||||
pub(crate) fn get_all_after<'a>(bytes: &'a [u8], end: &[u8]) -> &'a [u8] {
|
||||
if let Some(mut end_index) = find(bytes, end) {
|
||||
end_index += end.len();
|
||||
return &bytes[end_index..];
|
||||
} else {
|
||||
return &[];
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the offset of specific bytes in bytes
|
||||
pub(crate) fn find(bytes: &[u8], search: &[u8]) -> Option<usize> {
|
||||
bytes
|
||||
.windows(search.len())
|
||||
.position(|window| window == search)
|
||||
}
|
||||
|
||||
pub(crate) fn replace(bytes: &mut [u8], from: char, to: char) {
|
||||
for byte in bytes.iter_mut() {
|
||||
if *byte == from as u8 {
|
||||
*byte = to as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn replace_slice(source: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
|
||||
let mut result = source.to_vec();
|
||||
let from_len = from.len();
|
||||
let to_len = to.len();
|
||||
|
||||
let mut i = 0;
|
||||
while i + from_len <= result.len() {
|
||||
if result[i..].starts_with(from) {
|
||||
result.splice(i..i + from_len, to.iter().cloned());
|
||||
i += to_len;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_find() {
|
||||
assert_eq!(find(&[97, 98, 99], &[1]), None);
|
||||
assert_eq!(find(&[97, 98, 99], &[97]), Some(0));
|
||||
assert_eq!(find(&[97, 98, 99], &[97, 98]), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_replace_slice() {
|
||||
let source = "aba".as_bytes();
|
||||
assert_eq!(replace_slice(&source, &[97], &[99]), "cbc".as_bytes());
|
||||
assert_eq!(replace_slice(&source, &[97, 98], &[]), "a".as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Inspired from https://docs.rs/dkim/latest/src/dkim/canonicalization.rs.html
|
||||
use crate::bytes;
|
||||
|
||||
#[derive(PartialEq, Clone, Debug)]
|
||||
pub enum Type {
|
||||
Simple,
|
||||
Relaxed,
|
||||
}
|
||||
|
||||
/// Canonicalize body using the simple canonicalization algorithm.
|
||||
///
|
||||
/// The first argument **must** be the body of the mail.
|
||||
pub(crate) fn canonicalize_body_simple(mut body: &[u8]) -> Vec<u8> {
|
||||
if body.is_empty() {
|
||||
return b"\r\n".to_vec();
|
||||
}
|
||||
|
||||
while body.ends_with(b"\r\n\r\n") {
|
||||
body = &body[..body.len() - 2];
|
||||
}
|
||||
|
||||
body.to_vec()
|
||||
}
|
||||
|
||||
/// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.3
|
||||
/// Canonicalize body using the relaxed canonicalization algorithm.
|
||||
///
|
||||
/// The first argument **must** be the body of the mail.
|
||||
pub(crate) fn canonicalize_body_relaxed(body: &[u8]) -> Vec<u8> {
|
||||
let mut body = body.to_vec();
|
||||
// See https://tools.ietf.org/html/rfc6376#section-3.4.4 for implementation details
|
||||
|
||||
// Reduce all sequences of WSP within a line to a single SP character.
|
||||
bytes::replace(&mut body, '\t', ' ');
|
||||
let mut previous = false;
|
||||
body.retain(|c| {
|
||||
if *c == b' ' {
|
||||
if previous {
|
||||
false
|
||||
} else {
|
||||
previous = true;
|
||||
true
|
||||
}
|
||||
} else {
|
||||
previous = false;
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
// Ignore all whitespace at the end of lines. Implementations MUST NOT remove the CRLF at the end of the line.
|
||||
while let Some(idx) = bytes::find(&body, b" \r\n") {
|
||||
body.remove(idx);
|
||||
}
|
||||
|
||||
// Ignore all empty lines at the end of the message body. "Empty line" is defined in Section 3.4.3.
|
||||
while body.ends_with(b"\r\n\r\n") {
|
||||
body.remove(body.len() - 1);
|
||||
body.remove(body.len() - 1);
|
||||
}
|
||||
|
||||
// If the body is non-empty but does not end with a CRLF, a CRLF is added. (For email, this is only possible when using extensions to SMTP or non-SMTP transport mechanisms.)
|
||||
if !body.is_empty() && !body.ends_with(b"\r\n") {
|
||||
body.push(b'\r');
|
||||
body.push(b'\n');
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.1
|
||||
pub(crate) fn canonicalize_header_simple(key: &str, value: &[u8]) -> Vec<u8> {
|
||||
// TODO: according to the spec whitespace MUST NOT be changed? pydkim does
|
||||
// change it too.
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&key.as_bytes());
|
||||
out.extend_from_slice(b":");
|
||||
out.extend_from_slice(value);
|
||||
out.extend_from_slice(b"\r\n");
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.2
|
||||
pub(crate) fn canonicalize_header_relaxed(key: &str, value: &[u8]) -> Vec<u8> {
|
||||
let key = key.to_lowercase();
|
||||
let key = key.trim_end();
|
||||
let value = canonicalize_header_value_relaxed(value);
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&key.as_bytes());
|
||||
out.extend_from_slice(b":");
|
||||
out.extend_from_slice(&value);
|
||||
out.extend_from_slice(b"\r\n");
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn canonicalize_header_value_relaxed(value: &[u8]) -> Vec<u8> {
|
||||
let mut value = value.to_vec();
|
||||
bytes::replace(&mut value, '\t', ' ');
|
||||
value = bytes::replace_slice(&value, b"\r\n", b"");
|
||||
|
||||
while value.ends_with(b" ") {
|
||||
value.remove(value.len() - 1);
|
||||
}
|
||||
while value.starts_with(b" ") {
|
||||
value.remove(0);
|
||||
}
|
||||
let mut previous = false;
|
||||
value.retain(|c| {
|
||||
if *c == b' ' {
|
||||
if previous {
|
||||
false
|
||||
} else {
|
||||
previous = true;
|
||||
true
|
||||
}
|
||||
} else {
|
||||
previous = false;
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
value
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_canonicalize_header_relaxed() {
|
||||
assert_eq!(
|
||||
canonicalize_header_relaxed("SUBJect", b" AbC\r\n"),
|
||||
b"subject:AbC\r\n"
|
||||
);
|
||||
assert_eq!(
|
||||
canonicalize_header_relaxed("Subject \t", b"\t Your Name\t \r\n"),
|
||||
b"subject:Your Name\r\n"
|
||||
);
|
||||
assert_eq!(
|
||||
canonicalize_header_relaxed("Subject \t", b"\t Kimi \t \r\n No \t\r\n Na Wa\r\n"),
|
||||
b"subject:Kimi No Na Wa\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonicalize_body_relaxed() {
|
||||
assert_eq!(canonicalize_body_relaxed(b"\r\n"), b"\r\n");
|
||||
assert_eq!(canonicalize_body_relaxed(b"hey \r\n"), b"hey\r\n");
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
use crate::DKIMError;
|
||||
use futures::future::BoxFuture;
|
||||
use std::sync::Arc;
|
||||
use trust_dns_resolver::error::{ResolveError, ResolveErrorKind};
|
||||
use trust_dns_resolver::TokioAsyncResolver;
|
||||
|
||||
/// A trait for entities that perform DNS resolution.
|
||||
pub trait Lookup: Sync + Send {
|
||||
fn lookup_txt<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<Vec<String>, DKIMError>>;
|
||||
}
|
||||
|
||||
fn to_lookup_error(err: ResolveError) -> DKIMError {
|
||||
match err.kind() {
|
||||
ResolveErrorKind::NoRecordsFound { .. } => DKIMError::NoKeyForSignature,
|
||||
_ => DKIMError::KeyUnavailable(format!("failed to query DNS: {}", err)),
|
||||
}
|
||||
}
|
||||
|
||||
// Technically we should be able to implemement Lookup for TokioAsyncResolver
|
||||
// directly but it's failing for some reason.
|
||||
struct TokioAsyncResolverWrapper {
|
||||
inner: TokioAsyncResolver,
|
||||
}
|
||||
impl Lookup for TokioAsyncResolverWrapper {
|
||||
fn lookup_txt<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<Vec<String>, DKIMError>> {
|
||||
Box::pin(async move {
|
||||
self.inner
|
||||
.txt_lookup(name)
|
||||
.await
|
||||
.map_err(to_lookup_error)?
|
||||
.into_iter()
|
||||
.map(|txt| {
|
||||
Ok(txt
|
||||
.iter()
|
||||
.map(|data| String::from_utf8_lossy(data))
|
||||
.collect())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_tokio_resolver(resolver: TokioAsyncResolver) -> Arc<dyn Lookup> {
|
||||
Arc::new(TokioAsyncResolverWrapper { inner: resolver })
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/// DKIM error status
|
||||
pub enum Status {
|
||||
Permfail,
|
||||
Tempfail,
|
||||
}
|
||||
|
||||
quick_error! {
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
/// DKIM errors
|
||||
pub enum DKIMError {
|
||||
UnsupportedHashAlgorithm(value: String) {
|
||||
display("unsupported hash algorithm: {}", value)
|
||||
}
|
||||
UnsupportedCanonicalizationType(value: String) {
|
||||
display("unsupported canonicalization: {}", value)
|
||||
}
|
||||
SignatureSyntaxError(err: String) {
|
||||
display("signature syntax error: {}", err)
|
||||
}
|
||||
SignatureMissingRequiredTag(name: &'static str) {
|
||||
display("signature missing required tag ({})", name)
|
||||
}
|
||||
IncompatibleVersion {
|
||||
display("incompatible version")
|
||||
}
|
||||
DomainMismatch {
|
||||
display("domain mismatch")
|
||||
}
|
||||
FromFieldNotSigned {
|
||||
display("From field not signed")
|
||||
}
|
||||
SignatureExpired {
|
||||
display("signature expired")
|
||||
}
|
||||
UnacceptableSignatureHeader {
|
||||
display("unacceptable signature header")
|
||||
}
|
||||
UnsupportedQueryMethod {
|
||||
display("unsupported query method")
|
||||
}
|
||||
KeyUnavailable(err: String) {
|
||||
display("key unavailable: {}", err)
|
||||
}
|
||||
UnknownInternalError(err: String) {
|
||||
display("internal error: {}", err)
|
||||
}
|
||||
NoKeyForSignature {
|
||||
display("no key for signature")
|
||||
}
|
||||
KeySyntaxError {
|
||||
display("key syntax error")
|
||||
}
|
||||
KeyIncompatibleVersion {
|
||||
display("key incompatible version")
|
||||
}
|
||||
InappropriateKeyAlgorithm {
|
||||
display("inappropriate key algorithm")
|
||||
}
|
||||
SignatureDidNotVerify {
|
||||
display("signature did not verify")
|
||||
}
|
||||
BodyHashDidNotVerify {
|
||||
display("body hash did not verify")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DKIMError {
|
||||
pub fn status(self) -> Status {
|
||||
use DKIMError::*;
|
||||
match self {
|
||||
SignatureSyntaxError(_)
|
||||
| SignatureMissingRequiredTag(_)
|
||||
| IncompatibleVersion
|
||||
| DomainMismatch
|
||||
| FromFieldNotSigned
|
||||
| SignatureExpired
|
||||
| UnacceptableSignatureHeader
|
||||
| UnsupportedQueryMethod
|
||||
| NoKeyForSignature
|
||||
| KeySyntaxError
|
||||
| KeyIncompatibleVersion
|
||||
| InappropriateKeyAlgorithm
|
||||
| SignatureDidNotVerify
|
||||
| BodyHashDidNotVerify
|
||||
| UnsupportedCanonicalizationType(_)
|
||||
| UnsupportedHashAlgorithm(_) => Status::Permfail,
|
||||
KeyUnavailable(_) | UnknownInternalError(_) => Status::Tempfail,
|
||||
}
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
use indexmap::set::IndexSet;
|
||||
use mailparse::MailHeaderMap;
|
||||
use slog::debug;
|
||||
|
||||
use crate::canonicalization::{
|
||||
self, canonicalize_body_relaxed, canonicalize_body_simple, canonicalize_header_relaxed,
|
||||
canonicalize_header_simple,
|
||||
};
|
||||
use crate::{bytes, DKIMError, DKIMHeader};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HashAlgo {
|
||||
RsaSha1,
|
||||
RsaSha256,
|
||||
}
|
||||
|
||||
/// Get the body part of an email
|
||||
fn get_body<'a>(email: &'a mailparse::ParsedMail<'a>) -> &'a [u8] {
|
||||
let body = email.raw_bytes;
|
||||
bytes::get_all_after(body, b"\r\n\r\n")
|
||||
}
|
||||
|
||||
fn hash_sha1<T: AsRef<[u8]>>(data: T) -> Vec<u8> {
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(data);
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
fn hash_sha256<T: AsRef<[u8]>>(data: T) -> Vec<u8> {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Returns the hash of message's body
|
||||
/// https://datatracker.ietf.org/doc/html/rfc6376#section-3.7
|
||||
pub(crate) fn compute_body_hash<'a>(
|
||||
canonicalization_type: canonicalization::Type,
|
||||
length: Option<String>,
|
||||
hash_algo: HashAlgo,
|
||||
email: &'a mailparse::ParsedMail<'a>,
|
||||
) -> Result<String, DKIMError> {
|
||||
let body = get_body(email);
|
||||
|
||||
let mut canonicalized_body = if canonicalization_type == canonicalization::Type::Simple {
|
||||
canonicalize_body_simple(body)
|
||||
} else {
|
||||
canonicalize_body_relaxed(body)
|
||||
};
|
||||
if let Some(length) = length {
|
||||
let length = length
|
||||
.parse::<usize>()
|
||||
.map_err(|err| DKIMError::SignatureSyntaxError(format!("invalid length: {}", err)))?;
|
||||
canonicalized_body.truncate(length);
|
||||
};
|
||||
|
||||
let hash = match hash_algo {
|
||||
HashAlgo::RsaSha1 => hash_sha1(&canonicalized_body),
|
||||
HashAlgo::RsaSha256 => hash_sha256(&canonicalized_body),
|
||||
};
|
||||
Ok(base64::encode(&hash))
|
||||
}
|
||||
|
||||
fn select_headers<'a, 'b>(
|
||||
headers: &'b str,
|
||||
email: &'a mailparse::ParsedMail<'a>,
|
||||
) -> Result<Vec<(String, &'a [u8])>, DKIMError> {
|
||||
let mut signed_headers = vec![];
|
||||
|
||||
// Transform the header list into a ordered set to deduplicate the headers
|
||||
// while precerving the order
|
||||
let headers: IndexSet<&str> = IndexSet::from_iter(headers.split(":"));
|
||||
|
||||
for name in headers {
|
||||
let name = name.trim();
|
||||
if let Some(header) = email.headers.get_first_header(name) {
|
||||
signed_headers.push((name.to_owned(), header.get_value_raw()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(signed_headers)
|
||||
}
|
||||
|
||||
pub(crate) fn compute_headers_hash<'a, 'b>(
|
||||
logger: &slog::Logger,
|
||||
canonicalization_type: canonicalization::Type,
|
||||
headers: &'b str,
|
||||
hash_algo: HashAlgo,
|
||||
dkim_header: &'b DKIMHeader<'b>,
|
||||
email: &'a mailparse::ParsedMail<'a>,
|
||||
) -> Result<Vec<u8>, DKIMError> {
|
||||
let mut input = Vec::new();
|
||||
|
||||
// Add the headers defined in `h=` in the hash
|
||||
for (key, value) in select_headers(headers, email)? {
|
||||
let canonicalized_value = if canonicalization_type == canonicalization::Type::Simple {
|
||||
canonicalize_header_simple(&key, &value)
|
||||
} else {
|
||||
canonicalize_header_relaxed(&key, &value)
|
||||
};
|
||||
input.extend_from_slice(&canonicalized_value);
|
||||
}
|
||||
|
||||
// Add the DKIM-Signature header in the hash. Remove the value of the
|
||||
// signature (b) first.
|
||||
{
|
||||
let sign = dkim_header.get_raw_tag("b").unwrap();
|
||||
let value = dkim_header.raw_bytes.replace(&sign, "");
|
||||
input.extend_from_slice(&"dkim-signature:".as_bytes());
|
||||
input.extend_from_slice(&value.as_bytes());
|
||||
}
|
||||
debug!(logger, "headers to hash: {:?}", input);
|
||||
|
||||
let hash = match hash_algo {
|
||||
HashAlgo::RsaSha1 => hash_sha1(&input),
|
||||
HashAlgo::RsaSha256 => hash_sha256(&input),
|
||||
};
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dkim_header() -> DKIMHeader<'static> {
|
||||
crate::validate_header("v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; s=smtp; d=test.com; t=1641506955; h=content-type:to: subject:date:from:mime-version:sender; bh=PU2XIErWsXvhvt1W96ntPWZ2VImjVZ3vBY2T/A+wA3A=; b=PIO0A014nyntOGKdTdtvCJor9ZxvP1M3hoLeEh8HqZ+RvAyEKdAc7VOg+/g/OTaZgsmw6U sZCoN0YNVp+2o9nkaeUslsVz3M4I55HcZnarxl+fhplIMcJ/3s0nIhXL51MfGPRqPbB7/M Gjg9/07/2vFoid6Kitg6Z+CfoD2wlSRa8xDfmeyA2cHpeVuGQhGxu7BXuU8kGbeM4+weit Ql3t9zalhikEPI5Pr7dzYFrgWNOEO6w6rQfG7niKON1BimjdbJlGanC7cO4UL361hhXT4X iXLnC9TG39xKFPT/+4nkHy8pp6YvWkD3wKlBjwkYNm0JvKGwTskCMDeTwxXhAg==").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_body_hash_simple() {
|
||||
let email = mailparse::parse_mail(
|
||||
r#"To: test@sauleau.com
|
||||
Subject: subject
|
||||
From: Sven Sauleau <sven@cloudflare.com>
|
||||
|
||||
Hello Alice
|
||||
"#
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let canonicalization_type = canonicalization::Type::Simple;
|
||||
let length = None;
|
||||
let hash_algo = HashAlgo::RsaSha1;
|
||||
assert_eq!(
|
||||
compute_body_hash(
|
||||
canonicalization_type.clone(),
|
||||
length.clone(),
|
||||
hash_algo,
|
||||
&email
|
||||
)
|
||||
.unwrap(),
|
||||
"uoq1oCgLlTqpdDX/iUbLy7J1Wic="
|
||||
);
|
||||
let hash_algo = HashAlgo::RsaSha256;
|
||||
assert_eq!(
|
||||
compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
|
||||
"frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY="
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_body_hash_relaxed() {
|
||||
let email = mailparse::parse_mail(
|
||||
r#"To: test@sauleau.com
|
||||
Subject: subject
|
||||
From: Sven Sauleau <sven@cloudflare.com>
|
||||
|
||||
Hello Alice
|
||||
"#
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let canonicalization_type = canonicalization::Type::Relaxed;
|
||||
let length = None;
|
||||
let hash_algo = HashAlgo::RsaSha1;
|
||||
assert_eq!(
|
||||
compute_body_hash(
|
||||
canonicalization_type.clone(),
|
||||
length.clone(),
|
||||
hash_algo,
|
||||
&email
|
||||
)
|
||||
.unwrap(),
|
||||
"2jmj7l5rSw0yVb/vlWAYkK/YBwk="
|
||||
);
|
||||
let hash_algo = HashAlgo::RsaSha256;
|
||||
assert_eq!(
|
||||
compute_body_hash(canonicalization_type, length, hash_algo, &email).unwrap(),
|
||||
"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_body_hash_length() {
|
||||
let email = mailparse::parse_mail(
|
||||
r#"To: test@sauleau.com
|
||||
Subject: subject
|
||||
From: Sven Sauleau <sven@cloudflare.com>
|
||||
|
||||
Hello Alice
|
||||
"#
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let canonicalization_type = canonicalization::Type::Relaxed;
|
||||
let length = Some("3".to_owned());
|
||||
let hash_algo = HashAlgo::RsaSha1;
|
||||
assert_eq!(
|
||||
compute_body_hash(
|
||||
canonicalization_type.clone(),
|
||||
length.clone(),
|
||||
hash_algo,
|
||||
&email
|
||||
)
|
||||
.unwrap(),
|
||||
"2jmj7l5rSw0yVb/vlWAYkK/YBwk="
|
||||
);
|
||||
let hash_algo = HashAlgo::RsaSha256;
|
||||
assert_eq!(
|
||||
compute_body_hash(canonicalization_type, length.clone(), hash_algo, &email).unwrap(),
|
||||
"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_body_hash_empty_simple() {
|
||||
let email = mailparse::parse_mail(&[]).unwrap();
|
||||
|
||||
let canonicalization_type = canonicalization::Type::Simple;
|
||||
let length = None;
|
||||
let hash_algo = HashAlgo::RsaSha1;
|
||||
assert_eq!(
|
||||
compute_body_hash(
|
||||
canonicalization_type.clone(),
|
||||
length.clone(),
|
||||
hash_algo,
|
||||
&email
|
||||
)
|
||||
.unwrap(),
|
||||
"uoq1oCgLlTqpdDX/iUbLy7J1Wic="
|
||||
);
|
||||
let hash_algo = HashAlgo::RsaSha256;
|
||||
assert_eq!(
|
||||
compute_body_hash(canonicalization_type, length.clone(), hash_algo, &email).unwrap(),
|
||||
"frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY="
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_body_hash_empty_relaxed() {
|
||||
let email = mailparse::parse_mail(&[]).unwrap();
|
||||
|
||||
let canonicalization_type = canonicalization::Type::Relaxed;
|
||||
let length = None;
|
||||
let hash_algo = HashAlgo::RsaSha1;
|
||||
assert_eq!(
|
||||
compute_body_hash(
|
||||
canonicalization_type.clone(),
|
||||
length.clone(),
|
||||
hash_algo,
|
||||
&email
|
||||
)
|
||||
.unwrap(),
|
||||
"2jmj7l5rSw0yVb/vlWAYkK/YBwk="
|
||||
);
|
||||
let hash_algo = HashAlgo::RsaSha256;
|
||||
assert_eq!(
|
||||
compute_body_hash(canonicalization_type, length.clone(), hash_algo, &email).unwrap(),
|
||||
"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_headers_hash_simple() {
|
||||
let email = mailparse::parse_mail(
|
||||
r#"To: test@sauleau.com
|
||||
Subject: subject
|
||||
From: Sven Sauleau <sven@cloudflare.com>
|
||||
|
||||
Hello Alice
|
||||
"#
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let canonicalization_type = canonicalization::Type::Simple;
|
||||
let hash_algo = HashAlgo::RsaSha1;
|
||||
let headers = "To: Subject".to_owned();
|
||||
let logger = slog::Logger::root(slog::Discard, slog::o!());
|
||||
assert_eq!(
|
||||
compute_headers_hash(
|
||||
&logger,
|
||||
canonicalization_type.clone(),
|
||||
&headers,
|
||||
hash_algo,
|
||||
&dkim_header(),
|
||||
&email
|
||||
)
|
||||
.unwrap(),
|
||||
&[
|
||||
139, 181, 80, 152, 144, 190, 55, 167, 172, 184, 152, 202, 222, 81, 169, 121, 20, 5,
|
||||
213, 151
|
||||
],
|
||||
);
|
||||
let hash_algo = HashAlgo::RsaSha256;
|
||||
assert_eq!(
|
||||
compute_headers_hash(
|
||||
&logger,
|
||||
canonicalization_type.clone(),
|
||||
&headers,
|
||||
hash_algo,
|
||||
&dkim_header(),
|
||||
&email
|
||||
)
|
||||
.unwrap(),
|
||||
&[
|
||||
34, 222, 85, 83, 216, 70, 124, 226, 60, 174, 156, 184, 140, 247, 178, 88, 76, 99,
|
||||
182, 251, 149, 224, 243, 172, 54, 202, 138, 72, 45, 45, 88, 9
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_headers_hash_relaxed() {
|
||||
let email = mailparse::parse_mail(
|
||||
r#"To: test@sauleau.com
|
||||
Subject: subject
|
||||
From: Sven Sauleau <sven@cloudflare.com>
|
||||
|
||||
Hello Alice
|
||||
"#
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let canonicalization_type = canonicalization::Type::Relaxed;
|
||||
let hash_algo = HashAlgo::RsaSha1;
|
||||
let headers = "To: Subject".to_owned();
|
||||
let logger = slog::Logger::root(slog::Discard, slog::o!());
|
||||
assert_eq!(
|
||||
compute_headers_hash(
|
||||
&logger,
|
||||
canonicalization_type.clone(),
|
||||
&headers,
|
||||
hash_algo,
|
||||
&dkim_header(),
|
||||
&email
|
||||
)
|
||||
.unwrap(),
|
||||
&[
|
||||
14, 171, 230, 1, 77, 117, 47, 207, 243, 167, 179, 5, 150, 82, 154, 25, 125, 124,
|
||||
44, 164
|
||||
]
|
||||
);
|
||||
let hash_algo = HashAlgo::RsaSha256;
|
||||
assert_eq!(
|
||||
compute_headers_hash(
|
||||
&logger,
|
||||
canonicalization_type.clone(),
|
||||
&headers,
|
||||
hash_algo,
|
||||
&dkim_header(),
|
||||
&email
|
||||
)
|
||||
.unwrap(),
|
||||
&[
|
||||
45, 186, 211, 81, 49, 111, 18, 147, 180, 245, 207, 39, 9, 9, 118, 137, 248, 204,
|
||||
70, 214, 16, 98, 216, 111, 230, 130, 196, 3, 60, 201, 166, 224
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::parser;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub(crate) const HEADER: &str = "DKIM-Signature";
|
||||
pub(crate) const REQUIRED_TAGS: &[&str] = &["v", "a", "b", "bh", "d", "h", "s"];
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DKIMHeader<'a> {
|
||||
pub(crate) tags: HashMap<String, parser::Tag>,
|
||||
pub(crate) raw_bytes: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> DKIMHeader<'a> {
|
||||
pub(crate) fn get_tag(&self, name: &str) -> Option<String> {
|
||||
self.tags.get(name).map(|v| v.value.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn get_raw_tag(&self, name: &str) -> Option<String> {
|
||||
self.tags.get(name).map(|v| v.raw_value.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn get_required_tag(&self, name: &str) -> String {
|
||||
// Required tags are guaranteed by the parser to be present so it's safe
|
||||
// to assert and unwrap.
|
||||
debug_assert!(REQUIRED_TAGS.contains(&name));
|
||||
self.tags.get(name).unwrap().value.clone()
|
||||
}
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
// Implementation of DKIM: https://datatracker.ietf.org/doc/html/rfc6376
|
||||
|
||||
use rsa::PublicKey;
|
||||
use slog::debug;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use trust_dns_resolver::TokioAsyncResolver;
|
||||
|
||||
use mailparse::MailHeaderMap;
|
||||
|
||||
#[macro_use]
|
||||
extern crate quick_error;
|
||||
|
||||
mod bytes;
|
||||
mod canonicalization;
|
||||
mod dns;
|
||||
mod errors;
|
||||
mod hash;
|
||||
mod header;
|
||||
mod parser;
|
||||
mod public_key;
|
||||
mod result;
|
||||
|
||||
pub use errors::DKIMError;
|
||||
use header::*;
|
||||
pub use parser::tag_list as parse_tag_list;
|
||||
pub use parser::Tag;
|
||||
pub use result::DKIMResult;
|
||||
|
||||
const SIGN_EXPIRATION_DRIFT_MINS: i64 = 15;
|
||||
const DNS_NAMESPACE: &str = "_domainkey";
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1
|
||||
fn validate_header<'a>(value: &'a str) -> Result<DKIMHeader<'a>, DKIMError> {
|
||||
let (_, tags) =
|
||||
parser::tag_list(value).map_err(|err| DKIMError::SignatureSyntaxError(err.to_string()))?;
|
||||
|
||||
// Check presence of required tags
|
||||
{
|
||||
let mut tag_names: HashSet<String> = HashSet::new();
|
||||
for tag in &tags {
|
||||
tag_names.insert(tag.name.clone());
|
||||
}
|
||||
for required in REQUIRED_TAGS {
|
||||
if tag_names.get(*required).is_none() {
|
||||
return Err(DKIMError::SignatureMissingRequiredTag(required));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut tags_map = HashMap::new();
|
||||
for tag in &tags {
|
||||
tags_map.insert(tag.name.clone(), tag.clone());
|
||||
}
|
||||
let header = DKIMHeader {
|
||||
tags: tags_map,
|
||||
raw_bytes: value,
|
||||
};
|
||||
// FIXME: we could get the keys instead of generating tag_names ourselves
|
||||
|
||||
// Check version
|
||||
{
|
||||
let version = header.get_required_tag("v");
|
||||
if version != "1" {
|
||||
return Err(DKIMError::IncompatibleVersion);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that "d=" tag is the same as or a parent domain of the domain part
|
||||
// of the "i=" tag
|
||||
if let Some(user) = header.get_tag("i") {
|
||||
let signing_domain = header.get_required_tag("d");
|
||||
// TODO: naive check, should switch to parsing the domains/email
|
||||
if !user.ends_with(&signing_domain) {
|
||||
return Err(DKIMError::DomainMismatch);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that "h=" tag includes the From header
|
||||
{
|
||||
let value = header.get_required_tag("h");
|
||||
let headers = value.split(":");
|
||||
let headers: Vec<String> = headers.map(|h| h.to_lowercase()).collect();
|
||||
if !headers.contains(&"from".to_string()) {
|
||||
return Err(DKIMError::FromFieldNotSigned);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(query_method) = header.get_tag("q") {
|
||||
if query_method != "dns/txt" {
|
||||
return Err(DKIMError::UnsupportedQueryMethod);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that "x=" tag isn't expired
|
||||
if let Some(expiration) = header.get_tag("x") {
|
||||
let mut expiration =
|
||||
chrono::NaiveDateTime::from_timestamp(expiration.parse::<i64>().unwrap_or_default(), 0);
|
||||
expiration += chrono::Duration::minutes(SIGN_EXPIRATION_DRIFT_MINS);
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
if now > expiration {
|
||||
return Err(DKIMError::SignatureExpired);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(header)
|
||||
}
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.3 Step 4
|
||||
// TODO: implement verification with ed25519 keys
|
||||
fn verify_signature(
|
||||
hash_algo: hash::HashAlgo,
|
||||
header_hash: Vec<u8>,
|
||||
signature: Vec<u8>,
|
||||
public_key: impl PublicKey,
|
||||
) -> Result<bool, DKIMError> {
|
||||
Ok(public_key
|
||||
.verify(
|
||||
rsa::PaddingScheme::PKCS1v15Sign {
|
||||
hash: Some(match hash_algo {
|
||||
hash::HashAlgo::RsaSha1 => rsa::hash::Hash::SHA1,
|
||||
hash::HashAlgo::RsaSha256 => rsa::hash::Hash::SHA2_256,
|
||||
}),
|
||||
},
|
||||
&header_hash,
|
||||
&signature,
|
||||
)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
async fn verify_email_header<'a>(
|
||||
logger: &'a slog::Logger,
|
||||
resolver: Arc<dyn dns::Lookup>,
|
||||
dkim_header: &'a DKIMHeader<'a>,
|
||||
email: &'a mailparse::ParsedMail<'a>,
|
||||
) -> Result<(), DKIMError> {
|
||||
let public_key = public_key::retrieve_public_key(
|
||||
logger,
|
||||
Arc::clone(&resolver),
|
||||
dkim_header.get_required_tag("d"),
|
||||
dkim_header.get_required_tag("s"),
|
||||
dkim_header.get_tag("k"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (header_canonicalization_type, body_canonicalization_type) =
|
||||
parser::parse_canonicalization(dkim_header.get_tag("c"))?;
|
||||
let hash_algo = parser::parse_hash_algo(&dkim_header.get_required_tag("a"))?;
|
||||
let computed_body_hash = hash::compute_body_hash(
|
||||
body_canonicalization_type,
|
||||
dkim_header.get_tag("l"),
|
||||
hash_algo.clone(),
|
||||
email,
|
||||
)?;
|
||||
let computed_headers_hash = hash::compute_headers_hash(
|
||||
logger,
|
||||
header_canonicalization_type,
|
||||
&dkim_header.get_required_tag("h"),
|
||||
hash_algo.clone(),
|
||||
&dkim_header,
|
||||
email,
|
||||
)?;
|
||||
debug!(logger, "body_hash {:?}", computed_body_hash);
|
||||
|
||||
let header_body_hash = dkim_header.get_required_tag("bh").clone();
|
||||
if header_body_hash != computed_body_hash {
|
||||
return Err(DKIMError::BodyHashDidNotVerify);
|
||||
}
|
||||
|
||||
let signature = base64::decode(dkim_header.get_required_tag("b")).map_err(|err| {
|
||||
DKIMError::SignatureSyntaxError(format!("failed to decode signature: {}", err))
|
||||
})?;
|
||||
if !verify_signature(hash_algo, computed_headers_hash, signature, public_key)? {
|
||||
return Err(DKIMError::SignatureDidNotVerify);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the DKIM verification on the email
|
||||
pub async fn verify_email<'a>(
|
||||
logger: &slog::Logger,
|
||||
from_domain: &str,
|
||||
email: &'a mailparse::ParsedMail<'a>,
|
||||
) -> Result<DKIMResult, DKIMError> {
|
||||
let resolver = TokioAsyncResolver::tokio_from_system_conf().map_err(|err| {
|
||||
DKIMError::UnknownInternalError(format!("failed to create DNS resolver: {}", err))
|
||||
})?;
|
||||
let resolver = dns::from_tokio_resolver(resolver);
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for h in email.headers.get_all_headers(HEADER) {
|
||||
let value = h.get_value();
|
||||
debug!(logger, "checking signature {:?}", value);
|
||||
|
||||
let dkim_header = match validate_header(&value) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
debug!(logger, "failed to verify: {}", err);
|
||||
last_error = Some(err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Select the signature corresponding to the email sender
|
||||
let signing_domain = dkim_header.get_required_tag("d");
|
||||
if signing_domain != from_domain {
|
||||
continue;
|
||||
}
|
||||
|
||||
match verify_email_header(logger, Arc::clone(&resolver), &dkim_header, email).await {
|
||||
Ok(()) => return Ok(DKIMResult::pass(signing_domain)),
|
||||
Err(err) => {
|
||||
debug!(logger, "failed to verify: {}", err);
|
||||
last_error = Some(err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = last_error {
|
||||
Ok(DKIMResult::fail(err, from_domain.to_owned()))
|
||||
} else {
|
||||
Ok(DKIMResult::neutral(from_domain.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_header() {
|
||||
let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane;
|
||||
c=relaxed/simple; q=dns/txt; i=foo@eng.example.net;
|
||||
t=1117574938; x=9118006938; l=200;
|
||||
h=from:to:subject:date:keywords:keywords;
|
||||
z=From:foo@eng.example.net|To:joe@example.com|
|
||||
Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;
|
||||
bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;
|
||||
b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniSbav+yuU4zGeeruD00lszZ
|
||||
VoG4ZHRNiYzR
|
||||
"#;
|
||||
validate_header(header).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_header_missing_tag() {
|
||||
let header = "v=1; a=rsa-sha256; bh=a; b=b";
|
||||
assert_eq!(
|
||||
validate_header(header).unwrap_err(),
|
||||
DKIMError::SignatureMissingRequiredTag("d")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_header_domain_mismatch() {
|
||||
let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@hein.com; h=headers; bh=hash; b=hash
|
||||
"#;
|
||||
assert_eq!(
|
||||
validate_header(header).unwrap_err(),
|
||||
DKIMError::DomainMismatch
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_header_incompatible_version() {
|
||||
let header = r#"v=3; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=headers; bh=hash; b=hash
|
||||
"#;
|
||||
assert_eq!(
|
||||
validate_header(header).unwrap_err(),
|
||||
DKIMError::IncompatibleVersion
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_header_missing_from_in_headers_signature() {
|
||||
let header = r#"v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=Subject:A:B; bh=hash; b=hash
|
||||
"#;
|
||||
assert_eq!(
|
||||
validate_header(header).unwrap_err(),
|
||||
DKIMError::FromFieldNotSigned
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_header_expired_in_drift() {
|
||||
let mut now = chrono::Utc::now().naive_utc();
|
||||
now -= chrono::Duration::seconds(1);
|
||||
|
||||
let header = format!("v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=From:B; bh=hash; b=hash; x={}", now.timestamp());
|
||||
|
||||
assert!(validate_header(&header).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_header_expired() {
|
||||
let mut now = chrono::Utc::now().naive_utc();
|
||||
now -= chrono::Duration::hours(3);
|
||||
|
||||
let header = format!("v=1; a=rsa-sha256; d=example.net; s=brisbane; i=foo@example.net; h=From:B; bh=hash; b=hash; x={}", now.timestamp());
|
||||
|
||||
assert_eq!(
|
||||
validate_header(&header).unwrap_err(),
|
||||
DKIMError::SignatureExpired
|
||||
);
|
||||
}
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
use crate::{canonicalization, hash, DKIMError};
|
||||
use nom::bytes::complete::tag;
|
||||
use nom::bytes::complete::take_while1;
|
||||
use nom::character::complete::alpha1;
|
||||
use nom::combinator::opt;
|
||||
use nom::multi::fold_many0;
|
||||
use nom::sequence::delimited;
|
||||
use nom::sequence::pair;
|
||||
use nom::sequence::preceded;
|
||||
use nom::sequence::terminated;
|
||||
use nom::IResult;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
/// DKIM signature tag
|
||||
pub struct Tag {
|
||||
/// Name of the tag (v, i, a, h, ...)
|
||||
pub name: String,
|
||||
/// Value of the tag with spaces removed
|
||||
pub value: String,
|
||||
/// Value of the tag as seen in the text
|
||||
pub raw_value: String,
|
||||
}
|
||||
|
||||
/// Main entrypoint of the parser. Parses the DKIM signature tag list
|
||||
/// as specified https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.1.
|
||||
/// tag-list = tag-spec *( ";" tag-spec ) [ ";" ]
|
||||
pub fn tag_list(input: &str) -> IResult<&str, Vec<Tag>> {
|
||||
let (input, start) = tag_spec(input)?;
|
||||
|
||||
terminated(
|
||||
fold_many0(
|
||||
preceded(tag(";"), tag_spec),
|
||||
move || vec![start.clone()],
|
||||
|mut acc: Vec<Tag>, item| {
|
||||
acc.push(item);
|
||||
acc
|
||||
},
|
||||
),
|
||||
opt(tag(";")),
|
||||
)(input)
|
||||
}
|
||||
|
||||
/// tag-spec = [FWS] tag-name [FWS] "=" [FWS] tag-value [FWS]
|
||||
fn tag_spec(input: &str) -> IResult<&str, Tag> {
|
||||
let (input, name) = delimited(opt(fws), tag_name, opt(fws))(input)?;
|
||||
let (input, _) = tag("=")(input)?;
|
||||
|
||||
// Parse the twice to keep the original text
|
||||
let value_input = input;
|
||||
let (_, raw_value) = delimited(opt(fws), raw_tag_value, opt(fws))(value_input)?;
|
||||
let (input, value) = delimited(opt(fws), tag_value, opt(fws))(value_input)?;
|
||||
|
||||
Ok((
|
||||
input,
|
||||
Tag {
|
||||
name: name.to_owned(),
|
||||
value,
|
||||
raw_value,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// tag-name = ALPHA *ALNUMPUNC
|
||||
/// ALNUMPUNC = ALPHA / DIGIT / "_"
|
||||
fn tag_name(input: &str) -> IResult<&str, &str> {
|
||||
alpha1(input)
|
||||
}
|
||||
|
||||
/// tag-value = [ tval *( 1*(WSP / FWS) tval ) ]
|
||||
/// tval = 1*VALCHAR
|
||||
/// VALCHAR = %x21-3A / %x3C-7E
|
||||
fn tag_value(input: &str) -> IResult<&str, String> {
|
||||
let is_valchar = |c| (c >= '!' && c <= ':') || (c >= '<' && c <= '~');
|
||||
match opt(take_while1(is_valchar))(input)? {
|
||||
(input, Some(start)) => fold_many0(
|
||||
preceded(fws, take_while1(is_valchar)),
|
||||
|| start.to_owned(),
|
||||
|mut acc: String, item| {
|
||||
acc += item;
|
||||
acc
|
||||
},
|
||||
)(input),
|
||||
(input, None) => Ok((input, "".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_tag_value(input: &str) -> IResult<&str, String> {
|
||||
let is_valchar = |c| (c >= '!' && c <= ':') || (c >= '<' && c <= '~');
|
||||
match opt(take_while1(is_valchar))(input)? {
|
||||
(input, Some(start)) => fold_many0(
|
||||
pair(fws, take_while1(is_valchar)),
|
||||
|| start.to_owned(),
|
||||
|mut acc: String, item| {
|
||||
acc += &(item.0.to_owned() + item.1);
|
||||
acc
|
||||
},
|
||||
)(input),
|
||||
(input, None) => Ok((input, "".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// FWS is folding whitespace. It allows multiple lines separated by CRLF followed by at least one whitespace, to be joined.
|
||||
fn fws(input: &str) -> IResult<&str, &str> {
|
||||
take_while1(|c| c == ' ' || c == '\t' || c == '\r' || c == '\n')(input)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_hash_algo(value: &String) -> Result<hash::HashAlgo, DKIMError> {
|
||||
use hash::HashAlgo;
|
||||
match value.as_str() {
|
||||
"rsa-sha1" => Ok(HashAlgo::RsaSha1),
|
||||
"rsa-sha256" => Ok(HashAlgo::RsaSha256),
|
||||
e => Err(DKIMError::UnsupportedHashAlgorithm(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the canonicalization value (passed in c=) and returns canonicalization
|
||||
/// for (Header, Body)
|
||||
pub(crate) fn parse_canonicalization(
|
||||
value: Option<String>,
|
||||
) -> Result<(canonicalization::Type, canonicalization::Type), DKIMError> {
|
||||
use canonicalization::Type::{Relaxed, Simple};
|
||||
if value.is_none() {
|
||||
return Ok((Simple, Simple));
|
||||
}
|
||||
match value.unwrap().as_str() {
|
||||
"simple/simple" => Ok((Simple, Simple)),
|
||||
"relaxed/simple" => Ok((Relaxed, Simple)),
|
||||
"simple/relaxed" => Ok((Simple, Relaxed)),
|
||||
"relaxed/relaxed" => Ok((Relaxed, Relaxed)),
|
||||
"relaxed" => Ok((Relaxed, Simple)),
|
||||
"simple" => Ok((Simple, Simple)),
|
||||
v => Err(DKIMError::UnsupportedCanonicalizationType(v.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_canonicalization_empty() {
|
||||
use canonicalization::Type::Simple;
|
||||
assert_eq!(parse_canonicalization(None).unwrap(), (Simple, Simple));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonicalization_one_algo() {
|
||||
use canonicalization::Type::{Relaxed, Simple};
|
||||
|
||||
assert_eq!(
|
||||
parse_canonicalization(Some("simple".to_string())).unwrap(),
|
||||
(Simple, Simple)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_canonicalization(Some("relaxed".to_string())).unwrap(),
|
||||
(Relaxed, Simple)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tag_list() {
|
||||
assert_eq!(
|
||||
tag_list("a = a/1@.-:= ").unwrap(),
|
||||
(
|
||||
"",
|
||||
vec![Tag {
|
||||
name: "a".to_string(),
|
||||
value: "a/1@.-:=".to_string(),
|
||||
raw_value: "a/1@.-:=".to_string()
|
||||
}]
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
tag_list("a= a ; b = a\n bc").unwrap(),
|
||||
(
|
||||
"",
|
||||
vec![
|
||||
Tag {
|
||||
name: "a".to_string(),
|
||||
value: "a".to_string(),
|
||||
raw_value: "a".to_string()
|
||||
},
|
||||
Tag {
|
||||
name: "b".to_string(),
|
||||
value: "abc".to_string(),
|
||||
raw_value: "a\n bc".to_string()
|
||||
}
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tag_spec() {
|
||||
assert_eq!(
|
||||
tag_spec("a=b").unwrap(),
|
||||
(
|
||||
"",
|
||||
Tag {
|
||||
name: "a".to_string(),
|
||||
value: "b".to_string(),
|
||||
raw_value: "b".to_string()
|
||||
}
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
tag_spec("a=b c d e f").unwrap(),
|
||||
(
|
||||
"",
|
||||
Tag {
|
||||
name: "a".to_string(),
|
||||
value: "bcdef".to_string(),
|
||||
raw_value: "b c d e f".to_string()
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tag_list_dns() {
|
||||
assert_eq!(
|
||||
tag_list("k=rsa; p=kEy+/").unwrap(),
|
||||
(
|
||||
"",
|
||||
vec![
|
||||
Tag {
|
||||
name: "k".to_string(),
|
||||
value: "rsa".to_string(),
|
||||
raw_value: "rsa".to_string()
|
||||
},
|
||||
Tag {
|
||||
name: "p".to_string(),
|
||||
value: "kEy+/".to_string(),
|
||||
raw_value: "kEy+/".to_string()
|
||||
}
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use rsa::{pkcs8, RsaPublicKey};
|
||||
use slog::{debug, warn};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{dns, parser, DKIMError, DNS_NAMESPACE};
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2
|
||||
pub(crate) async fn retrieve_public_key(
|
||||
logger: &slog::Logger,
|
||||
resolver: Arc<dyn dns::Lookup>,
|
||||
domain: String,
|
||||
subdomain: String,
|
||||
key_type: Option<String>,
|
||||
) -> Result<RsaPublicKey, DKIMError> {
|
||||
let dns_name = format!("{}.{}.{}", subdomain, DNS_NAMESPACE, domain);
|
||||
let res = resolver.lookup_txt(&dns_name).await?;
|
||||
// TODO: Return multiple keys for when verifiying the signatures. During key
|
||||
// rotation they are often multiple keys to consider.
|
||||
let txt = res.first().ok_or(DKIMError::NoKeyForSignature)?;
|
||||
debug!(logger, "DKIM TXT: {:?}", txt);
|
||||
|
||||
// Parse the tags inside the DKIM TXT DNS record
|
||||
let (_, tags) = parser::tag_list(txt).map_err(|err| {
|
||||
warn!(logger, "key syntax error: {}", err);
|
||||
DKIMError::KeySyntaxError
|
||||
})?;
|
||||
|
||||
let mut tags_map = HashMap::new();
|
||||
for tag in &tags {
|
||||
tags_map.insert(tag.name.clone(), tag.clone());
|
||||
}
|
||||
|
||||
// Check version
|
||||
if let Some(version) = tags_map.get("v") {
|
||||
if version.value != "DKIM1" {
|
||||
return Err(DKIMError::KeyIncompatibleVersion);
|
||||
}
|
||||
}
|
||||
|
||||
// Check key has right type
|
||||
if let Some(v) = tags_map.get("k") {
|
||||
let key_type = key_type.unwrap_or_else(|| "rsa".to_string());
|
||||
if v.value != key_type {
|
||||
return Err(DKIMError::InappropriateKeyAlgorithm);
|
||||
}
|
||||
}
|
||||
|
||||
let tag = tags_map.get("p").ok_or(DKIMError::NoKeyForSignature)?;
|
||||
let bytes = base64::decode(&tag.value).map_err(|err| {
|
||||
DKIMError::KeyUnavailable(format!("failed to decode public key: {}", err))
|
||||
})?;
|
||||
let key = pkcs8::FromPublicKey::from_public_key_der(&bytes)
|
||||
.map_err(|err| DKIMError::KeyUnavailable(format!("failed to parse public key: {}", err)))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retrieve_public_key() {
|
||||
struct TestResolver {}
|
||||
impl dns::Lookup for TestResolver {
|
||||
fn lookup_txt<'a>(
|
||||
&'a self,
|
||||
name: &'a str,
|
||||
) -> BoxFuture<'a, Result<Vec<String>, DKIMError>> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(name, "dkim._domainkey.cloudflare.com");
|
||||
Ok(vec!["v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6gmVDBSBJ0l1/33uAF0gwIsrjQV6nnYjL9DMX6+ez4NNJ2um0InYy128Rd+OlIhmdSld6g3tj3O6R+BwsYsQgU8RWE8VJaRybvPw2P3Asgms4uPrFWHSFiWMPH0P9i/oPwnUO9jZKHiz4+MzFC3bG8BacX7YIxCuWnDU8XNmNsRaLmrv9CHX4/3GHyoHSmDA1ETtyz9JHRCOC8ho8C7b4f2Auwedlau9Lid9LGBhozhgRFhrFwFMe93y34MO1clPbY6HwxpudKWBkMQCTlmXVRnkKxHlJ+fYCyC2jjpCIbGWj2oLxBtFOASWMESR4biW0ph2bsZXslcUSPMTVTkFxQIDAQAB".to_string()])
|
||||
})
|
||||
}
|
||||
}
|
||||
let resolver = Arc::new(TestResolver {});
|
||||
let logger = slog::Logger::root(slog::Discard, slog::o!());
|
||||
|
||||
retrieve_public_key(
|
||||
&logger,
|
||||
resolver,
|
||||
"cloudflare.com".to_string(),
|
||||
"dkim".to_string(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retrieve_public_key_incompatible_version() {
|
||||
struct TestResolver {}
|
||||
impl dns::Lookup for TestResolver {
|
||||
fn lookup_txt<'a>(
|
||||
&'a self,
|
||||
name: &'a str,
|
||||
) -> BoxFuture<'a, Result<Vec<String>, DKIMError>> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(name, "dkim._domainkey.cloudflare.com");
|
||||
Ok(vec!["v=DKIM6; p=key".to_string()])
|
||||
})
|
||||
}
|
||||
}
|
||||
let resolver = Arc::new(TestResolver {});
|
||||
let logger = slog::Logger::root(slog::Discard, slog::o!());
|
||||
|
||||
let key = retrieve_public_key(
|
||||
&logger,
|
||||
resolver,
|
||||
"cloudflare.com".to_string(),
|
||||
"dkim".to_string(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(key, DKIMError::KeyIncompatibleVersion);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_retrieve_public_key_inappropriate_key_algorithm() {
|
||||
struct TestResolver {}
|
||||
impl dns::Lookup for TestResolver {
|
||||
fn lookup_txt<'a>(
|
||||
&'a self,
|
||||
name: &'a str,
|
||||
) -> BoxFuture<'a, Result<Vec<String>, DKIMError>> {
|
||||
Box::pin(async move {
|
||||
assert_eq!(name, "dkim._domainkey.cloudflare.com");
|
||||
Ok(vec!["v=DKIM1; p=key; k=foo".to_string()])
|
||||
})
|
||||
}
|
||||
}
|
||||
let resolver = Arc::new(TestResolver {});
|
||||
let logger = slog::Logger::root(slog::Discard, slog::o!());
|
||||
|
||||
let key = retrieve_public_key(
|
||||
&logger,
|
||||
resolver,
|
||||
"cloudflare.com".to_string(),
|
||||
"dkim".to_string(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(key, DKIMError::InappropriateKeyAlgorithm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use crate::DKIMError;
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Result of the DKIM verification
|
||||
pub struct DKIMResult {
|
||||
value: &'static str,
|
||||
error: Option<DKIMError>,
|
||||
domain_used: String,
|
||||
}
|
||||
impl DKIMResult {
|
||||
/// Constructs a `pass` result
|
||||
pub fn pass(domain_used: String) -> Self {
|
||||
DKIMResult {
|
||||
value: "pass",
|
||||
error: None,
|
||||
domain_used,
|
||||
}
|
||||
}
|
||||
/// Constructs a `neutral` result
|
||||
pub fn neutral(domain_used: String) -> Self {
|
||||
DKIMResult {
|
||||
value: "neutral",
|
||||
error: None,
|
||||
domain_used,
|
||||
}
|
||||
}
|
||||
/// Constructs a `fail` result with a reason
|
||||
pub fn fail(reason: DKIMError, domain_used: String) -> Self {
|
||||
DKIMResult {
|
||||
value: "fail",
|
||||
error: Some(reason),
|
||||
domain_used,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(&self) -> Option<DKIMError> {
|
||||
self.error.clone()
|
||||
}
|
||||
|
||||
/// Returns the domain used to pass the DKIM verification
|
||||
pub fn domain_used(&self) -> String {
|
||||
self.domain_used.to_lowercase()
|
||||
}
|
||||
|
||||
/// Returns the verification result as a summary: fail, neutral or pass.
|
||||
pub fn summary(&self) -> &'static str {
|
||||
self.value
|
||||
}
|
||||
|
||||
/// Similar to `summary` but with detail on fail. Typically used for the
|
||||
/// `Authentication-Results` header.
|
||||
pub fn with_detail(&self) -> String {
|
||||
if let Some(err) = self.error() {
|
||||
format!("{} ({})", self.value, err)
|
||||
} else {
|
||||
self.value.to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user