Skip to content

Commit 844dce5

Browse files
committed
Security TOFU, PersonalCertificateStore
1 parent 8a02a90 commit 844dce5

File tree

5 files changed

+89
-58
lines changed

5 files changed

+89
-58
lines changed

neo4j/v1/connection.py

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from struct import pack as struct_pack, unpack as struct_unpack, unpack_from as struct_unpack_from
3434

3535
from .constants import DEFAULT_PORT, DEFAULT_USER_AGENT, KNOWN_HOSTS, MAGIC_PREAMBLE, \
36-
SECURITY_NONE, SECURITY_TRUST_ON_FIRST_USE
36+
SECURITY_DEFAULT, SECURITY_TRUST_ON_FIRST_USE
3737
from .compat import hex2
3838
from .exceptions import ProtocolError
3939
from .packstream import Packer, Unpacker
@@ -316,36 +316,51 @@ def close(self):
316316
self.closed = True
317317

318318

319-
def verify_certificate(host, der_encoded_certificate):
320-
base64_encoded_certificate = b64encode(der_encoded_certificate)
321-
if isfile(KNOWN_HOSTS):
322-
with open(KNOWN_HOSTS) as f_in:
323-
for line in f_in:
324-
known_host, _, known_cert = line.strip().partition(":")
325-
if host == known_host:
326-
if base64_encoded_certificate == known_cert:
327-
# Certificate match
328-
return
329-
else:
330-
# Certificate mismatch
331-
print(base64_encoded_certificate)
332-
print(known_cert)
333-
raise ProtocolError("Server certificate does not match known certificate for %r; check "
334-
"details in file %r" % (host, KNOWN_HOSTS))
335-
# First use (no hosts match)
336-
try:
337-
makedirs(dirname(KNOWN_HOSTS))
338-
except OSError:
339-
pass
340-
f_out = os_open(KNOWN_HOSTS, O_CREAT | O_APPEND | O_WRONLY, 0o600) # TODO: Windows
341-
if isinstance(host, bytes):
342-
os_write(f_out, host)
343-
else:
344-
os_write(f_out, host.encode("utf-8"))
345-
os_write(f_out, b":")
346-
os_write(f_out, base64_encoded_certificate)
347-
os_write(f_out, b"\n")
348-
os_close(f_out)
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+
if host == known_host:
346+
print("Received: %s" % base64_encoded_certificate)
347+
print("Known: %s" % known_cert)
348+
return base64_encoded_certificate == known_cert
349+
# First use (no hosts match)
350+
try:
351+
makedirs(dirname(self.path))
352+
except OSError:
353+
pass
354+
f_out = os_open(self.path, O_CREAT | O_APPEND | O_WRONLY, 0o600) # TODO: Windows
355+
if isinstance(host, bytes):
356+
os_write(f_out, host)
357+
else:
358+
os_write(f_out, host.encode("utf-8"))
359+
os_write(f_out, b":")
360+
os_write(f_out, base64_encoded_certificate)
361+
os_write(f_out, b"\n")
362+
os_close(f_out)
363+
return True
349364

350365

351366
def connect(host, port=None, ssl_context=None, **config):
@@ -372,9 +387,12 @@ def connect(host, port=None, ssl_context=None, **config):
372387
der_encoded_server_certificate = s.getpeercert(binary_form=True)
373388
if der_encoded_server_certificate is None:
374389
raise ProtocolError("When using a secure socket, the server should always provide a certificate")
375-
security = config.get("security", SECURITY_NONE)
390+
security = config.get("security", SECURITY_DEFAULT)
376391
if security == SECURITY_TRUST_ON_FIRST_USE:
377-
verify_certificate(host, der_encoded_server_certificate)
392+
store = PersonalCertificateStore()
393+
if not store.match_or_trust(host, der_encoded_server_certificate):
394+
raise ProtocolError("Server certificate does not match known certificate for %r; check "
395+
"details in file %r" % (host, KNOWN_HOSTS))
378396
else:
379397
der_encoded_server_certificate = None
380398

neo4j/v1/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,5 @@
3434
SECURITY_NONE = 0
3535
SECURITY_TRUST_ON_FIRST_USE = 1
3636
SECURITY_VERIFIED = 2
37+
38+
SECURITY_DEFAULT = SECURITY_TRUST_ON_FIRST_USE

neo4j/v1/session.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class which can be used to obtain `Driver` instances that are used for
3333

3434
from .compat import integer, string, urlparse
3535
from .connection import connect, Response, RUN, PULL_ALL
36-
from .constants import SECURITY_NONE, SECURITY_VERIFIED
36+
from .constants import SECURITY_NONE, SECURITY_VERIFIED, SECURITY_DEFAULT
3737
from .exceptions import CypherError, ResultError
3838
from .typesystem import hydrated
3939

@@ -79,7 +79,7 @@ def __init__(self, url, **config):
7979
self.config = config
8080
self.max_pool_size = config.get("max_pool_size", DEFAULT_MAX_POOL_SIZE)
8181
self.session_pool = deque()
82-
self.security = security = config.get("security", SECURITY_NONE)
82+
self.security = security = config.get("security", SECURITY_DEFAULT)
8383
if security > SECURITY_NONE:
8484
ssl_context = SSLContext(PROTOCOL_SSLv23)
8585
ssl_context.options |= OP_NO_SSLv2

