mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Valery Kharseko
yesterday 5e8c08fb1ac4a2cdaa5d11ce1e852dab573ef86a
[#1154] Find the OpenIDM certificate by comparing names, not their string forms (#1162)

## Problem

The OpenIDM account change notification handler looks up the OpenIDM
certificate in its truststore by the configured
`certificate-subject-dn`. It did not find the certificate when the
subject contains `=`, repeated spaces, an `EMAILADDRESS` (or `DC`)
attribute, or a multi-valued RDN. The handler then rejects its
configuration with `ERR_OPENIDM_PWSYNC_INVALID_SERVERKEYALIAS`
("certificate-subject-dn '%s' is not found in provided truststore") and
cannot start, because it needs that certificate's public key to encrypt
the passwords it sends to OpenIDM. See #1154.

## Cause

`getServerCertificate()` compared two strings case-insensitively:
`DN.toString()` of the configured DN and
`X500Principal.getName(CANONICAL)` of each certificate subject. The two
are different serialisations of the same name:
- `DN.toString()` escapes `=` in a value (since #201), the canonical
form does not.
- The canonical form collapses internal spaces.
- The canonical form sorts the AVAs of a multi-valued RDN.
- The canonical form writes `EMAILADDRESS` as an OID with a BER hex
string, and so does it with `DC`, which the JDK encodes as an IA5String.

## Change

- The lookup moves into a package-private `findCertificateBySubject(DN,
X509Certificate...)`, which compares names instead of strings: `new
X500Principal(subjectDN.toString()).equals(cert.getSubjectX500Principal())`.
`X500Principal.equals()` compares the canonical forms of both sides and
parses OpenDJ's `\=` escape.
- If the JDK cannot parse the configured DN (an attribute type it has no
keyword for, such as `mail`), the method returns `null` and the handler
reports the same `ERR_OPENIDM_PWSYNC_INVALID_SERVERKEYALIAS` as before,
instead of letting an unchecked `IllegalArgumentException` escape. Such
a DN could not match a certificate before either: the canonical form
writes those attribute types as OIDs.
- `getServerCertificate()` keeps its error message and behaviour
otherwise.

Out of scope: a configured value written as a hex string
(`CN=#04026869,…`) still does not match, because OpenDJ keeps a hex
string as raw bytes instead of BER-decoding it. That is the parser issue
tracked in #1153.

## Test

`OpenidmAccountStatusNotificationHandlerTestCase` is the module's first
unit test (the module now declares the `build-tools` test dependency for
the surefire listener). It builds real self-signed certificates with
BouncyCastle, which comes with `opendj-server-legacy`, from an
`X500Principal`, so the subject is encoded as `keytool` encodes it.
Mockito cannot mock `X509Certificate` on Java 17+.

It checks that the certificate is found for the sample configuration's
subject and for each row of the issue's table (`=`, repeated spaces,
multi-valued RDN, `EMAILADDRESS`), plus `UID`/`DC`, and for a configured
DN in another case and AVA order. It also checks that no certificate is
found when no subject matches and when the JDK cannot parse the
configured DN.

- Without the fix: 6 of 9 fail, exactly the cases the issue predicts.
The sample configuration's subject and the negative cases pass.
- With the fix: 9 of 9 pass.
- With the `catch` removed, the unparseable-DN test fails with
`IllegalArgumentException: improperly specified input name`.

Fixes #1154
2 files modified
1 files added
160 ■■■■■ changed files
opendj-openidm-account-change-notification-handler/pom.xml 7 ●●●●● patch | view | raw | blame | history
opendj-openidm-account-change-notification-handler/src/main/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandler.java 40 ●●●●● patch | view | raw | blame | history
opendj-openidm-account-change-notification-handler/src/test/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandlerTestCase.java 113 ●●●●● patch | view | raw | blame | history
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>
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)
opendj-openidm-account-change-notification-handler/src/test/java/org/forgerock/openidm/accountchange/OpenidmAccountStatusNotificationHandlerTestCase.java
New file
@@ -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())));
    }
}