Skip to content

Commit 0a704bf

Browse files
committed
Merge pull request #36 from neo4j/1.0-tls
1.0 tls
2 parents b070622 + e9269db commit 0a704bf

File tree

9 files changed

+227
-57
lines changed

9 files changed

+227
-57
lines changed

examples/test_examples.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,17 @@
1919
# limitations under the License.
2020

2121

22-
from unittest import TestCase
22+
from test.util import ServerTestCase
2323

2424
# tag::minimal-example-import[]
2525
from neo4j.v1 import GraphDatabase
2626
# end::minimal-example-import[]
2727

2828

29-
class FreshDatabaseTestCase(TestCase):
29+
class FreshDatabaseTestCase(ServerTestCase):
3030

3131
def setUp(self):
32+
ServerTestCase.setUp(self)
3233
session = GraphDatabase.driver("bolt://localhost").session()
3334
session.run("MATCH (n) DETACH DELETE n")
3435
session.close()

neo4j/v1/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,6 @@
1818
# See the License for the specific language governing permissions and
1919
# limitations under the License.
2020

21+
from .constants import *
2122
from .session import *
2223
from .typesystem import *

neo4j/v1/compat.py

-16
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,3 @@ def perf_counter():
9090
from urllib.parse import urlparse
9191
except ImportError:
9292
from urlparse import urlparse
93-
94-
95-
try:
96-
from ssl import SSLContext, PROTOCOL_SSLv23, OP_NO_SSLv2, HAS_SNI
97-
except ImportError:
98-
from ssl import wrap_socket, PROTOCOL_SSLv23
99-
100-
def secure_socket(s, host):
101-
return wrap_socket(s, ssl_version=PROTOCOL_SSLv23)
102-
103-
else:
104-
105-
def secure_socket(s, host):
106-
ssl_context = SSLContext(PROTOCOL_SSLv23)
107-
ssl_context.options |= OP_NO_SSLv2
108-
return ssl_context.wrap_socket(s, server_hostname=host if HAS_SNI else None)

neo4j/v1/connection.py

+81-18
Original file line numberDiff line numberDiff line change
@@ -21,25 +21,24 @@
2121

2222
from __future__ import division
2323

24+
from base64 import b64encode
2425
from collections import deque
2526
from io import BytesIO
2627
import logging
27-
from os import environ
28+
from os import makedirs, open as os_open, write as os_write, close as os_close, O_CREAT, O_APPEND, O_WRONLY
29+
from os.path import dirname, isfile
2830
from select import select
2931
from socket import create_connection, SHUT_RDWR
32+
from ssl import HAS_SNI, SSLError
3033
from struct import pack as struct_pack, unpack as struct_unpack, unpack_from as struct_unpack_from
3134

32-
from ..meta import version
33-
from .compat import hex2, secure_socket
35+
from .constants import DEFAULT_PORT, DEFAULT_USER_AGENT, KNOWN_HOSTS, MAGIC_PREAMBLE, \
36+
SECURITY_DEFAULT, SECURITY_TRUST_ON_FIRST_USE
37+
from .compat import hex2
3438
from .exceptions import ProtocolError
3539
from .packstream import Packer, Unpacker
3640

3741

38-
DEFAULT_PORT = 7687
39-
DEFAULT_USER_AGENT = "neo4j-python/%s" % version
40-
41-
MAGIC_PREAMBLE = 0x6060B017
42-
4342
# Signature bytes for each message type
4443
INIT = b"\x01" # 0000 0001 // INIT <user_agent>
4544
RESET = b"\x0F" # 0000 1111 // RESET
@@ -211,14 +210,18 @@ def __init__(self, sock, **config):
211210
user_agent = config.get("user_agent", DEFAULT_USER_AGENT)
212211
if isinstance(user_agent, bytes):
213212
user_agent = user_agent.decode("UTF-8")
213+
self.user_agent = user_agent
214+
215+
# Pick up the server certificate, if any
216+
self.der_encoded_server_certificate = config.get("der_encoded_server_certificate")
214217

215218
def on_failure(metadata):
216219
raise ProtocolError("Initialisation failed")
217220

218221
response = Response(self)
219222
response.on_failure = on_failure
220223

221-
self.append(INIT, (user_agent,), response=response)
224+
self.append(INIT, (self.user_agent,), response=response)
222225
self.send()
223226
while not response.complete:
224227
self.fetch()
@@ -313,7 +316,53 @@ def close(self):
313316
self.closed = True
314317

315318

