tfc-mirror/src/tx/commands.py

538 lines
25 KiB
Python
Executable File

#!/usr/bin/env python3.6
# -*- coding: utf-8 -*-
"""
Copyright (C) 2013-2017 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 <http://www.gnu.org/licenses/>.
"""
import os
import struct
import textwrap
import time
import typing
import zlib
from multiprocessing import Queue
from typing import Any, Dict, List, Tuple, Union
from src.common.crypto import csprng, encrypt_and_sign
from src.common.db_logs import access_logs, re_encrypt, remove_logs
from src.common.encoding import int_to_bytes, str_to_bytes
from src.common.exceptions import FunctionReturn
from src.common.input import yes
from src.common.misc import ensure_dir, get_terminal_width
from src.common.output import box_print, clear_screen, phase, print_key, print_on_previous_line
from src.common.path import ask_path_gui
from src.common.statics import *
from src.tx.commands_g import process_group_command
from src.tx.contact import add_new_contact, change_nick, contact_setting, show_fingerprints, remove_contact
from src.tx.key_exchanges import new_local_key, rxm_load_psk
from src.tx.packet import cancel_packet, queue_command, queue_message, queue_to_nh
from src.tx.user_input import UserInput
from src.tx.windows import select_window
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
from src.common.db_settings import Settings
from src.tx.windows import TxWindow
def process_command(user_input: 'UserInput',
window: 'TxWindow',
settings: 'Settings',
queues: Dict[bytes, 'Queue'],
contact_list: 'ContactList',
group_list: 'GroupList',
master_key: 'MasterKey') -> None:
"""\
Select function based on first keyword of issued
command and pass relevant parameters to it.
"""
c = COMMAND_PACKET_QUEUE
m = MESSAGE_PACKET_QUEUE
n = NH_PACKET_QUEUE
# Keyword Function to run ( Parameters )
# ------------------------------------------------------------------------------------------------------------------------
d = {'about': (print_about, ),
'add': (add_new_contact, contact_list, group_list, settings, queues ),
'clear': (clear_screens, user_input, window, settings, queues ),
'cmd': (rxm_show_sys_win, user_input, window, settings, queues[c] ),
'cm': (cancel_packet, user_input, window, settings, queues ),
'cf': (cancel_packet, user_input, window, settings, queues ),
'exit': (exit_tfc, settings, queues ),
'export': (log_command, user_input, window, contact_list, group_list, settings, queues[c], master_key),
'fingerprints': (show_fingerprints, window ),
'fe': (export_file, settings, queues[n] ),
'fi': (import_file, settings, queues[n] ),
'fw': (rxm_show_sys_win, user_input, window, settings, queues[c] ),
'group': (process_group_command, user_input, contact_list, group_list, settings, queues, master_key),
'help': (print_help, settings ),
'history': (log_command, user_input, window, contact_list, group_list, settings, queues[c], master_key),
'localkey': (new_local_key, contact_list, settings, queues, ),
'logging': (contact_setting, user_input, window, contact_list, group_list, settings, queues[c] ),
'msg': (select_window, user_input, window, settings, queues ),
'names': (print_recipients, contact_list, group_list, ),
'nick': (change_nick, user_input, window, contact_list, group_list, settings, queues[c] ),
'notify': (contact_setting, user_input, window, contact_list, group_list, settings, queues[c] ),
'passwd': (change_master_key, user_input, contact_list, group_list, settings, queues, master_key),
'psk': (rxm_load_psk, window, contact_list, settings, queues[c] ),
'reset': (clear_screens, user_input, window, settings, queues ),
'rm': (remove_contact, user_input, window, contact_list, group_list, settings, queues, master_key),
'rmlogs': (remove_log, user_input, contact_list, settings, queues[c], master_key),
'set': (change_setting, user_input, contact_list, group_list, settings, queues ),
'settings': (settings.print_settings, ),
'store': (contact_setting, user_input, window, contact_list, group_list, settings, queues[c] ),
'unread': (rxm_display_unread, settings, queues[c] ),
'whisper': (whisper, user_input, window, settings, queues[m] ),
'wipe': (wipe, settings, queues )} # type: Dict[str, Any]
try:
cmd_key = user_input.plaintext.split()[0]
from_dict = d[cmd_key]
except KeyError:
raise FunctionReturn(f"Error: Invalid command '{cmd_key}'")
except (IndexError, UnboundLocalError):
raise FunctionReturn(f"Error: Invalid command.")
func = from_dict[0]
parameters = from_dict[1:]
func(*parameters)
def print_about() -> None:
"""Print URLs that direct to TFC's project site and documentation."""
clear_screen()
print(f"\n Tinfoil Chat {VERSION}\n\n"
" Website: https://github.com/maqp/tfc/\n"
" Wikipage: https://github.com/maqp/tfc/wiki\n"
" White paper: https://cs.helsinki.fi/u/oottela/tfc.pdf\n")
def clear_screens(user_input: 'UserInput',
window: 'TxWindow',
settings: 'Settings',
queues: Dict[bytes, 'Queue']) -> None:
"""Clear/reset TxM, RxM and NH screens.
Only send unencrypted command to NH if traffic masking is disabled and
if some related IM account can be bound to active window.
Since reset command removes ephemeral message log on RxM, TxM decides
the window to reset (in case e.g. previous window selection command
packet dropped and active window state is inconsistent between TxM/RxM).
"""
cmd = user_input.plaintext.split()[0]
command = CLEAR_SCREEN_HEADER if cmd == CLEAR else RESET_SCREEN_HEADER + window.uid.encode()
queue_command(command, settings, queues[COMMAND_PACKET_QUEUE])
clear_screen()
if not settings.session_traffic_masking and window.imc_name is not None:
im_window = window.imc_name.encode()
pt_cmd = UNENCRYPTED_SCREEN_CLEAR if cmd == CLEAR else UNENCRYPTED_SCREEN_RESET
packet = UNENCRYPTED_PACKET_HEADER + pt_cmd + im_window
queue_to_nh(packet, settings, queues[NH_PACKET_QUEUE])
if cmd == RESET:
os.system('reset')
def rxm_show_sys_win(user_input: 'UserInput',
window: 'TxWindow',
settings: 'Settings',
c_queue: 'Queue') -> None:
"""Display system window on RxM until user presses Enter."""
cmd = user_input.plaintext.split()[0]
win_name = dict(cmd=LOCAL_ID, fw=WIN_TYPE_FILE)[cmd]
command = WINDOW_SELECT_HEADER + win_name.encode()
queue_command(command, settings, c_queue)
box_print(f"<Enter> returns RxM to {window.name}'s window", manual_proceed=True)
print_on_previous_line(reps=4, flush=True)
command = WINDOW_SELECT_HEADER + window.uid.encode()
queue_command(command, settings, c_queue)
def exit_tfc(settings: 'Settings', queues: Dict[bytes, 'Queue']) -> None:
"""Exit TFC on TxM/RxM/NH."""
for q in [COMMAND_PACKET_QUEUE, NH_PACKET_QUEUE]:
while queues[q].qsize() != 0:
queues[q].get()
queue_command(EXIT_PROGRAM_HEADER, settings, queues[COMMAND_PACKET_QUEUE])
if not settings.session_traffic_masking:
if settings.local_testing_mode:
time.sleep(0.8)
if settings.data_diode_sockets:
time.sleep(2.2)
else:
time.sleep(settings.race_condition_delay)
queue_to_nh(UNENCRYPTED_PACKET_HEADER + UNENCRYPTED_EXIT_COMMAND, settings, queues[NH_PACKET_QUEUE])
def log_command(user_input: 'UserInput',
window: 'TxWindow',
contact_list: 'ContactList',
group_list: 'GroupList',
settings: 'Settings',
c_queue: 'Queue',
master_key: 'MasterKey') -> None:
"""Display message logs or export them to plaintext file on TxM/RxM.
TxM processes sent messages, RxM processes sent and
received messages for all participants in active window.
"""
cmd = user_input.plaintext.split()[0]
export, header = dict(export =(True, LOG_EXPORT_HEADER),
history=(False, LOG_DISPLAY_HEADER))[cmd]
try:
msg_to_load = int(user_input.plaintext.split()[1])
except ValueError:
raise FunctionReturn("Error: Invalid number of messages.")
except IndexError:
msg_to_load = 0
if export and not yes(f"Export logs for '{window.name}' in plaintext?", head=1, tail=1):
raise FunctionReturn("Logfile export aborted.")
try:
command = header + window.uid.encode() + US_BYTE + int_to_bytes(msg_to_load)
except struct.error:
raise FunctionReturn("Error: Invalid number of messages.")
queue_command(command, settings, c_queue)
access_logs(window, contact_list, group_list, settings, master_key, msg_to_load, export)
def export_file(settings: 'Settings', nh_queue: 'Queue') -> None:
"""Encrypt and export file to NH.
This is a faster method to send large files. It is used together
with file import (/fi) command that uploads ciphertext to RxM for
RxM-side decryption. Key is generated automatically so that bad
passwords selected by users do not affect security of ciphertexts.
"""
if settings.session_traffic_masking:
raise FunctionReturn("Error: Command is disabled during traffic masking.")
path = ask_path_gui("Select file to export...", settings, get_file=True)
name = path.split('/')[-1]
data = bytearray()
data.extend(str_to_bytes(name))
if not os.path.isfile(path):
raise FunctionReturn("Error: File not found.")
if os.path.getsize(path) == 0:
raise FunctionReturn("Error: Target file is empty.")
phase("Reading data")
with open(path, 'rb') as f:
data.extend(f.read())
phase(DONE)
phase("Compressing data")
comp = bytes(zlib.compress(bytes(data), level=COMPRESSION_LEVEL))
phase(DONE)
phase("Encrypting data")
file_key = csprng()
file_ct = encrypt_and_sign(comp, key=file_key)
phase(DONE)
phase("Exporting data")
queue_to_nh(EXPORTED_FILE_HEADER + file_ct, settings, nh_queue)
phase(DONE)
print_key(f"Decryption key for file '{name}':", file_key, settings, no_split=True, file_key=True)
def import_file(settings: 'Settings', nh_queue: 'Queue') -> None:
"""\
Send unencrypted command to NH that tells it to open
RxM upload prompt for received (exported) file.
"""
if settings.session_traffic_masking:
raise FunctionReturn("Error: Command is disabled during traffic masking.")
queue_to_nh(UNENCRYPTED_PACKET_HEADER + UNENCRYPTED_IMPORT_COMMAND, settings, nh_queue)
def print_help(settings: 'Settings') -> None:
"""Print the list of commands."""
def help_printer(tuple_list: List[Union[Tuple[str, str, bool]]]) -> None:
"""Print list of commands.
Style depends on terminal width and settings.
"""
len_longest_command = max(len(t[0]) for t in tuple_list) + 1 # Add one for spacing
for help_cmd, description, display in tuple_list:
if not display:
continue
wrapper = textwrap.TextWrapper(width=max(1, terminal_width - len_longest_command))
desc_lines = wrapper.fill(description).split('\n')
desc_indent = (len_longest_command - len(help_cmd)) * ' '
print(help_cmd + desc_indent + desc_lines[0])
# Print wrapped description lines with indent
if len(desc_lines) > 1:
for line in desc_lines[1:]:
print(len_longest_command * ' ' + line)
print('')
notm = not settings.session_traffic_masking
common = [("/about", "Show links to project resources", True),
("/add", "Add new contact", notm),
("/cf", "Cancel file transmission to active contact/group", True),
("/cm", "Cancel message transmission to active contact/group", True),
("/clear, ' '", "Clear screens from TxM, RxM and IM client", True),
("/cmd, '//'", "Display command window on RxM", True),
("/exit", "Exit TFC on TxM, NH and RxM", True),
("/export (n)", "Export (n) messages from recipient's logfile", True),
("/file", "Send file to active contact/group", True),
("/fingerprints", "Print public key fingerprints of user and contact", True),
("/fe", "Encrypt and export file to NH", notm),
("/fi", "Import file from NH to RxM", notm),
("/fw", "Display file reception window on RxM", True),
("/help", "Display this list of commands", True),
("/history (n)", "Print (n) messages from recipient's logfile", True),
("/localkey", "Generate new local key pair", notm),
("/logging {on,off}(' all')", "Change message log setting (for all contacts)", True),
("/msg {A,N}", "Change active recipient to account A or nick N", notm),
("/names", "List contacts and groups", True),
("/nick N", "Change nickname of active recipient to N", True),
("/notify {on,off} (' all')", "Change notification settings (for all contacts)", True),
("/passwd {tx,rx}", "Change master password on TxM/RxM", notm),
("/psk", "Open PSK import dialog on RxM", notm),
("/reset", "Reset ephemeral session log on TxM/RxM/IM client", True),
("/rm {A,N}", "Remove account A or nick N from TxM and RxM", notm),
("/rmlogs {A,N}", "Remove log entries for A/N on TxM and RxM", True),
("/set S V", "Change setting S to value V on TxM/RxM(/NH)", True),
("/settings", "List setting names, values and descriptions", True),
("/store {on,off} (' all')", "Change file reception (for all contacts)", True),
("/unread, ' '", "List windows with unread messages on RxM", True),
("/whisper M", "Send message M, asking it not to be logged", True),
("/wipe", "Wipe all TFC/IM user data and power off systems", True),
("Shift + PgUp/PgDn", "Scroll terminal up/down", True),]
groupc = [("/group create G A₁ .. Aₙ ", "Create group G and add accounts A₁ .. Aₙ", notm),
("/group add G A₁ .. Aₙ", "Add accounts A₁ .. Aₙ to group G", notm),
("/group rm G A₁ .. Aₙ", "Remove accounts A₁ .. Aₙ from group G", notm),
("/group rm G", "Remove group G", notm)]
terminal_width = get_terminal_width()
clear_screen()
print(textwrap.fill("List of commands:", width=terminal_width))
print('')
help_printer(common)
print(terminal_width * '')
if settings.session_traffic_masking:
print('')
else:
print("Group management:\n")
help_printer(groupc)
print(terminal_width * '' + '\n')
def print_recipients(contact_list: 'ContactList', group_list: 'GroupList') -> None:
"""Print list of contacts and groups."""
contact_list.print_contacts()
group_list.print_groups()
def change_master_key(user_input: 'UserInput',
contact_list: 'ContactList',
group_list: 'GroupList',
settings: 'Settings',
queues: Dict[bytes, 'Queue'],
master_key: 'MasterKey') -> None:
"""Change master key on TxM/RxM."""
try:
if settings.session_traffic_masking:
raise FunctionReturn("Error: Command is disabled during traffic masking.")
try:
device = user_input.plaintext.split()[1].lower()
except IndexError:
raise FunctionReturn("Error: No target system specified.")
if device not in [TX, RX]:
raise FunctionReturn("Error: Invalid target system.")
if device == RX:
queue_command(CHANGE_MASTER_K_HEADER, settings, queues[COMMAND_PACKET_QUEUE])
return None
old_master_key = master_key.master_key[:]
master_key.new_master_key()
new_master_key = master_key.master_key
phase("Re-encrypting databases")
queues[KEY_MANAGEMENT_QUEUE].put((KDB_CHANGE_MASTER_KEY_HEADER, master_key))
ensure_dir(DIR_USER_DATA)
file_name = f'{DIR_USER_DATA}{settings.software_operation}_logs'
if os.path.isfile(file_name):
re_encrypt(old_master_key, new_master_key, settings)
settings.store_settings()
contact_list.store_contacts()
group_list.store_groups()
phase(DONE)
box_print("Master key successfully changed.", head=1)
clear_screen(delay=1.5)
except KeyboardInterrupt:
raise FunctionReturn("Password change aborted.", delay=1, head=3, tail_clear=True)
def remove_log(user_input: 'UserInput',
contact_list: 'ContactList',
settings: 'Settings',
c_queue: 'Queue',
master_key: 'MasterKey') -> None:
"""Remove log entries for contact."""
try:
selection = user_input.plaintext.split()[1]
except IndexError:
raise FunctionReturn("Error: No contact/group specified.")
if not yes(f"Remove logs for {selection}?", head=1):
raise FunctionReturn("Logfile removal aborted.")
# Swap specified nick to rx_account
if selection in contact_list.get_list_of_nicks():
selection = contact_list.get_contact(selection).rx_account
command = LOG_REMOVE_HEADER + selection.encode()
queue_command(command, settings, c_queue)
remove_logs(selection, settings, master_key)
def change_setting(user_input: 'UserInput',
contact_list: 'ContactList',
group_list: 'GroupList',
settings: 'Settings',
queues: Dict[bytes, 'Queue']) -> None:
"""Change setting on TxM / RxM."""
try:
setting = user_input.plaintext.split()[1]
except IndexError:
raise FunctionReturn("Error: No setting specified.")
if setting not in settings.key_list:
raise FunctionReturn(f"Error: Invalid setting '{setting}'")
try:
value = user_input.plaintext.split()[2]
except IndexError:
raise FunctionReturn("Error: No value for setting specified.")
pt_cmd = dict(serial_error_correction=UNENCRYPTED_EC_RATIO,
serial_baudrate =UNENCRYPTED_BAUDRATE,
disable_gui_dialog =UNENCRYPTED_GUI_DIALOG)
if setting in pt_cmd:
if settings.session_traffic_masking:
raise FunctionReturn("Error: Can't change this setting during traffic masking.")
settings.change_setting(setting, value, contact_list, group_list)
command = CHANGE_SETTING_HEADER + setting.encode() + US_BYTE + value.encode()
queue_command(command, settings, queues[COMMAND_PACKET_QUEUE])
if setting in pt_cmd:
packet = UNENCRYPTED_PACKET_HEADER + pt_cmd[setting] + value.encode()
queue_to_nh(packet, settings, queues[NH_PACKET_QUEUE])
def rxm_display_unread(settings: 'Settings', c_queue: 'Queue') -> None:
"""Temporarily display list of windows with unread messages on RxM."""
queue_command(SHOW_WINDOW_ACTIVITY_HEADER, settings, c_queue)
def whisper(user_input: 'UserInput', window: 'TxWindow', settings: 'Settings', m_queue: 'Queue') -> None:
"""Send a message to contact that overrides enabled logging setting.
The functionality of this feature is impossible to enforce, but if
the recipient can be trusted, it can be used to send keys for to be
imported files as well as off-the-record messages, without worrying
they are stored into log files, ruining forward secrecy for imported
(and later deleted) files.
"""
message = user_input.plaintext[len('whisper '):]
queue_message(user_input=UserInput(message, MESSAGE),
window =window,
settings =settings,
m_queue =m_queue,
header =WHISPER_MESSAGE_HEADER,
log_as_ph =True)
def wipe(settings: 'Settings', queues: Dict[bytes, 'Queue']) -> None:
"""Reset terminals, wipe all user data from TxM/RxM/NH and power off systems.
No effective RAM overwriting tool currently exists, so as long as TxM/RxM
use FDE and DDR3 memory, recovery of user data becomes impossible very fast:
https://www1.cs.fau.de/filepool/projects/coldboot/fares_coldboot.pdf
"""
if not yes("Wipe all user data and power off systems?"):
raise FunctionReturn("Wipe command aborted.")
clear_screen()
for q in [COMMAND_PACKET_QUEUE, NH_PACKET_QUEUE]:
while queues[q].qsize() != 0:
queues[q].get()
queue_command(WIPE_USER_DATA_HEADER, settings, queues[COMMAND_PACKET_QUEUE])
if not settings.session_traffic_masking:
if settings.local_testing_mode:
time.sleep(0.8)
if settings.data_diode_sockets:
time.sleep(2.2)
else:
time.sleep(settings.race_condition_delay)
queue_to_nh(UNENCRYPTED_PACKET_HEADER + UNENCRYPTED_WIPE_COMMAND, settings, queues[NH_PACKET_QUEUE])
os.system('reset')