]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] Add DKIM signing key API for flexible ARC signing
authorVsevolod Stakhov <vsevolod@rspamd.com>
Wed, 15 Oct 2025 15:30:53 +0000 (16:30 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Wed, 15 Oct 2025 15:30:53 +0000 (16:30 +0100)
Implements new C API for DKIM signing operations:
- rspamd_plugins.dkim.load_sign_key() - loads signing key
- rspamd_plugins.dkim.sign_key_get_alg() - detects key algorithm
- rspamd_plugins.dkim.sign_digest() - signs digest with loaded key

Updates ARC module to use new API for proper ed25519 and RSA support.
Adds comprehensive tests and improved signing eligibility diagnostics.

12 files changed:
lualib/lua_dkim_tools.lua
src/libserver/dkim.c
src/libserver/dkim.h
src/plugins/dkim_check.c
src/plugins/lua/arc.lua
test/functional/cases/320_arc_signing/003_roundtrip.robot [new file with mode: 0644]
test/functional/cases/320_arc_signing/004_ed25519.robot [new file with mode: 0644]
test/functional/configs/arc_signing/ed25519.conf [new file with mode: 0644]
test/functional/configs/arc_signing/roundtrip.conf [new file with mode: 0644]
test/functional/configs/dkim_ed25519.key [new file with mode: 0644]
test/functional/configs/plugins.conf
test/functional/messages/dmarc/ed25519_from.eml [new file with mode: 0644]

index fe13c0c5023496526c49e9adca57460a17857899..1942f4c2d8d5003ca68a552b34a305024fd39e8c 100644 (file)
@@ -191,7 +191,12 @@ local function prepare_dkim_signing(N, task, settings)
   elseif settings.sign_inbound and not is_local and not auser then
     lua_util.debugm(N, task, 'mail was sent to us')
   else
-    lua_util.debugm(N, task, 'mail is ineligible for signing')
+    lua_util.debugm(N, task,
+      'mail is ineligible for signing: auser=%s, ip=%s, is_local=%s, ' ..
+      'sign_authenticated=%s, sign_networks=%s, sign_local=%s, sign_inbound=%s',
+      auser, tostring(ip), is_local,
+      settings.sign_authenticated, settings.sign_networks ~= nil,
+      settings.sign_local, settings.sign_inbound)
     return false, {}
   end
 
index 7084fe02adbefdd93962e2668a9e32f6367f6727..acb9d419003cea001cf2ee7282c0281d27f60267 100644 (file)
@@ -3220,6 +3220,87 @@ void rspamd_dkim_sign_key_unref(rspamd_dkim_sign_key_t *k)
        REF_RELEASE(k);
 }
 
