use crate::{
AuthenticatedMessage, Dkim2Result, MessageAuthenticator,
common::{
cache::test::DummyCaches,
crypto::{Ed25519Key, RsaKey, Sha256},
headers::HeaderWriter,
parse::TxtRecordParser,
verify::DomainKey,
},
dkim2::{
Dkim2Signer, Envelope, Hop, sign::Dkim2Signed,
verify::test_reverse_path::LenientReversePath,
},
};
use rustls_pki_types::{PrivateKeyDer, pem::PemObject};
use std::{
path::PathBuf,
time::{Duration, Instant},
};
use testcontainers::{
ContainerAsync, GenericBuildableImage, Image, ImageExt,
core::{ExecCommand, Mount, WaitFor},
runners::{AsyncBuilder, AsyncRunner},
};
const TS: u64 = 1740002100;
const NOW: u64 = 1740002100;
const READY: &str = "dkim2-interop-dns-ready";
const DOCKERFILE: &str = r#"
FROM golang:1.26-bookworm
# Default branches resolve to upstream HEAD (latest). Pin with a build arg.
ARG GO_DKIM2_REF=
ARG PY_INTEROP_REF=
# PhoenixDKIM ships DKIM2 on a feature branch; pin a commit with the build arg.
ARG PHOENIX_REF=feature/dkim2
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip git ca-certificates \
build-essential cmake pkg-config libssl-dev liblmdb-dev libmilter-dev \
libidn2-dev libcjson-dev libbsd-dev \
&& rm -rf /var/lib/apt/lists/*
RUN pip3 install --break-system-packages --no-cache-dir cryptography dnslib
RUN git clone https://forge.turscar.ie/turscar/dkim2.git /src/go \
&& if [ -n "${GO_DKIM2_REF}" ]; then git -C /src/go checkout "${GO_DKIM2_REF}"; fi \
&& go build -C /src/go -o /usr/local/bin/dkim2sign ./cmd/dkim2sign \
&& go build -C /src/go -o /usr/local/bin/dkim2verify ./cmd/dkim2verify
# dkim2recipe exposes the Go library's DiffMail + Sign(Modifications:) so that a
# genuine Go-generated recipe can be produced (the shipped dkim2sign CLI never
# wires up recipe generation).
COPY dkim2recipe.go /src/go/cmd/dkim2recipe/main.go
RUN go build -C /src/go -o /usr/local/bin/dkim2recipe ./cmd/dkim2recipe
RUN git clone https://github.com/dkim2wg/interop.git /src/interop \
&& if [ -n "${PY_INTEROP_REF}" ]; then git -C /src/interop checkout "${PY_INTEROP_REF}"; fi
ENV PYTHONPATH=/src/interop/python
# PhoenixDKIM (C, OpenDKIM-derived) exposes standalone DKIM2 sign/verify CLIs
# under WITH_DKIM2. Only those two tools are needed for the interop matrix, so
# the milter daemon, IDN, Lua and unbound support are all left out of the build.
RUN git clone https://github.com/edmundlod/PhoenixDKIM.git /src/phoenix \
&& git -C /src/phoenix checkout "${PHOENIX_REF}" \
&& cmake -S /src/phoenix -B /src/phoenix/build \
-DWITH_DKIM2=ON -DWITH_UNBOUND=OFF -DWITH_LUA=OFF -DWITH_IDN=OFF \
-DCMAKE_BUILD_TYPE=Release \
&& cmake --build /src/phoenix/build \
--target phoenixdkim2-sign phoenixdkim2-verify -j"$(nproc)" \
&& cp /src/phoenix/build/libphoenixdkim/phoenixdkim2-sign \
/src/phoenix/build/libphoenixdkim/phoenixdkim2-verify /usr/local/bin/
COPY dns_server.py /usr/local/bin/dns_server.py
CMD ["python3", "/usr/local/bin/dns_server.py", "/res/dns.json"]
"#;
const DNS_SERVER_PY: &str = r#"
import json, sys, time
from dnslib import RR, QTYPE, TXT
from dnslib.server import DNSServer, BaseResolver
def chunks(value):
raw = value.encode()
return [raw[i:i + 255] for i in range(0, len(raw), 255)] or [b""]
class Resolver(BaseResolver):
def __init__(self, path):
data = json.load(open(path))
self.records = {}
for domain, selectors in data.items():
for selector_key, items in selectors.items():
name = f"{selector_key}.{domain}.".lower()
self.records[name] = items[0][1]
def resolve(self, request, handler):
reply = request.reply()
qname = str(request.q.qname).lower()
if not qname.endswith("."):
qname += "."
value = self.records.get(qname)
if value is not None and request.q.qtype in (QTYPE.TXT, QTYPE.ANY):
reply.add_answer(RR(request.q.qname, QTYPE.TXT, ttl=300, rdata=TXT(chunks(value))))
return reply
resolver = Resolver(sys.argv[1])
DNSServer(resolver, port=53, address="0.0.0.0").start_thread()
DNSServer(resolver, port=53, address="0.0.0.0", tcp=True).start_thread()
print("dkim2-interop-dns-ready", flush=True)
while True:
time.sleep(3600)
"#;
const DKIM2RECIPE_GO: &str = r#"
package main
import (
"bytes"
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"log"
"net/mail"
"os"
"regexp"
"strings"
flag "github.com/spf13/pflag"
"go.turscar.ie/dkim2"
)
var crlfRe = regexp.MustCompile(`\r?\n`)
func mustReadCRLF(path string) []byte {
data, err := os.ReadFile(path)
if err != nil {
log.Fatalf("read %s: %v", path, err)
}
return crlfRe.ReplaceAll(data, []byte("\r\n"))
}
func loadPrivateKey(filename string) (crypto.Signer, error) {
raw, err := os.ReadFile(filename)
if err != nil {
return nil, fmt.Errorf("read key: %w", err)
}
block, _ := pem.Decode(raw)
if block == nil {
return nil, fmt.Errorf("no PEM block in %s", filename)
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse key: %w", err)
}
switch key := k.(type) {
case *rsa.PrivateKey:
return key, nil
case ed25519.PrivateKey:
return key, nil
}
return nil, fmt.Errorf("unsupported key type")
}
func wrapAddress(addr string) string {
if addr == "" {
return ""
}
if !strings.HasPrefix(addr, "<") {
addr = "<" + addr
}
if !strings.HasSuffix(addr, ">") {
addr = addr + ">"
}
return addr
}
func main() {
var oldPath, newPath, domain, selector, keyfile, mailFrom string
var rcptTo []string
var timestamp int64
flag.StringVar(&oldPath, "old", "", "original (previous) message file")
flag.StringVar(&newPath, "new", "", "modified (current) message file")
flag.StringVar(&domain, "domain", "", "signing domain")
flag.StringVar(&selector, "selector", "", "selector")
flag.StringVar(&keyfile, "key", "", "private key file")
flag.StringVar(&mailFrom, "from", "", "mail from")
flag.StringSliceVar(&rcptTo, "to", nil, "rcpt to")
flag.Int64Var(×tamp, "timestamp", 0, "signing timestamp")
flag.Parse()
oldBytes := mustReadCRLF(oldPath)
newBytes := mustReadCRLF(newPath)
oldMsg, err := mail.ReadMessage(bytes.NewReader(oldBytes))
if err != nil {
log.Fatalf("parse old: %v", err)
}
newMsg, err := mail.ReadMessage(bytes.NewReader(newBytes))
if err != nil {
log.Fatalf("parse new: %v", err)
}
recipe := dkim2.DiffMail(*oldMsg, *newMsg)
key, err := loadPrivateKey(keyfile)
if err != nil {
log.Fatal(err)
}
opts := dkim2.SignOptions{
Timestamp: timestamp,
Domain: domain,
Keys: []dkim2.SigningKey{{Selector: selector, Signer: key}},
MailFrom: wrapAddress(mailFrom),
RcptTo: rcptTo,
Modifications: &recipe,
}
if err := dkim2.Sign(os.Stdout, bytes.NewReader(newBytes), opts); err != nil {
log.Fatalf("sign: %v", err)
}
}
"#;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Alg {
Ed25519,
Rsa,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Im {
Rust,
Py,
Go,
Phoenix,
}
impl Im {
fn name(self) -> &'static str {
match self {
Im::Rust => "rust",
Im::Py => "py",
Im::Go => "go",
Im::Phoenix => "phoenix",
}
}
}
const ALL: [Im; 4] = [Im::Rust, Im::Py, Im::Go, Im::Phoenix];
const PHOENIX_FIXTURE: &str = "/work/dns.fixture";
#[derive(Clone, Copy)]
struct HopSpec {
domain: &'static str,
selector: &'static str,
alg: Alg,
mail_from: &'static str,
rcpt_to: &'static [&'static str],
}
fn resource(parts: &[&str]) -> PathBuf {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("resources/dkim2");
for part in parts {
path.push(part);
}
path
}
fn load_ed25519(domain: &str, selector: &str) -> Ed25519Key {
let pem = std::fs::read(resource(&[
"keys",
&format!("{selector}._domainkey.{domain}.pem"),
]))
.unwrap();
let PrivateKeyDer::Pkcs8(der) = PrivateKeyDer::from_pem_slice(&pem).unwrap() else {
panic!("expected PKCS8 key");
};
Ed25519Key::from_pkcs8_maybe_unchecked_der(der.secret_pkcs8_der()).unwrap()
}
fn load_rsa(domain: &str, selector: &str) -> RsaKey<Sha256> {
let pem = std::fs::read(resource(&[
"keys",
&format!("{selector}._domainkey.{domain}.pem"),
]))
.unwrap();
RsaKey::<Sha256>::from_key_der(PrivateKeyDer::from_pem_slice(&pem).unwrap()).unwrap()
}
fn load_caches() -> DummyCaches {
let caches = DummyCaches::new();
let dns = std::fs::read(resource(&["dns.json"])).unwrap();
let dns: serde_json::Value = serde_json::from_slice(&dns).unwrap();
let valid_until = Instant::now() + Duration::new(3600, 0);
for (domain, selectors) in dns.as_object().unwrap() {
for (selector, records) in selectors.as_object().unwrap() {
let record = records[0][1].as_str().unwrap();
let name = format!("{selector}.{domain}.");
caches.txt_add(
name,
DomainKey::parse(record.as_bytes()).unwrap(),
valid_until,
);
}
}
caches
}
fn write_phoenix_fixture(work: &std::path::Path) {
let dns = std::fs::read(resource(&["dns.json"])).unwrap();
let dns: serde_json::Value = serde_json::from_slice(&dns).unwrap();
let mut out = String::new();
for (domain, selectors) in dns.as_object().unwrap() {
for (selector, records) in selectors.as_object().unwrap() {
let record = records[0][1].as_str().unwrap();
out.push_str(&format!("{selector}.{domain} {record}\n"));
}
}
std::fs::write(work.join("dns.fixture"), out).unwrap();
}
fn normalize_crlf(message: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(message.len() + 16);
let mut iter = message.iter().peekable();
while let Some(&ch) = iter.next() {
match ch {
b'\r' => {
out.push(b'\r');
out.push(b'\n');
if iter.peek() == Some(&&b'\n') {
iter.next();
}
}
b'\n' => {
out.push(b'\r');
out.push(b'\n');
}
_ => out.push(ch),
}
}
out
}
fn prepend(signed: &Dkim2Signed, message: &[u8], fold: bool) -> Vec<u8> {
let mut out = Vec::with_capacity(message.len() + 512);
if fold {
signed.signature.write_header(&mut out);
if let Some(instance) = &signed.message_instance {
instance.write_header(&mut out);
}
} else {
signed.write(&mut out);
}
out.extend_from_slice(message);
out
}
fn wrap(addr: &str) -> String {
let addr = addr.trim();
if addr.starts_with('<') && addr.ends_with('>') {
addr.to_string()
} else {
format!("<{addr}>")
}
}
fn key_path(hop: &HopSpec) -> String {
format!("/res/keys/{}._domainkey.{}.pem", hop.selector, hop.domain)
}
fn rust_sign(message: &[u8], hop: &HopSpec) -> Vec<u8> {
let envelope = Hop::real(hop.mail_from, hop.rcpt_to);
let signed = match hop.alg {
Alg::Ed25519 => Dkim2Signer::from_key(load_ed25519(hop.domain, hop.selector))
.domain(hop.domain)
.selector(hop.selector)
.sign_at(message, envelope, TS)
.unwrap(),
Alg::Rsa => Dkim2Signer::from_key(load_rsa(hop.domain, hop.selector))
.domain(hop.domain)
.selector(hop.selector)
.sign_at(message, envelope, TS)
.unwrap(),
};
prepend(&signed, message, false)
}
async fn rust_verify(
resolver: &MessageAuthenticator,
caches: &DummyCaches,
message: &[u8],
mail_from: &str,
rcpt_to: &[&str],
) -> (bool, String) {
let Some(parsed) = AuthenticatedMessage::parse(message) else {
return (false, "parse failed".to_string());
};
let params = caches.parameters(&parsed);
let envelope = Envelope::new(mail_from, rcpt_to);
let output = resolver
.verify_dkim2_(&parsed, envelope, params.cache_txt, NOW, true)
.await;
let ok = *output.result() == Dkim2Result::Pass;
let detail = if ok {
String::new()
} else {
format!(
"{:?}: {}",
output.result(),
output.failure_reason().unwrap_or_default()
)
};
(ok, detail)
}
async fn exec_raw(
container: &ContainerAsync<impl Image>,
args: Vec<String>,
) -> (i64, Vec<u8>, Vec<u8>) {
let mut result = container
.exec(ExecCommand::new(args))
.await
.expect("exec failed");
let stdout = result.stdout_to_vec().await.expect("stdout");
let stderr = result.stderr_to_vec().await.expect("stderr");
let code = result.exit_code().await.expect("exit code").unwrap_or(-1);
(code, stdout, stderr)
}
async fn ext_sign(
container: &ContainerAsync<impl Image>,
work: &std::path::Path,
idx: u32,
im: Im,
message: &[u8],
hop: &HopSpec,
) -> Option<Vec<u8>> {
let infile = format!("sign_in_{idx}.eml");
std::fs::write(work.join(&infile), message).unwrap();
let container_in = format!("/work/{infile}");
let args: Vec<String> = match im {
Im::Py => {
let mut a = vec![
"python3".into(),
"/src/interop/python/dkim2sign.py".into(),
container_in,
"-s".into(),
hop.selector.into(),
"-d".into(),
hop.domain.into(),
"-k".into(),
key_path(hop),
"--mailfrom".into(),
wrap(hop.mail_from),
"--timestamp".into(),
TS.to_string(),
];
for rcpt in hop.rcpt_to {
a.push("--rcptto".into());
a.push(wrap(rcpt));
}
a
}
Im::Go => {
let mut a = vec![
"dkim2sign".into(),
"--in".into(),
container_in,
"--domain".into(),
hop.domain.into(),
"--selector".into(),
hop.selector.into(),
"--key".into(),
key_path(hop),
"--from".into(),
wrap(hop.mail_from),
"--timestamp".into(),
TS.to_string(),
];
for rcpt in hop.rcpt_to {
a.push("--to".into());
a.push(wrap(rcpt));
}
a
}
Im::Phoenix => {
let mut cmd = format!(
"phoenixdkim2-sign --key '{}' --domain '{}' --selector '{}' \
--mail-from '{}' --time {}",
key_path(hop),
hop.domain,
hop.selector,
wrap(hop.mail_from),
TS
);
for rcpt in hop.rcpt_to {
cmd.push_str(&format!(" --rcpt-to '{}'", wrap(rcpt)));
}
cmd.push_str(&format!(" < '{container_in}'"));
vec!["sh".into(), "-c".into(), cmd]
}
Im::Rust => unreachable!(),
};
let (code, stdout, stderr) = exec_raw(container, args).await;
if code != 0 || stdout.is_empty() {
eprintln!(
"[{} sign] exit={code} stderr={}",
im.name(),
String::from_utf8_lossy(&stderr)
);
return None;
}
Some(stdout)
}
async fn ext_verify(
container: &ContainerAsync<impl Image>,
work: &std::path::Path,
idx: u32,
im: Im,
message: &[u8],
mail_from: &str,
rcpt_to: &[&str],
) -> (bool, String) {
let vfile = format!("verify_in_{idx}.eml");
std::fs::write(work.join(&vfile), message).unwrap();
let container_in = format!("/work/{vfile}");
match im {
Im::Py => {
let args = vec![
"python3".into(),
"/src/interop/python/dkim2verify.py".into(),
container_in,
"--dns-json".into(),
"/res/dns.json".into(),
"--ignore-timestamps".into(),
];
let (code, stdout, stderr) = exec_raw(container, args).await;
let combined = format!(
"{}{}",
String::from_utf8_lossy(&stdout).trim(),
String::from_utf8_lossy(&stderr).trim()
);
(code == 0, combined)
}
Im::Go => {
let mut args = vec![
"dkim2verify".into(),
"--in".into(),
container_in,
"--from".into(),
wrap(mail_from),
"--server".into(),
"127.0.0.1:53".into(),
"--ignore-timestamp".into(),
];
for rcpt in rcpt_to {
args.push("--to".into());
args.push(wrap(rcpt));
}
let (_, stdout, stderr) = exec_raw(container, args).await;
let combined = format!(
"{}{}",
String::from_utf8_lossy(&stdout).trim(),
String::from_utf8_lossy(&stderr).trim()
);
let ok = combined.contains("Authentication result:") && !combined.contains("Error:");
(ok, combined)
}
Im::Phoenix => {
let mut cmd = format!(
"phoenixdkim2-verify --mail-from '{}' --ignore-timestamps --dns-fixture '{}'",
wrap(mail_from),
PHOENIX_FIXTURE
);
for rcpt in rcpt_to {
cmd.push_str(&format!(" --rcpt-to '{}'", wrap(rcpt)));
}
cmd.push_str(&format!(" < '{container_in}'"));
let args = vec!["sh".into(), "-c".into(), cmd];
let (code, stdout, stderr) = exec_raw(container, args).await;
let combined = format!(
"{}{}",
String::from_utf8_lossy(&stdout).trim(),
String::from_utf8_lossy(&stderr).trim()
);
(code == 0, combined)
}
Im::Rust => unreachable!(),
}
}
async fn sign_hop(
container: &ContainerAsync<impl Image>,
work: &std::path::Path,
idx: u32,
im: Im,
message: &[u8],
hop: &HopSpec,
) -> Option<Vec<u8>> {
match im {
Im::Rust => Some(rust_sign(message, hop)),
other => ext_sign(container, work, idx, other, message, hop).await,
}
}
#[allow(clippy::too_many_arguments)]
async fn verify_msg(
container: &ContainerAsync<impl Image>,
resolver: &MessageAuthenticator,
caches: &DummyCaches,
work: &std::path::Path,
idx: u32,
im: Im,
message: &[u8],
mail_from: &str,
rcpt_to: &[&str],
) -> (bool, String) {
match im {
Im::Rust => rust_verify(resolver, caches, message, mail_from, rcpt_to).await,
other => ext_verify(container, work, idx, other, message, mail_from, rcpt_to).await,
}
}
#[tokio::test]
#[ignore = "requires Docker and network access to upstream DKIM2 reference repos"]
async fn dkim2_interop_matrix() {
let _lenient = LenientReversePath::new();
let res_dir = resource(&[]);
let work = tempfile::tempdir().expect("tempdir");
write_phoenix_fixture(work.path());
let image = GenericBuildableImage::new("mail-auth-dkim2-interop", "latest")
.with_dockerfile_string(DOCKERFILE)
.with_data(DNS_SERVER_PY, "./dns_server.py")
.with_data(DKIM2RECIPE_GO, "./dkim2recipe.go")
.build_image()
.await
.expect("failed to build interop image (is Docker running?)");
let container = image
.with_wait_for(WaitFor::message_on_stdout(READY))
.with_mount(Mount::bind_mount(
res_dir.to_string_lossy().into_owned(),
"/res",
))
.with_mount(Mount::bind_mount(
work.path().to_string_lossy().into_owned(),
"/work",
))
.start()
.await
.expect("failed to start interop container");
let resolver = MessageAuthenticator::new_system_conf().unwrap();
let caches = load_caches();
let original = normalize_crlf(&std::fs::read(resource(&["emails", "simple.eml"])).unwrap());
let mut idx = 0u32;
let mut failures: Vec<String> = Vec::new();
let single_hops = [
HopSpec {
domain: "test1.dkim2.com",
selector: "ed25519",
alg: Alg::Ed25519,
mail_from: "sender@test1.dkim2.com",
rcpt_to: &["recipient@example.com"],
},
HopSpec {
domain: "test1.dkim2.com",
selector: "sel1",
alg: Alg::Rsa,
mail_from: "sender@test1.dkim2.com",
rcpt_to: &["recipient@example.com"],
},
];
for hop in &single_hops {
for signer in ALL {
idx += 1;
let Some(signed) = sign_hop(&container, work.path(), idx, signer, &original, hop).await
else {
failures.push(format!(
"single/{}/{}: SIGN failed",
alg_tag(hop.alg),
signer.name()
));
continue;
};
for verifier in ALL {
idx += 1;
let (ok, detail) = verify_msg(
&container,
&resolver,
&caches,
work.path(),
idx,
verifier,
&signed,
hop.mail_from,
hop.rcpt_to,
)
.await;
let label = format!(
"single/{}/{}->{}",
alg_tag(hop.alg),
signer.name(),
verifier.name()
);
if !ok {
failures.push(format!("{label}: VERIFY failed {detail}"));
}
println!("{label}: {}", if ok { "pass" } else { "FAIL" });
}
}
}
let two_hop = [
HopSpec {
domain: "test1.dkim2.com",
selector: "ed25519",
alg: Alg::Ed25519,
mail_from: "sender@test1.dkim2.com",
rcpt_to: &["list@test2.dkim2.com"],
},
HopSpec {
domain: "test2.dkim2.com",
selector: "ed25519",
alg: Alg::Ed25519,
mail_from: "relay@test2.dkim2.com",
rcpt_to: &["recipient@example.com"],
},
];
let top = two_hop[two_hop.len() - 1];
for s1 in ALL {
for s2 in ALL {
idx += 1;
let Some(msg1) =
sign_hop(&container, work.path(), idx, s1, &original, &two_hop[0]).await
else {
failures.push(format!(
"multihop2/{}-{}: HOP1 sign failed",
s1.name(),
s2.name()
));
continue;
};
idx += 1;
let Some(msg2) = sign_hop(&container, work.path(), idx, s2, &msg1, &two_hop[1]).await
else {
failures.push(format!(
"multihop2/{}-{}: HOP2 sign failed",
s1.name(),
s2.name()
));
continue;
};
for verifier in ALL {
if skip_phoenix_vs_gopy(&[s1, s2], verifier) {
continue;
}
idx += 1;
let (ok, detail) = verify_msg(
&container,
&resolver,
&caches,
work.path(),
idx,
verifier,
&msg2,
top.mail_from,
top.rcpt_to,
)
.await;
let label = format!("multihop2/{}-{}->{}", s1.name(), s2.name(), verifier.name());
if !ok {
failures.push(format!("{label}: VERIFY failed {detail}"));
}
println!("{label}: {}", if ok { "pass" } else { "FAIL" });
}
}
}
let three_hop = [
HopSpec {
domain: "test1.dkim2.com",
selector: "ed25519",
alg: Alg::Ed25519,
mail_from: "sender@test1.dkim2.com",
rcpt_to: &["list@test2.dkim2.com"],
},
HopSpec {
domain: "test2.dkim2.com",
selector: "ed25519",
alg: Alg::Ed25519,
mail_from: "relay@test2.dkim2.com",
rcpt_to: &["fwd@test3.dkim2.com"],
},
HopSpec {
domain: "test3.dkim2.com",
selector: "ed25519",
alg: Alg::Ed25519,
mail_from: "gateway@test3.dkim2.com",
rcpt_to: &["recipient@example.com"],
},
];
let top3 = three_hop[three_hop.len() - 1];
let patterns = [
[Im::Rust, Im::Py, Im::Go],
[Im::Go, Im::Py, Im::Rust],
[Im::Phoenix, Im::Go, Im::Rust],
[Im::Rust, Im::Py, Im::Phoenix],
[Im::Rust, Im::Rust, Im::Rust],
[Im::Py, Im::Py, Im::Py],
[Im::Go, Im::Go, Im::Go],
[Im::Phoenix, Im::Phoenix, Im::Phoenix],
];
for pattern in patterns {
idx += 1;
let mut message = original.clone();
let mut chain_ok = true;
for (hop_idx, signer) in pattern.iter().enumerate() {
idx += 1;
match sign_hop(
&container,
work.path(),
idx,
*signer,
&message,
&three_hop[hop_idx],
)
.await
{
Some(next) => message = next,
None => {
failures.push(format!(
"multihop3/{}-{}-{}: HOP{} sign failed",
pattern[0].name(),
pattern[1].name(),
pattern[2].name(),
hop_idx + 1
));
chain_ok = false;
break;
}
}
}
if !chain_ok {
continue;
}
for verifier in ALL {
if skip_phoenix_vs_gopy(&pattern, verifier) {
continue;
}
idx += 1;
let (ok, detail) = verify_msg(
&container,
&resolver,
&caches,
work.path(),
idx,
verifier,
&message,
top3.mail_from,
top3.rcpt_to,
)
.await;
let label = format!(
"multihop3/{}-{}-{}->{}",
pattern[0].name(),
pattern[1].name(),
pattern[2].name(),
verifier.name()
);
if !ok {
failures.push(format!("{label}: VERIFY failed {detail}"));
}
println!("{label}: {}", if ok { "pass" } else { "FAIL" });
}
}
assert!(
failures.is_empty(),
"DKIM2 interop matrix had {} failure(s):\n{}",
failures.len(),
failures.join("\n")
);
}
fn alg_tag(alg: Alg) -> &'static str {
match alg {
Alg::Ed25519 => "ed25519",
Alg::Rsa => "rsa",
}
}
fn skip_phoenix_vs_gopy(producers: &[Im], verifier: Im) -> bool {
verifier == Im::Phoenix
&& producers
.iter()
.skip(1)
.any(|p| matches!(p, Im::Py | Im::Go))
}
const RECIPE_HOP1: HopSpec = HopSpec {
domain: "test1.dkim2.com",
selector: "ed25519",
alg: Alg::Ed25519,
mail_from: "sender@test1.dkim2.com",
rcpt_to: &["relay@test1.dkim2.com"],
};
fn recipe_hop2(alg: Alg) -> HopSpec {
HopSpec {
domain: "test1.dkim2.com",
selector: match alg {
Alg::Ed25519 => "ed25519",
Alg::Rsa => "sel1",
},
alg,
mail_from: "relay@test1.dkim2.com",
rcpt_to: &["recipient@example.com"],
}
}
struct RecipeCase {
name: &'static str,
alg: Alg,
original: Vec<u8>,
modified: Vec<u8>,
}
fn assemble(headers: &[&str], body: &[&str]) -> Vec<u8> {
let mut out = String::new();
for header in headers {
out.push_str(header);
out.push_str("\r\n");
}
out.push_str("\r\n");
for line in body {
out.push_str(line);
out.push_str("\r\n");
}
out.into_bytes()
}
fn rust_recipe_sign(signed1: &[u8], inflight: &[u8], hop: &HopSpec) -> Vec<u8> {
let envelope = Hop::real(hop.mail_from, hop.rcpt_to);
let signed = match hop.alg {
Alg::Ed25519 => Dkim2Signer::from_key(load_ed25519(hop.domain, hop.selector))
.domain(hop.domain)
.selector(hop.selector)
.sign_revised_at(signed1, inflight, envelope, TS)
.unwrap(),
Alg::Rsa => Dkim2Signer::from_key(load_rsa(hop.domain, hop.selector))
.domain(hop.domain)
.selector(hop.selector)
.sign_revised_at(signed1, inflight, envelope, TS)
.unwrap(),
};
prepend(&signed, inflight, false)
}
async fn go_recipe_sign(
container: &ContainerAsync<impl Image>,
work: &std::path::Path,
idx: u32,
signed1: &[u8],
inflight: &[u8],
hop: &HopSpec,
) -> Option<Vec<u8>> {
let old_name = format!("recipe_old_{idx}.eml");
let new_name = format!("recipe_new_{idx}.eml");
std::fs::write(work.join(&old_name), signed1).unwrap();
std::fs::write(work.join(&new_name), inflight).unwrap();
let mut args: Vec<String> = vec![
"dkim2recipe".into(),
"--old".into(),
format!("/work/{old_name}"),
"--new".into(),
format!("/work/{new_name}"),
"--domain".into(),
hop.domain.into(),
"--selector".into(),
hop.selector.into(),
"--key".into(),
key_path(hop),
"--from".into(),
wrap(hop.mail_from),
"--timestamp".into(),
TS.to_string(),
];
for rcpt in hop.rcpt_to {
args.push("--to".into());
args.push(wrap(rcpt));
}
let (code, stdout, stderr) = exec_raw(container, args).await;
if code != 0 || stdout.is_empty() {
eprintln!(
"[go recipe-sign] exit={code} stderr={}",
String::from_utf8_lossy(&stderr)
);
return None;
}
Some(stdout)
}
async fn phoenix_recipe_sign(
container: &ContainerAsync<impl Image>,
work: &std::path::Path,
idx: u32,
signed1: &[u8],
inflight: &[u8],
hop: &HopSpec,
) -> Option<Vec<u8>> {
let old_name = format!("recipe_old_{idx}.eml");
let new_name = format!("recipe_new_{idx}.eml");
std::fs::write(work.join(&old_name), signed1).unwrap();
std::fs::write(work.join(&new_name), inflight).unwrap();
let mut cmd = format!(
"phoenixdkim2-sign --key '{}' --domain '{}' --selector '{}' --mail-from '{}' \
--time {} --orig '/work/{old_name}'",
key_path(hop),
hop.domain,
hop.selector,
wrap(hop.mail_from),
TS
);
for rcpt in hop.rcpt_to {
cmd.push_str(&format!(" --rcpt-to '{}'", wrap(rcpt)));
}
cmd.push_str(&format!(" < '/work/{new_name}'"));
let (code, stdout, stderr) = exec_raw(container, vec!["sh".into(), "-c".into(), cmd]).await;
if code != 0 || stdout.is_empty() {
eprintln!(
"[phoenix recipe-sign] exit={code} stderr={}",
String::from_utf8_lossy(&stderr)
);
return None;
}
Some(stdout)
}
fn recipe_cases() -> Vec<RecipeCase> {
let headers = &["From: sender@test1.dkim2.com", "To: recipient@example.com"];
vec![
RecipeCase {
name: "body_copy_preserves_whitespace",
alg: Alg::Ed25519,
original: assemble(
&[headers[0], headers[1], "Subject: report"],
&[
" two leading spaces",
"trailing spaces here ",
"mid multiple internal spaces",
"REPLACE THIS LINE",
],
),
modified: assemble(
&[headers[0], headers[1], "Subject: report"],
&[
" two leading spaces",
"trailing spaces here ",
"mid multiple internal spaces",
"line was replaced",
],
),
},
RecipeCase {
name: "body_data_preserves_whitespace",
alg: Alg::Ed25519,
original: assemble(
&[headers[0], headers[1], "Subject: report"],
&[
"stable first line",
" spaced original line ",
"stable last line",
],
),
modified: assemble(
&[headers[0], headers[1], "Subject: report"],
&[
"stable first line",
"totally different content",
"stable last line",
],
),
},
RecipeCase {
name: "body_empty_and_blank_lines",
alg: Alg::Ed25519,
original: assemble(
&[headers[0], headers[1], "Subject: report"],
&["alpha", "", "beta", " ", "", "gamma REPLACE"],
),
modified: assemble(
&[headers[0], headers[1], "Subject: report"],
&["alpha", "", "beta", " ", "", "gamma replaced"],
),
},
RecipeCase {
name: "body_empty_line_inserted",
alg: Alg::Ed25519,
original: assemble(
&[headers[0], headers[1], "Subject: report"],
&["first line", "second line"],
),
modified: assemble(
&[headers[0], headers[1], "Subject: report"],
&["first line", "", "second line"],
),
},
RecipeCase {
name: "header_value_whitespace",
alg: Alg::Ed25519,
original: assemble(
&[
headers[0],
headers[1],
"Subject: Weekly Status Report ",
"Comment: keep me unchanged",
],
&["body stays the same"],
),
modified: assemble(
&[
headers[0],
headers[1],
"Subject: changed subject",
"Comment: keep me unchanged",
],
&["body stays the same"],
),
},
RecipeCase {
name: "header_folded",
alg: Alg::Ed25519,
original: assemble(
&[
headers[0],
headers[1],
"Subject: folded part one\r\n\tpart two \r\n part three",
],
&["body stays the same"],
),
modified: assemble(
&[headers[0], headers[1], "Subject: now on a single line"],
&["body stays the same"],
),
},
RecipeCase {
name: "header_and_body_rsa",
alg: Alg::Rsa,
original: assemble(
&[headers[0], headers[1], "Subject: original subject"],
&["keep this spaced line", "change me"],
),
modified: assemble(
&[headers[0], headers[1], "Subject: new subject"],
&["keep this spaced line", "changed"],
),
},
RecipeCase {
name: "body_trailing_blank_line",
alg: Alg::Ed25519,
original: assemble(
&[headers[0], headers[1], "Subject: report"],
&["line a", "REPLACE", "line c", ""],
),
modified: assemble(
&[headers[0], headers[1], "Subject: report"],
&["line a", "was replaced", "line c", ""],
),
},
]
}
#[tokio::test]
#[ignore = "requires Docker and network access to upstream DKIM2 reference repos"]
async fn dkim2_recipe_interop() {
let _lenient = LenientReversePath::new();
let res_dir = resource(&[]);
let work = tempfile::tempdir().expect("tempdir");
write_phoenix_fixture(work.path());
let image = GenericBuildableImage::new("mail-auth-dkim2-interop", "latest")
.with_dockerfile_string(DOCKERFILE)
.with_data(DNS_SERVER_PY, "./dns_server.py")
.with_data(DKIM2RECIPE_GO, "./dkim2recipe.go")
.build_image()
.await
.expect("failed to build interop image (is Docker running?)");
let container = image
.with_wait_for(WaitFor::message_on_stdout(READY))
.with_mount(Mount::bind_mount(
res_dir.to_string_lossy().into_owned(),
"/res",
))
.with_mount(Mount::bind_mount(
work.path().to_string_lossy().into_owned(),
"/work",
))
.start()
.await
.expect("failed to start interop container");
let resolver = MessageAuthenticator::new_system_conf().unwrap();
let caches = load_caches();
let mut idx = 0u32;
let mut failures: Vec<String> = Vec::new();
let mut dumped = false;
for case in recipe_cases() {
let tag = alg_tag(case.alg);
let hop2 = recipe_hop2(case.alg);
let signed1 = rust_sign(&case.original, &RECIPE_HOP1);
let prefix = &signed1[..signed1.len() - case.original.len()];
let mut inflight = Vec::with_capacity(prefix.len() + case.modified.len());
inflight.extend_from_slice(prefix);
inflight.extend_from_slice(&case.modified);
let signed2_rust = rust_recipe_sign(&signed1, &inflight, &hop2);
for verifier in ALL {
idx += 1;
let (ok, detail) = verify_msg(
&container,
&resolver,
&caches,
work.path(),
idx,
verifier,
&signed2_rust,
hop2.mail_from,
hop2.rcpt_to,
)
.await;
let label = format!(
"recipe/{}/{}: mail-auth->{}",
case.name,
tag,
verifier.name()
);
if !ok {
failures.push(format!("{label}: {detail}"));
if !dumped {
dumped = true;
eprintln!(
"\n===== FAILING MESSAGE ({label}) =====\n{}\n===== detail: {detail} =====\n",
String::from_utf8_lossy(&signed2_rust)
);
}
}
println!("{label}: {}", if ok { "pass" } else { "FAIL" });
}
idx += 1;
match go_recipe_sign(&container, work.path(), idx, &signed1, &inflight, &hop2).await {
Some(signed2_go) => {
for verifier in [Im::Rust, Im::Go] {
idx += 1;
let (ok, detail) = verify_msg(
&container,
&resolver,
&caches,
work.path(),
idx,
verifier,
&signed2_go,
hop2.mail_from,
hop2.rcpt_to,
)
.await;
let label = format!("recipe/{}/{}: go->{}", case.name, tag, verifier.name());
if !ok {
failures.push(format!("{label}: {detail}"));
}
println!("{label}: {}", if ok { "pass" } else { "FAIL" });
}
}
None => failures.push(format!(
"recipe/{}/{}: go recipe-sign failed",
case.name, tag
)),
}
idx += 1;
match phoenix_recipe_sign(&container, work.path(), idx, &signed1, &inflight, &hop2).await {
Some(signed2_phoenix) => {
for verifier in [Im::Rust, Im::Go, Im::Phoenix] {
idx += 1;
let (ok, detail) = verify_msg(
&container,
&resolver,
&caches,
work.path(),
idx,
verifier,
&signed2_phoenix,
hop2.mail_from,
hop2.rcpt_to,
)
.await;
let label =
format!("recipe/{}/{}: phoenix->{}", case.name, tag, verifier.name());
if !ok {
failures.push(format!("{label}: {detail}"));
}
println!("{label}: {}", if ok { "pass" } else { "FAIL" });
}
}
None => failures.push(format!(
"recipe/{}/{}: phoenix recipe-sign failed",
case.name, tag
)),
}
}
assert!(
failures.is_empty(),
"DKIM2 recipe interop had {} failure(s):\n{}",
failures.len(),
failures.join("\n")
);
}