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 @@
+
\ 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 @@
+
\ 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
+
+
+