Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions framework/common/entitydef/entitymodel.xml
Original file line number Diff line number Diff line change
Expand Up @@ -937,4 +937,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>
8 changes: 8 additions & 0 deletions framework/common/servicedef/services_email.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@
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;
Expand Down Expand Up @@ -65,7 +74,9 @@
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;
Expand All @@ -78,6 +89,11 @@
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;
Expand Down Expand Up @@ -359,10 +375,16 @@ public static Map<String, Object> sendMail(DispatchContext ctx, Map<String, ? ex
} 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
Expand Down Expand Up @@ -738,6 +760,104 @@ public static void sendFailureNotification(DispatchContext dctx, Map<String, ? e
}
}

/**
* 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;
Expand Down
Loading
Loading