316-
def connect(host, port=None, **config):
319+
class CertificateStore(object):
320+
321+
def match_or_trust(self, host, der_encoded_certificate):
322+
""" Check whether the supplied certificate matches that stored for the
323+
specified host. If it does, return ``True``, if it doesn't, return
324+
``False``. If no entry for that host is found, add it to the store
325+
and return ``True``.
326+
327+
:arg host:
328+
:arg der_encoded_certificate:
329+
:return:
330+
"""
331+
raise NotImplementedError()
332+
333+
334+
class PersonalCertificateStore(CertificateStore):
335+
336+
def __init__(self, path=None):
337+
self.path = path or KNOWN_HOSTS
338+
339+
def match_or_trust(self, host, der_encoded_certificate):
340+
base64_encoded_certificate = b64encode(der_encoded_certificate)
341+
if isfile(self.path):
342+
with open(self.path) as f_in:
343+
for line in f_in:
344+
known_host, _, known_cert = line.strip().partition(":")
345+
known_cert = known_cert.encode("utf-8")
346+
if host == known_host:
347+
return base64_encoded_certificate == known_cert
348+
# First use (no hosts match)
349+
try:
350+
makedirs(dirname(self.path))
351+
except OSError:
352+
pass
353+
f_out = os_open(self.path, O_CREAT | O_APPEND | O_WRONLY, 0o600) # TODO: Windows
354+
if isinstance(host, bytes):
355+
os_write(f_out, host)
356+
else:
357+
os_write(f_out, host.encode("utf-8"))
358+
os_write(f_out, b":")
359+
os_write(f_out, base64_encoded_certificate)
360+
os_write(f_out, b"\n")
361+
os_close(f_out)
362+
return True
363+
364+
365+
def connect(host, port=None, ssl_context=None, **config):
317366
""" Connect and perform a handshake and return a valid Connection object, assuming
318367
a protocol version can be agreed.
319368
"""
@@ -323,14 +372,28 @@ def connect(host, port=None, **config):
323372
if __debug__: log_info("~~ [CONNECT] %s %d", host, port)
324373
s = create_connection((host, port))
325374

326-
# Secure the connection if so requested
327-
try:
328-
secure = environ["NEO4J_SECURE"]
329-
except KeyError:
330-
secure = config.get("secure", False)
331-
if secure:
375+
# Secure the connection if an SSL context has been provided
376+
if ssl_context:
332377
if __debug__: log_info("~~ [SECURE] %s", host)
333-
s = secure_socket(s, host)
378+
try:
379+
s = ssl_context.wrap_socket(s, server_hostname=host if HAS_SNI else None)
380+
except SSLError as cause:
381+
error = ProtocolError("Cannot establish secure connection; %s" % cause.args[1])
382+
error.__cause__ = cause
383+
raise error
384+
else:
385+
# Check that the server provides a certificate
386+
der_encoded_server_certificate = s.getpeercert(binary_form=True)
387+
if der_encoded_server_certificate is None:
388+
raise ProtocolError("When using a secure socket, the server should always provide a certificate")
389+
security = config.get("security", SECURITY_DEFAULT)
390+
if security == SECURITY_TRUST_ON_FIRST_USE:
391+
store = PersonalCertificateStore()
392+
if not store.match_or_trust(host, der_encoded_server_certificate):
393+
raise ProtocolError("Server certificate does not match known certificate for %r; check "
394+
"details in file %r" % (host, KNOWN_HOSTS))
395+
else:
396+
der_encoded_server_certificate = None
334397

335398
# Send details of the protocol versions supported
336399
supported_versions = [1, 0, 0, 0]
@@ -364,4 +427,4 @@ def connect(host, port=None, **config):
364427
s.shutdown(SHUT_RDWR)
365428
s.close()
366429
else:
367-
return Connection(s, **config)
430+
return Connection(s, der_encoded_server_certificate=der_encoded_server_certificate, **config)

