def test_show_public_key_diffs(self, _: Any) -> None: self.assert_prints("""\ ┌──────────────────────────────────────────────────────────────────────────────────────┐ │ Source Computer received an invalid public key. │ │ See arrows below that point to correct characters. │ │ │ │ 4EEue4P8vkwzjAEnxiUw9s4ibVA3YVWvzshd6tCQp67qjqda7n93SCtM8Z24tVFd8ZuS9Kt5kecghuajaneR │ │ ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ ↓↓↓↓↓↓↓ ↓↓↓↓↓↓↓↓↓↓↓↓↓ ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ │ │ 4EEjKap9yReFo8SdSKPhUgsQgsKD19nJBrhiBuDmcB7yzucbYMaGtpQF8de99KHWLqWtohzLKWtqTv9HG5Fb │ └──────────────────────────────────────────────────────────────────────────────────────┘ """, show_value_diffs, 'public key', b58encode(TFC_PUBLIC_KEY_LENGTH*b'a', public_key=True), b58encode(TFC_PUBLIC_KEY_LENGTH*b'b', public_key=True), local_test=True) self.assert_prints("""\ ┌─────────────────────────────────────────────────────────────────────────────────────────────────┐ │ Source Computer received an invalid public key. │ │ See arrows below that point to correct characters. │ │ │ │ A B C D E F G H I J K L │ │ 4EEue4P 8vkwzjA EnxiUw9 s4ibVA3 YVWvzsh d6tCQp6 7qjqda7 n93SCtM 8Z24tVF d8ZuS9K t5kecgh uajaneR │ │ ↓↓↓↓ ↓↓↓↓↓↓↓ ↓↓↓↓↓↓↓ ↓↓↓↓↓↓↓ ↓↓↓↓↓↓ ↓↓↓↓↓↓↓ ↓↓↓↓↓↓ ↓↓↓↓↓↓↓ ↓↓↓↓↓↓ ↓↓↓↓↓↓↓ ↓↓↓↓↓↓↓ ↓↓↓↓↓↓↓ │ │ 4EEjKap 9yReFo8 SdSKPhU gsQgsKD 19nJBrh iBuDmcB 7yzucbY MaGtpQF 8de99KH WLqWtoh zLKWtqT v9HG5Fb │ │ A B C D E F G H I J K L │ └─────────────────────────────────────────────────────────────────────────────────────────────────┘ """, show_value_diffs, 'public key', b58encode(TFC_PUBLIC_KEY_LENGTH*b'a', public_key=True), b58encode(TFC_PUBLIC_KEY_LENGTH*b'b', public_key=True), local_test=False)
def pub_key_checker(queues: 'QueueDict', local_test: bool, unit_test: bool = False) -> None: """\ Display diffs between received public keys and public keys manually imported to Source Computer. """ pub_key_check_queue = queues[PUB_KEY_CHECK_QUEUE] pub_key_send_queue = queues[PUB_KEY_SEND_QUEUE] pub_key_dictionary = dict() while True: with ignored(EOFError, KeyboardInterrupt): if pub_key_send_queue.qsize() != 0: account, pub_key = pub_key_send_queue.get() pub_key_dictionary[account] = b58encode(pub_key, public_key=True) continue if pub_key_check_queue.qsize() != 0: purp_account, purp_pub_key = pub_key_check_queue.get( ) # type: bytes, bytes if purp_account in pub_key_dictionary: purp_b58_pub_key = purp_pub_key.decode() true_b58_pub_key = pub_key_dictionary[purp_account] show_value_diffs("public key", true_b58_pub_key, purp_b58_pub_key, local_test) time.sleep(0.01) if unit_test: break
def test_invalid_compression_raises_fr(self): # Setup data = os.urandom(1000) compressed = zlib.compress(data, level=9) compressed = compressed[:-2] + b'aa' key = os.urandom(32) key_b58 = b58encode(key) packet = IMPORTED_FILE_CT_HEADER + encrypt_and_sign(compressed, key) ts = datetime.datetime.now() window_list = WindowList() o_input = builtins.input input_list = ['bad', key_b58] gen = iter(input_list) def mock_input(_): return str(next(gen)) builtins.input = mock_input # Test self.assertFR("Decompression of file data failed.", process_imported_file, ts, packet, window_list) # Teardown builtins.input = o_input
def test_valid_import(self): file_name = str_to_bytes('testfile.txt') data = file_name + os.urandom(1000) compressed = zlib.compress(data, level=9) key = os.urandom(32) key_b58 = b58encode(key) packet = IMPORTED_FILE_CT_HEADER + encrypt_and_sign(compressed, key) ts = datetime.datetime.now() window_list = WindowList(nicks=['local']) o_input = builtins.input input_list = ['2QJL5gVSPEjMTaxWPfYkzG9UJxzZDNSx6PPeVWdzS5CFN7knZy', key_b58] gen = iter(input_list) def mock_input(_): return str(next(gen)) builtins.input = mock_input # Setup self.assertIsNone(process_imported_file(ts, packet, window_list)) self.assertTrue(os.path.isfile(f"{DIR_IMPORTED}/testfile.txt")) # Teardown builtins.input = o_input shutil.rmtree(f'{DIR_IMPORTED}/')
def g_msg_manager(queues: 'QueueDict', unit_test: bool = False) -> None: """Show group management messages according to contact list state. This process keeps track of existing contacts for whom there's a `client` process. When a group management message from a contact is received, existing contacts are displayed under "known contacts", and non-existing contacts are displayed under "unknown contacts". """ existing_contacts = [] # type: List[bytes] group_management_queue = queues[GROUP_MGMT_QUEUE] while True: with ignored(EOFError, KeyboardInterrupt): while queues[GROUP_MSG_QUEUE].qsize() == 0: time.sleep(0.01) header, payload, trunc_addr = queues[GROUP_MSG_QUEUE].get() group_id, data = separate_header(payload, GROUP_ID_LENGTH) if len(group_id) != GROUP_ID_LENGTH: continue group_id_hr = b58encode(group_id) existing_contacts = update_list_of_existing_contacts( group_management_queue, existing_contacts) process_group_management_message(data, existing_contacts, group_id_hr, header, trunc_addr) if unit_test and queues[UNIT_TEST_QUEUE].qsize() != 0: break
def test_invalid_decoding(self): key = KEY_LENGTH * b'\x01' encoded = b58encode( key) # 5HpjE2Hs7vjU4SN3YyPQCdhzCu92WoEeuE6PWNuiPyTu3ESGnzn changed = encoded[:-1] + 'a' with self.assertRaises(ValueError): b58decode(changed)
def print_key(message: str, # Instructive message key_bytes: bytes, # 32-byte key to be displayed settings: Union['Settings', 'GWSettings'], # Settings object public_key: bool = False # When True, uses Testnet address WIF format ) -> None: """Print a symmetric key in WIF format. If local testing is not enabled, this function adds spacing in the middle of the key, as well as guide letters to help the user keep track of typing progress: Local key encryption keys: A B C D E F G H I J K L M N O P Q 5Ka 52G yNz vjF nM4 2jw Duu rWo 7di zgi Y8g iiy yGd 78L cCx mwQ mWV X448 public keys: A B C D E F H H I J K L 4EcuqaD ddsdsuc gBX2PY2 qR8hReA aeSN2oh JB9w5Cv q6BQjDa PPgzSvW 932aHio sT42SKJ Gu2PpS1 Za3Xrao """ b58key = b58encode(key_bytes, public_key) if settings.local_testing_mode: m_print([message, b58key], box=True) else: guide, chunk_len = (B58_PUBLIC_KEY_GUIDE, 7) if public_key else (B58_LOCAL_KEY_GUIDE, 3) key = ' '.join(split_string(b58key, item_len=chunk_len)) m_print([message, guide, key], box=True)
def process_public_key(ts: 'datetime', packet: bytes, window_list: 'WindowList', settings: 'Settings', pubkey_buf: Dict[str, bytes]) -> None: """Display contact's public key and add it to buffer.""" pub_key = packet[1:33] origin = packet[33:34] try: account = packet[34:].decode() except UnicodeError: raise FunctionReturn( "Error! Account for received public key had invalid encoding.") if origin not in [ORIGIN_CONTACT_HEADER, ORIGIN_USER_HEADER]: raise FunctionReturn( "Error! Received public key had an invalid origin header.") if origin == ORIGIN_CONTACT_HEADER: pubkey_buf[account] = pub_key print_key(f"Received public key from {account}:", pub_key, settings) local_win = window_list.get_local_window() pub_key_b58 = ' '.join( split_string(b58encode(pub_key), item_len=(51 if settings.local_testing_mode else 3))) local_win.add_new( ts, f"Received public key from {account}: {pub_key_b58}") elif origin == ORIGIN_USER_HEADER and account in pubkey_buf: clear_screen() print_key(f"Public key for {account}:", pubkey_buf[account], settings)
def test_new_local_key(self, *_: Any) -> None: # Setup self.settings.nc_bypass_messages = False self.settings.traffic_masking = False # Test self.assertIsNone(new_local_key(*self.args)) local_contact = self.contact_list.get_contact_by_pub_key(LOCAL_PUBKEY) self.assertEqual(local_contact.onion_pub_key, LOCAL_PUBKEY) self.assertEqual(local_contact.nick, LOCAL_NICK) self.assertEqual(local_contact.tx_fingerprint, blake2b(b58encode(blake2b(SYMMETRIC_KEY_LENGTH*b'a')).encode())) self.assertEqual(local_contact.rx_fingerprint, bytes(FINGERPRINT_LENGTH)) self.assertFalse(local_contact.log_messages) self.assertFalse(local_contact.file_reception) self.assertFalse(local_contact.notifications) self.assertEqual(self.queues[COMMAND_PACKET_QUEUE].qsize(), 1) cmd, account, tx_key, rx_key, tx_hek, rx_hek = self.queues[KEY_MANAGEMENT_QUEUE].get() self.assertEqual(cmd, KDB_ADD_ENTRY_HEADER) self.assertEqual(account, LOCAL_PUBKEY) for key in [tx_key, rx_key, tx_hek, rx_hek]: self.assertIsInstance(key, bytes) self.assertEqual(len(key), SYMMETRIC_KEY_LENGTH)
def process_public_key(ts: 'datetime', packet: bytes, window_list: 'WindowList', settings: 'Settings', pubkey_buf: Dict[str, str]) -> None: """Display public from contact.""" pub_key = packet[1:33] origin = packet[33:34] account = packet[34:].decode() if origin == ORIGIN_CONTACT_HEADER: pub_key_enc = b58encode(pub_key) ssl = {48: 8, 49: 7, 50: 5}.get(len(pub_key_enc), 5) pub_key_enc = pub_key_enc if settings.local_testing_mode else ' '.join( split_string(pub_key_enc, item_len=ssl)) pubkey_buf[account] = pub_key_enc box_print( [f"Received public key from {account}", '', pubkey_buf[account]], head=1, tail=1) local_win = window_list.get_local_window() local_win.print_new( ts, f"Received public key from {account}: {pub_key_enc}", print_=False) if origin == ORIGIN_USER_HEADER and account in pubkey_buf: clear_screen() box_print([f"Public key for {account}", '', pubkey_buf[account]], head=1, tail=1)
def test_zero_public_key_raises_fr(self): # Setup builtins.input = lambda _: b58encode(bytes(32)) # Test self.assertFR("Error: Zero public key", start_key_exchange, '*****@*****.**', '*****@*****.**', 'Alice', self.contact_list, self.settings, self.queues)
def test_compare_pub_keys(self): # Setup onion_pub_key = ONION_SERVICE_PUBLIC_KEY_LENGTH * b'a' invalid_pub_key = b58encode(TFC_PUBLIC_KEY_LENGTH * b'a').encode() # Test compare_pub_keys(onion_pub_key + invalid_pub_key, self.queues) self.assertEqual(self.queues[PUB_KEY_CHECK_QUEUE].get(), (onion_pub_key, invalid_pub_key))
def test_bitcoin_wif_test_vectors(self): """Test vectors are available at https://en.bitcoin.it/wiki/Wallet_import_format """ byte_key = bytes.fromhex("0C28FCA386C7A227600B2FE50B7CAE11" "EC86D3BF1FBE471BE89827E19D72AA1D") b58_key = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" self.assertEqual(b58encode(byte_key), b58_key) self.assertEqual(b58decode(b58_key), byte_key)
def setUp(self): self.o_input = builtins.input self.settings = Settings() self.ts = datetime.datetime.now() self.window_list = WindowList(nicks=[LOCAL_ID]) self.key = os.urandom(KEY_LENGTH) self.key_b58 = b58encode(self.key, file_key=True) input_list = ['91avARGdfge8E4tZfYLoxeJ5sGBdNJQH4kvjJoQFacbgwi1C2GD', self.key_b58] gen = iter(input_list) builtins.input = lambda _: str(next(gen))
def test_pub_key_checker(self, _: Any) -> None: # Setup public_key = TFC_PUBLIC_KEY_LENGTH*b'a' invalid_public_key = b58encode(public_key, public_key=True)[:-1] + 'a' account = nick_to_pub_key('Bob') for local_test in [True, False]: self.queues[PUB_KEY_SEND_QUEUE].put((account, public_key)) self.queues[PUB_KEY_CHECK_QUEUE].put((account, invalid_public_key.encode())) # Test self.assertIsNone(pub_key_checker(self.queues, local_test=local_test, unit_test=True)) self.assertIsNone(pub_key_checker(self.queues, local_test=local_test, unit_test=True))
def test_account_checker(self, *_: Any) -> None: # Setup user_account = b58encode(nick_to_pub_key('Alice')) account = b58encode(nick_to_pub_key('Bob')) unknown_account = b58encode(nick_to_pub_key('Charlie')) invalid_account1 = account[:-1] + 'c' invalid_account2 = unknown_account[:-1] + 'c' def queue_delayer() -> None: """Place messages to queue one at a time.""" time.sleep(0.05) self.queues[USER_ACCOUNT_QUEUE].put(user_account) threading.Thread(target=queue_delayer).start() self.queues[GUI_INPUT_QUEUE].put(unknown_account) self.queues[ACCOUNT_SEND_QUEUE].put(invalid_account1) self.queues[ACCOUNT_CHECK_QUEUE].put(account) self.queues[ACCOUNT_CHECK_QUEUE].put(invalid_account2) # Test with mock.patch('time.sleep', lambda _: None): self.assertIsNone(account_checker(self.queues, stdin_fd=1, unit_test=True))
def __init__(self, uid: bytes, contact_list: 'ContactList', group_list: 'GroupList', settings: 'Settings', packet_list: 'PacketList') -> None: """Create a new RxWindow object.""" self.uid = uid self.contact_list = contact_list self.group_list = group_list self.settings = settings self.packet_list = packet_list self.is_active = False self.contact = None self.group = None self.group_msg_id = os.urandom(GROUP_MSG_ID_LENGTH) self.window_contacts = [] # type: List[Contact] self.message_log = [] # type: List[MsgTuple] self.handle_dict = dict() # type: Dict[bytes, str] self.previous_msg_ts = datetime.now() self.unread_messages = 0 if self.uid == WIN_UID_COMMAND: self.type = WIN_TYPE_COMMAND # type: str self.name = self.type # type: str self.window_contacts = [] elif self.uid == WIN_UID_FILE: self.type = WIN_TYPE_FILE self.packet_list = packet_list elif self.uid in self.contact_list.get_list_of_pub_keys(): self.type = WIN_TYPE_CONTACT self.contact = self.contact_list.get_contact_by_pub_key(uid) self.name = self.contact.nick self.window_contacts = [self.contact] elif self.uid in self.group_list.get_list_of_group_ids(): self.type = WIN_TYPE_GROUP self.group = self.group_list.get_group_by_id(self.uid) self.name = self.group.name self.window_contacts = self.group.members else: if len(uid) == ONION_SERVICE_PUBLIC_KEY_LENGTH: hr_uid = pub_key_to_onion_address(uid) elif len(uid) == GROUP_ID_LENGTH: hr_uid = b58encode(uid) else: hr_uid = "<unable to encode>" raise SoftError(f"Invalid window '{hr_uid}'.")
def test_successful_local_key_processing_existing_local_key(self): # Setup conf_code = os.urandom(1) key = os.urandom(KEY_LENGTH) hek = os.urandom(KEY_LENGTH) kek = os.urandom(KEY_LENGTH) packet = LOCAL_KEY_PACKET_HEADER + encrypt_and_sign(key + hek + conf_code, key=kek) input_list = ['5JJwZE46Eic9B8sKJ8Qocyxa8ytUJSfcqRo7Hr5ES7YgFGeJjCJ', b58encode(kek)] gen = iter(input_list) builtins.input = lambda _: str(next(gen)) # Test self.assertIsNone(process_local_key(self.ts, packet, self.window_list, self.contact_list, self.key_list, self.settings))
def protect_kdk(kdk: bytes) -> None: """Prevent leak of KDK via terminal history / clipboard.""" readline.clear_history() reset_terminal() root = tkinter.Tk() root.withdraw() try: if root.clipboard_get() == b58encode(kdk): # type: ignore root.clipboard_clear() # type: ignore except tkinter.TclError: pass root.destroy()
def test_successful_local_key_processing_existing_bootstrap(self): # Setup conf_code = os.urandom(1) key = os.urandom(KEY_LENGTH) hek = os.urandom(KEY_LENGTH) kek = os.urandom(KEY_LENGTH) packet = LOCAL_KEY_PACKET_HEADER + encrypt_and_sign(key + hek + conf_code, key=kek) input_list = [b58encode(kek)] gen = iter(input_list) builtins.input = lambda _: str(next(gen)) self.key_list.keysets = [] # Test self.assertIsNone(process_local_key(self.ts, packet, self.window_list, self.contact_list, self.key_list, self.settings)) self.assertEqual(self.window_list.active_win.uid, LOCAL_ID)
def test_successful_local_key_processing(self): # Setup conf_code = os.urandom(1) key = os.urandom(32) hek = os.urandom(32) kek = os.urandom(32) packet = LOCAL_KEY_PACKET_HEADER + encrypt_and_sign(key + hek + conf_code, key=kek) contact_list = ContactList() key_list = KeyList() o_input = builtins.input builtins.input = lambda x: b58encode(kek) # Test self.assertIsNone(process_local_key(packet, contact_list, key_list)) # Teardown builtins.input = o_input
def print_kdk(kdk_bytes: bytes, settings: 'Settings') -> None: """Print symmetric key decryption key. If local testing is not enabled, this function will add spacing between key decryption key to help user keep track of key typing progress. The length of the Base58 encoded key varies between 48..50 characters, thus spacing is adjusted to get even length for each substring. :param kdk_bytes: Key decryption key :param settings: Settings object :return: None """ kdk_enc = b58encode(kdk_bytes) ssl = {48: 8, 49: 7, 50: 5}.get(len(kdk_enc), 5) kdk = kdk_enc if settings.local_testing_mode else ' '.join( split_string(kdk_enc, item_len=ssl)) box_print(["Local key decryption key (to RxM)", kdk])
def export_file(settings: 'Settings', gateway: 'Gateway'): """Encrypt and export file to NH. This is a faster method of sending large files. It is used together with '/fi' import_file command that loads ciphertext to RxM for later decryption. Key is generated automatically so that bad passwords by users do not affect security of ciphertexts. As use of this command reveals use of TFC, it is disabled during trickle connection. """ if settings.session_trickle: raise FunctionReturn("Command disabled during trickle connection.") 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. No file was sent.") 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=9)) phase("Done") phase("Encrypting data") file_key = keygen() file_ct = encrypt_and_sign(comp, key=file_key) phase("Done") phase("Exporting data") transmit(EXPORTED_FILE_CT_HEADER + file_ct, settings, gateway) phase("Done") box_print([f"Decryption key for file {name}:", '', b58encode(file_key)], head=1, tail=1)
def print_key(message: str, key_bytes: bytes, settings: 'Settings', no_split: bool = False, file_key: bool = False) -> None: """Print symmetric key. If local testing is not enabled, this function will add spacing in the middle of the key to help user keep track of typing progress. The ideal substring length in Cowan's `focus of attention` is four digits: https://en.wikipedia.org/wiki/Working_memory#Working_memory_as_part_of_long-term_memory The 51 char KDK is however not divisible by 4, and remembering which symbols are letters and if they are capitalized is harder than remembering just digits. 51 is divisible by 3. The 17 segments are displayed with guide letter A..Q to help keep track when typing: A B C D E F G H I J K L M N O P Q 5Ka 52G yNz vjF nM4 2jw Duu rWo 7di zgi Y8g iiy yGd 78L cCx mwQ mWV :param message: Message to print :param key_bytes: Decryption key :param settings: Settings object :param no_split: When True, does not split decryption key to chunks :param file_key When True, uses testnet address format :return: None """ b58key = b58encode(key_bytes, file_key) if settings.local_testing_mode or no_split: box_print([message, b58key]) else: box_print([ message, ' '.join('ABCDEFGHIJKLMNOPQ'), ' '.join(split_string(b58key, item_len=3)) ])
def test_invalid_name_raises_fr(self): # Setup file_name = str_to_bytes('\x01testfile.txt') data = file_name + os.urandom(1000) compressed = zlib.compress(data, level=9) key = os.urandom(32) key_b58 = b58encode(key) packet = IMPORTED_FILE_CT_HEADER + encrypt_and_sign(compressed, key) ts = datetime.datetime.now() window_list = WindowList(nicks=['local']) o_input = builtins.input input_list = ['2QJL5gVSPEjMTaxWPfYkzG9UJxzZDNSx6PPeVWdzS5CFN7knZy', key_b58] gen = iter(input_list) def mock_input(_): return str(next(gen)) builtins.input = mock_input # Test self.assertFR("Received file had an invalid name.", process_imported_file, ts, packet, window_list) # Teardown builtins.input = o_input
def test_local_keys_raise_value_error_when_expecting_public_key(self): b58_file_key = b58encode(self.key, public_key=True) with self.assertRaises(ValueError): b58decode(b58_file_key)
def test_encoding_and_decoding_of_random_public_keys(self): for _ in range(100): key = os.urandom(TFC_PUBLIC_KEY_LENGTH) encoded = b58encode(key, public_key=True) decoded = b58decode(encoded, public_key=True) self.assertEqual(key, decoded)
def test_encoding_and_decoding_of_random_local_keys(self): for _ in range(100): key = os.urandom(SYMMETRIC_KEY_LENGTH) encoded = b58encode(key) decoded = b58decode(encoded) self.assertEqual(key, decoded)
def print_groups(self) -> None: """Print list of groups. Neatly printed group list allows easy group management and it also allows the user to check active logging and notification setting, as well as what group ID Relay Program shows corresponds to what group, and which contacts are in the group. """ # Initialize columns c1 = ['Group'] c2 = ['Group ID'] c3 = ['Logging '] c4 = ['Notify'] c5 = ['Members'] # Populate columns with group data that has only a single line for g in self.groups: c1.append(g.name) c2.append(b58encode(g.group_id)) c3.append('Yes' if g.log_messages else 'No') c4.append('Yes' if g.notifications else 'No') # Calculate the width of single-line columns c1w, c2w, c3w, c4w = [ max(len(v) for v in column) + CONTACT_LIST_INDENT for column in [c1, c2, c3, c4] ] # Create a wrapper for Members-column wrapped_members_line_indent = c1w + c2w + c3w + c4w members_column_width = max( 1, get_terminal_width() - wrapped_members_line_indent) wrapper = textwrap.TextWrapper(width=members_column_width) # Populate the Members-column for g in self.groups: if g.empty(): c5.append("<Empty group>\n") else: comma_separated_nicks = ', '.join( sorted([m.nick for m in g.members])) members_column_lines = wrapper.fill( comma_separated_nicks).split('\n') final_str = members_column_lines[0] + '\n' for line in members_column_lines[1:]: final_str += wrapped_members_line_indent * ' ' + line + '\n' c5.append(final_str) # Align columns by adding whitespace between fields of each line lines = [ f'{f1:{c1w}}{f2:{c2w}}{f3:{c3w}}{f4:{c4w}}{f5}' for f1, f2, f3, f4, f5 in zip(c1, c2, c3, c4, c5) ] # Add a terminal-wide line between the column names and the data lines.insert(1, get_terminal_width() * '─') # Print the group list print('\n'.join(lines) + '\n')
def get_list_of_hr_group_ids(self) -> List[str]: """Return list of human readable (B58 encoded) group IDs.""" return [b58encode(g.group_id) for g in self.groups]