diff --git a/src/vorta/assets/UI/changeborgpass.ui b/src/vorta/assets/UI/changeborgpass.ui new file mode 100644 index 000000000..efac139ae --- /dev/null +++ b/src/vorta/assets/UI/changeborgpass.ui @@ -0,0 +1,52 @@ + + + ChangeBorgPassphrase + + + true + + + + 0 + + + + + + QFormLayout::ExpandingFieldsGrow + + + 5 + + + 5 + + + 5 + + + 20 + + + + + + + + + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/src/vorta/assets/UI/repotab.ui b/src/vorta/assets/UI/repotab.ui index ae083ebef..ff40ff15a 100644 --- a/src/vorta/assets/UI/repotab.ui +++ b/src/vorta/assets/UI/repotab.ui @@ -106,6 +106,13 @@ + + + + Change Borg Passphrase + + + diff --git a/src/vorta/assets/icons/lock.svg b/src/vorta/assets/icons/lock.svg new file mode 100644 index 000000000..bc685d525 --- /dev/null +++ b/src/vorta/assets/icons/lock.svg @@ -0,0 +1 @@ + diff --git a/src/vorta/borg/_compatibility.py b/src/vorta/borg/_compatibility.py index bf2a01b0b..1672b2bef 100644 --- a/src/vorta/borg/_compatibility.py +++ b/src/vorta/borg/_compatibility.py @@ -8,6 +8,7 @@ 'COMPACT_SUBCOMMAND': parse_version('1.2.0a1'), 'V122': parse_version('1.2.2'), 'V2': parse_version('2.0.0b1'), + 'CHANGE_PASSPHRASE': parse_version('1.1.0'), # add new version-checks here. } diff --git a/src/vorta/borg/change_passphrase.py b/src/vorta/borg/change_passphrase.py new file mode 100644 index 000000000..4b15c4180 --- /dev/null +++ b/src/vorta/borg/change_passphrase.py @@ -0,0 +1,71 @@ +from typing import Any, Dict + +from vorta.borg._compatibility import MIN_BORG_FOR_FEATURE +from vorta.config import LOG_DIR +from vorta.i18n import trans_late, translate +from vorta.store.models import RepoModel +from vorta.utils import borg_compat + +from .borg_job import BorgJob + + +class BorgChangePassJob(BorgJob): + def started_event(self): + self.app.backup_started_event.emit() + self.app.backup_progress_event.emit(self.tr('Changing Borg passphrase...')) + + def finished_event(self, result: Dict[str, Any]): + """ + Process that the job terminated with the given results. + + Parameters + ---------- + result : Dict[str, Any] + The (json-like) dictionary containing the job results. + """ + self.app.backup_finished_event.emit(result) + self.result.emit(result) + if result['returncode'] != 0: + self.app.backup_progress_event.emit( + translate( + 'BorgChangePassJob', + 'Errors during changing passphrase. See the logs for details.', + ).format(LOG_DIR.as_uri()) + ) + else: + self.app.backup_progress_event.emit(self.tr('Borg passphrase changed.')) + + @classmethod + def prepare(cls, profile, newPass): + ret = super().prepare(profile) + if not ret['ok']: + return ret + else: + ret['ok'] = False # Set back to false, so we can do our own checks here. + + if not borg_compat.check('CHANGE_PASSPHRASE'): + ret['ok'] = False + ret['message'] = trans_late( + 'messages', 'This feature needs Borg {} or higher.'.format(MIN_BORG_FOR_FEATURE['CHANGE_PASSPHRASE']) + ) + return ret + + cmd = ['borg', '--info', '--log-json', 'key', 'change-passphrase'] + cmd.append(f'{profile.repo.url}') + + ret['additional_env'] = {'BORG_NEW_PASSPHRASE': newPass} + + ret['ok'] = True + ret['cmd'] = cmd + + return ret + + def process_result(self, result): + if result['returncode'] == 0: + # Change passphrase in keyring + repo = RepoModel.get(url=result['params']['repo_url']) + if repo.encryption != 'none': + self.keyring.set_password( + "vorta-repo", repo.url, result['params']['additional_env']['BORG_NEW_PASSPHRASE'] + ) + repo.save() diff --git a/src/vorta/views/change_borg_passphrase_dialog.py b/src/vorta/views/change_borg_passphrase_dialog.py new file mode 100644 index 000000000..3a995aab3 --- /dev/null +++ b/src/vorta/views/change_borg_passphrase_dialog.py @@ -0,0 +1,73 @@ +from PyQt6 import QtCore, uic +from PyQt6.QtWidgets import QApplication, QDialogButtonBox + +from vorta.borg.change_passphrase import BorgChangePassJob +from vorta.utils import get_asset +from vorta.views.partials.password_input import PasswordInput + +uifile = get_asset('UI/changeborgpass.ui') +ChangeBorgPassUI, ChangeBorgPassBase = uic.loadUiType(uifile) + + +class ChangeBorgPassphraseWindow(ChangeBorgPassBase, ChangeBorgPassUI): + change_borg_passphrase = QtCore.pyqtSignal(dict) + + def __init__(self, profile): + super().__init__() + self.setupUi(self) + self.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose) + self.result = None + self.profile = profile + + self.setMinimumWidth(583) + + self.passwordInput = PasswordInput() + self.passwordInput.add_form_to_layout(self.repoDataFormLayout) + + self.saveButton = self.buttonBox.button(QDialogButtonBox.StandardButton.Ok) + self.saveButton.setText(self.tr("Update")) + + self.buttonBox.rejected.connect(self.close) + self.buttonBox.accepted.connect(self.run) + + def retranslateUi(self, dialog): + """Retranslate strings in ui.""" + super().retranslateUi(dialog) + + # setupUi calls retranslateUi + if hasattr(self, 'saveButton'): + self.saveButton.setText(self.tr("Update")) + + def run(self): + # if self.password_listener() and self.validate(): + if self.passwordInput.validate(): + newPass = self.passwordInput.passwordLineEdit.text() + + params = BorgChangePassJob.prepare(self.profile, newPass) + if params['ok']: + self.saveButton.setEnabled(False) + job = BorgChangePassJob(params['cmd'], params) + job.updated.connect(self._set_status) + job.result.connect(self.run_result) + QApplication.instance().jobs_manager.add_job(job) + else: + self._set_status(params['message']) + + def _set_status(self, text): + self.errorText.setText(text) + self.errorText.repaint() + + def run_result(self, result): + self.saveButton.setEnabled(True) + if result['returncode'] == 0: + self.change_borg_passphrase.emit(result) + self.accept() + else: + self._set_status(self.tr('Unable to change Borg passphrase.')) + + # def validate(self): + # """Check encryption type""" + # if self.profile.repo.encryption.startswith('repokey'): + # return True + # self.errorText.setText(translate('utils', 'Encryption type must be repokey.')) + # return False diff --git a/src/vorta/views/repo_tab.py b/src/vorta/views/repo_tab.py index 96b614b02..5037dd5a4 100644 --- a/src/vorta/views/repo_tab.py +++ b/src/vorta/views/repo_tab.py @@ -8,6 +8,7 @@ from vorta.store.models import ArchiveModel, BackupProfileMixin, RepoModel from vorta.utils import borg_compat, get_asset, get_private_keys, pretty_bytes +from .change_borg_passphrase_dialog import ChangeBorgPassphraseWindow from .repo_add_dialog import AddRepoWindow, ExistingRepoWindow from .ssh_dialog import SSHAddWindow from .utils import get_colored_icon @@ -28,6 +29,9 @@ def __init__(self, parent=None): self.repoRemoveToolbutton.clicked.connect(self.repo_unlink_action) self.copyURLbutton.clicked.connect(self.copy_URL_action) + # passphrase change button + self.changePassbutton.clicked.connect(self.change_borg_passphrase) + # init repo add button self.menuAddRepo = QMenu(self.bAddRepo) @@ -75,6 +79,7 @@ def set_icons(self): self.repoRemoveToolbutton.setIcon(get_colored_icon('unlink')) self.sshKeyToClipboardButton.setIcon(get_colored_icon('copy')) self.copyURLbutton.setIcon(get_colored_icon('copy')) + self.changePassbutton.setIcon(get_colored_icon('lock')) def set_repos(self): self.repoSelector.clear() @@ -111,6 +116,7 @@ def init_repo_stats(self): # prepare translations na = self.tr('N/A', "Not available.") no_repo_selected = self.tr("Select a repository first.") + no_repokey_encryption = self.tr("Change Borg Passphrase (Repokey encryption needed)") refresh = self.tr("Try refreshing the metadata of any archive.") # set labels @@ -129,6 +135,13 @@ def init_repo_stats(self): self.sshComboBox.setEnabled(ssh_enabled) self.sshKeyToClipboardButton.setEnabled(ssh_enabled) + # Disable the change passphrase button if encryption type is not repokey + if repo.encryption.startswith('repokey'): + self.changePassbutton.setEnabled(True) + else: + self.changePassbutton.setEnabled(False) + self.changePassbutton.setToolTip(no_repokey_encryption) + # update stats if repo.unique_csize is not None: self.sizeCompressed.setText(pretty_bytes(repo.unique_csize)) @@ -253,6 +266,13 @@ def add_existing_repo(self): # window.rejected.connect(lambda: self.repoSelector.setCurrentIndex(0)) window.open() + def change_borg_passphrase(self): + window = ChangeBorgPassphraseWindow(self.profile()) + self._window = window # For tests + window.setWindowTitle(self.tr("Change Borg Passphrase")) + window.setParent(self, QtCore.Qt.WindowType.Sheet) + window.open() + def repo_select_action(self): profile = self.profile() profile.repo = self.repoSelector.currentData() diff --git a/tests/borg_json_output/change_passphrase_stderr.json b/tests/borg_json_output/change_passphrase_stderr.json new file mode 100644 index 000000000..d73afe5bd --- /dev/null +++ b/tests/borg_json_output/change_passphrase_stderr.json @@ -0,0 +1,2 @@ +{"type": "log_message", "time": 1679134475.3384268, "message": "Key updated", "levelname": "INFO", "name": "borg.archiver"} +{"type": "log_message", "time": 1679134475.338515, "message": "Key location: /Users/chirag/Projects/vorta/repo2", "levelname": "INFO", "name": "borg.archiver"} diff --git a/tests/borg_json_output/change_passphrase_stdout.json b/tests/borg_json_output/change_passphrase_stdout.json new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_repo.py b/tests/test_repo.py index 3e13084d7..2a0f568b9 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -152,3 +152,51 @@ def test_create(qapp, borg_json_output, mocker, qtbot): assert main.createStartBtn.isEnabled() assert main.archiveTab.archiveTable.rowCount() == 3 assert main.scheduleTab.logTableWidget.rowCount() == 1 + + +def test_passphrase_change_failures(qapp, qtbot): + # Add new repo window + main = qapp.main_window + main.repoTab.change_borg_passphrase() + change_pass_window = main.repoTab._window + qtbot.addWidget(change_pass_window) + + change_pass_window.passwordInput.clear() + qtbot.keyClicks(change_pass_window.passwordInput.passwordLineEdit, SHORT_PASSWORD) + qtbot.keyClicks(change_pass_window.passwordInput.confirmLineEdit, SHORT_PASSWORD) + + qtbot.mouseClick(change_pass_window.saveButton, QtCore.Qt.MouseButton.LeftButton) + assert change_pass_window.passwordInput.validation_label.text() == 'Passwords must be atleast 9 characters long.' + + change_pass_window.passwordInput.clear() + qtbot.keyClicks(change_pass_window.passwordInput.passwordLineEdit, SHORT_PASSWORD + "1") + qtbot.keyClicks(change_pass_window.passwordInput.confirmLineEdit, SHORT_PASSWORD) + qtbot.mouseClick(change_pass_window.saveButton, QtCore.Qt.MouseButton.LeftButton) + assert ( + change_pass_window.passwordInput.validation_label.text() + == 'Passwords must be identical and atleast 9 characters long.' + ) + + change_pass_window.passwordInput.clear() + qtbot.keyClicks(change_pass_window.passwordInput.passwordLineEdit, LONG_PASSWORD) + qtbot.keyClicks(change_pass_window.passwordInput.confirmLineEdit, SHORT_PASSWORD) + qtbot.mouseClick(change_pass_window.saveButton, QtCore.Qt.MouseButton.LeftButton) + assert change_pass_window.passwordInput.validation_label.text() == 'Passwords must be identical.' + + +def test_passphrase_change(qapp, qtbot, mocker, borg_json_output): + main = qapp.main_window + main.repoTab.change_borg_passphrase() + change_pass_window = main.repoTab._window + + qtbot.keyClicks(change_pass_window.passwordInput.passwordLineEdit, LONG_PASSWORD) + qtbot.keyClicks(change_pass_window.passwordInput.confirmLineEdit, LONG_PASSWORD) + + stdout, stderr = borg_json_output('change_passphrase') + popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) + mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) + + change_pass_window.run() + + qtbot.waitUntil(lambda: main.progressText.text().startswith('Borg passphrase changed.'), **pytest._wait_defaults) + assert main.progressText.text() == 'Borg passphrase changed.'