test/test_session.py

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,19 @@
1919
# limitations under the License.
2020

2121

22-
from os import remove, rename
23-
from os.path import isfile
2422
from socket import socket
2523
from ssl import SSLSocket
26-
from unittest import TestCase
2724

2825
from mock import patch
29-
from neo4j.v1.constants import KNOWN_HOSTS, SECURITY_NONE, SECURITY_TRUST_ON_FIRST_USE, SECURITY_VERIFIED
26+
from neo4j.v1.constants import SECURITY_NONE, SECURITY_TRUST_ON_FIRST_USE
3027
from neo4j.v1.exceptions import CypherError, ResultError
3128
from neo4j.v1.session import GraphDatabase, Record, record
3229
from neo4j.v1.typesystem import Node, Relationship, Path
3330

31+
from test.util import ServerTestCase
3432

35-
KNOWN_HOSTS_BACKUP = KNOWN_HOSTS + ".backup"
3633

37-
38-
class DriverTestCase(TestCase):
34+
class DriverTestCase(ServerTestCase):
3935

4036
def test_healthy_session_will_be_returned_to_the_pool_on_close(self):
4137
driver = GraphDatabase.driver("bolt://localhost")
@@ -89,20 +85,11 @@ def test_sessions_are_not_reused_if_still_in_use(self):
8985
assert session_1 is not session_2
9086

9187

92-
class SecurityTestCase(TestCase):
93-
94-
def setUp(self):
95-
if isfile(KNOWN_HOSTS):
96-
rename(KNOWN_HOSTS, KNOWN_HOSTS_BACKUP)
88+
class SecurityTestCase(ServerTestCase):
9789

98-
def tearDown(self):
99-
if isfile(KNOWN_HOSTS_BACKUP):
100-
rename(KNOWN_HOSTS_BACKUP, KNOWN_HOSTS)
101-
102-
def test_default_session_uses_security_none(self):
103-
# TODO: verify this is the correct default (maybe TOFU?)
90+
def test_default_session_uses_tofu(self):
10491
driver = GraphDatabase.driver("bolt://localhost")
105-
assert driver.security == SECURITY_NONE
92+
assert driver.security == SECURITY_TRUST_ON_FIRST_USE
10693

10794
def test_insecure_session_uses_normal_socket(self):
10895
driver = GraphDatabase.driver("bolt://localhost", security=SECURITY_NONE)
@@ -141,7 +128,7 @@ def test_tofu_session_trusts_certificate_after_first_use(self):
141128
# session.close()
142129

143130

144-
class RunTestCase(TestCase):
131+
class RunTestCase(ServerTestCase):
145132

146133
def test_can_run_simple_statement(self):
147134
session = GraphDatabase.driver("bolt://localhost").session()
@@ -363,7 +350,7 @@ def test_can_obtain_notification_info(self):
363350
assert position.column == 1
364351

365352

366-
class ResetTestCase(TestCase):
353+
class ResetTestCase(ServerTestCase):
367354

368355
def test_automatic_reset_after_failure(self):
369356
with GraphDatabase.driver("bolt://localhost").session() as session:
@@ -387,7 +374,7 @@ def test_defunct(self):
387374
assert session.connection.closed
388375

389376

390-
class RecordTestCase(TestCase):
377+
class RecordTestCase(ServerTestCase):
391378
def test_record_equality(self):
392379
record1 = Record(["name", "empire"], ["Nigel", "The British Empire"])
393380
record2 = Record(["name", "empire"], ["Nigel", "The British Empire"])
@@ -461,7 +448,8 @@ def test_record_repr(self):
461448
assert repr(a_record) == "<Record name='Nigel' empire='The British Empire'>"
462449

463450

464-
class TransactionTestCase(TestCase):
451+
class TransactionTestCase(ServerTestCase):
452+
465453
def test_can_commit_transaction(self):
466454
with GraphDatabase.driver("bolt://localhost").session() as session:
467455
tx = session.begin_transaction()

test/util.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,15 @@
2020

2121

2222
import functools
23+
from os import rename
24+
from os.path import isfile
25+
from unittest import TestCase
2326

2427
from neo4j.util import Watcher
28+
from neo4j.v1.constants import KNOWN_HOSTS
29+
30+
31+
KNOWN_HOSTS_BACKUP = KNOWN_HOSTS + ".backup"
2532

2633

2734
def watch(f):
@@ -39,3 +46,19 @@ def wrapper(*args, **kwargs):
3946
f(*args, **kwargs)
4047
watcher.stop()
4148
return wrapper
49+
50+
51+
class ServerTestCase(TestCase):
52+
""" Base class for test cases that use a remote server.
53+
"""
54+
55+
known_hosts = KNOWN_HOSTS
56+
known_hosts_backup = known_hosts + ".backup"
57+
58+
def setUp(self):
59+
if isfile(self.known_hosts):
60+
rename(self.known_hosts, self.known_hosts_backup)
61+
62+
def tearDown(self):
63+
if isfile(self.known_hosts_backup):
64+
rename(self.known_hosts_backup, self.known_hosts)

0 commit comments

Comments
 (0)