commit 2793e49ea9f72398ce16e7ebbfbf4f728792cb1a Author: Samer Afach Date: Tue Mar 27 22:16:42 2018 +0200 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/SamAuthenticator/AddKeyDialog.py b/SamAuthenticator/AddKeyDialog.py new file mode 100644 index 0000000..83bbb3f --- /dev/null +++ b/SamAuthenticator/AddKeyDialog.py @@ -0,0 +1,36 @@ +from PyQt5.QtWidgets import QDialog, QMainWindow, QWidget, QTableView, QGridLayout, QLineEdit, QAction, qApp, QInputDialog, \ + QMessageBox, QPushButton +from PyQt5.QtCore import pyqtSignal + + +class AddKeyDialog(QDialog): + new_key_to_add_signal = pyqtSignal(str, str) + + def __init__(self): + super().__init__() + self.main_layout = QGridLayout() + + self.name_field = QLineEdit() + self.name_field.setPlaceholderText("Name") + self.key_field = QLineEdit() + self.key_field.setPlaceholderText("Secret Key") + self.ok_button = QPushButton("OK") + + self.main_layout.addWidget(self.name_field, 0, 0, 1, 1) + self.main_layout.addWidget(self.key_field, 1, 0, 1, 1) + self.main_layout.addWidget(self.ok_button, 2, 0, 1, 1) + + self.setLayout(self.main_layout) + + self.ok_button.clicked.connect(self.ok_clicked) + + def get_new_key(self): + self.setModal(True) + self.name_field.setFocus() + self.name_field.setText("") + self.key_field.setText("") + self.open() + + def ok_clicked(self): + self.new_key_to_add_signal.emit(self.name_field.text(), self.key_field.text()) + self.close() diff --git a/SamAuthenticator/Authenticator.py b/SamAuthenticator/Authenticator.py new file mode 100644 index 0000000..e543ba3 --- /dev/null +++ b/SamAuthenticator/Authenticator.py @@ -0,0 +1,79 @@ +import onetimepass as otp +import json +import cryptography.fernet +import argon2 +import base64 +import os +import copy + +_salt = "V5RlhpuwACffXuUNLex7Al9ulPy4SRHbyaAxWigjX9Z01OVaCO" + + +def GetDefaultSalt(): + return copy.deepcopy(_salt) + +def encrypt_data(data_bytes, password, salt): + password_hash = argon2.argon2_hash(password=password, salt=salt) + encoded_hash = base64.urlsafe_b64encode(password_hash[:32]) + encryptor = cryptography.fernet.Fernet(encoded_hash) + return encryptor.encrypt(data_bytes) + + +def decrypt_data(cipher_bytes, password, salt): + password_hash = argon2.argon2_hash(password=password, salt=salt) + encoded_hash = base64.urlsafe_b64encode(password_hash[:32]) + decryptor = cryptography.fernet.Fernet(encoded_hash) + return decryptor.decrypt(cipher_bytes) + + +def write_keys_to_file(auth_keys, password, file_name="data.dat"): + backup_file = file_name + ".bak" + if os.path.exists(file_name): + os.rename(file_name, backup_file) + with open(file_name, 'wb') as f: + f.write(encrypt_data(auth_keys.dump_data().encode(), password, _salt)) + if os.path.exists(backup_file): + os.remove(backup_file) + + +def read_keys_from_file(password, file_name="data.dat"): + with open(file_name, 'rb') as f: + ciphered_data = f.read() + readable_data = decrypt_data(ciphered_data, password, _salt) + keys_object = AuthenticatorKeys() + keys_object.read_dump(readable_data.decode()) + return keys_object + + +class AuthenticatorKeys: + def __init__(self): + self.data = {'secrets': {}, + 'version': '1.0'} + + def set_secret(self, name, secret): + self.data['secrets'][name] = {'secret': secret} + + def get_secret(self, name): + return self.data['secrets'][name]['secret'] + + def get_token(self, name): + return otp.get_totp(self.get_secret(name)) + + def remove_secret(self, name): + del self.data['secrets'][name] + + def get_names(self): + return self.data['secrets'].keys() + + def get_size(self): + return len(self.data['secrets']) + + def dump_data(self): + return json.dumps(self.data) + + def read_dump(self, dump_data): + self.data = json.loads(dump_data) + + @staticmethod + def test_secret_validity(secret): + otp.get_totp(secret) diff --git a/SamAuthenticator/AuthenticatorGUIApp.py b/SamAuthenticator/AuthenticatorGUIApp.py new file mode 100644 index 0000000..75c6a2b --- /dev/null +++ b/SamAuthenticator/AuthenticatorGUIApp.py @@ -0,0 +1,13 @@ +from PyQt5.QtWidgets import QApplication +import SamAuthenticator.AuthenticatorWindow as MainWindow +import sys + + +def start(): + app = QApplication(sys.argv) + + w = MainWindow.AuthenticatorGUI() + + w.show() + + return app.exec_() diff --git a/SamAuthenticator/AuthenticatorWindow.py b/SamAuthenticator/AuthenticatorWindow.py new file mode 100644 index 0000000..a684e22 --- /dev/null +++ b/SamAuthenticator/AuthenticatorWindow.py @@ -0,0 +1,231 @@ +from PyQt5.QtWidgets import QMainWindow, QWidget, QGridLayout, QLineEdit, QAction, qApp, QInputDialog, \ + QMessageBox, QFileDialog +from PyQt5.QtCore import QSortFilterProxyModel, pyqtSlot, QSettings +from PyQt5.Qt import Qt, QIcon, QSizePolicy +import SamAuthenticator.KeysDataModel as model +import SamAuthenticator.KeyDataView as dataview +import SamAuthenticator.Authenticator as auth +import SamAuthenticator.TrayIcon as tray +import functools +import os + + +class AuthenticatorGUI(QMainWindow): + new_key_to_add_slot = pyqtSlot(str, str) + + def __init__(self): + super().__init__() + self.data_file_name = "data.dat" + + self.load_geometry() + + current_path = os.path.dirname(os.path.abspath(__file__)) + main_icon = QIcon(os.path.join(current_path, "images/key.png")) + # self.setWindowIcon(main_icon) + + self.tray_icon = tray.SamAuthenticatorTrayIcon(self, main_icon) + self.tray_icon.show() + + self.mainWidget = QWidget() + self.setCentralWidget(self.mainWidget) + + self.main_layout = QGridLayout() + self.filter_line_edit = QLineEdit() + self.filter_line_edit.setPlaceholderText("Enter filter (Ctrl+F)") + self.keys_table_view = dataview.KeyDataView() + + self.keys_data_model = None + self.keys_data_model_proxy = None + self.setup_data_model(auth.AuthenticatorKeys()) + + self.main_layout.addWidget(self.filter_line_edit, 1, 0, 1, 2) + self.main_layout.addWidget(self.keys_table_view, 2, 0, 1, 2) + + self.mainWidget.setLayout(self.main_layout) + + self.filter_line_edit.textChanged.connect(self.set_filter_string) + + self.add_menus() + self.add_toolbar() + + self.setWindowTitle('Sam Authenticator') + self.show() + self.load_data_from_default_path() + + def set_filter_string(self, filter_str): + if self.keys_data_model_proxy is not None: + self.keys_data_model_proxy.setFilterWildcard(filter_str) + + def set_data_file_name(self, file_name): + self.data_file_name = file_name + + def import_data(self): + file_name = QFileDialog.getOpenFileName(self, "Import data from...", "", "Encrypted data (*.dat, *.*)") + # if a file is chosen + if file_name[1]: + if os.path.exists(file_name[0]): + self.load_data_from(file_name[0]) + else: + QMessageBox.warning(self, "File not found", "The path you chose doesn't contain a file.") + + def load_data_from_default_path(self): + if not os.path.exists(self.data_file_name): + return + + self.load_data_from(self.data_file_name) + self.filter_line_edit.clear() + + def decrypt_data_file(self): + source_file = QFileDialog.getOpenFileName(self, "Choose the file to decrypt...", "", "Encrypted data (*.dat, *.*)") + # if a file is chosen + if not source_file[1]: + return + + if not os.path.exists(source_file[0]): + QMessageBox.warning(self, "File not found", "The path you chose doesn't contain a file.") + return + + dest_file = QFileDialog.getSaveFileName(self, "Save decrypted file as...", "", "JSON file (.json)") + # if a file is chosen + if not dest_file[1]: + return + + password_from_dialog = QInputDialog.getText(self, "Input encryption password", + "Encryption password:", + QLineEdit.Password, "") + ok_pressed = password_from_dialog[1] + if not ok_pressed: + return + + try: + with open(source_file[0], 'rb') as f_load: + ciphered_data = f_load.read() + readable_data = auth.decrypt_data(ciphered_data, password_from_dialog[0], auth.GetDefaultSalt()) + + with open(dest_file[0], 'wb') as f_save: + f_save.write(readable_data) + + except Exception as e: + QMessageBox.warning(self, "Error", "Decryption failed. " + str(e)) + + def load_data_from(self, data_file_path): + password_from_dialog = QInputDialog.getText(self, "Input encryption password", + "Encryption password:", + QLineEdit.Password, "") + ok_pressed = password_from_dialog[1] + if not ok_pressed: + return + + try: + keys = auth.read_keys_from_file(password_from_dialog[0], data_file_path) + self.setup_data_model(keys) + except Exception as e: + QMessageBox.warning(self, "Unable to read data", "Unable to read data. " + str(e)) + + def save_data(self, data_file): + pass_from_dialog = QInputDialog.getText(self, "Input encryption password", + "New encryption password:", + QLineEdit.Password, "") + ok_pressed = pass_from_dialog[1] + if not ok_pressed: + return + + password = pass_from_dialog[0] + + if self.keys_data_model is not None: + auth.write_keys_to_file(self.keys_data_model.getKeysObject(), password, data_file) + else: + QMessageBox.warning(self, "No data loaded", "Data should be loaded before attempting to save it") + + def save_data_as(self): + file_name = QFileDialog.getSaveFileName(self, "Save data as...", "", "Encrypted data (*.dat)") + # if a file is chosen + if file_name[1]: + self.save_data(file_name[0]) + + def add_menus(self): + exit_act = QAction('&Exit', self) + exit_act.setShortcut('Ctrl+Q') + exit_act.setStatusTip('Exit application') + exit_act.triggered.connect(qApp.quit) + + reload_data_act = QAction('&Reload data from default location (' + self.data_file_name + ')' , self) + reload_data_act.setStatusTip('Reload data from file') + reload_data_act.setShortcut('Ctrl+R') + reload_data_act.triggered.connect(self.load_data_from_default_path) + + save_data_act = QAction('&Save data to default location (' + self.data_file_name + ')', self) + save_data_act.setShortcut('Ctrl+S') + save_data_act.setStatusTip('Save data to the default path') + save_data_act.triggered.connect(functools.partial(self.save_data, self.data_file_name)) + + save_data_as_act = QAction('&Save data as...', self) + save_data_as_act.setShortcut('Ctrl+Shift+S') + save_data_as_act.setStatusTip('Save data to...') + save_data_as_act.triggered.connect(self.save_data_as) + + import_data_act = QAction('&Import data from...' , self) + import_data_act.setStatusTip('Import data from a file...') + import_data_act.triggered.connect(self.import_data) + + decrypt_data_file_act = QAction('&Decrypt a data file', self) + decrypt_data_file_act.setStatusTip('Decrypt a data file to raw text (unsafe)') + decrypt_data_file_act.triggered.connect(self.decrypt_data_file) + + QAction("File") + file_menu = self.menuBar().addMenu('&File') + file_menu.addAction(reload_data_act) + file_menu.addAction(save_data_act) + file_menu.addSeparator() + file_menu.addAction(import_data_act) + file_menu.addAction(save_data_as_act) + file_menu.addSeparator() + file_menu.addAction(decrypt_data_file_act) + file_menu.addSeparator() + file_menu.addAction(exit_act) + + def add_toolbar(self): + toolbar = self.addToolBar("File") + + left_spacer = QWidget() + left_spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + right_spacer = QWidget() + right_spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + current_path = os.path.dirname(os.path.abspath(__file__)) + toolbar.addWidget(left_spacer) + add_act = QAction(QIcon(os.path.join(current_path, "images/add.png")), "Add new key", self) + add_act.setShortcut('Ctrl+A') + toolbar.addAction(add_act) + remove_act = QAction(QIcon(os.path.join(current_path, "images/delete.png")), "Remove selected key", self) + remove_act.setShortcut('Ctrl+D') + toolbar.addAction(remove_act) + toolbar.addWidget(right_spacer) + + add_act.triggered.connect(self.keys_table_view.add_new_key_from_dialog) + remove_act.triggered.connect(self.keys_table_view.remove_row) + + def setup_data_model(self, keys): + self.keys_data_model_proxy = QSortFilterProxyModel() + self.keys_data_model_proxy.setFilterCaseSensitivity(Qt.CaseInsensitive) + self.keys_data_model = model.AuthenticatorKeysDataModel(keys) + self.keys_data_model_proxy.setSourceModel(self.keys_data_model) + self.keys_table_view.setModel(self.keys_data_model_proxy) + + def keyPressEvent(self, event): + if event.key() == Qt.Key_C and event.modifiers() & Qt.ControlModifier: + self.keys_table_view.copy_selected() + if event.key() == Qt.Key_F and event.modifiers() & Qt.ControlModifier: + self.filter_line_edit.setFocus() + self.filter_line_edit.selectAll() + if event.key() == Qt.Key_Escape: + self.filter_line_edit.clear() + + def load_geometry(self): + settings = QSettings("SamApps", "SamAuthenticator") + self.restoreGeometry(settings.value("geometry")) + + def closeEvent(self, event): + settings = QSettings("SamApps", "SamAuthenticator") + settings.setValue("geometry", self.saveGeometry()) + super().closeEvent(event) diff --git a/SamAuthenticator/KeyDataView.py b/SamAuthenticator/KeyDataView.py new file mode 100644 index 0000000..98cb803 --- /dev/null +++ b/SamAuthenticator/KeyDataView.py @@ -0,0 +1,70 @@ +from PyQt5.QtWidgets import QTableView, QAction, QMenu, QMessageBox, qApp +from PyQt5.Qt import Qt, QHeaderView, QCursor +from SamAuthenticator.AddKeyDialog import AddKeyDialog +import SamAuthenticator.KeysDataModel as model + + +class KeyDataView(QTableView): + def __init__(self): + super().__init__() + + self.add_key_dialog = AddKeyDialog() + self.add_key_dialog.new_key_to_add_signal.connect(self.add_new_key) + + self.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.setSelectionBehavior(QTableView.SelectRows) + self.setSelectionMode(QTableView.SingleSelection) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.contextMenuEvent) + + def contextMenuEvent(self, event): + table_context_menu = QMenu(self) + menu = QMenu(self) + copy_action = QAction('Copy', self) + copy_action.triggered.connect(self.copy_selected) + add_key_action = QAction('Add new key', self) + add_key_action.triggered.connect(self.add_new_key_from_dialog) + remove_key_action = QAction('Remove key', self) + remove_key_action.triggered.connect(self.remove_row) + + menu.addAction(copy_action) + menu.addSeparator() + menu.addAction(add_key_action) + if len(self.selectedIndexes()) > 0: + menu.addAction(remove_key_action) + + menu.popup(QCursor.pos()) + + def copy_selected(self): + cells = self.selectedIndexes() + if len(cells) == 0: + return + if len(cells) != self.model().columnCount(): + return + for el in cells: + if el.column() == model.AuthenticatorKeysDataModel.TOKEN_COL: + qApp.clipboard().setText(str(el.data())) + + def remove_row(self): + cells = self.selectedIndexes() + if len(cells) == 0: + return + if len(cells) != self.model().columnCount(): + return + for el in cells: + row = el.row() + self.model().removeRow(row) + return + + def add_new_key_from_dialog(self): + self.add_key_dialog.get_new_key() + + def add_new_key(self, name, secret): + try: + self.model().getKeysObject().test_secret_validity(secret) + self.model().getKeysObject().set_secret(name, secret) + self.model().refreshAll() + except Exception as e: + self.add_key_dialog.close() + QMessageBox.warning(self, "Error", "Testing the secret you entered failed. " + str(e)) + return diff --git a/SamAuthenticator/KeysDataModel.py b/SamAuthenticator/KeysDataModel.py new file mode 100644 index 0000000..e5f76fd --- /dev/null +++ b/SamAuthenticator/KeysDataModel.py @@ -0,0 +1,106 @@ +from PyQt5 import QtCore +from PyQt5.Qt import Qt, pyqtSignal +import SamAuthenticator.Authenticator as auth + + +class AuthenticatorKeysDataModel(QtCore.QAbstractTableModel): + tokensUpdatedSignal = pyqtSignal() + + NAME_COL = 0 + TOKEN_COL = 1 + + UpdateTimerPeriod = 2000 + + def __init__(self, authenticator_keys: auth.AuthenticatorKeys, parent=None): + QtCore.QAbstractTableModel.__init__(self, parent) + self._keys = authenticator_keys + self.updateTimer = QtCore.QTimer() + self.updateTimer.start(self.UpdateTimerPeriod) + self.updateTimer.timeout.connect(self.updateTokens) + + def getKeysObject(self): + return self._keys + + def rowCount(self, parent=None): + return self._keys.get_size() + + def columnCount(self, parent=None): + return 2 + + def data(self, index, role=QtCore.Qt.DisplayRole): + if index.isValid(): + if role == QtCore.Qt.EditRole: + self.updateTimer.stop() + if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: + all_names = sorted(list(self._keys.get_names())) + if index.column() == self.NAME_COL: + return all_names[index.row()] + if index.column() == self.TOKEN_COL: + try: + return str(self._keys.get_token(all_names[index.row()])).zfill(6) + except Exception as e: + return "" + if role == Qt.TextAlignmentRole: + return Qt.AlignCenter + return None + + def setData(self, index, value, role=QtCore.Qt.DisplayRole): + try: + if index.column() == self.NAME_COL: + if index.data() != value: + old_name = str(index.data()) + new_name = str(value) + self._keys.set_secret(new_name, self._keys.get_secret(old_name)) + self._keys.remove_secret(old_name) + self.dataChanged.emit(index, index) + return True + finally: + self.updateTimer.start(self.UpdateTimerPeriod) + return False + + def headerData(self, rowcol, orientation, role=QtCore.Qt.DisplayRole): + if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole: + if rowcol == self.NAME_COL: + return "Name" + if rowcol == self.TOKEN_COL: + return "Token" + if role == Qt.TextAlignmentRole: + return Qt.AlignCenter + return None + + def flags(self, index): + flags = super(self.__class__, self).flags(index) + if index.isValid(): + l = sorted(list(self._keys.get_names())) + if index.column() == self.NAME_COL: + flags |= QtCore.Qt.ItemIsEditable + flags |= QtCore.Qt.ItemIsSelectable + flags |= QtCore.Qt.ItemIsEnabled + return flags + if index.column() == self.TOKEN_COL: + return flags + + # flags |= QtCore.Qt.ItemIsEditable + # flags |= QtCore.Qt.ItemIsSelectable + # flags |= QtCore.Qt.ItemIsEnabled + # flags |= QtCore.Qt.ItemIsDragEnabled + # flags |= QtCore.Qt.ItemIsDropEnabled + return flags + + def updateTokens(self): + self.dataChanged.emit(self.index(1, 0), self.index(1, self._keys.get_size() - 1)) + self.tokensUpdatedSignal.emit() + + def refreshAll(self): + self.beginResetModel() + self.endResetModel() + + def removeRows(self, start_row, count, parent=None, *args, **kwargs): + self.beginRemoveRows(QtCore.QModelIndex(), start_row, start_row + count - 1) + to_remove = [] + for i in range(start_row, start_row + count): + to_remove.append(self.data(self.index(i, self.NAME_COL), role=QtCore.Qt.DisplayRole)) + for name in to_remove: + self._keys.remove_secret(name) + self.endRemoveRows() + return True diff --git a/SamAuthenticator/TrayIcon.py b/SamAuthenticator/TrayIcon.py new file mode 100644 index 0000000..2ce0af1 --- /dev/null +++ b/SamAuthenticator/TrayIcon.py @@ -0,0 +1,22 @@ +from PyQt5.QtWidgets import QSystemTrayIcon, QMenu, qApp +from PyQt5.Qt import QIcon +import os + + +class SamAuthenticatorTrayIcon(QSystemTrayIcon): + IconTooltip_normal = "Sam Authenticator" + + def __init__(self, main_win, icon, parent=None): + QSystemTrayIcon.__init__(self, parent) + + self.setIcon(icon) + + self.main_win = main_win + self.menu = QMenu(parent) + self.show_action = self.menu.addAction("Show") + self.menu.addSeparator() + self.exit_action = self.menu.addAction("Exit") + self.setContextMenu(self.menu) + self.exit_action.triggered.connect(qApp.quit) + self.show_action.triggered.connect(self.main_win.raise_) + self.setToolTip(self.IconTooltip_normal) diff --git a/SamAuthenticator/__init__.py b/SamAuthenticator/__init__.py new file mode 100644 index 0000000..5b985e1 --- /dev/null +++ b/SamAuthenticator/__init__.py @@ -0,0 +1,2 @@ +from SamAuthenticator.Authenticator import * +from SamAuthenticator.AuthenticatorGUIApp import * diff --git a/SamAuthenticator/images/add.png b/SamAuthenticator/images/add.png new file mode 100644 index 0000000..8e4e895 Binary files /dev/null and b/SamAuthenticator/images/add.png differ diff --git a/SamAuthenticator/images/delete.png b/SamAuthenticator/images/delete.png new file mode 100644 index 0000000..003174d Binary files /dev/null and b/SamAuthenticator/images/delete.png differ diff --git a/SamAuthenticator/images/key.png b/SamAuthenticator/images/key.png new file mode 100644 index 0000000..54f7281 Binary files /dev/null and b/SamAuthenticator/images/key.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..c29d3de --- /dev/null +++ b/main.py @@ -0,0 +1,4 @@ +import SamAuthenticator as auth + +auth.start() +