diff --git a/src/vorta/keyring/abc.py b/src/vorta/keyring/abc.py index fc35f9c6d..1ad85158c 100644 --- a/src/vorta/keyring/abc.py +++ b/src/vorta/keyring/abc.py @@ -54,6 +54,12 @@ def get_password(self, service, repo_url): """ raise NotImplementedError + def remove_password(self, service, repo_url): + """ + Removes a password form the underlying store. + """ + raise NotImplementedError + @property def is_system(self): """ diff --git a/src/vorta/keyring/darwin.py b/src/vorta/keyring/darwin.py index d0f52a3cb..d598d9030 100644 --- a/src/vorta/keyring/darwin.py +++ b/src/vorta/keyring/darwin.py @@ -15,7 +15,11 @@ class VortaDarwinKeyring(VortaKeyring): - """Homemade macOS Keychain Service""" + """ + Homemade macOS Keychain Service + + TODO: Could use the newer API, as done here: https://github.com/jaraco/keyring/pull/522/files + """ login_keychain = None @@ -42,6 +46,7 @@ def _set_keychain(self): b'i@I*I*o^Io^^{OpaquePassBuff}o^^{OpaqueSecKeychainItemRef}', ), ('SecKeychainGetStatus', b'i^{OpaqueSecKeychainRef=}o^I'), + ('SecKeychainItemDelete', b'i^{OpaqueSecKeychainItemRef=}o^I'), ] objc.loadBundleFunctions(Security, globals(), S_functions) @@ -97,6 +102,25 @@ def get_password(self, service, repo_url): logger.debug(f"Retrieved password for repo {repo_url}") return password + def remove_password(self, service, repo_url): + if not self.login_keychain: + self._set_keychain() + + (result, password_length, password_buffer, keychain_item,) = SecKeychainFindGenericPassword( + self.login_keychain, + len(service), + service.encode(), + len(repo_url), + repo_url.encode(), + None, + None, + None, + ) + password = None + if (result == 0) and (password_length != 0): + logger.debug(f"Found password for repo {repo_url}") + SecKeychainItemDelete(keychain_item, None) + @property def is_unlocked(self): kSecUnlockStateStatus = 1 diff --git a/src/vorta/keyring/db.py b/src/vorta/keyring/db.py index 706c0d913..e525df80d 100644 --- a/src/vorta/keyring/db.py +++ b/src/vorta/keyring/db.py @@ -1,6 +1,6 @@ import logging import peewee -from vorta.store.models import SettingsModel +from vorta.store.models import RepoPassword, SettingsModel from .abc import VortaKeyring logger = logging.getLogger(__name__) @@ -14,16 +14,13 @@ class VortaDBKeyring(VortaKeyring): """ def set_password(self, service, repo_url, password): - from vorta.store.models import RepoPassword keyring_entry, created = RepoPassword.get_or_create(url=repo_url, defaults={'password': password}) keyring_entry.password = password keyring_entry.save() - logger.debug(f"Saved password for repo {repo_url}") def get_password(self, service, repo_url): - from vorta.store.models import RepoPassword try: keyring_entry = RepoPassword.get(url=repo_url) @@ -33,6 +30,15 @@ def get_password(self, service, repo_url): except peewee.DoesNotExist: return None + def remove_password(self, service, repo_url): + + try: + keyring_entry = RepoPassword.get(url=repo_url) + keyring_entry.delete_instance() + logger.debug(f"Removed password for repo {repo_url}") + except peewee.DoesNotExist: + logger.debug(f"Password wasn't found for repo {repo_url}") + @property def is_system(self): return False diff --git a/src/vorta/keyring/kwallet.py b/src/vorta/keyring/kwallet.py index 32402160a..f677fdb3d 100644 --- a/src/vorta/keyring/kwallet.py +++ b/src/vorta/keyring/kwallet.py @@ -45,6 +45,12 @@ def get_password(self, service, repo_url): logger.debug(f"Retrieved password for repo {repo_url}") return password + def remove_password(self, service, repo_url): + entry = [self.handle, self.folder_name, repo_url, service] + if self.is_unlocked and self.get_result("hasEntry", args=entry): + self.get_result("removeEntry", args=entry) + logger.debug(f"Removed password for repo {repo_url}") + def get_result(self, method, args=[]): if args: result = self.iface.callWithArgumentList(QtDBus.QDBus.AutoDetect, method, args) diff --git a/src/vorta/keyring/secretstorage.py b/src/vorta/keyring/secretstorage.py index 02c1c3198..607c97110 100644 --- a/src/vorta/keyring/secretstorage.py +++ b/src/vorta/keyring/secretstorage.py @@ -65,6 +65,25 @@ def get_password(self, service, repo_url): return item.get_secret().decode("utf-8") return None + def remove_password(self, service, repo_url): + """ + Remove a password from the underlying store. + """ + if self.is_unlocked: + asyncio.set_event_loop(asyncio.new_event_loop()) + attributes = { + 'application': 'Vorta', + 'service': service, + 'repo_url': repo_url, + } + items = list(self.collection.search_items(attributes)) + logger.debug('Found %i passwords matching repo URL.', len(items)) + for item in items: + if item.is_locked() and item.unlock(): + continue + self.collection.item.delete() + logger.debug(f"Removed password for repo {repo_url}") + @property def is_unlocked(self): # unlock() will return True if the unlock prompt is dismissed diff --git a/tests/test_repo.py b/tests/test_repo.py index f90798712..402eee596 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -10,6 +10,19 @@ SHORT_PASSWORD = 'hunter2' +@pytest.fixture(scope="function") +def keyring_fixture(): + test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain + password = str(uuid.uuid4()) + keyring = VortaKeyring.get_keyring() + keyring.set_password('vorta-repo', test_repo_url, password) + + yield test_repo_url, password, keyring, LONG_PASSWORD + + # Remove password from keyring for the test + keyring.remove_password('vorta-repo', test_repo_url) + + def test_repo_add_failures(qapp, qtbot, mocker, borg_json_output): # Add new repo window main = qapp.main_window @@ -61,27 +74,23 @@ def test_repo_unlink(qapp, qtbot): assert 'Select a backup repository first.' in main.progressText.text() -def test_password_autofill(qapp, qtbot): +def test_password_autofill(qapp, qtbot, keyring_fixture): main = qapp.main_window main.repoTab.new_repo() # couldn't click menu add_repo_window = main.repoTab._window - test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain - - keyring = VortaKeyring.get_keyring() - password = str(uuid.uuid4()) - keyring.set_password('vorta-repo', test_repo_url, password) + test_repo_url, password, keyring, long_password = keyring_fixture qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) assert add_repo_window.passwordLineEdit.text() == password -def test_repo_add_success(qapp, qtbot, mocker, borg_json_output): +def test_repo_add_success(qapp, qtbot, mocker, borg_json_output, keyring_fixture): # Add new repo window main = qapp.main_window main.repoTab.new_repo() # couldn't click menu add_repo_window = main.repoTab._window - test_repo_url = f'vorta-test-repo.{uuid.uuid4()}.com:repo' # Random repo URL to avoid macOS keychain + test_repo_url, password, keyring, long_password = keyring_fixture qtbot.keyClicks(add_repo_window.repoURL, test_repo_url) qtbot.keyClicks(add_repo_window.passwordLineEdit, LONG_PASSWORD) @@ -98,8 +107,7 @@ def test_repo_add_success(qapp, qtbot, mocker, borg_json_output): assert RepoModel.get(id=2).url == test_repo_url - keyring = VortaKeyring.get_keyring() - assert keyring.get_password("vorta-repo", RepoModel.get(id=2).url) == LONG_PASSWORD + assert long_password.lower() in keyring.get_password("vorta-repo", RepoModel.get(id=2).url).lower() assert main.repoTab.repoSelector.currentText() == test_repo_url diff --git a/tests/test_utils.py b/tests/test_utils.py index 07585e3f9..d67ea6cb0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,6 +10,8 @@ def test_keyring(): keyring = VortaKeyring.get_keyring() keyring.set_password('vorta-repo', REPO, UNICODE_PW) assert keyring.get_password("vorta-repo", REPO) == UNICODE_PW + keyring.remove_password('vorta-repo', REPO) + assert keyring.remove_password("vorta-repo", REPO) is None def test_best_size_unit_precision0():