748 lines
31 KiB
Python
748 lines
31 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 base64
|
|
import hashlib
|
|
import json
|
|
import multiprocessing.connection
|
|
import os
|
|
import os.path
|
|
import serial
|
|
import socket
|
|
import textwrap
|
|
import time
|
|
import typing
|
|
|
|
from datetime import datetime
|
|
from typing import Any, Dict, Optional, Tuple, Union
|
|
|
|
from serial.serialutil import SerialException
|
|
|
|
from src.common.exceptions import CriticalError, graceful_exit, SoftError
|
|
from src.common.input import box_input, yes
|
|
from src.common.misc import (calculate_race_condition_delay, ensure_dir, ignored, get_terminal_width,
|
|
separate_trailer, split_byte_string, validate_ip_address)
|
|
from src.common.output import m_print, phase, print_on_previous_line
|
|
from src.common.reed_solomon import ReedSolomonError, RSCodec
|
|
from src.common.statics import (BAUDS_PER_BYTE, DIR_USER_DATA, DONE, DST_DD_LISTEN_SOCKET, DST_LISTEN_SOCKET,
|
|
GATEWAY_QUEUE, LOCALHOST, LOCAL_TESTING_PACKET_DELAY, MAX_INT, NC,
|
|
QUBES_DST_LISTEN_SOCKET, QUBES_RX_IP_ADDR_FILE, QUBES_SRC_LISTEN_SOCKET,
|
|
PACKET_CHECKSUM_LENGTH, RECEIVER, RELAY, RP_LISTEN_SOCKET, RX,
|
|
SERIAL_RX_MIN_TIMEOUT, SETTINGS_INDENT, SOCKET_BUFFER_SIZE, SRC_DD_LISTEN_SOCKET,
|
|
TRANSMITTER, TX, US_BYTE)
|
|
|
|
if typing.TYPE_CHECKING:
|
|
from multiprocessing import Queue
|
|
JSONDict = Dict[str, Union[int, bool, str]]
|
|
|
|
|
|
def gateway_loop(queues: Dict[bytes, 'Queue[Tuple[datetime, bytes]]'],
|
|
gateway: 'Gateway',
|
|
unit_test: bool = False
|
|
) -> None:
|
|
"""Load data from serial interface or socket into a queue.
|
|
|
|
Also place the current timestamp to queue to be delivered to the
|
|
Receiver Program. The timestamp is used both to notify when the sent
|
|
message was received by the Relay Program, and as part of a
|
|
commitment scheme: For more information, see the section on "Covert
|
|
channel based on user interaction" under TFC's Security Design wiki
|
|
article.
|
|
"""
|
|
queue = queues[GATEWAY_QUEUE]
|
|
|
|
while True:
|
|
with ignored(EOFError, KeyboardInterrupt):
|
|
queue.put((datetime.now(), gateway.read()))
|
|
if unit_test:
|
|
break
|
|
|
|
|
|
class Gateway(object):
|
|
"""\
|
|
Gateway object is a wrapper for interfaces that connect
|
|
Source/Destination Computer with the Networked Computer.
|
|
"""
|
|
|
|
def __init__(self,
|
|
operation: str,
|
|
local_test: bool,
|
|
dd_sockets: bool,
|
|
qubes: bool,
|
|
) -> None:
|
|
"""Create a new Gateway object."""
|
|
self.settings = GatewaySettings(operation, local_test, dd_sockets, qubes)
|
|
self.tx_serial = None # type: Optional[serial.Serial]
|
|
self.rx_serial = None # type: Optional[serial.Serial]
|
|
self.rx_socket = None # type: Optional[multiprocessing.connection.Connection]
|
|
self.tx_socket = None # type: Optional[multiprocessing.connection.Connection]
|
|
self.txq_socket = None # type: Optional[socket.socket]
|
|
self.rxq_socket = None # type: Optional[socket.socket]
|
|
|
|
# Initialize Reed-Solomon erasure code handler
|
|
self.rs = RSCodec(2 * self.settings.session_serial_error_correction)
|
|
|
|
# Set True when the serial interface is initially found so that
|
|
# further interface searches know to announce disconnection.
|
|
self.init_found = False
|
|
|
|
if self.settings.local_testing_mode:
|
|
if self.settings.software_operation in [TX, NC]:
|
|
self.client_establish_socket()
|
|
if self.settings.software_operation in [NC, RX]:
|
|
self.server_establish_socket()
|
|
elif qubes:
|
|
if self.settings.software_operation in [TX, NC]:
|
|
self.qubes_client_establish_socket()
|
|
if self.settings.software_operation in [NC, RX]:
|
|
self.qubes_server_establish_socket()
|
|
else:
|
|
self.establish_serial()
|
|
|
|
def establish_serial(self) -> None:
|
|
"""Create a new Serial object.
|
|
|
|
By setting the Serial object's timeout to 0, the method
|
|
`Serial().read_all()` will return 0..N bytes where N is the serial
|
|
interface buffer size (496 bytes for FTDI FT232R for example).
|
|
This is not enough for large packets. However, in this case,
|
|
`read_all` will return
|
|
a) immediately when the buffer is full
|
|
b) if no bytes are received during the time it would take
|
|
to transmit the next byte of the datagram.
|
|
|
|
This type of behaviour allows us to read 0..N bytes from the
|
|
serial interface at a time, and add them to a bytearray buffer.
|
|
|
|
In our implementation below, if the receiver side stops
|
|
receiving data when it calls `read_all`, it starts a timer that
|
|
is evaluated with every subsequent call of `read_all` that
|
|
returns an empty string. If the timer exceeds the
|
|
`settings.rx_receive_timeout` value (twice the time it takes to
|
|
send the next byte with given baud rate), the gateway object
|
|
will return the received packet.
|
|
|
|
The timeout timer is triggered intentionally by the transmitter
|
|
side Gateway object, that after each transmission sleeps for
|
|
`settings.tx_inter_packet_delay` seconds. This value is set to
|
|
twice the length of `settings.rx_receive_timeout`, or four times
|
|
the time it takes to send one byte with given baud rate.
|
|
"""
|
|
try:
|
|
self.tx_serial = self.rx_serial = serial.Serial(self.search_serial_interface(),
|
|
self.settings.session_serial_baudrate,
|
|
timeout=0)
|
|
except SerialException:
|
|
raise CriticalError("SerialException. Ensure $USER is in the dialout group by restarting this computer.")
|
|
|
|
def write_udp_packet(self, packet: bytes) -> None:
|
|
"""Split packet to smaller parts and transmit them over the socket."""
|
|
udp_port = QUBES_SRC_LISTEN_SOCKET if self.settings.software_operation == TX else QUBES_DST_LISTEN_SOCKET
|
|
|
|
packet = base64.b85encode(packet)
|
|
packets = split_byte_string(packet, SOCKET_BUFFER_SIZE)
|
|
|
|
if self.txq_socket is not None:
|
|
for p in packets:
|
|
self.txq_socket.sendto(p, (self.settings.rx_udp_ip, udp_port))
|
|
time.sleep(0.000001)
|
|
self.txq_socket.sendto(US_BYTE, (self.settings.rx_udp_ip, udp_port))
|
|
|
|
def write(self, orig_packet: bytes) -> None:
|
|
"""Add error correction data and output data via socket/serial interface.
|
|
|
|
After outputting the packet via serial, sleep long enough to
|
|
trigger the Rx-side timeout timer, or if local testing is
|
|
enabled, add slight delay to simulate that introduced by the
|
|
serial interface.
|
|
"""
|
|
packet = self.add_error_correction(orig_packet)
|
|
|
|
if self.settings.local_testing_mode and self.tx_socket is not None:
|
|
try:
|
|
self.tx_socket.send(packet)
|
|
time.sleep(LOCAL_TESTING_PACKET_DELAY)
|
|
except BrokenPipeError:
|
|
raise CriticalError("Relay IPC server disconnected.", exit_code=0)
|
|
|
|
elif self.txq_socket is not None:
|
|
self.write_udp_packet(packet)
|
|
|
|
elif self.tx_serial is not None:
|
|
try:
|
|
self.tx_serial.write(packet)
|
|
self.tx_serial.flush()
|
|
time.sleep(self.settings.tx_inter_packet_delay)
|
|
except SerialException:
|
|
self.establish_serial()
|
|
self.write(orig_packet)
|
|
|
|
def read_socket(self) -> bytes:
|
|
"""Read packet from socket interface."""
|
|
if self.rx_socket is None:
|
|
raise CriticalError("Socket interface has not been initialized.")
|
|
|
|
while True:
|
|
try:
|
|
packet = self.rx_socket.recv() # type: bytes
|
|
return packet
|
|
except KeyboardInterrupt:
|
|
pass
|
|
except EOFError:
|
|
raise CriticalError("Relay IPC client disconnected.", exit_code=0)
|
|
|
|
def read_qubes_socket(self) -> bytes:
|
|
"""Read packet from Qubes' socket interface."""
|
|
if self.rxq_socket is None:
|
|
raise CriticalError("Socket interface has not been initialized.")
|
|
|
|
while True:
|
|
try:
|
|
read_buffer = bytearray()
|
|
|
|
while True:
|
|
read = self.rxq_socket.recv(SOCKET_BUFFER_SIZE)
|
|
if read == US_BYTE:
|
|
return read_buffer
|
|
read_buffer.extend(read)
|
|
|
|
except (EOFError, KeyboardInterrupt):
|
|
pass
|
|
|
|
def read_serial(self) -> bytes:
|
|
"""Read packet from serial interface.
|
|
|
|
Read 0..N bytes from serial interface, where N is the buffer
|
|
size of the serial interface. Once `read_buffer` has data, and
|
|
the interface hasn't returned data long enough for the timer to
|
|
exceed the timeout value, return received data.
|
|
"""
|
|
if self.rx_serial is None:
|
|
raise CriticalError("Serial interface has not been initialized.")
|
|
|
|
while True:
|
|
try:
|
|
start_time = 0.0
|
|
read_buffer = bytearray()
|
|
while True:
|
|
read = self.rx_serial.read_all()
|
|
if read:
|
|
start_time = time.monotonic()
|
|
read_buffer.extend(read)
|
|
else:
|
|
if read_buffer:
|
|
delta = time.monotonic() - start_time
|
|
if delta > self.settings.rx_receive_timeout:
|
|
return bytes(read_buffer)
|
|
else:
|
|
time.sleep(0.0001)
|
|
|
|
except (EOFError, KeyboardInterrupt):
|
|
pass
|
|
except (OSError, SerialException):
|
|
self.establish_serial()
|
|
|
|
def read(self) -> bytes:
|
|
"""Read data via socket/serial interface."""
|
|
if self.settings.local_testing_mode:
|
|
return self.read_socket()
|
|
if self.settings.qubes:
|
|
return self.read_qubes_socket()
|
|
return self.read_serial()
|
|
|
|
def add_error_correction(self, packet: bytes) -> bytes:
|
|
"""Add error correction to packet that will be output.
|
|
|
|
If the error correction setting is set to 1 or higher, TFC adds
|
|
Reed-Solomon erasure codes to detect and correct errors during
|
|
transmission over the serial interface. For more information on
|
|
Reed-Solomon, see
|
|
https://en.wikipedia.org/wiki/Reed%E2%80%93Solomon_error_correction
|
|
https://www.cs.cmu.edu/~guyb/realworld/reedsolomon/reed_solomon_codes.html
|
|
|
|
If error correction is set to 0, errors are only detected. This
|
|
is done by using a BLAKE2b based, 128-bit checksum.
|
|
|
|
If Qubes is used, Reed-Solomon is not used as it only slows down data transfer.
|
|
"""
|
|
if self.settings.session_serial_error_correction and not self.settings.qubes:
|
|
packet = self.rs.encode(packet)
|
|
else:
|
|
packet = packet + hashlib.blake2b(packet, digest_size=PACKET_CHECKSUM_LENGTH).digest()
|
|
return packet
|
|
|
|
def detect_errors(self, packet: bytes) -> bytes:
|
|
"""Handle received packet error detection and/or correction."""
|
|
if self.settings.qubes:
|
|
try:
|
|
packet = base64.b85decode(packet)
|
|
except ValueError:
|
|
raise SoftError("Error: Received packet had invalid Base85 encoding.")
|
|
|
|
if self.settings.session_serial_error_correction and not self.settings.qubes:
|
|
try:
|
|
packet, _ = self.rs.decode(packet)
|
|
return bytes(packet)
|
|
except ReedSolomonError:
|
|
raise SoftError("Error: Reed-Solomon failed to correct errors in the received packet.", bold=True)
|
|
else:
|
|
packet, checksum = separate_trailer(packet, PACKET_CHECKSUM_LENGTH)
|
|
if hashlib.blake2b(packet, digest_size=PACKET_CHECKSUM_LENGTH).digest() != checksum:
|
|
raise SoftError("Warning! Received packet had an invalid checksum.", bold=True)
|
|
return packet
|
|
|
|
def search_serial_interface(self) -> str:
|
|
"""Search for a serial interface."""
|
|
if self.settings.session_usb_serial_adapter:
|
|
search_announced = False
|
|
|
|
if not self.init_found:
|
|
phase("Searching for USB-to-serial interface", offset=len('Found'))
|
|
|
|
while True:
|
|
for f in sorted(os.listdir('/dev/')):
|
|
if f.startswith('ttyUSB'):
|
|
if self.init_found:
|
|
time.sleep(1)
|
|
phase('Found', done=True)
|
|
if self.init_found:
|
|
print_on_previous_line(reps=2)
|
|
self.init_found = True
|
|
return f'/dev/{f}'
|
|
|
|
time.sleep(0.1)
|
|
if self.init_found and not search_announced:
|
|
phase("Serial adapter disconnected. Waiting for interface", head=1, offset=len('Found'))
|
|
search_announced = True
|
|
|
|
else:
|
|
if self.settings.built_in_serial_interface in sorted(os.listdir('/dev/')):
|
|
return f'/dev/{self.settings.built_in_serial_interface}'
|
|
raise CriticalError(f"Error: /dev/{self.settings.built_in_serial_interface} was not found.")
|
|
|
|
# Qubes
|
|
|
|
def qubes_client_establish_socket(self) -> None:
|
|
"""Establish Qubes socket for outgoing data."""
|
|
self.txq_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
|
|
def qubes_server_establish_socket(self) -> None:
|
|
"""Establish Qubes socket for incoming data."""
|
|
udp_port = QUBES_SRC_LISTEN_SOCKET if self.settings.software_operation == NC else QUBES_DST_LISTEN_SOCKET
|
|
self.rxq_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
self.rxq_socket.bind((self.get_local_ip_addr(), udp_port))
|
|
|
|
@staticmethod
|
|
def get_local_ip_addr() -> str:
|
|
"""Get local IP address of the system."""
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
s.connect(('192.0.0.8', 1027))
|
|
except socket.error:
|
|
raise CriticalError("Socket error")
|
|
ip_address = s.getsockname()[0] # type: str
|
|
|
|
return ip_address
|
|
|
|
# Local testing
|
|
|
|
def server_establish_socket(self) -> None:
|
|
"""Initialize the receiver (IPC server).
|
|
|
|
The multiprocessing connection during local test does not
|
|
utilize authentication keys* because a MITM-attack against the
|
|
connection requires endpoint compromise, and in such situation,
|
|
MITM attack is not nearly as effective as key/screen logging or
|
|
RAM dump.
|
|
|
|
* https://docs.python.org/3/library/multiprocessing.html#authentication-keys
|
|
|
|
Similar to the case of standard mode of operation, all sensitive
|
|
data that passes through the socket/serial interface and Relay
|
|
Program is encrypted. A MITM attack between the sockets could of
|
|
course be used to e.g. inject public keys, but like with all key
|
|
exchanges, that would only work if the user neglects fingerprint
|
|
verification.
|
|
|
|
Another reason why the authentication key is useless, is the key
|
|
needs to be pre-shared. This means there's two ways to share it:
|
|
|
|
1) Hard-code the key to source file from where malware could
|
|
read it.
|
|
|
|
2) Force the user to manually copy the PSK from one program
|
|
to another. This would change the workflow that the local
|
|
test configuration tries to simulate.
|
|
|
|
To conclude, the local test configuration should never be used
|
|
under a threat model where endpoint security is of importance.
|
|
"""
|
|
try:
|
|
socket_number = RP_LISTEN_SOCKET if self.settings.software_operation == NC else DST_LISTEN_SOCKET
|
|
listener = multiprocessing.connection.Listener((LOCALHOST, socket_number))
|
|
self.rx_socket = listener.accept()
|
|
except KeyboardInterrupt:
|
|
graceful_exit()
|
|
|
|
def client_establish_socket(self) -> None:
|
|
"""Initialize the transmitter (IPC client)."""
|
|
try:
|
|
target = RECEIVER if self.settings.software_operation == NC else RELAY
|
|
phase(f"Connecting to {target}")
|
|
while True:
|
|
try:
|
|
if self.settings.software_operation == TX:
|
|
socket_number = SRC_DD_LISTEN_SOCKET if self.settings.data_diode_sockets else RP_LISTEN_SOCKET
|
|
else:
|
|
socket_number = DST_DD_LISTEN_SOCKET if self.settings.data_diode_sockets else DST_LISTEN_SOCKET
|
|
|
|
try:
|
|
self.tx_socket = multiprocessing.connection.Client((LOCALHOST, socket_number))
|
|
except ConnectionRefusedError:
|
|
time.sleep(0.1)
|
|
continue
|
|
|
|
phase(DONE)
|
|
break
|
|
|
|
except socket.error:
|
|
time.sleep(0.1)
|
|
|
|
except KeyboardInterrupt:
|
|
graceful_exit()
|
|
|
|
|
|
class GatewaySettings(object):
|
|
"""\
|
|
Gateway settings store settings for serial interface in an
|
|
unencrypted JSON database.
|
|
|
|
The reason these settings are in plaintext is it protects the system
|
|
from an inconsistent serial setting state: Would the user change one
|
|
or more settings of their serial interfaces, and would the setting
|
|
adjusting packet to Receiver Program drop, Relay Program could in
|
|
some situations no longer communicate with the Receiver Program.
|
|
|
|
Serial interface settings are not sensitive enough to justify the
|
|
inconveniences that would result from encrypting the setting values.
|
|
"""
|
|
|
|
def __init__(self,
|
|
operation: str,
|
|
local_test: bool,
|
|
dd_sockets: bool,
|
|
qubes: bool
|
|
) -> None:
|
|
"""Create a new Settings object.
|
|
|
|
The settings below are altered from within the program itself.
|
|
Changes made to the default settings are stored in the JSON
|
|
file under $HOME/tfc/user_data from where, if needed, they can
|
|
be manually altered by the user.
|
|
"""
|
|
self.serial_baudrate = 19200
|
|
self.serial_error_correction = 5
|
|
self.use_serial_usb_adapter = True
|
|
self.built_in_serial_interface = 'ttyS0'
|
|
self.rx_udp_ip = ''
|
|
|
|
self.software_operation = operation
|
|
self.local_testing_mode = local_test
|
|
self.data_diode_sockets = dd_sockets
|
|
|
|
self.qubes = qubes
|
|
self.all_keys = list(vars(self).keys())
|
|
self.key_list = self.all_keys[:self.all_keys.index('software_operation')]
|
|
self.defaults = {k: self.__dict__[k] for k in self.key_list}
|
|
|
|
self.file_name = f'{DIR_USER_DATA}{self.software_operation}_serial_settings.json'
|
|
|
|
ensure_dir(DIR_USER_DATA)
|
|
if os.path.isfile(self.file_name):
|
|
self.load_settings()
|
|
else:
|
|
self.setup()
|
|
self.store_settings()
|
|
|
|
self.session_serial_baudrate = self.serial_baudrate
|
|
self.session_serial_error_correction = self.serial_error_correction
|
|
self.session_usb_serial_adapter = self.use_serial_usb_adapter
|
|
|
|
self.tx_inter_packet_delay, self.rx_receive_timeout = self.calculate_serial_delays(self.session_serial_baudrate)
|
|
|
|
self.race_condition_delay = calculate_race_condition_delay(self.session_serial_error_correction,
|
|
self.serial_baudrate)
|
|
|
|
@classmethod
|
|
def calculate_serial_delays(cls, baud_rate: int) -> Tuple[float, float]:
|
|
"""Calculate the inter-packet delay and receive timeout.
|
|
|
|
Although this calculation mainly depends on the baud rate, a
|
|
minimal value will be set for rx_receive_timeout. This is to
|
|
ensure high baud rates do not cause issues by having shorter
|
|
delays than what the `time.sleep()` resolution allows.
|
|
"""
|
|
bytes_per_sec = baud_rate / BAUDS_PER_BYTE
|
|
byte_travel_t = 1 / bytes_per_sec
|
|
|
|
rx_receive_timeout = max(2 * byte_travel_t, SERIAL_RX_MIN_TIMEOUT)
|
|
tx_inter_packet_delay = 2 * rx_receive_timeout
|
|
|
|
return tx_inter_packet_delay, rx_receive_timeout
|
|
|
|
def setup(self) -> None:
|
|
"""Prompt the user to enter initial serial interface setting.
|
|
|
|
Ensure that the serial interface is available before proceeding.
|
|
"""
|
|
if not self.local_testing_mode and not self.qubes:
|
|
name = {TX: TRANSMITTER, NC: RELAY, RX: RECEIVER}[self.software_operation]
|
|
|
|
self.use_serial_usb_adapter = yes(f"Use USB-to-serial/TTL adapter for {name} Computer?", head=1, tail=1)
|
|
|
|
if self.use_serial_usb_adapter:
|
|
for f in sorted(os.listdir('/dev/')):
|
|
if f.startswith('ttyUSB'):
|
|
return None
|
|
m_print("Error: USB-to-serial/TTL adapter not found.")
|
|
self.setup()
|
|
else:
|
|
if self.built_in_serial_interface not in sorted(os.listdir('/dev/')):
|
|
m_print(f"Error: Serial interface /dev/{self.built_in_serial_interface} not found.")
|
|
self.setup()
|
|
|
|
if self.qubes and self.software_operation != RX:
|
|
|
|
# Check if IP address was stored by the installer.
|
|
if os.path.isfile(QUBES_RX_IP_ADDR_FILE):
|
|
cached_ip = open(QUBES_RX_IP_ADDR_FILE).read().strip()
|
|
os.remove(QUBES_RX_IP_ADDR_FILE)
|
|
|
|
if validate_ip_address(cached_ip) == '':
|
|
self.rx_udp_ip = cached_ip
|
|
return
|
|
|
|
# If we reach this point, no cached IP was found, prompt for IP address from the user.
|
|
rx_device, short = ('Networked', 'NET') if self.software_operation == TX else ('Destination', 'DST')
|
|
m_print(f"Enter the IP address of the {rx_device} Computer", head=1, tail=1)
|
|
self.rx_udp_ip = box_input(f"{short} IP-address", expected_len=15, validator=validate_ip_address, tail=1)
|
|
|
|
def store_settings(self) -> None:
|
|
"""Store serial settings in JSON format."""
|
|
serialized = json.dumps(self, default=(lambda o: {k: self.__dict__[k] for k in self.key_list}), indent=4)
|
|
|
|
with open(self.file_name, 'w+') as f:
|
|
f.write(serialized)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
|
|
def invalid_setting(self,
|
|
key: str,
|
|
json_dict: Dict[str, Union[bool, int, str]]
|
|
) -> None:
|
|
"""Notify about setting an invalid value to default value."""
|
|
m_print([f"Error: Invalid value '{json_dict[key]}' for setting '{key}' in '{self.file_name}'.",
|
|
f"The value has been set to default ({self.defaults[key]})."], head=1, tail=1)
|
|
setattr(self, key, self.defaults[key])
|
|
|
|
def load_settings(self) -> None:
|
|
"""Load and validate JSON settings for serial interface."""
|
|
with open(self.file_name) as f:
|
|
try:
|
|
json_dict = json.load(f)
|
|
except json.decoder.JSONDecodeError:
|
|
os.remove(self.file_name)
|
|
self.store_settings()
|
|
print(f"\nError: Invalid JSON format in '{self.file_name}'."
|
|
"\nSerial interface settings have been set to default values.\n")
|
|
return None
|
|
|
|
# Check for missing setting
|
|
self.check_missing_settings(json_dict)
|
|
|
|
# Store after loading to add missing, to replace invalid settings,
|
|
# and to remove settings that do not belong in the JSON file.
|
|
self.store_settings()
|
|
|
|
def check_missing_settings(self, json_dict: Any) -> None:
|
|
"""Check for missing JSON fields and invalid values."""
|
|
for key in self.key_list:
|
|
try:
|
|
self.check_key_in_key_list(key, json_dict)
|
|
|
|
if key == 'serial_baudrate':
|
|
self.validate_serial_baudrate(key, json_dict)
|
|
|
|
elif key == 'serial_error_correction':
|
|
self.validate_serial_error_correction(key, json_dict)
|
|
|
|
elif key == 'use_serial_usb_adapter':
|
|
self.validate_serial_usb_adapter_value(key, json_dict)
|
|
|
|
elif key == 'built_in_serial_interface':
|
|
self.validate_serial_interface_value(key, json_dict)
|
|
|
|
elif key == 'rx_udp_ip':
|
|
json_dict[key] = self.validate_rx_udp_ip_address(key, json_dict)
|
|
|
|
except SoftError:
|
|
continue
|
|
|
|
setattr(self, key, json_dict[key])
|
|
|
|
def check_key_in_key_list(self, key: str, json_dict: Any) -> None:
|
|
"""Check if the setting's key value is in the setting dictionary."""
|
|
if key not in json_dict:
|
|
m_print([f"Error: Missing setting '{key}' in '{self.file_name}'.",
|
|
f"The value has been set to default ({self.defaults[key]})."], head=1, tail=1)
|
|
setattr(self, key, self.defaults[key])
|
|
raise SoftError("Missing key", output=False)
|
|
|
|
def validate_serial_usb_adapter_value(self, key: str, json_dict: Any) -> None:
|
|
"""Validate the serial usb adapter setting value."""
|
|
if not isinstance(json_dict[key], bool):
|
|
self.invalid_setting(key, json_dict)
|
|
raise SoftError("Invalid value", output=False)
|
|
|
|
def validate_serial_baudrate(self, key: str, json_dict: Any) -> None:
|
|
"""Validate the serial baudrate setting value."""
|
|
if json_dict[key] not in serial.Serial().BAUDRATES:
|
|
self.invalid_setting(key, json_dict)
|
|
raise SoftError("Invalid value", output=False)
|
|
|
|
def validate_serial_error_correction(self, key: str, json_dict: Any) -> None:
|
|
"""Validate the serial error correction setting value."""
|
|
if not isinstance(json_dict[key], int) or json_dict[key] < 0:
|
|
self.invalid_setting(key, json_dict)
|
|
raise SoftError("Invalid value", output=False)
|
|
|
|
def validate_serial_interface_value(self, key: str, json_dict: Any) -> None:
|
|
"""Validate the serial interface setting value."""
|
|
if not isinstance(json_dict[key], str):
|
|
self.invalid_setting(key, json_dict)
|
|
raise SoftError("Invalid value", output=False)
|
|
|
|
if not any(json_dict[key] == f for f in os.listdir('/sys/class/tty')):
|
|
self.invalid_setting(key, json_dict)
|
|
raise SoftError("Invalid value", output=False)
|
|
|
|
def validate_rx_udp_ip_address(self, key: str, json_dict: Any) -> str:
|
|
"""Validate IP address of receiving Qubes VM."""
|
|
if self.qubes:
|
|
if not isinstance(json_dict[key], str) or validate_ip_address(json_dict[key]) != '':
|
|
self.setup()
|
|
return self.rx_udp_ip
|
|
|
|
rx_udp_ip = json_dict[key] # type: str
|
|
return rx_udp_ip
|
|
|
|
def change_setting(self, key: str, value_str: str) -> 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]
|
|
|
|
elif isinstance(attribute, int):
|
|
value = int(value_str)
|
|
if value < 0 or value > MAX_INT:
|
|
raise ValueError
|
|
|
|
else:
|
|
raise CriticalError("Invalid attribute type in settings.")
|
|
|
|
except (KeyError, ValueError):
|
|
raise SoftError(f"Error: Invalid setting value '{value_str}'.", delay=1, tail_clear=True)
|
|
|
|
self.validate_key_value_pair(key, value)
|
|
|
|
setattr(self, key, value)
|
|
self.store_settings()
|
|
|
|
@staticmethod
|
|
def validate_key_value_pair(key: str,
|
|
value: Union[int, bool]
|
|
) -> None:
|
|
"""\
|
|
Perform further evaluation on settings the values of which have
|
|
restrictions.
|
|
"""
|
|
if key == 'serial_baudrate':
|
|
if value not in serial.Serial().BAUDRATES:
|
|
raise SoftError("Error: The specified baud rate is not supported.")
|
|
m_print("Baud rate will change on restart.", head=1, tail=1)
|
|
|
|
if key == 'serial_error_correction':
|
|
if value < 0:
|
|
raise SoftError("Error: Invalid value for error correction ratio.")
|
|
m_print("Error correction ratio 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 = {"serial_baudrate": "The speed of serial interface in bauds per second",
|
|
"serial_error_correction": "Number of byte errors serial datagrams can recover from"}
|
|
|
|
# Columns
|
|
c1 = ['Serial interface setting']
|
|
c2 = ['Current value']
|
|
c3 = ['Default value']
|
|
c4 = ['Description']
|
|
|
|
terminal_width = get_terminal_width()
|
|
description_indent = 64
|
|
|
|
if terminal_width < description_indent + 1:
|
|
raise SoftError("Error: Screen width is too small.")
|
|
|
|
# Populate columns with setting data
|
|
for key in desc_d:
|
|
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
|
|
print('\n' + '\n'.join(lines) + '\n')
|