Files
BARANTMail/DKIMVerifier.cpp
Brad Arant cd5e0a9905 Add real IMAP4rev1/POP3 implementations, DKIM/DMARC/SPF, SpamAssassin delivery hook
IMAP (RFC 3501): LOGIN through UID, previously all stubs except
CAPABILITY/LOGOUT/a partial APPEND. Multi-folder support with per-folder
UID/flag indexing (new MailFolder class, backed by .uidvalidity/.uidnext/
.imapindex sidecar files), ENVELOPE support (new MessageHeaders class:
header parsing, address-list parsing, IMAP quoting), and shared
cursor-based argument parsing (new IMAPArgs class: sequence sets, flag
lists, dates, folder-name normalization, and path-traversal protection
via isValidFolderName - a bare ".." folder name would otherwise resolve
into a sibling user's mailbox directory).

POP3 (RFC 1939): USER through QUIT, previously only CAPA. Added UIDL.
POP3 handlers use cursor-based parsing throughout rather than indexed
access - ZString's copy constructor doesn't copy its `list` field, and
POP3's dispatch passes the request by value, so indexed access into a
copy was silently reading nothing.

SMTP: DMARC evaluation added to the DATA-terminator path (DKIM
verification + alignment/policy, Authentication-Results header,
conditional rejection matching RFC 7489 6.3's p=reject + sampled pct=).
AUTH LOGIN now handles RFC 4954's optional inline initial response
(e.g. Python smtplib's "AUTH LOGIN <base64>" in one line) instead of
only the exact three-line form. Outbound relay now forces IPv4
(AF_INET) instead of AF_UNSPEC - this server's IPv4 has a correct PTR
record and SPF coverage, its IPv6 doesn't, and Gmail was rejecting
outbound mail with 550 5.7.25 as a result.

Delivery pipeline: local delivery now runs the message through
SpamAssassin (new SpamAssassinFilter class, speaking spamd's PROCESS
protocol directly over a raw socket - fails open/delivers unscanned if
spamd is unreachable) instead of a plain hardlink, since scanning can
tag/modify content.

BARANTMailConfig: extracted config loading into its own class.

compile: (see individual file changes - build script adjustments to
match the above).

Deliberately excluded from this commit: barantmail.log/
barantmail-service.log (operational logs, not source - barantmail.log
in particular was the runaway 55GB/12.9GB file from a busy-loop bug
fixed separately in ServerCore) and barantmail.cfg (this server's own
live deployment config, matching how APIServer's equivalent
config also stays untracked).
2026-09-03 05:24:22 +00:00

406 lines
16 KiB
C++

#include "DKIMVerifier.h"
#include <arpa/nameser.h>
#include <cctype>
#include <cstring>
#include <openssl/bio.h>
#include <openssl/evp.h>
#include <openssl/x509.h>
#include <resolv.h>
#include <sstream>
namespace mail {
namespace {
struct RawHeader {
std::string name; // original case
std::string rawText; // exact bytes as they appeared, folding included, ending in CRLF
std::string unfoldedValue; // continuation lines joined with a single space, not yet WSP-collapsed
};
std::vector<RawHeader> parseRawHeaders(const std::string &message) {
std::vector<RawHeader> headers;
size_t headerEnd = message.find("\r\n\r\n");
std::string block = (headerEnd == std::string::npos) ? message : message.substr(0, headerEnd);
std::vector<std::string> rawLines;
size_t pos = 0;
while (pos < block.size()) {
size_t next = block.find("\r\n", pos);
std::string line = (next == std::string::npos) ? block.substr(pos) : block.substr(pos, next - pos);
rawLines.push_back(line);
if (next == std::string::npos)
break;
pos = next + 2;
}
for (auto &line : rawLines) {
if (line.empty())
continue;
if ((line[0] == ' ' || line[0] == '\t') && !headers.empty()) {
headers.back().rawText += line + "\r\n";
headers.back().unfoldedValue += " " + line;
continue;
}
size_t colon = line.find(':');
if (colon == std::string::npos)
continue;
RawHeader h;
h.name = line.substr(0, colon);
h.rawText = line + "\r\n";
h.unfoldedValue = line.substr(colon + 1);
headers.push_back(h);
}
return headers;
}
std::string bodyOf(const std::string &message) {
size_t headerEnd = message.find("\r\n\r\n");
if (headerEnd == std::string::npos)
return "";
return message.substr(headerEnd + 4);
}
std::string collapseWSP(const std::string &s) {
std::string result;
bool lastWasSpace = false;
for (char c : s) {
if (c == ' ' || c == '\t') {
if (!lastWasSpace)
result += ' ';
lastWasSpace = true;
} else {
result += c;
lastWasSpace = false;
}
}
size_t start = 0;
while (start < result.size() && result[start] == ' ')
++start;
size_t end = result.size();
while (end > start && result[end - 1] == ' ')
--end;
return result.substr(start, end - start);
}
// RFC 6376 3.4.2 (relaxed) - as in DKIMSigner. Returns rawText
// unmodified for 3.4.1 (simple), which requires the header field
// exactly as it appeared, folding included.
std::string canonicalizeHeaderField(bool simple, const RawHeader &h) {
if (simple)
return h.rawText;
std::string lowerName = h.name;
for (char &c : lowerName)
c = std::tolower((unsigned char)c);
return lowerName + ":" + collapseWSP(h.unfoldedValue) + "\r\n";
}
// For synthesizing the DKIM-Signature field itself (with b=
// emptied) under simple canonicalization, which needs the same
// "exactly as it appeared" rule applied to a header this code
// built rather than one that was actually received on the wire -
// so "exactly as it appeared" here means exactly the tag text,
// with the SAME folding (none) it already has.
std::string canonicalizeSyntheticHeader(bool simple, const std::string &name, const std::string &value) {
if (simple)
return name + ":" + value + "\r\n";
std::string lowerName = name;
for (char &c : lowerName)
c = std::tolower((unsigned char)c);
return lowerName + ":" + collapseWSP(value) + "\r\n";
}
std::string canonicalizeBody(bool simple, const std::string &body) {
std::vector<std::string> lines;
size_t pos = 0;
while (pos < body.size()) {
size_t next = body.find("\r\n", pos);
if (next == std::string::npos) {
lines.push_back(body.substr(pos));
break;
}
lines.push_back(body.substr(pos, next - pos));
pos = next + 2;
}
if (!simple) {
for (auto &line : lines) {
std::string collapsed;
bool lastWasSpace = false;
for (char c : line) {
if (c == ' ' || c == '\t') {
if (!lastWasSpace)
collapsed += ' ';
lastWasSpace = true;
} else {
collapsed += c;
lastWasSpace = false;
}
}
while (!collapsed.empty() && collapsed.back() == ' ')
collapsed.pop_back();
line = collapsed;
}
}
while (!lines.empty() && lines.back().empty())
lines.pop_back();
std::string result;
for (auto &line : lines) {
result += line;
result += "\r\n";
}
if (result.empty())
result = "\r\n";
return result;
}
std::string base64Decode(const std::string &encoded) {
BIO *b64 = BIO_new(BIO_f_base64());
BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL);
BIO *mem = BIO_new_mem_buf(encoded.data(), (int)encoded.size());
BIO_push(b64, mem);
std::string out(encoded.size(), '\0');
int len = BIO_read(b64, out.data(), (int)out.size());
BIO_free_all(b64);
if (len <= 0)
return "";
out.resize(len);
return out;
}
std::string sha1or256Base64(bool useSha1, const std::string &data) {
unsigned char hash[EVP_MAX_MD_SIZE];
unsigned int hashLen = 0;
EVP_Digest(data.data(), data.size(), hash, &hashLen, useSha1 ? EVP_sha1() : EVP_sha256(), nullptr);
BIO *b64 = BIO_new(BIO_f_base64());
BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL);
BIO *mem = BIO_new(BIO_s_mem());
BIO_push(b64, mem);
BIO_write(b64, hash, (int)hashLen);
BIO_flush(b64);
char *encodedData = nullptr;
long encodedLen = BIO_get_mem_data(mem, &encodedData);
std::string result(encodedData, encodedLen);
BIO_free_all(b64);
return result;
}
// Fetches the signer's public key: TXT record at
// <selector>._domainkey.<domain> (RFC 6376 3.6.1/3.6.2.2), tag
// format "v=DKIM1; k=rsa; p=<base64 SubjectPublicKeyInfo>" - v=
// and k= are optional (default to exactly this), only p= is
// actually required. An empty p= is a published, deliberate key
// revocation (6376 3.6.1) and correctly fails to load as a key.
EVP_PKEY *fetchPublicKey(const std::string &selector, const std::string &domain) {
std::string queryName = selector + "._domainkey." + domain;
unsigned char answer[4096];
int len = res_query(queryName.c_str(), C_IN, T_TXT, answer, sizeof(answer));
if (len <= 0)
return nullptr;
ns_msg handle;
if (ns_initparse(answer, len, &handle) != 0)
return nullptr;
std::string record;
int count = ns_msg_count(handle, ns_s_an);
for (int i = 0; i < count && record.empty(); ++i) {
ns_rr rr;
if (ns_parserr(&handle, ns_s_an, i, &rr) != 0 || ns_rr_type(rr) != ns_t_txt)
continue;
const unsigned char *rdata = ns_rr_rdata(rr);
int rdlen = ns_rr_rdlen(rr);
std::string full;
int pos = 0;
while (pos < rdlen) {
int segLen = rdata[pos];
++pos;
if (pos + segLen > rdlen)
break;
full.append((const char *)(rdata + pos), segLen);
pos += segLen;
}
if (full.find("p=") != std::string::npos)
record = full;
}
if (record.empty())
return nullptr;
std::string p;
std::istringstream stream(record);
std::string tag;
while (std::getline(stream, tag, ';')) {
size_t start = tag.find_first_not_of(" \t");
if (start == std::string::npos)
continue;
tag = tag.substr(start);
if (tag.rfind("p=", 0) == 0) {
p = tag.substr(2);
// Public key TXT records are often split across several
// quoted strings by the zone editor for readability -
// res_query()/ns_parserr() already concatenated the RR's
// own multiple character-strings, but stray whitespace
// between them (or trailing junk after the base64) still
// needs stripping before base64 decoding.
std::string cleaned;
for (char c : p)
if (!std::isspace((unsigned char)c))
cleaned += c;
p = cleaned;
}
}
if (p.empty())
return nullptr; // revoked key (6376 3.6.1) or malformed record
std::string der = base64Decode(p);
if (der.empty())
return nullptr;
const unsigned char *derPtr = (const unsigned char *)der.data();
return d2i_PUBKEY(nullptr, &derPtr, (long)der.size());
}
}
std::vector<DKIMVerifier::SignatureResult> DKIMVerifier::verify(const std::string &message) {
std::vector<SignatureResult> results;
std::vector<RawHeader> headers = parseRawHeaders(message);
std::string body = bodyOf(message);
for (size_t sigIndex = 0; sigIndex < headers.size(); ++sigIndex) {
if (headers[sigIndex].name.size() != 14) continue;
bool isDkimSig = true;
{
static const char *want = "DKIM-Signature";
for (size_t i = 0; i < 14 && isDkimSig; ++i)
isDkimSig = std::tolower((unsigned char)headers[sigIndex].name[i]) == std::tolower((unsigned char)want[i]);
}
if (!isDkimSig)
continue;
SignatureResult result;
// Parse this DKIM-Signature's own tags.
std::string tagStr = headers[sigIndex].unfoldedValue;
std::string a = "rsa-sha256", c = "simple/simple", d, s, h, bh, b;
std::istringstream stream(tagStr);
std::string part;
while (std::getline(stream, part, ';')) {
size_t start = part.find_first_not_of(" \t");
if (start == std::string::npos)
continue;
part = part.substr(start);
size_t eq = part.find('=');
if (eq == std::string::npos)
continue;
std::string key = part.substr(0, eq);
std::string value = part.substr(eq + 1);
while (!value.empty() && std::isspace((unsigned char)value.back()))
value.pop_back();
if (key == "a") a = value;
else if (key == "c") c = value;
else if (key == "d") d = value;
else if (key == "s") s = value;
else if (key == "h") h = value;
else if (key == "bh") bh = value;
else if (key == "b") { std::string cleaned; for (char ch : value) if (!std::isspace((unsigned char)ch)) cleaned += ch; b = cleaned; }
}
result.domain = d;
result.selector = s;
if (d.empty() || s.empty() || bh.empty() || b.empty()) {
results.push_back(result); // valid=false: malformed signature
continue;
}
bool simpleHeader = (c.rfind("simple", 0) == 0);
bool simpleBody = (c.find('/') != std::string::npos) ? (c.substr(c.find('/') + 1).rfind("simple", 0) == 0)
: simpleHeader; // "c=simple" alone means simple/simple
bool useSha1 = (a == "rsa-sha1");
std::string computedBH = sha1or256Base64(useSha1, canonicalizeBody(simpleBody, body));
if (computedBH != bh) {
results.push_back(result); // body was altered in transit - valid=false
continue;
}
// Build the signing input the same way DKIMSigner does: every
// header named in h=, in that order, canonicalized per c='s
// header algorithm, followed by this DKIM-Signature field
// itself (with b= emptied) - also canonicalized, and, for
// simple canonicalization specifically, using the ORIGINAL
// header exactly as received up through "b=" (nothing after
// it), not a re-synthesized one, since simple canonicalization
// forbids changing header text at all.
std::string signingInput;
std::istringstream hstream(h);
std::string wanted;
while (std::getline(hstream, wanted, ':')) {
for (auto &hdr : headers) {
if (hdr.name.size() == wanted.size()) {
bool match = true;
for (size_t i = 0; i < wanted.size() && match; ++i)
match = std::tolower((unsigned char)hdr.name[i]) == std::tolower((unsigned char)wanted[i]);
if (match) {
signingInput += canonicalizeHeaderField(simpleHeader, hdr);
break;
}
}
}
}
size_t bPos = tagStr.rfind("b=");
std::string tagsUpToB = tagStr.substr(0, bPos + 2);
std::string dkimFieldCanon;
if (simpleHeader) {
// rawText already reads "DKIM-Signature: v=1; ...; b=..." in
// full (name, colon, and value all together, folding
// preserved) - simple canonicalization requires using that
// exact original text unmodified, just truncated right after
// the literal "b=" so the actual signature value isn't
// included (RFC 6376 3.7).
size_t rawBPos = headers[sigIndex].rawText.find("b=");
dkimFieldCanon = (rawBPos == std::string::npos)
? canonicalizeSyntheticHeader(true, headers[sigIndex].name, tagsUpToB)
: headers[sigIndex].rawText.substr(0, rawBPos + 2);
} else {
dkimFieldCanon = canonicalizeSyntheticHeader(false, headers[sigIndex].name, tagsUpToB);
}
// The CRLF that would terminate this header field MUST NOT be
// included (RFC 6376 3.7) - it doesn't exist yet from the
// signer's perspective. canonicalizeSyntheticHeader's relaxed
// path always appends one; the simple path (a rawText prefix
// ending right at "b=") never had one to begin with.
if (dkimFieldCanon.size() >= 2 && dkimFieldCanon.substr(dkimFieldCanon.size() - 2) == "\r\n")
dkimFieldCanon.erase(dkimFieldCanon.size() - 2);
signingInput += dkimFieldCanon;
EVP_PKEY *pubkey = fetchPublicKey(s, d);
if (!pubkey) {
results.push_back(result); // no key published, or revoked - valid=false
continue;
}
std::string sigBytes = base64Decode(b);
EVP_MD_CTX *mdctx = EVP_MD_CTX_new();
int rc = 0;
if (EVP_DigestVerifyInit(mdctx, nullptr, useSha1 ? EVP_sha1() : EVP_sha256(), nullptr, pubkey) == 1)
rc = EVP_DigestVerify(mdctx, (const unsigned char *)sigBytes.data(), sigBytes.size(),
(const unsigned char *)signingInput.data(), signingInput.size());
EVP_MD_CTX_free(mdctx);
EVP_PKEY_free(pubkey);
result.valid = (rc == 1);
results.push_back(result);
}
return results;
}
}