diff --git a/nitrokeyapp/device_data.py b/nitrokeyapp/device_data.py index 3a9b8fde..0a70dd03 100644 --- a/nitrokeyapp/device_data.py +++ b/nitrokeyapp/device_data.py @@ -45,6 +45,7 @@ def __init__(self, device: TrussedBase, using_ccid: bool) -> None: self._device = device self._using_ccid = using_ccid + def __repr__(self) -> str: fields = { "path": self.path, diff --git a/nitrokeyapp/fido2_tab/__init__.py b/nitrokeyapp/fido2_tab/__init__.py index b39df4cf..45e083e2 100644 --- a/nitrokeyapp/fido2_tab/__init__.py +++ b/nitrokeyapp/fido2_tab/__init__.py @@ -62,6 +62,8 @@ def _adapt_ui(self) -> None: self.ui.btn_edit.hide() self.ui.btn_abort.hide() self.ui.is_protected.hide() + self.ui.btn_backup.hide() + self.ui.btn_restore.hide() self.ui.name.hide() self.ui.algorithm_tab.hide() diff --git a/nitrokeyapp/secrets_tab/__init__.py b/nitrokeyapp/secrets_tab/__init__.py index 60bca8f6..7a8a53c8 100644 --- a/nitrokeyapp/secrets_tab/__init__.py +++ b/nitrokeyapp/secrets_tab/__init__.py @@ -1,4 +1,5 @@ import binascii +import json import logging from base64 import b32decode, b32encode from datetime import datetime @@ -8,13 +9,14 @@ from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot from PySide6.QtGui import QGuiApplication -from PySide6.QtWidgets import QLineEdit, QListWidgetItem, QWidget +from PySide6.QtWidgets import QFileDialog, QLineEdit, QListWidgetItem, QWidget from nitrokeyapp.common_ui import CommonUi from nitrokeyapp.device_data import DeviceData from nitrokeyapp.qt_utils_mix_in import QtUtilsMixIn from nitrokeyapp.worker import Worker +from .backup_restore_ui import BackupRestoreAction, open_backup_restore_ui from .data import Credential, OtherKind, OtpData, OtpKind from .worker import SecretsWorker @@ -65,6 +67,8 @@ class SecretsTab(QtUtilsMixIn, QWidget): trigger_refresh_credentials = Signal(DeviceData, bool) trigger_get_credential = Signal(DeviceData, Credential) trigger_edit_credential = Signal(DeviceData, Credential, bytes, bytes) + trigger_backup_credential = Signal(DeviceData, bool, bool) + trigger_restore_credential = Signal(DeviceData, str, str) def __init__(self, parent: QWidget) -> None: QWidget.__init__(self, parent) @@ -84,9 +88,13 @@ def __init__(self, parent: QWidget) -> None: self.trigger_refresh_credentials.connect(self._worker.refresh_credentials) self.trigger_get_credential.connect(self._worker.get_credential) self.trigger_edit_credential.connect(self._worker.edit_credential) + self.trigger_backup_credential.connect(self._worker.backup_credential) + self.trigger_restore_credential.connect(self._worker.restore_credential) self._worker.pin_cache.pin_cleared.connect(self.common_ui.info.pin_cleared) self._worker.pin_cache.pin_cleared.connect(lambda: self.uncheck_checkbox(True)) + self._worker.credential_bkp.connect(self.save_credential_backup) + self._worker.credential_restore.connect(self.on_credential_restored) self._worker.pin_cache.pin_cached.connect(self.common_ui.info.pin_cached) self.common_ui.info.pin_pressed.connect(self._worker.pin_cache.clear) @@ -112,11 +120,13 @@ def __init__(self, parent: QWidget) -> None: self.clipboard = QGuiApplication.clipboard() self.originalText = self.clipboard.text() + self.backup_content: str = "" # self.ui === self -> this tricks mypy due to monkey-patching self self.ui = self.load_ui("secrets_tab.ui", self) icon_copy = self.get_qicon("content_copy.svg") + self.icon_copy = icon_copy icon_refresh = self.get_qicon("OTP_generate.svg") icon_edit = self.get_qicon("edit.svg") icon_visibility = self.get_qicon("visibility_off.svg") @@ -169,6 +179,8 @@ def __init__(self, parent: QWidget) -> None: } self.ui.btn_add.pressed.connect(self.add_new_credential) + self.ui.btn_backup.pressed.connect(self.backup_credentials) + self.ui.btn_restore.pressed.connect(self.restore_credentials) self.ui.btn_abort.pressed.connect(lambda: self.show_secrets(True)) self.ui.btn_save.pressed.connect(self.save_credential) self.ui.btn_edit.pressed.connect(self.prepare_edit_credential) @@ -301,6 +313,38 @@ def otp_generated(self, data: OtpData) -> None: self.ui.otp_timeout_progress.setVisible(data.validity is not None) self.ui.otp.show() + @Slot() + def backup_credentials(self) -> None: + if not self.data: + return + self._open_backup_restore(BackupRestoreAction.BACKUP, "Backup passwords") + + @Slot(str) + def save_credential_backup(self, credential_list_formatted: str) -> None: + path, _ = QFileDialog.getSaveFileName( + self, "Save Credential Backup", "credential_backup.json", "JSON Files (*.json)" + ) + if path: + with open(path, "w") as f: + f.write(credential_list_formatted) + + @Slot() + def restore_credentials(self) -> None: + if not self.data: + return + path, _ = QFileDialog.getOpenFileName( + self, "Open Credential Backup", "", "JSON Files (*.json)" + ) + if not path: + return + with open(path, "r") as f: + self.backup_content = f.read() + self._open_backup_restore(BackupRestoreAction.RESTORE, f"Restore passwords from {path}") + + @Slot() + def on_credential_restored(self) -> None: + self.refresh_credential_list() + def add_credential(self, credential: Credential) -> QListWidgetItem: icon = "lock" if credential.protected else "lock_open" item = QListWidgetItem(credential.name) @@ -875,3 +919,31 @@ def uncheck_checkbox(self, uncheck: bool) -> None: def generate_hmac(self) -> None: secret = b32encode(randbytes(20)) self.ui.otp.setText(secret.decode()) + + def _open_backup_restore(self, action: BackupRestoreAction, title: str) -> None: + ui = open_backup_restore_ui(action, title, self.icon_copy, self) + ui.update_status("Idle") + + self._worker.passphrase_ready.connect(ui.update_passphrase) + self._worker.backup_progress.connect(ui.update_fields) + self._worker.restore_progress.connect(ui.update_fields) + self._worker.credential_bkp.connect(lambda _: ui.update_status("Backup complete")) + self._worker.credential_restore.connect(lambda: ui.update_status("Restore complete")) + + def on_begin(cleartext: bool, passphrase: str, action_name: BackupRestoreAction) -> None: + ui.update_status("Working... Press your Nitrokey if it blinks.") + if action_name == BackupRestoreAction.BACKUP: + self.trigger_backup_credential.emit(self.data, True, cleartext) + else: + try: + tempdata = json.loads(self.backup_content) + if "EncryptedCXF" in tempdata and not passphrase: + ui.update_status("The backup is encrypted. Please enter the passphrase.") + else: + self.trigger_restore_credential.emit( + self.data, self.backup_content, passphrase + ) + except json.JSONDecodeError as e: + ui.update_status(f"Invalid backup file {e}") + + ui.begin(on_begin) diff --git a/nitrokeyapp/secrets_tab/backup_restore_ui.py b/nitrokeyapp/secrets_tab/backup_restore_ui.py new file mode 100644 index 00000000..4a4dd277 --- /dev/null +++ b/nitrokeyapp/secrets_tab/backup_restore_ui.py @@ -0,0 +1,168 @@ +from enum import Enum +from typing import Callable, List, Optional + +from PySide6.QtGui import QGuiApplication, QIcon +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QPushButton, + QStatusBar, + QToolButton, + QVBoxLayout, + QWidget, +) + + +class BackupRestoreAction(str, Enum): + BACKUP = "backup" + RESTORE = "restore" + + +class BackupRestoreUi(QDialog): + def __init__( + self, name: BackupRestoreAction, title: str, icon: QIcon, parent: Optional[QWidget] = None + ) -> None: + super().__init__(parent) + + self.name = name + self.setWindowTitle(name.capitalize()) + + self.action_edit = QLabel(f"Action: {title}") + + action_layout = QHBoxLayout() + action_layout.addWidget(self.action_edit) + + self.cleartext_checkbox = QCheckBox("Cleartext") + self.cleartext_checkbox.setToolTip( + "This option disables encryption of the generated backup and is discouraged. Only use for interoperability with other password managers." + ) + self.cleartext_checkbox.setVisible(name == BackupRestoreAction.BACKUP) + + self.passphrase_edit = QLineEdit() + self.passphrase_edit.setToolTip( + "Passphrase is automatically created during an encrypted backup." + ) + self.passphrase_edit.setReadOnly(name == BackupRestoreAction.BACKUP) + + self.copy_passphrase_button = QToolButton() + self.copy_passphrase_button.setIcon(icon) + self.copy_passphrase_button.setToolTip("Copy passphrase") + + self.begin_button = QPushButton("Begin") + + passphrase_layout = QHBoxLayout() + passphrase_layout.setContentsMargins(0, 0, 0, 0) + passphrase_layout.addWidget(self.passphrase_edit) + if self.name == BackupRestoreAction.BACKUP: + passphrase_layout.addWidget(self.copy_passphrase_button) + + middle_layout = QHBoxLayout() + middle_layout.addWidget(self.cleartext_checkbox) + middle_layout.addStretch(1) + middle_layout.addWidget(QLabel("Passphrase")) + middle_layout.addLayout(passphrase_layout, 1) + middle_layout.addWidget(self.begin_button) + + self.copy_passphrase_button.clicked.connect(self.copy_passphrase) + + self.successful_list = QListWidget() + self.failed_list = QListWidget() + self.skipped_list = QListWidget() + + self.failed_name = ( + "Not passwords" if name == BackupRestoreAction.BACKUP else "Already exists" + ) + + self.successful_label = QLabel("Successful (0)") + self.successful_label.setToolTip("Operation on these credentials completed successfully") + + self.failed_label = QLabel(f"{self.failed_name} (0)") + self.failed_label.setToolTip( + "Credentials without a password (for example OTP only) are skipped during the backup as they cannot be extracted from the device." + if name == BackupRestoreAction.BACKUP + else "Credentials not imported because a credential with same label already exists on the device" + ) + + self.skipped_label = QLabel("Skipped (0)") + self.skipped_label.setToolTip( + "Credentials are skipped if they are PIN protected but PIN is not supplied." + ) + + lists_layout = QHBoxLayout() + for label, widget in ( + (self.successful_label, self.successful_list), + (self.failed_label, self.failed_list), + (self.skipped_label, self.skipped_list), + ): + column = QVBoxLayout() + column.addWidget(label) + column.addWidget(widget) + lists_layout.addLayout(column) + + self.status_edit = QStatusBar() + # self.status_edit.setReadOnly(True) + + status_layout = QHBoxLayout() + status_layout.addWidget(QLabel("Status")) + status_layout.addWidget(self.status_edit) + + layout = QVBoxLayout() + layout.addLayout(action_layout) + layout.addSpacing(16) + layout.addLayout(middle_layout) + layout.addLayout(lists_layout) + layout.addLayout(status_layout) + self.setLayout(layout) + + self.resize(900, 520) + + def copy_passphrase(self) -> None: + if self.passphrase_edit.text() != "": + QGuiApplication.clipboard().setText(self.passphrase_edit.text()) + self.update_status("Passphrase copied!") + else: + self.update_status("Nothing to copy!") + + def update_fields( + self, success_list: List[bytes], failed_list: List[bytes], skipped_list: List[bytes] + ) -> None: + self.successful_list.clear() + self.failed_list.clear() + self.skipped_list.clear() + + for item in success_list: + self.successful_list.addItem(item.decode("utf-8", errors="ignore")) + + for item in failed_list: + self.failed_list.addItem(item.decode("utf-8", errors="ignore")) + + for item in skipped_list: + self.skipped_list.addItem(item.decode("utf-8", errors="ignore")) + + self.successful_label.setText(f"Successful ({len(success_list)})") + self.failed_label.setText(f"{self.failed_name} ({len(failed_list)})") + self.skipped_label.setText(f"Skipped ({len(skipped_list)})") + + def update_status(self, status: str) -> None: + self.status_edit.showMessage(status) + + def update_passphrase(self, passphrase: str) -> None: + self.passphrase_edit.setText(passphrase) + + def begin(self, callback: Callable[[bool, str, BackupRestoreAction], None]) -> None: + def on_clicked() -> None: + callback(self.cleartext_checkbox.isChecked(), self.passphrase_edit.text(), self.name) + + self.begin_button.clicked.connect(on_clicked) + + +def open_backup_restore_ui( + action: BackupRestoreAction, title: str, icon: QIcon, parent: Optional[QWidget] = None +) -> BackupRestoreUi: + ui = BackupRestoreUi(action, title, icon, parent) + ui.show() + return ui diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index 4504a7b9..34228da2 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -1,10 +1,16 @@ +import json import logging from dataclasses import dataclass from datetime import datetime from typing import Dict, Optional from nitrokey.nk3 import NK3 -from nitrokey.nk3.secrets_app import SecretsApp, SecretsAppException +from nitrokey.nk3.secrets_app import ( + CXFBackupCombined, + CXFRestoreCombined, + SecretsApp, + SecretsAppException, +) from nitrokey.trussed import Uuid from PySide6.QtCore import QObject, Signal, Slot from PySide6.QtWidgets import QWidget @@ -620,6 +626,138 @@ def get_credential(self) -> None: self.received_credential.emit(cred) +class BackupCredentialJob(Job): + credential_bkp = Signal(str) + passphrase_ready = Signal(str) + backup_progress = Signal(list, list, list) + + def __init__( + self, + common_ui: CommonUi, + pin_cache: PinCache, + pin_ui: PinUi, + data: DeviceData, + pin_protected: bool, + cleartext: bool, + ) -> None: + super().__init__(common_ui) + self.pin_cache = pin_cache + self.pin_ui = pin_ui + self.data = data + self.pin_protected = pin_protected + self.cleartext = cleartext + self.credential_bkp.connect(lambda _: self.finished.emit()) + + def run(self) -> None: + with self.touch_prompt(): + if self.pin_protected: + verify_pin_job = VerifyPinJob( + self.common_ui, self.pin_cache, self.pin_ui, self.data + ) + verify_pin_job.pin_verified.connect(self.get_credential_backup) + self.spawn(verify_pin_job) + else: + self.get_credential_backup() + + def _progress_callback(self, total: int, status: CXFRestoreCombined) -> None: + self.backup_progress.emit( + list(status.successful_credentials), + list(status.failed_credentials), + list(status.skipped_credentials), + ) + + @Slot() + def get_credential_backup(self) -> None: + with self.data.open() as device: + if not isinstance(device, NK3): + return + secrets = SecretsApp(device) + pin = self.pin_cache.get(self.data) or "" + encryption = not self.cleartext + + export_combined = secrets.export_cxf( + encryption=encryption, callback=self._progress_callback, password=pin + ) + + self.passphrase_ready.emit(export_combined.passphrase if encryption else "") + + results = export_combined.results + self.backup_progress.emit( + list(results.successful_credentials) if results else [], + list(results.failed_credentials) if results else [], + list(results.skipped_credentials) if results else [], + ) + + credential_list_formatted = json.dumps(export_combined.payload, indent=4) + self.credential_bkp.emit(credential_list_formatted) + + +class RestoreCredentialJob(Job): + credential_restore = Signal() + restore_progress = Signal(list, list, list) + + def __init__( + self, + common_ui: CommonUi, + pin_cache: PinCache, + pin_ui: PinUi, + data: DeviceData, + credential_backup: str, + passphrase: str, + ) -> None: + super().__init__(common_ui) + self.pin_cache = pin_cache + self.pin_ui = pin_ui + self.data = data + self.credential_backup = credential_backup + self.passphrase = passphrase + self.credential_restore.connect(lambda: self.finished.emit()) + + def run(self) -> None: + with self.touch_prompt(): + verify_pin_job = VerifyPinJob(self.common_ui, self.pin_cache, self.pin_ui, self.data) + verify_pin_job.pin_verified.connect(self.restore_credential_backup) + self.spawn(verify_pin_job) + + def _progress_callback(self, total: int, status: CXFRestoreCombined) -> None: + self.restore_progress.emit( + list(status.successful_credentials), + list(status.failed_credentials), + list(status.skipped_credentials), + ) + + @Slot(bool) + def restore_credential_backup(self, successful: bool) -> None: + if not successful: + self.credential_restore.emit() + return + + try: + cred_bkp = json.loads(self.credential_backup) + except json.JSONDecodeError as e: + self.trigger_exception(e) + return + + with self.data.open() as device: + if not isinstance(device, NK3): + return + secrets = SecretsApp(device) + pin = self.pin_cache.get(self.data) or "" + + import_content = CXFBackupCombined(payload=cred_bkp, passphrase=self.passphrase) + restore_result = secrets.import_cxf( + import_content, callback=self._progress_callback, password=pin + ) + + self.restore_progress.emit( + list(restore_result.successful_credentials), + list(restore_result.failed_credentials), + list(restore_result.skipped_credentials), + ) + + self.credential_restore.emit() + + class SecretsWorker(Worker): # TODO: remove DeviceData from signatures @@ -631,6 +769,11 @@ class SecretsWorker(Worker): device_checked = Signal(bool) otp_generated = Signal(OtpData) received_credential = Signal(Credential) + credential_bkp = Signal(str) + credential_restore = Signal() + passphrase_ready = Signal(str) + backup_progress = Signal(list, list, list) + restore_progress = Signal(list, list, list) def __init__(self, common_ui: CommonUi, app_widget: QWidget) -> None: super().__init__(common_ui) @@ -686,3 +829,22 @@ def edit_credential( ) job.credential_edited.connect(self.credential_edited) self.run(job) + + @Slot(DeviceData, bool, bool) + def backup_credential(self, data: DeviceData, pin_protected: bool, cleartext: bool) -> None: + job = BackupCredentialJob( + self.common_ui, self.pin_cache, self.pin_ui, data, pin_protected, cleartext + ) + job.credential_bkp.connect(self.credential_bkp) + job.passphrase_ready.connect(self.passphrase_ready) + job.backup_progress.connect(self.backup_progress) + self.run(job) + + @Slot(DeviceData, str, str) + def restore_credential(self, data: DeviceData, backup_content: str, passphrase: str) -> None: + job = RestoreCredentialJob( + self.common_ui, self.pin_cache, self.pin_ui, data, backup_content, passphrase + ) + job.credential_restore.connect(self.credential_restore) + job.restore_progress.connect(self.restore_progress) + self.run(job) diff --git a/nitrokeyapp/ui/icons/backup.svg b/nitrokeyapp/ui/icons/backup.svg new file mode 100644 index 00000000..1227d0b4 --- /dev/null +++ b/nitrokeyapp/ui/icons/backup.svg @@ -0,0 +1,5 @@ + + backup-line + + + \ No newline at end of file diff --git a/nitrokeyapp/ui/icons/backup.svg.license b/nitrokeyapp/ui/icons/backup.svg.license new file mode 100644 index 00000000..52a0f667 --- /dev/null +++ b/nitrokeyapp/ui/icons/backup.svg.license @@ -0,0 +1,4 @@ +Copyright (c) 2018 VMware, Inc. +SPDX-License-Identifier: MIT + +Source: https://github.com/vmware-archive/clarity-assets/blob/master/icons/technology/backup-line.svg \ No newline at end of file diff --git a/nitrokeyapp/ui/icons/restore.svg b/nitrokeyapp/ui/icons/restore.svg new file mode 100644 index 00000000..43aeb983 --- /dev/null +++ b/nitrokeyapp/ui/icons/restore.svg @@ -0,0 +1,5 @@ + + backup-restore-line + + + \ No newline at end of file diff --git a/nitrokeyapp/ui/icons/restore.svg.license b/nitrokeyapp/ui/icons/restore.svg.license new file mode 100644 index 00000000..a1b3e6d9 --- /dev/null +++ b/nitrokeyapp/ui/icons/restore.svg.license @@ -0,0 +1,4 @@ +Copyright (c) 2018 VMware, Inc. +SPDX-License-Identifier: MIT + +Source: https://github.com/vmware-archive/clarity-assets/blob/master/icons/technology/backup-restore-line.svg \ No newline at end of file diff --git a/nitrokeyapp/ui/secrets_tab.ui b/nitrokeyapp/ui/secrets_tab.ui index 1bdd703f..209af982 100644 --- a/nitrokeyapp/ui/secrets_tab.ui +++ b/nitrokeyapp/ui/secrets_tab.ui @@ -138,6 +138,50 @@ + + + + + 0 + 0 + + + + + 11 + + + + Export + + + + icons/backup.svgicons/backup.svg + + + + + + + + 0 + 0 + + + + + 11 + + + + Import + + + + icons/restore.svgicons/restore.svg + + +