Skip to content

Commit f71eb47

Browse files
committed
Security TOFU, PersonalCertificateStore
1 parent cc7d492 commit f71eb47

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
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,22 +19,18 @@
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.session import GraphDatabase, CypherError, Record, record
3128
from neo4j.v1.typesystem import Node, Relationship, Path
3229

30+
from test.util import ServerTestCase
3331

34-
KNOWN_HOSTS_BACKUP = KNOWN_HOSTS + ".backup"
3532

36-
37-
class DriverTestCase(TestCase):
33+
class DriverTestCase(ServerTestCase):
3834

3935
def test_healthy_session_will_be_returned_to_the_pool_on_close(self):
4036
driver = GraphDatabase.driver("bolt://localhost")
@@ -88,20 +84,11 @@ def test_sessions_are_not_reused_if_still_in_use(self):
8884
assert session_1 is not session_2
8985

9086

91-
class SecurityTestCase(TestCase):
92-
93-
def setUp(self):
94-
if isfile(KNOWN_HOSTS):
95-
rename(KNOWN_HOSTS, KNOWN_HOSTS_BACKUP)
87+
class SecurityTestCase(ServerTestCase):
9688

97-
def tearDown(self):
98-
if isfile(KNOWN_HOSTS_BACKUP):
99-
rename(KNOWN_HOSTS_BACKUP, KNOWN_HOSTS)
100-
101-
def test_default_session_uses_security_none(self):
102-
# TODO: verify this is the correct default (maybe TOFU?)
89+
def test_default_session_uses_tofu(self):
10390
driver = GraphDatabase.driver("bolt://localhost")
104-
assert driver.security == SECURITY_NONE
91+
assert driver.security == SECURITY_TRUST_ON_FIRST_USE
10592

10693
def test_insecure_session_uses_normal_socket(self):
10794
driver = GraphDatabase.driver("bolt://localhost", security=SECURITY_NONE)
@@ -140,7 +127,7 @@ def test_tofu_session_trusts_certificate_after_first_use(self):
140127
# session.close()
141128

142129

143-
class RunTestCase(TestCase):
130+
class RunTestCase(ServerTestCase):
144131

145132
def test_can_run_simple_statement(self):
146133
session = GraphDatabase.driver("bolt://localhost").session()
@@ -335,7 +322,7 @@ def test_keys_with_an_error(self):
335322
_ = list(cursor.keys())
336323

337324

338-
class ResetTestCase(TestCase):
325+
class ResetTestCase(ServerTestCase):
339326

340327
def test_automatic_reset_after_failure(self):
341328
with GraphDatabase.driver("bolt://localhost").session() as session:
@@ -359,7 +346,7 @@ def test_defunct(self):
359346
assert session.connection.closed
360347

361348

362-
class RecordTestCase(TestCase):
349+
class RecordTestCase(ServerTestCase):
363350
def test_record_equality(self):
364351
record1 = Record(["name", "empire"], ["Nigel", "The British Empire"])
365352
record2 = Record(["name", "empire"], ["Nigel", "The British Empire"])
@@ -433,7 +420,8 @@ def test_record_repr(self):
433420
assert repr(a_record) == "<Record name='Nigel' empire='The British Empire'>"
434421

435422

436-
class TransactionTestCase(TestCase):
423+
class TransactionTestCase(ServerTestCase):
424+
437425
def test_can_commit_transaction(self):
438426
with GraphDatabase.driver("bolt://localhost").session() as session:
439427
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)