Skip to content

Support CA fingerprint validation#1499

Merged
delvedor merged 24 commits intomasterfrom
ca-fingerprint
Aug 2, 2021
Merged

Support CA fingerprint validation#1499
delvedor merged 24 commits intomasterfrom
ca-fingerprint

Conversation

@delvedor
Copy link
Copy Markdown
Member

@delvedor delvedor commented Jul 22, 2021

As titled.

Usage:

const client = new Client({
  node: 'https://localhost:9200',
  caFingerprint: 'string'
})

TODO:

  • More unit test
  • Docs
  • Integration test with Elasticsearch

Comment on lines +132 to +136
if (socket.authorized === false) {
onError(new Error(socket.authorizationError))
request.once('error', () => {}) // we need to catch the request aborted error
return request.abort()
}
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I’m not entirely sure this is needed, as this check would catch the self-signed certificate.
One of the test is failing because of this check, if we remove it, everything works as expected.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I suspect it's not needed since we're going to validate a fingerprint of the top-most CA certificate, that will most-likely be a self-signed one (from the tech spec The fingerprint (SHA256) of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for TLS. ). @jkakavas is my understanding correct?

Copy link
Copy Markdown

@jkakavas jkakavas Jul 28, 2021

Choose a reason for hiding this comment

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

@jkakavas is my understanding correct?

Yes. We elected to use the CA certificate for the pinning as it originally felt like it provided for more options. It will be a self signed CA certificate that Elasticsearch generates on startup for security on by default but in general, it is common/fine/expected that CA certificates ( the root ones ) are self-signed

Copy link
Copy Markdown

@thomheymann thomheymann left a comment

Choose a reason for hiding this comment

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

I haven't tested this locally yet but added a few comments based on the code.

One general question I have is how we can avoid the "self signed certificate in certificate chain" error nodejs will throw during interactive setup when connecting to the (at that point) untrusted cert. We can't ask users to set NODE_TLS_REJECT_UNAUTHORIZED=0 and setting ssl.verificationMode=none in the elasticsearch client doesn't seem to prevent it.

this.headers = prepareHeaders(opts.headers, opts.auth)
this.deadCount = 0
this.resurrectTimeout = 0
this.certFingerprint = opts.certFingerprint
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

question: should we name it caCertFingerprint? I assume we're planning to validate fingerprint of the CA certificate, not the leaf one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good call, I would say that caFingerprint is the best, so we avoid the double "certificate" repetition :)

Copy link
Copy Markdown
Contributor

@azasypkin azasypkin Jul 26, 2021

Choose a reason for hiding this comment

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

Technically we don't repeat (or rather repeat, but legitimately) since it's Certificate Authority certificate fingerprint, but either works for me 🙂

Comment on lines +132 to +136
if (socket.authorized === false) {
onError(new Error(socket.authorizationError))
request.once('error', () => {}) // we need to catch the request aborted error
return request.abort()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I suspect it's not needed since we're going to validate a fingerprint of the top-most CA certificate, that will most-likely be a self-signed one (from the tech spec The fingerprint (SHA256) of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for TLS. ). @jkakavas is my understanding correct?

Copy link
Copy Markdown
Contributor

@sethmlarson sethmlarson left a comment

Choose a reason for hiding this comment

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

LGTM!

@delvedor delvedor marked this pull request as ready for review July 26, 2021 14:56
Copy link
Copy Markdown

@thomheymann thomheymann left a comment

Choose a reason for hiding this comment

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

I've tested this locally and the fingerprint is correctly verified. 🥳

However, this only works when running node with NODE_TLS_REJECT_UNAUTHORIZED=0 flag. Otherwise we don't get to the verification step since node throws an error for self signed certificates.

Asking users to run Kibana with that flag is not a viable option. Appreciate that this is node throwing an error but could we set rejectUnauthorized to false when caFingerprint option is set on the client to avoid this? Is there another option you can think of?

$ node test.js

ERROR ConnectionError: self signed certificate in certificate chain
    at ClientRequest.onError (/Users/thom/Repositories/elasticsearch-js/lib/Connection.js:116:16)
    at ClientRequest.emit (events.js:375:28)
    at TLSSocket.socketErrorListener (_http_client.js:475:9)
    at TLSSocket.emit (events.js:375:28)
    at emitErrorNT (internal/streams/destroy.js:106:8)
    at emitErrorCloseNT (internal/streams/destroy.js:74:3)
    at processTicksAndRejections (internal/process/task_queues.js:82:21) 

@delvedor
Copy link
Copy Markdown
Member Author

Appreciate that this is node throwing an error but could we set rejectUnauthorized to false when caFingerprint option is set on the client to avoid this? Is there another option you can think of?

I'm fine to setting rejectUnauthorized by default when caFingerprint is configured, I'll do it :)

