Skip to content

Commit 52f6b1d

Browse files
committed
Add support for TLS encrypted connections.
This was a bit tricky, several things play into making this as complicated as it turned out: - TOFU (Trust on First Use) as we implement it only works on NodeJS, and only since nodejs/node@345c40b6 - Browsers do not support specifying which certificates to trust, so CA list configuration only works on NodeJS - The location of `.neo4j/known_hosts`, used by TOFU, differs between windows and linux. However, tests are now green, all rejoice. For a modern NodeJS deployment, the driver now defaults to using encryption and uses TOFU for establishing trust, meaning users will get a low-hassle encrypted setup by default. If users are running in a web browser, or running a several-years-old version of NodeJS, the driver will default to not using encryption. I'm not entirely comfortable with this, but doing encrypted websockets in the browser is a massive hurdle for users, since each Neo4j database has it's own self-signed certificate by default. It seems that if we forced first-time users to install new CA certificates in their browsers, they will just opt to turn encryption off.
1 parent c4b33ef commit 52f6b1d

16 files changed

+532
-80
lines changed

gulpfile.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ gulp.task('download-tck', function() {
204204
gulp.task('run-tck', ['download-tck', 'nodejs'], function() {
205205
return gulp.src(featureHome + "/*").pipe(cucumber({
206206
'steps': 'test/v1/tck/steps/*.js',
207-
'format': 'pretty',
207+
'format': 'summary',
208208
'tags' : ['~@in_dev', '~@db']
209209
}));
210210
});

src/v1/driver.js

+6-4
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@ class Driver {
3232
* @param {string} url
3333
* @param {string} userAgent
3434
* @param {Object} token
35+
* @param {Object} config
3536
*/
36-
constructor(url, userAgent, token) {
37+
constructor(url, userAgent = 'neo4j-javascript/0.0', token = {}, config = {}) {
3738
this._url = url;
38-
this._userAgent = userAgent || 'neo4j-javascript/0.0';
39+
this._userAgent = userAgent;
3940
this._openSessions = {};
4041
this._sessionIdGenerator = 0;
41-
this._token = token || {};
42+
this._token = token;
43+
this._config = config;
4244
this._pool = new Pool(
4345
this._createConnection.bind(this),
4446
this._destroyConnection.bind(this),
@@ -54,7 +56,7 @@ class Driver {
5456
_createConnection( release ) {
5557
let sessionId = this._sessionIdGenerator++;
5658
let streamObserver = new _ConnectionStreamObserver(this);
57-
let conn = connect(this._url);
59+
let conn = connect(this._url, this._config);
5860
conn.initialize(this._userAgent, this._token, streamObserver);
5961
conn._id = sessionId;
6062
conn._release = () => release(conn);

src/v1/index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {Node, Relationship, UnboundRelationship, PathSegment, Path} from './grap
2525
let USER_AGENT = "neo4j-javascript/" + VERSION;
2626

2727
export default {
28-
driver: (url, token) => new Driver(url, USER_AGENT, token),
28+
driver: (url, token, config={}) => new Driver(url, USER_AGENT, token, config),
2929
int,
3030
isInt,
3131
auth: {

src/v1/internal/buf.js

+9-9
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,7 @@ class HeapBuffer extends BaseBuffer {
399399
let copy = new HeapBuffer(length);
400400
for (var i = 0; i < length; i++) {
401401
copy.putUInt8( i, this.getUInt8( i + start ) );
402-
};
402+
}
403403
return copy;
404404
}
405405
}
@@ -414,7 +414,7 @@ class HeapBuffer extends BaseBuffer {
414414
}
415415

416416
/**
417-
* Represents a view of slice of another buffer.
417+
* Represents a view as slice of another buffer.
418418
* @access private
419419
*/
420420
class SliceBuffer extends BaseBuffer {
@@ -486,7 +486,7 @@ class CombinedBuffer extends BaseBuffer {
486486
} else {
487487
return buffer.getInt8(position);
488488
}
489-
};
489+
}
490490
}
491491

492492
getFloat64 ( position ) {
@@ -561,12 +561,12 @@ try {
561561
} catch(e) {}
562562

563563
/**
564-
* Allocate a new buffer using whatever mechanism is most sensible for the
565-
* current platform
566-
* @access private
567-
* @param {Integer} size
568-
* @return new buffer
569-
*/
564+
* Allocate a new buffer using whatever mechanism is most sensible for the
565+
* current platform
566+
* @access private
567+
* @param {Integer} size
568+
* @return new buffer
569+
*/
570570
function alloc (size) {
571571
return new _DefaultBuffer(size);
572572
}

src/v1/internal/ch-node.js

+172-27
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,152 @@
1818
*/
1919

2020
import net from 'net';
21+
import tls from 'tls';
22+
import fs from 'fs';
23+
import path from 'path';
24+
import {EOL} from 'os';
2125
import {NodeBuffer} from './buf';
26+
import {newError} from './error';
2227

2328
let _CONNECTION_IDGEN = 0;
2429

25-
/**
26-
* In a Node.js environment the 'net' module is used
27-
* as transport.
28-
* @access private
29-
*/
30+
function userHome() {
31+
// For some reason, Browserify chokes on shimming `process`. This code
32+
// will never get executed on the browser anyway, to just hack around it
33+
let getOutOfHereBrowserifyYoureDrunk = require;
34+
let process = getOutOfHereBrowserifyYoureDrunk('process');
35+
36+
return process.env[(process.platform == 'win32') ? 'USERPROFILE' : 'HOME'];
37+
}
38+
39+
function loadFingerprint( serverId, knownHostsPath, cb ) {
40+
if( !fs.existsSync( knownHostsPath )) {
41+
cb(null);
42+
return;
43+
}
44+
let found = false;
45+
require('readline').createInterface({
46+
input: fs.createReadStream(knownHostsPath)
47+
}).on('line', (line) => {
48+
if( line.startsWith( serverId )) {
49+
found = true;
50+
cb( line.split(" ")[1] );
51+
}
52+
}).on('close', () => {
53+
if(!found) {
54+
cb(null);
55+
}
56+
});
57+
}
58+
59+
function storeFingerprint( serverId, knownHostsPath, fingerprint ) {
60+
fs.appendFile(knownHostsPath, serverId + " " + fingerprint + EOL, "utf8" );
61+
}
62+
63+
const TrustStrategy = {
64+
TRUST_SIGNED_CERTIFICATES : function( opts, onSuccess, onFailure ) {
65+
if( !opts.trustedCertificates || opts.trustedCertificates.length == 0 ) {
66+
onFailure(newError("You are using TRUST_SIGNED_CERTIFICATES as the method " +
67+
"to verify trust for encrypted connections, but have not configured any " +
68+
"trustedCertificates. You must specify the path to at least one trusted " +
69+
"X.509 certificate for this to work. Two other alternatives is to use " +
70+
"TRUST_ON_FIRST_USE or to disable encryption by setting encrypted=false " +
71+
"in your driver configuration."));
72+
return;
73+
}
74+
75+
let tlsOpts = {
76+
ca: opts.trustedCertificates.map(fs.readFileSync),
77+
// Because we manually check for this in the connect callback, to give
78+
// a more helpful error to the user
79+
rejectUnauthorized: false
80+
};
81+
82+
let socket = tls.connect(opts.port, opts.host, tlsOpts, function () {
83+
if (!socket.authorized) {
84+
onFailure(newError("Server certificate is not trusted. If you trust the database you are connecting to, add" +
85+
" the signing certificate, or the server certificate, to the list of certificates trusted by this driver" +
86+
" using `neo4j.v1.driver(.., { trustedCertificates:['path/to/certificate.crt']}). This " +
87+
" is a security measure to protect against man-in-the-middle attacks. If you are just trying " +
88+
" Neo4j out and are not concerned about encryption, simply disable it using `encrypted=false` in the driver" +
89+
" options."));
90+
} else {
91+
onSuccess();
92+
}
93+
});
94+
return socket;
95+
},
96+
TRUST_ON_FIRST_USE : function( opts, onSuccess, onFailure ) {
97+
let tlsOpts = {
98+
// Because we manually verify the certificate against known_hosts
99+
rejectUnauthorized: false
100+
};
101+
102+
let socket = tls.connect(opts.port, opts.host, tlsOpts, function () {
103+
var serverCert = socket.getPeerCertificate(true);
104+
105+
if( !serverCert.raw ) {
106+
// If `raw` is not available, we're on an old version of NodeJS, and
107+
// the raw cert cannot be accessed (or, at least I couldn't find a way to)
108+
// therefore, we can't generate a SHA512 fingerprint, meaning we can't
109+
// do TOFU, and the safe approach is to fail.
110+
onFailure(newError("You are using a version of NodeJS that does not " +
111+
"support trust-on-first use encryption. You can either upgrade NodeJS to " +
112+
"a newer version, use `trust:TRUST_SIGNED_CERTIFICATES` in your driver " +
113+
"config instead, or disable encryption using `encrypted:false`."));
114+
return;
115+
}
116+
117+
var serverFingerprint = require('crypto').createHash('sha512').update(serverCert.raw).digest("hex");
118+
let knownHostsPath = opts.knownHosts || path.join(userHome(), ".neo4j", "known_hosts");
119+
let serverId = opts.host + ":" + opts.port;
120+
121+
loadFingerprint(serverId, knownHostsPath, (knownFingerprint) => {
122+
if( knownFingerprint === serverFingerprint ) {
123+
onSuccess();
124+
} else if( knownFingerprint == null ) {
125+
storeFingerprint( serverId, knownHostsPath, serverFingerprint );
126+
onSuccess();
127+
} else {
128+
onFailure(newError("Database encryption certificate has changed, and no longer " +
129+
"matches the certificate stored for " + serverId + " in `" + knownHostsPath +
130+
"`. As a security precaution, this driver will not automatically trust the new " +
131+
"certificate, because doing so would allow an attacker to pretend to be the Neo4j " +
132+
"instance we want to connect to. The certificate provided by the server looks like: " +
133+
serverCert + ". If you trust that this certificate is valid, simply remove the line " +
134+
"starting with " + serverId + " in `" + knownHostsPath + "`, and the driver will " +
135+
"update the file with the new certificate. You can configure which file the driver " +
136+
"should use to store this information by setting `knownHosts` to another path in " +
137+
"your driver configuration - and you can disable encryption there as well using " +
138+
"`encrypted:false`."))
139+
}
140+
});
141+
});
142+
return socket;
143+
}
144+
};
145+
146+
function connect( opts, onSuccess, onFailure=(()=>null) ) {
147+
if( opts.encrypted === false ) {
148+
return net.connect(opts.port, opts.host, onSuccess);
149+
} else if( TrustStrategy[opts.trust]) {
150+
return TrustStrategy[opts.trust](opts, onSuccess, onFailure);
151+
} else {
152+
onFailure(newError("Unknown trust strategy: " + opts.trust + ". Please use either " +
153+
"trust:'TRUST_SIGNED_CERTIFICATES' or trust:'TRUST_ON_FIRST_USE' in your driver " +
154+
"configuration. Alternatively, you can disable encryption by setting " +
155+
"`encrypted:false`. There is no mechanism to use encryption without trust verification, " +
156+
"because this incurs the overhead of encryption without improving security. If " +
157+
"the driver does not verify that the peer it is connected to is really Neo4j, it " +
158+
"is very easy for an attacker to bypass the encryption by pretending to be Neo4j."));
159+
}
160+
}
30161

162+
/**
163+
* In a Node.js environment the 'net' module is used
164+
* as transport.
165+
* @access private
166+
*/
31167
class NodeChannel {
32168

33169
/**
@@ -37,35 +173,42 @@ class NodeChannel {
37173
* @param {Integer} opts.port - The port to use.
38174
*/
39175
constructor (opts) {
40-
let _self = this;
176+
let self = this;
41177

42178
this.id = _CONNECTION_IDGEN++;
43179
this.available = true;
44180
this._pending = [];
45181
this._open = true;
46-
this._conn = net.connect((opts.port || 7687), opts.host, () => {
47-
if(!_self._open) {
182+
this._error = null;
183+
this._handleConnectionError = this._handleConnectionError.bind(this);
184+
185+
this._conn = connect(opts, () => {
186+
if(!self._open) {
48187
return;
49188
}
189+
190+
self._conn.on('data', ( buffer ) => {
191+
if( self.onmessage ) {
192+
self.onmessage( new NodeBuffer( buffer ) );
193+
}
194+
});
195+
196+
self._conn.on('error', self._handleConnectionError);
197+
50198
// Drain all pending messages
51-
let pending = _self._pending;
52-
_self._pending = null;
199+
let pending = self._pending;
200+
self._pending = null;
53201
for (let i = 0; i < pending.length; i++) {
54-
_self.write( pending[i] );
202+
self.write( pending[i] );
55203
}
56-
});
57-
58-
this._conn.on('data', ( buffer ) => {
59-
if( _self.onmessage ) {
60-
_self.onmessage( new NodeBuffer( buffer ) );
61-
}
62-
});
204+
}, this._handleConnectionError);
205+
}
63206

64-
this._conn.on('error', function(err){
65-
if( _self.onerror ) {
66-
_self.onerror(err);
67-
}
68-
});
207+
_handleConnectionError( err ) {
208+
this._error = err;
209+
if( this.onerror ) {
210+
this.onerror(err);
211+
}
69212
}
70213

71214
/**
@@ -89,12 +232,14 @@ class NodeChannel {
89232
* Close the connection
90233
* @param {function} cb - Function to call on close.
91234
*/
92-
close(cb) {
93-
if(cb) {
235+
close(cb = (() => null)) {
236+
this._open = false;
237+
if( this._conn ) {
238+
this._conn.end();
94239
this._conn.on('end', cb);
240+
} else {
241+
cb();
95242
}
96-
this._open = false;
97-
this._conn.end();
98243
}
99244
}
100245
let _nodeChannelModule = {channel: NodeChannel, available: true};

0 commit comments

Comments
 (0)