Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions nitrokeyapp/device_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions nitrokeyapp/fido2_tab/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
74 changes: 73 additions & 1 deletion nitrokeyapp/secrets_tab/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import binascii
import json
import logging
from base64 import b32decode, b32encode
from datetime import datetime
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
168 changes: 168 additions & 0 deletions nitrokeyapp/secrets_tab/backup_restore_ui.py
Original file line number Diff line number Diff line change
@@ -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")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be useful to add tooltips to these fields that describe:

  • Cleartext: that this option disables encryption of the generated backup and is thus discouraged
  • Passphrase: that the passphrase is automatically generated when creating a backup
  • Failed and skipped: more details on when a credential ends up in this list

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.failed_name = "Not passwords" if name == BackupRestoreAction.BACKUP else "Already exists"

Failed is not shown as failed. During backup, failed means it is not a password credential. During restore failed means another credential with same label already exists on the key

@AdityaMitra5102 AdityaMitra5102 Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleartext and passphrase tooltips added

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. For us it is clear what Not passwords means but I think without context it could be confusing. That’s why I’d also add a tooltip here, something like: Credentials without a password (for example OTP only) are skipped during the backup as they cannot be extracted from the device.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

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
Loading