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
5 changes: 5 additions & 0 deletions docs/changelog/137230.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
pr: 137230
summary: Principal Extraction from Certificate RDN Attribute Value in PKI Realm
area: Security
type: bug
issues: []
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,22 @@ public final class DerParser {
private static final int CONSTRUCTED = 0x20;

// Tag and data types
static final class Type {
static final int INTEGER = 0x02;
static final int OCTET_STRING = 0x04;
static final int OBJECT_OID = 0x06;
static final int SEQUENCE = 0x10;
static final int NUMERIC_STRING = 0x12;
static final int PRINTABLE_STRING = 0x13;
static final int VIDEOTEX_STRING = 0x15;
static final int IA5_STRING = 0x16;
static final int GRAPHIC_STRING = 0x19;
static final int ISO646_STRING = 0x1A;
static final int GENERAL_STRING = 0x1B;
static final int UTF8_STRING = 0x0C;
static final int UNIVERSAL_STRING = 0x1C;
static final int BMP_STRING = 0x1E;
public static final class Type {
public static final int INTEGER = 0x02;
public static final int OCTET_STRING = 0x04;
public static final int OBJECT_OID = 0x06;
public static final int SEQUENCE = 0x10;
public static final int SET = 0x11;
public static final int NUMERIC_STRING = 0x12;
public static final int PRINTABLE_STRING = 0x13;
public static final int VIDEOTEX_STRING = 0x15;
public static final int IA5_STRING = 0x16;
public static final int GRAPHIC_STRING = 0x19;
public static final int ISO646_STRING = 0x1A;
public static final int GENERAL_STRING = 0x1B;
public static final int UTF8_STRING = 0x0C;
public static final int UNIVERSAL_STRING = 0x1C;
public static final int BMP_STRING = 0x1E;
}

private InputStream derInputStream;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
*/
package org.elasticsearch.xpack.core.security.authc.pki;

import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.schema.AttributeTypeDefinition;
import com.unboundid.ldap.sdk.schema.Schema;

import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Setting;
import org.elasticsearch.core.TimeValue;
Expand All @@ -29,6 +33,33 @@ public final class PkiRealmSettings {
key -> new Setting<>(key, DEFAULT_USERNAME_PATTERN, s -> Pattern.compile(s, Pattern.CASE_INSENSITIVE), Setting.Property.NodeScope)
);

public static final Setting.AffixSetting<String> USERNAME_RDN_OID_SETTING = Setting.affixKeySetting(
RealmSettings.realmSettingPrefix(TYPE),
"username_rdn_oid",
key -> Setting.simpleString(key, Setting.Property.NodeScope)
);

public static final Setting.AffixSetting<String> USERNAME_RDN_NAME_SETTING = Setting.affixKeySetting(
RealmSettings.realmSettingPrefix(TYPE),
"username_rdn_name",
key -> new Setting<>(key, (String) null, s -> {
if (s == null) {
return "";
}
Schema schema;
try {
schema = Schema.getDefaultStandardSchema();
} catch (LDAPException e) {
throw new IllegalStateException("Unexpected error occurred obtaining default LDAP schema", e);
}
AttributeTypeDefinition atd = schema.getAttributeType(s);
if (atd == null) {
throw new IllegalArgumentException("Unknown RDN name [" + s + "] for setting [" + key + "]");
}
return atd.getOID();
}, Setting.Property.NodeScope)
);

private static final TimeValue DEFAULT_TTL = TimeValue.timeValueMinutes(20);
public static final Setting.AffixSetting<TimeValue> CACHE_TTL_SETTING = Setting.affixKeySetting(
RealmSettings.realmSettingPrefix(TYPE),
Expand Down Expand Up @@ -75,6 +106,8 @@ private PkiRealmSettings() {}
public static Set<Setting.AffixSetting<?>> getSettings() {
Set<Setting.AffixSetting<?>> settings = new HashSet<>();
settings.add(USERNAME_PATTERN_SETTING);
settings.add(USERNAME_RDN_OID_SETTING);
settings.add(USERNAME_RDN_NAME_SETTING);
settings.add(CACHE_TTL_SETTING);
settings.add(CACHE_MAX_USERS_SETTING);
settings.add(DELEGATION_ENABLED_SETTING);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import org.elasticsearch.common.cache.Cache;
import org.elasticsearch.common.cache.CacheBuilder;
import org.elasticsearch.common.hash.MessageDigests;
import org.elasticsearch.common.settings.SettingsException;
import org.elasticsearch.common.ssl.SslConfiguration;
import org.elasticsearch.common.ssl.SslTrustConfig;
import org.elasticsearch.common.util.concurrent.ReleasableLock;
Expand Down Expand Up @@ -51,6 +52,7 @@

import javax.net.ssl.X509ExtendedTrustManager;
import javax.net.ssl.X509TrustManager;
import javax.security.auth.x500.X500Principal;

import static org.elasticsearch.core.Strings.format;

Expand All @@ -76,6 +78,7 @@ public class PkiRealm extends Realm implements CachingRealm {

private final X509TrustManager trustManager;
private final Pattern principalPattern;
private final String principalRdnOid;
private final UserRoleMapper roleMapper;
private final Cache<BytesKey, User> cache;
private DelegatedAuthorizationSupport delegatedRealms;
Expand All @@ -91,6 +94,18 @@ public PkiRealm(RealmConfig config, ResourceWatcherService watcherService, UserR
this.delegationEnabled = config.getSetting(PkiRealmSettings.DELEGATION_ENABLED_SETTING);
this.trustManager = trustManagers(config);
this.principalPattern = config.getSetting(PkiRealmSettings.USERNAME_PATTERN_SETTING);
String rdnOid = config.getSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING);
String rdnOidFromName = config.getSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING);
if (false == rdnOid.isEmpty() && false == rdnOidFromName.isEmpty()) {
throw new SettingsException(
"Both ["
+ config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING).getKey()
+ "] and ["
+ config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING).getKey()
+ "] are set. Only one of these settings can be configured."
);
}
this.principalRdnOid = false == rdnOid.isEmpty() ? rdnOid : (false == rdnOidFromName.isEmpty() ? rdnOidFromName : null);
this.roleMapper = roleMapper;
this.roleMapper.clearRealmCacheOnChange(this);
this.cache = CacheBuilder.<BytesKey, User>builder()
Expand Down Expand Up @@ -133,7 +148,7 @@ public X509AuthenticationToken token(ThreadContext context) {
// validation). In this case the principal should be set by the realm that completes the authentication. But in the common case,
// where a single PKI realm is configured, there is no risk of eagerly parsing the principal before authentication and it also
// maintains BWC.
String parsedPrincipal = getPrincipalFromSubjectDN(principalPattern, token, logger);
String parsedPrincipal = getPrincipalFromToken(token);
if (parsedPrincipal == null) {
return null;
}
Expand Down Expand Up @@ -164,7 +179,7 @@ public void authenticate(AuthenticationToken authToken, ActionListener<Authentic
// parse the principal again after validating the cert chain, and do not rely on the token.principal one, because that could
// be set by a different realm that failed trusted chain validation. We SHOULD NOT parse the principal BEFORE this step, but
// we do it for BWC purposes. Changing this is a breaking change.
final String principal = getPrincipalFromSubjectDN(principalPattern, token, logger);
final String principal = getPrincipalFromToken(token);
if (principal == null) {
logger.debug(
() -> format(
Expand Down Expand Up @@ -231,6 +246,24 @@ public void lookupUser(String username, ActionListener<User> listener) {
listener.onResponse(null);
}

String getPrincipalFromToken(X509AuthenticationToken token) {
return principalRdnOid != null
? getPrincipalFromRdnAttribute(principalRdnOid, token, logger)
: getPrincipalFromSubjectDN(principalPattern, token, logger);
}

static String getPrincipalFromRdnAttribute(String principalRdnOid, X509AuthenticationToken token, Logger logger) {
X500Principal certPrincipal = token.credentials()[0].getSubjectX500Principal();
String principal = RdnFieldExtractor.extract(certPrincipal.getEncoded(), principalRdnOid);
if (principal == null) {
logger.debug(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should be a warning since it's probably due to a misconfiguration most of the time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was also a case of remaining consistent with surrounding code. getPrincipalFromSubjectDN uses debug logging.

() -> format("the extracted principal from DN [%s] using RDN OID [%s] is empty", certPrincipal.toString(), principalRdnOid)
);
return null;
}
return principal;
}

static String getPrincipalFromSubjectDN(Pattern principalPattern, X509AuthenticationToken token, Logger logger) {
String dn = token.credentials()[0].getSubjectX500Principal().toString();
Matcher matcher = principalPattern.matcher(dn);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

package org.elasticsearch.xpack.security.authc.pki;

import org.elasticsearch.common.ssl.DerParser;

import java.io.IOException;

/**
* Utility class to extract RDN field values from X500 principal DER encoding.
*/
public class RdnFieldExtractor {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I proposed that we process RDNs, I didn't think we were going to need to write our own parser (but the alternative isn't really obvious)

We have multiple DN/RDN implementations already, they're not a perfect fit for what we want, but I think some of them can be made to work

  • javax.security.auth.x500.X500Principal has all the decoding we need (via sun.security.x509.X500Name) but doesn't expose it. It does however provide a way of turning it into a canonical string representation
  • com.unboundid.ldap.sdk.DN is a good DN representation that exposes RDNs and Attributes, but doesn't include a way to go from a DER byte[] to a DN. However, it can parse canonical strings, so it would be possible to turn a X500Principal into a DN via string encoding.
  • com.unboundid.util.ssl.cert.X509Certificate.decodeName can turn a ASN1Element into a DN but it's package private, so not useful.
  • org.cryptacular.x509.dn.NameReader.readX500Principal can turn a X500Principal into a RDNSequence, although I don't love making our dependency on cryptacular stronger, it does do what we need
  • org.bouncycastle.asn1.x500.X500Name is, in typical Bouncy Castle fashion, exactly what we want, but we try to limit our dependency on BC due to FIPS complexities.

I think we can avoid this custom parsing code entirely if we use either

  • org.cryptacular.x509.dn.NameReader.readX500Principal, or
  • new com.unboundid.ldap.sdk.DN(subject.getName(format)) (I'd need to do more investigation to know which format is going to suit us best).

We use DN in lots of other places, (DnRoleMapper, DistinguishedNameNormalizer) so it would have been my preference, if ldapsdk had an easy to use DER decoder. But parsing via an RFC based string representation isn't completely terrible.

If I had to pick, I guess I'd use cryptacular, since it does exactly what we need, and we can swap it out if we ever need to.

That said, it's not the worst thing to use our own parser (it's surprisingly short since we have a working DER parser), but we lose schema support, and I really don't think we can force OIDs onto our users. So, even if we stick with our own parsing we'll need to pull in a schema from one of those libraries so we can work with standard attribute names.

Copy link
Contributor Author

@ebarlas ebarlas Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that requiring schema definition awareness weakens the case for DerParser, although doing an initial mapping from schema definition attribute name to OID may be reasonable.

javax.naming.ldap.LdapName is another option and it doesn't require dependencies.

  • This parser operates on RFC 2253 text
  • It also provides a way to iterate over RDNs (via javax.naming.ldap.Rdn)
  • Conversion to RFC 2253 format (via X500Principal) results in hex-conversion of string values for unknown OIDs

Can we determine how the username pattern setting is used today? That might help inform the OID decision.

Copy link
Contributor Author

@ebarlas ebarlas Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hurdle that I ran into earlier with javax.naming.ldap.LdapName is that the RFC 2253 formatted text is a hexadecimal representation of the BER encoding. That representation is mirrored in the LdapName or DN object.

This is what led me to a direct interpretation of the DER bytes, which bypasses nuances with text formatting.

String s = "OID.1.3.6.1.4.1.50000.1.1=EMP-2024-42, CN=Jane Developer, OU=Engineering, O=Acme Corp";
X500Principal principal = new X500Principal(s);
System.out.println(principal.getName(X500Principal.RFC2253));
1.3.6.1.4.1.50000.1.1=#130b454d502d323032342d3432,CN=Jane Developer,OU=Engineering,O=Acme Corp


public static String extract(byte[] encoded, String oid) {
try {
return doExtract(encoded, oid);
} catch (IOException | IllegalStateException e) {
return null; // invalid encoding
}
}

private static String doExtract(byte[] encoded, String oid) throws IOException {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trying to understand what's going on here. Each Asn1Object is a TLV (Type Length Value), and "constructed" means that it's another object, not a primitive.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's right. My understanding is that "constructed" is a tag bit that denotes a sequence or set.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can read chapter 12 of the ASN.1 book if you're particularly interested in becoming an expert in ASN.1, but yes, "constructed" is akin to "structured", that is a non-primitive ( a sequence, set or choice )

DerParser parser = new DerParser(encoded);

DerParser.Asn1Object dnSequence = parser.readAsn1Object(DerParser.Type.SEQUENCE);
DerParser sequenceParser = dnSequence.getParser();

String value = null;

while (true) {
try {
DerParser.Asn1Object rdnSet = sequenceParser.readAsn1Object(DerParser.Type.SET); // throws IOException on EOF
DerParser setParser = rdnSet.getParser();

while (true) {
try {
DerParser.Asn1Object attrSeq = setParser.readAsn1Object(DerParser.Type.SEQUENCE); // throws IOException on EOF
DerParser attrParser = attrSeq.getParser();

String attrOid = attrParser.readAsn1Object().getOid();
DerParser.Asn1Object attrValue = attrParser.readAsn1Object();
if (oid.equals(attrOid)) {
value = attrValue.getString(); // retain last (most-significant) occurrence
}
} catch (IOException e) {
break; // RDN SET EOF
}
}
} catch (IOException e) {
break; // DN SEQUENCE EOF
}
}

return value;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.elasticsearch.common.settings.SecureString;
import org.elasticsearch.common.settings.Setting;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.common.settings.SettingsException;
import org.elasticsearch.common.ssl.SslConfigException;
import org.elasticsearch.common.util.CollectionUtils;
import org.elasticsearch.common.util.concurrent.ThreadContext;
Expand Down Expand Up @@ -230,7 +231,7 @@ private AuthenticationResult<User> authenticate(X509AuthenticationToken token, P
}

public void testCustomUsernamePatternMatches() throws Exception {
final Settings settings = Settings.builder()
Settings settings = Settings.builder()
.put(globalSettings)
.put("xpack.security.authc.realms.pki.my_pki.username_pattern", "OU=(.*?),")
.build();
Expand All @@ -249,6 +250,74 @@ public void testCustomUsernamePatternMatches() throws Exception {
assertThat(user.roles().length, is(0));
}

public void testRdnOidMatches() throws Exception {
Settings settings = Settings.builder()
.put(globalSettings)
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.11")
.build();
ThreadContext threadContext = new ThreadContext(settings);
X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt"));
UserRoleMapper roleMapper = buildRoleMapper();
PkiRealm realm = buildRealm(roleMapper, settings);
threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate });

X509AuthenticationToken token = realm.token(threadContext);
User user = authenticate(token, realm).getValue();
assertThat(user, is(notNullValue()));
assertThat(user.principal(), is("elasticsearch"));
}

public void testRdnOidNameMatches() throws Exception {
Settings settings = Settings.builder()
.put(globalSettings)
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "OU")
.build();
ThreadContext threadContext = new ThreadContext(settings);
X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt"));
UserRoleMapper roleMapper = buildRoleMapper();
PkiRealm realm = buildRealm(roleMapper, settings);
threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate });

X509AuthenticationToken token = realm.token(threadContext);
User user = authenticate(token, realm).getValue();
assertThat(user, is(notNullValue()));
assertThat(user.principal(), is("elasticsearch"));
}

public void testRdnOidNameNotMatches() throws Exception {
Settings settings = Settings.builder()
.put(globalSettings)
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID")
.build();
ThreadContext threadContext = new ThreadContext(settings);
X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt"));
UserRoleMapper roleMapper = buildRoleMapper();
PkiRealm realm = buildRealm(roleMapper, settings);
threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate });

X509AuthenticationToken token = realm.token(threadContext);
assertThat(token, is(nullValue()));
}

public void testRdnOidNameUnknown() {
Settings settings = Settings.builder()
.put(globalSettings)
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UNKNOWN_OID_NAME")
.build();
UserRoleMapper roleMapper = buildRoleMapper();
assertThrows(IllegalArgumentException.class, () -> buildRealm(roleMapper, settings));
}

public void testRedundantRdnOidSettings() {
Settings settings = Settings.builder()
.put(globalSettings)
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.3")
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID")
.build();
UserRoleMapper roleMapper = buildRoleMapper();
assertThrows(SettingsException.class, () -> buildRealm(roleMapper, settings));
}

public void testCustomUsernamePatternMismatchesAndNullToken() throws Exception {
final Settings settings = Settings.builder()
.put(globalSettings)
Expand Down
Loading