This is an automated email from the ASF dual-hosted git repository.

mridulpathak pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 90c5ae72df Implemented: Add DKIM signing for outgoing SMTP mail 
(OFBIZ-13488)
90c5ae72df is described below

commit 90c5ae72dfef79cae62adbf06e3d65ef1af31ea1
Author: Mridul Pathak <[email protected]>
AuthorDate: Tue Aug 11 11:54:39 2026 +0530

    Implemented: Add DKIM signing for outgoing SMTP mail (OFBIZ-13488)
    
    OFBiz's outgoing mail (EmailServices.sendMail) was never cryptographically 
signed, so receiving mail servers had no way to verify a message actually came 
from the sending domain. This adds DKIM (RFC 6376) signing via 
org.simplejavamail:utils-mail-dkim, hooked in right before the transport's 
sendMessage call. Signing config lives in a new MailDkimConfig entity (domain, 
selector, PKCS#8 PEM private key, enabled flag), encrypted at rest. Signing 
fails open on config/key errors -- a missi [...]
---
 dependencies.gradle                                |   1 +
 framework/common/entitydef/entitymodel.xml         |  21 ++
 framework/common/servicedef/services_email.xml     |   8 +
 .../apache/ofbiz/common/email/EmailServices.java   | 122 ++++++++-
 .../ofbiz/common/email/EmailServicesDkimTests.java | 305 +++++++++++++++++++++
 gradle/libs.versions.toml                          |   2 +
 6 files changed, 458 insertions(+), 1 deletion(-)

diff --git a/dependencies.gradle b/dependencies.gradle
index 4d9a331faf..30586c3e40 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -29,6 +29,7 @@ dependencies {
     implementation libs.openpdf
     implementation libs.jakarta.mail.api
     implementation libs.angus.mail
+    implementation libs.utils.mail.dkim
     implementation libs.rome
     implementation libs.xstream
     implementation libs.commons.cli
diff --git a/framework/common/entitydef/entitymodel.xml 
b/framework/common/entitydef/entitymodel.xml
index dfe0993a29..aef8a0d4ad 100644
--- a/framework/common/entitydef/entitymodel.xml
+++ b/framework/common/entitydef/entitymodel.xml
@@ -939,4 +939,25 @@ under the License.
         <field name="oauth2Scope" type="value"></field>
         <prim-key field="mailSmtpConfigId"/>
     </entity>
+    <entity entity-name="MailDkimConfig"
+            package-name="org.apache.ofbiz.common.email"
+            title="Mail DKIM Signing Configuration">
+        <field name="mailDkimConfigId" type="id"></field>
+        <field name="mailSmtpConfigId" type="id">
+            <description>Optional link to a MailSmtpConfig row; unset today, 
ready for per-relay DKIM
+            once multi-SMTP-config support exists</description>
+        </field>
+        <field name="domain" type="value"><description>Signing domain, e.g. 
example.com</description></field>
+        <field name="selector" type="value"><description>DKIM selector, e.g. 
ofbiz</description></field>
+        <field name="privateKey" type="very-long" encrypt="true">
+            <description>PKCS#8 PEM RSA private key</description>
+        </field>
+        <field name="enabled" type="indicator">
+            <description>Y signs outgoing mail; N/unset leaves mail unsigned 
even if key material is present</description>
+        </field>
+        <prim-key field="mailDkimConfigId"/>
+        <relation type="one" fk-name="MAIL_DKIM_SMTP" 
rel-entity-name="MailSmtpConfig">
+            <key-map field-name="mailSmtpConfigId"/>
+        </relation>
+    </entity>
 </entitymodel>
diff --git a/framework/common/servicedef/services_email.xml 
b/framework/common/servicedef/services_email.xml
index a286320402..294f46afab 100644
--- a/framework/common/servicedef/services_email.xml
+++ b/framework/common/servicedef/services_email.xml
@@ -187,4 +187,12 @@ under the License.
         <description>Delete a EmailTemplateSetting record</description>
         <auto-attributes include="pk" mode="IN"/>
     </service>
+    <service name="getDkimDnsRecord" engine="java" auth="true"
+            location="org.apache.ofbiz.common.email.EmailServices" 
invoke="getDkimDnsRecord">
+        <description>Derives the DNS TXT record (name and value) to publish 
for a MailDkimConfig's
+            signing key, so admins can verify/copy it without hand-computing 
the DKIM public key.</description>
+        <attribute name="mailDkimConfigId" type="String" mode="IN" 
optional="false"/>
+        <attribute name="recordName" type="String" mode="OUT" 
optional="false"/>
+        <attribute name="recordValue" type="String" mode="OUT" 
optional="false"/>
+    </service>
 </services>
diff --git 
a/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
 
b/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
index 940d133141..1de5b4237c 100644
--- 
a/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
+++ 
b/framework/common/src/main/java/org/apache/ofbiz/common/email/EmailServices.java
@@ -30,6 +30,15 @@ import java.net.MalformedURLException;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.URL;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.interfaces.RSAPrivateCrtKey;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.RSAPublicKeySpec;
+import java.util.Base64;
 import java.util.Date;
 import java.util.LinkedHashMap;
 import java.util.LinkedList;
@@ -65,7 +74,9 @@ import org.apache.ofbiz.base.util.UtilValidate;
 import org.apache.ofbiz.base.util.collections.MapStack;
 import org.apache.ofbiz.base.util.string.FlexibleStringExpander;
 import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
 import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.entity.util.EntityQuery;
 import org.apache.ofbiz.entity.util.EntityUtilProperties;
 import org.apache.ofbiz.service.DispatchContext;
 import org.apache.ofbiz.service.GenericServiceException;
@@ -78,6 +89,11 @@ import org.apache.ofbiz.widget.renderer.ScreenRenderer;
 import org.apache.ofbiz.widget.renderer.ScreenStringRenderer;
 import org.apache.ofbiz.widget.renderer.VisualTheme;
 import org.apache.ofbiz.widget.renderer.macro.MacroScreenRenderer;
+import org.simplejavamail.utils.mail.dkim.Canonicalization;
+import org.simplejavamail.utils.mail.dkim.DkimMessage;
+import org.simplejavamail.utils.mail.dkim.DkimSigner;
+import org.simplejavamail.utils.mail.dkim.DkimSigningException;
+import org.simplejavamail.utils.mail.dkim.SigningAlgorithm;
 import org.xml.sax.SAXException;
 
 import org.eclipse.angus.mail.smtp.SMTPAddressFailedException;
@@ -359,10 +375,16 @@ public class EmailServices {
             } else {
                 trans.connect(sendVia, authUser, effectiveAuthPass);
             }
-            trans.sendMessage(mail, mail.getAllRecipients());
+            MimeMessage messageToSend = dkimSign(mail, delegator);
+            trans.sendMessage(messageToSend, messageToSend.getAllRecipients());
             results.put("messageWrapper", new MimeMessageWrapper(session, 
mail));
             results.put("messageId", mail.getMessageID());
             trans.close();
+        } catch (DkimSigningException e) {
+            Debug.logError(e, "DKIM signing failed at write-time for [" + 
sendTo + "] from [" + sendFrom
+                    + "] subject [" + subject + "]; message NOT sent (not an 
SMTP connection problem)", MODULE);
+            return ServiceUtil.returnError(UtilProperties.getMessage(RESOURCE, 
"CommonEmailSendConnectionError", UtilMisc.toMap("sendTo",
+                    sendTo, "sendFrom", sendFrom, "sendCc", sendCc, "sendBcc", 
sendBcc, "subject", subject), locale));
         } catch (SendFailedException e) {
             // message code prefix may be used by calling services to 
determine the cause of the failure
             Debug.logError(e, "[ADDRERR] Address error when sending message to 
[" + sendTo + "] from [" + sendFrom + "] cc [" + sendCc
@@ -738,6 +760,104 @@ public class EmailServices {
         }
     }
 
+    /**
+     * Parses a PKCS#8 PEM RSA private key into an RSAPrivateCrtKey. Always 
throws
+     * GeneralSecurityException (never unchecked), since callers narrow their 
catch to it.
+     */
+    static RSAPrivateCrtKey parsePemRsaPrivateKey(String pem) throws 
GeneralSecurityException {
+        String base64 = pem.replaceAll("-----BEGIN [A-Z ]+-----", "")
+                .replaceAll("-----END [A-Z ]+-----", "")
+                .replaceAll("\\s", "");
+        byte[] der;
+        try {
+            der = Base64.getDecoder().decode(base64);
+        } catch (IllegalArgumentException e) {
+            throw new InvalidKeySpecException("MailDkimConfig privateKey is 
not valid PEM/base64: " + e.getMessage());
+        }
+        PrivateKey key = KeyFactory.getInstance("RSA").generatePrivate(new 
PKCS8EncodedKeySpec(der));
+        if (!(key instanceof RSAPrivateCrtKey)) {
+            throw new InvalidKeySpecException("MailDkimConfig privateKey is 
not a PKCS#8 RSA private key");
+        }
+        return (RSAPrivateCrtKey) key;
+    }
+
+    /** Signs mail with DKIM if MailDkimConfig is enabled and complete; 
otherwise returns it unchanged. Fails open on any error. */
+    static MimeMessage dkimSign(MimeMessage mail, Delegator delegator) {
+        GenericValue config;
+        try {
+            config = 
EntityQuery.use(delegator).from("MailDkimConfig").cache(true)
+                    .orderBy("mailDkimConfigId").queryFirst();
+        } catch (GenericEntityException e) {
+            Debug.logWarning(e, "Error loading MailDkimConfig; sending 
unsigned", MODULE);
+            return mail;
+        }
+        if (config == null || !"Y".equals(config.getString("enabled"))) {
+            return mail;
+        }
+        String domain = config.getString("domain");
+        String selector = config.getString("selector");
+        String privateKeyPem = config.getString("privateKey");
+        if (UtilValidate.isEmpty(domain) || UtilValidate.isEmpty(selector) || 
UtilValidate.isEmpty(privateKeyPem)) {
+            Debug.logError("MailDkimConfig [" + 
config.getString("mailDkimConfigId")
+                    + "] is enabled but missing domain/selector/privateKey; 
sending unsigned", MODULE);
+            return mail;
+        }
+        try {
+            RSAPrivateCrtKey privateKey = parsePemRsaPrivateKey(privateKeyPem);
+            DkimSigner signer = new DkimSigner(domain, selector, privateKey);
+            signer.setHeaderCanonicalization(Canonicalization.RELAXED);
+            signer.setBodyCanonicalization(Canonicalization.RELAXED);
+            signer.setSigningAlgorithm(SigningAlgorithm.SHA256_WITH_RSA);
+            // l= tag omitted (library default): would let an attacker append 
unsigned content after
+            // the signed body. checkDomainKey disabled: avoids a live DNS 
lookup on every send --
+            // that's the receiver's job, not ours; see getDkimDnsRecord for 
setup-time verification.
+            signer.setCheckDomainKey(false);
+            return new DkimMessage(mail, signer);
+        } catch (Exception e) {
+            Debug.logError(e, "DKIM signing failed; sending unsigned", MODULE);
+            return mail;
+        }
+    }
+
+    /** Derives the "v=DKIM1; k=rsa; p=..." TXT record value from an RSA 
private key's CRT parameters. */
+    static String derivePublicKeyRecordValue(RSAPrivateCrtKey privateKey) 
throws GeneralSecurityException {
+        RSAPublicKeySpec publicSpec = new 
RSAPublicKeySpec(privateKey.getModulus(), privateKey.getPublicExponent());
+        PublicKey publicKey = 
KeyFactory.getInstance("RSA").generatePublic(publicSpec);
+        return "v=DKIM1; k=rsa; p=" + 
Base64.getEncoder().encodeToString(publicKey.getEncoded());
+    }
+
+    /** Derives the DNS TXT record an admin needs to publish for a 
MailDkimConfig's signing key. */
+    public static Map<String, Object> getDkimDnsRecord(DispatchContext ctx, 
Map<String, ?> context) {
+        Delegator delegator = ctx.getDelegator();
+        String mailDkimConfigId = (String) context.get("mailDkimConfigId");
+        GenericValue config;
+        try {
+            config = 
EntityQuery.use(delegator).from("MailDkimConfig").where("mailDkimConfigId", 
mailDkimConfigId)
+                    .cache(true).queryOne();
+        } catch (GenericEntityException e) {
+            return ServiceUtil.returnError(e.getMessage());
+        }
+        if (config == null) {
+            return ServiceUtil.returnError("No MailDkimConfig found for ID [" 
+ mailDkimConfigId + "]");
+        }
+        String domain = config.getString("domain");
+        String selector = config.getString("selector");
+        String privateKeyPem = config.getString("privateKey");
+        if (UtilValidate.isEmpty(domain) || UtilValidate.isEmpty(selector) || 
UtilValidate.isEmpty(privateKeyPem)) {
+            return ServiceUtil.returnError("MailDkimConfig [" + 
mailDkimConfigId + "] is missing domain/selector/privateKey");
+        }
+        try {
+            RSAPrivateCrtKey privateKey = parsePemRsaPrivateKey(privateKeyPem);
+            Map<String, Object> result = ServiceUtil.returnSuccess();
+            result.put("recordName", selector + "._domainkey." + domain);
+            result.put("recordValue", derivePublicKeyRecordValue(privateKey));
+            return result;
+        } catch (GeneralSecurityException e) {
+            return ServiceUtil.returnError("Could not parse MailDkimConfig [" 
+ mailDkimConfigId
+                    + "] private key: " + e.getMessage());
+        }
+    }
+
     /** class to create a file in memory required for sending as an attachment 
*/
     public static class StringDataSource implements DataSource {
         private String contentType;
diff --git 
a/framework/common/src/test/java/org/apache/ofbiz/common/email/EmailServicesDkimTests.java
 
b/framework/common/src/test/java/org/apache/ofbiz/common/email/EmailServicesDkimTests.java
new file mode 100644
index 0000000000..23eb1c9c93
--- /dev/null
+++ 
b/framework/common/src/test/java/org/apache/ofbiz/common/email/EmailServicesDkimTests.java
@@ -0,0 +1,305 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ 
*******************************************************************************/
+package org.apache.ofbiz.common.email;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
+import java.security.interfaces.RSAPrivateCrtKey;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Properties;
+
+import jakarta.mail.Message;
+import jakarta.mail.Session;
+import jakarta.mail.internet.InternetAddress;
+import jakarta.mail.internet.MimeMessage;
+
+import org.apache.ofbiz.entity.Delegator;
+import org.apache.ofbiz.entity.GenericEntityException;
+import org.apache.ofbiz.entity.GenericValue;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.simplejavamail.utils.mail.dkim.Canonicalization;
+
+public final class EmailServicesDkimTests {
+
+    private static KeyPair testKeyPair;
+    private static String testPrivateKeyPem;
+
+    @BeforeAll
+    public static void generateTestKeyPair() throws Exception {
+        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+        generator.initialize(2048);
+        testKeyPair = generator.generateKeyPair();
+        String base64 = 
Base64.getEncoder().encodeToString(testKeyPair.getPrivate().getEncoded());
+        StringBuilder pem = new StringBuilder("-----BEGIN PRIVATE KEY-----\n");
+        for (int i = 0; i < base64.length(); i += 64) {
+            pem.append(base64, i, Math.min(i + 64, 
base64.length())).append('\n');
+        }
+        pem.append("-----END PRIVATE KEY-----\n");
+        testPrivateKeyPem = pem.toString();
+    }
+
+    @Test
+    public void parsePemRsaPrivateKeyParsesValidPem() throws Exception {
+        RSAPrivateCrtKey parsed = 
EmailServices.parsePemRsaPrivateKey(testPrivateKeyPem);
+        RSAPrivateCrtKey original = (RSAPrivateCrtKey) 
testKeyPair.getPrivate();
+        assertEquals(original.getModulus(), parsed.getModulus());
+    }
+
+    @Test
+    public void parsePemRsaPrivateKeyRejectsNonBase64Garbage() {
+        String badPem = "-----BEGIN PRIVATE KEY-----\nnot valid 
base64!!!\n-----END PRIVATE KEY-----\n";
+        assertThrows(GeneralSecurityException.class, () -> 
EmailServices.parsePemRsaPrivateKey(badPem));
+    }
+
+    @Test
+    public void parsePemRsaPrivateKeyRejectsWellFormedNonKeyBytes() {
+        // Valid base64, but not a key -- must fail as 
GeneralSecurityException, not unchecked.
+        String fakePem = "-----BEGIN PRIVATE KEY-----\n"
+                + Base64.getEncoder().encodeToString("this is definitely not a 
key".getBytes(StandardCharsets.UTF_8))
+                + "\n-----END PRIVATE KEY-----\n";
+        assertThrows(GeneralSecurityException.class, () -> 
EmailServices.parsePemRsaPrivateKey(fakePem));
+    }
+
+    private static Delegator mockDelegatorReturning(GenericValue... rows) 
throws GenericEntityException {
+        Delegator delegator = mock(Delegator.class);
+        when(delegator.getDelegator()).thenReturn(delegator);
+        when(delegator.findList(eq("MailDkimConfig"), any(), any(), any(), 
any(), any(), eq(true)))
+                .thenReturn(Arrays.asList(rows));
+        return delegator;
+    }
+
+    private static MimeMessage buildTestMessage() throws Exception {
+        Session session = Session.getInstance(new Properties());
+        MimeMessage mail = new MimeMessage(session);
+        mail.setFrom(new InternetAddress("[email protected]"));
+        mail.setRecipients(Message.RecipientType.TO, "[email protected]");
+        mail.setSubject("Test Subject");
+        mail.setText("Test body");
+        mail.saveChanges();
+        return mail;
+    }
+
+    @Test
+    public void dkimSignReturnsOriginalWhenNoConfigRow() throws Exception {
+        Delegator delegator = mockDelegatorReturning();
+        MimeMessage mail = buildTestMessage();
+        assertEquals(mail, EmailServices.dkimSign(mail, delegator));
+    }
+
+    @Test
+    public void dkimSignReturnsOriginalWhenNotEnabled() throws Exception {
+        GenericValue config = mock(GenericValue.class);
+        when(config.getString("enabled")).thenReturn("N");
+        Delegator delegator = mockDelegatorReturning(config);
+        MimeMessage mail = buildTestMessage();
+        assertEquals(mail, EmailServices.dkimSign(mail, delegator));
+    }
+
+    @Test
+    public void dkimSignReturnsOriginalWhenIncompleteConfig() throws Exception 
{
+        GenericValue config = mock(GenericValue.class);
+        when(config.getString("enabled")).thenReturn("Y");
+        when(config.getString("domain")).thenReturn("example.com");
+        when(config.getString("selector")).thenReturn("");
+        when(config.getString("privateKey")).thenReturn("");
+        when(config.getString("mailDkimConfigId")).thenReturn("TEST_DKIM_1");
+        Delegator delegator = mockDelegatorReturning(config);
+        MimeMessage mail = buildTestMessage();
+        assertEquals(mail, EmailServices.dkimSign(mail, delegator));
+    }
+
+    @Test
+    public void dkimSignReturnsOriginalWhenPrivateKeyIsGarbage() throws 
Exception {
+        GenericValue config = mock(GenericValue.class);
+        when(config.getString("enabled")).thenReturn("Y");
+        when(config.getString("domain")).thenReturn("example.com");
+        when(config.getString("selector")).thenReturn("ofbiz");
+        when(config.getString("privateKey")).thenReturn("not a real key");
+        when(config.getString("mailDkimConfigId")).thenReturn("TEST_DKIM_1");
+        Delegator delegator = mockDelegatorReturning(config);
+        MimeMessage mail = buildTestMessage();
+        assertEquals(mail, EmailServices.dkimSign(mail, delegator));
+    }
+
+    @Test
+    public void dkimSignWrapsAndSignsWhenFullyConfigured() throws Exception {
+        GenericValue config = mock(GenericValue.class);
+        when(config.getString("enabled")).thenReturn("Y");
+        when(config.getString("domain")).thenReturn("example.com");
+        when(config.getString("selector")).thenReturn("ofbiz");
+        when(config.getString("privateKey")).thenReturn(testPrivateKeyPem);
+        when(config.getString("mailDkimConfigId")).thenReturn("TEST_DKIM_1");
+        Delegator delegator = mockDelegatorReturning(config);
+        MimeMessage mail = buildTestMessage();
+
+        MimeMessage result = EmailServices.dkimSign(mail, delegator);
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        result.writeTo(baos);
+        String raw = baos.toString(StandardCharsets.UTF_8);
+        assertTrue(raw.contains("DKIM-Signature:"), "expected a DKIM-Signature 
header, got:\n" + raw);
+        assertTrue(raw.contains("d=example.com"), "expected d=example.com in 
signature, got:\n" + raw);
+        assertTrue(raw.contains("s=ofbiz"), "expected s=ofbiz in signature, 
got:\n" + raw);
+        assertTrue(raw.contains("a=rsa-sha256"), "expected a=rsa-sha256 in 
signature, got:\n" + raw);
+        assertTrue(raw.contains("c=relaxed/relaxed"), "expected 
c=relaxed/relaxed in signature, got:\n" + raw);
+        // Anchored on the real tag separators (" " or "\r\n\t") rather than a 
bare "l=" substring,
+        // which could spuriously match unrelated future body/subject/header 
content.
+        assertTrue(!raw.contains(" l=") && !raw.contains("\r\n\tl="),
+                "DKIM-Signature must not include an l= tag (setLengthParam 
must stay false), got:\n" + raw);
+    }
+
+    @Test
+    public void dkimSignatureVerifiesAgainstDerivedPublicKey() throws 
Exception {
+        GenericValue config = mock(GenericValue.class);
+        when(config.getString("enabled")).thenReturn("Y");
+        when(config.getString("domain")).thenReturn("example.com");
+        when(config.getString("selector")).thenReturn("ofbiz");
+        when(config.getString("privateKey")).thenReturn(testPrivateKeyPem);
+        when(config.getString("mailDkimConfigId")).thenReturn("TEST_DKIM_1");
+        Delegator delegator = mockDelegatorReturning(config);
+        MimeMessage mail = buildTestMessage();
+
+        MimeMessage result = EmailServices.dkimSign(mail, delegator);
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        result.writeTo(baos);
+        String raw = baos.toString(StandardCharsets.UTF_8);
+
+        int blankLineIndex = raw.indexOf("\r\n\r\n");
+        String headerBlock = raw.substring(0, blankLineIndex);
+        String bodyBlock = raw.substring(blankLineIndex + 4);
+
+        // DkimMessage.writeTo() always writes DKIM-Signature first; capture 
its full (possibly
+        // folded) value up to the first CRLF not followed by continuation 
whitespace.
+        java.util.regex.Matcher m = 
java.util.regex.Pattern.compile("^DKIM-Signature: (.*?)\r\n(?=\\S)", 
java.util.regex.Pattern.DOTALL)
+                .matcher(headerBlock + "\r\n");
+        assertTrue(m.find(), "could not locate DKIM-Signature header in:\n" + 
headerBlock);
+        String dkimSignatureRawValue = m.group(1);
+
+        // Anchor on "\r\n\tb=" -- the exact literal 
DkimSigner.serializeSignature emits before the
+        // signature (DkimSigner.java:623) -- rather than a bare 
lastIndexOf("b=").
+        int bTagIndex = dkimSignatureRawValue.lastIndexOf("\r\n\tb=");
+        assertTrue(bTagIndex >= 0, "could not find the b= tag boundary in:\n" 
+ dkimSignatureRawValue);
+        String preSignaturePortion = dkimSignatureRawValue.substring(0, 
bTagIndex + 5);
+        String signatureBase64 = dkimSignatureRawValue.substring(bTagIndex + 
5).replaceAll("\\s+", "");
+
+        // DkimSigner's default signed-header set (DEFAULT_HEADERS_TO_SIGN), 
case-insensitive like the
+        // library's own TreeSet<>(CASE_INSENSITIVE_ORDER) 
(DkimSigner.java:115) -- jakarta.mail's own
+        // header casing varies ("Message-Id" vs "Message-ID").
+        java.util.Set<String> headersToSign = new 
java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER);
+        headersToSign.addAll(java.util.Arrays.asList(
+                "From", "To", "Subject", "Content-Description", "Content-ID", 
"Content-Type",
+                "Content-Transfer-Encoding", "Cc", "Date", "In-Reply-To", 
"List-Subscribe", "List-Post",
+                "List-Owner", "List-Id", "List-Archive", "List-Help", 
"List-Unsubscribe", "MIME-Version",
+                "Message-ID", "Resent-Sender", "Resent-Cc", "Resent-Date", 
"Resent-To", "Reply-To",
+                "References", "Resent-Message-ID", "Resent-From", "Sender"));
+
+        // Iterate result (the DkimMessage), not mail: DkimMessage.writeTo() 
signs itself
+        // (DkimMessage.java:113), and compileHeadersToSign reverses the 
header order via add(0, ...).
+        java.util.List<jakarta.mail.Header> matchedHeaders = new 
java.util.ArrayList<>();
+        java.util.Enumeration<jakarta.mail.Header> allHeaders = 
result.getAllHeaders();
+        while (allHeaders.hasMoreElements()) {
+            jakarta.mail.Header h = allHeaders.nextElement();
+            if (headersToSign.contains(h.getName())) {
+                matchedHeaders.add(0, h);
+            }
+        }
+
+        // Cross-check against the signature's own h= tag first, so a mismatch 
is a readable diff
+        // rather than an opaque verify() failure.
+        StringBuilder reconstructedHeaderNames = new StringBuilder();
+        for (jakarta.mail.Header h : matchedHeaders) {
+            reconstructedHeaderNames.append(h.getName()).append(":");
+        }
+        String hTag = reconstructedHeaderNames.substring(0, 
reconstructedHeaderNames.length() - 1);
+        assertTrue(preSignaturePortion.contains("h=" + hTag + ";"),
+                "reconstructed signed-header list did not match the 
signature's h= tag -- reconstructed [" + hTag
+                        + "], signature says:\n" + preSignaturePortion);
+
+        StringBuilder signedBytesBuilder = new StringBuilder();
+        for (jakarta.mail.Header h : matchedHeaders) {
+            
signedBytesBuilder.append(Canonicalization.RELAXED.canonicalizeHeader(h.getName(),
 h.getValue())).append("\r\n");
+        }
+        
signedBytesBuilder.append(Canonicalization.RELAXED.canonicalizeHeader("DKIM-Signature",
 preSignaturePortion));
+        byte[] signedBytes = 
signedBytesBuilder.toString().getBytes(StandardCharsets.UTF_8);
+
+        // Sanity check: bh= in the real signature should match our 
independently-canonicalized body.
+        String canonicalBody = 
Canonicalization.RELAXED.canonicalizeBody(bodyBlock);
+        String expectedBodyHash = Base64.getEncoder().encodeToString(
+                
java.security.MessageDigest.getInstance("SHA-256").digest(canonicalBody.getBytes(StandardCharsets.UTF_8)));
+        assertTrue(preSignaturePortion.contains("bh=" + expectedBodyHash),
+                "independently-canonicalized body hash did not match the 
signature's bh= tag -- got preSignaturePortion:\n"
+                        + preSignaturePortion + "\nexpected bh=" + 
expectedBodyHash);
+
+        byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
+
+        // Verify against the SAME key used to sign (sanity check on our own 
reconstruction).
+        java.security.Signature verifier = 
java.security.Signature.getInstance("SHA256withRSA");
+        verifier.initVerify(testKeyPair.getPublic());
+        verifier.update(signedBytes);
+        assertTrue(verifier.verify(signatureBytes),
+                "reconstructed signed bytes did not verify against the 
original test key -- our canonicalization "
+                        + "reconstruction is wrong somewhere, not a real 
signing defect. Report this back rather than "
+                        + "adjusting the assertion.");
+
+        // The point of this test: verify against getDkimDnsRecord's published 
key too, proving the
+        // two features agree.
+        String recordValue = 
EmailServices.derivePublicKeyRecordValue((RSAPrivateCrtKey) 
testKeyPair.getPrivate());
+        String base64PublicKeyFromRecord = recordValue.substring("v=DKIM1; 
k=rsa; p=".length());
+        java.security.PublicKey publicKeyFromRecord = 
KeyFactory.getInstance("RSA")
+                .generatePublic(new 
java.security.spec.X509EncodedKeySpec(Base64.getDecoder().decode(base64PublicKeyFromRecord)));
+        java.security.Signature verifier2 = 
java.security.Signature.getInstance("SHA256withRSA");
+        verifier2.initVerify(publicKeyFromRecord);
+        verifier2.update(signedBytes);
+        assertTrue(verifier2.verify(signatureBytes),
+                "DKIM signature did not verify against the public key 
getDkimDnsRecord would publish for the same "
+                        + "MailDkimConfig -- this would mean the signing 
feature and the DNS-record helper feature "
+                        + "are inconsistent with each other.");
+    }
+
+    @Test
+    public void derivePublicKeyRecordValueMatchesOriginalPublicKey() throws 
Exception {
+        RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) 
testKeyPair.getPrivate();
+        String recordValue = 
EmailServices.derivePublicKeyRecordValue(privateKey);
+
+        assertTrue(recordValue.startsWith("v=DKIM1; k=rsa; p="), "unexpected 
record value: " + recordValue);
+        String base64PublicKey = recordValue.substring("v=DKIM1; k=rsa; 
p=".length());
+        byte[] decoded = Base64.getDecoder().decode(base64PublicKey);
+        PublicKey reconstructed = 
KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
+
+        assertEquals(testKeyPair.getPublic().getEncoded().length, 
reconstructed.getEncoded().length);
+        assertTrue(Arrays.equals(testKeyPair.getPublic().getEncoded(), 
reconstructed.getEncoded()),
+                "reconstructed public key bytes did not match the original 
keypair's public key");
+    }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a377a08245..0f64d7af0e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -29,6 +29,7 @@ icu4j = "76.1"
 openpdf = "1.4.2"
 jakarta-mail-api = "2.1.5"
 angus-mail = "2.0.5"
+utils-mail-dkim = "3.2.2"
 rome = "2.1.0"
 xstream = "1.4.21"
 commons-cli = "1.11.0"
@@ -127,6 +128,7 @@ icu4j = { module = "com.ibm.icu:icu4j", version.ref = 
"icu4j" }
 openpdf = { module = "com.github.librepdf:openpdf", version.ref = "openpdf" }
 jakarta-mail-api = { module = "jakarta.mail:jakarta.mail-api", version.ref = 
"jakarta-mail-api" }
 angus-mail = { module = "org.eclipse.angus:angus-mail", version.ref = 
"angus-mail" }
+utils-mail-dkim = { module = "org.simplejavamail:utils-mail-dkim", version.ref 
= "utils-mail-dkim" }
 rome = { module = "com.rometools:rome", version.ref = "rome" }
 xstream = { module = "com.thoughtworks.xstream:xstream", version.ref = 
"xstream" }
 commons-cli = { module = "commons-cli:commons-cli", version.ref = 
"commons-cli" }

Reply via email to