Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Added change passphrase dialog #1659

Draft
wants to merge 14 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/vorta/assets/UI/changeborgpass.ui
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>ChangeBorgPassphrase</class>
<widget class="QDialog" name="ChangeRepositoryPass">
<property name="modal">
<bool>true</bool>
</property>
<layout class="QGridLayout" name="gridLayout">
<property name="verticalSpacing">
<number>0</number>
</property>

<item row="2" column="0">
<layout class="QFormLayout" name="repoDataFormLayout">
<property name="fieldGrowthPolicy">
<enum>QFormLayout::ExpandingFieldsGrow</enum>
</property>
<property name="leftMargin">
<number>5</number>
</property>
<property name="topMargin">
<number>5</number>
</property>
<property name="rightMargin">
<number>5</number>
</property>
<property name="bottomMargin">
<number>20</number>
</property>

<item row="4" column="0" colspan="2">
<widget class="QLabel" name="errorText">
<property name="text">
<string></string>
</property>
</widget>
</item>

</layout>
</item>
<item row="3" column="0">
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources />
<connections />
</ui>
7 changes: 7 additions & 0 deletions src/vorta/assets/UI/repotab.ui
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@
</property>
</widget>
</item>
<item row="0" column="4">
<widget class="QToolButton" name="changePassbutton">
<property name="toolTip">
<string>Change Borg Passphrase</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QComboBox" name="repoSelector">
<property name="sizePolicy">
Expand Down
1 change: 1 addition & 0 deletions src/vorta/assets/icons/lock.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/vorta/borg/_compatibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
real-yfprojects marked this conversation as resolved.
Show resolved Hide resolved
# add new version-checks here.
}

Expand Down
71 changes: 71 additions & 0 deletions src/vorta/borg/change_passphrase.py
Original file line number Diff line number Diff line change
@@ -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 <a href="{0}">logs</a> 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()
73 changes: 73 additions & 0 deletions src/vorta/views/change_borg_passphrase_dialog.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +68 to +73
Copy link
Collaborator

Choose a reason for hiding this comment

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

Dead code, Is this still needed?

20 changes: 20 additions & 0 deletions src/vorta/views/repo_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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)
jetchirag marked this conversation as resolved.
Show resolved Hide resolved
self.changePassbutton.setToolTip(no_repokey_encryption)

# update stats
if repo.unique_csize is not None:
self.sizeCompressed.setText(pretty_bytes(repo.unique_csize))
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions tests/borg_json_output/change_passphrase_stderr.json
Original file line number Diff line number Diff line change
@@ -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"}
Empty file.
48 changes: 48 additions & 0 deletions tests/test_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.'