+enum rspamd_dkim_key_type
+rspamd_dkim_sign_key_get_type(rspamd_dkim_sign_key_t *key)
+{
+       if (key) {
+               return key->type;
+       }
+       return RSPAMD_DKIM_KEY_UNKNOWN;
+}
+
+gboolean
+rspamd_dkim_sign_digest(rspamd_dkim_sign_key_t *key,
+                                               const unsigned char *digest, gsize dlen,
+                                               char **sig_out, GError **err)
+{
+       unsigned char *sig_buf;
+       unsigned int sig_len;
+
+       if (!key || !digest || dlen != 32 || !sig_out) {
+               g_set_error(err, DKIM_ERROR, DKIM_SIGERROR_INVALID_HC,
+                                       "invalid arguments");
+               return FALSE;
+       }
+
+#ifdef HAVE_OPENSSL
+       if (key->type == RSPAMD_DKIM_KEY_RSA) {
+               sig_len = EVP_PKEY_size(key->specific.key_ssl.key_evp);
+               sig_buf = g_alloca(sig_len);
+               EVP_PKEY_CTX *pctx = EVP_PKEY_CTX_new(key->specific.key_ssl.key_evp, NULL);
+               if (EVP_PKEY_sign_init(pctx) <= 0) {
+                       g_set_error(err, DKIM_ERROR, DKIM_SIGERROR_KEYFAIL,
+                                               "rsa sign error: %s",
+                                               ERR_error_string(ERR_get_error(), NULL));
+                       EVP_PKEY_CTX_free(pctx);
+                       return FALSE;
+               }
+               if (EVP_PKEY_CTX_set_rsa_padding(pctx, RSA_PKCS1_PADDING) <= 0) {
+                       g_set_error(err, DKIM_ERROR, DKIM_SIGERROR_KEYFAIL,
+                                               "rsa sign error: %s",
+                                               ERR_error_string(ERR_get_error(), NULL));
+                       EVP_PKEY_CTX_free(pctx);
+                       return FALSE;
+               }
+               if (EVP_PKEY_CTX_set_signature_md(pctx, EVP_sha256()) <= 0) {
+                       g_set_error(err, DKIM_ERROR, DKIM_SIGERROR_KEYFAIL,
+                                               "rsa sign error: %s",
+                                               ERR_error_string(ERR_get_error(), NULL));
+                       EVP_PKEY_CTX_free(pctx);
+                       return FALSE;
+               }
+               size_t sig_len_size_t = sig_len;
+               if (EVP_PKEY_sign(pctx, sig_buf, &sig_len_size_t, digest, dlen) <= 0) {
+                       g_set_error(err, DKIM_ERROR, DKIM_SIGERROR_KEYFAIL,
+                                               "rsa sign error: %s",
+                                               ERR_error_string(ERR_get_error(), NULL));
+                       EVP_PKEY_CTX_free(pctx);
+                       return FALSE;
+               }
+               sig_len = sig_len_size_t;
+               EVP_PKEY_CTX_free(pctx);
+       }
+       else
+#endif
+#ifdef HAVE_ED25519
+               if (key->type == RSPAMD_DKIM_KEY_EDDSA) {
+               sig_len = crypto_sign_bytes();
+               sig_buf = g_alloca(sig_len);
+               rspamd_cryptobox_sign(sig_buf, NULL, digest, dlen, key->specific.key_eddsa);
+       }
+       else
+#endif
+       {
+               g_set_error(err, DKIM_ERROR, DKIM_SIGERROR_KEYFAIL,
+                                       "unsupported key type");
+               return FALSE;
+       }
+
+       /* Encode signature as base64 */
+       *sig_out = rspamd_encode_base64(sig_buf, sig_len, 0, NULL);
+       return TRUE;
+}
+
 const char *
 rspamd_dkim_get_domain(rspamd_dkim_context_t *ctx)
 {
index 0c3f6edebbfe7b63e2f151cb4be5370c4831a85c..de3ba0ff0bffa286e93e81c9f47d3aea783d9b72 100644 (file)
@@ -224,6 +224,26 @@ rspamd_dkim_sign_key_t *rspamd_dkim_sign_key_ref(rspamd_dkim_sign_key_t *k);
 
 void rspamd_dkim_sign_key_unref(rspamd_dkim_sign_key_t *k);
 
+/**
+ * Get the type of a signing key
+ * @param key signing key
+ * @return key type (RSA, EDDSA, etc)
+ */
+enum rspamd_dkim_key_type rspamd_dkim_sign_key_get_type(rspamd_dkim_sign_key_t *key);
+
+/**
+ * Sign a digest with a DKIM signing key
+ * @param key signing key
+ * @param digest SHA256 digest (32 bytes)
+ * @param dlen digest length (must be 32)
+ * @param sig_out output signature buffer (base64 encoded), must be freed by caller
+ * @param err error pointer
+ * @return TRUE if successful
+ */
+gboolean rspamd_dkim_sign_digest(rspamd_dkim_sign_key_t *key,
+                                                                const unsigned char *digest, gsize dlen,
+                                                                char **sig_out, GError **err);
+
 const char *rspamd_dkim_get_domain(rspamd_dkim_context_t *ctx);
 
 const char *rspamd_dkim_get_selector(rspamd_dkim_context_t *ctx);
index 1fbe8eff3945c54b9a5f4131946cd2b86e978204..bb0af617520e08ad34dcb70ce314ba76e45d796e 100644 (file)
@@ -107,6 +107,30 @@ static void dkim_symbol_callback(struct rspamd_task *task,
 static int lua_dkim_sign_handler(lua_State *L);
 static int lua_dkim_verify_handler(lua_State *L);
 static int lua_dkim_canonicalize_handler(lua_State *L);
+static int lua_dkim_load_sign_key_handler(lua_State *L);
+static int lua_dkim_sign_key_get_alg_handler(lua_State *L);
+static int lua_dkim_sign_digest_handler(lua_State *L);
+
+/* Lua userdata classname for DKIM signing keys */
+#define rspamd_dkim_sign_key_classname "rspamd{dkim_sign_key}"
+
+static rspamd_dkim_sign_key_t *
+lua_check_dkim_sign_key(lua_State *L, int pos)
+{
+       void *ud = rspamd_lua_check_udata(L, pos, rspamd_dkim_sign_key_classname);
+       luaL_argcheck(L, ud != NULL, pos, "'dkim_sign_key' expected");
+       return ud ? *((rspamd_dkim_sign_key_t **) ud) : NULL;
+}
+
+static int
+lua_dkim_sign_key_gc(lua_State *L)
+{
+       rspamd_dkim_sign_key_t *key = lua_check_dkim_sign_key(L, 1);
+       if (key) {
+               rspamd_dkim_sign_key_unref(key);
+       }
+       return 0;
+}
 
 /* Initialization */
 int dkim_module_init(struct rspamd_config *cfg, struct module_ctx **ctx);
@@ -328,10 +352,26 @@ int dkim_module_config(struct rspamd_config *cfg, bool validate)
                lua_pushstring(cfg->lua_state, "canon_header_relaxed");
                lua_pushcfunction(cfg->lua_state, lua_dkim_canonicalize_handler);
                lua_settable(cfg->lua_state, -3);
+               lua_pushstring(cfg->lua_state, "load_sign_key");
+               lua_pushcfunction(cfg->lua_state, lua_dkim_load_sign_key_handler);
+               lua_settable(cfg->lua_state, -3);
+               lua_pushstring(cfg->lua_state, "sign_key_get_alg");
+               lua_pushcfunction(cfg->lua_state, lua_dkim_sign_key_get_alg_handler);
+               lua_settable(cfg->lua_state, -3);
+               lua_pushstring(cfg->lua_state, "sign_digest");
+               lua_pushcfunction(cfg->lua_state, lua_dkim_sign_digest_handler);
+               lua_settable(cfg->lua_state, -3);
                /* Finish dkim key */
                lua_settable(cfg->lua_state, -3);
        }
 
+       /* Register metatable for dkim_sign_key userdata */
+       luaL_Reg dkim_sign_key_mt[] = {
+               {"__gc", lua_dkim_sign_key_gc},
+               {NULL, NULL}};
+       rspamd_lua_new_class(cfg->lua_state, rspamd_dkim_sign_key_classname, dkim_sign_key_mt);
+       lua_pop(cfg->lua_state, 1);
+
        lua_pop(cfg->lua_state, 1); /* Remove global function */
        dkim_module_ctx->whitelist_ip = NULL;
 
@@ -904,6 +944,157 @@ lua_dkim_sign_handler(lua_State *L)
        return 2;
 }
 
