diff --git a/cura/Backups/Backup.py b/cura/Backups/Backup.py index 011eb97310..fa135f951b 100644 --- a/cura/Backups/Backup.py +++ b/cura/Backups/Backup.py @@ -5,6 +5,7 @@ import io import os import re import shutil +from copy import deepcopy from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile from typing import Dict, Optional, TYPE_CHECKING @@ -27,6 +28,9 @@ class Backup: IGNORED_FILES = [r"cura\.log", r"plugins\.json", r"cache", r"__pycache__", r"\.qmlc", r"\.pyc"] """These files should be ignored when making a backup.""" + SECRETS_SETTINGS = ["general/ultimaker_auth_data"] + """Secret preferences that need to obfuscated when making a backup of Cura""" + catalog = i18nCatalog("cura") """Re-use translation catalog""" @@ -43,6 +47,9 @@ class Backup: Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir) + # obfuscate sensitive secrets + secrets = self._obfuscate() + # Ensure all current settings are saved. self._application.saveSettings() @@ -78,6 +85,8 @@ class Backup: "profile_count": str(profile_count), "plugin_count": str(plugin_count) } + # Restore the obfuscated settings + self._illuminate(**secrets) def _makeArchive(self, buffer: "io.BytesIO", root_path: str) -> Optional[ZipFile]: """Make a full archive from the given root path with the given name. @@ -134,6 +143,9 @@ class Backup: "Tried to restore a Cura backup that is higher than the current version.")) return False + # Get the current secrets and store since the back-up doesn't contain those + secrets = self._obfuscate() + version_data_dir = Resources.getDataStoragePath() archive = ZipFile(io.BytesIO(self.zip_file), "r") extracted = self._extractArchive(archive, version_data_dir) @@ -146,6 +158,9 @@ class Backup: Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file) shutil.move(backup_preferences_file, preferences_file) + # Restore the obfuscated settings + self._illuminate(**secrets) + return extracted @staticmethod @@ -173,3 +188,28 @@ class Backup: Logger.logException("e", "Unable to extract the backup due to permission or file system errors.") return False return True + + def _obfuscate(self) -> Dict[str, str]: + """ + Obfuscate and remove the secret preferences that are specified in SECRETS_SETTINGS + + :return: a dictionary of the removed secrets. Note: the '/' is replaced by '__' + """ + preferences = self._application.getPreferences() + secrets = {} + for secret in self.SECRETS_SETTINGS: + secrets[secret.replace("/", "__")] = deepcopy(preferences.getValue(secret)) + preferences.setValue(secret, None) + self._application.savePreferences() + return secrets + + def _illuminate(self, **kwargs) -> None: + """ + Restore the obfuscated settings + + :param kwargs: a dict of obscured preferences. Note: the '__' of the keys will be replaced by '/' + """ + preferences = self._application.getPreferences() + for key, value in kwargs.items(): + preferences.setValue(key.replace("__", "/"), value) + self._application.savePreferences() diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py index 986f8d9a56..da654b52bb 100644 --- a/cura/OAuth2/AuthorizationService.py +++ b/cura/OAuth2/AuthorizationService.py @@ -255,10 +255,9 @@ class AuthorizationService: self._auth_data = auth_data if auth_data: self._user_profile = self.getUserProfile() - self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data))) + self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(auth_data.dump())) else: self._user_profile = None self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY) self.accessTokenChanged.emit() - diff --git a/cura/OAuth2/KeyringAttribute.py b/cura/OAuth2/KeyringAttribute.py new file mode 100644 index 0000000000..76385d6a0b --- /dev/null +++ b/cura/OAuth2/KeyringAttribute.py @@ -0,0 +1,73 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Type, TYPE_CHECKING + +import keyring +from keyring.backend import KeyringBackend +from keyring.errors import NoKeyringError, PasswordSetError + +from UM.Logger import Logger + +if TYPE_CHECKING: + from cura.OAuth2.Models import BaseModel + +# Need to do some extra workarounds on windows: +import sys +from UM.Platform import Platform +if Platform.isWindows() and hasattr(sys, "frozen"): + import win32timezone + from keyring.backends.Windows import WinVaultKeyring + keyring.set_keyring(WinVaultKeyring()) + +# Even if errors happen, we don't want this stored locally: +DONT_EVER_STORE_LOCALLY = ["refresh_token"] + +class KeyringAttribute: + """ + Descriptor for attributes that need to be stored in the keyring. With Fallback behaviour to the preference cfg file + """ + def __get__(self, instance: Type["BaseModel"], owner: type) -> str: + if self._store_secure: + try: + return keyring.get_password("cura", self._keyring_name) + except NoKeyringError: + self._store_secure = False + Logger.logException("w", "No keyring backend present") + return getattr(instance, self._name) + else: + return getattr(instance, self._name) + + def __set__(self, instance: Type["BaseModel"], value: str): + if self._store_secure: + setattr(instance, self._name, None) + try: + keyring.set_password("cura", self._keyring_name, value) + except PasswordSetError: + self._store_secure = False + if self._name not in DONT_EVER_STORE_LOCALLY: + setattr(instance, self._name, value) + Logger.logException("w", "Keyring access denied") + except NoKeyringError: + self._store_secure = False + if self._name not in DONT_EVER_STORE_LOCALLY: + setattr(instance, self._name, value) + Logger.logException("w", "No keyring backend present") + except BaseException as e: + # A BaseException can occur in Windows when the keyring attempts to write a token longer than 1024 + # characters in the Windows Credentials Manager. + self._store_secure = False + if self._name not in DONT_EVER_STORE_LOCALLY: + setattr(instance, self._name, value) + Logger.log("w", "Keyring failed: {}".format(e)) + else: + setattr(instance, self._name, value) + + def __set_name__(self, owner: type, name: str): + self._name = "_{}".format(name) + self._keyring_name = name + self._store_secure = False + try: + self._store_secure = KeyringBackend.viable + except NoKeyringError: + Logger.logException("w", "Could not use keyring") + setattr(owner, self._name, None) diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py index f49fdc1421..4c84872a09 100644 --- a/cura/OAuth2/Models.py +++ b/cura/OAuth2/Models.py @@ -1,6 +1,8 @@ -# Copyright (c) 2020 Ultimaker B.V. +# Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, Dict, Any, List +from typing import Optional, Dict, Any, List, Union +from copy import deepcopy +from cura.OAuth2.KeyringAttribute import KeyringAttribute class BaseModel: @@ -37,12 +39,29 @@ class AuthenticationResponse(BaseModel): # Data comes from the token response with success flag and error message added. success = True # type: bool token_type = None # type: Optional[str] - access_token = None # type: Optional[str] - refresh_token = None # type: Optional[str] expires_in = None # type: Optional[str] scope = None # type: Optional[str] err_message = None # type: Optional[str] received_at = None # type: Optional[str] + access_token = KeyringAttribute() + refresh_token = KeyringAttribute() + + def __init__(self, **kwargs: Any) -> None: + self.access_token = kwargs.pop("access_token", None) + self.refresh_token = kwargs.pop("refresh_token", None) + super(AuthenticationResponse, self).__init__(**kwargs) + + def dump(self) -> Dict[str, Union[bool, Optional[str]]]: + """ + Dumps the dictionary of Authentication attributes. KeyringAttributes are transformed to public attributes + If the keyring was used, these will have a None value, otherwise they will have the secret value + + :return: Dictionary of Authentication attributes + """ + dumped = deepcopy(vars(self)) + dumped["access_token"] = dumped.pop("_access_token") + dumped["refresh_token"] = dumped.pop("_refresh_token") + return dumped class ResponseStatus(BaseModel): diff --git a/requirements.txt b/requirements.txt index b13e160868..2233fb81e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,3 +32,5 @@ urllib3==1.25.6 PyYAML==5.1.2 zeroconf==0.24.1 comtypes==1.1.7 +pywin32==300 +keyring==23.0.1 diff --git a/resources/qml/Dialogs/AboutDialog.qml b/resources/qml/Dialogs/AboutDialog.qml index f8018d3d34..1ee0b31040 100644 --- a/resources/qml/Dialogs/AboutDialog.qml +++ b/resources/qml/Dialogs/AboutDialog.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2020 Ultimaker B.V. +// Copyright (c) 2021 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.2 @@ -15,7 +15,7 @@ UM.Dialog title: catalog.i18nc("@title:window The argument is the application name.", "About %1").arg(CuraApplication.applicationDisplayName) minimumWidth: 500 * screenScaleFactor - minimumHeight: 650 * screenScaleFactor + minimumHeight: 700 * screenScaleFactor width: minimumWidth height: minimumHeight @@ -158,7 +158,8 @@ UM.Dialog projectsModel.append({ name: "Sentry", description: catalog.i18nc("@Label", "Python Error tracking library"), license: "BSD 2-Clause 'Simplified'", url: "https://sentry.io/for/python/" }); projectsModel.append({ name: "libnest2d", description: catalog.i18nc("@label", "Polygon packing library, developed by Prusa Research"), license: "LGPL", url: "https://github.com/tamasmeszaros/libnest2d" }); projectsModel.append({ name: "pynest2d", description: catalog.i18nc("@label", "Python bindings for libnest2d"), license: "LGPL", url: "https://github.com/Ultimaker/pynest2d" }); - + projectsModel.append({ name: "keyring", description: catalog.i18nc("@label", "Support library for system keyring access"), license: "MIT", url: "https://github.com/jaraco/keyring" }); + projectsModel.append({ name: "pywin32", description: catalog.i18nc("@label", "Python extensions for Microsoft Windows"), license: "PSF", url: "https://github.com/mhammond/pywin32" }); projectsModel.append({ name: "Noto Sans", description: catalog.i18nc("@label", "Font"), license: "Apache 2.0", url: "https://www.google.com/get/noto/" }); projectsModel.append({ name: "Font-Awesome-SVG-PNG", description: catalog.i18nc("@label", "SVG icons"), license: "SIL OFL 1.1", url: "https://github.com/encharm/Font-Awesome-SVG-PNG" }); projectsModel.append({ name: "AppImageKit", description: catalog.i18nc("@label", "Linux cross-distribution application deployment"), license: "MIT", url: "https://github.com/AppImage/AppImageKit" });