From 5e8c08fb1ac4a2cdaa5d11ce1e852dab573ef86a Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Sun, 04 Oct 2026 07:22:08 +0000
Subject: [PATCH] [#1154] Find the OpenIDM certificate by comparing names, not their string forms (#1162)

---
 opendj-openidm-account-change-notification-handler/src/main/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandler.java         |   40 +++++++++++-
 opendj-openidm-account-change-notification-handler/src/test/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandlerTestCase.java |  113 +++++++++++++++++++++++++++++++++++++
 opendj-openidm-account-change-notification-handler/pom.xml                                                                                                |    7 ++
 3 files changed, 155 insertions(+), 5 deletions(-)

diff --git a/opendj-openidm-account-change-notification-handler/pom.xml b/opendj-openidm-account-change-notification-handler/pom.xml
index 9e9b926..a872b79 100644
--- a/opendj-openidm-account-change-notification-handler/pom.xml
+++ b/opendj-openidm-account-change-notification-handler/pom.xml
@@ -70,6 +70,13 @@
       <groupId>org.openidentityplatform.commons.http-framework</groupId>
       <artifactId>client-apache-async</artifactId>
     </dependency>
+
+    <!-- Test dependencies (TestNG is inherited from the parent, BouncyCastle comes with opendj-server-legacy) -->
+    <dependency>
+      <groupId>org.openidentityplatform.commons</groupId>
+      <artifactId>build-tools</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
   
   <build><finalName>${project.groupId}.${project.artifactId}</finalName>
diff --git a/opendj-openidm-account-change-notification-handler/src/main/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandler.java b/opendj-openidm-account-change-notification-handler/src/main/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandler.java
index 975f69a..2ff4f32 100644
--- a/opendj-openidm-account-change-notification-handler/src/main/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandler.java
+++ b/opendj-openidm-account-change-notification-handler/src/main/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandler.java
@@ -337,14 +337,44 @@
             OpenidmAccountStatusNotificationHandlerCfg configuration) throws ConfigException {
 
         X509TrustManager trustMgr = (X509TrustManager) trustMgrs[0];
-        String serverCertSubject = configuration.getCertificateSubjectDN().toString();
-        for (X509Certificate cert : trustMgr.getAcceptedIssuers()) {
-            String subjectX500Principal = cert.getSubjectX500Principal().getName(X500Principal.CANONICAL);
-            if (serverCertSubject.equalsIgnoreCase(subjectX500Principal)) {
+        DN serverCertSubject = configuration.getCertificateSubjectDN();
+        X509Certificate cert = findCertificateBySubject(serverCertSubject, trustMgr.getAcceptedIssuers());
+        if (cert == null) {
+            throw new ConfigException(ERR_OPENIDM_PWSYNC_INVALID_SERVERKEYALIAS.get(serverCertSubject));
+        }
+        return cert;
+    }
+
+    /**
+     * Returns the certificate whose subject is the provided DN.
+     * <p>
+     * Names are compared, not strings: {@code DN.toString()} and the JDK's canonical form serialise the same
+     * name differently (escaped {@code =}, repeated spaces, the order of the AVAs of a multi-valued RDN,
+     * attribute types written as an OID with a BER hex string), and {@link X500Principal#equals(Object)}
+     * compares the canonical forms of both sides.
+     *
+     * @param subjectDN
+     *            The subject DN of the certificate to find.
+     * @param certificates
+     *            The certificates to search.
+     * @return The first certificate whose subject is {@code subjectDN}, or {@code null} if there is none,
+     *         including when the JDK cannot parse {@code subjectDN} as an X.500 name.
+     */
+    static X509Certificate findCertificateBySubject(DN subjectDN, X509Certificate... certificates) {
+        X500Principal subject;
+        try {
+            subject = new X500Principal(subjectDN.toString());
+        } catch (IllegalArgumentException e) {
+            // An attribute type without a keyword known to the JDK, such as "mail": no certificate subject
+            logger.traceException(e);
+            return null;
+        }
+        for (X509Certificate cert : certificates) {
+            if (subject.equals(cert.getSubjectX500Principal())) {
                 return cert;
             }
         }
-        throw new ConfigException(ERR_OPENIDM_PWSYNC_INVALID_SERVERKEYALIAS.get(serverCertSubject));
+        return null;
     }
 
     private TrustManager[] getTrustManagers(OpenidmAccountStatusNotificationHandlerCfg configuration)
diff --git a/opendj-openidm-account-change-notification-handler/src/test/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandlerTestCase.java b/opendj-openidm-account-change-notification-handler/src/test/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandlerTestCase.java
new file mode 100644
index 0000000..0008e13
--- /dev/null
+++ b/opendj-openidm-account-change-notification-handler/src/test/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandlerTestCase.java
@@ -0,0 +1,113 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.forgerock.openidm.accountchange;
+
+import static org.forgerock.openidm.accountchange.OpenidmAccountStatusNotificationHandler.findCertificateBySubject;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertSame;
+
+import java.math.BigInteger;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+
+import javax.security.auth.x500.X500Principal;
+
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.forgerock.opendj.ldap.DN;
+import org.forgerock.testng.ForgeRockTestCase;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+/**
+ * Tests how the handler finds the OpenIDM certificate named by {@code certificate-subject-dn}
+ * among the certificates of its truststore (issue #1154).
+ */
+@SuppressWarnings("javadoc")
+@Test(groups = { "precommit" })
+public class OpenidmAccountStatusNotificationHandlerTestCase extends ForgeRockTestCase {
+
+    private KeyPair keyPair;
+
+    @BeforeClass
+    public void generateKeyPair() throws Exception {
+        KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+        generator.initialize(2048);
+        keyPair = generator.generateKeyPair();
+    }
+
+    @DataProvider
+    public Object[][] sameName() {
+        return new Object[][] {
+            // The subject of the sample configuration
+            { "CN=localhost, O=OpenIDM Self-Signed Certificate, OU=None, L=None, ST=None, C=None" },
+            // DN.toString() escapes '=' in a value, the JDK's canonical form does not
+            { "CN=idm=1,O=Example" },
+            // The JDK's canonical form collapses internal spaces
+            { "CN=idm  node,O=Example" },
+            // The JDK's canonical form sorts the AVAs of a multi-valued RDN
+            { "OU=sync+CN=idm,O=Example" },
+            // The JDK's canonical form writes EMAILADDRESS as an OID with a BER hex string
+            { "EMAILADDRESS=idm@example.com,CN=idm,O=Example" },
+            // ... and DC, which it encodes as an IA5String, as a BER hex string
+            { "UID=idm,DC=example,DC=com" },
+        };
+    }
+
+    @Test(dataProvider = "sameName")
+    public void findsTheCertificateWhoseSubjectIsTheConfiguredDN(String name) throws Exception {
+        X509Certificate cert = certificate(name);
+
+        assertSame(findCertificateBySubject(DN.valueOf(name), certificate("CN=other,O=Example"), cert), cert);
+    }
+
+    @Test
+    public void findsTheCertificateWhateverTheCaseAndTheAVAOrderOfTheConfiguredDN() throws Exception {
+        X509Certificate cert = certificate("CN=idm+OU=sync,O=Example");
+
+        assertSame(findCertificateBySubject(DN.valueOf("ou=SYNC+cn=IDM,o=example"), cert), cert);
+    }
+
+    @Test
+    public void findsNoCertificateWhenNoSubjectIsTheConfiguredDN() throws Exception {
+        assertNull(findCertificateBySubject(DN.valueOf("CN=idm,O=Example"),
+                certificate("CN=idm,O=Other"), certificate("CN=idm2,O=Example")));
+    }
+
+    @Test
+    public void findsNoCertificateWhenTheJDKCannotParseTheConfiguredDN() throws Exception {
+        // X500Principal does not know the "mail" keyword: the DN cannot name a certificate subject
+        assertNull(findCertificateBySubject(DN.valueOf("mail=idm@example.com,O=Example"),
+                certificate("CN=idm,O=Example")));
+    }
+
+    /** Returns a self-signed certificate whose subject is encoded as keytool encodes it. */
+    private X509Certificate certificate(String subject) throws Exception {
+        X500Principal name = new X500Principal(subject);
+        Instant now = Instant.now();
+        JcaX509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name, BigInteger.ONE,
+                Date.from(now.minus(1, ChronoUnit.DAYS)), Date.from(now.plus(1, ChronoUnit.DAYS)), name,
+                keyPair.getPublic());
+        return new JcaX509CertificateConverter().getCertificate(
+                builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate())));
+    }
+}

--
Gitblit v1.10.0