Skip to content

Commit 74da8c0

Browse files
authored
Send cross cluster api key signature as header (#135674)
This PR is a follow up to #134137 and #134893. It adds serialization and verification of the new header: - The signing configurations are now used to generate a signature that's passed as a header for cross cluster api keys. - The signature headers are deserializaed and validated on the server side and auth fails if the validation fails. This PR does not use the certificate identity that was added in #134604 to verify that the identity in the passed leaf certificate belongs to the signed cross cluster API key by matching it against the API key certificate identity pattern. That will be done in a follow up PR to keep the scope of this PR manageable.
1 parent 89c58cf commit 74da8c0

File tree

25 files changed

+792
-123
lines changed

25 files changed

+792
-123
lines changed

docs/changelog/135674.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 135674
2+
summary: Send cross cluster api key signature as headers
3+
area: Security
4+
type: enhancement
5+
issues: []
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
9191000
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
reordered_translog_operations,9190000
1+
add_cross_cluster_api_key_signature,9191000

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

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,6 @@ public void writeToContext(final ThreadContext ctx) throws IOException {
7676
ctx.putHeader(CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY, encode());
7777
}
7878

79-
public static CrossClusterAccessSubjectInfo readFromContext(final ThreadContext ctx) throws IOException {
80-
final String header = ctx.getHeader(CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY);
81-
if (header == null) {
82-
throw new IllegalArgumentException(
83-
"cross cluster access header [" + CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY + "] is required"
84-
);
85-
}
86-
return decode(header);
87-
}
88-
8979
public Authentication getAuthentication() {
9080
return authentication;
9181
}

x-pack/plugin/core/src/test/java/org/elasticsearch/xpack/core/security/authc/CrossClusterAccessSubjectInfoTests.java

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ public void testWriteReadContextRoundtrip() throws IOException {
4848
);
4949

5050
expectedCrossClusterAccessSubjectInfo.writeToContext(ctx);
51-
final CrossClusterAccessSubjectInfo actual = CrossClusterAccessSubjectInfo.readFromContext(ctx);
51+
final CrossClusterAccessSubjectInfo actual = CrossClusterAccessSubjectInfo.decode(
52+
ctx.getHeader(CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY)
53+
);
5254

5355
assertThat(actual.getAuthentication(), equalTo(expectedCrossClusterAccessSubjectInfo.getAuthentication()));
5456
final List<Set<RoleDescriptor>> roleDescriptorsList = new ArrayList<>();
@@ -70,17 +72,6 @@ public void testRoleDescriptorsBytesToRoleDescriptors() throws IOException {
7072
assertThat(actualRoleDescriptors, equalTo(expectedRoleDescriptors));
7173
}
7274

73-
public void testThrowsOnMissingEntry() {
74-
var actual = expectThrows(
75-
IllegalArgumentException.class,
76-
() -> CrossClusterAccessSubjectInfo.readFromContext(new ThreadContext(Settings.EMPTY))
77-
);
78-
assertThat(
79-
actual.getMessage(),
80-
equalTo("cross cluster access header [" + CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY + "] is required")
81-
);
82-
}
83-
8475
public void testCleanWithValidationForApiKeys() {
8576
final Map<String, Object> initialMetadata = newHashMapWithRandomMetadata();
8677
final AuthenticationTestHelper.AuthenticationTestBuilder builder = AuthenticationTestHelper.builder()

x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityBWCToRCS2ClusterRestIT.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import org.elasticsearch.test.cluster.ElasticsearchCluster;
1111
import org.elasticsearch.test.cluster.local.distribution.DistributionType;
12+
import org.elasticsearch.test.cluster.util.resource.Resource;
1213
import org.junit.Before;
1314
import org.junit.ClassRule;
1415
import org.junit.rules.RuleChain;
@@ -54,6 +55,10 @@ public class RemoteClusterSecurityBWCToRCS2ClusterRestIT extends AbstractRemoteC
5455
.apply(commonClusterConfig)
5556
.setting("xpack.security.remote_cluster_client.ssl.enabled", "true")
5657
.setting("xpack.security.remote_cluster_client.ssl.certificate_authorities", "remote-cluster-ca.crt")
58+
.configFile("signing.crt", Resource.fromClasspath("signing/signing.crt"))
59+
.setting("cluster.remote.my_remote_cluster.signing.certificate", "signing.crt")
60+
.configFile("signing.key", Resource.fromClasspath("signing/signing.key"))
61+
.setting("cluster.remote.my_remote_cluster.signing.key", "signing.key")
5762
.keystore("cluster.remote.my_remote_cluster.credentials", () -> {
5863
if (API_KEY_MAP_REF.get() == null) {
5964
final Map<String, Object> apiKeyMap = createCrossClusterAccessApiKey("""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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.remotecluster;
9+
10+
import io.netty.handler.codec.http.HttpMethod;
11+
12+
import org.elasticsearch.action.search.SearchResponse;
13+
import org.elasticsearch.client.Request;
14+
import org.elasticsearch.client.RequestOptions;
15+
import org.elasticsearch.client.Response;
16+
import org.elasticsearch.client.ResponseException;
17+
import org.elasticsearch.common.settings.Settings;
18+
import org.elasticsearch.core.Strings;
19+
import org.elasticsearch.search.SearchHit;
20+
import org.elasticsearch.search.SearchResponseUtils;
21+
import org.elasticsearch.test.cluster.ElasticsearchCluster;
22+
import org.elasticsearch.test.cluster.util.resource.Resource;
23+
import org.junit.ClassRule;
24+
import org.junit.rules.RuleChain;
25+
import org.junit.rules.TestRule;
26+
27+
import java.io.IOException;
28+
import java.util.Arrays;
29+
import java.util.List;
30+
import java.util.Locale;
31+
import java.util.Map;
32+
import java.util.concurrent.atomic.AtomicReference;
33+
import java.util.stream.Collectors;
34+
35+
import static org.hamcrest.Matchers.containsInAnyOrder;
36+
import static org.hamcrest.Matchers.containsString;
37+
import static org.hamcrest.Matchers.equalTo;
38+
39+
public class RemoteClusterSecurityCrossClusterApiKeySigningIT extends AbstractRemoteClusterSecurityTestCase {
40+
41+
private static final AtomicReference<Map<String, Object>> API_KEY_MAP_REF = new AtomicReference<>();
42+
43+
static {
44+
fulfillingCluster = ElasticsearchCluster.local()
45+
.name("fulfilling-cluster")
46+
.apply(commonClusterConfig)
47+
.setting("remote_cluster_server.enabled", "true")
48+
.setting("remote_cluster.port", "0")
49+
.setting("xpack.security.remote_cluster_server.ssl.enabled", "true")
50+
.setting("xpack.security.remote_cluster_server.ssl.key", "remote-cluster.key")
51+
.setting("xpack.security.remote_cluster_server.ssl.certificate", "remote-cluster.crt")
52+
.configFile("signing_ca.crt", Resource.fromClasspath("signing/root.crt"))
53+
.setting("cluster.remote.signing.certificate_authorities", "signing_ca.crt")
54+
.keystore("xpack.security.remote_cluster_server.ssl.secure_key_passphrase", "remote-cluster-password")
55+
.build();
56+
57+
queryCluster = ElasticsearchCluster.local()
58+
.name("query-cluster")
59+
.apply(commonClusterConfig)
60+
.setting("xpack.security.remote_cluster_client.ssl.enabled", "true")
61+
.setting("xpack.security.remote_cluster_client.ssl.certificate_authorities", "remote-cluster-ca.crt")
62+
.configFile("signing.crt", Resource.fromClasspath("signing/signing.crt"))
63+
.setting("cluster.remote.my_remote_cluster.signing.certificate", "signing.crt")
64+
.configFile("signing.key", Resource.fromClasspath("signing/signing.key"))
65+
.setting("cluster.remote.my_remote_cluster.signing.key", "signing.key")
66+
.keystore("cluster.remote.my_remote_cluster.credentials", () -> {
67+
if (API_KEY_MAP_REF.get() == null) {
68+
final Map<String, Object> apiKeyMap = createCrossClusterAccessApiKey("""
69+
{
70+
"search": [
71+
{
72+
"names": ["index*", "not_found_index"]
73+
}
74+
]
75+
}""");
76+
API_KEY_MAP_REF.set(apiKeyMap);
77+
}
78+
return (String) API_KEY_MAP_REF.get().get("encoded");
79+
})
80+
.keystore("cluster.remote.invalid_remote.credentials", randomEncodedApiKey())
81+
.build();
82+
}
83+
84+
@ClassRule
85+
// Use a RuleChain to ensure that fulfilling cluster is started before query cluster
86+
public static TestRule clusterRule = RuleChain.outerRule(fulfillingCluster).around(queryCluster);
87+
88+
public void testCrossClusterSearchWithCrossClusterApiKeySigning() throws Exception {
89+
indexTestData();
90+
assertCrossClusterSearchSuccessfulWithResult();
91+
92+
// Change the CA to something that doesn't trust the signing cert
93+
updateClusterSettingsFulfillingCluster(
94+
Settings.builder().put("cluster.remote.signing.certificate_authorities", "transport-ca.crt").build()
95+
);
96+
assertCrossClusterAuthFail();
97+
98+
// Update settings on query cluster to ignore unavailable remotes
99+
updateClusterSettings(Settings.builder().put("cluster.remote.my_remote_cluster.skip_unavailable", Boolean.toString(true)).build());
100+
101+
assertCrossClusterSearchSuccessfulWithoutResult();
102+
103+
// TODO add test for certificate identity configured for API key but no signature provided (should 401)
104+
105+
// TODO add test for certificate identity not configured for API key but signature provided (should 200)
106+
107+
// TODO add test for certificate identity not configured for API key but wrong signature provided (should 401)
108+
109+
// TODO add test for certificate identity regex matching (should 200)
110+
}
111+
112+
private void assertCrossClusterAuthFail() {
113+
var responseException = assertThrows(ResponseException.class, () -> simpleCrossClusterSearch(randomBoolean()));
114+
assertThat(responseException.getResponse().getStatusLine().getStatusCode(), equalTo(401));
115+
assertThat(responseException.getMessage(), containsString("Failed to verify cross cluster api key signature certificate from [("));
116+
}
117+
118+
private void assertCrossClusterSearchSuccessfulWithoutResult() throws IOException {
119+
boolean alsoSearchLocally = randomBoolean();
120+
final Response response = simpleCrossClusterSearch(alsoSearchLocally);
121+
assertOK(response);
122+
}
123+
124+
private void assertCrossClusterSearchSuccessfulWithResult() throws IOException {
125+
boolean alsoSearchLocally = randomBoolean();
126+
final Response response = simpleCrossClusterSearch(alsoSearchLocally);
127+
assertOK(response);
128+
final SearchResponse searchResponse;
129+
try (var parser = responseAsParser(response)) {
130+
searchResponse = SearchResponseUtils.parseSearchResponse(parser);
131+
}
132+
try {
133+
final List<String> actualIndices = Arrays.stream(searchResponse.getHits().getHits())
134+
.map(SearchHit::getIndex)
135+
.collect(Collectors.toList());
136+
if (alsoSearchLocally) {
137+
assertThat(actualIndices, containsInAnyOrder("index1", "local_index"));
138+
} else {
139+
assertThat(actualIndices, containsInAnyOrder("index1"));
140+
}
141+
} finally {
142+
searchResponse.decRef();
143+
}
144+
}
145+
146+
private Response simpleCrossClusterSearch(boolean alsoSearchLocally) throws IOException {
147+
final var searchRequest = new Request(
148+
"GET",
149+
String.format(
150+
Locale.ROOT,
151+
"/%s%s:%s/_search?ccs_minimize_roundtrips=%s",
152+
alsoSearchLocally ? "local_index," : "",
153+
randomFrom("my_remote_cluster", "*", "my_remote_*"),
154+
randomFrom("index1", "*"),
155+
randomBoolean()
156+
)
157+
);
158+
return performRequestWithRemoteAccessUser(searchRequest);
159+
}
160+
161+
private void indexTestData() throws Exception {
162+
configureRemoteCluster();
163+
164+
// Fulfilling cluster
165+
{
166+
// Index some documents, so we can attempt to search them from the querying cluster
167+
final Request bulkRequest = new Request("POST", "/_bulk?refresh=true");
168+
bulkRequest.setJsonEntity(Strings.format("""
169+
{ "index": { "_index": "index1" } }
170+
{ "foo": "bar" }
171+
{ "index": { "_index": "index2" } }
172+
{ "bar": "foo" }
173+
{ "index": { "_index": "prefixed_index" } }
174+
{ "baz": "fee" }\n"""));
175+
assertOK(performRequestAgainstFulfillingCluster(bulkRequest));
176+
}
177+
178+
// Query cluster
179+
{
180+
// Index some documents, to use them in a mixed-cluster search
181+
final var indexDocRequest = new Request("POST", "/local_index/_doc?refresh=true");
182+
indexDocRequest.setJsonEntity("{\"local_foo\": \"local_bar\"}");
183+
assertOK(client().performRequest(indexDocRequest));
184+
185+
// Create user role with privileges for remote and local indices
186+
final var putRoleRequest = new Request("PUT", "/_security/role/" + REMOTE_SEARCH_ROLE);
187+
putRoleRequest.setJsonEntity("""
188+
{
189+
"description": "role with privileges for remote and local indices",
190+
"cluster": ["manage_own_api_key"],
191+
"indices": [
192+
{
193+
"names": ["local_index"],
194+
"privileges": ["read"]
195+
}
196+
],
197+
"remote_indices": [
198+
{
199+
"names": ["index1", "not_found_index", "prefixed_index"],
200+
"privileges": ["read", "read_cross_cluster"],
201+
"clusters": ["my_remote_cluster"]
202+
}
203+
]
204+
}""");
205+
assertOK(adminClient().performRequest(putRoleRequest));
206+
final var putUserRequest = new Request("PUT", "/_security/user/" + REMOTE_SEARCH_USER);
207+
putUserRequest.setJsonEntity("""
208+
{
209+
"password": "x-pack-test-password",
210+
"roles" : ["remote_search"]
211+
}""");
212+
assertOK(adminClient().performRequest(putUserRequest));
213+
}
214+
}
215+
216+
private void updateClusterSettingsFulfillingCluster(Settings settings) throws IOException {
217+
final var request = newXContentRequest(HttpMethod.PUT, "/_cluster/settings", (builder, params) -> {
218+
builder.startObject("persistent");
219+
settings.toXContent(builder, params);
220+
return builder.endObject();
221+
});
222+
223+
performRequestWithAdminUser(fulfillingClusterClient, request);
224+
}
225+
226+
private Response performRequestWithRemoteAccessUser(final Request request) throws IOException {
227+
request.setOptions(RequestOptions.DEFAULT.toBuilder().addHeader("Authorization", basicAuthHeaderValue(REMOTE_SEARCH_USER, PASS)));
228+
return client().performRequest(request);
229+
}
230+
}

x-pack/plugin/security/qa/multi-cluster/src/javaRestTest/java/org/elasticsearch/xpack/remotecluster/RemoteClusterSecurityFcActionAuthorizationIT.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ private static MockTransportService startTransport(
619619
action,
620620
SystemUser.crossClusterAccessSubjectInfo(TransportVersion.current(), nodeName)
621621
)
622-
).writeToContext(threadContext);
622+
).writeToContext(threadContext, null);
623623
connection.sendRequest(requestId, action, request, options);
624624
}
625625
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDSzCCAjOgAwIBAgIUPw9V/LIrB5Y+Krhqp1mXhK/BQDIwDQYJKoZIhvcNAQEL
3+
BQAwNDEyMDAGA1UEAxMpRWxhc3RpYyBDZXJ0aWZpY2F0ZSBUb29sIEF1dG9nZW5l
4+
cmF0ZWQgQ0EwIBcNMjUwOTE4MDczMzQ2WhgPMjA1MzAyMDMwNzMzNDZaMDQxMjAw
5+
BgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2VuZXJhdGVkIENB
6+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuQkSVLDqxWT83K+gfljq
7+
WWL5KQxiN/7XZ8ug6e5b+kY0MZQnaAUWNaj5RFgSMDB+2N6EWJMk5cmDXK7NB/xq
8+
gTbC/o3o7B9AZMTpu5Wbj8chRBCTTirRaIh79/VdLWDHriIgxGBLdMm/A2b3IW6H
9+
YeUGUdOszEjytCjslrrktnIMIQHlQQ5o/fSPslCunsm7P+rDyf3GjapflKtpcrIV
10+
cZKExaaCaqJtQYn66JCyr/ZFmjiTRPjPBcFx2SqAvkPXSK4SghvhKX69K+qDQWjF
11+
rPx9BUWgLnE00bJ27CCSCRzZ3dTgcZ86ou/2mOJqqpeCMacJGWnn7Cu1P3+LcgjT
12+
cQIDAQABo1MwUTAdBgNVHQ4EFgQUlL1P7M7/YCDULUeMUHPxDMtHT9swHwYDVR0j
13+
BBgwFoAUlL1P7M7/YCDULUeMUHPxDMtHT9swDwYDVR0TAQH/BAUwAwEB/zANBgkq
14+
hkiG9w0BAQsFAAOCAQEATCKw10zkCI21nuNppQZKFbHf/m3IZR9mZYYU0tKBSIy7
15+
KoCTHZUTadbJuDzJ8eDRiqnUuXHUXNijykEphvfpckNDhb6ty5g707kET3EYDfkh
16+
S1EKet2clM9DRqqcmFt3cyOmLJE3we7NjrNOuKNiwuXbGrqTTqNkqiiB3gWYOpSM
17+
uwtCz1Syyl4y5sjocedkikqaeIKtl2htN3tEYd0BfLNVo5hN/syP8WDT6FdpCDpY
18+
lZ2nqT622KDuusORCMTiC1qgUVR3RghPHy55Jq6Qq1+a1//E/Q9OfCs98JeUnoSp
19+
W/q1hUVlSN0Edsn1T5LehGMjiH3UVszWvEThUqNHuA==
20+
-----END CERTIFICATE-----
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDJTCCAg2gAwIBAgIVAIXaEONwJyih5d7KhnPYfqW5eNgKMA0GCSqGSIb3DQEB
3+
CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu
4+
ZXJhdGVkIENBMCAXDTI1MDkxODA5NDQwMVoYDzIwNTMwMjAzMDk0NDAxWjATMREw
5+
DwYDVQQDEwhpbnN0YW5jZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
6+
AJCOS/v9I34GaRD4BoMpIa/zhL1TBc1tqIDFKDm4Y/g4a+KUMJlaqHFRZkNI35LF
7+
TkgMkRJUrWEunXJ2wf2A2s+7xWykSoa+nJ2qghwDgBtoSBWSm6I2Hi9n400Mmde/
8+
xg912NzlfLJZH3la3/w3u7ENUY3GTNLeE5s5CpZAcOk+KQ/2/1Y7TgKPxyhbNtRA
9+
2whWD862pnJypskQ9UGgB3Zq5h+2llQ2sB367pE77DyvXReKLHfCtA3lmTob6pLm
10+
fK2cIBEJDwkFaAgrcWH5MwMkn+4v/Xw1PjAI4AVMOge+Rxt6waWxqQIJvOoyccXY
11+
Vdvo8swUAjMPnR6E/5+bwykCAwEAAaNNMEswHQYDVR0OBBYEFH1XQX26JBIwvu95
12+
xPhSCqOFrz9IMB8GA1UdIwQYMBaAFJS9T+zO/2Ag1C1HjFBz8QzLR0/bMAkGA1Ud
13+
EwQCMAAwDQYJKoZIhvcNAQELBQADggEBAIF/LkOYm52Q+buBqGS380HWkNitTLG2
14+
8qtICtXtLYd9673+c3RNIrW2CGFq3Z3TJ60FNvVT1z6NKiR8ZPUeqN+Avq5qN+dB
15+
u9SPRFOrszlD6+2ZkNaZyRs2w6NQa6zBZWs0Zp3+ouu4fUEdsa/UmKud6njLaAGA
16+
Rq8Sc7ckssykh1HKk8dOJt83GlvsBGXKALNv3vfHnMj+5XHC2NzZS5bn1IXWQE5z
17+
0z5cHHD4NHiuGBnTl7MI8KzrF/Axwc2krsVO7WIQ/GpDVVwrCoKyvNm54GfpIAE/
18+
ndH7bu9hGVM6swzpAdhQC/HK6Vc0NoGfoARXVRtxuEZmoq2amixHJJU=
19+
-----END CERTIFICATE-----

0 commit comments

Comments
 (0)