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.'