Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce Client Certificate configuration #1183

Merged
merged 23 commits into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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