From 5892baab461ab150ea60121736eda8d791635a89 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Mon, 1 Jun 2026 20:08:13 +0530 Subject: [PATCH 01/18] Backup and restore Backup and restore. Edit to sign --- nitrokeyapp/secrets_tab/__init__.py | 49 ++++++++ nitrokeyapp/secrets_tab/data.py | 34 +++++- nitrokeyapp/secrets_tab/worker.py | 169 ++++++++++++++++++++++++++++ nitrokeyapp/ui/secrets_tab.ui | 36 ++++++ 4 files changed, 287 insertions(+), 1 deletion(-) diff --git a/nitrokeyapp/secrets_tab/__init__.py b/nitrokeyapp/secrets_tab/__init__.py index 60bca8f6..ca044d25 100644 --- a/nitrokeyapp/secrets_tab/__init__.py +++ b/nitrokeyapp/secrets_tab/__init__.py @@ -65,6 +65,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) + trigger_restore_credential = Signal(DeviceData, str) def __init__(self, parent: QWidget) -> None: QWidget.__init__(self, parent) @@ -84,9 +86,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) @@ -169,6 +175,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 +309,47 @@ 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 + pin_protected = self.ui.is_protected.isChecked() + self.trigger_backup_credential.emit(self.data, pin_protected) + + @Slot(str) + def save_credential_backup(self, credential_list_formatted: str) -> None: + from PySide6.QtWidgets import QFileDialog + 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 + from PySide6.QtWidgets import QFileDialog + path, _ = QFileDialog.getOpenFileName( + self, + "Open Credential Backup", + "", + "JSON Files (*.json)", + ) + if not path: + return + with open(path, "r") as f: + backup_content = f.read() + self.trigger_restore_credential.emit(self.data, backup_content) + + @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) diff --git a/nitrokeyapp/secrets_tab/data.py b/nitrokeyapp/secrets_tab/data.py index 01c591e0..a776d056 100644 --- a/nitrokeyapp/secrets_tab/data.py +++ b/nitrokeyapp/secrets_tab/data.py @@ -6,6 +6,7 @@ from nitrokey.nk3.secrets_app import Kind as RawKind from nitrokey.nk3.secrets_app import ListItem, PasswordSafeEntry, SecretsApp +from base64 import b64encode, b64decode # TODO: these could be moved into pynitrokey @@ -74,7 +75,6 @@ def _kind_from_raw(kind: RawKind) -> Optional[Kind]: else: raise RuntimeError(f"Unexpected credential kind: {kind}") - @dataclass class Credential: id: bytes @@ -126,6 +126,38 @@ def extend_with_password_safe_entry(self, item: PasswordSafeEntry) -> "Credentia self.loaded = True return self + def serialize_credential(self) -> dict: + if not self.loaded: + raise RuntimeError("Cannot serialize credential which is not loaded yet") + credential_dict={ + "id" : b64encode(self.id).decode(), + "kind" : self.otp.raw_kind().value if self.otp else self.other.raw_kind().value if self.other else RawKind.NotSet.value, + "login" : b64encode(self.login).decode() if self.login else "", + "password" : b64encode(self.password).decode() if self.password else "", + "comment" : b64encode(self.comment).decode() if self.comment else "", + "protected" : self.protected, + "touch_required" : self.touch_required + } + return credential_dict + + @classmethod + def deserialize_credential(cls, credential_dict: dict) -> "Credential": + credential = cls( + id=b64decode(credential_dict.get("id")), + login=b64decode(credential_dict.get("login")) if credential_dict.get("login", "") else None, + password=b64decode(credential_dict.get("password")) if credential_dict.get("password", "") else None, + comment=b64decode(credential_dict.get("comment")) if credential_dict.get("comment", "") else None, + protected=credential_dict.get("protected"), + touch_required=credential_dict.get("touch_required") + ) + + kind= _kind_from_raw(RawKind(credential_dict.get("kind"))) + if isinstance(kind, OtpKind): + credential.otp = kind + elif isinstance(kind, OtherKind): + credential.other = kind + + return credential @dataclass class OtpData: diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index 4504a7b9..26e6f339 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from datetime import datetime from typing import Dict, Optional +import json +import traceback from nitrokey.nk3 import NK3 from nitrokey.nk3.secrets_app import SecretsApp, SecretsAppException @@ -619,6 +621,159 @@ def get_credential(self) -> None: cred = self.credential.extend_with_password_safe_entry(pse) self.received_credential.emit(cred) +class BackupCredentialJob(Job): + credential_bkp = Signal(str) + + def __init__( + self, + common_ui: CommonUi, + pin_cache: PinCache, + pin_ui: PinUi, + data: DeviceData, + pin_protected: 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.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() + + @Slot() + def get_credential_backup(self) -> None: + with self.data.open() as device: + if not isinstance(device, NK3): + return + secrets = SecretsApp(device) + + credential_list=Credential.list(secrets=secrets) + credential_list_serialized=[] + for credential in credential_list: + try: + pse = secrets.get_credential(credential.id) + except SecretsAppException as e: + self.trigger_exception(e) + continue + + cred = credential.extend_with_password_safe_entry(pse) + try: + credential_list_serialized.append(cred.serialize_credential()) + except: + logger.warn("Serialization failed") + + credential_list_formatted=json.dumps(credential_list_serialized, indent=4) + self.credential_bkp.emit(credential_list_formatted) + +class RestoreCredentialJob(Job): + credential_restore = Signal() + + def __init__( + self, + common_ui: CommonUi, + pin_cache: PinCache, + pin_ui: PinUi, + data: DeviceData, + credential_backup: 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.credential_restore.connect(lambda: self.finished.emit()) + + def run(self) -> None: + list_credentials_job = ListCredentialsJob( + self.common_ui, self.pin_cache, self.pin_ui, self.data, pin_protected=True + ) + list_credentials_job.credentials_listed.connect(self.check_credential_backup) + self.spawn(list_credentials_job) + + @Slot(list) + def check_credential_backup(self, existing_credentials: list[Credential]) -> None: + try: + serialized_credential_list=json.loads(self.credential_backup) + except: + self.trigger_error('Error: Invalid backup file') + credential_list=[] + for serialized_credential in serialized_credential_list: + try: + credential = Credential.deserialize_credential(serialized_credential) + credential_list.append(credential) + except Exception as e: + self.trigger_error('Error: Invalid credential entry') + + pin_required=False + ids = {credential.id for credential in existing_credentials} + temp_cred_list=[] + for credential in credential_list: + if credential.id in ids: + logger.warning("Warn: Credential with same ID already exists") + else: + temp_cred_list.append(credential) + + if credential.protected: + pin_required=True + + self.credential_list=temp_cred_list + if pin_required: + verify_pin_job = VerifyPinJob( + self.common_ui, + self.pin_cache, + self.pin_ui, + self.data, + set_pin=pin_required, + ) + verify_pin_job.pin_verified.connect(self.add_credential) + self.spawn(verify_pin_job) + else: + self.add_credential() + + @Slot(bool) + def add_credential(self, successful: bool = True) -> None: + if not successful: + self.finished.emit() + return + + with self.data.open() as device: + if not isinstance(device, NK3): + return + secrets = SecretsApp(device) + + for credential in self.credential_list: + reg_data = { + "credid": credential.id, + "touch_button_required": credential.touch_required, + "pin_based_encryption": credential.protected, + } + + if credential.login: + reg_data["login"] = credential.login + if credential.password: + reg_data["password"] = credential.password + if credential.comment: + reg_data["metadata"] = credential.comment + try: + secrets.register(**reg_data) # type: ignore [arg-type] + except SecretsAppException as e: + #pass + self.trigger_error('Addition failed') + + + self.credential_restore.emit() class SecretsWorker(Worker): # TODO: remove DeviceData from signatures @@ -631,6 +786,8 @@ class SecretsWorker(Worker): device_checked = Signal(bool) otp_generated = Signal(OtpData) received_credential = Signal(Credential) + credential_bkp = Signal(str) + credential_restore = Signal() def __init__(self, common_ui: CommonUi, app_widget: QWidget) -> None: super().__init__(common_ui) @@ -686,3 +843,15 @@ def edit_credential( ) job.credential_edited.connect(self.credential_edited) self.run(job) + + @Slot(DeviceData) + def backup_credential(self, data: DeviceData, pin_protected: bool) -> None: + job = BackupCredentialJob(self.common_ui, self.pin_cache, self.pin_ui, data, pin_protected) + job.credential_bkp.connect(self.credential_bkp) + self.run(job) + + @Slot(DeviceData, str) + def restore_credential(self, data:DeviceData, backup_conent: str) -> None: + job = RestoreCredentialJob(self.common_ui, self.pin_cache, self.pin_ui, data, backup_conent) + job.credential_restore.connect(self.credential_restore) + self.run(job) \ No newline at end of file diff --git a/nitrokeyapp/ui/secrets_tab.ui b/nitrokeyapp/ui/secrets_tab.ui index 1bdd703f..7bf54637 100644 --- a/nitrokeyapp/ui/secrets_tab.ui +++ b/nitrokeyapp/ui/secrets_tab.ui @@ -138,6 +138,42 @@ + + + + + 0 + 0 + + + + + 11 + + + + Backup + + + + + + + + 0 + 0 + + + + + 11 + + + + Restore + + + From 12f6723287e26c89182b308f8d653a8b1e2473df Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Tue, 2 Jun 2026 12:43:48 +0530 Subject: [PATCH 02/18] Nitrokey touch prompt --- nitrokeyapp/secrets_tab/worker.py | 38 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index 26e6f339..0b0da617 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -752,26 +752,24 @@ def add_credential(self, successful: bool = True) -> None: if not isinstance(device, NK3): return secrets = SecretsApp(device) - - for credential in self.credential_list: - reg_data = { - "credid": credential.id, - "touch_button_required": credential.touch_required, - "pin_based_encryption": credential.protected, - } - - if credential.login: - reg_data["login"] = credential.login - if credential.password: - reg_data["password"] = credential.password - if credential.comment: - reg_data["metadata"] = credential.comment - try: - secrets.register(**reg_data) # type: ignore [arg-type] - except SecretsAppException as e: - #pass - self.trigger_error('Addition failed') - + with self.touch_prompt(): + for credential in self.credential_list: + reg_data = { + "credid": credential.id, + "touch_button_required": credential.touch_required, + "pin_based_encryption": credential.protected, + } + + if credential.login: + reg_data["login"] = credential.login + if credential.password: + reg_data["password"] = credential.password + if credential.comment: + reg_data["metadata"] = credential.comment + try: + secrets.register(**reg_data) # type: ignore [arg-type] + except SecretsAppException as e: + self.trigger_error('Addition failed') self.credential_restore.emit() From 0115205bc5cad737d77159c575a9ed4fa1a4cc97 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Tue, 2 Jun 2026 13:48:40 +0530 Subject: [PATCH 03/18] Remove Backup, Restore buttons from passkey window Fix pin perms during backup --- nitrokeyapp/fido2_tab/__init__.py | 2 ++ nitrokeyapp/secrets_tab/worker.py | 3 +++ 2 files changed, 5 insertions(+) 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/worker.py b/nitrokeyapp/secrets_tab/worker.py index 0b0da617..588d338b 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -662,6 +662,9 @@ def get_credential_backup(self) -> None: credential_list_serialized=[] for credential in credential_list: try: + if credential.protected: + pin= self.pin_cache.get(self.data) + secrets.verify_pin_raw(pin) pse = secrets.get_credential(credential.id) except SecretsAppException as e: self.trigger_exception(e) From 76dd671ba47d6d9f5fd782c6e01b4c9f09a40057 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Tue, 2 Jun 2026 14:22:27 +0530 Subject: [PATCH 04/18] Fix static typing and code style issues --- nitrokeyapp/secrets_tab/data.py | 15 +++++++++------ nitrokeyapp/secrets_tab/worker.py | 18 +++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/nitrokeyapp/secrets_tab/data.py b/nitrokeyapp/secrets_tab/data.py index a776d056..9ddffbe2 100644 --- a/nitrokeyapp/secrets_tab/data.py +++ b/nitrokeyapp/secrets_tab/data.py @@ -1,3 +1,4 @@ +from base64 import b64encode, b64decode from dataclasses import dataclass from datetime import datetime from enum import Enum, auto, unique @@ -6,7 +7,6 @@ from nitrokey.nk3.secrets_app import Kind as RawKind from nitrokey.nk3.secrets_app import ListItem, PasswordSafeEntry, SecretsApp -from base64 import b64encode, b64decode # TODO: these could be moved into pynitrokey @@ -142,13 +142,16 @@ def serialize_credential(self) -> dict: @classmethod def deserialize_credential(cls, credential_dict: dict) -> "Credential": + login=credential_dict.get("login", "") + password=credential_dict.get("password", "") + comment=credential_dict.get("comment", "") credential = cls( id=b64decode(credential_dict.get("id")), - login=b64decode(credential_dict.get("login")) if credential_dict.get("login", "") else None, - password=b64decode(credential_dict.get("password")) if credential_dict.get("password", "") else None, - comment=b64decode(credential_dict.get("comment")) if credential_dict.get("comment", "") else None, - protected=credential_dict.get("protected"), - touch_required=credential_dict.get("touch_required") + login=b64decode(login) if login else None + password=b64decode(password) if password else None + comment=b64decode(comment) if comment else None + protected=credential_dict.get("protected", False), + touch_required=credential_dict.get("touch_required", False) ) kind= _kind_from_raw(RawKind(credential_dict.get("kind"))) diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index 588d338b..affda9ac 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -3,7 +3,6 @@ from datetime import datetime from typing import Dict, Optional import json -import traceback from nitrokey.nk3 import NK3 from nitrokey.nk3.secrets_app import SecretsApp, SecretsAppException @@ -664,7 +663,8 @@ def get_credential_backup(self) -> None: try: if credential.protected: pin= self.pin_cache.get(self.data) - secrets.verify_pin_raw(pin) + if pin: + secrets.verify_pin_raw(pin) pse = secrets.get_credential(credential.id) except SecretsAppException as e: self.trigger_exception(e) @@ -673,8 +673,8 @@ def get_credential_backup(self) -> None: cred = credential.extend_with_password_safe_entry(pse) try: credential_list_serialized.append(cred.serialize_credential()) - except: - logger.warn("Serialization failed") + except SecretsAppException as e: + self.trigger_exception(e) credential_list_formatted=json.dumps(credential_list_serialized, indent=4) self.credential_bkp.emit(credential_list_formatted) @@ -709,15 +709,15 @@ def run(self) -> None: def check_credential_backup(self, existing_credentials: list[Credential]) -> None: try: serialized_credential_list=json.loads(self.credential_backup) - except: + except json.JSONDecodeError as e: self.trigger_error('Error: Invalid backup file') credential_list=[] for serialized_credential in serialized_credential_list: try: credential = Credential.deserialize_credential(serialized_credential) credential_list.append(credential) - except Exception as e: - self.trigger_error('Error: Invalid credential entry') + except SecretsAppException as e: + self.trigger_exception(e) pin_required=False ids = {credential.id for credential in existing_credentials} @@ -772,7 +772,7 @@ def add_credential(self, successful: bool = True) -> None: try: secrets.register(**reg_data) # type: ignore [arg-type] except SecretsAppException as e: - self.trigger_error('Addition failed') + self.trigger_exception(e) self.credential_restore.emit() @@ -855,4 +855,4 @@ def backup_credential(self, data: DeviceData, pin_protected: bool) -> None: def restore_credential(self, data:DeviceData, backup_conent: str) -> None: job = RestoreCredentialJob(self.common_ui, self.pin_cache, self.pin_ui, data, backup_conent) job.credential_restore.connect(self.credential_restore) - self.run(job) \ No newline at end of file + self.run(job) From 6a181bebc2fc3a25cb2fd4d962c88519f2a5e853 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Tue, 2 Jun 2026 14:29:00 +0530 Subject: [PATCH 05/18] Fix typing, format issues --- nitrokeyapp/secrets_tab/__init__.py | 12 +++----- nitrokeyapp/secrets_tab/data.py | 43 +++++++++++++++++------------ nitrokeyapp/secrets_tab/worker.py | 43 ++++++++++++++--------------- 3 files changed, 50 insertions(+), 48 deletions(-) diff --git a/nitrokeyapp/secrets_tab/__init__.py b/nitrokeyapp/secrets_tab/__init__.py index ca044d25..3a2ae106 100644 --- a/nitrokeyapp/secrets_tab/__init__.py +++ b/nitrokeyapp/secrets_tab/__init__.py @@ -319,11 +319,9 @@ def backup_credentials(self) -> None: @Slot(str) def save_credential_backup(self, credential_list_formatted: str) -> None: from PySide6.QtWidgets import QFileDialog + path, _ = QFileDialog.getSaveFileName( - self, - "Save Credential Backup", - "credential_backup.json", - "JSON Files (*.json)", + self, "Save Credential Backup", "credential_backup.json", "JSON Files (*.json)" ) if path: with open(path, "w") as f: @@ -334,11 +332,9 @@ def restore_credentials(self) -> None: if not self.data: return from PySide6.QtWidgets import QFileDialog + path, _ = QFileDialog.getOpenFileName( - self, - "Open Credential Backup", - "", - "JSON Files (*.json)", + self, "Open Credential Backup", "", "JSON Files (*.json)" ) if not path: return diff --git a/nitrokeyapp/secrets_tab/data.py b/nitrokeyapp/secrets_tab/data.py index 9ddffbe2..2c34fe89 100644 --- a/nitrokeyapp/secrets_tab/data.py +++ b/nitrokeyapp/secrets_tab/data.py @@ -1,4 +1,4 @@ -from base64 import b64encode, b64decode +from base64 import b64decode, b64encode from dataclasses import dataclass from datetime import datetime from enum import Enum, auto, unique @@ -75,6 +75,7 @@ def _kind_from_raw(kind: RawKind) -> Optional[Kind]: else: raise RuntimeError(f"Unexpected credential kind: {kind}") + @dataclass class Credential: id: bytes @@ -129,32 +130,37 @@ def extend_with_password_safe_entry(self, item: PasswordSafeEntry) -> "Credentia def serialize_credential(self) -> dict: if not self.loaded: raise RuntimeError("Cannot serialize credential which is not loaded yet") - credential_dict={ - "id" : b64encode(self.id).decode(), - "kind" : self.otp.raw_kind().value if self.otp else self.other.raw_kind().value if self.other else RawKind.NotSet.value, - "login" : b64encode(self.login).decode() if self.login else "", - "password" : b64encode(self.password).decode() if self.password else "", - "comment" : b64encode(self.comment).decode() if self.comment else "", - "protected" : self.protected, - "touch_required" : self.touch_required + credential_dict = { + "id": b64encode(self.id).decode(), + "kind": self.otp.raw_kind().value + if self.otp + else self.other.raw_kind().value + if self.other + else RawKind.NotSet.value, + "login": b64encode(self.login).decode() if self.login else "", + "password": b64encode(self.password).decode() if self.password else "", + "comment": b64encode(self.comment).decode() if self.comment else "", + "protected": self.protected, + "touch_required": self.touch_required, } return credential_dict @classmethod def deserialize_credential(cls, credential_dict: dict) -> "Credential": - login=credential_dict.get("login", "") - password=credential_dict.get("password", "") - comment=credential_dict.get("comment", "") + id = credential_dict.get("id", "") + login = credential_dict.get("login", "") + password = credential_dict.get("password", "") + comment = credential_dict.get("comment", "") credential = cls( - id=b64decode(credential_dict.get("id")), - login=b64decode(login) if login else None - password=b64decode(password) if password else None - comment=b64decode(comment) if comment else None + id=id, + login=b64decode(login) if login else None, + password=b64decode(password) if password else None, + comment=b64decode(comment) if comment else None, protected=credential_dict.get("protected", False), - touch_required=credential_dict.get("touch_required", False) + touch_required=credential_dict.get("touch_required", False), ) - kind= _kind_from_raw(RawKind(credential_dict.get("kind"))) + kind = _kind_from_raw(RawKind(credential_dict.get("kind"))) if isinstance(kind, OtpKind): credential.otp = kind elif isinstance(kind, OtherKind): @@ -162,6 +168,7 @@ def deserialize_credential(cls, credential_dict: dict) -> "Credential": return credential + @dataclass class OtpData: otp: str diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index affda9ac..acf42a05 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -1,8 +1,8 @@ +import json import logging from dataclasses import dataclass from datetime import datetime from typing import Dict, Optional -import json from nitrokey.nk3 import NK3 from nitrokey.nk3.secrets_app import SecretsApp, SecretsAppException @@ -620,6 +620,7 @@ def get_credential(self) -> None: cred = self.credential.extend_with_password_safe_entry(pse) self.received_credential.emit(cred) + class BackupCredentialJob(Job): credential_bkp = Signal(str) @@ -629,14 +630,14 @@ def __init__( pin_cache: PinCache, pin_ui: PinUi, data: DeviceData, - pin_protected: bool + pin_protected: 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.pin_protected = pin_protected self.credential_bkp.connect(lambda _: self.finished.emit()) def run(self) -> None: @@ -657,12 +658,12 @@ def get_credential_backup(self) -> None: return secrets = SecretsApp(device) - credential_list=Credential.list(secrets=secrets) - credential_list_serialized=[] + credential_list = Credential.list(secrets=secrets) + credential_list_serialized = [] for credential in credential_list: try: if credential.protected: - pin= self.pin_cache.get(self.data) + pin = self.pin_cache.get(self.data) if pin: secrets.verify_pin_raw(pin) pse = secrets.get_credential(credential.id) @@ -676,9 +677,10 @@ def get_credential_backup(self) -> None: except SecretsAppException as e: self.trigger_exception(e) - credential_list_formatted=json.dumps(credential_list_serialized, indent=4) + credential_list_formatted = json.dumps(credential_list_serialized, indent=4) self.credential_bkp.emit(credential_list_formatted) + class RestoreCredentialJob(Job): credential_restore = Signal() @@ -688,14 +690,14 @@ def __init__( pin_cache: PinCache, pin_ui: PinUi, data: DeviceData, - credential_backup: str + credential_backup: 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.credential_backup = credential_backup self.credential_restore.connect(lambda: self.finished.emit()) def run(self) -> None: @@ -708,10 +710,10 @@ def run(self) -> None: @Slot(list) def check_credential_backup(self, existing_credentials: list[Credential]) -> None: try: - serialized_credential_list=json.loads(self.credential_backup) + serialized_credential_list = json.loads(self.credential_backup) except json.JSONDecodeError as e: - self.trigger_error('Error: Invalid backup file') - credential_list=[] + self.trigger_exception(e) + credential_list = [] for serialized_credential in serialized_credential_list: try: credential = Credential.deserialize_credential(serialized_credential) @@ -719,9 +721,9 @@ def check_credential_backup(self, existing_credentials: list[Credential]) -> Non except SecretsAppException as e: self.trigger_exception(e) - pin_required=False + pin_required = False ids = {credential.id for credential in existing_credentials} - temp_cred_list=[] + temp_cred_list = [] for credential in credential_list: if credential.id in ids: logger.warning("Warn: Credential with same ID already exists") @@ -729,16 +731,12 @@ def check_credential_backup(self, existing_credentials: list[Credential]) -> Non temp_cred_list.append(credential) if credential.protected: - pin_required=True + pin_required = True - self.credential_list=temp_cred_list + self.credential_list = temp_cred_list if pin_required: verify_pin_job = VerifyPinJob( - self.common_ui, - self.pin_cache, - self.pin_ui, - self.data, - set_pin=pin_required, + self.common_ui, self.pin_cache, self.pin_ui, self.data, set_pin=pin_required ) verify_pin_job.pin_verified.connect(self.add_credential) self.spawn(verify_pin_job) @@ -776,6 +774,7 @@ def add_credential(self, successful: bool = True) -> None: self.credential_restore.emit() + class SecretsWorker(Worker): # TODO: remove DeviceData from signatures @@ -852,7 +851,7 @@ def backup_credential(self, data: DeviceData, pin_protected: bool) -> None: self.run(job) @Slot(DeviceData, str) - def restore_credential(self, data:DeviceData, backup_conent: str) -> None: + def restore_credential(self, data: DeviceData, backup_conent: str) -> None: job = RestoreCredentialJob(self.common_ui, self.pin_cache, self.pin_ui, data, backup_conent) job.credential_restore.connect(self.credential_restore) self.run(job) From 3d04e0acad4786dbba290dbde2f3001f396662e4 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Tue, 2 Jun 2026 16:23:44 +0530 Subject: [PATCH 06/18] Fixed imports --- nitrokeyapp/secrets_tab/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/nitrokeyapp/secrets_tab/__init__.py b/nitrokeyapp/secrets_tab/__init__.py index 3a2ae106..0a8fabfd 100644 --- a/nitrokeyapp/secrets_tab/__init__.py +++ b/nitrokeyapp/secrets_tab/__init__.py @@ -8,7 +8,7 @@ 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 @@ -318,8 +318,6 @@ def backup_credentials(self) -> None: @Slot(str) def save_credential_backup(self, credential_list_formatted: str) -> None: - from PySide6.QtWidgets import QFileDialog - path, _ = QFileDialog.getSaveFileName( self, "Save Credential Backup", "credential_backup.json", "JSON Files (*.json)" ) @@ -331,8 +329,6 @@ def save_credential_backup(self, credential_list_formatted: str) -> None: def restore_credentials(self) -> None: if not self.data: return - from PySide6.QtWidgets import QFileDialog - path, _ = QFileDialog.getOpenFileName( self, "Open Credential Backup", "", "JSON Files (*.json)" ) From ca5b1b46c5bf7a459c23119058e90d56a0167ca3 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Tue, 2 Jun 2026 17:46:12 +0530 Subject: [PATCH 07/18] Icons added --- nitrokeyapp/ui/icons/backup.svg | 6 ++++++ nitrokeyapp/ui/icons/restore.svg | 6 ++++++ nitrokeyapp/ui/secrets_tab.ui | 8 ++++++++ 3 files changed, 20 insertions(+) create mode 100644 nitrokeyapp/ui/icons/backup.svg create mode 100644 nitrokeyapp/ui/icons/restore.svg diff --git a/nitrokeyapp/ui/icons/backup.svg b/nitrokeyapp/ui/icons/backup.svg new file mode 100644 index 00000000..e71cddf7 --- /dev/null +++ b/nitrokeyapp/ui/icons/backup.svg @@ -0,0 +1,6 @@ + + + backup-restore-line + + + \ 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..4f937e63 --- /dev/null +++ b/nitrokeyapp/ui/icons/restore.svg @@ -0,0 +1,6 @@ + + + backup-line + + + \ No newline at end of file diff --git a/nitrokeyapp/ui/secrets_tab.ui b/nitrokeyapp/ui/secrets_tab.ui index 7bf54637..e96f9a34 100644 --- a/nitrokeyapp/ui/secrets_tab.ui +++ b/nitrokeyapp/ui/secrets_tab.ui @@ -154,6 +154,10 @@ Backup + + + icons/backup.svgicons/backup.svg + @@ -172,6 +176,10 @@ Restore + + + icons/restore.svgicons/restore.svg + From a9ffaeee02cda586f4149dbb6534d845c797e7ed Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Thu, 4 Jun 2026 17:49:48 +0530 Subject: [PATCH 08/18] Updated and moved logic to SDK --- nitrokeyapp/secrets_tab/worker.py | 112 +++++++----------------------- 1 file changed, 26 insertions(+), 86 deletions(-) diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index acf42a05..988e556f 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -1,11 +1,12 @@ import json import logging -from dataclasses import dataclass +from dataclasses import dataclass, asdict 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 SecretsApp, SecretsAppException, CXF +from nitrokey.nk3.credential_exchange_format import PasswordToCXF from nitrokey.trussed import Uuid from PySide6.QtCore import QObject, Signal, Slot from PySide6.QtWidgets import QWidget @@ -656,28 +657,14 @@ def get_credential_backup(self) -> None: with self.data.open() as device: if not isinstance(device, NK3): return - secrets = SecretsApp(device) - - credential_list = Credential.list(secrets=secrets) - credential_list_serialized = [] - for credential in credential_list: - try: - if credential.protected: - pin = self.pin_cache.get(self.data) - if pin: - secrets.verify_pin_raw(pin) - pse = secrets.get_credential(credential.id) - except SecretsAppException as e: - self.trigger_exception(e) - continue - - cred = credential.extend_with_password_safe_entry(pse) - try: - credential_list_serialized.append(cred.serialize_credential()) - except SecretsAppException as e: - self.trigger_exception(e) - - credential_list_formatted = json.dumps(credential_list_serialized, indent=4) + secrets = SecretsApp(device) + + + pin=self.pin_cache.get(self.data) + if not pin: + pin = '' + cxf_export = secrets.get_export_cxf(pin) + credential_list_formatted = json.dumps(asdict(cxf_export), indent=4) self.credential_bkp.emit(credential_list_formatted) @@ -701,77 +688,30 @@ def __init__( self.credential_restore.connect(lambda: self.finished.emit()) def run(self) -> None: - list_credentials_job = ListCredentialsJob( - self.common_ui, self.pin_cache, self.pin_ui, self.data, pin_protected=True - ) - list_credentials_job.credentials_listed.connect(self.check_credential_backup) - self.spawn(list_credentials_job) - + 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) + @Slot(list) - def check_credential_backup(self, existing_credentials: list[Credential]) -> None: + def restore_credential_backup(self, existing_credentials: list[Credential]) -> None: try: - serialized_credential_list = json.loads(self.credential_backup) + cred_bkp = json.loads(self.credential_backup) except json.JSONDecodeError as e: self.trigger_exception(e) - credential_list = [] - for serialized_credential in serialized_credential_list: - try: - credential = Credential.deserialize_credential(serialized_credential) - credential_list.append(credential) - except SecretsAppException as e: - self.trigger_exception(e) - - pin_required = False - ids = {credential.id for credential in existing_credentials} - temp_cred_list = [] - for credential in credential_list: - if credential.id in ids: - logger.warning("Warn: Credential with same ID already exists") - else: - temp_cred_list.append(credential) - - if credential.protected: - pin_required = True - - self.credential_list = temp_cred_list - if pin_required: - verify_pin_job = VerifyPinJob( - self.common_ui, self.pin_cache, self.pin_ui, self.data, set_pin=pin_required - ) - verify_pin_job.pin_verified.connect(self.add_credential) - self.spawn(verify_pin_job) - else: - self.add_credential() - - @Slot(bool) - def add_credential(self, successful: bool = True) -> None: - if not successful: - self.finished.emit() - return + cxf_export = PasswordToCXF.cxf_from_dict(cred_bkp) with self.data.open() as device: if not isinstance(device, NK3): return secrets = SecretsApp(device) - with self.touch_prompt(): - for credential in self.credential_list: - reg_data = { - "credid": credential.id, - "touch_button_required": credential.touch_required, - "pin_based_encryption": credential.protected, - } - - if credential.login: - reg_data["login"] = credential.login - if credential.password: - reg_data["password"] = credential.password - if credential.comment: - reg_data["metadata"] = credential.comment - try: - secrets.register(**reg_data) # type: ignore [arg-type] - except SecretsAppException as e: - self.trigger_exception(e) - + pin = self.pin_cache.get(self.data) + if not pin: + pin = '' + secrets.bulk_import_cxf(cxf_export, pin) self.credential_restore.emit() From 8dd143d36654f28644076ca0ba6359c4828f110a Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Fri, 5 Jun 2026 13:30:25 +0530 Subject: [PATCH 09/18] Updated to use dicts directly instead of CXFPayload objects for backup and restore --- nitrokeyapp/secrets_tab/worker.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index 988e556f..85712af2 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -663,8 +663,8 @@ def get_credential_backup(self) -> None: pin=self.pin_cache.get(self.data) if not pin: pin = '' - cxf_export = secrets.get_export_cxf(pin) - credential_list_formatted = json.dumps(asdict(cxf_export), indent=4) + cxf_export = secrets.get_export_cxf(pin, as_dict=True) + credential_list_formatted = json.dumps(cxf_export, indent=4) self.credential_bkp.emit(credential_list_formatted) @@ -702,8 +702,6 @@ def restore_credential_backup(self, existing_credentials: list[Credential]) -> N cred_bkp = json.loads(self.credential_backup) except json.JSONDecodeError as e: self.trigger_exception(e) - - cxf_export = PasswordToCXF.cxf_from_dict(cred_bkp) with self.data.open() as device: if not isinstance(device, NK3): return @@ -711,7 +709,7 @@ def restore_credential_backup(self, existing_credentials: list[Credential]) -> N pin = self.pin_cache.get(self.data) if not pin: pin = '' - secrets.bulk_import_cxf(cxf_export, pin) + secrets.bulk_import_cxf(cred_bkp, pin) self.credential_restore.emit() From e8af392941a97a5e9760fdd5584b5ff28ffd0350 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Tue, 9 Jun 2026 15:03:13 +0530 Subject: [PATCH 10/18] Remove references removed from SDK --- nitrokeyapp/secrets_tab/worker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index 85712af2..3afedad5 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -5,7 +5,7 @@ from typing import Dict, Optional from nitrokey.nk3 import NK3 -from nitrokey.nk3.secrets_app import SecretsApp, SecretsAppException, CXF +from nitrokey.nk3.secrets_app import SecretsApp, SecretsAppException from nitrokey.nk3.credential_exchange_format import PasswordToCXF from nitrokey.trussed import Uuid from PySide6.QtCore import QObject, Signal, Slot @@ -663,7 +663,7 @@ def get_credential_backup(self) -> None: pin=self.pin_cache.get(self.data) if not pin: pin = '' - cxf_export = secrets.get_export_cxf(pin, as_dict=True) + cxf_export, non_exportable_creds = secrets.get_export_cxf(pin, as_dict=True) credential_list_formatted = json.dumps(cxf_export, indent=4) self.credential_bkp.emit(credential_list_formatted) From 27be8ad5b149eccd2ca8712cff71e5e39c5baac3 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Tue, 9 Jun 2026 19:09:58 +0530 Subject: [PATCH 11/18] Just for testing --- nitrokeyapp/secrets_tab/worker.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index 3afedad5..d1956dc2 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -663,7 +663,8 @@ def get_credential_backup(self) -> None: pin=self.pin_cache.get(self.data) if not pin: pin = '' - cxf_export, non_exportable_creds = secrets.get_export_cxf(pin, as_dict=True) + cxf_export, passphrase, non_exportable_creds = secrets.get_export_cxf_encrypted(pin) + print("Passphrase ", passphrase) credential_list_formatted = json.dumps(cxf_export, indent=4) self.credential_bkp.emit(credential_list_formatted) @@ -709,6 +710,7 @@ def restore_credential_backup(self, existing_credentials: list[Credential]) -> N pin = self.pin_cache.get(self.data) if not pin: pin = '' + passphrase = input("Enter passphrase") secrets.bulk_import_cxf(cred_bkp, pin) self.credential_restore.emit() From 3d2fc8bb746b4927c5c45adb1148e70517aca669 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Tue, 23 Jun 2026 18:30:49 +0530 Subject: [PATCH 12/18] Fix backup restore UI --- nitrokeyapp/secrets_tab/__init__.py | 32 ++++- nitrokeyapp/secrets_tab/backup_restore_ui.py | 137 +++++++++++++++++++ nitrokeyapp/secrets_tab/data.py | 42 ------ nitrokeyapp/secrets_tab/worker.py | 117 +++++++++++----- 4 files changed, 248 insertions(+), 80 deletions(-) create mode 100644 nitrokeyapp/secrets_tab/backup_restore_ui.py diff --git a/nitrokeyapp/secrets_tab/__init__.py b/nitrokeyapp/secrets_tab/__init__.py index 0a8fabfd..1dea82a6 100644 --- a/nitrokeyapp/secrets_tab/__init__.py +++ b/nitrokeyapp/secrets_tab/__init__.py @@ -15,6 +15,7 @@ from nitrokeyapp.qt_utils_mix_in import QtUtilsMixIn from nitrokeyapp.worker import Worker +from .backup_restore_ui import open_backup_restore_ui from .data import Credential, OtherKind, OtpData, OtpKind from .worker import SecretsWorker @@ -65,8 +66,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) - trigger_restore_credential = Signal(DeviceData, str) + trigger_backup_credential = Signal(DeviceData, bool, bool) + trigger_restore_credential = Signal(DeviceData, str, str) def __init__(self, parent: QWidget) -> None: QWidget.__init__(self, parent) @@ -118,6 +119,7 @@ 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) @@ -313,8 +315,7 @@ def otp_generated(self, data: OtpData) -> None: def backup_credentials(self) -> None: if not self.data: return - pin_protected = self.ui.is_protected.isChecked() - self.trigger_backup_credential.emit(self.data, pin_protected) + self._open_backup_restore("Backup passwords") @Slot(str) def save_credential_backup(self, credential_list_formatted: str) -> None: @@ -335,8 +336,8 @@ def restore_credentials(self) -> None: if not path: return with open(path, "r") as f: - backup_content = f.read() - self.trigger_restore_credential.emit(self.data, backup_content) + self.backup_content = f.read() + self._open_backup_restore(f"Restore passwords from {path}") @Slot() def on_credential_restored(self) -> None: @@ -916,3 +917,22 @@ 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: str) -> None: + ui = open_backup_restore_ui(action, 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: str) -> None: + ui.update_status("Working... Press your Nitrokey if it blinks.") + if action_name.lower().startswith("backup"): + self.trigger_backup_credential.emit(self.data, True, cleartext) + else: + self.trigger_restore_credential.emit(self.data, self.backup_content, passphrase) + + 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..0d82577a --- /dev/null +++ b/nitrokeyapp/secrets_tab/backup_restore_ui.py @@ -0,0 +1,137 @@ +from typing import Callable, List, Optional + +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import ( + QCheckBox, + QDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QPushButton, + QToolButton, + QVBoxLayout, + QWidget, +) + + +class BackupRestoreUi(QDialog): + def __init__(self, name: str, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + + processname = name + name = "backup" if processname.lower().startswith("backup") else "restore" + + self.name = name + self.setWindowTitle(name.capitalize()) + + self.action_edit = QLineEdit(processname) + self.action_edit.setReadOnly(True) + + action_layout = QHBoxLayout() + action_layout.addWidget(QLabel("Action:")) + action_layout.addWidget(self.action_edit) + + self.cleartext_checkbox = QCheckBox("Cleartext") + self.cleartext_checkbox.setVisible(name == "backup") + + self.passphrase_edit = QLineEdit() + self.passphrase_edit.setReadOnly(name == "backup") + + self.copy_passphrase_button = QToolButton() + self.copy_passphrase_button.setText("📋") + 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) + 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( + lambda: QGuiApplication.clipboard().setText(self.passphrase_edit.text()) + ) + + self.successful_list = QListWidget() + self.failed_list = QListWidget() + self.skipped_list = QListWidget() + + self.failed_name = "Not passwords" if name == "backup" else "Already exists" + + self.successful_label = QLabel("Successful (0)") + self.failed_label = QLabel(f"{self.failed_name} (0)") + self.skipped_label = QLabel("Skipped (0)") + + 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 = QLineEdit() + 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 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.setText(status) + + def update_passphrase(self, passphrase: str) -> None: + self.passphrase_edit.setText(passphrase) + + def begin(self, callback: Callable[[bool, str, str], 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(name: str, parent: Optional[QWidget] = None) -> BackupRestoreUi: + ui = BackupRestoreUi(name, parent) + ui.show() + return ui diff --git a/nitrokeyapp/secrets_tab/data.py b/nitrokeyapp/secrets_tab/data.py index 2c34fe89..01c591e0 100644 --- a/nitrokeyapp/secrets_tab/data.py +++ b/nitrokeyapp/secrets_tab/data.py @@ -1,4 +1,3 @@ -from base64 import b64decode, b64encode from dataclasses import dataclass from datetime import datetime from enum import Enum, auto, unique @@ -127,47 +126,6 @@ def extend_with_password_safe_entry(self, item: PasswordSafeEntry) -> "Credentia self.loaded = True return self - def serialize_credential(self) -> dict: - if not self.loaded: - raise RuntimeError("Cannot serialize credential which is not loaded yet") - credential_dict = { - "id": b64encode(self.id).decode(), - "kind": self.otp.raw_kind().value - if self.otp - else self.other.raw_kind().value - if self.other - else RawKind.NotSet.value, - "login": b64encode(self.login).decode() if self.login else "", - "password": b64encode(self.password).decode() if self.password else "", - "comment": b64encode(self.comment).decode() if self.comment else "", - "protected": self.protected, - "touch_required": self.touch_required, - } - return credential_dict - - @classmethod - def deserialize_credential(cls, credential_dict: dict) -> "Credential": - id = credential_dict.get("id", "") - login = credential_dict.get("login", "") - password = credential_dict.get("password", "") - comment = credential_dict.get("comment", "") - credential = cls( - id=id, - login=b64decode(login) if login else None, - password=b64decode(password) if password else None, - comment=b64decode(comment) if comment else None, - protected=credential_dict.get("protected", False), - touch_required=credential_dict.get("touch_required", False), - ) - - kind = _kind_from_raw(RawKind(credential_dict.get("kind"))) - if isinstance(kind, OtpKind): - credential.otp = kind - elif isinstance(kind, OtherKind): - credential.other = kind - - return credential - @dataclass class OtpData: diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index d1956dc2..34228da2 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -1,12 +1,16 @@ import json import logging -from dataclasses import dataclass, asdict +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.credential_exchange_format import PasswordToCXF +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 @@ -624,6 +628,8 @@ def get_credential(self) -> None: class BackupCredentialJob(Job): credential_bkp = Signal(str) + passphrase_ready = Signal(str) + backup_progress = Signal(list, list, list) def __init__( self, @@ -632,13 +638,14 @@ def __init__( 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: @@ -652,25 +659,42 @@ def run(self) -> None: 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) - if not pin: - pin = '' - cxf_export, passphrase, non_exportable_creds = secrets.get_export_cxf_encrypted(pin) - print("Passphrase ", passphrase) - credential_list_formatted = json.dumps(cxf_export, indent=4) + 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, @@ -679,39 +703,58 @@ def __init__( 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 = 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) - - @Slot(list) - def restore_credential_backup(self, existing_credentials: list[Credential]) -> None: + + 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) - if not pin: - pin = '' - passphrase = input("Enter passphrase") - secrets.bulk_import_cxf(cred_bkp, pin) + 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() @@ -728,6 +771,9 @@ class SecretsWorker(Worker): 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) @@ -784,14 +830,21 @@ def edit_credential( job.credential_edited.connect(self.credential_edited) self.run(job) - @Slot(DeviceData) - def backup_credential(self, data: DeviceData, pin_protected: bool) -> None: - job = BackupCredentialJob(self.common_ui, self.pin_cache, self.pin_ui, data, pin_protected) + @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) - def restore_credential(self, data: DeviceData, backup_conent: str) -> None: - job = RestoreCredentialJob(self.common_ui, self.pin_cache, self.pin_ui, data, backup_conent) + @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) From 764a089190698e558dd93e98a259fbd380838ade Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Wed, 24 Jun 2026 14:42:25 +0530 Subject: [PATCH 13/18] address comments from Robin --- nitrokeyapp/secrets_tab/__init__.py | 27 ++++++--- nitrokeyapp/secrets_tab/backup_restore_ui.py | 64 +++++++++++++------- nitrokeyapp/ui/icons/backup.svg | 7 +-- nitrokeyapp/ui/icons/backup.svg.license | 4 ++ nitrokeyapp/ui/icons/restore.svg | 7 +-- nitrokeyapp/ui/icons/restore.svg.license | 4 ++ 6 files changed, 75 insertions(+), 38 deletions(-) create mode 100644 nitrokeyapp/ui/icons/backup.svg.license create mode 100644 nitrokeyapp/ui/icons/restore.svg.license diff --git a/nitrokeyapp/secrets_tab/__init__.py b/nitrokeyapp/secrets_tab/__init__.py index 1dea82a6..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 @@ -15,7 +16,7 @@ from nitrokeyapp.qt_utils_mix_in import QtUtilsMixIn from nitrokeyapp.worker import Worker -from .backup_restore_ui import open_backup_restore_ui +from .backup_restore_ui import BackupRestoreAction, open_backup_restore_ui from .data import Credential, OtherKind, OtpData, OtpKind from .worker import SecretsWorker @@ -125,6 +126,7 @@ def __init__(self, parent: QWidget) -> None: 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") @@ -315,7 +317,7 @@ def otp_generated(self, data: OtpData) -> None: def backup_credentials(self) -> None: if not self.data: return - self._open_backup_restore("Backup passwords") + self._open_backup_restore(BackupRestoreAction.BACKUP, "Backup passwords") @Slot(str) def save_credential_backup(self, credential_list_formatted: str) -> None: @@ -337,7 +339,7 @@ def restore_credentials(self) -> None: return with open(path, "r") as f: self.backup_content = f.read() - self._open_backup_restore(f"Restore passwords from {path}") + self._open_backup_restore(BackupRestoreAction.RESTORE, f"Restore passwords from {path}") @Slot() def on_credential_restored(self) -> None: @@ -918,8 +920,8 @@ def generate_hmac(self) -> None: secret = b32encode(randbytes(20)) self.ui.otp.setText(secret.decode()) - def _open_backup_restore(self, action: str) -> None: - ui = open_backup_restore_ui(action, self) + 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) @@ -928,11 +930,20 @@ def _open_backup_restore(self, action: str) -> None: 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: str) -> None: + def on_begin(cleartext: bool, passphrase: str, action_name: BackupRestoreAction) -> None: ui.update_status("Working... Press your Nitrokey if it blinks.") - if action_name.lower().startswith("backup"): + if action_name == BackupRestoreAction.BACKUP: self.trigger_backup_credential.emit(self.data, True, cleartext) else: - self.trigger_restore_credential.emit(self.data, self.backup_content, passphrase) + 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 index 0d82577a..9ab5b6d6 100644 --- a/nitrokeyapp/secrets_tab/backup_restore_ui.py +++ b/nitrokeyapp/secrets_tab/backup_restore_ui.py @@ -1,6 +1,7 @@ +from enum import Enum from typing import Callable, List, Optional -from PySide6.QtGui import QGuiApplication +from PySide6.QtGui import QGuiApplication, QIcon from PySide6.QtWidgets import ( QCheckBox, QDialog, @@ -9,37 +10,46 @@ QLineEdit, QListWidget, QPushButton, + QStatusBar, QToolButton, QVBoxLayout, QWidget, ) +class BackupRestoreAction(str, Enum): + BACKUP = "backup" + RESTORE = "restore" + + class BackupRestoreUi(QDialog): - def __init__(self, name: str, parent: Optional[QWidget] = None) -> None: + def __init__( + self, name: BackupRestoreAction, title: str, icon: QIcon, parent: Optional[QWidget] = None + ) -> None: super().__init__(parent) - processname = name - name = "backup" if processname.lower().startswith("backup") else "restore" - self.name = name self.setWindowTitle(name.capitalize()) - self.action_edit = QLineEdit(processname) - self.action_edit.setReadOnly(True) + self.action_edit = QLabel(f"Action: {title}") action_layout = QHBoxLayout() - action_layout.addWidget(QLabel("Action:")) action_layout.addWidget(self.action_edit) self.cleartext_checkbox = QCheckBox("Cleartext") - self.cleartext_checkbox.setVisible(name == "backup") + 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.setReadOnly(name == "backup") + 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.setText("📋") + self.copy_passphrase_button.setIcon(icon) self.copy_passphrase_button.setToolTip("Copy passphrase") self.begin_button = QPushButton("Begin") @@ -47,7 +57,8 @@ def __init__(self, name: str, parent: Optional[QWidget] = None) -> None: passphrase_layout = QHBoxLayout() passphrase_layout.setContentsMargins(0, 0, 0, 0) passphrase_layout.addWidget(self.passphrase_edit) - passphrase_layout.addWidget(self.copy_passphrase_button) + if self.name == BackupRestoreAction.BACKUP: + passphrase_layout.addWidget(self.copy_passphrase_button) middle_layout = QHBoxLayout() middle_layout.addWidget(self.cleartext_checkbox) @@ -56,15 +67,15 @@ def __init__(self, name: str, parent: Optional[QWidget] = None) -> None: middle_layout.addLayout(passphrase_layout, 1) middle_layout.addWidget(self.begin_button) - self.copy_passphrase_button.clicked.connect( - lambda: QGuiApplication.clipboard().setText(self.passphrase_edit.text()) - ) + 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 == "backup" else "Already exists" + self.failed_name = ( + "Not passwords" if name == BackupRestoreAction.BACKUP else "Already exists" + ) self.successful_label = QLabel("Successful (0)") self.failed_label = QLabel(f"{self.failed_name} (0)") @@ -81,8 +92,8 @@ def __init__(self, name: str, parent: Optional[QWidget] = None) -> None: column.addWidget(widget) lists_layout.addLayout(column) - self.status_edit = QLineEdit() - self.status_edit.setReadOnly(True) + self.status_edit = QStatusBar() + # self.status_edit.setReadOnly(True) status_layout = QHBoxLayout() status_layout.addWidget(QLabel("Status")) @@ -98,6 +109,13 @@ def __init__(self, name: str, parent: Optional[QWidget] = None) -> None: 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: @@ -119,19 +137,21 @@ def update_fields( self.skipped_label.setText(f"Skipped ({len(skipped_list)})") def update_status(self, status: str) -> None: - self.status_edit.setText(status) + self.status_edit.showMessage(status) def update_passphrase(self, passphrase: str) -> None: self.passphrase_edit.setText(passphrase) - def begin(self, callback: Callable[[bool, str, str], None]) -> None: + 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(name: str, parent: Optional[QWidget] = None) -> BackupRestoreUi: - ui = BackupRestoreUi(name, parent) +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/ui/icons/backup.svg b/nitrokeyapp/ui/icons/backup.svg index e71cddf7..1227d0b4 100644 --- a/nitrokeyapp/ui/icons/backup.svg +++ b/nitrokeyapp/ui/icons/backup.svg @@ -1,6 +1,5 @@ - - - backup-restore-line - + + 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 index 4f937e63..43aeb983 100644 --- a/nitrokeyapp/ui/icons/restore.svg +++ b/nitrokeyapp/ui/icons/restore.svg @@ -1,6 +1,5 @@ - - - backup-line - + + 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 From f376ba86c1d6cdf3014dd83f387a6944b3cc3939 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Wed, 24 Jun 2026 18:10:16 +0530 Subject: [PATCH 14/18] Pin protected passwords visible without admin. Issue 419 --- nitrokeyapp/secrets_tab/data.py | 4 ++-- nitrokeyapp/secrets_tab/worker.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nitrokeyapp/secrets_tab/data.py b/nitrokeyapp/secrets_tab/data.py index 01c591e0..23f656e6 100644 --- a/nitrokeyapp/secrets_tab/data.py +++ b/nitrokeyapp/secrets_tab/data.py @@ -110,9 +110,9 @@ def from_list_item(cls, item: ListItem) -> "Credential": return credential @classmethod - def list(cls, secrets: SecretsApp) -> list["Credential"]: + def list(cls, secrets: SecretsApp, perform_select: Optional[bool] = True) -> list["Credential"]: credentials = [] - for item in secrets.list_with_properties(): + for item in secrets.list_with_properties(perform_select=perform_select): credentials.append(cls.from_list_item(item)) return credentials diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index 34228da2..757f969d 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -573,7 +573,7 @@ def list_protected_credentials(self, successful: bool) -> None: if not isinstance(device, NK3): return secrets = SecretsApp(device) - for credential in Credential.list(secrets): + for credential in Credential.list(secrets, perform_select=False): credentials.append(credential) self.credentials_listed.emit(credentials) From 88ee979ebf4b9aa48e082ab0d4ee0ba32e08b842 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Wed, 24 Jun 2026 18:27:28 +0530 Subject: [PATCH 15/18] UI Changes done as per last comment --- nitrokeyapp/secrets_tab/backup_restore_ui.py | 11 +++++++++++ nitrokeyapp/ui/secrets_tab.ui | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/nitrokeyapp/secrets_tab/backup_restore_ui.py b/nitrokeyapp/secrets_tab/backup_restore_ui.py index 9ab5b6d6..4a4dd277 100644 --- a/nitrokeyapp/secrets_tab/backup_restore_ui.py +++ b/nitrokeyapp/secrets_tab/backup_restore_ui.py @@ -78,8 +78,19 @@ def __init__( ) 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 ( diff --git a/nitrokeyapp/ui/secrets_tab.ui b/nitrokeyapp/ui/secrets_tab.ui index e96f9a34..209af982 100644 --- a/nitrokeyapp/ui/secrets_tab.ui +++ b/nitrokeyapp/ui/secrets_tab.ui @@ -152,7 +152,7 @@ - Backup + Export @@ -174,7 +174,7 @@ - Restore + Import From ce1011eaa74774a43b5f49e45722d9bd9ff3254c Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Thu, 25 Jun 2026 14:31:34 +0530 Subject: [PATCH 16/18] Revert changes to make password protected lists visible without admin --- nitrokeyapp/secrets_tab/data.py | 4 ++-- nitrokeyapp/secrets_tab/worker.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/nitrokeyapp/secrets_tab/data.py b/nitrokeyapp/secrets_tab/data.py index 23f656e6..01c591e0 100644 --- a/nitrokeyapp/secrets_tab/data.py +++ b/nitrokeyapp/secrets_tab/data.py @@ -110,9 +110,9 @@ def from_list_item(cls, item: ListItem) -> "Credential": return credential @classmethod - def list(cls, secrets: SecretsApp, perform_select: Optional[bool] = True) -> list["Credential"]: + def list(cls, secrets: SecretsApp) -> list["Credential"]: credentials = [] - for item in secrets.list_with_properties(perform_select=perform_select): + for item in secrets.list_with_properties(): credentials.append(cls.from_list_item(item)) return credentials diff --git a/nitrokeyapp/secrets_tab/worker.py b/nitrokeyapp/secrets_tab/worker.py index 757f969d..34228da2 100644 --- a/nitrokeyapp/secrets_tab/worker.py +++ b/nitrokeyapp/secrets_tab/worker.py @@ -573,7 +573,7 @@ def list_protected_credentials(self, successful: bool) -> None: if not isinstance(device, NK3): return secrets = SecretsApp(device) - for credential in Credential.list(secrets, perform_select=False): + for credential in Credential.list(secrets): credentials.append(credential) self.credentials_listed.emit(credentials) From 93e343dbb5c39a76d81a5a717a46d80b72556003 Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Mon, 29 Jun 2026 19:35:25 +0530 Subject: [PATCH 17/18] Handle Issue 4444 --- nitrokeyapp/device_data.py | 5 +++++ nitrokeyapp/gui.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/nitrokeyapp/device_data.py b/nitrokeyapp/device_data.py index 3a9b8fde..3cdc0e10 100644 --- a/nitrokeyapp/device_data.py +++ b/nitrokeyapp/device_data.py @@ -45,6 +45,11 @@ def __init__(self, device: TrussedBase, using_ccid: bool) -> None: self._device = device self._using_ccid = using_ccid + if isinstance(self._device, TrussedDevice): + self._status = self._device.admin.status() + self._uuid = self._device.uuid() + self._version = self._device.admin.version() + def __repr__(self) -> str: fields = { "path": self.path, diff --git a/nitrokeyapp/gui.py b/nitrokeyapp/gui.py index 301447a9..780292c6 100644 --- a/nitrokeyapp/gui.py +++ b/nitrokeyapp/gui.py @@ -163,6 +163,24 @@ def toggle_update_btn(self) -> None: def detect_added_devices( self, device_id: Optional[str] = None, device_info: Optional[Dict[str, str]] = None ) -> None: + vid = device_info["ID_VENDOR_ID"] if device_info and "ID_VENDOR_ID" in device_info else "" + pid = device_info["ID_MODEL_ID"] if device_info and "ID_MODEL_ID" in device_info else "" + + vid_dec = int(vid, 16) if vid else 0 + pid_dec = int(pid, 16) if pid else 0 + + if vid_dec and pid_dec: + for current_devices in self.device_manager._devices: + currdev = current_devices._device + if ( + currdev.vid + and currdev.vid == vid_dec + and currdev.pid + and currdev.pid == pid_dec + ): + logger.info(f"Skipping device {device_id}") + return + # retry for up to 2secs for _tries in range(8): devs = self.device_manager.add() From eee7dacadb528114f5f1eded5b92766b3097a50e Mon Sep 17 00:00:00 2001 From: AdityaMitra5102 Date: Tue, 30 Jun 2026 14:28:58 +0530 Subject: [PATCH 18/18] Revert 444 commit because it is shifted to a different PR --- nitrokeyapp/device_data.py | 4 ---- nitrokeyapp/gui.py | 18 ------------------ 2 files changed, 22 deletions(-) diff --git a/nitrokeyapp/device_data.py b/nitrokeyapp/device_data.py index 3cdc0e10..0a70dd03 100644 --- a/nitrokeyapp/device_data.py +++ b/nitrokeyapp/device_data.py @@ -45,10 +45,6 @@ def __init__(self, device: TrussedBase, using_ccid: bool) -> None: self._device = device self._using_ccid = using_ccid - if isinstance(self._device, TrussedDevice): - self._status = self._device.admin.status() - self._uuid = self._device.uuid() - self._version = self._device.admin.version() def __repr__(self) -> str: fields = { diff --git a/nitrokeyapp/gui.py b/nitrokeyapp/gui.py index 780292c6..301447a9 100644 --- a/nitrokeyapp/gui.py +++ b/nitrokeyapp/gui.py @@ -163,24 +163,6 @@ def toggle_update_btn(self) -> None: def detect_added_devices( self, device_id: Optional[str] = None, device_info: Optional[Dict[str, str]] = None ) -> None: - vid = device_info["ID_VENDOR_ID"] if device_info and "ID_VENDOR_ID" in device_info else "" - pid = device_info["ID_MODEL_ID"] if device_info and "ID_MODEL_ID" in device_info else "" - - vid_dec = int(vid, 16) if vid else 0 - pid_dec = int(pid, 16) if pid else 0 - - if vid_dec and pid_dec: - for current_devices in self.device_manager._devices: - currdev = current_devices._device - if ( - currdev.vid - and currdev.vid == vid_dec - and currdev.pid - and currdev.pid == pid_dec - ): - logger.info(f"Skipping device {device_id}") - return - # retry for up to 2secs for _tries in range(8): devs = self.device_manager.add()