diff --git a/packages/bolt-connection/src/channel/browser/browser-client-certificates-loader.js b/packages/bolt-connection/src/channel/browser/browser-client-certificates-loader.js new file mode 100644 index 000000000..2bc1d5619 --- /dev/null +++ b/packages/bolt-connection/src/channel/browser/browser-client-certificates-loader.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export default { + async load (clientCertificate) { + return clientCertificate + } +} diff --git a/packages/bolt-connection/src/channel/browser/index.js b/packages/bolt-connection/src/channel/browser/index.js index 25fc2aeb4..e27fec4a3 100644 --- a/packages/bolt-connection/src/channel/browser/index.js +++ b/packages/bolt-connection/src/channel/browser/index.js @@ -17,7 +17,7 @@ import WebSocketChannel from './browser-channel' import BrowserHosNameResolver from './browser-host-name-resolver' - +import BrowserClientCertificatesLoader from './browser-client-certificates-loader' /* This module exports a set of components to be used in browser environment. @@ -30,3 +30,4 @@ NOTE: exports in this module should have exactly the same names/structure as exp */ export const Channel = WebSocketChannel export const HostNameResolver = BrowserHosNameResolver +export const ClientCertificatesLoader = BrowserClientCertificatesLoader diff --git a/packages/bolt-connection/src/channel/channel-config.js b/packages/bolt-connection/src/channel/channel-config.js index 3d9254b12..3f4e743e3 100644 --- a/packages/bolt-connection/src/channel/channel-config.js +++ b/packages/bolt-connection/src/channel/channel-config.js @@ -46,8 +46,9 @@ export default class ChannelConfig { * @param {ServerAddress} address the address for the channel to connect to. * @param {Object} driverConfig the driver config provided by the user when driver is created. * @param {string} connectionErrorCode the default error code to use on connection errors. + * @param {object} clientCertificate the client certificate */ - constructor (address, driverConfig, connectionErrorCode) { + constructor (address, driverConfig, connectionErrorCode, clientCertificate) { this.address = address this.encrypted = extractEncrypted(driverConfig) this.trust = extractTrust(driverConfig) @@ -55,6 +56,7 @@ export default class ChannelConfig { this.knownHostsPath = extractKnownHostsPath(driverConfig) this.connectionErrorCode = connectionErrorCode || SERVICE_UNAVAILABLE this.connectionTimeout = driverConfig.connectionTimeout + this.clientCertificate = clientCertificate } } diff --git a/packages/bolt-connection/src/channel/deno/deno-channel.js b/packages/bolt-connection/src/channel/deno/deno-channel.js index a80812e6d..7d6d9ec2b 100644 --- a/packages/bolt-connection/src/channel/deno/deno-channel.js +++ b/packages/bolt-connection/src/channel/deno/deno-channel.js @@ -239,6 +239,8 @@ const TrustStrategy = { ); } + assertNotClientCertificates(config) + const caCerts = await Promise.all( config.trustedCertificates.map(f => Deno.readTextFile(f)) ) @@ -250,6 +252,8 @@ const TrustStrategy = { }) }, TRUST_SYSTEM_CA_SIGNED_CERTIFICATES: function (config) { + assertNotClientCertificates(config) + return Deno.connectTls({ hostname: config.address.resolvedHost(), port: config.address.port() @@ -265,6 +269,13 @@ const TrustStrategy = { } } +async function assertNotClientCertificates (config) { + if (config.clientCertificate != null) { + throw newError('clientCertificates are not supported in DenoJS since the API does not ' + + 'support its configuration. See, https://deno.land/api@v1.29.0?s=Deno.ConnectTlsOptions.') + } +} + async function _connect (config) { if (!isEncrypted(config)) { return Deno.connect({ diff --git a/packages/bolt-connection/src/channel/deno/deno-client-certificates-loader.js b/packages/bolt-connection/src/channel/deno/deno-client-certificates-loader.js new file mode 100644 index 000000000..2bc1d5619 --- /dev/null +++ b/packages/bolt-connection/src/channel/deno/deno-client-certificates-loader.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export default { + async load (clientCertificate) { + return clientCertificate + } +} diff --git a/packages/bolt-connection/src/channel/deno/index.js b/packages/bolt-connection/src/channel/deno/index.js index e1746391a..b9b5585a0 100644 --- a/packages/bolt-connection/src/channel/deno/index.js +++ b/packages/bolt-connection/src/channel/deno/index.js @@ -17,7 +17,7 @@ import DenoChannel from './deno-channel' import DenoHostNameResolver from './deno-host-name-resolver' - +import DenoClientCertificatesLoader from './deno-client-certificates-loader' /* This module exports a set of components to be used in deno environment. @@ -30,3 +30,4 @@ import DenoHostNameResolver from './deno-host-name-resolver' */ export const Channel = DenoChannel export const HostNameResolver = DenoHostNameResolver +export const ClientCertificatesLoader = DenoClientCertificatesLoader diff --git a/packages/bolt-connection/src/channel/node/index.js b/packages/bolt-connection/src/channel/node/index.js index 1923d6b59..e8d61dcb4 100644 --- a/packages/bolt-connection/src/channel/node/index.js +++ b/packages/bolt-connection/src/channel/node/index.js @@ -17,7 +17,7 @@ import NodeChannel from './node-channel' import NodeHostNameResolver from './node-host-name-resolver' - +import NodeClientCertificatesLoader from './node-client-certificates-loader' /* This module exports a set of components to be used in NodeJS environment. @@ -31,3 +31,4 @@ NOTE: exports in this module should have exactly the same names/structure as exp export const Channel = NodeChannel export const HostNameResolver = NodeHostNameResolver +export const ClientCertificatesLoader = NodeClientCertificatesLoader diff --git a/packages/bolt-connection/src/channel/node/node-channel.js b/packages/bolt-connection/src/channel/node/node-channel.js index e5c0ee368..f682bb150 100644 --- a/packages/bolt-connection/src/channel/node/node-channel.js +++ b/packages/bolt-connection/src/channel/node/node-channel.js @@ -49,7 +49,8 @@ const TrustStrategy = { const tlsOpts = newTlsOptions( config.address.host(), - config.trustedCertificates.map(f => fs.readFileSync(f)) + config.trustedCertificates.map(f => fs.readFileSync(f)), + config.clientCertificate ) const socket = tls.connect( config.address.port(), @@ -79,7 +80,11 @@ const TrustStrategy = { return configureSocket(socket) }, TRUST_SYSTEM_CA_SIGNED_CERTIFICATES: function (config, onSuccess, onFailure) { - const tlsOpts = newTlsOptions(config.address.host()) + const tlsOpts = newTlsOptions( + config.address.host(), + undefined, + config.clientCertificate + ) const socket = tls.connect( config.address.port(), config.address.resolvedHost(), @@ -109,7 +114,11 @@ const TrustStrategy = { return configureSocket(socket) }, TRUST_ALL_CERTIFICATES: function (config, onSuccess, onFailure) { - const tlsOpts = newTlsOptions(config.address.host()) + const tlsOpts = newTlsOptions( + config.address.host(), + undefined, + config.clientCertificate + ) const socket = tls.connect( config.address.port(), config.address.resolvedHost(), @@ -198,13 +207,17 @@ function trustStrategyName (config) { * Create a new configuration options object for the {@code tls.connect()} call. * @param {string} hostname the target hostname. * @param {string|undefined} ca an optional CA. + * @param {string|undefined} cert an optional client cert. + * @param {string|undefined} key an optional client cert key. + * @param {string|undefined} passphrase an optional client cert passphrase * @return {Object} a new options object. */ -function newTlsOptions (hostname, ca = undefined) { +function newTlsOptions (hostname, ca = undefined, clientCertificate = undefined) { return { rejectUnauthorized: false, // we manually check for this in the connect callback, to give a more helpful error to the user servername: hostname, // server name for the SNI (Server Name Indication) TLS extension - ca // optional CA useful for TRUST_CUSTOM_CA_SIGNED_CERTIFICATES trust mode + ca, // optional CA useful for TRUST_CUSTOM_CA_SIGNED_CERTIFICATES trust mode, + ...clientCertificate } } diff --git a/packages/bolt-connection/src/channel/node/node-client-certificates-loader.js b/packages/bolt-connection/src/channel/node/node-client-certificates-loader.js new file mode 100644 index 000000000..17381d0d3 --- /dev/null +++ b/packages/bolt-connection/src/channel/node/node-client-certificates-loader.js @@ -0,0 +1,65 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs' + +function readFile (file) { + return new Promise((resolve, reject) => fs.readFile(file, (err, data) => { + if (err) { + return reject(err) + } + return resolve(data) + })) +} + +function loadCert (fileOrFiles) { + if (Array.isArray(fileOrFiles)) { + return Promise.all(fileOrFiles.map(loadCert)) + } + return readFile(fileOrFiles) +} + +function loadKey (fileOrFiles) { + if (Array.isArray(fileOrFiles)) { + return Promise.all(fileOrFiles.map(loadKey)) + } + + if (typeof fileOrFiles === 'string') { + return readFile(fileOrFiles) + } + + return readFile(fileOrFiles.path) + .then(pem => ({ + pem, + passphrase: fileOrFiles.password + })) +} + +export default { + async load (clientCertificate) { + const certPromise = loadCert(clientCertificate.certfile) + const keyPromise = loadKey(clientCertificate.keyfile) + + const [cert, key] = await Promise.all([certPromise, keyPromise]) + + return { + cert, + key, + passphrase: clientCertificate.password + } + } +} diff --git a/packages/bolt-connection/src/connection-provider/client-certificate-holder.js b/packages/bolt-connection/src/connection-provider/client-certificate-holder.js new file mode 100644 index 000000000..672e2be5d --- /dev/null +++ b/packages/bolt-connection/src/connection-provider/client-certificate-holder.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ClientCertificatesLoader } from '../channel' + +export default class ClientCertificateHolder { + constructor ({ clientCertificateProvider, loader }) { + this._clientCertificateProvider = clientCertificateProvider + this._loader = loader || ClientCertificatesLoader + this._clientCertificate = null + } + + async getClientCertificate () { + if (this._clientCertificateProvider != null && + (this._clientCertificate == null || await this._clientCertificateProvider.hasUpdate())) { + this._clientCertificate = Promise.resolve(this._clientCertificateProvider.getClientCertificate()) + .then(this._loader.load) + .then(clientCertificate => { + this._clientCertificate = clientCertificate + return this._clientCertificate + }) + .catch(error => { + this._clientCertificate = null + throw error + }) + } + + return this._clientCertificate + } +} diff --git a/packages/bolt-connection/src/connection-provider/connection-provider-direct.js b/packages/bolt-connection/src/connection-provider/connection-provider-direct.js index 1e4ee5efa..565955a2e 100644 --- a/packages/bolt-connection/src/connection-provider/connection-provider-direct.js +++ b/packages/bolt-connection/src/connection-provider/connection-provider-direct.js @@ -17,7 +17,6 @@ import PooledConnectionProvider from './connection-provider-pooled' import { - createChannelConnection, DelegateConnection, ConnectionErrorHandler } from '../connection' @@ -75,12 +74,7 @@ export default class DirectConnectionProvider extends PooledConnectionProvider { } async _hasProtocolVersion (versionPredicate) { - const connection = await createChannelConnection( - this._address, - this._config, - this._createConnectionErrorHandler(), - this._log - ) + const connection = await this._createChannelConnection(this._address) const protocolVersion = connection.protocol() ? connection.protocol().version diff --git a/packages/bolt-connection/src/connection-provider/connection-provider-pooled.js b/packages/bolt-connection/src/connection-provider/connection-provider-pooled.js index cb3875bf4..a85809eff 100644 --- a/packages/bolt-connection/src/connection-provider/connection-provider-pooled.js +++ b/packages/bolt-connection/src/connection-provider/connection-provider-pooled.js @@ -21,6 +21,7 @@ import { error, ConnectionProvider, ServerInfo, newError } from 'neo4j-driver-co import AuthenticationProvider from './authentication-provider' import { object } from '../lang' import LivenessCheckProvider from './liveness-check-provider' +import ClientCertificateHolder from './client-certificate-holder' const { SERVICE_UNAVAILABLE } = error const AUTHENTICATION_ERRORS = [ @@ -40,18 +41,20 @@ export default class PooledConnectionProvider extends ConnectionProvider { this._id = id this._config = config this._log = log + this._clientCertificateHolder = new ClientCertificateHolder({ clientCertificateProvider: this._config.clientCertificate }) this._authenticationProvider = new AuthenticationProvider({ authTokenManager, userAgent, boltAgent }) this._livenessCheckProvider = new LivenessCheckProvider({ connectionLivenessCheckTimeout: config.connectionLivenessCheckTimeout }) this._userAgent = userAgent this._boltAgent = boltAgent this._createChannelConnection = createChannelConnectionHook || - (address => { + (async address => { return createChannelConnection( address, this._config, this._createConnectionErrorHandler(), - this._log + this._log, + await this._clientCertificateHolder.getClientCertificate() ) }) this._connectionPool = newPool({ @@ -75,6 +78,10 @@ export default class PooledConnectionProvider extends ConnectionProvider { return new ConnectionErrorHandler(SERVICE_UNAVAILABLE) } + async _getClientCertificate () { + return this._config.clientCertificate.getClientCertificate() + } + /** * Create a new connection and initialize it. * @return {Promise} promise resolved with a new connection or rejected when failed to connect. diff --git a/packages/bolt-connection/src/connection-provider/connection-provider-routing.js b/packages/bolt-connection/src/connection-provider/connection-provider-routing.js index b3e567793..8653b64ca 100644 --- a/packages/bolt-connection/src/connection-provider/connection-provider-routing.js +++ b/packages/bolt-connection/src/connection-provider/connection-provider-routing.js @@ -71,12 +71,13 @@ export default class RoutingConnectionProvider extends PooledConnectionProvider routingTablePurgeDelay, newPool }) { - super({ id, config, log, userAgent, boltAgent, authTokenManager, newPool }, address => { + super({ id, config, log, userAgent, boltAgent, authTokenManager, newPool }, async address => { return createChannelConnection( address, this._config, this._createConnectionErrorHandler(), this._log, + await this._clientCertificateHolder.getClientCertificate(), this._routingContext ) }) @@ -212,12 +213,7 @@ export default class RoutingConnectionProvider extends PooledConnectionProvider let lastError for (let i = 0; i < addresses.length; i++) { try { - const connection = await createChannelConnection( - addresses[i], - this._config, - this._createConnectionErrorHandler(), - this._log - ) + const connection = await this._createChannelConnection(addresses[i]) const protocolVersion = connection.protocol() ? connection.protocol().version : null diff --git a/packages/bolt-connection/src/connection/connection-channel.js b/packages/bolt-connection/src/connection/connection-channel.js index 73ec2a821..b056e3e1b 100644 --- a/packages/bolt-connection/src/connection/connection-channel.js +++ b/packages/bolt-connection/src/connection/connection-channel.js @@ -33,6 +33,7 @@ let idGenerator = 0 * @param {Object} config - the driver configuration. * @param {ConnectionErrorHandler} errorHandler - the error handler for connection errors. * @param {Logger} log - configured logger. + * @param {clientCertificate} clientCertificate - configured client certificate * @return {Connection} - new connection. */ export function createChannelConnection ( @@ -40,13 +41,15 @@ export function createChannelConnection ( config, errorHandler, log, + clientCertificate, serversideRouting = null, createChannel = channelConfig => new Channel(channelConfig) ) { const channelConfig = new ChannelConfig( address, config, - errorHandler.errorCode() + errorHandler.errorCode(), + clientCertificate ) const channel = createChannel(channelConfig) diff --git a/packages/bolt-connection/test/connection-provider/client-certificate-holder.test.js b/packages/bolt-connection/test/connection-provider/client-certificate-holder.test.js new file mode 100644 index 000000000..aa9f8d28f --- /dev/null +++ b/packages/bolt-connection/test/connection-provider/client-certificate-holder.test.js @@ -0,0 +1,313 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { clientCertificateProviders, newError } from 'neo4j-driver-core' +import ClientCertificateHolder from '../../src/connection-provider/client-certificate-holder' + +describe('ClientCertificateHolder', () => { + describe('.getClientCertificate()', () => { + describe('when provider is not set', () => { + it('should resolve as null when provider is not set', async () => { + const config = extendsDefaultConfigWith() + const holder = new ClientCertificateHolder(config) + + await expect(holder.getClientCertificate()).resolves.toBe(null) + expect(config.loader.load).not.toHaveBeenCalled() + }) + }) + + describe('when provider is set', () => { + it('should load and resolve the loaded certificate in the first call', async () => { + const initialCertificate = { + keyfile: 'the file', + certfile: 'cert file' + } + const clientCertificateProvider = clientCertificateProviders.rotating({ + initialCertificate + }) + + const config = extendsDefaultConfigWith({ clientCertificateProvider }) + const holder = new ClientCertificateHolder(config) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...initialCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledWith(initialCertificate) + }) + + it('should resolve the previous certificate if certificate was not updated', async () => { + const initialCertificate = { + keyfile: 'the file', + certfile: 'cert file' + } + const clientCertificateProvider = clientCertificateProviders.rotating({ + initialCertificate + }) + + const config = extendsDefaultConfigWith({ clientCertificateProvider }) + const holder = new ClientCertificateHolder(config) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...initialCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledWith(initialCertificate) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...initialCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledTimes(1) + }) + + it('should update certificate when certificate get updated', async () => { + const initialCertificate = { + keyfile: 'the file', + certfile: 'cert file' + } + const newCertificate = { + keyfile: 'the new file', + certfile: 'new cert file' + } + + const clientCertificateProvider = clientCertificateProviders.rotating({ + initialCertificate + }) + + const config = extendsDefaultConfigWith({ clientCertificateProvider }) + const holder = new ClientCertificateHolder(config) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...initialCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledWith(initialCertificate) + + clientCertificateProvider.updateCertificate(newCertificate) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...newCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledTimes(2) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...newCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledTimes(2) + }) + + it('should return same promise when multiple requests are depending on same loaded certificate', async () => { + const initialCertificate = { + keyfile: 'the file', + certfile: 'cert file' + } + + const clientCertificateProvider = clientCertificateProviders.rotating({ + initialCertificate + }) + + const promiseStates = [] + + clientCertificateProvider.getClientCertificate = jest.fn(() => { + const promiseState = {} + const promise = new Promise((resolve, reject) => { + promiseState.resolve = resolve + promiseState.reject = reject + }) + + promiseStates.push(promiseState) + return promise + }) + + const config = extendsDefaultConfigWith({ clientCertificateProvider }) + const holder = new ClientCertificateHolder(config) + + const certPromises = [ + holder.getClientCertificate(), + holder.getClientCertificate(), + holder.getClientCertificate() + ] + + expect(promiseStates.length).toBe(1) + promiseStates.forEach(promiseState => promiseState.resolve(initialCertificate)) + + for (let i = 0; i < certPromises.length - 1; i++) { + await expect(certPromises[i]).resolves.toEqual({ + ...initialCertificate, + loaded: true + }) + } + + expect(config.loader.load).toHaveBeenCalledTimes(1) + }) + + it('should throws when getting certificates fail', async () => { + const initialCertificate = { + keyfile: 'the file', + certfile: 'cert file' + } + const newCertificate = { + keyfile: 'the new file', + certfile: 'new cert file' + } + const clientCertificateProvider = clientCertificateProviders.rotating({ + initialCertificate + }) + + const config = extendsDefaultConfigWith({ clientCertificateProvider }) + const holder = new ClientCertificateHolder(config) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...initialCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledWith(initialCertificate) + + clientCertificateProvider.updateCertificate(newCertificate) + + const expectedError = newError('Error') + clientCertificateProvider.getClientCertificate = jest.fn(() => Promise.reject(expectedError)) + + await expect(holder.getClientCertificate()).rejects.toEqual(expectedError) + + expect(config.loader.load).toHaveBeenCalledTimes(1) + }) + + it('should recover from getting certificates failures', async () => { + const initialCertificate = { + keyfile: 'the file', + certfile: 'cert file' + } + const newCertificate = { + keyfile: 'the new file', + certfile: 'new cert file' + } + const clientCertificateProvider = clientCertificateProviders.rotating({ + initialCertificate + }) + + const config = extendsDefaultConfigWith({ clientCertificateProvider }) + const holder = new ClientCertificateHolder(config) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...initialCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledWith(initialCertificate) + + clientCertificateProvider.updateCertificate(newCertificate) + + const expectedError = newError('Error') + clientCertificateProvider.getClientCertificate = jest.fn(() => Promise.reject(expectedError)) + + await expect(holder.getClientCertificate()).rejects.toEqual(expectedError) + expect(config.loader.load).toHaveBeenCalledTimes(1) + + clientCertificateProvider.getClientCertificate = jest.fn(() => Promise.resolve(newCertificate)) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...newCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledWith(newCertificate) + }) + + it('should throws when loading certificates fail', async () => { + const initialCertificate = { + keyfile: 'the file', + certfile: 'cert file' + } + const newCertificate = { + keyfile: 'the new file', + certfile: 'new cert file' + } + const clientCertificateProvider = clientCertificateProviders.rotating({ + initialCertificate + }) + + const config = extendsDefaultConfigWith({ clientCertificateProvider }) + const holder = new ClientCertificateHolder(config) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...initialCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledWith(initialCertificate) + + clientCertificateProvider.updateCertificate(newCertificate) + + const expectedError = newError('Error') + config.loader.load.mockReturnValueOnce(Promise.reject(expectedError)) + + await expect(holder.getClientCertificate()).rejects.toEqual(expectedError) + + expect(config.loader.load).toHaveBeenCalledTimes(2) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...newCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledWith(newCertificate) + expect(config.loader.load).toHaveBeenCalledTimes(3) + }) + + it('should recover from loading certificates fail', async () => { + const initialCertificate = { + keyfile: 'the file', + certfile: 'cert file' + } + const newCertificate = { + keyfile: 'the new file', + certfile: 'new cert file' + } + const clientCertificateProvider = clientCertificateProviders.rotating({ + initialCertificate + }) + + const config = extendsDefaultConfigWith({ clientCertificateProvider }) + const holder = new ClientCertificateHolder(config) + + await expect(holder.getClientCertificate()).resolves.toEqual({ + ...initialCertificate, + loaded: true + }) + expect(config.loader.load).toHaveBeenCalledWith(initialCertificate) + + clientCertificateProvider.updateCertificate(newCertificate) + + const expectedError = newError('Error') + config.loader.load.mockReturnValueOnce(Promise.reject(expectedError)) + + await expect(holder.getClientCertificate()).rejects.toEqual(expectedError) + + expect(config.loader.load).toHaveBeenCalledTimes(2) + }) + }) + }) +}) + +function extendsDefaultConfigWith (params) { + return { + clientCertificateProvider: null, + loader: { + load: jest.fn(async a => ({ ...a, loaded: true })) + }, + ...params + } +} diff --git a/packages/core/src/client-certificate.ts b/packages/core/src/client-certificate.ts new file mode 100644 index 000000000..d69bb8b03 --- /dev/null +++ b/packages/core/src/client-certificate.ts @@ -0,0 +1,334 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as json from './json' + +type KeyFile = string | { path: string, password?: string } + +/** + * Represents KeyFile represented as file. + * + * @typedef {object} KeyFileObject + * @property {string} path - The path of the file + * @property {string|undefined} password - the password of the key. If none, + * the password defined at {@link ClientCertificate} will be used. + */ +/** + * Holds the Client TLS certificate information. + * + * Browser instances of the driver should configure the certificate + * in the system. + * + * Files defined in the {@link ClientCertificate#certfile} + * and {@link ClientCertificate#keyfile} will read and loaded to + * memory to fill the fields `cert` and `key` in security context. + * + * @interface + * @see https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions + * @experimental Exposed as preview feature. + * @since 5.19 + */ +export default class ClientCertificate { + public readonly certfile: string | string[] + public readonly keyfile: KeyFile | KeyFile[] + public readonly password?: string + + private constructor () { + /** + * The path to client certificate file. + * + * @type {string|string[]} + */ + this.certfile = '' + + /** + * The path to the key file. + * + * @type {string|string[]|KeyFileObject|KeyFileObject[]} + */ + this.keyfile = '' + + /** + * The key's password. + * + * @type {string|undefined} + */ + this.password = undefined + } +} + +/** + * Provides a client certificate to the driver for mutual TLS. + * + * The driver will call {@link ClientCertificateProvider#hasUpdate()} to check if the client wants to update the certificate. + * If so, it will call {@link ClientCertificateProvider#getCertificate()} to get the new certificate. + * + * The certificate is only used as a second factor for authentication authenticating the client. + * The DMBS user still needs to authenticate with an authentication token. + * + * All implementations of this interface must be thread-safe and non-blocking for caller threads. + * For instance, IO operations must not be done on the calling thread. + * + * Note that the work done in the methods of this interface count towards the connectionAcquisition. + * Should fetching the certificate be particularly slow, it might be necessary to increase the timeout. + * + * @interface + * @experimental Exposed as preview feature. + * @since 5.19 + */ +export class ClientCertificateProvider { + /** + * Indicates whether the client wants the driver to update the certificate. + * + * @returns {Promise|boolean} true if the client wants the driver to update the certificate + */ + hasUpdate (): boolean | Promise { + throw new Error('Not Implemented') + } + + /** + * Returns the certificate to use for new connections. + * + * Will be called by the driver after {@link ClientCertificateProvider#hasUpdate()} returned true + * or when the driver establishes the first connection. + * + * @returns {Promise|ClientCertificate} the certificate to use for new connections + */ + getClientCertificate (): ClientCertificate | Promise { + throw new Error('Not Implemented') + } +} + +/** + * Interface for {@link ClientCertificateProvider} which provides update certificate function. + * @interface + * @experimental Exposed as preview feature. + * @since 5.19 + */ +export class RotatingClientCertificateProvider extends ClientCertificateProvider { + /** + * Updates the certificate stored in the provider. + * + * To be called by user-code when a new client certificate is available. + * + * @param {ClientCertificate} certificate - the new certificate + * @throws {TypeError} If initialCertificate is not a ClientCertificate. + */ + updateCertificate (certificate: ClientCertificate): void { + throw new Error('Not implemented') + } +} + +/** + * Defines the object which holds the common {@link ClientCertificateProviders} used in the Driver + * + * @experimental Exposed as preview feature. + * @since 5.19 + */ +class ClientCertificateProviders { + /** + * + * @param {object} param0 - The params + * @param {ClientCertificate} param0.initialCertificate - The certificated used by the driver until {@link RotatingClientCertificateProvider#updateCertificate} get called. + * + * @returns {RotatingClientCertificateProvider} The rotating client certificate provider + * @throws {TypeError} If initialCertificate is not a ClientCertificate. + */ + rotating ({ initialCertificate }: { initialCertificate: ClientCertificate }): RotatingClientCertificateProvider { + if (initialCertificate == null || !isClientClientCertificate(initialCertificate)) { + throw new TypeError(`initialCertificate should be ClientCertificate, but got ${json.stringify(initialCertificate)}`) + } + + const certificate = { ...initialCertificate } + return new InternalRotatingClientCertificateProvider(certificate) + } +} + +/** + * Holds the common {@link ClientCertificateProviders} used in the Driver. + * + * @experimental Exposed as preview feature. + * @since 5.19 + */ +const clientCertificateProviders: ClientCertificateProviders = new ClientCertificateProviders() + +Object.freeze(clientCertificateProviders) + +export { + clientCertificateProviders +} + +export type { + ClientCertificateProviders +} + +/** + * Resolves ClientCertificate or ClientCertificateProvider to a ClientCertificateProvider + * + * Method validates the input. + * + * @private + * @param input + * @returns {ClientCertificateProvider?} A client certificate provider if provided a ClientCertificate or a ClientCertificateProvider + * @throws {TypeError} If input is not a ClientCertificate, ClientCertificateProvider, undefined or null. + */ +export function resolveCertificateProvider (input: unknown): ClientCertificateProvider | undefined { + if (input == null) { + return undefined + } + + if (typeof input === 'object' && 'hasUpdate' in input && 'getClientCertificate' in input && + typeof input.getClientCertificate === 'function' && typeof input.hasUpdate === 'function') { + return input as ClientCertificateProvider + } + + if (isClientClientCertificate(input)) { + const certificate = { ...input } as unknown as ClientCertificate + return { + getClientCertificate: () => certificate, + hasUpdate: () => false + } + } + + throw new TypeError(`clientCertificate should be configured with ClientCertificate or ClientCertificateProvider, but got ${json.stringify(input)}`) +} + +/** + * Verify if object is a client certificate + * @private + * @param maybeClientCertificate - Maybe the certificate + * @returns {boolean} if maybeClientCertificate is a client certificate object + */ +function isClientClientCertificate (maybeClientCertificate: unknown): maybeClientCertificate is ClientCertificate { + return maybeClientCertificate != null && + typeof maybeClientCertificate === 'object' && + 'certfile' in maybeClientCertificate && isCertFile(maybeClientCertificate.certfile) && + 'keyfile' in maybeClientCertificate && isKeyFile(maybeClientCertificate.keyfile) && + isStringOrNotPresent('password', maybeClientCertificate) +} + +/** + * Check value is a cert file + * @private + * @param {any} value the value + * @returns {boolean} is a cert file + */ +function isCertFile (value: unknown): value is string | string [] { + return isString(value) || isArrayOf(value, isString) +} + +/** + * Check if the value is a keyfile. + * + * @private + * @param {any} maybeKeyFile might be a keyfile value + * @returns {boolean} the value is a KeyFile + */ +function isKeyFile (maybeKeyFile: unknown): maybeKeyFile is KeyFile { + function check (obj: unknown): obj is KeyFile { + return typeof obj === 'string' || + (obj != null && + typeof obj === 'object' && + 'path' in obj && typeof obj.path === 'string' && + isStringOrNotPresent('password', obj)) + } + + return check(maybeKeyFile) || isArrayOf(maybeKeyFile, check) +} + +/** + * Verify if value is string + * + * @private + * @param {any} value the value + * @returns {boolean} is string + */ +function isString (value: unknown): value is string { + return typeof value === 'string' +} + +/** + * Verifies if value is a array of type + * + * @private + * @param {any} value the value + * @param {function} isType the type checker + * @returns {boolean} value is array of type + */ +function isArrayOf (value: unknown, isType: (val: unknown) => val is T, allowEmpty: boolean = false): value is T[] { + return Array.isArray(value) && + (allowEmpty || value.length > 0) && + value.filter(isType).length === value.length +} + +/** + * Verify if valueName is present in the object and is a string, or not present at all. + * + * @private + * @param {string} valueName The value in the object + * @param {object} obj The object + * @returns {boolean} if the value is present in object as string or not present + */ +function isStringOrNotPresent (valueName: string, obj: Record): boolean { + return !(valueName in obj) || obj[valueName] == null || typeof obj[valueName] === 'string' +} + +/** + * Internal implementation + * + * @private + */ +class InternalRotatingClientCertificateProvider { + constructor ( + private _certificate: ClientCertificate, + private _updated: boolean = false) { + } + + /** + * + * @returns {boolean|Promise} + */ + + hasUpdate (): boolean | Promise { + try { + return this._updated + } finally { + this._updated = false + } + } + + /** + * + * @returns {ClientCertificate|Promise} + */ + getClientCertificate (): ClientCertificate | Promise { + return this._certificate + } + + /** + * + * @param certificate + * @returns {void} + */ + updateCertificate (certificate: ClientCertificate): void { + if (!isClientClientCertificate(certificate)) { + throw new TypeError(`certificate should be ClientCertificate, but got ${json.stringify(certificate)}`) + } + this._certificate = { ...certificate } + this._updated = true + } +} diff --git a/packages/core/src/connection-provider.ts b/packages/core/src/connection-provider.ts index 226358a1f..0cfacc1d4 100644 --- a/packages/core/src/connection-provider.ts +++ b/packages/core/src/connection-provider.ts @@ -28,6 +28,9 @@ import { AuthToken } from './types' * @interface */ class Releasable { + /** + * @returns {Promise} + */ release (): Promise { throw new Error('Not implemented') } diff --git a/packages/core/src/connection.ts b/packages/core/src/connection.ts index 568dc2128..ed2e72cfa 100644 --- a/packages/core/src/connection.ts +++ b/packages/core/src/connection.ts @@ -72,34 +72,72 @@ interface RunQueryConfig extends BeginTransactionConfig { * @interface */ class Connection { + /** + * + * @param config + * @returns {ResultStreamObserver} + */ beginTransaction (config: BeginTransactionConfig): ResultStreamObserver { throw new Error('Not implemented') } + /** + * + * @param query + * @param parameters + * @param config + * @returns {ResultStreamObserver} + */ run (query: string, parameters?: Record, config?: RunQueryConfig): ResultStreamObserver { throw new Error('Not implemented') } + /** + * + * @param config + * @returns {ResultStreamObserver} + */ commitTransaction (config: CommitTransactionConfig): ResultStreamObserver { throw new Error('Not implemented') } + /** + * + * @param config + * @returns {ResultStreamObserver} + */ rollbackTransaction (config: RollbackConnectionConfig): ResultStreamObserver { throw new Error('Not implemented') } + /** + * + * @returns {Promise} + */ resetAndFlush (): Promise { throw new Error('Not implemented') } + /** + * + * @returns {boolean} + */ isOpen (): boolean { throw new Error('Not implemented') } + /** + * + * @returns {number} + */ getProtocolVersion (): number { throw new Error('Not implemented') } + /** + * + * @returns {boolean} + */ hasOngoingObservableRequests (): boolean { throw new Error('Not implemented') } diff --git a/packages/core/src/driver.ts b/packages/core/src/driver.ts index d2476810e..e045204cb 100644 --- a/packages/core/src/driver.ts +++ b/packages/core/src/driver.ts @@ -906,6 +906,7 @@ function validateConfig (config: any, log: Logger): any { /** * @private + * @returns {void} */ function sanitizeConfig (config: any): void { config.maxConnectionLifetime = sanitizeIntValue( @@ -932,6 +933,7 @@ function sanitizeConfig (config: any): void { /** * @private + * @returns {number} */ function sanitizeIntValue (rawValue: any, defaultWhenAbsent: number): number { const sanitizedValue = parseInt(rawValue, 10) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7a2580d11..af9677287 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -91,6 +91,7 @@ import { Config } from './types' import * as types from './types' import * as json from './json' import resultTransformers, { ResultTransformer } from './result-transformers' +import ClientCertificate, { clientCertificateProviders, ClientCertificateProvider, ClientCertificateProviders, RotatingClientCertificateProvider, resolveCertificateProvider } from './client-certificate' import * as internal from './internal' // todo: removed afterwards /** @@ -169,7 +170,9 @@ const forExport = { notificationCategory, notificationSeverityLevel, notificationFilterDisabledCategory, - notificationFilterMinimumSeverityLevel + notificationFilterMinimumSeverityLevel, + clientCertificateProviders, + resolveCertificateProvider } export { @@ -238,7 +241,9 @@ export { notificationCategory, notificationSeverityLevel, notificationFilterDisabledCategory, - notificationFilterMinimumSeverityLevel + notificationFilterMinimumSeverityLevel, + clientCertificateProviders, + resolveCertificateProvider } export type { @@ -263,7 +268,11 @@ export type { NotificationSeverityLevel, NotificationFilter, NotificationFilterDisabledCategory, - NotificationFilterMinimumSeverityLevel + NotificationFilterMinimumSeverityLevel, + ClientCertificate, + ClientCertificateProvider, + ClientCertificateProviders, + RotatingClientCertificateProvider } export default forExport diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 05e07d134..5e63b9ff1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import ClientCertificate, { ClientCertificateProvider } from './client-certificate' import NotificationFilter from './notification-filter' /** @@ -80,6 +81,7 @@ export class Config { resolver?: (address: string) => string[] | Promise userAgent?: string telemetryDisabled?: boolean + clientCertificate?: ClientCertificate | ClientCertificateProvider /** * @constructor @@ -340,6 +342,18 @@ export class Config { * @type {boolean} */ this.telemetryDisabled = false + + /** + * Client Certificate used for mutual TLS. + * + * A {@link ClientCertificateProvider} can be configure for scenarios + * where the {@link ClientCertificate} might change over time. + * + * @type {ClientCertificate|ClientCertificateProvider|undefined} + * @experimental Exposed as preview feature. + * @since 5.19 + */ + this.clientCertificate = undefined } } diff --git a/packages/core/test/client-certificate.test.ts b/packages/core/test/client-certificate.test.ts new file mode 100644 index 000000000..5dd64d4d2 --- /dev/null +++ b/packages/core/test/client-certificate.test.ts @@ -0,0 +1,253 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ClientCertificate, { ClientCertificateProvider, RotatingClientCertificateProvider, clientCertificateProviders, resolveCertificateProvider } from '../src/client-certificate' + +describe('clientCertificateProviders', () => { + describe('.rotating()', () => { + describe.each([ + undefined, + null, + {}, + { initialCertificate: null }, + { + someOtherProperty: { + certfile: 'other_file', + keyfile: 'some_file', + password: 'pass' + } + }, + ...invalidCertificates().map(initialCertificate => ({ initialCertificate })) + ])('when invalid configuration (%o)', (config) => { + it('should thrown TypeError', () => { + // @ts-expect-error + expect(() => clientCertificateProviders.rotating(config)) + .toThrow(TypeError) + }) + }) + + describe.each(validCertificates() + .map(initialCertificate => ({ initialCertificate })) + )('when valid configuration (%o)', (config) => { + it('should return a RotatingClientCertificateProvider', () => { + const provider = clientCertificateProviders.rotating(config) + + expect(provider).toBeDefined() + expect(provider.getClientCertificate).toBeInstanceOf(Function) + expect(provider.hasUpdate).toBeInstanceOf(Function) + expect(provider.updateCertificate).toBeInstanceOf(Function) + }) + + it('should getClientCertificate return a copy of initialCertificate until certificate is not update', async () => { + const provider = clientCertificateProviders.rotating(config) + + for (let i = 0; i < 100; i++) { + const certificate = await provider.getClientCertificate() + expect(certificate).toEqual(config.initialCertificate) + expect(certificate).not.toBe(config.initialCertificate) + } + + provider.updateCertificate({ + certfile: 'new_cert_file', + keyfile: 'new_key_file', + password: 'new_pass_word' + }) + + const certificate = await provider.getClientCertificate() + expect(certificate).not.toEqual(config.initialCertificate) + expect(certificate).not.toBe(config.initialCertificate) + }) + + it('should updateCertificate change certificate for a new one', async () => { + const provider = clientCertificateProviders.rotating(config) + + await expect(Promise.resolve(provider.getClientCertificate())).resolves.toEqual(config.initialCertificate) + + for (let i = 0; i < 100; i++) { + const certificate = { + certfile: `new_cert_file${i}`, + keyfile: `new_key_file${i}`, + password: i % 2 === 0 ? `new_pass_word${i}` : undefined + } + + provider.updateCertificate(certificate) + + await expect(Promise.resolve(provider.getClientCertificate())).resolves.toEqual(certificate) + await expect(Promise.resolve(provider.getClientCertificate())).resolves.toEqual(certificate) + await expect(Promise.resolve(provider.getClientCertificate())).resolves.toEqual(certificate) + } + }) + + it.each([ + ...invalidCertificates(), + null, + undefined + ])('should updateCertificate change certificate for a new one', async (invalidCertificate) => { + const provider = clientCertificateProviders.rotating(config) + + await expect(Promise.resolve(provider.getClientCertificate())).resolves.toEqual(config.initialCertificate) + + expect(() => provider.updateCertificate(invalidCertificate)).toThrow(TypeError) + }) + + it('should hasUpdate Return false, unless updateCertificate() was called since the last call of hasUpdate', async () => { + const provider = clientCertificateProviders.rotating(config) + + await expect(Promise.resolve(provider.hasUpdate())).resolves.toBe(false) + + const certificate = { + certfile: 'new_cert_file', + keyfile: 'new_key_file' + } + provider.updateCertificate(certificate) + + await expect(Promise.resolve(provider.hasUpdate())).resolves.toBe(true) + await expect(Promise.resolve(provider.hasUpdate())).resolves.toBe(false) + + await expect(Promise.resolve(provider.getClientCertificate())).resolves.toEqual(certificate) + provider.updateCertificate(certificate) + await expect(Promise.resolve(provider.getClientCertificate())).resolves.toEqual(certificate) + + await expect(Promise.resolve(provider.hasUpdate())).resolves.toBe(true) + await expect(Promise.resolve(provider.hasUpdate())).resolves.toBe(false) + }) + }) + }) +}) + +describe('resolveCertificateProvider', () => { + const rotatingProvider = clientCertificateProviders.rotating({ initialCertificate: { certfile: 'certfile', keyfile: 'keyfile' } }) + const customProvider: ClientCertificateProvider = { + getClientCertificate () { + return { certfile: 'certfile', keyfile: 'keyfile' } + }, + hasUpdate () { + return false + } + } + + const customRotatingProvider: RotatingClientCertificateProvider = { + getClientCertificate () { + return { certfile: 'certfile', keyfile: 'keyfile' } + }, + hasUpdate () { + return false + }, + updateCertificate (certificate) { + } + } + + it.each([ + [undefined, undefined], + [undefined, null], + [rotatingProvider, rotatingProvider], + [customProvider, customProvider], + [customRotatingProvider, customRotatingProvider] + ])('should return %o when called with %o', (expectedResult, input) => { + expect(resolveCertificateProvider(input)).toBe(expectedResult) + }) + + it.each(validCertificates())('should a static provider when configured with ClientCertificate ', async (certificate) => { + const maybeProvider = resolveCertificateProvider(certificate) + + expect(maybeProvider).toBeDefined() + + expect(maybeProvider?.getClientCertificate).toBeInstanceOf(Function) + expect(maybeProvider?.hasUpdate).toBeInstanceOf(Function) + // @ts-expect-error + expect(maybeProvider?.updateCertificate).toBeUndefined() + + for (let i = 0; i < 100; i++) { + await expect(Promise.resolve(maybeProvider?.getClientCertificate())).resolves.toEqual(certificate) + await expect(Promise.resolve(maybeProvider?.getClientCertificate())).resolves.not.toBe(certificate) + await expect(Promise.resolve(maybeProvider?.hasUpdate())).resolves.toBe(false) + } + }) + + it.each([ + ...invalidCertificates(), + { getClientCertificate () {} }, + { hasUpdate () {} }, + { updateCertificate () {} }, + { getClientCertificate () {}, hasUpdate: true }, + { getClientCertificate () {}, get hasUpdate () { return true } }, + { getClientCertificate: 'certificate', hasUpdate () {} } + ])('should thrown when object is not a ClientCertificate, ClientCertificateProvider or absent (%o)', (value) => { + expect(() => resolveCertificateProvider(value)).toThrow(TypeError) + }) +}) + +function invalidCertificates (): any[] { + return [ + [], + ['certfile', 'file', 'keyfile', 'the key file'], + { certfile: 'file' }, + { keyfile: 'file' }, + { password: 'password_123' }, + { certfile: 123, keyfile: 'file' }, + { certfile: 'file', keyfile: 3.4 }, + { certfile: 3.5, keyfile: 3.4 }, + { certfile: 'sAED', keyfile: Symbol.asyncIterator }, + { certfile: '123', keyfile: 'file', password: 123 }, + { certfile () { return 'the cert file' }, get keyfile () { return 'the key file' } }, + { get certfile () { return 'the cert file' }, keyfile () { return 'the key file' } }, + { get certfile () { return 'the cert file' }, get keyfile () { return 'the key file' }, password () { return 'the password' } }, + // key file as object + { certfile: 'certfile', keyfile: { } }, + { certfile: 'certfile', keyfile: { path: null, password: 142 } }, + { certfile: 'certfile', keyfile: { path: 1123 }, password: 'the password' }, + { certfile: 'certfile', keyfile: { path: 'the key path', password: 456 }, password: 'the password' }, + // key file as object and getter + { certfile: 'certfile', get keyfile () { return { path: 1919 } } }, + { certfile: 'certfile', get keyfile () { return { path: 'the key path', password: {} } } }, + { certfile: 'certfile', get keyfile () { return { path: { path: 'path' } } }, password: 'the password' }, + { certfile: 'certfile', get keyfile () { return { path: ['123'], password: 'password' } }, password: 'the password' }, + // multiple certificates + { certfile: ['certfile'], keyfile: [] }, + { certfile: [], keyfile: ['keyfile'], password: 'password' }, + { certfile: [1234], keyfile: ['keyfile'] }, + { certfile: ['certfile'], keyfile: [1234], password: 'password' }, + { certfile: ['certfile'], keyfile: [{ path: 1234 }] }, + { certfile: ['certfile'], keyfile: [{ path: 'the key path', password: 1234 }], password: 'password' } + ] +} + +function validCertificates (): ClientCertificate[] { + return [ + // strings + { certfile: 'certfile', keyfile: 'keyfile' }, + { certfile: 'certfile', keyfile: 'keyfile', password: 'password' }, + // string getters + { get certfile () { return 'the cert file' }, get keyfile () { return 'the key file' } }, + { get certfile () { return 'the cert file' }, get keyfile () { return 'the key file' }, get password () { return 'the password' } }, + // key file as object + { certfile: 'certfile', keyfile: { path: 'the key path' } }, + { certfile: 'certfile', keyfile: { path: 'the key path', password: 'password' } }, + { certfile: 'certfile', keyfile: { path: 'the key path' }, password: 'the password' }, + { certfile: 'certfile', keyfile: { path: 'the key path', password: 'password' }, password: 'the password' }, + // key file as object and getter + { certfile: 'certfile', get keyfile () { return { path: 'the key path' } } }, + { certfile: 'certfile', get keyfile () { return { path: 'the key path', password: 'password' } } }, + { certfile: 'certfile', get keyfile () { return { path: 'the key path' } }, password: 'the password' }, + { certfile: 'certfile', get keyfile () { return { path: 'the key path', password: 'password' } }, password: 'the password' }, + // multiple certificates + { certfile: ['certfile'], keyfile: ['keyfile'] }, + { certfile: ['certfile'], keyfile: ['keyfile'], password: 'password' }, + { certfile: ['certfile'], keyfile: [{ path: 'the key path' }] }, + { certfile: ['certfile'], keyfile: [{ path: 'the key path', password: 'password' }], password: 'password' } + ] +} diff --git a/packages/neo4j-driver-deno/README.md b/packages/neo4j-driver-deno/README.md index 913e2a591..de721d15e 100644 --- a/packages/neo4j-driver-deno/README.md +++ b/packages/neo4j-driver-deno/README.md @@ -48,6 +48,8 @@ For Deno versions bellow `1.27.1`, you should use the flag `--allow-env` instead For using system certificates, the `DENO_TLS_CA_STORE` should be set to `"system"`. `TRUST_ALL_CERTIFICATES` should be handle by `--unsafely-ignore-certificate-errors` and not by driver configuration. See, https://deno.com/blog/v1.13#disable-tls-verification; +Client certificates are not support in this version of the driver since there is no support for this feature in the DenoJS API. See, https://deno.land/api@v1.29.0?s=Deno.ConnectTlsOptions. + ### Basic Example ```typescript diff --git a/packages/neo4j-driver-deno/lib/README.md b/packages/neo4j-driver-deno/lib/README.md index 913e2a591..de721d15e 100644 --- a/packages/neo4j-driver-deno/lib/README.md +++ b/packages/neo4j-driver-deno/lib/README.md @@ -48,6 +48,8 @@ For Deno versions bellow `1.27.1`, you should use the flag `--allow-env` instead For using system certificates, the `DENO_TLS_CA_STORE` should be set to `"system"`. `TRUST_ALL_CERTIFICATES` should be handle by `--unsafely-ignore-certificate-errors` and not by driver configuration. See, https://deno.com/blog/v1.13#disable-tls-verification; +Client certificates are not support in this version of the driver since there is no support for this feature in the DenoJS API. See, https://deno.land/api@v1.29.0?s=Deno.ConnectTlsOptions. + ### Basic Example ```typescript diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/browser-client-certificates-loader.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/browser-client-certificates-loader.js new file mode 100644 index 000000000..2bc1d5619 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/browser-client-certificates-loader.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export default { + async load (clientCertificate) { + return clientCertificate + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/index.js index c6359740b..32ac0d984 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/index.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/browser/index.js @@ -17,7 +17,7 @@ import WebSocketChannel from './browser-channel.js' import BrowserHosNameResolver from './browser-host-name-resolver.js' - +import BrowserClientCertificatesLoader from './browser-client-certificates-loader.js' /* This module exports a set of components to be used in browser environment. @@ -30,3 +30,4 @@ NOTE: exports in this module should have exactly the same names/structure as exp */ export const Channel = WebSocketChannel export const HostNameResolver = BrowserHosNameResolver +export const ClientCertificatesLoader = BrowserClientCertificatesLoader diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/channel-config.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/channel-config.js index 605ce2658..d06c80acd 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/channel/channel-config.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/channel-config.js @@ -46,8 +46,9 @@ export default class ChannelConfig { * @param {ServerAddress} address the address for the channel to connect to. * @param {Object} driverConfig the driver config provided by the user when driver is created. * @param {string} connectionErrorCode the default error code to use on connection errors. + * @param {object} clientCertificate the client certificate */ - constructor (address, driverConfig, connectionErrorCode) { + constructor (address, driverConfig, connectionErrorCode, clientCertificate) { this.address = address this.encrypted = extractEncrypted(driverConfig) this.trust = extractTrust(driverConfig) @@ -55,6 +56,7 @@ export default class ChannelConfig { this.knownHostsPath = extractKnownHostsPath(driverConfig) this.connectionErrorCode = connectionErrorCode || SERVICE_UNAVAILABLE this.connectionTimeout = driverConfig.connectionTimeout + this.clientCertificate = clientCertificate } } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-channel.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-channel.js index 75d2d8ee2..1fbd4766f 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-channel.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-channel.js @@ -239,6 +239,8 @@ const TrustStrategy = { ); } + assertNotClientCertificates(config) + const caCerts = await Promise.all( config.trustedCertificates.map(f => Deno.readTextFile(f)) ) @@ -250,6 +252,8 @@ const TrustStrategy = { }) }, TRUST_SYSTEM_CA_SIGNED_CERTIFICATES: function (config) { + assertNotClientCertificates(config) + return Deno.connectTls({ hostname: config.address.resolvedHost(), port: config.address.port() @@ -265,6 +269,13 @@ const TrustStrategy = { } } +async function assertNotClientCertificates (config) { + if (config.clientCertificate != null) { + throw newError('clientCertificates are not supported in DenoJS since the API does not ' + + 'support its configuration. See, https://deno.land/api@v1.29.0?s=Deno.ConnectTlsOptions.') + } +} + async function _connect (config) { if (!isEncrypted(config)) { return Deno.connect({ diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-client-certificates-loader.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-client-certificates-loader.js new file mode 100644 index 000000000..2bc1d5619 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/deno-client-certificates-loader.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export default { + async load (clientCertificate) { + return clientCertificate + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/index.js index cc1e00925..b39fb3fd5 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/index.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/deno/index.js @@ -17,7 +17,7 @@ import DenoChannel from './deno-channel.js' import DenoHostNameResolver from './deno-host-name-resolver.js' - +import DenoClientCertificatesLoader from './deno-client-certificates-loader.js' /* This module exports a set of components to be used in deno environment. @@ -30,3 +30,4 @@ import DenoHostNameResolver from './deno-host-name-resolver.js' */ export const Channel = DenoChannel export const HostNameResolver = DenoHostNameResolver +export const ClientCertificatesLoader = DenoClientCertificatesLoader diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/index.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/index.js index 4ca485e5a..72506cf33 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/index.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/index.js @@ -17,7 +17,7 @@ import NodeChannel from './node-channel.js' import NodeHostNameResolver from './node-host-name-resolver.js' - +import NodeClientCertificatesLoader from './node-client-certificates-loader.js' /* This module exports a set of components to be used in NodeJS environment. @@ -31,3 +31,4 @@ NOTE: exports in this module should have exactly the same names/structure as exp export const Channel = NodeChannel export const HostNameResolver = NodeHostNameResolver +export const ClientCertificatesLoader = NodeClientCertificatesLoader diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-channel.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-channel.js index acf8eeb33..a3faa8e0c 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-channel.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-channel.js @@ -49,7 +49,8 @@ const TrustStrategy = { const tlsOpts = newTlsOptions( config.address.host(), - config.trustedCertificates.map(f => fs.readFileSync(f)) + config.trustedCertificates.map(f => fs.readFileSync(f)), + config.clientCertificate ) const socket = tls.connect( config.address.port(), @@ -79,7 +80,11 @@ const TrustStrategy = { return configureSocket(socket) }, TRUST_SYSTEM_CA_SIGNED_CERTIFICATES: function (config, onSuccess, onFailure) { - const tlsOpts = newTlsOptions(config.address.host()) + const tlsOpts = newTlsOptions( + config.address.host(), + undefined, + config.clientCertificate + ) const socket = tls.connect( config.address.port(), config.address.resolvedHost(), @@ -109,7 +114,11 @@ const TrustStrategy = { return configureSocket(socket) }, TRUST_ALL_CERTIFICATES: function (config, onSuccess, onFailure) { - const tlsOpts = newTlsOptions(config.address.host()) + const tlsOpts = newTlsOptions( + config.address.host(), + undefined, + config.clientCertificate + ) const socket = tls.connect( config.address.port(), config.address.resolvedHost(), @@ -198,13 +207,17 @@ function trustStrategyName (config) { * Create a new configuration options object for the {@code tls.connect()} call. * @param {string} hostname the target hostname. * @param {string|undefined} ca an optional CA. + * @param {string|undefined} cert an optional client cert. + * @param {string|undefined} key an optional client cert key. + * @param {string|undefined} passphrase an optional client cert passphrase * @return {Object} a new options object. */ -function newTlsOptions (hostname, ca = undefined) { +function newTlsOptions (hostname, ca = undefined, clientCertificate = undefined) { return { rejectUnauthorized: false, // we manually check for this in the connect callback, to give a more helpful error to the user servername: hostname, // server name for the SNI (Server Name Indication) TLS extension - ca // optional CA useful for TRUST_CUSTOM_CA_SIGNED_CERTIFICATES trust mode + ca, // optional CA useful for TRUST_CUSTOM_CA_SIGNED_CERTIFICATES trust mode, + ...clientCertificate } } diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-client-certificates-loader.js b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-client-certificates-loader.js new file mode 100644 index 000000000..17381d0d3 --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/channel/node/node-client-certificates-loader.js @@ -0,0 +1,65 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs' + +function readFile (file) { + return new Promise((resolve, reject) => fs.readFile(file, (err, data) => { + if (err) { + return reject(err) + } + return resolve(data) + })) +} + +function loadCert (fileOrFiles) { + if (Array.isArray(fileOrFiles)) { + return Promise.all(fileOrFiles.map(loadCert)) + } + return readFile(fileOrFiles) +} + +function loadKey (fileOrFiles) { + if (Array.isArray(fileOrFiles)) { + return Promise.all(fileOrFiles.map(loadKey)) + } + + if (typeof fileOrFiles === 'string') { + return readFile(fileOrFiles) + } + + return readFile(fileOrFiles.path) + .then(pem => ({ + pem, + passphrase: fileOrFiles.password + })) +} + +export default { + async load (clientCertificate) { + const certPromise = loadCert(clientCertificate.certfile) + const keyPromise = loadKey(clientCertificate.keyfile) + + const [cert, key] = await Promise.all([certPromise, keyPromise]) + + return { + cert, + key, + passphrase: clientCertificate.password + } + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/client-certificate-holder.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/client-certificate-holder.js new file mode 100644 index 000000000..0bd5796ea --- /dev/null +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/client-certificate-holder.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ClientCertificatesLoader } from '../channel/index.js' + +export default class ClientCertificateHolder { + constructor ({ clientCertificateProvider, loader }) { + this._clientCertificateProvider = clientCertificateProvider + this._loader = loader || ClientCertificatesLoader + this._clientCertificate = null + } + + async getClientCertificate () { + if (this._clientCertificateProvider != null && + (this._clientCertificate == null || await this._clientCertificateProvider.hasUpdate())) { + this._clientCertificate = Promise.resolve(this._clientCertificateProvider.getClientCertificate()) + .then(this._loader.load) + .then(clientCertificate => { + this._clientCertificate = clientCertificate + return this._clientCertificate + }) + .catch(error => { + this._clientCertificate = null + throw error + }) + } + + return this._clientCertificate + } +} diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-direct.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-direct.js index c815830d7..94d864865 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-direct.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-direct.js @@ -17,7 +17,6 @@ import PooledConnectionProvider from './connection-provider-pooled.js' import { - createChannelConnection, DelegateConnection, ConnectionErrorHandler } from '../connection/index.js' @@ -75,12 +74,7 @@ export default class DirectConnectionProvider extends PooledConnectionProvider { } async _hasProtocolVersion (versionPredicate) { - const connection = await createChannelConnection( - this._address, - this._config, - this._createConnectionErrorHandler(), - this._log - ) + const connection = await this._createChannelConnection(this._address) const protocolVersion = connection.protocol() ? connection.protocol().version diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-pooled.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-pooled.js index 7f9a5ef17..04ca8bf66 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-pooled.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-pooled.js @@ -21,6 +21,7 @@ import { error, ConnectionProvider, ServerInfo, newError } from '../../core/inde import AuthenticationProvider from './authentication-provider.js' import { object } from '../lang/index.js' import LivenessCheckProvider from './liveness-check-provider.js' +import ClientCertificateHolder from './client-certificate-holder.js' const { SERVICE_UNAVAILABLE } = error const AUTHENTICATION_ERRORS = [ @@ -40,18 +41,20 @@ export default class PooledConnectionProvider extends ConnectionProvider { this._id = id this._config = config this._log = log + this._clientCertificateHolder = new ClientCertificateHolder({ clientCertificateProvider: this._config.clientCertificate }) this._authenticationProvider = new AuthenticationProvider({ authTokenManager, userAgent, boltAgent }) this._livenessCheckProvider = new LivenessCheckProvider({ connectionLivenessCheckTimeout: config.connectionLivenessCheckTimeout }) this._userAgent = userAgent this._boltAgent = boltAgent this._createChannelConnection = createChannelConnectionHook || - (address => { + (async address => { return createChannelConnection( address, this._config, this._createConnectionErrorHandler(), - this._log + this._log, + await this._clientCertificateHolder.getClientCertificate() ) }) this._connectionPool = newPool({ @@ -75,6 +78,10 @@ export default class PooledConnectionProvider extends ConnectionProvider { return new ConnectionErrorHandler(SERVICE_UNAVAILABLE) } + async _getClientCertificate () { + return this._config.clientCertificate.getClientCertificate() + } + /** * Create a new connection and initialize it. * @return {Promise} promise resolved with a new connection or rejected when failed to connect. diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-routing.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-routing.js index 1500c75d0..fbd1a8b4d 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-routing.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection-provider/connection-provider-routing.js @@ -71,12 +71,13 @@ export default class RoutingConnectionProvider extends PooledConnectionProvider routingTablePurgeDelay, newPool }) { - super({ id, config, log, userAgent, boltAgent, authTokenManager, newPool }, address => { + super({ id, config, log, userAgent, boltAgent, authTokenManager, newPool }, async address => { return createChannelConnection( address, this._config, this._createConnectionErrorHandler(), this._log, + await this._clientCertificateHolder.getClientCertificate(), this._routingContext ) }) @@ -212,12 +213,7 @@ export default class RoutingConnectionProvider extends PooledConnectionProvider let lastError for (let i = 0; i < addresses.length; i++) { try { - const connection = await createChannelConnection( - addresses[i], - this._config, - this._createConnectionErrorHandler(), - this._log - ) + const connection = await this._createChannelConnection(addresses[i]) const protocolVersion = connection.protocol() ? connection.protocol().version : null diff --git a/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-channel.js b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-channel.js index 5efd40da1..12ad7fc86 100644 --- a/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-channel.js +++ b/packages/neo4j-driver-deno/lib/bolt-connection/connection/connection-channel.js @@ -33,6 +33,7 @@ let idGenerator = 0 * @param {Object} config - the driver configuration. * @param {ConnectionErrorHandler} errorHandler - the error handler for connection errors. * @param {Logger} log - configured logger. + * @param {clientCertificate} clientCertificate - configured client certificate * @return {Connection} - new connection. */ export function createChannelConnection ( @@ -40,13 +41,15 @@ export function createChannelConnection ( config, errorHandler, log, + clientCertificate, serversideRouting = null, createChannel = channelConfig => new Channel(channelConfig) ) { const channelConfig = new ChannelConfig( address, config, - errorHandler.errorCode() + errorHandler.errorCode(), + clientCertificate ) const channel = createChannel(channelConfig) diff --git a/packages/neo4j-driver-deno/lib/core/client-certificate.ts b/packages/neo4j-driver-deno/lib/core/client-certificate.ts new file mode 100644 index 000000000..7a338068b --- /dev/null +++ b/packages/neo4j-driver-deno/lib/core/client-certificate.ts @@ -0,0 +1,334 @@ +/** + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [https://neo4j.com] + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as json from './json.ts' + +type KeyFile = string | { path: string, password?: string } + +/** + * Represents KeyFile represented as file. + * + * @typedef {object} KeyFileObject + * @property {string} path - The path of the file + * @property {string|undefined} password - the password of the key. If none, + * the password defined at {@link ClientCertificate} will be used. + */ +/** + * Holds the Client TLS certificate information. + * + * Browser instances of the driver should configure the certificate + * in the system. + * + * Files defined in the {@link ClientCertificate#certfile} + * and {@link ClientCertificate#keyfile} will read and loaded to + * memory to fill the fields `cert` and `key` in security context. + * + * @interface + * @see https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions + * @experimental Exposed as preview feature. + * @since 5.19 + */ +export default class ClientCertificate { + public readonly certfile: string | string[] + public readonly keyfile: KeyFile | KeyFile[] + public readonly password?: string + + private constructor () { + /** + * The path to client certificate file. + * + * @type {string|string[]} + */ + this.certfile = '' + + /** + * The path to the key file. + * + * @type {string|string[]|KeyFileObject|KeyFileObject[]} + */ + this.keyfile = '' + + /** + * The key's password. + * + * @type {string|undefined} + */ + this.password = undefined + } +} + +/** + * Provides a client certificate to the driver for mutual TLS. + * + * The driver will call {@link ClientCertificateProvider#hasUpdate()} to check if the client wants to update the certificate. + * If so, it will call {@link ClientCertificateProvider#getCertificate()} to get the new certificate. + * + * The certificate is only used as a second factor for authentication authenticating the client. + * The DMBS user still needs to authenticate with an authentication token. + * + * All implementations of this interface must be thread-safe and non-blocking for caller threads. + * For instance, IO operations must not be done on the calling thread. + * + * Note that the work done in the methods of this interface count towards the connectionAcquisition. + * Should fetching the certificate be particularly slow, it might be necessary to increase the timeout. + * + * @interface + * @experimental Exposed as preview feature. + * @since 5.19 + */ +export class ClientCertificateProvider { + /** + * Indicates whether the client wants the driver to update the certificate. + * + * @returns {Promise|boolean} true if the client wants the driver to update the certificate + */ + hasUpdate (): boolean | Promise { + throw new Error('Not Implemented') + } + + /** + * Returns the certificate to use for new connections. + * + * Will be called by the driver after {@link ClientCertificateProvider#hasUpdate()} returned true + * or when the driver establishes the first connection. + * + * @returns {Promise|ClientCertificate} the certificate to use for new connections + */ + getClientCertificate (): ClientCertificate | Promise { + throw new Error('Not Implemented') + } +} + +/** + * Interface for {@link ClientCertificateProvider} which provides update certificate function. + * @interface + * @experimental Exposed as preview feature. + * @since 5.19 + */ +export class RotatingClientCertificateProvider extends ClientCertificateProvider { + /** + * Updates the certificate stored in the provider. + * + * To be called by user-code when a new client certificate is available. + * + * @param {ClientCertificate} certificate - the new certificate + * @throws {TypeError} If initialCertificate is not a ClientCertificate. + */ + updateCertificate (certificate: ClientCertificate): void { + throw new Error('Not implemented') + } +} + +/** + * Defines the object which holds the common {@link ClientCertificateProviders} used in the Driver + * + * @experimental Exposed as preview feature. + * @since 5.19 + */ +class ClientCertificateProviders { + /** + * + * @param {object} param0 - The params + * @param {ClientCertificate} param0.initialCertificate - The certificated used by the driver until {@link RotatingClientCertificateProvider#updateCertificate} get called. + * + * @returns {RotatingClientCertificateProvider} The rotating client certificate provider + * @throws {TypeError} If initialCertificate is not a ClientCertificate. + */ + rotating ({ initialCertificate }: { initialCertificate: ClientCertificate }): RotatingClientCertificateProvider { + if (initialCertificate == null || !isClientClientCertificate(initialCertificate)) { + throw new TypeError(`initialCertificate should be ClientCertificate, but got ${json.stringify(initialCertificate)}`) + } + + const certificate = { ...initialCertificate } + return new InternalRotatingClientCertificateProvider(certificate) + } +} + +/** + * Holds the common {@link ClientCertificateProviders} used in the Driver. + * + * @experimental Exposed as preview feature. + * @since 5.19 + */ +const clientCertificateProviders: ClientCertificateProviders = new ClientCertificateProviders() + +Object.freeze(clientCertificateProviders) + +export { + clientCertificateProviders +} + +export type { + ClientCertificateProviders +} + +/** + * Resolves ClientCertificate or ClientCertificateProvider to a ClientCertificateProvider + * + * Method validates the input. + * + * @private + * @param input + * @returns {ClientCertificateProvider?} A client certificate provider if provided a ClientCertificate or a ClientCertificateProvider + * @throws {TypeError} If input is not a ClientCertificate, ClientCertificateProvider, undefined or null. + */ +export function resolveCertificateProvider (input: unknown): ClientCertificateProvider | undefined { + if (input == null) { + return undefined + } + + if (typeof input === 'object' && 'hasUpdate' in input && 'getClientCertificate' in input && + typeof input.getClientCertificate === 'function' && typeof input.hasUpdate === 'function') { + return input as ClientCertificateProvider + } + + if (isClientClientCertificate(input)) { + const certificate = { ...input } as unknown as ClientCertificate + return { + getClientCertificate: () => certificate, + hasUpdate: () => false + } + } + + throw new TypeError(`clientCertificate should be configured with ClientCertificate or ClientCertificateProvider, but got ${json.stringify(input)}`) +} + +/** + * Verify if object is a client certificate + * @private + * @param maybeClientCertificate - Maybe the certificate + * @returns {boolean} if maybeClientCertificate is a client certificate object + */ +function isClientClientCertificate (maybeClientCertificate: unknown): maybeClientCertificate is ClientCertificate { + return maybeClientCertificate != null && + typeof maybeClientCertificate === 'object' && + 'certfile' in maybeClientCertificate && isCertFile(maybeClientCertificate.certfile) && + 'keyfile' in maybeClientCertificate && isKeyFile(maybeClientCertificate.keyfile) && + isStringOrNotPresent('password', maybeClientCertificate) +} + +/** + * Check value is a cert file + * @private + * @param {any} value the value + * @returns {boolean} is a cert file + */ +function isCertFile (value: unknown): value is string | string [] { + return isString(value) || isArrayOf(value, isString) +} + +/** + * Check if the value is a keyfile. + * + * @private + * @param {any} maybeKeyFile might be a keyfile value + * @returns {boolean} the value is a KeyFile + */ +function isKeyFile (maybeKeyFile: unknown): maybeKeyFile is KeyFile { + function check (obj: unknown): obj is KeyFile { + return typeof obj === 'string' || + (obj != null && + typeof obj === 'object' && + 'path' in obj && typeof obj.path === 'string' && + isStringOrNotPresent('password', obj)) + } + + return check(maybeKeyFile) || isArrayOf(maybeKeyFile, check) +} + +/** + * Verify if value is string + * + * @private + * @param {any} value the value + * @returns {boolean} is string + */ +function isString (value: unknown): value is string { + return typeof value === 'string' +} + +/** + * Verifies if value is a array of type + * + * @private + * @param {any} value the value + * @param {function} isType the type checker + * @returns {boolean} value is array of type + */ +function isArrayOf (value: unknown, isType: (val: unknown) => val is T, allowEmpty: boolean = false): value is T[] { + return Array.isArray(value) && + (allowEmpty || value.length > 0) && + value.filter(isType).length === value.length +} + +/** + * Verify if valueName is present in the object and is a string, or not present at all. + * + * @private + * @param {string} valueName The value in the object + * @param {object} obj The object + * @returns {boolean} if the value is present in object as string or not present + */ +function isStringOrNotPresent (valueName: string, obj: Record): boolean { + return !(valueName in obj) || obj[valueName] == null || typeof obj[valueName] === 'string' +} + +/** + * Internal implementation + * + * @private + */ +class InternalRotatingClientCertificateProvider { + constructor ( + private _certificate: ClientCertificate, + private _updated: boolean = false) { + } + + /** + * + * @returns {boolean|Promise} + */ + + hasUpdate (): boolean | Promise { + try { + return this._updated + } finally { + this._updated = false + } + } + + /** + * + * @returns {ClientCertificate|Promise} + */ + getClientCertificate (): ClientCertificate | Promise { + return this._certificate + } + + /** + * + * @param certificate + * @returns {void} + */ + updateCertificate (certificate: ClientCertificate): void { + if (!isClientClientCertificate(certificate)) { + throw new TypeError(`certificate should be ClientCertificate, but got ${json.stringify(certificate)}`) + } + this._certificate = { ...certificate } + this._updated = true + } +} diff --git a/packages/neo4j-driver-deno/lib/core/connection-provider.ts b/packages/neo4j-driver-deno/lib/core/connection-provider.ts index ab4a7dc96..977aeeada 100644 --- a/packages/neo4j-driver-deno/lib/core/connection-provider.ts +++ b/packages/neo4j-driver-deno/lib/core/connection-provider.ts @@ -28,6 +28,9 @@ import { AuthToken } from './types.ts' * @interface */ class Releasable { + /** + * @returns {Promise} + */ release (): Promise { throw new Error('Not implemented') } diff --git a/packages/neo4j-driver-deno/lib/core/connection.ts b/packages/neo4j-driver-deno/lib/core/connection.ts index dfe08e7bc..53cdbdb9d 100644 --- a/packages/neo4j-driver-deno/lib/core/connection.ts +++ b/packages/neo4j-driver-deno/lib/core/connection.ts @@ -72,34 +72,72 @@ interface RunQueryConfig extends BeginTransactionConfig { * @interface */ class Connection { + /** + * + * @param config + * @returns {ResultStreamObserver} + */ beginTransaction (config: BeginTransactionConfig): ResultStreamObserver { throw new Error('Not implemented') } + /** + * + * @param query + * @param parameters + * @param config + * @returns {ResultStreamObserver} + */ run (query: string, parameters?: Record, config?: RunQueryConfig): ResultStreamObserver { throw new Error('Not implemented') } + /** + * + * @param config + * @returns {ResultStreamObserver} + */ commitTransaction (config: CommitTransactionConfig): ResultStreamObserver { throw new Error('Not implemented') } + /** + * + * @param config + * @returns {ResultStreamObserver} + */ rollbackTransaction (config: RollbackConnectionConfig): ResultStreamObserver { throw new Error('Not implemented') } + /** + * + * @returns {Promise} + */ resetAndFlush (): Promise { throw new Error('Not implemented') } + /** + * + * @returns {boolean} + */ isOpen (): boolean { throw new Error('Not implemented') } + /** + * + * @returns {number} + */ getProtocolVersion (): number { throw new Error('Not implemented') } + /** + * + * @returns {boolean} + */ hasOngoingObservableRequests (): boolean { throw new Error('Not implemented') } diff --git a/packages/neo4j-driver-deno/lib/core/driver.ts b/packages/neo4j-driver-deno/lib/core/driver.ts index 9ba6e07cf..3f7a81b36 100644 --- a/packages/neo4j-driver-deno/lib/core/driver.ts +++ b/packages/neo4j-driver-deno/lib/core/driver.ts @@ -906,6 +906,7 @@ function validateConfig (config: any, log: Logger): any { /** * @private + * @returns {void} */ function sanitizeConfig (config: any): void { config.maxConnectionLifetime = sanitizeIntValue( @@ -932,6 +933,7 @@ function sanitizeConfig (config: any): void { /** * @private + * @returns {number} */ function sanitizeIntValue (rawValue: any, defaultWhenAbsent: number): number { const sanitizedValue = parseInt(rawValue, 10) diff --git a/packages/neo4j-driver-deno/lib/core/index.ts b/packages/neo4j-driver-deno/lib/core/index.ts index d9f4fb95f..ad3dd1f76 100644 --- a/packages/neo4j-driver-deno/lib/core/index.ts +++ b/packages/neo4j-driver-deno/lib/core/index.ts @@ -91,6 +91,7 @@ import { Config } from './types.ts' import * as types from './types.ts' import * as json from './json.ts' import resultTransformers, { ResultTransformer } from './result-transformers.ts' +import ClientCertificate, { clientCertificateProviders, ClientCertificateProvider, ClientCertificateProviders, RotatingClientCertificateProvider, resolveCertificateProvider } from './client-certificate.ts' import * as internal from './internal/index.ts' /** @@ -169,7 +170,9 @@ const forExport = { notificationCategory, notificationSeverityLevel, notificationFilterDisabledCategory, - notificationFilterMinimumSeverityLevel + notificationFilterMinimumSeverityLevel, + clientCertificateProviders, + resolveCertificateProvider } export { @@ -238,7 +241,9 @@ export { notificationCategory, notificationSeverityLevel, notificationFilterDisabledCategory, - notificationFilterMinimumSeverityLevel + notificationFilterMinimumSeverityLevel, + clientCertificateProviders, + resolveCertificateProvider } export type { @@ -263,7 +268,11 @@ export type { NotificationSeverityLevel, NotificationFilter, NotificationFilterDisabledCategory, - NotificationFilterMinimumSeverityLevel + NotificationFilterMinimumSeverityLevel, + ClientCertificate, + ClientCertificateProvider, + ClientCertificateProviders, + RotatingClientCertificateProvider } export default forExport diff --git a/packages/neo4j-driver-deno/lib/core/types.ts b/packages/neo4j-driver-deno/lib/core/types.ts index f8cc7a735..d493c400d 100644 --- a/packages/neo4j-driver-deno/lib/core/types.ts +++ b/packages/neo4j-driver-deno/lib/core/types.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import ClientCertificate, { ClientCertificateProvider } from './client-certificate.ts' import NotificationFilter from './notification-filter.ts' /** @@ -80,6 +81,7 @@ export class Config { resolver?: (address: string) => string[] | Promise userAgent?: string telemetryDisabled?: boolean + clientCertificate?: ClientCertificate | ClientCertificateProvider /** * @constructor @@ -340,6 +342,18 @@ export class Config { * @type {boolean} */ this.telemetryDisabled = false + + /** + * Client Certificate used for mutual TLS. + * + * A {@link ClientCertificateProvider} can be configure for scenarios + * where the {@link ClientCertificate} might change over time. + * + * @type {ClientCertificate|ClientCertificateProvider|undefined} + * @experimental Exposed as preview feature. + * @since 5.19 + */ + this.clientCertificate = undefined } } diff --git a/packages/neo4j-driver-deno/lib/mod.ts b/packages/neo4j-driver-deno/lib/mod.ts index fd65f7308..b8e652221 100644 --- a/packages/neo4j-driver-deno/lib/mod.ts +++ b/packages/neo4j-driver-deno/lib/mod.ts @@ -97,7 +97,13 @@ import { Transaction, TransactionPromise, types as coreTypes, - UnboundRelationship + UnboundRelationship, + ClientCertificate, + ClientCertificateProvider, + ClientCertificateProviders, + RotatingClientCertificateProvider, + clientCertificateProviders, + resolveCertificateProvider } from './core/index.ts' // @deno-types=./bolt-connection/types/index.d.ts import { DirectConnectionProvider, RoutingConnectionProvider } from './bolt-connection/index.js' @@ -209,6 +215,7 @@ function driver ( } _config.encrypted = ENCRYPTION_ON _config.trust = trust + _config.clientCertificate = resolveCertificateProvider(config.clientCertificate) } const authTokenManager = createAuthManager(authToken) @@ -424,7 +431,8 @@ const forExport = { notificationCategory, notificationSeverityLevel, notificationFilterDisabledCategory, - notificationFilterMinimumSeverityLevel + notificationFilterMinimumSeverityLevel, + clientCertificateProviders } export { @@ -491,7 +499,8 @@ export { notificationCategory, notificationSeverityLevel, notificationFilterDisabledCategory, - notificationFilterMinimumSeverityLevel + notificationFilterMinimumSeverityLevel, + clientCertificateProviders } export type { QueryResult, @@ -516,6 +525,10 @@ export type { NotificationSeverityLevel, NotificationFilter, NotificationFilterDisabledCategory, - NotificationFilterMinimumSeverityLevel + NotificationFilterMinimumSeverityLevel, + ClientCertificate, + ClientCertificateProvider, + ClientCertificateProviders, + RotatingClientCertificateProvider } export default forExport diff --git a/packages/neo4j-driver-deno/test/neo4j.test.ts b/packages/neo4j-driver-deno/test/neo4j.test.ts index 1560ac602..e682dac20 100644 --- a/packages/neo4j-driver-deno/test/neo4j.test.ts +++ b/packages/neo4j-driver-deno/test/neo4j.test.ts @@ -64,9 +64,10 @@ Deno.test('session.beginTransaction should rollback the transaction if not commi // Deno will fail with resource leaks Deno.test('session.beginTransaction should noop if resource committed', async () => { await using driver = neo4j.driver(uri, authToken) + const name = "Must Be Conor" + try { await using session = driver.session() - const name = "Must Be Conor" { await using tx = session.beginTransaction() diff --git a/packages/neo4j-driver-lite/src/index.ts b/packages/neo4j-driver-lite/src/index.ts index 7dfeeb1fa..a9b440367 100644 --- a/packages/neo4j-driver-lite/src/index.ts +++ b/packages/neo4j-driver-lite/src/index.ts @@ -97,7 +97,13 @@ import { Transaction, TransactionPromise, types as coreTypes, - UnboundRelationship + UnboundRelationship, + ClientCertificate, + ClientCertificateProvider, + ClientCertificateProviders, + RotatingClientCertificateProvider, + clientCertificateProviders, + resolveCertificateProvider } from 'neo4j-driver-core' import { DirectConnectionProvider, RoutingConnectionProvider } from 'neo4j-driver-bolt-connection' @@ -208,6 +214,7 @@ function driver ( } _config.encrypted = ENCRYPTION_ON _config.trust = trust + _config.clientCertificate = resolveCertificateProvider(config.clientCertificate) } const authTokenManager = createAuthManager(authToken) @@ -423,7 +430,8 @@ const forExport = { notificationCategory, notificationSeverityLevel, notificationFilterDisabledCategory, - notificationFilterMinimumSeverityLevel + notificationFilterMinimumSeverityLevel, + clientCertificateProviders } export { @@ -490,7 +498,8 @@ export { notificationCategory, notificationSeverityLevel, notificationFilterDisabledCategory, - notificationFilterMinimumSeverityLevel + notificationFilterMinimumSeverityLevel, + clientCertificateProviders } export type { QueryResult, @@ -515,6 +524,10 @@ export type { NotificationSeverityLevel, NotificationFilter, NotificationFilterDisabledCategory, - NotificationFilterMinimumSeverityLevel + NotificationFilterMinimumSeverityLevel, + ClientCertificate, + ClientCertificateProvider, + ClientCertificateProviders, + RotatingClientCertificateProvider } export default forExport diff --git a/packages/neo4j-driver/src/index.js b/packages/neo4j-driver/src/index.js index b2686f690..e0b2b4aa4 100644 --- a/packages/neo4j-driver/src/index.js +++ b/packages/neo4j-driver/src/index.js @@ -73,7 +73,9 @@ import { notificationSeverityLevel, notificationFilterDisabledCategory, notificationFilterMinimumSeverityLevel, - staticAuthTokenManager + staticAuthTokenManager, + clientCertificateProviders, + resolveCertificateProvider } from 'neo4j-driver-core' import { DirectConnectionProvider, @@ -169,6 +171,7 @@ function driver (url, authToken, config = {}) { } config.encrypted = ENCRYPTION_ON config.trust = trust + config.clientCertificate = resolveCertificateProvider(config.clientCertificate) } const authTokenManager = createAuthManager(authToken) @@ -394,7 +397,8 @@ const forExport = { notificationCategory, notificationSeverityLevel, notificationFilterDisabledCategory, - notificationFilterMinimumSeverityLevel + notificationFilterMinimumSeverityLevel, + clientCertificateProviders } export { @@ -462,6 +466,7 @@ export { notificationCategory, notificationSeverityLevel, notificationFilterDisabledCategory, - notificationFilterMinimumSeverityLevel + notificationFilterMinimumSeverityLevel, + clientCertificateProviders } export default forExport diff --git a/packages/neo4j-driver/test/internal/connection-channel.test.js b/packages/neo4j-driver/test/internal/connection-channel.test.js index ea8acd912..8a8bd9e91 100644 --- a/packages/neo4j-driver/test/internal/connection-channel.test.js +++ b/packages/neo4j-driver/test/internal/connection-channel.test.js @@ -157,6 +157,7 @@ describe('#integration ChannelConnection', () => { new ConnectionErrorHandler(SERVICE_UNAVAILABLE), Logger.noOp(), null, + null, () => channel ) .then(c => { diff --git a/packages/neo4j-driver/types/index.d.ts b/packages/neo4j-driver/types/index.d.ts index 25ce8748b..18c9a5cbf 100644 --- a/packages/neo4j-driver/types/index.d.ts +++ b/packages/neo4j-driver/types/index.d.ts @@ -87,6 +87,11 @@ import { notificationFilterMinimumSeverityLevel, AuthTokenManager, AuthTokenAndExpiration, + ClientCertificate, + ClientCertificateProvider, + ClientCertificateProviders, + RotatingClientCertificateProvider, + clientCertificateProviders, types as coreTypes } from 'neo4j-driver-core' import { @@ -283,6 +288,7 @@ declare const forExport: { notificationFilterDisabledCategory: typeof notificationFilterDisabledCategory notificationFilterMinimumSeverityLevel: typeof notificationFilterMinimumSeverityLevel logging: typeof logging + clientCertificateProviders: typeof clientCertificateProviders } export { @@ -358,7 +364,8 @@ export { notificationSeverityLevel, notificationFilterDisabledCategory, notificationFilterMinimumSeverityLevel, - logging + logging, + clientCertificateProviders } export type { @@ -376,7 +383,11 @@ export type { NotificationFilterDisabledCategory, NotificationFilterMinimumSeverityLevel, AuthTokenManager, - AuthTokenAndExpiration + AuthTokenAndExpiration, + ClientCertificate, + ClientCertificateProvider, + ClientCertificateProviders, + RotatingClientCertificateProvider } export default forExport diff --git a/packages/testkit-backend/src/context.js b/packages/testkit-backend/src/context.js index 4cdc99d74..019889125 100644 --- a/packages/testkit-backend/src/context.js +++ b/packages/testkit-backend/src/context.js @@ -19,6 +19,8 @@ export default class Context { this._basicAuthTokenProviderRequests = {} this._binder = binder this._environmentLogLevel = environmentLogLevel + this._clientCertificateProviders = {} + this._clientCertificateProviderRequests = {} } get binder () { @@ -226,6 +228,32 @@ export default class Context { delete this._basicAuthTokenProviderRequests[id] } + addClientCertificate (clientCertificateFactory) { + this._id++ + this._clientCertificateProviders[this._id] = clientCertificateFactory(this._id) + return this._id + } + + getClientCertificate (id) { + return this._clientCertificateProviders[id] + } + + removeClientCertificate (id) { + delete this._clientCertificateProviders[id] + } + + addClientCertificateProviderRequest (resolve, reject) { + return this._add(this._clientCertificateProviderRequests, { resolve, reject }) + } + + getClientCertificateProviderRequest (id) { + return this._clientCertificateProviderRequests[id] + } + + removeClientCertificateProviderRequest (id) { + delete this._clientCertificateProviderRequests[id] + } + _add (map, object) { this._id++ map[this._id] = object diff --git a/packages/testkit-backend/src/feature/common.js b/packages/testkit-backend/src/feature/common.js index 1e420e447..772e1b267 100644 --- a/packages/testkit-backend/src/feature/common.js +++ b/packages/testkit-backend/src/feature/common.js @@ -7,6 +7,7 @@ const features = [ 'Feature:API:BookmarkManager', 'Feature:API:RetryableExceptions', 'Feature:API:Session:AuthConfig', + 'Feature:API:SSLClientCertificate', 'Feature:API:SSLConfig', 'Feature:API:SSLSchemes', 'Feature:API:Type.Temporal', diff --git a/packages/testkit-backend/src/request-handlers-rx.js b/packages/testkit-backend/src/request-handlers-rx.js index c093932be..546820c03 100644 --- a/packages/testkit-backend/src/request-handlers-rx.js +++ b/packages/testkit-backend/src/request-handlers-rx.js @@ -33,6 +33,9 @@ export { BearerAuthTokenProviderCompleted, NewBasicAuthTokenManager, BasicAuthTokenProviderCompleted, + NewClientCertificateProvider, + ClientCertificateProviderClose, + ClientCertificateProviderCompleted, FakeTimeInstall, FakeTimeTick, FakeTimeUninstall diff --git a/packages/testkit-backend/src/request-handlers.js b/packages/testkit-backend/src/request-handlers.js index c7198c506..11a833207 100644 --- a/packages/testkit-backend/src/request-handlers.js +++ b/packages/testkit-backend/src/request-handlers.js @@ -86,6 +86,17 @@ export function NewDriver ({ neo4j }, context, data, wire) { config.connectionLivenessCheckTimeout = data.livenessCheckTimeoutMs } + if (data.clientCertificate != null && data.clientCertificateProviderId != null) { + throw new Error('Can not set clientCertificate and clientCertificateProviderId') + } if (data.clientCertificate != null) { + config.clientCertificate = data.clientCertificate.data + } else if (data.clientCertificateProviderId != null) { + config.clientCertificate = context.getClientCertificate(data.clientCertificateProviderId) + if (config.clientCertificate == null) { + throw new Error('Invalid ClientCertificateProvider') + } + } + let driver try { driver = neo4j.driver(uri, parsedAuthToken, config) @@ -613,6 +624,52 @@ export function BasicAuthTokenProviderCompleted (_, context, { requestId, auth } context.removeBasicAuthTokenProviderRequest(requestId) } +export function NewClientCertificateProvider (_, context, _data, wire) { + const id = context.addClientCertificate((id) => { + const state = { + clientCertificate: undefined, + hasUpdate: undefined + } + const requestCertificate = () => new Promise((resolve, reject) => { + const requestId = context.addClientCertificateProviderRequest(resolve, reject) + wire.writeResponse(responses.ClientCertificateProviderRequest({ + id: requestId, + clientCertificateProviderId: id + })) + }) + + return { + hasUpdate: async () => { + const { hasUpdate, clientCertificate } = await requestCertificate() + state.clientCertificate = clientCertificate + state.hasUpdate = hasUpdate + return hasUpdate + }, + getClientCertificate: async () => { + if (state.clientCertificate != null) { + return state.clientCertificate + } + const { clientCertificate } = await requestCertificate() + state.clientCertificate = clientCertificate + return clientCertificate + } + } + }) + + wire.writeResponse(responses.ClientCertificateProvider({ id })) +} + +export function ClientCertificateProviderClose (_, context, { id }, wire) { + context.removeClientCertificate(id) + wire.writeResponse(responses.ClientCertificateProvider({ id })) +} + +export function ClientCertificateProviderCompleted (_, context, { requestId, clientCertificate, hasUpdate }) { + const request = context.getClientCertificateProviderRequest(requestId) + request.resolve({ hasUpdate, clientCertificate: clientCertificate.data }) + context.removeClientCertificateProviderRequest(requestId) +} + export function GetRoutingTable (_, context, { driverId, database }, wire) { const driver = context.getDriver(driverId) const routingTable = diff --git a/packages/testkit-backend/src/responses.js b/packages/testkit-backend/src/responses.js index c6d67a3c4..b78324672 100644 --- a/packages/testkit-backend/src/responses.js +++ b/packages/testkit-backend/src/responses.js @@ -139,6 +139,14 @@ export function DriverIsAuthenticated ({ id, authenticated }) { return response('DriverIsAuthenticated', { id, authenticated }) } +export function ClientCertificateProvider ({ id }) { + return response('ClientCertificateProvider', { id }) +} + +export function ClientCertificateProviderRequest ({ id, clientCertificateProviderId }) { + return response('ClientCertificateProviderRequest', { id, clientCertificateProviderId }) +} + // Testkit controller messages export function RunTest () { return response('RunTest', null) diff --git a/packages/testkit-backend/src/skipped-tests/deno.js b/packages/testkit-backend/src/skipped-tests/deno.js index e2dd2eec2..90d3891f0 100644 --- a/packages/testkit-backend/src/skipped-tests/deno.js +++ b/packages/testkit-backend/src/skipped-tests/deno.js @@ -8,6 +8,9 @@ const skippedTests = [ ifEndsWith('test_untrusted_ca_correct_hostname'), ifEndsWith('test_1_1') ), + skip('DenoJS does not support client certificates', + ifStartsWith('tls.test_client_certificate.') + ), skip('Trust All is not available as configuration', ifStartsWith('tls.test_self_signed_scheme.TestTrustAllCertsConfig.'), ifStartsWith('tls.test_self_signed_scheme.TestSelfSignedScheme.')