+/* Load a DKIM signing key and return it as userdata */
+static int
+lua_dkim_load_sign_key_handler(lua_State *L)
+{
+       struct rspamd_task *task = lua_check_task(L, 1);
+       GError *err = NULL;
+       const char *key = NULL, *rawkey = NULL;
+       rspamd_dkim_sign_key_t *dkim_key;
+       gsize rawlen = 0, keylen = 0;
+       struct dkim_ctx *dkim_module_ctx;
+       rspamd_dkim_sign_key_t **pkey;
+
+       luaL_argcheck(L, lua_type(L, 2) == LUA_TTABLE, 2, "'table' expected");
+
+       if (!rspamd_lua_parse_table_arguments(L, 2, &err,
+                                                                                 RSPAMD_LUA_PARSE_ARGUMENTS_DEFAULT,
+                                                                                 "key=V;rawkey=V",
+                                                                                 &keylen, &key, &rawlen, &rawkey)) {
+               msg_err_task("cannot parse table arguments: %e", err);
+               g_error_free(err);
+               lua_pushnil(L);
+               return 1;
+       }
+
+       dkim_module_ctx = dkim_get_context(task->cfg);
+
+       if (key) {
+               dkim_key = dkim_module_load_key_format(task, dkim_module_ctx, key,
+                                                                                          keylen, RSPAMD_DKIM_KEY_UNKNOWN);
+       }
+       else if (rawkey) {
+               dkim_key = dkim_module_load_key_format(task, dkim_module_ctx, rawkey,
+                                                                                          rawlen, RSPAMD_DKIM_KEY_UNKNOWN);
+       }
+       else {
+               msg_err_task("neither key nor rawkey are specified");
+               lua_pushnil(L);
+               return 1;
+       }
+
+       if (dkim_key == NULL) {
+               lua_pushnil(L);
+               return 1;
+       }
+
+       /* Store key in userdata */
+       pkey = lua_newuserdata(L, sizeof(rspamd_dkim_sign_key_t *));
+       *pkey = rspamd_dkim_sign_key_ref(dkim_key);
+       rspamd_lua_setclass(L, rspamd_dkim_sign_key_classname, -1);
+
+       return 1;
+}
+
+/* Get algorithm name from a loaded signing key */
+static int
+lua_dkim_sign_key_get_alg_handler(lua_State *L)
+{
+       rspamd_dkim_sign_key_t *dkim_key = lua_check_dkim_sign_key(L, 1);
+       enum rspamd_dkim_key_type key_type;
+
+       if (dkim_key == NULL) {
+               return luaL_error(L, "invalid arguments");
+       }
+
+       key_type = rspamd_dkim_sign_key_get_type(dkim_key);
+
+       if (key_type == RSPAMD_DKIM_KEY_RSA) {
+               lua_pushstring(L, "rsa-sha256");
+       }
+       else if (key_type == RSPAMD_DKIM_KEY_EDDSA) {
+               lua_pushstring(L, "ed25519-sha256");
+       }
+       else {
+               lua_pushstring(L, "unknown");
+       }
+
+       return 1;
+}
+
+static int
+lua_dkim_sign_digest_handler(lua_State *L)
+{
+       struct rspamd_task *task = lua_check_task(L, 1);
+       GError *err = NULL;
+       rspamd_dkim_sign_key_t *dkim_key;
+       const char *digest_str = NULL;
+       gsize digest_len = 0;
+       struct rspamd_lua_text *digest_text = NULL;
+       const unsigned char *digest_data;
+       char *sig_b64 = NULL;
+
+       luaL_argcheck(L, lua_type(L, 2) == LUA_TTABLE, 2, "'table' expected");
+
+       /* Get sign_key from table */
+       lua_pushstring(L, "sign_key");
+       lua_gettable(L, 2);
+       dkim_key = lua_check_dkim_sign_key(L, -1);
+       lua_pop(L, 1);
+
+       if (dkim_key == NULL) {
+               msg_err_task("sign_key is not specified or invalid");
+               lua_pushboolean(L, FALSE);
+               return 1;
+       }
+
+       /* Get digest from table - can be string or rspamd_text */
+       lua_pushstring(L, "digest");
+       lua_gettable(L, 2);
+
+       if (lua_type(L, -1) == LUA_TSTRING) {
+               digest_str = lua_tolstring(L, -1, &digest_len);
+               digest_data = (const unsigned char *) digest_str;
+       }
+       else {
+               digest_text = lua_check_text(L, -1);
+               if (digest_text) {
+                       digest_data = (const unsigned char *) digest_text->start;
+                       digest_len = digest_text->len;
+               }
+               else {
+                       lua_pop(L, 1);
+                       msg_err_task("digest is not specified or invalid");
+                       lua_pushboolean(L, FALSE);
+                       return 1;
+               }
+       }
+       lua_pop(L, 1);
+
+       if (digest_len != 32) {
+               msg_err_task("digest must be exactly 32 bytes (SHA256), got %zu", digest_len);
+               lua_pushboolean(L, FALSE);
+               return 1;
+       }
+
+       /* Call C function to sign the digest */
+       if (!rspamd_dkim_sign_digest(dkim_key, digest_data, digest_len,
+                                                                &sig_b64, &err)) {
+               msg_err_task("cannot sign digest: %e", err);
+               g_error_free(err);
+               lua_pushboolean(L, FALSE);
+               return 1;
+       }
+
+       lua_pushboolean(L, TRUE);
+       lua_pushstring(L, sig_b64);
+
+       g_free(sig_b64);
+
+       return 2;
+}
+
 int dkim_module_reconfig(struct rspamd_config *cfg)
 {
        return dkim_module_config(cfg, false);
index b681572adab7ea89d27ee2b0cba75d402c0c14d4..39fb874fd1976d4d036a1d7636769a61b4bf691d 100644 (file)
@@ -21,6 +21,7 @@ local rspamd_util = require "rspamd_util"
 local fun = require "fun"
 local lua_auth_results = require "lua_auth_results"
 local lua_mime = require "lua_mime"
+local hash = require "rspamd_cryptobox_hash"
 
 if confighelp then
   return
@@ -36,6 +37,9 @@ end
 
 local dkim_verify = rspamd_plugins.dkim.verify
 local dkim_sign = rspamd_plugins.dkim.sign
+local dkim_load_sign_key = rspamd_plugins.dkim.load_sign_key
+local dkim_sign_key_get_alg = rspamd_plugins.dkim.sign_key_get_alg
+local dkim_sign_digest = rspamd_plugins.dkim.sign_digest
 local dkim_canonicalize = rspamd_plugins.dkim.canon_header_relaxed
 local redis_params
 
@@ -197,7 +201,31 @@ local function arc_callback(task)
   local arc_seal_headers = task:get_header_full('ARC-Seal')
   local arc_ar_headers = task:get_header_full('ARC-Authentication-Results')
 
+  lua_util.debugm(N, task, 'ARC verification: found %s ARC-MS, %s ARC-Seal, %s ARC-AR headers',
+                  arc_sig_headers and #arc_sig_headers or 0,
+                  arc_seal_headers and #arc_seal_headers or 0,
+                  arc_ar_headers and #arc_ar_headers or 0)
+
+  if arc_sig_headers then
+    for i, hdr in ipairs(arc_sig_headers) do
+      lua_util.debugm(N, task, 'ARC-Message-Signature[%s]: %s', i, hdr.decoded)
+    end
+  end
+
+  if arc_seal_headers then
+    for i, hdr in ipairs(arc_seal_headers) do
+      lua_util.debugm(N, task, 'ARC-Seal[%s]: %s', i, hdr.decoded)
+    end
+  end
+
+  if arc_ar_headers then
+    for i, hdr in ipairs(arc_ar_headers) do
+      lua_util.debugm(N, task, 'ARC-Authentication-Results[%s]: %s', i, hdr.decoded)
+    end
+  end
+
   if not arc_sig_headers or not arc_seal_headers then
+    lua_util.debugm(N, task, 'ARC verification failed: missing required headers')
     task:insert_result(arc_symbols['na'], 1.0)
     return
   end
@@ -553,7 +581,10 @@ local function arc_sign_seal(task, params, header)
     cur_auth_results,
     { stop_chars = ';', structured = true, encode = false })
 
-  -- Temporarily add AAR and AMS headers so they can be signed by the seal
+  -- Add AAR and AMS headers first
+  lua_util.debugm(N, task, 'adding ARC-Authentication-Results: %s', cur_auth_results)
+  lua_util.debugm(N, task, 'adding ARC-Message-Signature: %s', header)
+
   lua_mime.modify_headers(task, {
     add = {
       ['ARC-Authentication-Results'] = { order = 1, value = cur_auth_results },
@@ -561,37 +592,106 @@ local function arc_sign_seal(task, params, header)
     },
   })
 
-  -- Create ARC-Seal using the native C dkim_sign function
-  -- This supports both RSA and ed25519 keys automatically
-  local seal_params = {
-    domain = params.domain,
-    selector = params.selector,
+  -- Create ARC-Seal signature manually using SHA256 hash
+  -- We must canonicalize all ARC headers in order and sign them
+  local sha_ctx = hash.create_specific('sha256')
+
+  -- Canonicalize previous ARC sets if they exist (arc_seals already retrieved above)
+  local arc_sigs = task:cache_get('arc-sigs')
+  local arc_auth_results = task:cache_get('arc-authres')
+
+  if arc_seals then
+    for i = 1, #arc_seals do
+      if arc_auth_results[i] then
+        local s = dkim_canonicalize('ARC-Authentication-Results', arc_auth_results[i].raw_header)
+        sha_ctx:update(s)
+        lua_util.debugm(N, task, 'canonicalized previous AAR[%d]: %s', i, s)
+      end
+      if arc_sigs[i] then
+        local s = dkim_canonicalize('ARC-Message-Signature', arc_sigs[i].raw_header)
+        sha_ctx:update(s)
+        lua_util.debugm(N, task, 'canonicalized previous AMS[%d]: %s', i, s)
+      end
+      if arc_seals[i] then
+        local s = dkim_canonicalize('ARC-Seal', arc_seals[i].raw_header)
+        sha_ctx:update(s)
+        lua_util.debugm(N, task, 'canonicalized previous AS[%d]: %s', i, s)
+      end
+    end
+  end
+
+  -- Canonicalize the new ARC-Authentication-Results header
+  local s = dkim_canonicalize('ARC-Authentication-Results', cur_auth_results)
+  sha_ctx:update(s)
+  lua_util.debugm(N, task, 'canonicalized new AAR: %s', s)
+
+  -- Canonicalize the new ARC-Message-Signature header
+  s = dkim_canonicalize('ARC-Message-Signature', header)
+  sha_ctx:update(s)
+  lua_util.debugm(N, task, 'canonicalized new AMS: %s', s)
+
+  -- Load the signing key
+  local sign_key = dkim_load_sign_key(task, {
     key = params.key,
     rawkey = params.rawkey,
-    sign_type = 'arc-seal',
-    arc_idx = cur_idx,
-    arc_cv = params.arc_cv,
-    headers = 'arc-authentication-results:arc-message-signature' ..
-              string.rep(':arc-seal', cur_idx - 1), -- Include previous seals
-  }
+  })
+
+  if not sign_key then
+    rspamd_logger.errx(task, 'cannot load signing key')
+    return
+  end
+
+  -- Get the algorithm from the key
+  local algorithm = dkim_sign_key_get_alg(sign_key)
+  if not algorithm or algorithm == 'unknown' then
+    rspamd_logger.errx(task, 'cannot determine key algorithm')
+    return
+  end
+
+  -- Construct partial ARC-Seal header (without signature) using detected algorithm
+  local cur_arc_seal = string.format('i=%d; a=%s; d=%s; s=%s; cv=%s; t=%d; b=',
+    cur_idx, algorithm, params.domain, params.selector, params.arc_cv,
+    math.floor(rspamd_util.get_time()))
+
+  -- Canonicalize the partial ARC-Seal and add to hash
+  local seal_canon = string.format('%s:%s', 'arc-seal', cur_arc_seal)
+  sha_ctx:update(seal_canon)
+  lua_util.debugm(N, task, 'canonicalized partial seal: %s', seal_canon)
+
+  -- Now sign the complete digest
+  local dret, sig_b64 = dkim_sign_digest(task, {
+    sign_key = sign_key,
+    digest = sha_ctx:bin(),
+  })
 
-  local dret, seal_header = dkim_sign(task, seal_params)
-  if not dret then
+  if not dret or not sig_b64 then
     rspamd_logger.errx(task, 'cannot create ARC seal signature')
     return
   end
 
+  -- Get appropriate line ending
+  local nl_type
+  if task:has_flag("milter") then
+    nl_type = "lf"
+  else
+    nl_type = task:get_newlines_type()
+  end
+
+  -- Fold the signature to 70 chars per line
+  local folded_sig = rspamd_util.encode_base64(rspamd_util.decode_base64(sig_b64), 70, nl_type)
+  cur_arc_seal = cur_arc_seal .. folded_sig
+
+  lua_util.debugm(N, task, 'adding ARC-Seal: %s', cur_arc_seal)
+
   lua_mime.modify_headers(task, {
     add = {
       ['ARC-Seal'] = {
         order = 1,
         value = lua_util.fold_header_with_encoding(task,
-          'ARC-Seal', seal_header,
+          'ARC-Seal', cur_arc_seal,
           { structured = true, encode = false })
       }
     },
-    -- RFC requires a strict order for these headers to be inserted
-    order = { 'ARC-Authentication-Results', 'ARC-Message-Signature', 'ARC-Seal' },
   })
   task:insert_result(settings.sign_symbol, 1.0,
     string.format('%s:s=%s:i=%d', params.domain, params.selector, cur_idx))
diff --git a/test/functional/cases/320_arc_signing/003_roundtrip.robot b/test/functional/cases/320_arc_signing/003_roundtrip.robot
new file mode 100644 (file)
index 0000000..cd8472a
--- /dev/null
@@ -0,0 +1,114 @@
+*** Settings ***
+Suite Setup     Rspamd Setup
+Suite Teardown  Rspamd Teardown
+Library         ${RSPAMD_TESTDIR}/lib/rspamd.py
+Library         String
+Resource        ${RSPAMD_TESTDIR}/lib/rspamd.robot
+Variables       ${RSPAMD_TESTDIR}/lib/vars.py
+
+*** Variables ***
+${CONFIG}           ${RSPAMD_TESTDIR}/configs/arc_signing/roundtrip.conf
+${MESSAGE_RSA}      ${RSPAMD_TESTDIR}/messages/dmarc/fail_none.eml
+${MESSAGE_ED25519}  ${RSPAMD_TESTDIR}/messages/dmarc/ed25519_from.eml
+${REDIS_SCOPE}      Suite
+${RSPAMD_SCOPE}     Suite
+${RSPAMD_URL_TLD}   ${RSPAMD_TESTDIR}/../lua/unit/test_tld.dat
+
+*** Test Cases ***
+ARC ROUNDTRIP RSA SIGN AND VERIFY
+  # First pass: Sign the message with RSA key and check for ARC_SIGNED
+  ${result} =  Scan Message With Rspamc  ${MESSAGE_RSA}  -u  bob@cacophony.za.org  --mime
+  Should Contain  ${result.stdout}  ARC_SIGNED
+
+  # Write signed message to robot-save directory for debugging
+  ${signed_file} =  Write Mime Message To File  ${result}  rsa_signed.eml
+
+  # Second pass: Verify the ARC signature we just created
+  ${verify_result} =  Scan Message With Rspamc  ${signed_file}  --header=Settings-Id:arc_verify
+  Should Contain  ${verify_result.stdout}  ARC_ALLOW
+  Should Not Contain  ${verify_result.stdout}  ARC_INVALID
+  Should Not Contain  ${verify_result.stdout}  ARC_REJECT
+
+  # Cleanup
+  Remove File  ${signed_file}
+
+ARC ROUNDTRIP ED25519 SIGN AND VERIFY
+  # First pass: Sign the message with ed25519 key and check for ARC_SIGNED
+  ${result} =  Scan Message With Rspamc  ${MESSAGE_ED25519}  -u  bob@ed25519.za.org  --mime
+  Should Contain  ${result.stdout}  ARC_SIGNED
+
+  # Write signed message to robot-save directory for debugging
+  ${signed_file} =  Write Mime Message To File  ${result}  ed25519_signed.eml
+
+  # Second pass: Verify the ARC signature we just created
+  ${verify_result} =  Scan Message With Rspamc  ${signed_file}  --header=Settings-Id:arc_verify
+  Should Contain  ${verify_result.stdout}  ARC_ALLOW
+  Should Not Contain  ${verify_result.stdout}  ARC_INVALID
+  Should Not Contain  ${verify_result.stdout}  ARC_REJECT
+
+  # Cleanup
+  Remove File  ${signed_file}
+
+ARC ED25519 ALGORITHM CHECK
+  # Sign with ed25519 and verify the algorithm is correctly set
+  ${result} =  Scan Message With Rspamc  ${MESSAGE_ED25519}  -u  bob@ed25519.za.org  --mime
+  Should Contain  ${result.stdout}  ARC_SIGNED
+  Should Contain  ${result.stdout}  a=ed25519-sha256
+  Should Not Contain  ${result.stdout}  a=rsa-sha256
+
+ARC RSA ALGORITHM CHECK
+  # Sign with RSA and verify the algorithm is correctly set
+  ${result} =  Scan Message With Rspamc  ${MESSAGE_RSA}  -u  bob@cacophony.za.org  --mime
+  Should Contain  ${result.stdout}  ARC_SIGNED
+  Should Contain  ${result.stdout}  a=rsa-sha256
+  Should Not Contain  ${result.stdout}  a=ed25519-sha256
+
+*** Keywords ***
+Write Mime Message To File
+  [Arguments]  ${mime_result}  ${filename}
+  # Save to robot-save directory for artifact preservation
+  ${artifact_file} =  Set Variable  ${EXECDIR}/robot-save/${filename}
+  Create File  ${artifact_file}  ${mime_result.stdout}
+  Log  Saved signed message to ${artifact_file}
+
+  # Also save to temp directory for test use
+  ${temp_file} =  Set Variable  ${RSPAMD_TMPDIR}/${filename}
+  Create File  ${temp_file}  ${mime_result.stdout}
+
+  # Count ARC headers for debugging
+  ${lines} =  Split To Lines  ${mime_result.stdout}
+  ${arc_count} =  Set Variable  ${0}
+  FOR  ${line}  IN  @{lines}
+    ${is_arc_header} =  Run Keyword And Return Status  Should Match Regexp  ${line}  ^ARC-.*:
+    IF  ${is_arc_header}
+      ${arc_count} =  Evaluate  ${arc_count} + 1
+    END
+  END
+  Log  Total ARC headers found in mime output: ${arc_count}
+
+  # Log first few lines of the created file for debugging
+  ${lines} =  Split To Lines  ${mime_result.stdout}
+  ${first_10_lines} =  Get Slice From List  ${lines}  0  10
+  Log  First 10 lines of signed message file:
+  FOR  ${line}  IN  @{first_10_lines}
+    Log  ${line}
+  END
+
+  # Log any ARC headers found
+  FOR  ${line}  IN  @{lines}
+    ${is_arc_header} =  Run Keyword And Return Status  Should Match Regexp  ${line}  ^ARC-.*:
+    IF  ${is_arc_header}
+      Log  Found ARC header: ${line}
+    END
+  END
+
+  [Return]  ${temp_file}
+
+Write Signed Message To File
+  [Arguments]  ${scan_result}  ${filename}
+  # DEPRECATED: This method doesn't work with -p flag
+  # Use Write Mime Message To File instead
+  Log  WARNING: Write Signed Message To File is deprecated, use Write Mime Message To File
+  ${temp_file} =  Set Variable  ${RSPAMD_TMPDIR}/${filename}
+  Create File  ${temp_file}  ${scan_result.stdout}
+  [Return]  ${temp_file}
diff --git a/test/functional/cases/320_arc_signing/004_ed25519.robot b/test/functional/cases/320_arc_signing/004_ed25519.robot
new file mode 100644 (file)
index 0000000..1d04b95
--- /dev/null
@@ -0,0 +1,25 @@
+*** Settings ***
+Suite Setup     Rspamd Setup
+Suite Teardown  Rspamd Teardown
+Library         ${RSPAMD_TESTDIR}/lib/rspamd.py
+Resource        ${RSPAMD_TESTDIR}/lib/rspamd.robot
+Variables       ${RSPAMD_TESTDIR}/lib/vars.py
+
+*** Variables ***
+${CONFIG}           ${RSPAMD_TESTDIR}/configs/arc_signing/ed25519.conf
+${MESSAGE_RSA}      ${RSPAMD_TESTDIR}/messages/dmarc/fail_none.eml
+${MESSAGE_ED25519}  ${RSPAMD_TESTDIR}/messages/dmarc/ed25519_from.eml
+${REDIS_SCOPE}      Suite
+${RSPAMD_SCOPE}     Suite
+${RSPAMD_URL_TLD}   ${RSPAMD_TESTDIR}/../lua/unit/test_tld.dat
+
+*** Test Cases ***
+ARC ED25519 BASIC SIGNING
+  ${result} =  Scan Message With Rspamc  ${MESSAGE_ED25519}  -u  bob@ed25519.za.org  --mime
+  Should Contain  ${result.stdout}  ARC_SIGNED
+  Should Contain  ${result.stdout}  a=ed25519-sha256
+
+ARC RSA BASIC SIGNING
+  ${result} =  Scan Message With Rspamc  ${MESSAGE_RSA}  -u  bob@cacophony.za.org  --mime
+  Should Contain  ${result.stdout}  ARC_SIGNED
+  Should Contain  ${result.stdout}  a=rsa-sha256
diff --git a/test/functional/configs/arc_signing/ed25519.conf b/test/functional/configs/arc_signing/ed25519.conf
new file mode 100644 (file)
index 0000000..44e5458
--- /dev/null
@@ -0,0 +1,25 @@
+.include(duplicate=append,priority=0) "{= env.TESTDIR =}/configs/plugins.conf"
+
+# ARC signing configuration with both RSA and ed25519 keys
+arc {
+  sign_authenticated = true;
+  use_http_headers = true;
+  allow_headers_fallback = true;
+
+  domain {
+    # RSA key for cacophony.za.org
+    cacophony.za.org {
+      path = "{= env.TESTDIR =}/configs/dkim.key";
+      selector = "dkim";
+    }
+    # ed25519 key for ed25519.za.org
+    ed25519.za.org {
+      path = "{= env.TESTDIR =}/configs/dkim_ed25519.key";
+      selector = "dkim";
+    }
+  }
+
+  check_pubkey = false;  # Disable pubkey checking for tests
+  allow_pubkey_mismatch = true;
+  allow_username_mismatch = true;  # Allow user domain to differ from signing domain
+}
diff --git a/test/functional/configs/arc_signing/roundtrip.conf b/test/functional/configs/arc_signing/roundtrip.conf
new file mode 100644 (file)
index 0000000..06efd2f
--- /dev/null
@@ -0,0 +1,36 @@
+.include(duplicate=append,priority=0) "{= env.TESTDIR =}/configs/plugins.conf"
+
+# ARC signing configuration with both RSA and ed25519 keys
+arc {
+  sign_authenticated = true;
+  use_http_headers = true;
+  allow_headers_fallback = true;
+
+  domain {
+    # RSA key for cacophony.za.org
+    cacophony.za.org {
+      path = "{= env.TESTDIR =}/configs/dkim.key";
+      selector = "dkim";
+    }
+    # ed25519 key for ed25519.za.org
+    ed25519.za.org {
+      path = "{= env.TESTDIR =}/configs/dkim_ed25519.key";
+      selector = "dkim";
+    }
+  }
+
+  check_pubkey = false;  # Disable pubkey checking for tests
+  allow_pubkey_mismatch = true;
+  allow_username_mismatch = true;  # Allow user domain to differ from signing domain
+}
+
+# Settings for ARC verification phase
+settings {
+  arc_verify {
+    id = "arc_verify";
+    apply {
+      symbols_enabled = ["ARC_CHECK", "ARC_ALLOW", "ARC_REJECT", "ARC_INVALID", "ARC_DNSFAIL", "ARC_NA"];
+      symbols_disabled = ["ARC_SIGNED"];
+    }
+  }
+}
diff --git a/test/functional/configs/dkim_ed25519.key b/test/functional/configs/dkim_ed25519.key
new file mode 100644 (file)
index 0000000..f9ef600
--- /dev/null
@@ -0,0 +1 @@
+eG7PwSiukhUE4CUIfaEBw50VVLcgNRMAq85k4tqKT7StW8cdiXqvj5pSkB6MD0LKPYRsx7FReXnkur8MCiKlyQ==
\ No newline at end of file
index 22190a37c4d438534626a38b7a9789df78ac7561..2e5dee93dc35579c49ad52e388f9ab167a1e9c75 100644 (file)
@@ -28,6 +28,11 @@ options = {
           type = "txt";
           replies = ["v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDXtxBE5IiNRMcq2/lc2zErfdCvDFyQNBnMjbOjBQrPST2k4fdGbtpe5Iu5uS01Met+dAEf94XL8I0hwmYw+n70PP834zfJGi2egwGqrakpaWsCDPvIJZLkxJCJKQRA/zrQ622uEXdvYixVbsEGVw7U4wAGSmT5rU2eU1y63AlOlQIDAQAB"];
         },
+        {
+          name = "dkim._domainkey.ed25519.za.org",
+          type = "txt";
+          replies = ["v=DKIM1; k=ed25519; p=rVvHHYl6r4+aUpAejA9Cyj2EbMexUXl55Lq/DAoipck="];
+        },
         {
           name = "eddsa._domainkey.cacophony.za.org",
           type = "txt";
diff --git a/test/functional/messages/dmarc/ed25519_from.eml b/test/functional/messages/dmarc/ed25519_from.eml
new file mode 100644 (file)
index 0000000..eb21e80
--- /dev/null
@@ -0,0 +1,3 @@
+From: Rspamd <foo@ed25519.za.org>
+
+hello