Skip to content

Commit 74ff953

Browse files
ebarlasncordon
authored andcommitted
Extract principal from certificate RDN (elastic#137230)
Add support for RDN attribute value principal extraction in PKI Realm. This allows extracting the principal from a specific RDN attribute (e.g., CN, OU) in the client certificate, improving flexibility in identifying users based on certificate details.
1 parent a7e0489 commit 74ff953

File tree

8 files changed

+352
-18
lines changed

8 files changed

+352
-18
lines changed

docs/changelog/137230.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 137230
2+
summary: Principal Extraction from Certificate RDN Attribute Value in PKI Realm
3+
area: Security
4+
type: bug
5+
issues: []

docs/reference/elasticsearch/configuration-reference/security-settings.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,18 @@ In addition to the [settings that are valid for all realms](#ref-realm-settings)
769769
`username_pattern`
770770
: ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The regular expression pattern used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The first match group is the used as the username. Defaults to `CN=(.*?)(?:,|$)`.
771771

772+
This setting is ignored if either `username_rdn_oid` or `username_rdn_name` is set.
773+
774+
`username_rdn_oid`
775+
: ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The relative distinguished name (RDN) attribute OID used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The value of the most specific RDN matching this attribute OID is used as the username.
776+
777+
This setting takes precedent over `username_pattern`. You cannot use this setting and `username_rdn_name` at the same time.
778+
779+
`username_rdn_name`
780+
: ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) The relative distinguished name (RDN) attribute name used to extract the username from the certificate DN. The username is used for auditing and logging. The username can also be used with the [role mapping API](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/mapping-users-groups-to-roles.md) and [authorization delegation](docs-content://deploy-manage/users-roles/cluster-or-deployment-auth/authorization-delegation.md). The value of the most specific RDN matching this attribute name is used as the username.
781+
782+
This setting takes precedent over `username_pattern`. You cannot use this setting and `username_rdn_oid` at the same time.
783+
772784
`certificate_authorities`
773785
: ([Static](docs-content://deploy-manage/stack-settings.md#static-cluster-setting)) List of paths to the PEM certificate files that should be used to authenticate a user’s certificate as trusted. Defaults to the trusted certificates configured for SSL. This setting cannot be used with `truststore.path`.
774786

libs/ssl-config/src/main/java/org/elasticsearch/common/ssl/DerParser.java

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,21 +36,22 @@ public final class DerParser {
3636
private static final int CONSTRUCTED = 0x20;
3737

3838
// Tag and data types
39-
static final class Type {
40-
static final int INTEGER = 0x02;
41-
static final int OCTET_STRING = 0x04;
42-
static final int OBJECT_OID = 0x06;
43-
static final int SEQUENCE = 0x10;
44-
static final int NUMERIC_STRING = 0x12;
45-
static final int PRINTABLE_STRING = 0x13;
46-
static final int VIDEOTEX_STRING = 0x15;
47-
static final int IA5_STRING = 0x16;
48-
static final int GRAPHIC_STRING = 0x19;
49-
static final int ISO646_STRING = 0x1A;
50-
static final int GENERAL_STRING = 0x1B;
51-
static final int UTF8_STRING = 0x0C;
52-
static final int UNIVERSAL_STRING = 0x1C;
53-
static final int BMP_STRING = 0x1E;
39+
public static final class Type {
40+
public static final int INTEGER = 0x02;
41+
public static final int OCTET_STRING = 0x04;
42+
public static final int OBJECT_OID = 0x06;
43+
public static final int SEQUENCE = 0x10;
44+
public static final int SET = 0x11;
45+
public static final int NUMERIC_STRING = 0x12;
46+
public static final int PRINTABLE_STRING = 0x13;
47+
public static final int VIDEOTEX_STRING = 0x15;
48+
public static final int IA5_STRING = 0x16;
49+
public static final int GRAPHIC_STRING = 0x19;
50+
public static final int ISO646_STRING = 0x1A;
51+
public static final int GENERAL_STRING = 0x1B;
52+
public static final int UTF8_STRING = 0x0C;
53+
public static final int UNIVERSAL_STRING = 0x1C;
54+
public static final int BMP_STRING = 0x1E;
5455
}
5556

5657
private InputStream derInputStream;

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/pki/PkiRealmSettings.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66
*/
77
package org.elasticsearch.xpack.core.security.authc.pki;
88

9+
import com.unboundid.ldap.sdk.LDAPException;
10+
import com.unboundid.ldap.sdk.schema.AttributeTypeDefinition;
11+
import com.unboundid.ldap.sdk.schema.Schema;
12+
913
import org.elasticsearch.common.settings.SecureString;
1014
import org.elasticsearch.common.settings.Setting;
1115
import org.elasticsearch.core.TimeValue;
@@ -29,6 +33,33 @@ public final class PkiRealmSettings {
2933
key -> new Setting<>(key, DEFAULT_USERNAME_PATTERN, s -> Pattern.compile(s, Pattern.CASE_INSENSITIVE), Setting.Property.NodeScope)
3034
);
3135

36+
public static final Setting.AffixSetting<String> USERNAME_RDN_OID_SETTING = Setting.affixKeySetting(
37+
RealmSettings.realmSettingPrefix(TYPE),
38+
"username_rdn_oid",
39+
key -> Setting.simpleString(key, Setting.Property.NodeScope)
40+
);
41+
42+
public static final Setting.AffixSetting<String> USERNAME_RDN_NAME_SETTING = Setting.affixKeySetting(
43+
RealmSettings.realmSettingPrefix(TYPE),
44+
"username_rdn_name",
45+
key -> new Setting<>(key, (String) null, s -> {
46+
if (s == null) {
47+
return "";
48+
}
49+
Schema schema;
50+
try {
51+
schema = Schema.getDefaultStandardSchema();
52+
} catch (LDAPException e) {
53+
throw new IllegalStateException("Unexpected error occurred obtaining default LDAP schema", e);
54+
}
55+
AttributeTypeDefinition atd = schema.getAttributeType(s);
56+
if (atd == null) {
57+
throw new IllegalArgumentException("Unknown RDN name [" + s + "] for setting [" + key + "]");
58+
}
59+
return atd.getOID();
60+
}, Setting.Property.NodeScope)
61+
);
62+
3263
private static final TimeValue DEFAULT_TTL = TimeValue.timeValueMinutes(20);
3364
public static final Setting.AffixSetting<TimeValue> CACHE_TTL_SETTING = Setting.affixKeySetting(
3465
RealmSettings.realmSettingPrefix(TYPE),
@@ -75,6 +106,8 @@ private PkiRealmSettings() {}
75106
public static Set<Setting.AffixSetting<?>> getSettings() {
76107
Set<Setting.AffixSetting<?>> settings = new HashSet<>();
77108
settings.add(USERNAME_PATTERN_SETTING);
109+
settings.add(USERNAME_RDN_OID_SETTING);
110+
settings.add(USERNAME_RDN_NAME_SETTING);
78111
settings.add(CACHE_TTL_SETTING);
79112
settings.add(CACHE_MAX_USERS_SETTING);
80113
settings.add(DELEGATION_ENABLED_SETTING);

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/pki/PkiRealm.java

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import org.elasticsearch.common.cache.Cache;
1313
import org.elasticsearch.common.cache.CacheBuilder;
1414
import org.elasticsearch.common.hash.MessageDigests;
15+
import org.elasticsearch.common.settings.SettingsException;
1516
import org.elasticsearch.common.ssl.SslConfiguration;
1617
import org.elasticsearch.common.ssl.SslTrustConfig;
1718
import org.elasticsearch.common.util.concurrent.ReleasableLock;
@@ -51,6 +52,7 @@
5152

5253
import javax.net.ssl.X509ExtendedTrustManager;
5354
import javax.net.ssl.X509TrustManager;
55+
import javax.security.auth.x500.X500Principal;
5456

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

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

7779
private final X509TrustManager trustManager;
7880
private final Pattern principalPattern;
81+
private final String principalRdnOid;
7982
private final UserRoleMapper roleMapper;
8083
private final Cache<BytesKey, User> cache;
8184
private DelegatedAuthorizationSupport delegatedRealms;
@@ -91,6 +94,18 @@ public PkiRealm(RealmConfig config, ResourceWatcherService watcherService, UserR
9194
this.delegationEnabled = config.getSetting(PkiRealmSettings.DELEGATION_ENABLED_SETTING);
9295
this.trustManager = trustManagers(config);
9396
this.principalPattern = config.getSetting(PkiRealmSettings.USERNAME_PATTERN_SETTING);
97+
String rdnOid = config.getSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING);
98+
String rdnOidFromName = config.getSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING);
99+
if (false == rdnOid.isEmpty() && false == rdnOidFromName.isEmpty()) {
100+
throw new SettingsException(
101+
"Both ["
102+
+ config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_OID_SETTING).getKey()
103+
+ "] and ["
104+
+ config.getConcreteSetting(PkiRealmSettings.USERNAME_RDN_NAME_SETTING).getKey()
105+
+ "] are set. Only one of these settings can be configured."
106+
);
107+
}
108+
this.principalRdnOid = false == rdnOid.isEmpty() ? rdnOid : (false == rdnOidFromName.isEmpty() ? rdnOidFromName : null);
94109
this.roleMapper = roleMapper;
95110
this.roleMapper.clearRealmCacheOnChange(this);
96111
this.cache = CacheBuilder.<BytesKey, User>builder()
@@ -133,7 +148,7 @@ public X509AuthenticationToken token(ThreadContext context) {
133148
// validation). In this case the principal should be set by the realm that completes the authentication. But in the common case,
134149
// where a single PKI realm is configured, there is no risk of eagerly parsing the principal before authentication and it also
135150
// maintains BWC.
136-
String parsedPrincipal = getPrincipalFromSubjectDN(principalPattern, token, logger);
151+
String parsedPrincipal = getPrincipalFromToken(token);
137152
if (parsedPrincipal == null) {
138153
return null;
139154
}
@@ -164,7 +179,7 @@ public void authenticate(AuthenticationToken authToken, ActionListener<Authentic
164179
// parse the principal again after validating the cert chain, and do not rely on the token.principal one, because that could
165180
// be set by a different realm that failed trusted chain validation. We SHOULD NOT parse the principal BEFORE this step, but
166181
// we do it for BWC purposes. Changing this is a breaking change.
167-
final String principal = getPrincipalFromSubjectDN(principalPattern, token, logger);
182+
final String principal = getPrincipalFromToken(token);
168183
if (principal == null) {
169184
logger.debug(
170185
() -> format(
@@ -231,6 +246,24 @@ public void lookupUser(String username, ActionListener<User> listener) {
231246
listener.onResponse(null);
232247
}
233248

249+
String getPrincipalFromToken(X509AuthenticationToken token) {
250+
return principalRdnOid != null
251+
? getPrincipalFromRdnAttribute(principalRdnOid, token, logger)
252+
: getPrincipalFromSubjectDN(principalPattern, token, logger);
253+
}
254+
255+
static String getPrincipalFromRdnAttribute(String principalRdnOid, X509AuthenticationToken token, Logger logger) {
256+
X500Principal certPrincipal = token.credentials()[0].getSubjectX500Principal();
257+
String principal = RdnFieldExtractor.extract(certPrincipal.getEncoded(), principalRdnOid);
258+
if (principal == null) {
259+
logger.debug(
260+
() -> format("the extracted principal from DN [%s] using RDN OID [%s] is empty", certPrincipal.toString(), principalRdnOid)
261+
);
262+
return null;
263+
}
264+
return principal;
265+
}
266+
234267
static String getPrincipalFromSubjectDN(Pattern principalPattern, X509AuthenticationToken token, Logger logger) {
235268
String dn = token.credentials()[0].getSubjectX500Principal().toString();
236269
Matcher matcher = principalPattern.matcher(dn);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
package org.elasticsearch.xpack.security.authc.pki;
9+
10+
import org.elasticsearch.common.ssl.DerParser;
11+
12+
import java.io.IOException;
13+
14+
/**
15+
* Utility class to extract RDN field values from X500 principal DER encoding.
16+
*/
17+
public class RdnFieldExtractor {
18+
19+
public static String extract(byte[] encoded, String oid) {
20+
try {
21+
return doExtract(encoded, oid);
22+
} catch (IOException | IllegalStateException e) {
23+
return null; // invalid encoding
24+
}
25+
}
26+
27+
private static String doExtract(byte[] encoded, String oid) throws IOException {
28+
DerParser parser = new DerParser(encoded);
29+
30+
DerParser.Asn1Object dnSequence = parser.readAsn1Object(DerParser.Type.SEQUENCE);
31+
DerParser sequenceParser = dnSequence.getParser();
32+
33+
String value = null;
34+
35+
while (true) {
36+
try {
37+
DerParser.Asn1Object rdnSet = sequenceParser.readAsn1Object(DerParser.Type.SET); // throws IOException on EOF
38+
DerParser setParser = rdnSet.getParser();
39+
40+
while (true) {
41+
try {
42+
DerParser.Asn1Object attrSeq = setParser.readAsn1Object(DerParser.Type.SEQUENCE); // throws IOException on EOF
43+
DerParser attrParser = attrSeq.getParser();
44+
45+
String attrOid = attrParser.readAsn1Object().getOid();
46+
DerParser.Asn1Object attrValue = attrParser.readAsn1Object();
47+
if (oid.equals(attrOid)) {
48+
value = attrValue.getString(); // retain last (most-significant) occurrence
49+
}
50+
} catch (IOException e) {
51+
break; // RDN SET EOF
52+
}
53+
}
54+
} catch (IOException e) {
55+
break; // DN SEQUENCE EOF
56+
}
57+
}
58+
59+
return value;
60+
}
61+
62+
}

x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/pki/PkiRealmTests.java

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import org.elasticsearch.common.settings.SecureString;
1414
import org.elasticsearch.common.settings.Setting;
1515
import org.elasticsearch.common.settings.Settings;
16+
import org.elasticsearch.common.settings.SettingsException;
1617
import org.elasticsearch.common.ssl.SslConfigException;
1718
import org.elasticsearch.common.util.CollectionUtils;
1819
import org.elasticsearch.common.util.concurrent.ThreadContext;
@@ -230,7 +231,7 @@ private AuthenticationResult<User> authenticate(X509AuthenticationToken token, P
230231
}
231232

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

253+
public void testRdnOidMatches() throws Exception {
254+
Settings settings = Settings.builder()
255+
.put(globalSettings)
256+
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.11")
257+
.build();
258+
ThreadContext threadContext = new ThreadContext(settings);
259+
X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt"));
260+
UserRoleMapper roleMapper = buildRoleMapper();
261+
PkiRealm realm = buildRealm(roleMapper, settings);
262+
threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate });
263+
264+
X509AuthenticationToken token = realm.token(threadContext);
265+
User user = authenticate(token, realm).getValue();
266+
assertThat(user, is(notNullValue()));
267+
assertThat(user.principal(), is("elasticsearch"));
268+
}
269+
270+
public void testRdnOidNameMatches() throws Exception {
271+
Settings settings = Settings.builder()
272+
.put(globalSettings)
273+
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "OU")
274+
.build();
275+
ThreadContext threadContext = new ThreadContext(settings);
276+
X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt"));
277+
UserRoleMapper roleMapper = buildRoleMapper();
278+
PkiRealm realm = buildRealm(roleMapper, settings);
279+
threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate });
280+
281+
X509AuthenticationToken token = realm.token(threadContext);
282+
User user = authenticate(token, realm).getValue();
283+
assertThat(user, is(notNullValue()));
284+
assertThat(user.principal(), is("elasticsearch"));
285+
}
286+
287+
public void testRdnOidNameNotMatches() throws Exception {
288+
Settings settings = Settings.builder()
289+
.put(globalSettings)
290+
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID")
291+
.build();
292+
ThreadContext threadContext = new ThreadContext(settings);
293+
X509Certificate certificate = readCert(getDataPath("/org/elasticsearch/xpack/security/transport/ssl/certs/simple/testnode.crt"));
294+
UserRoleMapper roleMapper = buildRoleMapper();
295+
PkiRealm realm = buildRealm(roleMapper, settings);
296+
threadContext.putTransient(PkiRealm.PKI_CERT_HEADER_NAME, new X509Certificate[] { certificate });
297+
298+
X509AuthenticationToken token = realm.token(threadContext);
299+
assertThat(token, is(nullValue()));
300+
}
301+
302+
public void testRdnOidNameUnknown() {
303+
Settings settings = Settings.builder()
304+
.put(globalSettings)
305+
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UNKNOWN_OID_NAME")
306+
.build();
307+
UserRoleMapper roleMapper = buildRoleMapper();
308+
assertThrows(IllegalArgumentException.class, () -> buildRealm(roleMapper, settings));
309+
}
310+
311+
public void testRedundantRdnOidSettings() {
312+
Settings settings = Settings.builder()
313+
.put(globalSettings)
314+
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_oid", "2.5.4.3")
315+
.put("xpack.security.authc.realms.pki.my_pki.username_rdn_name", "UID")
316+
.build();
317+
UserRoleMapper roleMapper = buildRoleMapper();
318+
assertThrows(SettingsException.class, () -> buildRealm(roleMapper, settings));
319+
}
320+
252321
public void testCustomUsernamePatternMismatchesAndNullToken() throws Exception {
253322
final Settings settings = Settings.builder()
254323
.put(globalSettings)

0 commit comments

Comments
 (0)