neo4j/v1/constants.py

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env python
2+
# -*- encoding: utf-8 -*-
3+
4+
# Copyright (c) 2002-2016 "Neo Technology,"
5+
# Network Engine for Objects in Lund AB [http://neotechnology.com]
6+
#
7+
# This file is part of Neo4j.
8+
#
9+
# Licensed under the Apache License, Version 2.0 (the "License");
10+
# you may not use this file except in compliance with the License.
11+
# You may obtain a copy of the License at
12+
#
13+
# http://www.apache.org/licenses/LICENSE-2.0
14+
#
15+
# Unless required by applicable law or agreed to in writing, software
16+
# distributed under the License is distributed on an "AS IS" BASIS,
17+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
# See the License for the specific language governing permissions and
19+
# limitations under the License.
20+
21+
22+
from os.path import expanduser, join
23+
24+
from ..meta import version
25+
26+
27+
DEFAULT_PORT = 7687
28+
DEFAULT_USER_AGENT = "neo4j-python/%s" % version
29+
30+
KNOWN_HOSTS = join(expanduser("~"), ".neo4j", "known_hosts")
31+
32+
MAGIC_PREAMBLE = 0x6060B017
33+
34+
SECURITY_NONE = 0
35+
SECURITY_TRUST_ON_FIRST_USE = 1
36+
SECURITY_VERIFIED = 2
37+
38+
SECURITY_DEFAULT = SECURITY_TRUST_ON_FIRST_USE

neo4j/v1/session.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ class which can be used to obtain `Driver` instances that are used for
2929
from __future__ import division
3030

3131
from collections import deque, namedtuple
32+
from ssl import SSLContext, PROTOCOL_SSLv23, OP_NO_SSLv2, CERT_REQUIRED, Purpose
3233

3334
from .compat import integer, string, urlparse
3435
from .connection import connect, Response, RUN, PULL_ALL
36+
from .constants import SECURITY_NONE, SECURITY_VERIFIED, SECURITY_DEFAULT
3537
from .exceptions import CypherError, ResultError
3638
from .typesystem import hydrated
3739

@@ -77,6 +79,16 @@ def __init__(self, url, **config):
7779
self.config = config
7880
self.max_pool_size = config.get("max_pool_size", DEFAULT_MAX_POOL_SIZE)
7981
self.session_pool = deque()
82+
self.security = security = config.get("security", SECURITY_DEFAULT)
83+
if security > SECURITY_NONE:
84+
ssl_context = SSLContext(PROTOCOL_SSLv23)
85+
ssl_context.options |= OP_NO_SSLv2
86+
if security >= SECURITY_VERIFIED:
87+
ssl_context.verify_mode = CERT_REQUIRED
88+
ssl_context.load_default_certs(Purpose.SERVER_AUTH)
89+
self.ssl_context = ssl_context
90+
else:
91+
self.ssl_context = None
8092

8193
def session(self):
8294
""" Create a new session based on the graph database details
@@ -425,7 +437,7 @@ class Session(object):
425437

426438
def __init__(self, driver):
427439
self.driver = driver
428-
self.connection = connect(driver.host, driver.port, **driver.config)
440+
self.connection = connect(driver.host, driver.port, driver.ssl_context, **driver.config)
429441
self.transaction = None
430442
self.last_cursor = None
431443

@@ -654,6 +666,7 @@ def __eq__(self, other):
654666
def __ne__(self, other):
655667
return not self.__eq__(other)
656668

669+
657670
def record(obj):
658671
""" Obtain an immutable record for the given object
659672
(either by calling obj.__record__() or by copying out the record data)

test/tck/tck_util.py

+8-9
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@
1818
# See the License for the specific language governing permissions and
1919
# limitations under the License.
2020

21-
from neo4j.v1 import compat, Relationship, Node, Path
21+
from neo4j.v1 import GraphDatabase, Relationship, Node, Path, SECURITY_NONE
22+
from neo4j.v1.compat import string
2223

23-
from neo4j.v1 import GraphDatabase
2424

25-
driver = GraphDatabase.driver("bolt://localhost")
25+
driver = GraphDatabase.driver("bolt://localhost", security=SECURITY_NONE)
2626

2727

2828
def send_string(text):
@@ -39,11 +39,10 @@ def send_parameters(statement, parameters):
3939
return list(cursor.stream())
4040

4141

42-
def to_unicode(val):
43-
try:
44-
return unicode(val)
45-
except NameError:
46-
return str(val)
42+
try:
43+
to_unicode = unicode
44+
except NameError:
45+
to_unicode = str
4746

4847

4948
def string_to_type(str):
@@ -91,7 +90,7 @@ def __init__(self, entity):
9190
elif isinstance(entity, Path):
9291
self.content = self.create_path(entity)
9392
elif isinstance(entity, int) or isinstance(entity, float) or isinstance(entity,
94-
(str, compat.string)) or entity is None:
93+
(str, string)) or entity is None:
9594
self.content['value'] = entity
9695
else:
9796
raise ValueError("Do not support object type: %s" % entity)

0 commit comments

Comments
 (0)