tfc-mirror/src/common/gateway.py

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')