Copy link
Copy Markdown

@thomheymann thomheymann left a comment

Choose a reason for hiding this comment

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

As discussed offline, let's leave this as opt in and let the consumer decide whether to disable rejectUnauthorized or not for now.

Otherwise LGTM! Thanks for implementing this so swiftly. 🙏

Copy link
Copy Markdown
Contributor

@azasypkin azasypkin left a comment

Choose a reason for hiding this comment

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

LGTM from Kibana standpoint, thanks! Just one issue we discussed over Slack: handle/test the case when caFingerprint is provided, but ES client tries to connect to HTTP address.

Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
Copy link
Copy Markdown
Contributor

@azasypkin azasypkin left a comment

Choose a reason for hiding this comment

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

Still LGTM!

delvedor and others added 2 commits July 28, 2021 16:10
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
@jkakavas
Copy link
Copy Markdown

As discussed offline, let's leave this as opt in and let the consumer decide whether to disable rejectUnauthorized or not for now.

Just validating my understanding that Kibana will set this so that the users won't need to configure something extra. Can this be set per connection and only set it for the initial call to the enroll API ? We wouldn't want to set this globally off for Kibana

Copy link
Copy Markdown

@jkakavas jkakavas left a comment

Choose a reason for hiding this comment

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

Added just a few comments, thanks for tackling this so quickly!

// the fingerprint (SHA256) of the CA certificate that is used to sign the certificate that the Elasticsearch node presents for TLS.
caFingerprint: '20:0D:CA:FA:76:...',
ssl: {
// might be required if it's a self-signed certificate
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Most CA certificates are self signed. Well root CAs are, intermediate aren't ( are signed by the root ) but this is not very uncommon

/* istanbul ignore next */
certificate = certificate.issuerCertificate
} else {
break
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

when issuerCertificate is undefined, the certificate is invalid or malformed, let's not try to use it. Even if we don't care about the standards, then this here is the peer certificate, and not it's issuer's.

Copy link
Copy Markdown
Contributor

@azasypkin azasypkin Aug 2, 2021

Choose a reason for hiding this comment

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

when issuerCertificate is undefined, the certificate is invalid or malformed, let's not try to use it.

Hmm, it seems that missing issuerCertificate in certificate is one thing and missing issuerCertificate property in a certificate parsed by NodeJS is a different thing. If host just doesn't return a full chain, I guess, we'll have an empty issuerCertificate property (or if we pass false to socket.getPeerCertificate).

If ES is configured to not return full chain, do we treat it as a misconfiguration @jkakavas or you meant something else?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

missing issuerCertificate in certificate is one thing and missing issuerCertificate property in a certificate parsed by NodeJS is a different thing

Correct. I focused on the former I guess :/ Still though, we're in a method called getIssuerCertificate and we shouldn't return a certificate just because we couldn't get it's issuer. If ES doesn't return the full chain, then caFingerprint does not make much sense to be set.

Copy link
Copy Markdown
Contributor

@azasypkin azasypkin Aug 2, 2021

Choose a reason for hiding this comment

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

Still though, we're in a method called getIssuerCertificate and we shouldn't return a certificate just because we couldn't get it's issuer.

Yep

If ES doesn't return the full chain, then caFingerprint does not make much sense to be set.

Would it make sense to rename it to rootCAFingerprint then? Because we may still have a number intermediate CA certificates in the chain even though root CA certificate isn't there.

And just for my own education, if I issue a Let's Encrypt certificate to use with my ES instance, I still have to configure ES with the full chain, even though root CA certificate is issued by the well-known public CA, right?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Would it make sense to rename it to rootCAFingerprint then? Because we may still have a number intermediate CA certificates in the chain even though root CA certificate isn't there.

Possibly, it makes it clearer I think but we can handle this with documentation also.

And just for my own education, if I issue a Let's Encrypt certificate to use with my ES instance, I still have to configure ES with the full chain, even though root CA certificate is issued by the well-known public CA, right?

Well, we don't support the enrollment process for arbitrary configurations but if you'd want to use let's encrypt and use caFingerprint in a way in your es client, then yes ,you'd need to do that.

Generically, it would make more sense for a client to support leaf certificate pinning ( as in I want to trust this server certificate only ). The idea behind us using CA certificate pinning for the enrollment process is that it allows you to get a single enrollment token that might contain many addresses for all ES nodes in a cluster and you can try/selct any of them and validate the connection with a single fingerprint ( as opposed to one per node )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yeah, but I had an impression that caFingerprint we've introduced here is not specific to enrollment process otherwise we should have named it differently I guess.

Generically, it would make more sense for a client to support leaf certificate pinning ( as in I want to trust this server certificate only ).

That's actually what I was alluding to initially, pinning to the leaf (this specific server) or intermediate CA (this specific group of servers) seems to be a perfectly valid use case. My concern was that ES seems to not explicitly require full chains and moreover works fine with non-complete chains. That'd be enough for the aforementioned use case, but it won't work if we treat certificate with null/undefined issuerCertificate as invalid (the one that NodeJS parses, aka if (certificate.issuerCertificate == null) return null).

Anyway, PR is merged, so let's if it ever becomes a problem 🤷‍♂️

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

That's actually what I was alluding to initially, pinning to the leaf (this specific server) or intermediate CA (this specific group of servers) seems to be a perfectly valid use case.

Good point, I'd be 👍 This would cover security on by default enrollment process, and more generic cases at the same time. Maybe we can do in a followup ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Maybe we can do in a followup ?

Yeah, I'd 👍 too.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'm exactly having this issue. Returned certificate only has issuer not issuerCertificate. It results in error: Unhandled Rejection at: Promise [object Promise] reason ConnectionError: Invalid or malformed certificate. This is in contrast with Python client where ssl_assert_fingerprint parameter works as expected with self-signed certificate that elastic generates by default.

delvedor and others added 3 commits July 29, 2021 10:25
Co-authored-by: Ioannis Kakavas <ioannis@elastic.co>
Co-authored-by: Ioannis Kakavas <ioannis@elastic.co>
Co-authored-by: Ioannis Kakavas <ioannis@elastic.co>
@thomheymann
Copy link
Copy Markdown

thomheymann commented Jul 29, 2021

Just validating my understanding that Kibana will set this so that the users won't need to configure something extra. Can this be set per connection and only set it for the initial call to the enroll API ? We wouldn't want to set this globally off for Kibana

Correct, Kibana will disable rejectUnauthorized for the enrolment call so that we can connect to Elasticsearch until we have established trust. During that call we verify the CA using the fingerprint so we can still guarantee that we're speaking to the correct node.

All other requests to Elasticsearch will keep that security check enabled.

@delvedor delvedor requested a review from jkakavas July 30, 2021 10:48
Copy link
Copy Markdown

@jkakavas jkakavas left a comment

Choose a reason for hiding this comment

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

LGTM with my latest comments addressed, I don't think this needs another round. Thanks Tomas!

delvedor and others added 3 commits July 30, 2021 15:45
Co-authored-by: Ioannis Kakavas <ioannis@elastic.co>
Co-authored-by: Ioannis Kakavas <ioannis@elastic.co>
@delvedor delvedor merged commit 2d1505e into master Aug 2, 2021
@delvedor delvedor deleted the ca-fingerprint branch August 2, 2021 09:20
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Aug 2, 2021

The backport to 7.x failed:

The process '/usr/bin/git' failed with exit code 1

To backport manually, run these commands in your terminal:

# Fetch latest updates from GitHub
git fetch
# Create a new working tree
git worktree add .worktrees/backport-7.x 7.x
# Navigate to the new working tree
cd .worktrees/backport-7.x
# Create a new branch
git switch --create backport-1499-to-7.x
# Cherry-pick the merged commit of this pull request and resolve the conflicts
git cherry-pick --mainline 1 2d1505eb2b7ad05387ca8ba8a6874f580a126ecd
# Push it to GitHub
git push --set-upstream origin backport-1499-to-7.x
# Go back to the original working tree
cd ../..
# Delete the working tree
git worktree remove .worktrees/backport-7.x

Then, create a pull request where the base branch is 7.x and the compare/head branch is backport-1499-to-7.x.

delvedor added a commit that referenced this pull request Aug 2, 2021
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
Co-authored-by: Ioannis Kakavas <ioannis@elastic.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants