tfc-mirror/tests/common/test_database.py

442 lines
16 KiB
Python

#!/usr/bin/env python3.7
# -*- coding: utf-8 -*-
"""
TFC - Onion-routed, endpoint secure messaging system
Copyright (C) 2013-2020 Markus Ottela
This file is part of TFC.
TFC is free software: you can redistribute it and/or modify it under the terms
of the GNU General Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later version.
TFC is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR
PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with TFC. If not, see <https://www.gnu.org/licenses/>.
"""
import sqlite3
import os
import unittest
from unittest import mock
from unittest.mock import MagicMock
from src.common.crypto import auth_and_decrypt, blake2b, encrypt_and_sign
from src.common.database import TFCDatabase, MessageLog, TFCUnencryptedDatabase
from src.common.statics import (DB_WRITE_RETRY_LIMIT, DIR_USER_DATA, MASTERKEY_DB_SIZE, LOG_ENTRY_LENGTH,
SYMMETRIC_KEY_LENGTH)
from tests.mock_classes import MasterKey, Settings
from tests.utils import cd_unit_test, cleanup, tamper_file
class TestTFCDatabase(unittest.TestCase):
def setUp(self) -> None:
"""Pre-test actions."""
self.unit_test_dir = cd_unit_test()
self.database_name = 'unittest_db'
self.master_key = MasterKey()
self.database = TFCDatabase(self.database_name, self.master_key)
def tearDown(self) -> None:
"""Post-test actions."""
cleanup(self.unit_test_dir)
@mock.patch('os.fsync', return_value=MagicMock)
def test_write_to_file(self, mock_os_fsync) -> None:
# Setup
data = os.urandom(MASTERKEY_DB_SIZE)
# Test
self.assertIsNone(self.database.write_to_file(self.database_name, data))
with open(self.database_name, 'rb') as f:
stored_data = f.read()
self.assertEqual(data, stored_data)
mock_os_fsync.assert_called()
def test_verify_file(self) -> None:
# Setup
pt_bytes = os.urandom(MASTERKEY_DB_SIZE)
ct_bytes = encrypt_and_sign(pt_bytes, self.master_key.master_key)
with open(self.database_name, 'wb') as f:
f.write(ct_bytes)
# Test valid file content returns True.
self.assertTrue(self.database.verify_file(self.database_name))
# Test invalid file content returns False.
tamper_file(self.database_name, tamper_size=1)
self.assertFalse(self.database.verify_file(self.database_name))
def test_ensure_temp_write_raises_critical_error_after_exceeding_retry_limit(self) -> None:
# Setup
orig_verify_file = self.database.verify_file
self.database.verify_file = MagicMock(side_effect=DB_WRITE_RETRY_LIMIT*[False])
# Test
with self.assertRaises(SystemExit):
self.database.store_database(os.urandom(MASTERKEY_DB_SIZE))
# Teardown
self.database.verify_file = orig_verify_file
def test_ensure_temp_write_succeeds_just_before_limit(self) -> None:
# Setup
orig_verify_file = self.database.verify_file
self.database.verify_file = MagicMock(side_effect=(DB_WRITE_RETRY_LIMIT-1)*[False] + [True])
# Test
self.assertIsNone(self.database.store_database(os.urandom(MASTERKEY_DB_SIZE)))
# Teardown
self.database.verify_file = orig_verify_file
def test_store_database_encrypts_data_with_master_key_and_replaces_temp_file_and_original_file(self) -> None:
# Setup
pt_old = os.urandom(MASTERKEY_DB_SIZE)
ct_old = encrypt_and_sign(pt_old, self.master_key.master_key)
with open(self.database_name, 'wb') as f:
f.write(ct_old)
pt_new = os.urandom(MASTERKEY_DB_SIZE)
ct_temp = os.urandom(MASTERKEY_DB_SIZE)
with open(self.database.database_temp, 'wb') as f:
f.write(ct_temp)
# Test
self.assertTrue(os.path.isfile(self.database.database_temp))
self.assertIsNone(self.database.store_database(pt_new))
self.assertFalse(os.path.isfile(self.database.database_temp))
with open(self.database_name, 'rb') as f:
purp_data = f.read()
purp_pt = auth_and_decrypt(purp_data, self.master_key.master_key)
self.assertEqual(purp_pt, pt_new)
def test_replace_database(self) -> None:
# Setup
self.assertFalse(os.path.isfile(self.database.database_name))
self.assertFalse(os.path.isfile(self.database.database_temp))
with open(self.database.database_temp, 'wb') as f:
f.write(b'temp_file')
self.assertFalse(os.path.isfile(self.database.database_name))
self.assertTrue(os.path.isfile(self.database.database_temp))
# Test
self.assertIsNone(self.database.replace_database())
self.assertFalse(os.path.isfile(self.database.database_temp))
self.assertTrue(os.path.isfile(self.database.database_name))
def test_load_database_ignores_invalid_temp_database(self) -> None:
# Setup
pt_old = os.urandom(MASTERKEY_DB_SIZE)
ct_old = encrypt_and_sign(pt_old, self.master_key.master_key)
with open(self.database_name, 'wb') as f:
f.write(ct_old)
ct_temp = os.urandom(MASTERKEY_DB_SIZE)
with open(self.database.database_temp, 'wb') as f:
f.write(ct_temp)
# Test
self.assertTrue(os.path.isfile(self.database.database_temp))
self.assertEqual(self.database.load_database(), pt_old)
self.assertFalse(os.path.isfile(self.database.database_temp))
def test_load_database_prefers_valid_temp_database(self) -> None:
# Setup
pt_old = os.urandom(MASTERKEY_DB_SIZE)
ct_old = encrypt_and_sign(pt_old, self.master_key.master_key)
with open(self.database_name, 'wb') as f:
f.write(ct_old)
pt_temp = os.urandom(MASTERKEY_DB_SIZE)
ct_temp = encrypt_and_sign(pt_temp, self.master_key.master_key)
with open(self.database.database_temp, 'wb') as f:
f.write(ct_temp)
# Test
self.assertTrue(os.path.isfile(self.database.database_temp))
self.assertEqual(self.database.load_database(), pt_temp)
self.assertFalse(os.path.isfile(self.database.database_temp))
class TestTFCUnencryptedDatabase(unittest.TestCase):
def setUp(self) -> None:
"""Pre-test actions."""
self.unit_test_dir = cd_unit_test()
self.database_name = 'unittest_db'
self.database = TFCUnencryptedDatabase(self.database_name)
def tearDown(self) -> None:
"""Post-test actions."""
cleanup(self.unit_test_dir)
@mock.patch('os.fsync', return_value=MagicMock)
def test_write_to_file(self, mock_os_fsync) -> None:
# Setup
data = os.urandom(MASTERKEY_DB_SIZE)
# Test
self.assertIsNone(self.database.write_to_file(self.database_name, data))
with open(self.database_name, 'rb') as f:
stored_data = f.read()
self.assertEqual(data, stored_data)
mock_os_fsync.assert_called()
def test_verify_file(self) -> None:
# Setup
data = os.urandom(MASTERKEY_DB_SIZE)
checksummed_data = data + blake2b(data)
with open(self.database_name, 'wb') as f:
f.write(checksummed_data)
# Test valid file content returns True.
self.assertTrue(self.database.verify_file(self.database_name))
# Test invalid file content returns False.
tamper_file(self.database_name, tamper_size=1)
self.assertFalse(self.database.verify_file(self.database_name))
def test_ensure_temp_write_raises_critical_error_after_exceeding_retry_limit(self) -> None:
# Setup
orig_verify_file = self.database.verify_file
self.database.verify_file = MagicMock(side_effect=DB_WRITE_RETRY_LIMIT*[False])
# Test
with self.assertRaises(SystemExit):
self.database.store_unencrypted_database(os.urandom(MASTERKEY_DB_SIZE))
# Teardown
self.database.verify_file = orig_verify_file
def test_ensure_temp_write_succeeds_just_before_limit(self) -> None:
# Setup
orig_verify_file = self.database.verify_file
self.database.verify_file = MagicMock(side_effect=(DB_WRITE_RETRY_LIMIT-1)*[False] + [True])
# Test
self.assertIsNone(self.database.store_unencrypted_database(os.urandom(MASTERKEY_DB_SIZE)))
# Teardown
self.database.verify_file = orig_verify_file
def test_store_unencrypted_database_replaces_temp_file_and_original_file(self) -> None:
# Setup
data_old = os.urandom(MASTERKEY_DB_SIZE)
with open(self.database_name, 'wb') as f:
f.write(data_old)
data_new = os.urandom(MASTERKEY_DB_SIZE)
data_temp = os.urandom(MASTERKEY_DB_SIZE)
with open(self.database.database_temp, 'wb') as f:
f.write(data_temp)
# Test
self.assertTrue(os.path.isfile(self.database.database_temp))
self.assertIsNone(self.database.store_unencrypted_database(data_new))
self.assertFalse(os.path.isfile(self.database.database_temp))
with open(self.database_name, 'rb') as f:
purp_data = f.read()
self.assertEqual(purp_data, data_new + blake2b(data_new))
def test_replace_database(self) -> None:
# Setup
self.assertFalse(os.path.isfile(self.database.database_name))
self.assertFalse(os.path.isfile(self.database.database_temp))
with open(self.database.database_temp, 'wb') as f:
f.write(b'temp_file')
self.assertFalse(os.path.isfile(self.database.database_name))
self.assertTrue(os.path.isfile(self.database.database_temp))
# Test
self.assertIsNone(self.database.replace_database())
self.assertFalse(os.path.isfile(self.database.database_temp))
self.assertTrue(os.path.isfile(self.database.database_name))
def test_loading_invalid_database_data_raises_critical_error(self) -> None:
data_old = os.urandom(MASTERKEY_DB_SIZE)
checksummed = data_old + blake2b(data_old)
with open(self.database_name, 'wb') as f:
f.write(checksummed)
tamper_file(self.database_name, tamper_size=1)
with self.assertRaises(SystemExit):
self.database.load_database()
def test_load_database_ignores_invalid_temp_database(self) -> None:
# Setup
data_old = os.urandom(MASTERKEY_DB_SIZE)
checksummed = data_old + blake2b(data_old)
with open(self.database_name, 'wb') as f:
f.write(checksummed)
data_temp = os.urandom(MASTERKEY_DB_SIZE)
with open(self.database.database_temp, 'wb') as f:
f.write(data_temp)
# Test
self.assertTrue(os.path.isfile(self.database.database_temp))
self.assertEqual(self.database.load_database(), data_old)
self.assertFalse(os.path.isfile(self.database.database_temp))
def test_load_database_prioritizes_valid_temp_database(self) -> None:
# Setup
data_old = os.urandom(MASTERKEY_DB_SIZE)
checksummed_old = data_old + blake2b(data_old)
with open(self.database_name, 'wb') as f:
f.write(checksummed_old)
data_temp = os.urandom(MASTERKEY_DB_SIZE)
checksummed_temp = data_temp + blake2b(data_temp)
with open(self.database.database_temp, 'wb') as f:
f.write(checksummed_temp)
# Test
self.assertTrue(os.path.isfile(self.database.database_temp))
data_purp = self.database.load_database()
self.assertEqual(data_purp, data_temp)
self.assertFalse(os.path.isfile(self.database.database_temp))
class TestMessageLog(unittest.TestCase):
def setUp(self) -> None:
"""Pre-test actions."""
self.unit_test_dir = cd_unit_test()
self.file_name = f'{DIR_USER_DATA}ut_logs'
self.temp_name = self.file_name + '_temp'
self.settings = Settings()
self.database_key = os.urandom(SYMMETRIC_KEY_LENGTH)
self.message_log = MessageLog(self.file_name, self.database_key)
def tearDown(self) -> None:
"""Post-test actions."""
cleanup(self.unit_test_dir)
def test_empty_log_database_is_verified(self) -> None:
self.assertTrue(self.message_log.verify_file(self.file_name))
def test_database_with_one_entry_is_verified(self) -> None:
# Setup
test_entry = b'test_log_entry'
self.message_log.insert_log_entry(test_entry)
# Test
self.assertTrue(self.message_log.verify_file(self.file_name))
def test_invalid_database_returns_false(self) -> None:
# Setup
self.message_log.c.execute("DROP TABLE log_entries")
self.message_log.conn.commit()
# Test
self.assertFalse(self.message_log.verify_file(self.file_name))
def test_invalid_entry_returns_false(self) -> None:
# Setup
params = (os.urandom(LOG_ENTRY_LENGTH),)
self.message_log.c.execute(f"""INSERT INTO log_entries (log_entry) VALUES (?)""", params)
self.message_log.conn.commit()
# Test
self.assertFalse(self.message_log.verify_file(self.file_name))
def test_table_creation(self) -> None:
self.assertIsInstance(self.message_log, MessageLog)
self.assertTrue(os.path.isfile(self.file_name))
def test_writing_to_log_database(self) -> None:
data = os.urandom(LOG_ENTRY_LENGTH)
self.assertIsNone(self.message_log.insert_log_entry(data))
def test_iterating_over_log_database(self) -> None:
data = [os.urandom(LOG_ENTRY_LENGTH), os.urandom(LOG_ENTRY_LENGTH)]
for entry in data:
self.assertIsNone(self.message_log.insert_log_entry(entry))
for index, stored_entry in enumerate(self.message_log):
self.assertEqual(stored_entry, data[index])
def test_invalid_temp_database_is_not_loaded(self) -> None:
log_file = MessageLog(self.file_name, database_key=self.database_key)
tmp_file = MessageLog(self.temp_name, database_key=self.database_key)
log_file.insert_log_entry(b'a')
log_file.insert_log_entry(b'b')
log_file.insert_log_entry(b'c')
log_file.insert_log_entry(b'd')
log_file.insert_log_entry(b'e')
tmp_file.insert_log_entry(b'a')
tmp_file.insert_log_entry(b'b')
tmp_file.c.execute(f"""INSERT INTO log_entries (log_entry) VALUES (?)""", (b'c',))
tmp_file.conn.commit()
tmp_file.insert_log_entry(b'd')
tmp_file.insert_log_entry(b'e')
self.assertTrue(os.path.isfile(self.temp_name))
log_file = MessageLog(self.file_name, database_key=self.database_key)
self.assertEqual(list(log_file), [b'a', b'b', b'c', b'd', b'e'])
self.assertFalse(os.path.isfile(self.temp_name))
def test_valid_temp_database_is_loaded(self) -> None:
log_file = MessageLog(self.file_name, database_key=self.database_key)
tmp_file = MessageLog(self.temp_name, database_key=self.database_key)
log_file.insert_log_entry(b'a')
log_file.insert_log_entry(b'b')
log_file.insert_log_entry(b'c')
log_file.insert_log_entry(b'd')
log_file.insert_log_entry(b'e')
tmp_file.insert_log_entry(b'f')
tmp_file.insert_log_entry(b'g')
tmp_file.insert_log_entry(b'h')
tmp_file.insert_log_entry(b'i')
tmp_file.insert_log_entry(b'j')
self.assertTrue(os.path.isfile(self.temp_name))
log_file = MessageLog(self.file_name, database_key=self.database_key)
self.assertEqual(list(log_file), [b'f', b'g', b'h', b'i', b'j'])
self.assertFalse(os.path.isfile(self.temp_name))
def test_database_closing(self) -> None:
self.message_log.close_database()
# Test insertion would fail at this point
with self.assertRaises(sqlite3.ProgrammingError):
self.message_log.c.execute(f"""INSERT INTO log_entries (log_entry) VALUES (?)""",
(os.urandom(LOG_ENTRY_LENGTH),))
# Test closed database is re-opened during write
data = os.urandom(LOG_ENTRY_LENGTH)
self.assertIsNone(self.message_log.insert_log_entry(data))
if __name__ == '__main__':
unittest.main(exit=False)