Skip to content

Conversation

jfreden
Copy link
Contributor

@jfreden jfreden commented Sep 30, 2025

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.

);
}
return decode(header);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We need the encoded header for the signature so I moved this to CrossClusterAccessHeaders.

.apply(commonClusterConfig)
.setting("xpack.security.remote_cluster_client.ssl.enabled", "true")
.setting("xpack.security.remote_cluster_client.ssl.certificate_authorities", "remote-cluster-ca.crt")
.configFile("signing.crt", Resource.fromClasspath("signing/signing.crt"))
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 adds a signature config to the query cluster to make sure we don't send it when communicating with older fulfilling clusters.


assertCrossClusterSearchSuccessfulWithoutResult();

// TODO add test for certificate identity configured for API key but no signature provided (should 401)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

When the cross cluster api key with a cert identity is available these test will be implemented here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll do this in a separate PR to keep scope manageable.

@jfreden jfreden force-pushed the add_signature_to_headers branch 3 times, most recently from da8e485 to 1ad64cf Compare October 3, 2025 12:34
@@ -1 +1 @@
esql_plan_with_no_columns,9186000
Copy link
Contributor Author

Choose a reason for hiding this comment

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

New transport version needed for make sure we don't serialize the new thread context header for a fulfilling cluster that doesn't support it.

private static final String STATIC_TEST_CLUSTER_ALIAS = "static_test_cluster";

public void testSignWithPemKeyConfig() {
public void testSignWithPemKeyConfig() throws GeneralSecurityException {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the verifier to move the auth error message building out of the verifier. That way the auth error can be constructed in a way that we can easily separate between cert chain validation error and signature verification error.

assert authentication.isApiKey() : "initial authentication for cross cluster access must be by API key";
assert false == authentication.isRunAs() : "initial authentication for cross cluster access cannot be run-as";

// TODO ALWAYS check if used api key has a certificate identity and do this verification conditionally based on that
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 TODO will be addressed in a follow up.

this(credentialsHeader, crossClusterAccessSubjectInfo, null);
}

private CrossClusterAccessHeaders(
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 constructor is only used when we read the headers, the other one when constructing the headers.

components.securityContext(),
this.authenticationService,
this.crossClusterApiKeySignatureManager,
crossClusterApiKeySignatureManager,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I pass the full manager here and only the verifier above because the manager will issue a new signer per remote cluster while we only have a single verifier per cluster.

if (connection.getTransportVersion().supports(ADD_CROSS_CLUSTER_API_KEY_SIGNATURE)) {
crossClusterAccessHeaders.writeToContext(threadContext, signer);
} else {
crossClusterAccessHeaders.writeToContext(threadContext, null);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Passing a null signer is the same as "do not generate a signature", not very explicit but probably acceptable. Even if a signature is configured there is no use in providing it unless the fulfilling cluster can validate it. I considered failing here but this could actually happen in production while upgrading and we don't want to fail requests during a rolling upgrade for example.

} catch (GeneralSecurityException e) {
logger.debug("failed certificate validation for Signature [" + signature + "]", e);
throw new ElasticsearchSecurityException("Failed to verify signature from [{}]", signature.certificates()[0], e);
// Make sure the provided certificate chain is trusted
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 change only removes the try-catch to let the caller handle different exceptions however they please.

}

private String certificateToString(X509Certificate certificate) {
public static String certificateToString(X509Certificate certificate) {
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 is changed to be static and public to be used in error messages.

);
}

private X509Certificate[] getTestCertificates() throws CertificateException, IOException {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mocking certs is hard and therefore I'm just reading a real one here.

@jfreden jfreden force-pushed the add_signature_to_headers branch from 1ad64cf to 1173919 Compare October 3, 2025 13:26
@jfreden jfreden marked this pull request as ready for review October 3, 2025 13:26
@elasticsearchmachine elasticsearchmachine added the needs:triage Requires assignment of a team area label label Oct 3, 2025
@jfreden jfreden added >enhancement :Security/Security Security issues without another label and removed needs:triage Requires assignment of a team area label labels Oct 3, 2025
@elasticsearchmachine elasticsearchmachine added the Team:Security Meta label for security team label Oct 3, 2025
@elasticsearchmachine
Copy link
Collaborator

Pinging @elastic/es-security (Team:Security)

@elasticsearchmachine
Copy link
Collaborator

Hi @jfreden, I've created a changelog YAML for you.

@jfreden jfreden changed the title Send cross cluster api key signature as headers Send cross cluster api key signature as header Oct 3, 2025

public Signer signerForClusterAlias(String clusterAlias) {
return new Signer(clusterAlias);
return keyPairByClusterAlias.containsKey(clusterAlias) ? new Signer(clusterAlias) : null;
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 has changed to only return a signer if we have a signing config. This makes it a little easier to understand if there is a signing config or not in a caller.

public void writeToContext(final ThreadContext ctx, @Nullable CrossClusterApiKeySignatureManager.Signer signer) throws IOException {
var encodedSubjectInfo = crossClusterAccessSubjectInfo.encode();
if (signer != null) {
var signature = signer.sign(encodedSubjectInfo, credentialsHeader);
Copy link
Contributor

Choose a reason for hiding this comment

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

Does the signed payload include anything request-specific (timestamps, nonces, etc.)? Do we need to worry about replay protection here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Started an internal thread on this. In summary: replay protection is already handled by TLS.

Copy link
Contributor

Choose a reason for hiding this comment

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

This is more of a general question for my understanding; do we need to worry about header size limits? Would the new certificate information we are adding have any possibility of breaching them?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since this is all sent over the transport protocol we don't have any special restrictions on headers. I think a concern could be if it gets expensive to generate, serialize, parse or validate the headers.


final String subjectInfoHeader = ctx.getHeader(CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY);
if (subjectInfoHeader == null) {
throw new IllegalArgumentException(
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Wondering if SecurityException would be more appropriate here?

Copy link
Contributor Author

@jfreden jfreden Oct 7, 2025

Choose a reason for hiding this comment

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

This code actually just moved from the SubjectInfo class because I needed the encoded string before decoding it to create the signature.

I think IllegalArgumentException is appropriate here since we're missing the subject info argument in the API call and it appears to be the convention throughout the RCS implementation. I think SecurityException could make sense too, since you're missing a required auth argument.

Copy link
Contributor

@gmjehovich gmjehovich left a comment

Choose a reason for hiding this comment

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

LGTM! Will leave it up to you whether or not you also want Slobodan to look it over

}

public void writeToContext(final ThreadContext ctx) throws IOException {
public void writeToContext(final ThreadContext ctx, @Nullable CrossClusterApiKeySignatureManager.Signer signer) 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.

nit: you could overload the method here:

public void writeToContext(final ThreadContext ctx) throws IOException {
    return writeToContext(ctx, null);
}

which will cut down the footprint of the various writeToContext(threadContext, null); diffs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for reviewing! I was actually considering that but eventually decided not to since in production code we always specify the signer (unless we're on a different transport version, but that code will eventually go away).

I don't think we should add overloaded methods only to reduce the footprint of the test code, since it can result in test specific code that doesn't reflect reality.

Copy link
Contributor

Choose a reason for hiding this comment

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

oh I see! yeah, it's usually a code smell to update src/main merely for the sake of src/test

var signature = crossClusterAccessHeaders.signature();
// Always validate a signature if provided
if (signature != null) {
verifySignature(signature, crossClusterAccessHeaders);
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure if this is a good place to verify signature. Is there a reason why we don't pull verification into ApiKeyService?

What do you think if we extend ApiKeyCredentials with signature and let ApiKeyService verify API key credential, signature and caching?

Another advantage is that we would also have CrossClusterAccessAuthenticationService#authenticateHeaders method be able to do an early verification.

Also, if I'm not mistaken, throwing authentication exception here would cause us to audit the same request twice: once successfully because API keys auth succeeded and second failure because we failed the signature verification.

Copy link
Contributor

Choose a reason for hiding this comment

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

Well actually, I haven't fully thought about caching bit. Intuitively I'd say we should cache successful auth and signature verification results. But will need to look into this closely.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We talked about this offline, the idea is to follow this up when we do the certificate identity verification.

In summary we'll:

  1. Do the signature and certificate verification up front and extract the certificate identity from the request around here before anything else happens (so before regular auth)
  2. Add a new certificateIdentity field to ApiKeyCredentials here and populate it around here
  3. Only validate that the certificateIdentity against the field from that api key doc in the index later somewhere around here

In terms of cache we agreed that at least initially we won't do any caching for the signature verification since the signature would be unique per user (subject info) and risks being very large when there are a lot of concurrent users. We should revisit this decision in the follow up.

Comment on lines 40 to 49
static final Set<String> ALLOWED_TRANSPORT_HEADERS = Stream.concat(
Stream.of(
CROSS_CLUSTER_ACCESS_CREDENTIALS_HEADER_KEY,
CROSS_CLUSTER_ACCESS_SUBJECT_INFO_HEADER_KEY,
CROSS_CLUSTER_ACCESS_SIGNATURE_HEADER_KEY,
AuditUtil.AUDIT_REQUEST_ID,
Task.TRACE_STATE
),
Task.HEADERS_TO_COPY.stream()
).collect(Collectors.toUnmodifiableSet());
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice. Alternatively to this would be using Sets.union().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice, that's cleaner!

Copy link
Contributor

@slobodanadamovic slobodanadamovic left a comment

Choose a reason for hiding this comment

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

LGTM 👍

@jfreden jfreden force-pushed the add_signature_to_headers branch from 328e56b to 66791a0 Compare October 10, 2025 08:03
@jfreden jfreden added the auto-merge-without-approval Automatically merge pull request when CI checks pass (NB doesn't wait for reviews!) label Oct 10, 2025
@elasticsearchmachine elasticsearchmachine merged commit 74da8c0 into elastic:main Oct 10, 2025
40 checks passed
@jfreden jfreden deleted the add_signature_to_headers branch October 10, 2025 10:22
elasticsearchmachine pushed a commit that referenced this pull request Oct 13, 2025
We have to trust the cert we're signing with. Introduced in
#135674.

Resolves: #136368
georgewallace pushed a commit to georgewallace/elasticsearch that referenced this pull request Oct 13, 2025
We have to trust the cert we're signing with. Introduced in
elastic#135674.

Resolves: elastic#136368
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto-merge-without-approval Automatically merge pull request when CI checks pass (NB doesn't wait for reviews!) >enhancement :Security/Security Security issues without another label Team:Security Meta label for security team v9.3.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants