From e8c0b1f774ab92e2f506eb537fa89b6b36547f45 Mon Sep 17 00:00:00 2001
From: Joe Portner <5295965+jportner@users.noreply.github.com>
Date: Thu, 14 Jan 2021 09:15:41 -0500
Subject: [PATCH] Fixes incomplete client cert chain when using PKI
authentication with the login selector (#88229)
---
...n-core-server.ikibanasocket.getprotocol.md | 17 +
...kibana-plugin-core-server.ikibanasocket.md | 2 +
...n-core-server.ikibanasocket.renegotiate.md | 29 ++
src/core/server/http/router/socket.test.ts | 80 ++++-
src/core/server/http/router/socket.ts | 44 ++-
src/core/server/server.api.md | 5 +
.../authentication/providers/pki.test.ts | 307 ++++++++++++------
.../server/authentication/providers/pki.ts | 58 +++-
8 files changed, 414 insertions(+), 128 deletions(-)
create mode 100644 docs/development/core/server/kibana-plugin-core-server.ikibanasocket.getprotocol.md
create mode 100644 docs/development/core/server/kibana-plugin-core-server.ikibanasocket.renegotiate.md
diff --git a/docs/development/core/server/kibana-plugin-core-server.ikibanasocket.getprotocol.md b/docs/development/core/server/kibana-plugin-core-server.ikibanasocket.getprotocol.md
new file mode 100644
index 0000000000000..720091174629a
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.ikibanasocket.getprotocol.md
@@ -0,0 +1,17 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IKibanaSocket](./kibana-plugin-core-server.ikibanasocket.md) > [getProtocol](./kibana-plugin-core-server.ikibanasocket.getprotocol.md)
+
+## IKibanaSocket.getProtocol() method
+
+Returns a string containing the negotiated SSL/TLS protocol version of the current connection. The value 'unknown' will be returned for connected sockets that have not completed the handshaking process. The value null will be returned for server sockets or disconnected client sockets. See https://www.openssl.org/docs/man1.0.2/ssl/SSL\_get\_version.html for more information.
+
+Signature:
+
+```typescript
+getProtocol(): string | null;
+```
+Returns:
+
+`string | null`
+
diff --git a/docs/development/core/server/kibana-plugin-core-server.ikibanasocket.md b/docs/development/core/server/kibana-plugin-core-server.ikibanasocket.md
index afcabd834a1aa..99923aecef8df 100644
--- a/docs/development/core/server/kibana-plugin-core-server.ikibanasocket.md
+++ b/docs/development/core/server/kibana-plugin-core-server.ikibanasocket.md
@@ -26,4 +26,6 @@ export interface IKibanaSocket
| [getPeerCertificate(detailed)](./kibana-plugin-core-server.ikibanasocket.getpeercertificate.md) | |
| [getPeerCertificate(detailed)](./kibana-plugin-core-server.ikibanasocket.getpeercertificate_1.md) | |
| [getPeerCertificate(detailed)](./kibana-plugin-core-server.ikibanasocket.getpeercertificate_2.md) | Returns an object representing the peer's certificate. The returned object has some properties corresponding to the field of the certificate. If detailed argument is true the full chain with issuer property will be returned, if false only the top certificate without issuer property. If the peer does not provide a certificate, it returns null. |
+| [getProtocol()](./kibana-plugin-core-server.ikibanasocket.getprotocol.md) | Returns a string containing the negotiated SSL/TLS protocol version of the current connection. The value 'unknown' will be returned for connected sockets that have not completed the handshaking process. The value null will be returned for server sockets or disconnected client sockets. See https://www.openssl.org/docs/man1.0.2/ssl/SSL\_get\_version.html for more information. |
+| [renegotiate(options)](./kibana-plugin-core-server.ikibanasocket.renegotiate.md) | Renegotiates a connection to obtain the peer's certificate. This cannot be used when the protocol version is TLSv1.3. |
diff --git a/docs/development/core/server/kibana-plugin-core-server.ikibanasocket.renegotiate.md b/docs/development/core/server/kibana-plugin-core-server.ikibanasocket.renegotiate.md
new file mode 100644
index 0000000000000..f39d3c08d9f0b
--- /dev/null
+++ b/docs/development/core/server/kibana-plugin-core-server.ikibanasocket.renegotiate.md
@@ -0,0 +1,29 @@
+
+
+[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [IKibanaSocket](./kibana-plugin-core-server.ikibanasocket.md) > [renegotiate](./kibana-plugin-core-server.ikibanasocket.renegotiate.md)
+
+## IKibanaSocket.renegotiate() method
+
+Renegotiates a connection to obtain the peer's certificate. This cannot be used when the protocol version is TLSv1.3.
+
+Signature:
+
+```typescript
+renegotiate(options: {
+ rejectUnauthorized?: boolean;
+ requestCert?: boolean;
+ }): Promise;
+```
+
+## Parameters
+
+| Parameter | Type | Description |
+| --- | --- | --- |
+| options | {
rejectUnauthorized?: boolean;
requestCert?: boolean;
}
| The options may contain the following fields: rejectUnauthorized, requestCert (See tls.createServer() for details). |
+
+Returns:
+
+`Promise`
+
+A Promise that will be resolved if renegotiation succeeded, or will be rejected if renegotiation failed.
+
diff --git a/src/core/server/http/router/socket.test.ts b/src/core/server/http/router/socket.test.ts
index c813dcf3fc806..5a78edde2e0db 100644
--- a/src/core/server/http/router/socket.test.ts
+++ b/src/core/server/http/router/socket.test.ts
@@ -22,10 +22,10 @@ import { KibanaSocket } from './socket';
describe('KibanaSocket', () => {
describe('getPeerCertificate', () => {
- it('returns null for net.Socket instance', () => {
+ it('returns `null` for net.Socket instance', () => {
const socket = new KibanaSocket(new Socket());
- expect(socket.getPeerCertificate()).toBe(null);
+ expect(socket.getPeerCertificate()).toBeNull();
});
it('delegates a call to tls.Socket instance', () => {
@@ -40,20 +40,82 @@ describe('KibanaSocket', () => {
expect(result).toBe(cert);
});
- it('returns null if tls.Socket getPeerCertificate returns null', () => {
+ it('returns `null` if tls.Socket getPeerCertificate returns null', () => {
const tlsSocket = new TLSSocket(new Socket());
jest.spyOn(tlsSocket, 'getPeerCertificate').mockImplementation(() => null as any);
const socket = new KibanaSocket(tlsSocket);
- expect(socket.getPeerCertificate()).toBe(null);
+ expect(socket.getPeerCertificate()).toBeNull();
});
- it('returns null if tls.Socket getPeerCertificate returns empty object', () => {
+ it('returns `null` if tls.Socket getPeerCertificate returns empty object', () => {
const tlsSocket = new TLSSocket(new Socket());
jest.spyOn(tlsSocket, 'getPeerCertificate').mockImplementation(() => ({} as any));
const socket = new KibanaSocket(tlsSocket);
- expect(socket.getPeerCertificate()).toBe(null);
+ expect(socket.getPeerCertificate()).toBeNull();
+ });
+ });
+
+ describe('getProtocol', () => {
+ it('returns `null` for net.Socket instance', () => {
+ const socket = new KibanaSocket(new Socket());
+
+ expect(socket.getProtocol()).toBeNull();
+ });
+
+ it('delegates a call to tls.Socket instance', () => {
+ const tlsSocket = new TLSSocket(new Socket());
+ const protocol = 'TLSv1.2';
+ const spy = jest.spyOn(tlsSocket, 'getProtocol').mockImplementation(() => protocol);
+ const socket = new KibanaSocket(tlsSocket);
+ const result = socket.getProtocol();
+
+ expect(spy).toBeCalledTimes(1);
+ expect(result).toBe(protocol);
+ });
+
+ it('returns `null` if tls.Socket getProtocol returns null', () => {
+ const tlsSocket = new TLSSocket(new Socket());
+ jest.spyOn(tlsSocket, 'getProtocol').mockImplementation(() => null as any);
+ const socket = new KibanaSocket(tlsSocket);
+
+ expect(socket.getProtocol()).toBeNull();
+ });
+ });
+
+ describe('renegotiate', () => {
+ it('throws error for net.Socket instance', async () => {
+ const socket = new KibanaSocket(new Socket());
+
+ expect(() => socket.renegotiate({})).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Cannot renegotiate a connection when TLS is not enabled."`
+ );
+ });
+
+ it('delegates a call to tls.Socket instance', async () => {
+ const tlsSocket = new TLSSocket(new Socket());
+ const result = Symbol();
+ const spy = jest.spyOn(tlsSocket, 'renegotiate').mockImplementation((_, callback) => {
+ callback(result as any);
+ return undefined;
+ });
+ const socket = new KibanaSocket(tlsSocket);
+
+ expect(socket.renegotiate({})).resolves.toBe(result);
+ expect(spy).toBeCalledTimes(1);
+ });
+
+ it('throws error if tls.Socket renegotiate returns error', async () => {
+ const tlsSocket = new TLSSocket(new Socket());
+ const error = new Error('Oh no!');
+ jest.spyOn(tlsSocket, 'renegotiate').mockImplementation((_, callback) => {
+ callback(error);
+ return undefined;
+ });
+ const socket = new KibanaSocket(tlsSocket);
+
+ expect(() => socket.renegotiate({})).rejects.toThrow(error);
});
});
@@ -68,12 +130,11 @@ describe('KibanaSocket', () => {
const tlsSocket = new TLSSocket(new Socket());
tlsSocket.authorized = true;
- let socket = new KibanaSocket(tlsSocket);
+ const socket = new KibanaSocket(tlsSocket);
expect(tlsSocket.authorized).toBe(true);
expect(socket.authorized).toBe(true);
tlsSocket.authorized = false;
- socket = new KibanaSocket(tlsSocket);
expect(tlsSocket.authorized).toBe(false);
expect(socket.authorized).toBe(false);
});
@@ -90,13 +151,12 @@ describe('KibanaSocket', () => {
const tlsSocket = new TLSSocket(new Socket());
tlsSocket.authorizationError = undefined as any;
- let socket = new KibanaSocket(tlsSocket);
+ const socket = new KibanaSocket(tlsSocket);
expect(tlsSocket.authorizationError).toBeUndefined();
expect(socket.authorizationError).toBeUndefined();
const authorizationError = new Error('some error');
tlsSocket.authorizationError = authorizationError;
- socket = new KibanaSocket(tlsSocket);
expect(tlsSocket.authorizationError).toBe(authorizationError);
expect(socket.authorizationError).toBe(authorizationError);
diff --git a/src/core/server/http/router/socket.ts b/src/core/server/http/router/socket.ts
index 83bf65a288c4b..dcf583140e521 100644
--- a/src/core/server/http/router/socket.ts
+++ b/src/core/server/http/router/socket.ts
@@ -19,6 +19,7 @@
import { Socket } from 'net';
import { DetailedPeerCertificate, PeerCertificate, TLSSocket } from 'tls';
+import { promisify } from 'util';
/**
* A tiny abstraction for TCP socket.
@@ -38,6 +39,20 @@ export interface IKibanaSocket {
*/
getPeerCertificate(detailed?: boolean): PeerCertificate | DetailedPeerCertificate | null;
+ /**
+ * Returns a string containing the negotiated SSL/TLS protocol version of the current connection. The value 'unknown' will be returned for
+ * connected sockets that have not completed the handshaking process. The value null will be returned for server sockets or disconnected
+ * client sockets. See https://www.openssl.org/docs/man1.0.2/ssl/SSL_get_version.html for more information.
+ */
+ getProtocol(): string | null;
+
+ /**
+ * Renegotiates a connection to obtain the peer's certificate. This cannot be used when the protocol version is TLSv1.3.
+ * @param options - The options may contain the following fields: rejectUnauthorized, requestCert (See tls.createServer() for details).
+ * @returns A Promise that will be resolved if renegotiation succeeded, or will be rejected if renegotiation failed.
+ */
+ renegotiate(options: { rejectUnauthorized?: boolean; requestCert?: boolean }): Promise;
+
/**
* Indicates whether or not the peer certificate was signed by one of the specified CAs. When TLS
* isn't used the value is `undefined`.
@@ -52,15 +67,14 @@ export interface IKibanaSocket {
}
export class KibanaSocket implements IKibanaSocket {
- readonly authorized?: boolean;
- readonly authorizationError?: Error;
-
- constructor(private readonly socket: Socket) {
- if (this.socket instanceof TLSSocket) {
- this.authorized = this.socket.authorized;
- this.authorizationError = this.socket.authorizationError;
- }
+ public get authorized() {
+ return this.socket instanceof TLSSocket ? this.socket.authorized : undefined;
}
+ public get authorizationError() {
+ return this.socket instanceof TLSSocket ? this.socket.authorizationError : undefined;
+ }
+
+ constructor(private readonly socket: Socket) {}
getPeerCertificate(detailed: true): DetailedPeerCertificate | null;
getPeerCertificate(detailed: false): PeerCertificate | null;
@@ -76,4 +90,18 @@ export class KibanaSocket implements IKibanaSocket {
}
return null;
}
+
+ public getProtocol() {
+ if (this.socket instanceof TLSSocket) {
+ return this.socket.getProtocol();
+ }
+ return null;
+ }
+
+ public async renegotiate(options: { rejectUnauthorized?: boolean; requestCert?: boolean }) {
+ if (this.socket instanceof TLSSocket) {
+ return promisify(this.socket.renegotiate.bind(this.socket))(options);
+ }
+ return Promise.reject(new Error('Cannot renegotiate a connection when TLS is not enabled.'));
+ }
}
diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md
index 0d7156bbe998d..f8a1d41295df5 100644
--- a/src/core/server/server.api.md
+++ b/src/core/server/server.api.md
@@ -1104,6 +1104,11 @@ export interface IKibanaSocket {
// (undocumented)
getPeerCertificate(detailed: false): PeerCertificate | null;
getPeerCertificate(detailed?: boolean): PeerCertificate | DetailedPeerCertificate | null;
+ getProtocol(): string | null;
+ renegotiate(options: {
+ rejectUnauthorized?: boolean;
+ requestCert?: boolean;
+ }): Promise;
}
// @public @deprecated
diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts
index 5ccf2ead0a8c8..88753f8dc2ab1 100644
--- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts
+++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts
@@ -31,7 +31,7 @@ interface MockPeerCertificate extends Partial {
fingerprint256: string;
}
-function getMockPeerCertificate(chain: string[] | string) {
+function getMockPeerCertificate(chain: string[] | string, isChainIncomplete = false) {
const mockPeerCertificate = {} as MockPeerCertificate;
(Array.isArray(chain) ? chain : [chain]).reduce(
@@ -39,11 +39,16 @@ function getMockPeerCertificate(chain: string[] | string) {
certificate.fingerprint256 = fingerprint;
certificate.raw = { toString: (enc: string) => `fingerprint:${fingerprint}:${enc}` };
- // Imitate self-signed certificate that is issuer for itself.
- certificate.issuerCertificate = index === fingerprintChain.length - 1 ? certificate : {};
+ if (index === fingerprintChain.length - 1) {
+ // If the chain is incomplete, set the issuer to undefined.
+ // Otherwise, imitate self-signed certificate that is issuer for itself.
+ certificate.issuerCertificate = isChainIncomplete ? undefined : certificate;
+ } else {
+ certificate.issuerCertificate = {};
+ }
// Imitate other fields for logging assertions
- certificate.subject = 'mock subject';
+ certificate.subject = `mock subject(${fingerprint})`;
certificate.issuer = 'mock issuer';
certificate.subjectaltname = 'mock subjectaltname';
certificate.valid_from = 'mock valid_from';
@@ -60,17 +65,25 @@ function getMockPeerCertificate(chain: string[] | string) {
function getMockSocket({
authorized = false,
peerCertificate = null,
+ protocol = 'TLSv1.2',
+ renegotiateError = null,
}: {
authorized?: boolean;
peerCertificate?: MockPeerCertificate | null;
+ protocol?: string;
+ renegotiateError?: Error | null;
} = {}) {
const socket = new TLSSocket(new Socket());
socket.authorized = authorized;
if (!authorized) {
socket.authorizationError = new Error('mock authorization error');
}
- socket.getPeerCertificate = jest.fn().mockReturnValue(peerCertificate);
- return socket;
+ const mockGetPeerCertificate = jest.fn().mockReturnValue(peerCertificate);
+ const mockRenegotiate = jest.fn().mockImplementation((_, callback) => callback(renegotiateError));
+ socket.getPeerCertificate = mockGetPeerCertificate;
+ socket.renegotiate = mockRenegotiate;
+ socket.getProtocol = jest.fn().mockReturnValue(protocol);
+ return { socket, mockGetPeerCertificate, mockRenegotiate };
}
function expectAuthenticateCall(
@@ -95,72 +108,192 @@ describe('PKIAuthenticationProvider', () => {
afterEach(() => jest.clearAllMocks());
+ function expectDebugLogs(...messages: string[]) {
+ for (const message of messages) {
+ expect(mockOptions.logger.debug).toHaveBeenCalledWith(message);
+ }
+ }
+
function defineCommonLoginAndAuthenticateTests(
operation: (request: KibanaRequest) => Promise
) {
it('does not handle unauthorized requests.', async () => {
- const request = httpServerMock.createKibanaRequest({
- socket: getMockSocket({
- authorized: false,
- peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'),
- }),
- });
+ const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD');
+ const { socket } = getMockSocket({ authorized: false, peerCertificate });
+ const request = httpServerMock.createKibanaRequest({ socket });
await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
- expect(mockOptions.logger.debug).toHaveBeenCalledWith(
- 'Peer certificate chain: [{"subject":"mock subject","issuer":"mock issuer","issuerCertType":"object","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]'
- );
- expect(mockOptions.logger.debug).toHaveBeenCalledWith(
+ expectDebugLogs(
+ 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"object","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]',
'Authentication is not possible since peer certificate was not authorized: Error: mock authorization error.'
);
});
it('does not handle requests with a missing certificate chain.', async () => {
- const request = httpServerMock.createKibanaRequest({
- socket: getMockSocket({ authorized: true, peerCertificate: null }),
- });
+ const { socket } = getMockSocket({ authorized: true, peerCertificate: null });
+ const request = httpServerMock.createKibanaRequest({ socket });
await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
- expect(mockOptions.logger.debug).toHaveBeenCalledWith('Peer certificate chain: []');
- expect(mockOptions.logger.debug).toHaveBeenCalledWith(
+ expectDebugLogs(
+ 'Peer certificate chain: []',
'Authentication is not possible due to missing peer certificate chain.'
);
});
- it('does not handle requests with an incomplete certificate chain.', async () => {
- const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD');
- (peerCertificate as any).issuerCertificate = undefined; // This behavior has been observed, even though it's not valid according to the type definition
- const request = httpServerMock.createKibanaRequest({
- socket: getMockSocket({ authorized: true, peerCertificate }),
- });
+ describe('incomplete certificate chain', () => {
+ it('when the protocol does not allow renegotiation', async () => {
+ const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD', true);
+ const { socket, mockGetPeerCertificate, mockRenegotiate } = getMockSocket({
+ authorized: true,
+ peerCertificate,
+ protocol: 'TLSv1.3',
+ });
+ const request = httpServerMock.createKibanaRequest({ socket });
+
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
+
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ expectDebugLogs(
+ `Detected incomplete certificate chain with protocol 'TLSv1.3', cannot renegotiate connection.`,
+ 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"undefined","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]',
+ 'Authentication is not possible due to incomplete peer certificate chain.'
+ );
+ expect(mockGetPeerCertificate).toHaveBeenCalledTimes(1);
+ expect(mockRenegotiate).not.toHaveBeenCalled();
+ });
+
+ it('when renegotiation fails', async () => {
+ const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD', true);
+ const { socket, mockGetPeerCertificate, mockRenegotiate } = getMockSocket({
+ authorized: true,
+ peerCertificate,
+ renegotiateError: new Error('Oh no!'),
+ });
+ const request = httpServerMock.createKibanaRequest({ socket });
+
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
+
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ expectDebugLogs(
+ `Detected incomplete certificate chain with protocol 'TLSv1.2', attempting to renegotiate connection.`,
+ `Failed to renegotiate connection: Error: Oh no!.`,
+ 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"undefined","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]',
+ 'Authentication is not possible due to incomplete peer certificate chain.'
+ );
+ expect(mockGetPeerCertificate).toHaveBeenCalledTimes(1);
+ expect(mockRenegotiate).toHaveBeenCalledTimes(1);
+ });
+
+ it('when renegotiation results in an incomplete cert chain', async () => {
+ const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD', true);
+ const { socket, mockGetPeerCertificate, mockRenegotiate } = getMockSocket({
+ authorized: true,
+ peerCertificate,
+ });
+ const request = httpServerMock.createKibanaRequest({ socket });
- await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
- expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
- expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
- expect(mockOptions.logger.debug).toHaveBeenCalledWith(
- 'Peer certificate chain: [{"subject":"mock subject","issuer":"mock issuer","issuerCertType":"undefined","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]'
- );
- expect(mockOptions.logger.debug).toHaveBeenCalledWith(
- 'Authentication is not possible due to incomplete peer certificate chain.'
- );
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ expectDebugLogs(
+ `Detected incomplete certificate chain with protocol 'TLSv1.2', attempting to renegotiate connection.`,
+ 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"undefined","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]',
+ 'Authentication is not possible due to incomplete peer certificate chain.'
+ );
+ expect(mockGetPeerCertificate).toHaveBeenCalledTimes(2);
+ expect(mockRenegotiate).toHaveBeenCalledTimes(1);
+ });
+
+ it('when renegotiation results in a complete cert chain with an unauthorized socket', async () => {
+ const { socket, mockGetPeerCertificate, mockRenegotiate } = getMockSocket({
+ authorized: true,
+ });
+ const peerCertificate1 = getMockPeerCertificate('2A:7A:C2:DD', true); // incomplete chain
+ const peerCertificate2 = getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']); // complete chain
+ mockGetPeerCertificate.mockReturnValue(peerCertificate2);
+ mockGetPeerCertificate.mockReturnValueOnce(peerCertificate1);
+ mockRenegotiate.mockImplementation((_, callback) => {
+ socket.authorized = false;
+ socket.authorizationError = new Error('Oh no!');
+ callback();
+ });
+ const request = httpServerMock.createKibanaRequest({ socket });
+
+ await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled());
+
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled();
+ expectDebugLogs(
+ `Detected incomplete certificate chain with protocol 'TLSv1.2', attempting to renegotiate connection.`,
+ 'Self-signed certificate is detected in certificate chain',
+ 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"object","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}, {"subject":"mock subject(3B:8B:D3:EE)","issuer":"mock issuer","issuerCertType":"object","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]',
+ 'Authentication is not possible since peer certificate was not authorized: Error: Oh no!.'
+ );
+ expect(mockGetPeerCertificate).toHaveBeenCalledTimes(2);
+ expect(mockRenegotiate).toHaveBeenCalledTimes(1);
+ });
+
+ it('when renegotiation results in a complete cert chain with an authorized socket', async () => {
+ const user = mockAuthenticatedUser();
+ const { socket, mockGetPeerCertificate, mockRenegotiate } = getMockSocket({
+ authorized: true,
+ });
+ const peerCertificate1 = getMockPeerCertificate('2A:7A:C2:DD', true); // incomplete chain
+ const peerCertificate2 = getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']); // complete chain
+ mockGetPeerCertificate.mockReturnValue(peerCertificate2);
+ mockGetPeerCertificate.mockReturnValueOnce(peerCertificate1);
+ const request = httpServerMock.createKibanaRequest({ socket });
+
+ mockOptions.client.callAsInternalUser.mockResolvedValue({
+ authentication: user,
+ access_token: 'access-token',
+ });
+
+ await expect(operation(request)).resolves.toEqual(
+ AuthenticationResult.succeeded(
+ { ...user, authentication_provider: { type: 'pki', name: 'pki' } },
+ {
+ authHeaders: { authorization: 'Bearer access-token' },
+ state: { accessToken: 'access-token', peerCertificateFingerprint256: '2A:7A:C2:DD' },
+ }
+ )
+ );
+
+ expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1);
+ expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', {
+ body: {
+ x509_certificate_chain: [
+ 'fingerprint:2A:7A:C2:DD:base64',
+ 'fingerprint:3B:8B:D3:EE:base64',
+ ],
+ },
+ });
+ expect(mockOptions.client.asScoped).not.toHaveBeenCalled();
+ expectDebugLogs(
+ `Detected incomplete certificate chain with protocol 'TLSv1.2', attempting to renegotiate connection.`,
+ 'Self-signed certificate is detected in certificate chain',
+ 'Peer certificate chain: [{"subject":"mock subject(2A:7A:C2:DD)","issuer":"mock issuer","issuerCertType":"object","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}, {"subject":"mock subject(3B:8B:D3:EE)","issuer":"mock issuer","issuerCertType":"object","subjectaltname":"mock subjectaltname","validFrom":"mock valid_from","validTo":"mock valid_to"}]',
+ 'Successfully retrieved access token in exchange to peer certificate chain.'
+ );
+ expect(mockGetPeerCertificate).toHaveBeenCalledTimes(2);
+ expect(mockRenegotiate).toHaveBeenCalledTimes(1);
+ });
});
it('gets an access token in exchange to peer certificate chain and stores it in the state.', async () => {
const user = mockAuthenticatedUser();
- const request = httpServerMock.createKibanaRequest({
- headers: {},
- socket: getMockSocket({
- authorized: true,
- peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']),
- }),
- });
+ const peerCertificate = getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']);
+ const { socket } = getMockSocket({ authorized: true, peerCertificate });
+ const request = httpServerMock.createKibanaRequest({ socket, headers: {} });
mockOptions.client.callAsInternalUser.mockResolvedValue({
authentication: user,
@@ -193,13 +326,9 @@ describe('PKIAuthenticationProvider', () => {
it('gets an access token in exchange to a self-signed certificate and stores it in the state.', async () => {
const user = mockAuthenticatedUser();
- const request = httpServerMock.createKibanaRequest({
- headers: {},
- socket: getMockSocket({
- authorized: true,
- peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'),
- }),
- });
+ const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD');
+ const { socket } = getMockSocket({ authorized: true, peerCertificate });
+ const request = httpServerMock.createKibanaRequest({ socket, headers: {} });
mockOptions.client.callAsInternalUser.mockResolvedValue({
authentication: user,
@@ -226,12 +355,9 @@ describe('PKIAuthenticationProvider', () => {
});
it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => {
- const request = httpServerMock.createKibanaRequest({
- socket: getMockSocket({
- authorized: true,
- peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'),
- }),
- });
+ const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD');
+ const { socket } = getMockSocket({ authorized: true, peerCertificate });
+ const request = httpServerMock.createKibanaRequest({ socket, headers: {} });
const failureReason = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error());
mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason);
@@ -287,13 +413,9 @@ describe('PKIAuthenticationProvider', () => {
});
it('does not exchange peer certificate to access token if request does not require authentication.', async () => {
- const request = httpServerMock.createKibanaRequest({
- routeAuthRequired: false,
- socket: getMockSocket({
- authorized: true,
- peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']),
- }),
- });
+ const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD');
+ const { socket } = getMockSocket({ authorized: true, peerCertificate });
+ const request = httpServerMock.createKibanaRequest({ socket, routeAuthRequired: false });
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.notHandled()
);
@@ -303,12 +425,11 @@ describe('PKIAuthenticationProvider', () => {
});
it('does not exchange peer certificate to access token for Ajax requests.', async () => {
+ const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD');
+ const { socket } = getMockSocket({ authorized: true, peerCertificate });
const request = httpServerMock.createKibanaRequest({
+ socket,
headers: { 'kbn-xsrf': 'xsrf' },
- socket: getMockSocket({
- authorized: true,
- peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']),
- }),
});
await expect(provider.authenticate(request)).resolves.toEqual(
AuthenticationResult.notHandled()
@@ -319,9 +440,8 @@ describe('PKIAuthenticationProvider', () => {
});
it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => {
- const request = httpServerMock.createKibanaRequest({
- socket: getMockSocket({ authorized: true }),
- });
+ const { socket } = getMockSocket({ authorized: true });
+ const request = httpServerMock.createKibanaRequest({ socket });
const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
@@ -333,7 +453,8 @@ describe('PKIAuthenticationProvider', () => {
});
it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => {
- const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() });
+ const { socket } = getMockSocket();
+ const request = httpServerMock.createKibanaRequest({ socket });
const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
await expect(provider.authenticate(request, state)).resolves.toEqual(
@@ -347,9 +468,9 @@ describe('PKIAuthenticationProvider', () => {
});
it('invalidates token and fails with 401 if new certificate is present, but not authorized.', async () => {
- const request = httpServerMock.createKibanaRequest({
- socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }),
- });
+ const peerCertificate = getMockPeerCertificate('2A:7A:C2:DD');
+ const { socket } = getMockSocket({ peerCertificate });
+ const request = httpServerMock.createKibanaRequest({ socket });
const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
await expect(provider.authenticate(request, state)).resolves.toEqual(
@@ -364,12 +485,9 @@ describe('PKIAuthenticationProvider', () => {
it('invalidates existing token and gets a new one if fingerprints do not match.', async () => {
const user = mockAuthenticatedUser();
- const request = httpServerMock.createKibanaRequest({
- socket: getMockSocket({
- authorized: true,
- peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']),
- }),
- });
+ const peerCertificate = getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']);
+ const { socket } = getMockSocket({ authorized: true, peerCertificate });
+ const request = httpServerMock.createKibanaRequest({ socket });
const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '3A:9A:C5:DD' };
mockOptions.client.callAsInternalUser.mockResolvedValue({
@@ -422,7 +540,7 @@ describe('PKIAuthenticationProvider', () => {
socket: getMockSocket({
authorized: true,
peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']),
- }),
+ }).socket,
});
const nonAjaxState = {
accessToken: 'existing-token',
@@ -440,7 +558,7 @@ describe('PKIAuthenticationProvider', () => {
socket: getMockSocket({
authorized: true,
peerCertificate: getMockPeerCertificate(['3A:7A:C2:DD', '3B:8B:D3:EE']),
- }),
+ }).socket,
});
const ajaxState = {
accessToken: 'existing-token',
@@ -458,7 +576,7 @@ describe('PKIAuthenticationProvider', () => {
socket: getMockSocket({
authorized: true,
peerCertificate: getMockPeerCertificate(['4A:7A:C2:DD', '3B:8B:D3:EE']),
- }),
+ }).socket,
});
const optionalAuthState = {
accessToken: 'existing-token',
@@ -503,7 +621,8 @@ describe('PKIAuthenticationProvider', () => {
});
it('fails with 401 if existing token is expired, but certificate is not present.', async () => {
- const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() });
+ const { socket } = getMockSocket();
+ const request = httpServerMock.createKibanaRequest({ socket });
const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
@@ -524,13 +643,9 @@ describe('PKIAuthenticationProvider', () => {
it('succeeds if state contains a valid token.', async () => {
const user = mockAuthenticatedUser();
const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
- const request = httpServerMock.createKibanaRequest({
- headers: {},
- socket: getMockSocket({
- authorized: true,
- peerCertificate: getMockPeerCertificate(state.peerCertificateFingerprint256),
- }),
- });
+ const peerCertificate = getMockPeerCertificate(state.peerCertificateFingerprint256);
+ const { socket } = getMockSocket({ authorized: true, peerCertificate });
+ const request = httpServerMock.createKibanaRequest({ socket, headers: {} });
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user);
@@ -552,13 +667,9 @@ describe('PKIAuthenticationProvider', () => {
it('fails if token from the state is rejected because of unknown reason.', async () => {
const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' };
- const request = httpServerMock.createKibanaRequest({
- headers: {},
- socket: getMockSocket({
- authorized: true,
- peerCertificate: getMockPeerCertificate(state.peerCertificateFingerprint256),
- }),
- });
+ const peerCertificate = getMockPeerCertificate(state.peerCertificateFingerprint256);
+ const { socket } = getMockSocket({ authorized: true, peerCertificate });
+ const request = httpServerMock.createKibanaRequest({ socket, headers: {} });
const failureReason = new errors.ServiceUnavailable();
const mockScopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient();
diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts
index 5642a6feac2b5..3171c5ff24abe 100644
--- a/x-pack/plugins/security/server/authentication/providers/pki.ts
+++ b/x-pack/plugins/security/server/authentication/providers/pki.ts
@@ -30,6 +30,17 @@ interface ProviderState {
peerCertificateFingerprint256: string;
}
+interface CertificateChain {
+ peerCertificate: DetailedPeerCertificate | null;
+ certificateChain: string[];
+ isChainIncomplete: boolean;
+}
+
+/**
+ * List of protocols that can be renegotiated. Notably, TLSv1.3 is absent from this list, because it does not support renegotiation.
+ */
+const RENEGOTIATABLE_PROTOCOLS = ['TLSv1', 'TLSv1.1', 'TLSv1.2'];
+
/**
* Checks whether current request can initiate new session.
* @param request Request instance.
@@ -238,8 +249,9 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug('Trying to authenticate request via peer certificate chain.');
// We should collect entire certificate chain as an ordered array of certificates encoded as base64 strings.
- const peerCertificate = request.socket.getPeerCertificate(true);
- const { certificateChain, isChainIncomplete } = this.getCertificateChain(peerCertificate);
+ const { peerCertificate, certificateChain, isChainIncomplete } = await this.getCertificateChain(
+ request
+ );
if (!request.socket.authorized) {
this.logger.debug(
@@ -286,17 +298,21 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider {
}
/**
- * Starts from the leaf peer certificate and iterates up to the top-most available certificate
- * authority using `issuerCertificate` certificate property. THe iteration is stopped only when
- * we detect circular reference (root/self-signed certificate) or when `issuerCertificate` isn't
- * available (null or empty object).
- * @param peerCertificate Peer leaf certificate instance.
+ * Obtains the peer certificate chain. Starts from the leaf peer certificate and iterates up to the top-most available certificate
+ * authority using `issuerCertificate` certificate property. THe iteration is stopped only when we detect circular reference
+ * (root/self-signed certificate) or when `issuerCertificate` isn't available (null or empty object). Automatically attempts to
+ * renegotiate the TLS connection once if the peer certificate chain is incomplete.
+ * @param request Request instance.
*/
- private getCertificateChain(peerCertificate: DetailedPeerCertificate | null) {
- const certificateChain = [];
- const certificateStrings = [];
+ private async getCertificateChain(
+ request: KibanaRequest,
+ isRenegotiated = false
+ ): Promise {
+ const certificateChain: string[] = [];
+ const certificateStrings: string[] = [];
let isChainIncomplete = false;
- let certificate: DetailedPeerCertificate | null = peerCertificate;
+ const peerCertificate = request.socket.getPeerCertificate(true);
+ let certificate = peerCertificate;
while (certificate && Object.keys(certificate).length > 0) {
certificateChain.push(certificate.raw.toString('base64'));
@@ -307,6 +323,24 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug('Self-signed certificate is detected in certificate chain');
break;
} else if (certificate.issuerCertificate === undefined) {
+ const protocol = request.socket.getProtocol();
+ if (!isRenegotiated && protocol && RENEGOTIATABLE_PROTOCOLS.includes(protocol)) {
+ this.logger.debug(
+ `Detected incomplete certificate chain with protocol '${protocol}', attempting to renegotiate connection.`
+ );
+ try {
+ await request.socket.renegotiate({ requestCert: true, rejectUnauthorized: false });
+ return this.getCertificateChain(request, true);
+ } catch (err) {
+ this.logger.debug(`Failed to renegotiate connection: ${err}.`);
+ }
+ } else if (!isRenegotiated) {
+ this.logger.debug(
+ `Detected incomplete certificate chain with protocol '${protocol}', cannot renegotiate connection.`
+ );
+ } else {
+ this.logger.debug(`Detected incomplete certificate chain after renegotiation.`);
+ }
// The chain is only considered to be incomplete if one or more issuerCertificate values is undefined;
// this is not an expected return value from Node, but it can happen in some edge cases
isChainIncomplete = true;
@@ -319,6 +353,6 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider {
this.logger.debug(`Peer certificate chain: [${certificateStrings.join(', ')}]`);
- return { certificateChain, isChainIncomplete };
+ return { peerCertificate, certificateChain, isChainIncomplete };
}
}