430 lines
18 KiB
Python
430 lines
18 KiB
Python
#!/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 struct
|
|
import typing
|
|
import zlib
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Callable, Dict, Generator, Iterable, List, Optional, Sized
|
|
|
|
import nacl.exceptions
|
|
|
|
from src.common.crypto import auth_and_decrypt, blake2b, rm_padding_bytes
|
|
from src.common.encoding import bytes_to_int, int_to_bytes
|
|
from src.common.exceptions import FunctionReturn
|
|
from src.common.input import yes
|
|
from src.common.misc import decompress, readable_size, separate_header, separate_headers, separate_trailer
|
|
from src.common.output import m_print
|
|
from src.common.statics import *
|
|
|
|
from src.receiver.files import process_assembled_file
|
|
|
|
if typing.TYPE_CHECKING:
|
|
from src.common.db_contacts import Contact, ContactList
|
|
from src.common.db_keys import KeyList
|
|
from src.common.db_settings import Settings
|
|
from src.receiver.windows import RxWindow, WindowList
|
|
|
|
|
|
def process_offset(offset: int, # Number of dropped packets
|
|
origin: bytes, # "to/from" preposition
|
|
direction: str, # Direction of packet
|
|
nick: str, # Nickname of associated contact
|
|
window: 'RxWindow' # RxWindow object
|
|
) -> None:
|
|
"""Display warnings about increased offsets.
|
|
|
|
If the offset has increased over the threshold, ask the user to
|
|
confirm hash ratchet catch up.
|
|
"""
|
|
if offset > HARAC_WARN_THRESHOLD and origin == ORIGIN_CONTACT_HEADER:
|
|
m_print([f"Warning! {offset} packets from {nick} were not received.",
|
|
f"This might indicate that {offset} most recent packets were ",
|
|
f"lost during transmission, or that the contact is attempting ",
|
|
f"a DoS attack. You can wait for TFC to attempt to decrypt the ",
|
|
"packet, but it might take a very long time or even forever."])
|
|
|
|
if not yes("Proceed with the decryption?", abort=False, tail=1):
|
|
raise FunctionReturn(f"Dropped packet from {nick}.", window=window)
|
|
|
|
elif offset:
|
|
m_print(f"Warning! {offset} packet{'s' if offset > 1 else ''} {direction} {nick} were not received.")
|
|
|
|
|
|
def decrypt_assembly_packet(packet: bytes, # Assembly packet ciphertext
|
|
onion_pub_key: bytes, # Onion Service pubkey of associated contact
|
|
origin: bytes, # Direction of packet
|
|
window_list: 'WindowList', # WindowList object
|
|
contact_list: 'ContactList', # ContactList object
|
|
key_list: 'KeyList' # Keylist object
|
|
) -> bytes: # Decrypted assembly packet
|
|
"""Decrypt assembly packet from contact/local Transmitter."""
|
|
ct_harac, ct_assemby_packet = separate_header(packet, header_length=HARAC_CT_LENGTH)
|
|
local_window = window_list.get_local_window()
|
|
command = onion_pub_key == LOCAL_PUBKEY
|
|
|
|
p_type = "command" if command else "packet"
|
|
direction = "from" if command or (origin == ORIGIN_CONTACT_HEADER) else "sent to"
|
|
nick = contact_list.get_contact_by_pub_key(onion_pub_key).nick
|
|
|
|
# Load keys
|
|
keyset = key_list.get_keyset(onion_pub_key)
|
|
key_dir = TX if origin == ORIGIN_USER_HEADER else RX
|
|
|
|
header_key = getattr(keyset, f'{key_dir}_hk') # type: bytes
|
|
message_key = getattr(keyset, f'{key_dir}_mk') # type: bytes
|
|
|
|
if any(k == bytes(SYMMETRIC_KEY_LENGTH) for k in [header_key, message_key]):
|
|
raise FunctionReturn("Warning! Loaded zero-key for packet decryption.")
|
|
|
|
# Decrypt hash ratchet counter
|
|
try:
|
|
harac_bytes = auth_and_decrypt(ct_harac, header_key)
|
|
except nacl.exceptions.CryptoError:
|
|
raise FunctionReturn(
|
|
f"Warning! Received {p_type} {direction} {nick} had an invalid hash ratchet MAC.", window=local_window)
|
|
|
|
# Catch up with hash ratchet offset
|
|
purp_harac = bytes_to_int(harac_bytes)
|
|
stored_harac = getattr(keyset, f'{key_dir}_harac')
|
|
offset = purp_harac - stored_harac
|
|
if offset < 0:
|
|
raise FunctionReturn(
|
|
f"Warning! Received {p_type} {direction} {nick} had an expired hash ratchet counter.", window=local_window)
|
|
|
|
process_offset(offset, origin, direction, nick, local_window)
|
|
for harac in range(stored_harac, stored_harac + offset):
|
|
message_key = blake2b(message_key + int_to_bytes(harac), digest_size=SYMMETRIC_KEY_LENGTH)
|
|
|
|
# Decrypt packet
|
|
try:
|
|
assembly_packet = auth_and_decrypt(ct_assemby_packet, message_key)
|
|
except nacl.exceptions.CryptoError:
|
|
raise FunctionReturn(f"Warning! Received {p_type} {direction} {nick} had an invalid MAC.", window=local_window)
|
|
|
|
# Update message key and harac
|
|
keyset.update_mk(key_dir,
|
|
blake2b(message_key + int_to_bytes(stored_harac + offset), digest_size=SYMMETRIC_KEY_LENGTH),
|
|
offset + 1)
|
|
|
|
return assembly_packet
|
|
|
|
|
|
class Packet(object):
|
|
"""Packet objects collect and keep track of received assembly packets."""
|
|
|
|
def __init__(self,
|
|
onion_pub_key: bytes, # Public key of the contact associated with the packet <─┐
|
|
origin: bytes, # Origin of packet (user, contact) <─┼─ Form packet UID
|
|
p_type: str, # Packet type (message, file, command) <─┘
|
|
contact: 'Contact', # Contact object of contact associated with the packet
|
|
settings: 'Settings' # Settings object
|
|
) -> None:
|
|
"""Create a new Packet object."""
|
|
self.onion_pub_key = onion_pub_key
|
|
self.contact = contact
|
|
self.origin = origin
|
|
self.type = p_type
|
|
self.settings = settings
|
|
|
|
# File transmission metadata
|
|
self.packets = None # type: Optional[int]
|
|
self.time = None # type: Optional[str]
|
|
self.size = None # type: Optional[str]
|
|
self.name = None # type: Optional[str]
|
|
|
|
self.sh = {MESSAGE: M_S_HEADER, FILE: F_S_HEADER, COMMAND: C_S_HEADER}[self.type]
|
|
self.lh = {MESSAGE: M_L_HEADER, FILE: F_L_HEADER, COMMAND: C_L_HEADER}[self.type]
|
|
self.ah = {MESSAGE: M_A_HEADER, FILE: F_A_HEADER, COMMAND: C_A_HEADER}[self.type]
|
|
self.eh = {MESSAGE: M_E_HEADER, FILE: F_E_HEADER, COMMAND: C_E_HEADER}[self.type]
|
|
self.ch = {MESSAGE: M_C_HEADER, FILE: F_C_HEADER, COMMAND: C_C_HEADER}[self.type]
|
|
self.nh = {MESSAGE: P_N_HEADER, FILE: P_N_HEADER, COMMAND: C_N_HEADER}[self.type]
|
|
|
|
self.log_masking_ctr = 0 # type: int
|
|
self.assembly_pt_list = [] # type: List[bytes]
|
|
self.log_ct_list = [] # type: List[bytes]
|
|
self.long_active = False
|
|
self.is_complete = False
|
|
|
|
def add_masking_packet_to_log_file(self, increase: int = 1) -> None:
|
|
"""Increase `log_masking_ctr` for message and file packets."""
|
|
if self.type in [MESSAGE, FILE]:
|
|
self.log_masking_ctr += increase
|
|
|
|
def clear_file_metadata(self) -> None:
|
|
"""Clear file metadata."""
|
|
self.packets = None
|
|
self.time = None
|
|
self.size = None
|
|
self.name = None
|
|
|
|
def clear_assembly_packets(self) -> None:
|
|
"""Clear packet state."""
|
|
self.assembly_pt_list = []
|
|
self.log_ct_list = []
|
|
self.long_active = False
|
|
self.is_complete = False
|
|
|
|
def new_file_packet(self) -> None:
|
|
"""New file transmission handling logic."""
|
|
name = self.name
|
|
was_active = self.long_active
|
|
self.clear_file_metadata()
|
|
self.clear_assembly_packets()
|
|
|
|
if self.origin == ORIGIN_USER_HEADER:
|
|
self.add_masking_packet_to_log_file()
|
|
raise FunctionReturn("Ignored file from the user.", output=False)
|
|
|
|
if not self.contact.file_reception:
|
|
self.add_masking_packet_to_log_file()
|
|
raise FunctionReturn(f"Alert! File transmission from {self.contact.nick} but reception is disabled.")
|
|
|
|
if was_active:
|
|
m_print(f"Alert! File '{name}' from {self.contact.nick} never completed.", head=1, tail=1)
|
|
|
|
def check_long_packet(self) -> None:
|
|
"""Check if the long packet has permission to be extended."""
|
|
if not self.long_active:
|
|
self.add_masking_packet_to_log_file()
|
|
raise FunctionReturn("Missing start packet.", output=False)
|
|
|
|
if self.type == FILE and not self.contact.file_reception:
|
|
self.add_masking_packet_to_log_file(increase=len(self.assembly_pt_list) + 1)
|
|
self.clear_assembly_packets()
|
|
raise FunctionReturn("Alert! File reception disabled mid-transfer.")
|
|
|
|
def process_short_header(self,
|
|
packet: bytes,
|
|
packet_ct: Optional[bytes] = None
|
|
) -> None:
|
|
"""Process short packet."""
|
|
if self.long_active:
|
|
self.add_masking_packet_to_log_file(increase=len(self.assembly_pt_list))
|
|
|
|
if self.type == FILE:
|
|
self.new_file_packet()
|
|
sh, _, packet = separate_headers(packet, [ASSEMBLY_PACKET_HEADER_LENGTH] + [2*ENCODED_INTEGER_LENGTH])
|
|
packet = sh + packet
|
|
|
|
self.assembly_pt_list = [packet]
|
|
self.long_active = False
|
|
self.is_complete = True
|
|
|
|
if packet_ct is not None:
|
|
self.log_ct_list = [packet_ct]
|
|
|
|
def process_long_header(self,
|
|
packet: bytes,
|
|
packet_ct: Optional[bytes] = None
|
|
) -> None:
|
|
"""Process first packet of long transmission."""
|
|
if self.long_active:
|
|
self.add_masking_packet_to_log_file(increase=len(self.assembly_pt_list))
|
|
|
|
if self.type == FILE:
|
|
self.new_file_packet()
|
|
try:
|
|
lh, no_p_bytes, time_bytes, size_bytes, packet \
|
|
= separate_headers(packet, [ASSEMBLY_PACKET_HEADER_LENGTH] + 3*[ENCODED_INTEGER_LENGTH])
|
|
|
|
self.packets = bytes_to_int(no_p_bytes) # added by transmitter.packet.split_to_assembly_packets
|
|
self.time = str(timedelta(seconds=bytes_to_int(time_bytes)))
|
|
self.size = readable_size(bytes_to_int(size_bytes))
|
|
self.name = packet.split(US_BYTE)[0].decode()
|
|
packet = lh + packet
|
|
|
|
m_print([f'Receiving file from {self.contact.nick}:',
|
|
f'{self.name} ({self.size})',
|
|
f'ETA {self.time} ({self.packets} packets)'], bold=True)
|
|
|
|
except (struct.error, UnicodeError, ValueError):
|
|
self.add_masking_packet_to_log_file()
|
|
raise FunctionReturn("Error: Received file packet had an invalid header.")
|
|
|
|
self.assembly_pt_list = [packet]
|
|
self.long_active = True
|
|
self.is_complete = False
|
|
|
|
if packet_ct is not None:
|
|
self.log_ct_list = [packet_ct]
|
|
|
|
def process_append_header(self,
|
|
packet: bytes,
|
|
packet_ct: Optional[bytes] = None
|
|
) -> None:
|
|
"""Process consecutive packet(s) of long transmission."""
|
|
self.check_long_packet()
|
|
self.assembly_pt_list.append(packet)
|
|
|
|
if packet_ct is not None:
|
|
self.log_ct_list.append(packet_ct)
|
|
|
|
def process_end_header(self,
|
|
packet: bytes,
|
|
packet_ct: Optional[bytes] = None
|
|
) -> None:
|
|
"""Process last packet of long transmission."""
|
|
self.check_long_packet()
|
|
self.assembly_pt_list.append(packet)
|
|
self.long_active = False
|
|
self.is_complete = True
|
|
|
|
if packet_ct is not None:
|
|
self.log_ct_list.append(packet_ct)
|
|
|
|
def abort_packet(self, cancel: bool = False) -> None:
|
|
"""Process cancel/noise packet."""
|
|
if self.type == FILE and self.origin == ORIGIN_CONTACT_HEADER and self.long_active:
|
|
if cancel:
|
|
message = f"{self.contact.nick} cancelled file."
|
|
else:
|
|
message = f"Alert! File '{self.name}' from {self.contact.nick} never completed."
|
|
m_print(message, head=1, tail=1)
|
|
self.clear_file_metadata()
|
|
self.add_masking_packet_to_log_file(increase=len(self.assembly_pt_list) + 1)
|
|
self.clear_assembly_packets()
|
|
|
|
def process_cancel_header(self, *_: Any) -> None:
|
|
"""Process cancel packet for long transmission."""
|
|
self.abort_packet(cancel=True)
|
|
|
|
def process_noise_header(self, *_: Any) -> None:
|
|
"""Process traffic masking noise packet."""
|
|
self.abort_packet()
|
|
|
|
def add_packet(self,
|
|
packet: bytes,
|
|
packet_ct: Optional[bytes] = None
|
|
) -> None:
|
|
"""Add a new assembly packet to the object."""
|
|
try:
|
|
func_d = {self.sh: self.process_short_header,
|
|
self.lh: self.process_long_header,
|
|
self.ah: self.process_append_header,
|
|
self.eh: self.process_end_header,
|
|
self.ch: self.process_cancel_header,
|
|
self.nh: self.process_noise_header
|
|
} # type: Dict[bytes, Callable]
|
|
func = func_d[packet[:ASSEMBLY_PACKET_HEADER_LENGTH]]
|
|
except KeyError:
|
|
# Erroneous headers are ignored but stored as placeholder data.
|
|
self.add_masking_packet_to_log_file()
|
|
raise FunctionReturn("Error: Received packet had an invalid assembly packet header.")
|
|
func(packet, packet_ct)
|
|
|
|
def assemble_message_packet(self) -> bytes:
|
|
"""Assemble message packet."""
|
|
padded = b''.join([p[ASSEMBLY_PACKET_HEADER_LENGTH:] for p in self.assembly_pt_list])
|
|
payload = rm_padding_bytes(padded)
|
|
|
|
if len(self.assembly_pt_list) > 1:
|
|
msg_ct, msg_key = separate_trailer(payload, SYMMETRIC_KEY_LENGTH)
|
|
try:
|
|
payload = auth_and_decrypt(msg_ct, msg_key)
|
|
except nacl.exceptions.CryptoError:
|
|
raise FunctionReturn("Error: Decryption of message failed.")
|
|
|
|
try:
|
|
return decompress(payload, MAX_MESSAGE_SIZE)
|
|
except zlib.error:
|
|
raise FunctionReturn("Error: Decompression of message failed.")
|
|
|
|
def assemble_and_store_file(self,
|
|
ts: 'datetime',
|
|
onion_pub_key: bytes,
|
|
window_list: 'WindowList'
|
|
) -> None:
|
|
"""Assemble file packet and store it."""
|
|
padded = b''.join([p[ASSEMBLY_PACKET_HEADER_LENGTH:] for p in self.assembly_pt_list])
|
|
payload = rm_padding_bytes(padded)
|
|
|
|
process_assembled_file(ts, payload, onion_pub_key, self.contact.nick, self.settings, window_list)
|
|
|
|
def assemble_command_packet(self) -> bytes:
|
|
"""Assemble command packet."""
|
|
padded = b''.join([p[ASSEMBLY_PACKET_HEADER_LENGTH:] for p in self.assembly_pt_list])
|
|
payload = rm_padding_bytes(padded)
|
|
|
|
if len(self.assembly_pt_list) > 1:
|
|
payload, cmd_hash = separate_trailer(payload, BLAKE2_DIGEST_LENGTH)
|
|
if blake2b(payload) != cmd_hash:
|
|
raise FunctionReturn("Error: Received an invalid command.")
|
|
|
|
try:
|
|
return decompress(payload, self.settings.max_decompress_size)
|
|
except zlib.error:
|
|
raise FunctionReturn("Error: Decompression of command failed.")
|
|
|
|
|
|
class PacketList(Iterable, Sized):
|
|
"""PacketList manages all file, message, and command packets."""
|
|
|
|
def __init__(self,
|
|
settings: 'Settings',
|
|
contact_list: 'ContactList'
|
|
) -> None:
|
|
"""Create a new PacketList object."""
|
|
self.settings = settings
|
|
self.contact_list = contact_list
|
|
self.packets = [] # type: List[Packet]
|
|
|
|
def __iter__(self) -> Generator:
|
|
"""Iterate over packet list."""
|
|
yield from self.packets
|
|
|
|
def __len__(self) -> int:
|
|
"""Return number of packets in the packet list."""
|
|
return len(self.packets)
|
|
|
|
def has_packet(self,
|
|
onion_pub_key: bytes,
|
|
origin: bytes,
|
|
p_type: str
|
|
) -> bool:
|
|
"""Return True if a packet with matching selectors exists, else False."""
|
|
return any(p for p in self.packets if (p.onion_pub_key == onion_pub_key
|
|
and p.origin == origin
|
|
and p.type == p_type))
|
|
|
|
def get_packet(self,
|
|
onion_pub_key: bytes,
|
|
origin: bytes,
|
|
p_type: str,
|
|
log_access: bool = False
|
|
) -> Packet:
|
|
"""Get packet based on Onion Service public key, origin, and type.
|
|
|
|
If the packet does not exist, create it.
|
|
"""
|
|
if not self.has_packet(onion_pub_key, origin, p_type):
|
|
if log_access:
|
|
contact = self.contact_list.generate_dummy_contact()
|
|
else:
|
|
contact = self.contact_list.get_contact_by_pub_key(onion_pub_key)
|
|
|
|
self.packets.append(Packet(onion_pub_key, origin, p_type, contact, self.settings))
|
|
|
|
return next(p for p in self.packets if (p.onion_pub_key == onion_pub_key
|
|
and p.origin == origin
|
|
and p.type == p_type))
|