#!/usr/bin/env python3 from os import chmod, environ, listdir, remove, walk from os.path import exists, expanduser, isdir, isfile, realpath from pathlib import Path from random import randint from shutil import get_terminal_size, move, rmtree from sshync import delete as offline_delete, run_profile, make_profile, get_profile from subprocess import CalledProcessError, DEVNULL, PIPE, run from sys import argv, exit as s_exit # PORT START UNAME-IMPORT from os import uname # PORT END UNAME-IMPORT home = expanduser("~") # UTILITY FUNCTIONS # generates and prints full entry list def entry_list_gen(_directory=f"{home}/.local/share/sshyp/"): from textwrap import fill _ran = False print("\nfor a list of usable commands, run 'sshyp help'\n\n\u001b[38;5;0;48;5;15msshyp entries:\u001b[0m\n") for _root, _dirs, _files in sorted(walk(_directory, topdown=True)): _entry_list, _color_alternator = [], 1 if _ran: print(f"\u001b[38;5;15;48;5;238m{_root.replace(f'{home}/.local/share/sshyp', '', 1)}/\u001b[0m") for filename in sorted(_files): if _color_alternator > 0: _entry_list.append(filename[:-4]) else: _entry_list.append(f"\u001b[38;5;8m{filename[:-4]}\u001b[0m") _color_alternator = _color_alternator * -1 _real = len(' '.join(_entry_list)) - (5.5 * len(_entry_list)) if _real <= get_terminal_size()[0]: _width = len(' '.join(_entry_list)) else: _width = (len(' '.join(_entry_list)) / (_real / get_terminal_size()[0]) - 25) if len(_entry_list) > 0: print(fill(' '.join(_entry_list), width=_width) + '\n') elif _ran: print('\u001b[38;5;9m-empty directory-\u001b[0m\n') _ran = True # displays the contents of an entry in a readable format def entry_reader(_decrypted_entry): _entry_lines, _notes_flag = open(_decrypted_entry, 'r').readlines(), 0 if pass_show: _entry_password = f'\u001b[38;5;10m{_entry_lines[0]}\u001b[0m' else: _entry_password = f'\u001b[38;5;3mend command in "--show" or "-s" to view\u001b[0m\n' print() for _num in range(len(_entry_lines)): try: if _num == 0 and _entry_lines[1] != '\n': print(f"\u001b[38;5;15;48;5;238musername:\u001b[0m\n{_entry_lines[1]}") elif _num == 1 and _entry_lines[0] != '\n': print(f"\u001b[38;5;15;48;5;238mpassword:\u001b[0m\n{_entry_password}") elif _num == 2 and _entry_lines[2] != '\n': print(f"\u001b[38;5;15;48;5;238murl:\u001b[0m\n{_entry_lines[_num]}") elif _num >= 3 and _entry_lines[_num] != '\n' and _notes_flag != 1: _notes_flag = 1 print('\u001b[38;5;15;48;5;238mnotes:\u001b[0m\n' + _entry_lines[_num].strip('\n')) elif _num >= 3 and _notes_flag == 1: print(_entry_lines[_num].strip('\n')) if _notes_flag == 1: try: _line_test = _entry_lines[_num + 1] except IndexError: print() except IndexError: if _num == 0: print(f"\u001b[38;5;15;48;5;238mpassword:\u001b[0m\n{_entry_password}") if pass_show: print() # generates and returns a random string based on input def string_gen(_complexity, _length): from random import SystemRandom import string if _complexity == 's': _character_pool = string.ascii_letters + string.digits elif _complexity == 'f': _character_pool = string.digits + string.ascii_letters + string.punctuation.replace('/', '').replace('\\', '')\ .replace("'", '').replace('"', '').replace('`', '').replace('~', '') else: _character_pool = string.digits + string.ascii_letters + string.punctuation _min_special, _special = round(.2 * _length), 0 while True: _gen = ''.join(SystemRandom().choice(_character_pool) for _ in range(_length)) for _character in _gen: if not _character.isalpha(): _special += 1 if _special >= _min_special: break return _gen # prompts the user for necessary information to generate a password and passes it to string_gen def pass_gen(): _length = 9 while True: try: _length = int(input('password length: ')) except ValueError: continue else: if _length < 1: continue else: break _complexity = str(input('password complexity - simple (for compatibility) or complex (for security)? (s/C) ')) if _complexity not in ('s', 'S'): _complexity = 'c' _gen = string_gen(_complexity.lower(), _length) return _gen # creates a temporary directory for entry editing def shm_gen(_tmp_dir=f"{home}/.config/sshyp/tmp/"): _shm_folder_gen = string_gen('f', randint(12, 48)) _shm_entry_gen = string_gen('f', randint(12, 48)) Path(_tmp_dir + _shm_folder_gen).mkdir(mode=0o700) return _shm_folder_gen, _shm_entry_gen # encrypts an entry and cleans up the temporary files def encrypt(_entry_dir, _shm_folder, _shm_entry, _gpg_id, _tmp_dir=f"{home}/.config/sshyp/tmp/"): run(['gpg', '-qr', str(_gpg_id), '-e', f"{_tmp_dir}{_shm_folder}/{_shm_entry}"]) move(f"{_tmp_dir}{_shm_folder}/{_shm_entry}.gpg", f"{_entry_dir}.gpg") rmtree(f"{_tmp_dir}{_shm_folder}") # decrypts an entry to a temporary directory def decrypt(_entry_dir, _shm_folder, _shm_entry, _quick_pass, _tmp_dir=f"{home}/.config/sshyp/tmp/"): if not isinstance(_quick_pass, bool): _unlock_method = ['gpg', '--pinentry-mode', 'loopback', '--passphrase', _quick_pass, '-qd', '--output'] else: _unlock_method = ['gpg', '-qd', '--output'] if _shm_folder is None and _shm_entry is None: _output_target = ['/dev/null', f"{home}/.config/sshyp/lock.gpg"] else: _output_target = [f"{_tmp_dir}{_shm_folder}/{_shm_entry}", f"{_entry_dir}.gpg"] try: run(_unlock_method + _output_target, stderr=DEVNULL, check=True) except CalledProcessError: if not isinstance(_quick_pass, bool): print('\n\u001b[38;5;9merror: quick-unlock failed as a result of an incorrect passphrase, an unreachable ' 'sshyp server, or an invalid configuration\n\nfalling back to standard unlock\u001b[0m\n') try: run(['gpg', '-qd', '--output'] + _output_target, stderr=DEVNULL, check=True) except CalledProcessError: print('\n\u001b[38;5;9merror: could not decrypt - ensure the correct gpg key is present\u001b[0m\n') s_exit(4) else: print('\n\u001b[38;5;9merror: could not decrypt - ensure the correct gpg key is present\u001b[0m\n') s_exit(4) # call decrypt() based on quick-unlock status def determine_decrypt(_entry_dir, _shm_folder, _shm_entry): if quick_unlock_enabled == 'yes': decrypt(_entry_dir, _shm_folder, _shm_entry, whitelist_verify(port, username_ssh, ip, client_device_id)) else: decrypt(_entry_dir, _shm_folder, _shm_entry, False) # ensures an edited entry is optimized for best compatibility def optimized_edit(_lines, _edit_data, _edit_line): while len(_lines) < _edit_line + 1: _lines.append('\n') if _edit_data is not None: _lines[_edit_line] = _edit_data.strip('\n').rstrip() + '\n' for _num in range(len(_lines)): if not _lines[_num].endswith('\n'): _lines[_num] += '\n' for _num in reversed(range(len(_lines))): if _lines[_num] == '\n': _lines = _lines[:-1] elif _lines[_num].endswith('\n'): _lines[_num] = _lines[_num].rstrip() break else: break return _lines # edits the note attached to an entry def edit_note(_shm_folder, _shm_entry, _lines): _reg_lines = _lines[0:3] open(f"{tmp_dir}{_shm_folder}/{_shm_entry}-n", 'w').writelines(_lines[3:]) run([editor, f"{tmp_dir}{_shm_folder}/{_shm_entry}-n"]) _new_notes = open(f"{tmp_dir}{_shm_folder}/{_shm_entry}-n").readlines() while len(_reg_lines) < 3: _reg_lines.append('\n') _noted_lines = _reg_lines + _new_notes return _noted_lines # attempts to connect to the user's server via ssh to register the device for syncing def copy_id_check(_port, _username_ssh, _ip, _client_device_id): try: run(['ssh', '-o', 'ConnectTimeout=3', '-i', f"{home}/.ssh/sshyp", '-p', _port, f"{_username_ssh}@{_ip}", f'python3 -c \'from pathlib import Path; Path("/home/{_username_ssh}/.config/sshyp/devices/' f'{_client_device_id}").touch(mode=0o400, exist_ok=True)\''], stderr=DEVNULL, check=True) except CalledProcessError: print('\n\u001b[38;5;9mwarning: ssh connection could not be made - ensure the public key (~/.ssh/sshyp.pub) is ' 'registered on the remote server and that the entered ip, port, and username are correct\n\nsyncing ' 'functionality will be disabled until this is addressed\u001b[0m\n') open(f"{home}/.config/sshyp/ssh-error", 'w').write('1') return True open(f"{home}/.config/sshyp/ssh-error", 'w').write('0') return False # creates a radio selection between the provided options def settings_radio(_stdscr, _options, _pretext): curses.curs_set(0) _selected = 0 while True: _stdscr.clear() _stdscr.addstr(0, 0, _pretext, curses.A_BOLD) for _i, _option in enumerate(_options): _y = _i + 2 if _i == _selected: _stdscr.addstr(_y, 0, "[*] " + _option, curses.A_REVERSE) else: _stdscr.addstr(_y, 0, "[ ] " + _option) _stdscr.refresh() _key = _stdscr.getch() # update _selected based on user input if _key == curses.KEY_UP: _selected = (_selected - 1) % len(_options) elif _key == curses.KEY_DOWN: _selected = (_selected + 1) % len(_options) elif _key == ord('\n'): break _stdscr.refresh() curses.curs_set(1) return _selected # creates a text-box input def settings_text(_stdscr, _pretext): _stdscr.clear() _stdscr.addstr(0, 0, _pretext) _term_columns = get_terminal_size()[0] _editwin = curses.newwin(1, _term_columns-2, 3, 1) rectangle(_stdscr, 2, 0, 4, _term_columns-1) _stdscr.refresh() _box = Textbox(_editwin) # let the user edit until ctrl+g/enter is struck _box.edit() # return resulting contents return _box.gather().strip() # cleanly terminates curses def settings_terminate(_stdscr): curses.nocbreak() _stdscr.keypad(False) # TODO Needed?? curses.echo() curses.endwin() # ARGUMENT-SPECIFIC FUNCTIONS # runs configuration wizard def settings(): # set to avoid PEP8 warnings _sshyp_data = None # config directory creation Path(f"{home}/.config/sshyp/devices").mkdir(mode=0o700, parents=True, exist_ok=True) # temporary file symlink creation if not exists(f"{home}/.config/sshyp/tmp"): from os import symlink # PORT START UNAME-TMP if uname()[0] in ('Haiku', 'FreeBSD', 'Darwin'): symlink('/tmp', f"{home}/.config/sshyp/tmp") elif exists('/data/data/com.termux'): symlink('/data/data/com.termux/files/usr/tmp', f"{home}/.config/sshyp/tmp") else: symlink('/dev/shm', f"{home}/.config/sshyp/tmp") # PORT END UNAME-TMP # curses initialization _stdscr = curses.initscr() curses.noecho() curses.cbreak() _stdscr.keypad(True) # curses menu tree try: # device+sync type selection # PORT START TWEAK-DEVTYPE _install_type = settings_radio(_stdscr, ('server', 'client (ssh-synchronized)', 'client (offline)'), 'device + sync type configuration') if _install_type == 0: _sshyp_data = ['server'] Path(f"{home}/.config/sshyp/deleted").mkdir(mode=0o700, exist_ok=True) Path(f"{home}/.config/sshyp/whitelist").mkdir(mode=0o700, exist_ok=True) settings_terminate(_stdscr) print(f"\nmake sure the ssh service is running and properly configured") else: _offline_mode = False if _install_type == 2: _offline_mode = True _sshyp_data = ['client'] Path(f"{home}/.local/share/sshyp").mkdir(mode=0o700, parents=True, exist_ok=True) # PORT END TWEAK-DEVTYPE # gpg key selection _uid_list = [_item for _item in run(['gpg', '-k', '--with-colons'], stdout=PIPE, text=True).stdout.splitlines() if _item.startswith('uid')] _clean_uid_list = [] for _uid in _uid_list: _clean_uid_list.append(sub(r':+', ':', _uid).split(':')[4]) _clean_uid_list.append('auto-generate') _gpg_id_sel = settings_radio(_stdscr, _clean_uid_list, 'gpg key selection') _gpg_id = _clean_uid_list[_gpg_id_sel] if _gpg_id == 'auto-generate': print('\na unique gpg key is being generated for you...') if not isfile(f"{home}/.config/sshyp/gpg-gen"): open(f"{home}/.config/sshyp/gpg-gen", 'w').writelines([ 'Key-Type: 1\n', 'Key-Length: 4096\n', 'Key-Usage: sign encrypt\n', 'Name-Real: sshyp\n', 'Name-Comment: gpg-sshyp\n', 'Name-Email: https://github.com/rwinkhart/sshyp\n', 'Expire-Date: 0']) run(['gpg', '--batch', '--generate-key', f"{home}/.config/sshyp/gpg-gen"]) remove(f"{home}/.config/sshyp/gpg-gen") _sshyp_data.append(run(['gpg', '-k'], stdout=PIPE, text=True).stdout.splitlines()[-3].strip()) else: _sshyp_data.append(_gpg_id) # text editor configuration _sshyp_data.append(settings_text(_stdscr, 'enter the name of your preferred text editor:\n\n\n\n\n' '(ctrl+g/enter to confirm)\n\nexample input: vim')) # lock file generation if isfile(f"{home}/.config/sshyp/lock.gpg"): remove(f"{home}/.config/sshyp/lock.gpg") open(f"{home}/.config/sshyp/lock", 'w') run(['gpg', '-qr', str(_sshyp_data[1]), '-e', f"{home}/.config/sshyp/lock"]) remove(f"{home}/.config/sshyp/lock") # ssh configuration if not _offline_mode: _uiport = settings_text(_stdscr, 'enter the username, ip, and ssh port of your sshyp server:\n\n\n\n\n(' 'ctrl+g/enter to confirm)\n\nexample inputs:\n\n ipv4: user@10.10.10.' '10:22\n ipv6: user@[2000:2000:2000:2000:2000:2000:2000:2000]:22\n ' 'domain: user@mydomain.com:22').lstrip('[').replace(']', '') _uiport_split = _uiport.split('@') _username_ssh = _uiport_split[0] _iport = _uiport_split[1].rsplit(':', 1) # sshync profile generation make_profile(f"{home}/.config/sshyp/sshyp.sshync", f"{home}/.local/share/sshyp/", f"/home/{_username_ssh}/.local/share/sshyp/", f"{home}/.ssh/sshyp", _iport[0], _iport[1], _username_ssh) # device id configuration # remove existing device id for _id in listdir(f"{home}/.config/sshyp/devices"): remove(f"{home}/.config/sshyp/devices/{_id}") _device_id_prefix = settings_text(_stdscr, 'name this device:\n\n\n\n\n(ctrl+g/enter to confirm)\n\n' 'important:\u001b[0m this id \u001b[4;1mmust\u001b[0m be ' 'unique amongst your client devices\n\nthis is used to keep ' 'track of database syncing and quick-unlock permissions\n') _device_id_suffix = string_gen('f', randint(24, 48)) _device_id = _device_id_prefix + '-' + _device_id_suffix open(f"{home}/.config/sshyp/devices/{_device_id}", 'w') # quick-unlock configuration _quick_unlock_sel = settings_radio(_stdscr, ('yes', 'no'), 'enable quick-unlock?') if _quick_unlock_sel == 0: _sshyp_data.append('yes') else: _sshyp_data.append('no') settings_terminate(_stdscr) # test server connection and attempt to register device id copy_id_check(_iport[1], _username_ssh, _iport[0], _device_id) else: if isfile(f"{home}/.config/sshyp/sshyp.sshync"): remove(f"{home}/.config/sshyp/sshyp.sshync") settings_terminate(_stdscr) # PORT START CLIPTOOL # check for clipboard tool and display warning if missing if uname()[0] in ('Linux', 'FreeBSD'): if 'WAYLAND_DISPLAY' in environ: _display_server, _clipboard_tool, _clipboard_package = 'Wayland', 'wl-copy', 'wl-clipboard' else: _display_server, _clipboard_tool, _clipboard_package = 'X11', 'xclip', 'xclip' from shutil import which if which(_clipboard_tool) is None: print(f'\n\u001b[38;5;9mwarning: you are using {_display_server} and "{_clipboard_tool}" is not ' f'present - \ncopying entry fields will not function until ' f'"{_clipboard_package}" is installed\u001b[0m') # PORT END CLIPTOOL except KeyboardInterrupt: settings_terminate(_stdscr) # write main config file (sshyp-data) with open(f"{home}/.config/sshyp/sshyp-data", 'w') as _config_file: _lines = 0 for _item in _sshyp_data: _lines += 1 _config_file.write(str(_item) + '\n') while _lines < 4: _lines += 1 _config_file.write('n') print('\nconfiguration complete\n') # prints help text based on argument def print_info(): if arguments[0] in ('version', '-v'): _blank = '\u001b[38;5;7;48;5;8m/\u001b[38;5;15;48;5;15m' + 54*' ' + '\u001b[38;5;7;48;5;8m/\u001b[0m' _border = '\u001b[38;5;7;48;5;8m' + 28*'<>' + '\u001b[0m\n' print(f"""\nsshyp is a simple, self-hosted, sftp-synchronized\npassword manager for unix(-like) systems\n {16*' '}..{7*' '}\u001b[38;5;9m♥♥ ♥♥\u001b[0m{7*' '}.. {9*' '}.''.''/()\\{5*' '}\u001b[38;5;13m♥♥♥♥♥♥♥\u001b[0m{5*' '}/()\\''.''. {8*' '}*{7*' '}:{8*' '}\u001b[38;5;9m♥♥♥♥♥\u001b[0m{8*' '}:{7*' '}* {9*' '}`..'..'{10*' '}\u001b[38;5;13m♥♥♥\u001b[0m{10*' '}`..'..' {9*' '}//{3*' '}\\\\{11*' '}\u001b[38;5;9m♥\u001b[0m{11*' '}//{3*' '}\\\\""") print(f"{_border}{_blank}\n\u001b[38;5;7;48;5;8m/\u001b[38;5;15;48;5;15m{3*' '}\u001b[38;5;15;48;5;8m" f"sshyp ", f"copyright (c) 2021-2023 ", f"randall winkhart\u001b[38;5;15;48;5;15m{3*' '}" f"\u001b[38;5;7;48;5;8m/\u001b[0m\n{_blank}") print(f"\u001b[38;5;7;48;5;8m/\u001b[38;5;15;48;5;15m{20*' '}\u001b[38;5;15;48;5;8mversion 1.4.2" f"\u001b[38;5;15;48;5;15m{21*' '}\u001b[38;5;7;48;5;8m/\u001b[0m") print(f"\u001b[38;5;7;48;5;8m/\u001b[38;5;15;48;5;15m{9*' '}\u001b[38;5;15;48;5;8mthe argumentative " f"agronomist update\u001b[38;5;15;48;5;15m{10*' '}\u001b[38;5;7;48;5;8m/\u001b[0m") print(f"{_blank}\n{_border}\nsee https://github.com/rwinkhart/sshyp for more information\n") elif arguments[0] == 'license': print('\nThis program is free software: you can redistribute it and/or modify it under the terms\nof version 3 ' '(only) of the GNU General Public License as published by the Free Software Foundation.\n\nThis program ' 'is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;\nwithout even the implied ' 'warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.\nSee the GNU General Public License for' ' more details.\n\nhttps://opensource.org/licenses/GPL-3.0\n') elif arguments[0] == 'add' and device_type == 'client': print(f"""\n\u001b[1musage:\u001b[0m sshyp add \u001b[0m\n \u001b[1mflags:\u001b[0m add: password/-p{12*' '}add a password entry note/-n{16*' '}add a note entry folder/-f{14*' '}add a new folder for entries\n""") elif arguments[0] == 'edit' and device_type == 'client': print(f"""\n\u001b[1musage:\u001b[0m sshyp edit \u001b[0m\n \u001b[1mflags:\u001b[0m edit: rename/relocate/-r{5*' '}rename or relocate an entry username/-u{12*' '}change the username of an entry password/-p{12*' '}change the password of an entry url/-l{17*' '}change the url attached to an entry note/-n{16*' '}change the note attached to an entry\n""") elif arguments[0] == 'copy' and device_type == 'client': print(f"""\n\u001b[1musage:\u001b[0m sshyp copy \u001b[0m\n \u001b[1mflags:\u001b[0m copy: username/-u{12*' '}copy the username of an entry to your clipboard password/-p{12*' '}copy the password of an entry to your clipboard url/-l{17*' '}copy the url of an entry to your clipboard note/-n{16*' '}copy the note of an entry to your clipboard\n""") elif arguments[0] == 'gen' and device_type == 'client': print(f"""\n\u001b[1musage:\u001b[0m sshyp gen [flag]\u001b[0m\n \u001b[1mflags:\u001b[0m gen: update/-u{14*' '}generate a password for an existing entry\n""") elif arguments[0] == 'whitelist': if device_type == 'server': if arg_count > 1 and arguments[1] in ('add', 'del'): print("\nwhen adding or deleting devices from the whitelist,\nthe device ID must be specified as an" " argument\n\nexample: sshyp whitelist add 'this-is-a-quoted-device-id'") print(f"""\n\u001b[1musage:\u001b[0m sshyp whitelist [device id]\u001b[0m\n \u001b[1mflags:\u001b[0m whitelist: setup{18*' '}set up the quick-unlock whitelist list/-l{16*' '}view all registered device ids and their quick-unlock whitelist status add{20*' '}whitelist a device id for quick-unlock del{20*' '}remove a device id from the quick-unlock whitelist\n""") else: print('\n\u001b[38;5;9merror: argument (whitelist) only available on server\u001b[0m\n') else: print("\n\u001b[1msshyp ", "copyright (c) 2021-2023 ", """randall winkhart\u001b[0m this is free software, and you are welcome to redistribute it under certain conditions; this program comes with absolutely no warranty; type 'sshyp license' for details""") if device_type == 'client': print(f"""\n\u001b[1musage:\u001b[0m sshyp [ [option] [flag]] [option]\n \u001b[1moptions:\u001b[0m help/-h{17*' '}bring up this menu version/-v{14*' '}display sshyp version info settings{19*' '}configure sshyp add{21*' '}add an entry gen{21*' '}generate a new password edit{20*' '}edit an existing entry copy{20*' '}copy details of an entry to your clipboard shear{19*' '}delete an existing entry sync{20*' '}manually sync the entry directory via sshync \n\u001b[1mflags:\u001b[0m add: password/-p{12*' '}add a password entry note/-n{16*' '}add a note entry folder/-f{14*' '}add a new folder for entries edit: rename/relocate/-r{5*' '}rename or relocate an entry username/-u{12*' '}change the username of an entry password/-p{12*' '}change the password of an entry url/-l{17*' '}change the url attached to an entry note/-n{16*' '}change the note attached to an entry copy: username/-u{12*' '}copy the username of an entry to your clipboard password/-p{12*' '}copy the password of an entry to your clipboard url/-l{17*' '}copy the url of an entry to your clipboard note/-n{16*' '}copy the note of an entry to your clipboard gen: update/-u{14*' '}generate a password for an existing entry \n\u001b[1mtip 1:\u001b[0m you can quickly read an entry with 'sshyp ' \u001b[1mtip 2:\u001b[0m type 'sshyp' to view a list of saved entries\n""") # PORT START HELP-SERVER else: print(f"""\n\u001b[1musage:\u001b[0m sshyp