Skip to content

Commit

Permalink
Introduce Client Certificate configuration (#1183)
Browse files Browse the repository at this point in the history
⚠️ This API is released as preview.
⚠️ This feature is NodeJS only. Browser should configure the client certificate in the system certificates. 

The ClientCertificate is a mechanism to support mutual TLS as a second factor for authentication. The driver's certificate will not be used to authenticate a user on the DBMS, but only to authenticate the driver as a trusted client to the DBMS. Another authentication mechanism is still required to authenticate the user.

The configuration is done by using the driver configuration, the property name is `clientCertificate` and this a object with the following properties:

* `certfile`: The path to client certificate file. This is can configured as list of paths.
* `keyfile`: The path to key file. This can also be configured as a list of paths, an object contain `path` and `password` or a list of objects contain `path` and `password`.
* `password` (optional): The client key password.

See [node documentation](https://nodejs.org/api/tls.html#tlscreatesecurecontextoptions) for understanding how password, certs and keys work together. 

Configuration example:

```javascript
import neo4j from 'neo4j-driver'

const driver = neo4j.driver('neo4j+s://myhost:7687', MY_CREDENTIALS, {
   clientCertificate: {
      certfile: '/path/to/cert/file.cert',
      keyfile: '/path/to/cert/file.pem',
      password: 'the_key_password' // optional
   }
})

// then use your driver as usual. 
```

### Client Certificate Provider and Certificate Rotation

In case of the certificate needs to be changed, the driver offers an api for change client certificates without creating a new driver instance. The change is done by inform an `ClientCertificateProvider` instead of a `ClientCertificate` in the driver configuration. 

`ClientCertificateProvider` is a public interface which can be implemented by user. However, the driver offers an implementation of this interface for working with certificate rotation scenarios. 

```javascript
import neo4j from 'neo4j-driver'

const initialClientCertificate: {
  certfile: '/path/to/cert/file.cert',
  keyfile: '/path/to/cert/file.pem',
  password: 'the_key_password' // optional
}

const clientCertificateProvider = neo4j.clientCertificateProviders.rotating({
    initialCertificate: initialClientCertificate
})

const driver = neo4j.driver('neo4j+s://myhost:7687', MY_CREDENTIALS, {
   clientCertificate: clientCertificateProvider
})


// use the driver as usual

// then you have new certificate which will replace the old one
clientCertificateProvider.updateCertificate({
  certfile: '/path/to/cert/new_file.cert',
  keyfile: '/path/to/cert/new_file.pem',
  password: 'the_new_key_password' // optional
})

// New connections will be created using the new certificate.
// however, older connections will not be closed if they still working.
```

⚠️ This feature is NodeJS only. Browser should configure the client certificate in the system certificates. 
⚠️ This API is released as preview.
  • Loading branch information
bigmontz authored Mar 7, 2024
1 parent 6f72b3d commit 552ea6c
Show file tree
Hide file tree
Showing 56 changed files with 1,944 additions and 72 deletions.
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 2 additions & 1 deletion packages/bolt-connection/src/channel/browser/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
4 changes: 3 additions & 1 deletion packages/bolt-connection/src/channel/channel-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,17 @@ 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)
this.trustedCertificates = extractTrustedCertificates(driverConfig)
this.knownHostsPath = extractKnownHostsPath(driverConfig)
this.connectionErrorCode = connectionErrorCode || SERVICE_UNAVAILABLE
this.connectionTimeout = driverConfig.connectionTimeout
this.clientCertificate = clientCertificate
}
}

Expand Down
11 changes: 11 additions & 0 deletions packages/bolt-connection/src/channel/deno/deno-channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ const TrustStrategy = {
);
}

assertNotClientCertificates(config)

const caCerts = await Promise.all(
config.trustedCertificates.map(f => Deno.readTextFile(f))
)
Expand All @@ -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()
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 2 additions & 1 deletion packages/bolt-connection/src/channel/deno/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -30,3 +30,4 @@ import DenoHostNameResolver from './deno-host-name-resolver'
*/
export const Channel = DenoChannel
export const HostNameResolver = DenoHostNameResolver
export const ClientCertificatesLoader = DenoClientCertificatesLoader
3 changes: 2 additions & 1 deletion packages/bolt-connection/src/channel/node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
23 changes: 18 additions & 5 deletions packages/bolt-connection/src/channel/node/node-channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@

import PooledConnectionProvider from './connection-provider-pooled'
import {
createChannelConnection,
DelegateConnection,
ConnectionErrorHandler
} from '../connection'
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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({
Expand All @@ -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<Connection>} promise resolved with a new connection or rejected when failed to connect.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
})
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 552ea6c

Please sign in to comment.