tfc-mirror/src/common/misc.py

436 lines
16 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 argparse
import base64
import binascii
import hashlib
import math
import os
import shutil
import subprocess
import sys
import time
import typing
import zlib
from contextlib import contextmanager
from typing import Any, Callable, Dict, Generator, List, Tuple, Union
from multiprocessing import Process, Queue
from src.common.reed_solomon import RSCodec
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_settings import Settings
from src.common.gateway import Gateway
def calculate_race_condition_delay(serial_error_correction: int,
serial_baudrate: int
) -> float:
"""\
Calculate the delay required to prevent Relay Program race condition.
When Transmitter Program outputs a command to exit or wipe data,
Relay program will also receive a copy of the command. If Relay
Program acts on the command too early, Receiver Program will not
receive the exit/wipe command at all.
This program calculates the delay Transmitter Program should wait
before outputting command for Relay Program, to ensure Receiver
Program has received the encrypted command.
"""
rs = RSCodec(2 * serial_error_correction)
message_length = PACKET_LENGTH + ONION_ADDRESS_LENGTH
enc_msg_length = len(rs.encode(os.urandom(message_length)))
enc_cmd_length = len(rs.encode(os.urandom(COMMAND_LENGTH)))
max_bytes = enc_msg_length + (2 * enc_cmd_length)
return (max_bytes * BAUDS_PER_BYTE) / serial_baudrate
def decompress(data: bytes, # Data to be decompressed
max_size: int # The maximum size of decompressed data.
) -> bytes: # Decompressed data
"""Decompress received data.
The decompressed data has a maximum size, designed to prevent zip
bombs from filling the drive of an unsuspecting user.
"""
from src.common.exceptions import FunctionReturn # Avoid circular import
dec = zlib.decompressobj()
data = dec.decompress(data, max_size)
if dec.unconsumed_tail:
raise FunctionReturn("Error: Decompression aborted due to possible zip bomb.")
del dec
return data
def ensure_dir(directory: str) -> None:
"""Ensure directory exists.
This function is run before checking a database exists in the
specified directory, or before storing data into a directory.
It prevents errors in case user has for some reason removed
the directory.
"""
name = os.path.dirname(directory)
if not os.path.exists(name):
with ignored(FileExistsError):
os.makedirs(name)
def get_tab_complete_list(contact_list: 'ContactList',
group_list: 'GroupList',
settings: 'Settings',
gateway: 'Gateway'
) -> List[str]:
"""Return a list of tab-complete words."""
commands = ['about', 'add ', 'clear', 'cmd', 'connect', 'exit', 'export ', 'file', 'group ', 'help', 'history ',
'localkey', 'logging ', 'msg ', 'names', 'nick ', 'notify ', 'passwd ', 'psk', 'reset', 'rmlogs ',
'set ', 'settings', 'store ', 'unread', 'verify', 'whisper ', 'whois ']
tc_list = ['all', 'create ', 'false', 'False', 'join ', 'true', 'True']
tc_list += commands
tc_list += [(a + ' ') for a in contact_list.get_list_of_addresses()]
tc_list += [(n + ' ') for n in contact_list.get_list_of_nicks()]
tc_list += [(g + ' ') for g in group_list.get_list_of_group_names()]
tc_list += [(i + ' ') for i in group_list.get_list_of_hr_group_ids()]
tc_list += [(s + ' ') for s in settings.key_list]
tc_list += [(s + ' ') for s in gateway.settings.key_list]
return tc_list
def get_tab_completer(contact_list: 'ContactList',
group_list: 'GroupList',
settings: 'Settings',
gateway: 'Gateway'
) -> Callable:
"""Return the tab completer object."""
def tab_complete(text: str, state: Any) -> List[str]:
"""Return tab-complete options."""
tab_complete_list = get_tab_complete_list(contact_list, group_list, settings, gateway)
options = [t for t in tab_complete_list if t.startswith(text)] # type: List[str]
with ignored(IndexError):
tc = options[state] # type: List[str]
return tc
return tab_complete
def get_terminal_height() -> int:
"""Return the height of the terminal."""
return shutil.get_terminal_size()[1]
def get_terminal_width() -> int:
"""Return the width of the terminal."""
return shutil.get_terminal_size()[0]
@contextmanager
def ignored(*exceptions: Any) -> Generator:
"""Ignore an exception."""
try:
yield
except exceptions:
pass
def monitor_processes(process_list: List[Process],
software_operation: str,
queues: Dict[bytes, Queue],
error_exit_code: int = 1
) -> None:
"""Monitor the status of `process_list` and EXIT_QUEUE.
This function monitors a list of processes. If one of them dies, it
terminates the rest and closes TFC with exit code 1.
If EXIT or WIPE signal is received to EXIT_QUEUE, the function
terminates running processes and closes the program with exit code 0
or overwrites existing user data and powers the system off.
"""
while True:
with ignored(EOFError, KeyboardInterrupt):
time.sleep(0.1)
if not all([p.is_alive() for p in process_list]):
for p in process_list:
p.terminate()
sys.exit(error_exit_code)
if queues[EXIT_QUEUE].qsize() > 0:
command = queues[EXIT_QUEUE].get()
for p in process_list:
p.terminate()
if command == EXIT:
sys.exit(0)
if command == WIPE:
if TAILS not in subprocess.check_output('lsb_release -a', shell=True):
if software_operation == RX:
subprocess.Popen("find {} -type f -exec shred -n 3 -z -u {{}} \;"
.format(DIR_RECV_FILES), shell=True).wait()
subprocess.Popen("find {} -name '{}*' -type f -exec shred -n 3 -z -u {{}} \;"
.format(DIR_USER_DATA, software_operation), shell=True).wait()
for d in [DIR_USER_DATA, DIR_RECV_FILES]:
with ignored(FileNotFoundError):
shutil.rmtree(d)
os.system(POWEROFF)
def process_arguments() -> Tuple[str, bool, bool]:
"""Load program-specific settings from command line arguments.
The arguments are determined by the desktop entries and in the
Terminator configuration file for local testing. The descriptions
here are provided for the sake of completeness.
"""
parser = argparse.ArgumentParser(f'python3.6 {sys.argv[0]}',
usage='%(prog)s [OPTION]',
epilog='Full documentation at: <https://github.com/maqp/tfc/wiki>')
parser.add_argument('-r',
action='store_true',
default=False,
dest='operation',
help="run Receiver instead of Transmitter Program")
parser.add_argument('-l',
action='store_true',
default=False,
dest='local_test',
help="enable local testing mode")
parser.add_argument('-d',
action='store_true',
default=False,
dest='data_diode_sockets',
help="use data diode simulator sockets during local testing mode")
args = parser.parse_args()
operation = RX if args.operation else TX
return operation, args.local_test, args.data_diode_sockets
def readable_size(size: int) -> str:
"""Convert file size from bytes to a human-readable form."""
f_size = float(size)
for unit in ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']:
if abs(f_size) < 1024.0:
return f'{f_size:3.1f}{unit}B'
f_size /= 1024.0
return f'{f_size:3.1f}YB'
def round_up(value: Union[int, float]) -> int:
"""Round value to next 10."""
return int(math.ceil(value / 10.0)) * 10
def split_byte_string(bytestring: bytes, # Bytestring to split
item_len: int # Length of each substring
) -> List[bytes]: # List of substrings
"""Split a bytestring into a list of specific length substrings."""
return [bytestring[i:i + item_len] for i in range(0, len(bytestring), item_len)]
def split_string(string: str, # String to split
item_len: int # Length of each substring
) -> List[str]: # List of substrings
"""Split a string into a list of specific length substrings."""
return [string[i:i + item_len] for i in range(0, len(string), item_len)]
def separate_header(bytestring: bytes, # Bytestring to slice
header_length: int # Number of header bytes to separate
) -> Tuple[bytes, bytes]: # Header and payload
"""Separate `header_length` first bytes from a bytestring."""
return bytestring[:header_length], bytestring[header_length:]
def separate_headers(bytestring: bytes, # Bytestring to slice
header_length_list: List[int], # List of header lengths
) -> List[bytes]: # Header and payload
"""Separate a list of headers from bytestring.
Length of each header is determined in the `header_length_list`.
"""
fields = []
for header_length in header_length_list:
field, bytestring = separate_header(bytestring, header_length)
fields.append(field)
fields.append(bytestring)
return fields
def separate_trailer(bytestring: bytes, # Bytestring to slice
trailer_length: int # Number of trailer bytes to separate
) -> Tuple[bytes, bytes]: # Payload and trailer
"""Separate `trailer_length` last bytes from a bytestring.
This saves space and makes trailer separation more readable.
"""
return bytestring[:-trailer_length], bytestring[-trailer_length:]
def terminal_width_check(minimum_width: int) -> None:
"""Wait until user re-sizes their terminal to specified width. """
if get_terminal_width() < minimum_width:
print("Please make the terminal wider.")
while get_terminal_width() < minimum_width:
time.sleep(0.1)
time.sleep(0.1)
print(2*CURSOR_UP_ONE_LINE)
def validate_onion_addr(onion_address_contact: str, # String to slice
onion_address_user: str = '' # Number of header chars to separate
) -> str: # Payload and trailer
"""Validate a v3 Onion Service address."""
error_msg = ''
try:
decoded = base64.b32decode(onion_address_contact.upper())
public_key, checksum, version \
= separate_headers(decoded, [ONION_SERVICE_PUBLIC_KEY_LENGTH, ONION_ADDRESS_CHECKSUM_LENGTH])
if checksum != hashlib.sha3_256(ONION_ADDRESS_CHECKSUM_ID
+ public_key
+ version
).digest()[:ONION_ADDRESS_CHECKSUM_LENGTH]:
error_msg = "Checksum error - Check that the entered account is correct."
except (binascii.Error, ValueError):
return "Error: Invalid account format."
if onion_address_contact in (LOCAL_ID, DUMMY_CONTACT, DUMMY_MEMBER) or public_key == LOCAL_PUBKEY:
error_msg = "Error: Can not add reserved account."
if onion_address_user and onion_address_contact == onion_address_user:
error_msg = "Error: Can not add own account."
return error_msg
def validate_group_name(group_name: str, # Name of the group
contact_list: 'ContactList', # ContactList object
group_list: 'GroupList' # GroupList object
) -> str: # Error message if validation failed, else empty string
"""Validate the specified group name."""
error_msg = ''
# Avoids collision with delimiters
if not group_name.isprintable():
error_msg = "Error: Group name must be printable."
# Length is limited by database's Unicode padding
if len(group_name) >= PADDING_LENGTH:
error_msg = f"Error: Group name must be less than {PADDING_LENGTH} chars long."
if group_name == DUMMY_GROUP:
error_msg = "Error: Group name cannot use the name reserved for database padding."
if not validate_onion_addr(group_name):
error_msg = "Error: Group name cannot have the format of an account."
if group_name in contact_list.get_list_of_nicks():
error_msg = "Error: Group name cannot be a nick of contact."
if group_name in group_list.get_list_of_group_names():
error_msg = f"Error: Group with name '{group_name}' already exists."
return error_msg
def validate_key_exchange(key_ex: str, # Key exchange selection to validate
*_: Any # Unused arguments
) -> str: # Error message if validation failed, else empty string
"""Validate the specified key exchange."""
error_msg = ''
if key_ex.upper() not in [ECDHE, ECDHE[:1], PSK, PSK[:1]]:
error_msg = "Invalid key exchange selection."
return error_msg
def validate_nick(nick: str, # Nick to validate
args: Tuple['ContactList', 'GroupList', bytes] # Contact list and group list databases
) -> str: # Error message if validation failed, else ''
"""Validate the specified nickname."""
contact_list, group_list, onion_pub_key = args
error_msg = ''
# Length is limited by database's Unicode padding
if len(nick) >= PADDING_LENGTH:
error_msg = f"Error: Nick must be shorter than {PADDING_LENGTH} chars."
# Avoid delimiter char collision in output packets
if not nick.isprintable():
error_msg = "Error: Nick must be printable."
if nick == '':
error_msg = "Error: Nick cannot be empty."
# Receiver displays sent messages under 'Me'
if nick.lower() == ME.lower():
error_msg = f"Error: '{ME}' is a reserved nick."
# Receiver displays system notifications under reserved notification symbol
if nick == EVENT:
error_msg = f"Error: '{EVENT}' is a reserved nick."
# Ensure that nicks, accounts and group names are UIDs in recipient selection
if validate_onion_addr(nick) == '': # If no error message was received, nick had format of account
error_msg = "Error: Nick cannot have the format of an account."
if nick in (LOCAL_ID, DUMMY_CONTACT, DUMMY_MEMBER):
error_msg = "Error: Nick cannot have the format of an account."
if nick in contact_list.get_list_of_nicks():
error_msg = "Error: Nick already in use."
# Allow existing nick if it matches the account being replaced.
if contact_list.has_pub_key(onion_pub_key):
if nick == contact_list.get_contact_by_pub_key(onion_pub_key).nick:
error_msg = ''
if nick in group_list.get_list_of_group_names():
error_msg = "Error: Nick cannot be a group name."
return error_msg