tfc-mirror/src/common/db_settings.py

315 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3.6
# -*- coding: utf-8 -*-
"""
TFC - Onion-routed, endpoint secure messaging system
Copyright (C) 2013-2019 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 os
import textwrap
import typing
from typing import Union
from src.common.crypto import auth_and_decrypt, encrypt_and_sign
from src.common.encoding import bool_to_bytes, double_to_bytes, int_to_bytes
from src.common.encoding import bytes_to_bool, bytes_to_double, bytes_to_int
from src.common.exceptions import CriticalError, FunctionReturn
from src.common.input import yes
from src.common.misc import ensure_dir, get_terminal_width, round_up
from src.common.output import clear_screen, m_print
from src.common.statics import *
if typing.TYPE_CHECKING:
from src.common.db_contacts import ContactList
from src.common.db_groups import GroupList
from src.common.db_masterkey import MasterKey
class Settings(object):
"""\
Settings object stores user adjustable settings (excluding those
related to serial interface) under an encrypted database.
"""
def __init__(self,
master_key: 'MasterKey', # MasterKey object
operation: str, # Operation mode of the program (Tx or Rx)
local_test: bool, # Local testing setting from command-line argument
) -> None:
"""Create a new Settings object.
The settings below are defaults, and are only to be altered from
within the program itself. Changes made to the default settings
are stored in the encrypted settings database, from which they
are loaded when the program starts.
"""
# Common settings
self.disable_gui_dialog = False
self.max_number_of_group_members = 50
self.max_number_of_groups = 50
self.max_number_of_contacts = 50
self.log_messages_by_default = False
self.accept_files_by_default = False
self.show_notifications_by_default = True
self.log_file_masking = False
# Transmitter settings
self.nc_bypass_messages = False
self.confirm_sent_files = True
self.double_space_exits = False
self.traffic_masking = False
self.tm_static_delay = 2.0
self.tm_random_delay = 2.0
# Relay Settings
self.allow_contact_requests = True
# Receiver settings
self.new_message_notify_preview = False
self.new_message_notify_duration = 1.0
self.max_decompress_size = 100_000_000
self.master_key = master_key
self.software_operation = operation
self.local_testing_mode = local_test
self.file_name = f'{DIR_USER_DATA}{operation}_settings'
self.all_keys = list(vars(self).keys())
self.key_list = self.all_keys[:self.all_keys.index('master_key')]
self.defaults = {k: self.__dict__[k] for k in self.key_list}
ensure_dir(DIR_USER_DATA)
if os.path.isfile(self.file_name):
self.load_settings()
else:
self.store_settings()
def store_settings(self) -> None:
"""Store settings to an encrypted database.
The plaintext in the encrypted database is a constant
length bytestring regardless of stored setting values.
"""
attribute_list = [self.__getattribute__(k) for k in self.key_list]
bytes_lst = []
for a in attribute_list:
if isinstance(a, bool):
bytes_lst.append(bool_to_bytes(a))
elif isinstance(a, int):
bytes_lst.append(int_to_bytes(a))
elif isinstance(a, float):
bytes_lst.append(double_to_bytes(a))
else:
raise CriticalError("Invalid attribute type in settings.")
pt_bytes = b''.join(bytes_lst)
ct_bytes = encrypt_and_sign(pt_bytes, self.master_key.master_key)
ensure_dir(DIR_USER_DATA)
with open(self.file_name, 'wb+') as f:
f.write(ct_bytes)
def load_settings(self) -> None:
"""Load settings from the encrypted database."""
with open(self.file_name, 'rb') as f:
ct_bytes = f.read()
pt_bytes = auth_and_decrypt(ct_bytes, self.master_key.master_key, database=self.file_name)
# Update settings based on plaintext byte string content
for key in self.key_list:
attribute = self.__getattribute__(key)
if isinstance(attribute, bool):
value = bytes_to_bool(pt_bytes[0]) # type: Union[bool, int, float]
pt_bytes = pt_bytes[ENCODED_BOOLEAN_LENGTH:]
elif isinstance(attribute, int):
value = bytes_to_int(pt_bytes[:ENCODED_INTEGER_LENGTH])
pt_bytes = pt_bytes[ENCODED_INTEGER_LENGTH:]
elif isinstance(attribute, float):
value = bytes_to_double(pt_bytes[:ENCODED_FLOAT_LENGTH])
pt_bytes = pt_bytes[ENCODED_FLOAT_LENGTH:]
else:
raise CriticalError("Invalid data type in settings default values.")
setattr(self, key, value)
def change_setting(self,
key: str, # Name of the setting
value_str: str, # Value of the setting
contact_list: 'ContactList',
group_list: 'GroupList'
) -> None:
"""Parse, update and store new setting value."""
attribute = self.__getattribute__(key)
try:
if isinstance(attribute, bool):
value = dict(true=True, false=False)[value_str.lower()] # type: Union[bool, int, float]
elif isinstance(attribute, int):
value = int(value_str)
if value < 0 or value > MAX_INT:
raise ValueError
elif isinstance(attribute, float):
value = float(value_str)
if value < 0.0:
raise ValueError
else:
raise CriticalError("Invalid attribute type in settings.")
except (KeyError, ValueError):
raise FunctionReturn(f"Error: Invalid value '{value_str}'.", head_clear=True)
self.validate_key_value_pair(key, value, contact_list, group_list)
setattr(self, key, value)
self.store_settings()
@staticmethod
def validate_key_value_pair(key: str, # Name of the setting
value: Union[int, float, bool], # Value of the setting
contact_list: 'ContactList',
group_list: 'GroupList'
) -> None:
"""Evaluate values for settings that have further restrictions."""
if key in ['max_number_of_group_members', 'max_number_of_groups', 'max_number_of_contacts']:
if value % 10 != 0 or value == 0:
raise FunctionReturn("Error: Database padding settings must be divisible by 10.", head_clear=True)
if key == 'max_number_of_group_members':
min_size = round_up(group_list.largest_group())
if value < min_size:
raise FunctionReturn(
f"Error: Can't set the max number of members lower than {min_size}.", head_clear=True)
if key == 'max_number_of_groups':
min_size = round_up(len(group_list))
if value < min_size:
raise FunctionReturn(
f"Error: Can't set the max number of groups lower than {min_size}.", head_clear=True)
if key == 'max_number_of_contacts':
min_size = round_up(len(contact_list))
if value < min_size:
raise FunctionReturn(
f"Error: Can't set the max number of contacts lower than {min_size}.", head_clear=True)
if key == 'new_message_notify_duration' and value < 0.05:
raise FunctionReturn("Error: Too small value for message notify duration.", head_clear=True)
if key in ['tm_static_delay', 'tm_random_delay']:
for key_, name, min_setting in [('tm_static_delay', 'static', TRAFFIC_MASKING_MIN_STATIC_DELAY),
('tm_random_delay', 'random', TRAFFIC_MASKING_MIN_RANDOM_DELAY)]:
if key == key_ and value < min_setting:
raise FunctionReturn(f"Error: Can't set {name} delay lower than {min_setting}.", head_clear=True)
if contact_list.settings.software_operation == TX:
m_print(["WARNING!", "Changing traffic masking delay can make your endpoint and traffic look unique!"],
bold=True, head=1, tail=1)
if not yes("Proceed anyway?"):
raise FunctionReturn("Aborted traffic masking setting change.", head_clear=True)
m_print("Traffic masking setting will change on restart.", head=1, tail=1)
def print_settings(self) -> None:
"""\
Print list of settings, their current and
default values, and setting descriptions.
"""
desc_d = {
# Common settings
"disable_gui_dialog": "True replaces GUI dialogs with CLI prompts",
"max_number_of_group_members": "Maximum number of members in a group",
"max_number_of_groups": "Maximum number of groups",
"max_number_of_contacts": "Maximum number of contacts",
"log_messages_by_default": "Default logging setting for new contacts/groups",
"accept_files_by_default": "Default file reception setting for new contacts",
"show_notifications_by_default": "Default message notification setting for new contacts/groups",
"log_file_masking": "True hides real size of log file during traffic masking",
# Transmitter settings
"nc_bypass_messages": "False removes Networked Computer bypass interrupt messages",
"confirm_sent_files": "False sends files without asking for confirmation",
"double_space_exits": "True exits, False clears screen with double space command",
"traffic_masking": "True enables traffic masking to hide metadata",
"tm_static_delay": "The static delay between traffic masking packets",
"tm_random_delay": "Max random delay for traffic masking timing obfuscation",
# Relay settings
"allow_contact_requests": "When False, does not show TFC contact requests",
# Receiver settings
"new_message_notify_preview": "When True, shows a preview of the received message",
"new_message_notify_duration": "Number of seconds new message notification appears",
"max_decompress_size": "Max size Receiver accepts when decompressing file"}
# Columns
c1 = ['Setting name']
c2 = ['Current value']
c3 = ['Default value']
c4 = ['Description']
terminal_width = get_terminal_width()
description_indent = 64
if terminal_width < description_indent + 1:
raise FunctionReturn("Error: Screen width is too small.", head_clear=True)
# Populate columns with setting data
for key in self.defaults:
c1.append(key)
c2.append(str(self.__getattribute__(key)))
c3.append(str(self.defaults[key]))
description = desc_d[key]
wrapper = textwrap.TextWrapper(width=max(1, (terminal_width - description_indent)))
desc_lines = wrapper.fill(description).split('\n')
desc_string = desc_lines[0]
for line in desc_lines[1:]:
desc_string += '\n' + description_indent * ' ' + line
if len(desc_lines) > 1:
desc_string += '\n'
c4.append(desc_string)
# Calculate column widths
c1w, c2w, c3w = [max(len(v) for v in column) + SETTINGS_INDENT for column in [c1, c2, c3]]
# Align columns by adding whitespace between fields of each line
lines = [f'{f1:{c1w}} {f2:{c2w}} {f3:{c3w}} {f4}' for f1, f2, f3, f4 in zip(c1, c2, c3, c4)]
# Add a terminal-wide line between the column names and the data
lines.insert(1, get_terminal_width() * '')
# Print the settings
clear_screen()
print('\n' + '\n'.join(lines))