+// DKIM signature creation
+// https://www.rfc-editor.org/rfc/rfc6376.html
+//
+// Body canonicalization code (C) 2012 by Timothy E. Johansson
+// The rest is written for Citadel and OpenSSL 3.0 (C) 2024 by Art Cancro
+//
+// This program is open source software. Use, duplication, or disclosure is subject to the GNU General Public License v3.
+
+// Make sure we don't accidentally use any deprecated API calls
+#define OPENSSL_NO_DEPRECATED_3_0
+
+#include <stdlib.h>
+#include <unistd.h>
+#include <stdio.h>
+#include <ctype.h>
+#include <string.h>
+#include <time.h>
+#include <assert.h>
+#include <syslog.h>
+#include <openssl/rand.h>
+#include <openssl/rsa.h>
+#include <openssl/engine.h>
+#include <openssl/sha.h>
+#include <openssl/hmac.h>
+#include <openssl/evp.h>
+#include <openssl/bio.h>
+#include <openssl/pem.h>
+#include <openssl/buffer.h>
+#include <openssl/err.h>
+#include <openssl/evp.h>
+#include <libcitadel.h>
+
+// This utility function is used by the body canonicalizer
+char *dkim_rtrim(char *str) {
+ char *end;
+ int len = strlen(str);
+
+ while (*str && len) {
+ end = str + len-1;
+
+ if (*end == ' ' || *end == '\t') {
+ *end = '\0';
+ }
+ else {
+ break;
+ }
+
+ len = strlen(str);
+ }
+
+ return str;
+}
+
+
+// We can use this handy function to wrap our big dkim signature header to a reasonable width
+void dkim_wrap_header_strbuf(StrBuf *header_in) {
+ char *str = (char *)ChrPtr(header_in);
+ int len = StrLength(header_in);
+
+ char *tmp = malloc(len*3+1);
+ if (!tmp) {
+ return;
+ }
+ int tmp_len = 0;
+ int i;
+ int lcount = 0;
+
+ for (i = 0; i < len; ++i) {
+ if (str[i] == ' ' || lcount == 75) {
+ tmp[tmp_len++] = str[i];
+ tmp[tmp_len++] = '\r';
+ tmp[tmp_len++] = '\n';
+ tmp[tmp_len++] = '\t';
+ lcount = 0;
+ }
+ else {
+ tmp[tmp_len++] = str[i];
+ ++lcount;
+ }
+ }
+
+ tmp[tmp_len] = '\0';
+ StrBufPlain(header_in, tmp, tmp_len);
+ free(tmp);
+}
+
+
+// This utility function is used by the body canonicalizer
+char *dkim_rtrim_lines(char *str) {
+ char *end;
+ int len = strlen(str);
+
+ while (*str && len) {
+ end = str + len-1;
+
+ if (*end == '\r' || *end == '\n') {
+ *end = '\0';
+ }
+ else {
+ break;
+ }
+
+ len = strlen(str);
+ }
+
+ return str;
+}
+
+
+// Canonicalize one line of the message body as per the "relaxed" algorithm
+char *dkim_canonicalize_body_line(char *line) {
+ int line_len = 0;
+ int i;
+
+ // Ignores all whitespace at the end of lines. Implementations MUST NOT remove the CRLF at the end of the line.
+ dkim_rtrim(line);
+
+ // Reduces all sequences of whitespace within a line to a single space character.
+ line_len = strlen(line);
+ int new_len = 0;
+
+ for (i = 0; i < line_len; ++i) {
+ if (line[i] == '\t') {
+ line[i] = ' ';
+ }
+
+ if (i > 0) {
+ if (!(line[i-1] == ' ' && line[i] == ' ')) {
+ line[new_len++] = line[i];
+ }
+ }
+ else {
+ line[new_len++] = line[i];
+ }
+ }
+
+ line[new_len] = '\0';
+ return line;
+}
+
+
+// Canonicalize the message body as per the "relaxed" algorithm
+char *dkim_canonicalize_body(char *body) {
+ int i = 0;
+ int offset = 0;
+ int body_len = strlen(body);
+
+ char *new_body = malloc(body_len*2+3);
+ int new_body_len = 0;
+
+ for (i = 0; i < body_len; ++i) {
+ int is_r = 0;
+
+ if (body[i] == '\n') {
+ if (i > 0) {
+ if (body[i-1] == '\r') {
+ i--;
+ is_r = 1;
+ }
+ }
+
+ char *line = malloc(i - offset + 1);
+ memcpy(line, body+offset, i-offset);
+ line[i-offset] = '\0';
+
+ dkim_canonicalize_body_line(line);
+
+ int line_len = strlen(line);
+ memcpy(new_body+new_body_len, line, line_len);
+ memcpy(new_body+new_body_len+line_len, "\r\n", 2);
+ new_body_len += line_len+2;
+
+ if (is_r) {
+ i++;
+ }
+
+ offset = i+1;
+ free(line);
+ }
+ }
+
+ if (offset < body_len) {
+ char *line = malloc(i - offset + 1);
+ memcpy(line, body+offset, i-offset);
+ line[i-offset] = '\0';
+
+ dkim_canonicalize_body_line(line);
+
+ int line_len = strlen(line);
+ memcpy(new_body+new_body_len, line, line_len);
+ memcpy(new_body+new_body_len+line_len, "\r\n", 2);
+ new_body_len += line_len+2;
+
+ free(line);
+ }
+
+ memcpy(new_body+new_body_len, "\0", 1);
+
+ // Ignores all empty lines at the end of the message body. "Empty line" is defined in Section 3.4.3.
+ new_body = dkim_rtrim_lines(new_body);
+
+ // Note that a completely empty or missing body is canonicalized as a
+ // single "CRLF"; that is, the canonicalized length will be 2 octets.
+ new_body_len = strlen(new_body);
+ new_body[new_body_len++] = '\r';
+ new_body[new_body_len++] = '\n';
+ new_body[new_body_len] = '\0';
+
+ return new_body;
+}
+
+
+// First step to canonicalize a block of headers as per the "relaxed" algorithm.
+// Unfold all headers onto single lines.
+void dkim_unfold_headers(StrBuf *unfolded_headers) {
+ char *headers_start = (char *)ChrPtr(unfolded_headers);
+ char *fold;
+
+ while (
+ fold = strstr(headers_start, "\r\n "), // find the first holded header
+ fold = (fold ? fold : strstr(headers_start, "\r\n\t")), // it could be folded with tabs
+ fold != NULL // keep going until there aren't any left
+ ) {
+
+ // Replace CRLF<space> or CRLF<tab> with CRLF
+ StrBufReplaceToken(unfolded_headers, (long)(fold-headers_start), 3, HKEY("\r\n"));
+
+ // And when we've got them all, remove the CRLF as well.
+ if (
+ (strstr(headers_start, "\r\n ") != fold)
+ && (strstr(headers_start, "\r\n\t") != fold)
+ && (!strncmp(fold, HKEY("\r\n")))
+ ) {
+ StrBufReplaceToken(unfolded_headers, (long)(fold-headers_start), 2, HKEY(""));
+ }
+
+ }
+}
+
+
+// Second step to canonicalize a block of headers as per the "relaxed" algorithm.
+// Headers MUST already be unfolded with dkim_unfold_headers()
+void dkim_canonicalize_unfolded_headers(StrBuf *headers) {
+
+ char *cheaders = (char *)ChrPtr(headers);
+ char *ptr = cheaders;
+ while (*ptr) {
+
+ // We are at the beginning of a line. Find the colon separator between field name and value.
+ char *start_of_this_line = ptr;
+ char *colon = strstr(ptr, ":");
+
+ // remove whitespace after the colon
+ while ( (*(colon+1) == ' ') || (*(colon+2) == '\t') ) {
+ StrBufReplaceToken(headers, (long)(colon+1-cheaders), 1, HKEY(""));
+ }
+ char *end_of_this_line = strstr(ptr, "\r\n");
+
+ // replace all multiple whitespace runs with a single space
+ int replaced_something;
+ do {
+ replaced_something = 0;
+ char *double_space = strstr(ptr, " "); // space-space?
+ if (!double_space) {
+ double_space = strstr(ptr, " \t"); // space-tab?
+ }
+ if (!double_space) {
+ double_space = strstr(ptr, "\t "); // tab-space?
+ }
+ if (!double_space) {
+ double_space = strstr(ptr, "\t\t"); // tab-tab?
+ }
+ if (double_space) {
+ StrBufReplaceToken(headers, (long)(double_space-cheaders), 2, HKEY(" "));
+ ++replaced_something;
+ }
+ } while (replaced_something);
+
+ // remove whitespace at the end of the line
+ do {
+ replaced_something = 0;
+ char *trailing_space = strstr(ptr, " \r\n"); // line ends in a space?
+ if (!trailing_space) { // no?
+ trailing_space = strstr(ptr, "\t\r\n"); // how about a tab?
+ }
+ if (trailing_space) {
+ StrBufReplaceToken(headers, (long)(trailing_space-cheaders), 3, HKEY("\r\n"));
+ ++replaced_something;
+ }
+ } while (replaced_something);
+
+ // Convert header field names to all lower case
+ for (char *c = start_of_this_line; c<colon; ++c) {
+ cheaders[c-cheaders] = tolower(cheaders[c-cheaders]);
+ }
+
+ ptr = end_of_this_line + 2; // Advance to the beginning of the next line
+ }
+}
+
+
+// Third step to canonicalize a block of headers as per the "relaxed" algorithm.
+// Reduce the canonicalized header block to only the fields being signed
+void dkim_reduce_canonicalized_headers(StrBuf *headers, char *header_list) {
+
+ char *cheaders = (char *)ChrPtr(headers);
+ char *ptr = cheaders;
+ while (*ptr) {
+
+ // We are at the beginning of a line. Find the colon separator between field name and value.
+ char *start_of_this_line = ptr;
+ char *colon = strstr(ptr, ":");
+ char *end_of_this_line = strstr(ptr, "\r\n");
+
+ char relevant_headers[1024];
+ strncpy(relevant_headers, header_list, sizeof(relevant_headers));
+ char *rest = relevant_headers;
+ char *token = NULL;
+ int keep_this_header = 0;
+
+ while (token = strtok_r(rest, ":", &rest)) {
+ if (!strncmp(start_of_this_line, token, strlen(token))) {
+ keep_this_header = 1;
+ }
+ }
+
+ if (keep_this_header) { // Advance to the beginning of the next line
+ ptr = end_of_this_line + 2;
+ }
+ else {
+ StrBufReplaceToken(headers, (long)(start_of_this_line - cheaders), end_of_this_line-start_of_this_line+2, HKEY(""));
+ }
+ }
+
+}
+
+
+// Make a new header list containing only the headers actually present in the canonicalized header block.
+void dkim_final_header_list(char *header_list, size_t header_list_size, StrBuf *unfolded_headers) {
+ header_list[0] = 0;
+
+ char *cheaders = (char *)ChrPtr(unfolded_headers);
+ char *ptr = cheaders;
+ while (*ptr) {
+
+ // We are at the beginning of a line. Find the colon separator between field name and value.
+ char *start_of_this_line = ptr;
+ char *colon = strstr(ptr, ":");
+ char *end_of_this_line = strstr(ptr, "\r\n");
+
+ if (ptr != cheaders) {
+ strcat(header_list, ":");
+ }
+
+ strncat(header_list, start_of_this_line, (colon-start_of_this_line));
+
+ ptr = end_of_this_line + 2; // Advance to the beginning of the next line
+ }
+}
+
+
+// Supplied with a PEM-encoded PKCS#7 private key, that might also have newlines replaced with underscores, return an EVP_PKEY.
+// Caller is responsible for freeing it.
+EVP_PKEY *dkim_import_key(char *pkey_in) {
+
+ if (!pkey_in) {
+ return(NULL);
+ }
+
+ // Citadel Server stores the private key in PEM-encoded PKCS#7 format, but with all newlines replaced by underscores.
+ // Fix that before we try to decode it.
+ char *pkey_with_newlines = strdup(pkey_in);
+ if (!pkey_with_newlines) {
+ return(NULL);
+ }
+ char *sp;
+ while (sp = strchr(pkey_with_newlines, '_')) {
+ *sp = '\n';
+ }
+
+ // Load the private key into an OpenSSL "BIO" structure
+ BIO *bufio = BIO_new_mem_buf((void *)pkey_with_newlines, strlen(pkey_with_newlines));
+ if (bufio == NULL) {
+ syslog(LOG_ERR, "dkim: BIO_new_mem_buf() failed");
+ free(pkey_with_newlines);
+ return(NULL);
+ }
+
+ // Now import the private key
+ EVP_PKEY *pkey = NULL; // Don't combine this line with the next one. It will barf.
+ pkey = PEM_read_bio_PrivateKey(
+ bufio, // BIO to read the private key from
+ &pkey, // pointer to EVP_PKEY structure
+ NULL, // password callback - can be NULL
+ NULL // parameter passed to callback or password if callback is NULL
+ );
+
+ free(pkey_with_newlines);
+ BIO_free(bufio);
+
+ if (pkey == NULL) {
+ syslog(LOG_ERR, "dkim: PEM_read_bio_PrivateKey() failed with error 0x%lx", ERR_get_error());
+ }
+
+ return(pkey);
+}
+
+
+// Get the public key from our DKIM signing pair.
+// Returns a string that must be freed by the caller.
+char *dkim_get_public_key(EVP_PKEY *pkey) {
+ char *b64key = NULL;
+ EVP_PKEY_CTX *ctx;
+ ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, NULL);
+ if (ctx) {
+ BIO *bio = NULL;
+ bio = BIO_new(BIO_s_mem());
+ if (bio) {
+ PEM_write_bio_PUBKEY(bio, pkey);
+ b64key = malloc(4096);
+ if (b64key) {
+ size_t readbytes;
+ BIO_read_ex(bio, b64key, 4096, &readbytes);
+ b64key[readbytes] = 0;
+
+ // strip the header
+ if (!strncasecmp(b64key, HKEY("-----BEGIN PUBLIC KEY-----\n"))) {
+ strcpy(b64key, &b64key[27]);
+ }
+
+ // strip the footer
+ char *foot = strstr(b64key, "\n-----END PUBLIC KEY-----");
+ if (foot) {
+ *foot = 0;
+ }
+
+ // remove newlines
+ char *nl;
+ while (nl = strchr(b64key, '\n')) {
+ strcpy(nl, nl+1);
+ }
+ }
+ BIO_free(bio);
+ }
+ EVP_PKEY_CTX_free(ctx);
+ }
+ return(b64key);
+}
+
+// DKIM-sign an email, supplied as a full RFC2822-compliant message stored in a StrBuf
+void dkim_sign(StrBuf *email, char *pkey_in, char *domain, char *selector) {
+ int i = 0;
+
+ if (!email) { // no message was supplied
+ return;
+ }
+
+ // Import the private key
+ EVP_PKEY *pkey = dkim_import_key(pkey_in);
+ if (pkey == NULL) {
+ return;
+ }
+
+ // find the break between headers and body
+ size_t msglen = StrLength(email); // total length of message (headers + body)
+
+ char *body_ptr = strstr(ChrPtr(email), "\r\n\r\n");
+ if (body_ptr == NULL) {
+ syslog(LOG_ERR, "dkim: this message cannot be signed because it has no body");
+ return;
+ }
+
+ size_t body_offset = body_ptr - ChrPtr(email); // offset at which message body begins
+ StrBuf *header_block = NewStrBufPlain(ChrPtr(email), body_offset+2); // headers only (the +2 makes it include final CRLF)
+
+ // This removes the headers from the supplied email buffer. We MUST put them back in later.
+ StrBufCutLeft(email, body_offset+4); // The +4 makes it NOT include the CRLFCRLF
+
+ // Apply the "relaxed" canonicalization to the message body
+ char *relaxed_body = dkim_canonicalize_body((char *)ChrPtr(email));
+ int relaxed_body_len = strlen(relaxed_body);
+
+ // hash of the canonicalized body
+ unsigned char *body_hash = malloc(SHA256_DIGEST_LENGTH);
+ SHA256((unsigned char *)relaxed_body, relaxed_body_len, body_hash);
+ free(relaxed_body); // all we need now is the hash
+ relaxed_body = NULL;
+
+ // base64 encode the body hash
+ char *encoded_body_hash = malloc(SHA256_DIGEST_LENGTH * 2);
+ CtdlEncodeBase64(encoded_body_hash, body_hash, SHA256_DIGEST_LENGTH, BASE64_NO_LINEBREAKS);
+ free(body_hash); // all we need now is the encoded hash
+
+ // "relaxed" header canonicalization, step 1 : unfold the headers
+ StrBuf *unfolded_headers = NewStrBufDup(header_block);
+ dkim_unfold_headers(unfolded_headers);
+
+ // "relaxed" header canonicalization, step 2 : lowercase the header names, remove whitespace after the colon
+ dkim_canonicalize_unfolded_headers(unfolded_headers);
+
+ // "relaxed" header canonicalization, step 3 : reduce the canonicalized header block to only the fields being signed
+ char *header_list = "from:to:cc:reply-to:subject:date:list-unsubscribe:list-unsubscribe-post";
+ dkim_reduce_canonicalized_headers(unfolded_headers, header_list);
+
+ // Make a new header list containing only the ones we actually have.
+ char final_header_list[1024];
+ dkim_final_header_list(final_header_list, sizeof(final_header_list), unfolded_headers);
+
+ // create DKIM header
+ StrBuf *dkim_header = NewStrBuf();
+ StrBufPrintf(dkim_header,
+ "v=1; a=rsa-sha256; s=%s; d=%s; l=%d; t=%d; c=relaxed/relaxed; h=%s; bh=%s; b=",
+ selector,
+ domain,
+ relaxed_body_len,
+ time(NULL),
+ final_header_list,
+ encoded_body_hash
+ );
+ free(encoded_body_hash); // Hash is stored in the header now.
+
+ // Add the initial DKIM header (which is still missing the value after "b=") to the headers to be signed.
+ // RFC6376 3.7 tells us NOT to include CRLF after "b="
+ StrBufAppendBufPlain(unfolded_headers, HKEY("dkim-signature:"), 0);
+ StrBufAppendBuf(unfolded_headers, dkim_header, 0);
+
+ // Compute a hash of the canonicalized headers, and then sign that hash with our private key.
+ // RFC6376 says that we hash and sign everything up to the "b=" and then we'll add the rest at the end.
+
+ // The hashing/signing library calls are documented at https://wiki.openssl.org/index.php/EVP_Signing_and_Verifying
+ // NOTE: EVP_DigestSign*() functions are supplied with the actual data to be hashed and signed.
+ // That means we don't hash it first, otherwise we would be signing double-hashed (and therefore wrong) data.
+
+ // Create the Message Digest Context
+ EVP_MD_CTX *mdctx = EVP_MD_CTX_new();
+ if (mdctx == NULL) {
+ syslog(LOG_ERR, "dkim: EVP_MD_CTX_create() failed with error 0x%lx", ERR_get_error());
+ abort();
+ }
+
+ // Initialize the DigestSign operation using SHA-256 algorithm
+ if (EVP_DigestSignInit(mdctx, NULL, EVP_sha256(), NULL, pkey) != 1) {
+ syslog(LOG_ERR, "dkim: EVP_DigestSignInit() failed with error 0x%lx", ERR_get_error());
+ abort();
+ }
+
+ // Call update with the "message" (the canonicalized headers)
+ if (EVP_DigestSignUpdate(mdctx, (char *)ChrPtr(unfolded_headers), StrLength(unfolded_headers)) != 1) {
+ syslog(LOG_ERR, "dkim: EVP_DigestSignUpdate() failed with error 0x%lx", ERR_get_error());
+ abort();
+ }
+
+ // Finalize the DigestSign operation.
+ // First call EVP_DigestSignFinal with a NULL sig parameter to obtain the length of the signature.
+ // Length is returned in signature_len
+ size_t signature_len;
+ if (EVP_DigestSignFinal(mdctx, NULL, &signature_len) != 1) {
+ syslog(LOG_ERR, "dkim: EVP_DigestSignFinal() failed");
+ abort();
+ }
+
+ // Sanity check. The signature length should be the same as the size of the private key.
+ assert(signature_len == EVP_PKEY_size(pkey));
+
+ // Allocate memory for the signature based on size in signature_len
+ unsigned char *sig = OPENSSL_malloc(signature_len);
+ if (sig == NULL) {
+ syslog(LOG_ERR, "dkim: OPENSSL_malloc() failed");
+ abort();
+ }
+
+ // Obtain the signature
+ if (EVP_DigestSignFinal(mdctx, sig, &signature_len) != 1) {
+ syslog(LOG_ERR, "dkim: EVP_DigestSignFinal() failed");
+ abort();
+ }
+ EVP_MD_CTX_free(mdctx);
+
+ // This is an optional routine to verify our own signature.
+ // The test program in tests/dkimtester enables it. It is not enabled in Citadel Server.
+#ifdef DKIM_VERIFY_SIGNATURE
+ mdctx = EVP_MD_CTX_new();
+ if (mdctx) {
+ assert(EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, pkey) == 1);
+ assert(EVP_DigestVerifyUpdate(mdctx, (char *)ChrPtr(unfolded_headers), StrLength(unfolded_headers)) == 1);
+ assert(EVP_DigestVerifyFinal(mdctx, sig, signature_len) == 1);
+ EVP_MD_CTX_free(mdctx);
+ }
+#endif
+
+ // With the signature complete, we no longer need the private key or the unfolded headers.
+ EVP_PKEY_free(pkey);
+ FreeStrBuf(&unfolded_headers);
+
+ // base64 encode the signature
+ char *encoded_signature = malloc(signature_len * 2);
+ int encoded_signature_len = CtdlEncodeBase64(encoded_signature, sig, signature_len, BASE64_NO_LINEBREAKS);
+ free(sig); // Free the raw signature, keep the b64-encoded one.
+ StrBufAppendPrintf(dkim_header, "%s", encoded_signature); // Make the final header.
+ free(encoded_signature);
+
+ // wrap dkim header to 72-ish columns
+ dkim_wrap_header_strbuf(dkim_header);
+
+ // Now reassemble the message.
+ StrBuf *output_msg = NewStrBuf();
+ StrBufPrintf(output_msg, "DKIM-Signature: %s\r\n", (char *)ChrPtr(dkim_header));
+ StrBufAppendBuf(output_msg, header_block, 0);
+ StrBufAppendBufPlain(output_msg, HKEY("\r\n"), 0);
+ StrBufAppendBuf(output_msg, email, 0);
+
+ // Put the combined message where the caller can find it.
+ FreeStrBuf(&dkim_header);
+ FreeStrBuf(&header_block);
+ SwapBuffers(output_msg, email);
+ FreeStrBuf(&output_msg);
+
+ // And we're done!
+}
+
+