diff --git a/pyproject.toml b/pyproject.toml index 649faeca6..a0e7d1b8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,7 +142,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" "scripts/make.py" = ["E501"] "src/borg/archive.py" = ["E501"] "src/borg/archiver/help_cmd.py" = ["E501"] -"src/borg/cache.py" = ["E501"] +"src/borg/security.py" = ["E501"] "src/borg/helpers/__init__.py" = ["F401"] "src/borg/platform/__init__.py" = ["F401"] "src/borg/testsuite/archiver/disk_full_test.py" = ["F811"] diff --git a/src/borg/archiver/repo_delete_cmd.py b/src/borg/archiver/repo_delete_cmd.py index d9774ee35..5fff18176 100644 --- a/src/borg/archiver/repo_delete_cmd.py +++ b/src/borg/archiver/repo_delete_cmd.py @@ -1,5 +1,6 @@ from ._common import with_repository -from ..cache import Cache, SecurityManager +from ..cache import Cache +from ..security import SecurityManager from ..constants import * # NOQA from ..helpers import CancelledByUser from ..helpers import format_archive diff --git a/src/borg/cache.py b/src/borg/cache.py index c69553f84..464311d24 100644 --- a/src/borg/cache.py +++ b/src/borg/cache.py @@ -22,22 +22,20 @@ from borgstore.store import ItemInfo from .constants import CACHE_README, FILES_CACHE_MODE_DISABLED, ROBJ_FILE_STREAM, TIME_DIFFERS2_NS from .hashindex import ChunkIndex, ChunkIndexEntry -from .helpers import Error -from .helpers import get_cache_dir, get_security_dir +from .helpers import get_cache_dir from .helpers import hex_to_bin, bin_to_hex, parse_stringified_list from .helpers import format_file_size, safe_encode from .helpers import safe_ns -from .helpers import yes from .helpers import ProgressIndicatorMessage from .helpers import msgpack from .helpers.msgpack import int_to_timestamp, timestamp_to_int from .item import ChunkListEntry -from .crypto.key import PlaintextKey from .crypto.file_integrity import IntegrityCheckedFile, FileIntegrityError from .manifest import Manifest from .platform import SaveFile from .remote import RemoteRepository from .repository import LIST_SCAN_LIMIT, Repository, StoreObjectNotFound, repo_lister +from .security import SecurityManager, assert_secure # noqa: F401 def files_cache_name(archive_name, files_cache_name="files"): @@ -72,169 +70,6 @@ def discover_files_cache_names(path, files_cache_name="files"): FileCacheEntry = namedtuple("FileCacheEntry", "age inode size ctime mtime chunks") -class SecurityManager: - """ - Tracks repositories. Ensures that nothing bad happens (repository swaps, - replay attacks, unknown repositories, etc.). - - This is complicated by the cache being initially used for this, while - only some commands actually use the cache, which meant that other commands - did not perform these checks. - - Further complications were created by the cache being a cache, so it - could be legitimately deleted, which is annoying because Borg did not - recognize repositories after that. - - Therefore, a second location, the security database (see get_security_dir), - was introduced, which stores this information. However, this means that - the code has to deal with a cache existing but no security database entry, - or inconsistencies between the security database and the cache which have to - be reconciled, and also with no cache existing but a security database entry. - """ - - def __init__(self, repository): - self.repository = repository - self.dir = Path(get_security_dir(repository.id_str, legacy=(repository.version == 1))) - self.key_type_file = self.dir / "key-type" - self.location_file = self.dir / "location" - self.manifest_ts_file = self.dir / "manifest-timestamp" - - @staticmethod - def destroy(repository, path=None): - """Destroys the security directory for ``repository`` or at ``path``.""" - path = path or get_security_dir(repository.id_str, legacy=(repository.version == 1)) - if Path(path).exists(): - shutil.rmtree(path) - - def known(self): - return all(f.exists() for f in (self.key_type_file, self.location_file, self.manifest_ts_file)) - - def key_matches(self, key): - if not self.known(): - return False - try: - with self.key_type_file.open() as fd: - type = fd.read() - return type == str(key.TYPE) - except OSError as exc: - logger.warning("Could not read/parse key type file: %s", exc) - - def save(self, manifest, key): - logger.debug("security: saving state for %s to %s", self.repository.id_str, str(self.dir)) - current_location = self.repository._location.canonical_path() - logger.debug("security: current location %s", current_location) - logger.debug("security: key type %s", str(key.TYPE)) - logger.debug("security: manifest timestamp %s", manifest.timestamp) - with SaveFile(self.location_file) as fd: - fd.write(current_location) - with SaveFile(self.key_type_file) as fd: - fd.write(str(key.TYPE)) - with SaveFile(self.manifest_ts_file) as fd: - fd.write(manifest.timestamp) - - def assert_location_matches(self): - # Warn user before sending data to a relocated repository - try: - with self.location_file.open() as fd: - previous_location = fd.read() - logger.debug("security: read previous location %r", previous_location) - except FileNotFoundError: - logger.debug("security: previous location file %s not found", self.location_file) - previous_location = None - except OSError as exc: - logger.warning("Could not read previous location file: %s", exc) - previous_location = None - - repository_location = self.repository._location.canonical_path() - if previous_location and previous_location != repository_location: - msg = ( - "Warning: The repository at location {} was previously located at {}\n".format( - repository_location, previous_location - ) - + "Do you want to continue? [yN] " - ) - if not yes( - msg, - false_msg="Aborting.", - invalid_msg="Invalid answer, aborting.", - retry=False, - env_var_override="BORG_RELOCATED_REPO_ACCESS_IS_OK", - ): - raise Cache.RepositoryAccessAborted() - # adapt on-disk config immediately if the new location was accepted - logger.debug("security: updating location stored in security dir") - with SaveFile(self.location_file) as fd: - fd.write(repository_location) - - def assert_no_manifest_replay(self, manifest, key): - try: - with self.manifest_ts_file.open() as fd: - timestamp = fd.read() - logger.debug("security: read manifest timestamp %r", timestamp) - except FileNotFoundError: - logger.debug("security: manifest timestamp file %s not found", self.manifest_ts_file) - timestamp = "" - except OSError as exc: - logger.warning("Could not read previous location file: %s", exc) - timestamp = "" - logger.debug("security: determined newest manifest timestamp as %s", timestamp) - # If repository is older than the cache or security dir something fishy is going on - if timestamp and timestamp > manifest.timestamp: - if isinstance(key, PlaintextKey): - raise Cache.RepositoryIDNotUnique() - else: - raise Cache.RepositoryReplay() - - def assert_key_type(self, key): - # Make sure an encrypted repository has not been swapped for an unencrypted repository - if self.known() and not self.key_matches(key): - raise Cache.EncryptionMethodMismatch() - - def assert_secure(self, manifest, key, *, warn_if_unencrypted=True): - # warn_if_unencrypted=False is only used for initializing a new repository. - # Thus, avoiding asking about a repository that's currently initializing. - self.assert_access_unknown(warn_if_unencrypted, manifest, key) - self._assert_secure(manifest, key) - logger.debug("security: repository checks ok, allowing access") - - def _assert_secure(self, manifest, key): - self.assert_location_matches() - self.assert_key_type(key) - self.assert_no_manifest_replay(manifest, key) - if not self.known(): - logger.debug("security: remembering previously unknown repository") - self.save(manifest, key) - - def assert_access_unknown(self, warn_if_unencrypted, manifest, key): - # warn_if_unencrypted=False is only used for initializing a new repository. - # Thus, avoiding asking about a repository that's currently initializing. - if not key.logically_encrypted and not self.known(): - msg = ( - "Warning: Attempting to access a previously unknown unencrypted repository!\n" - + "Do you want to continue? [yN] " - ) - allow_access = not warn_if_unencrypted or yes( - msg, - false_msg="Aborting.", - invalid_msg="Invalid answer, aborting.", - retry=False, - env_var_override="BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK", - ) - if allow_access: - if warn_if_unencrypted: - logger.debug("security: remembering unknown unencrypted repository (explicitly allowed)") - else: - logger.debug("security: initializing unencrypted repository") - self.save(manifest, key) - else: - raise Cache.CacheInitAbortedError() - - -def assert_secure(repository, manifest): - sm = SecurityManager(repository) - sm.assert_secure(manifest, manifest.key) - - def cache_dir(repository, path=None): return Path(path) if path else Path(get_cache_dir()) / repository.id_str @@ -330,30 +165,13 @@ class CacheConfig: class Cache: """Client Side cache""" - class CacheInitAbortedError(Error): - """Cache initialization aborted""" - - exit_mcode = 60 - - class EncryptionMethodMismatch(Error): - """Repository encryption method changed since last access, refusing to continue""" - - exit_mcode = 61 - - class RepositoryAccessAborted(Error): - """Repository access aborted""" - - exit_mcode = 62 - - class RepositoryIDNotUnique(Error): - """Cache is newer than repository - do you have multiple, independently updated repos with same ID?""" - - exit_mcode = 63 - - class RepositoryReplay(Error): - """Cache, or information obtained from the security directory is newer than repository - this is either an attack or unsafe (multiple repos with same ID)""" - - exit_mcode = 64 + from .security import ( + CacheInitAbortedError, + EncryptionMethodMismatch, + RepositoryAccessAborted, + RepositoryIDNotUnique, + RepositoryReplay, + ) # noqa: F401 @staticmethod def break_lock(repository, path=None): diff --git a/src/borg/security.py b/src/borg/security.py new file mode 100644 index 000000000..a72130ec6 --- /dev/null +++ b/src/borg/security.py @@ -0,0 +1,206 @@ +import shutil +from pathlib import Path + +from .helpers import Error +from .helpers import get_security_dir +from .helpers import yes +from .platform import SaveFile + +from .logger import create_logger + +logger = create_logger() + + +class CacheInitAbortedError(Error): + """Cache initialization aborted""" + + exit_mcode = 60 + + +class EncryptionMethodMismatch(Error): + """Repository encryption method changed since last access, refusing to continue""" + + exit_mcode = 61 + + +class RepositoryAccessAborted(Error): + """Repository access aborted""" + + exit_mcode = 62 + + +class RepositoryIDNotUnique(Error): + """Cache is newer than repository - do you have multiple, independently updated repos with same ID?""" + + exit_mcode = 63 + + +class RepositoryReplay(Error): + """Cache, or information obtained from the security directory is newer than repository - this is either an attack or unsafe (multiple repos with same ID)""" + + exit_mcode = 64 + + +class SecurityManager: + """ + Tracks repositories. Ensures that nothing bad happens (repository swaps, + replay attacks, unknown repositories, etc.). + + This is complicated by the cache being initially used for this, while + only some commands actually use the cache, which meant that other commands + did not perform these checks. + + Further complications were created by the cache being a cache, so it + could be legitimately deleted, which is annoying because Borg did not + recognize repositories after that. + + Therefore, a second location, the security database (see get_security_dir), + was introduced, which stores this information. However, this means that + the code has to deal with a cache existing but no security database entry, + or inconsistencies between the security database and the cache which have to + be reconciled, and also with no cache existing but a security database entry. + """ + + def __init__(self, repository): + self.repository = repository + self.dir = Path(get_security_dir(repository.id_str, legacy=(repository.version == 1))) + self.key_type_file = self.dir / "key-type" + self.location_file = self.dir / "location" + self.manifest_ts_file = self.dir / "manifest-timestamp" + + @staticmethod + def destroy(repository, path=None): + """Destroys the security directory for ``repository`` or at ``path``.""" + path = path or get_security_dir(repository.id_str, legacy=(repository.version == 1)) + if Path(path).exists(): + shutil.rmtree(path) + + def known(self): + return all(f.exists() for f in (self.key_type_file, self.location_file, self.manifest_ts_file)) + + def key_matches(self, key): + if not self.known(): + return False + try: + with self.key_type_file.open() as fd: + type = fd.read() + return type == str(key.TYPE) + except OSError as exc: + logger.warning("Could not read/parse key type file: %s", exc) + + def save(self, manifest, key): + logger.debug("security: saving state for %s to %s", self.repository.id_str, str(self.dir)) + current_location = self.repository._location.canonical_path() + logger.debug("security: current location %s", current_location) + logger.debug("security: key type %s", str(key.TYPE)) + logger.debug("security: manifest timestamp %s", manifest.timestamp) + with SaveFile(self.location_file) as fd: + fd.write(current_location) + with SaveFile(self.key_type_file) as fd: + fd.write(str(key.TYPE)) + with SaveFile(self.manifest_ts_file) as fd: + fd.write(manifest.timestamp) + + def assert_location_matches(self): + # Warn user before sending data to a relocated repository + try: + with self.location_file.open() as fd: + previous_location = fd.read() + logger.debug("security: read previous location %r", previous_location) + except FileNotFoundError: + logger.debug("security: previous location file %s not found", self.location_file) + previous_location = None + except OSError as exc: + logger.warning("Could not read previous location file: %s", exc) + previous_location = None + + repository_location = self.repository._location.canonical_path() + if previous_location and previous_location != repository_location: + msg = ( + "Warning: The repository at location {} was previously located at {}\n".format( + repository_location, previous_location + ) + + "Do you want to continue? [yN] " + ) + if not yes( + msg, + false_msg="Aborting.", + invalid_msg="Invalid answer, aborting.", + retry=False, + env_var_override="BORG_RELOCATED_REPO_ACCESS_IS_OK", + ): + raise RepositoryAccessAborted() + # adapt on-disk config immediately if the new location was accepted + logger.debug("security: updating location stored in security dir") + with SaveFile(self.location_file) as fd: + fd.write(repository_location) + + def assert_no_manifest_replay(self, manifest, key): + from .crypto.key import PlaintextKey + + try: + with self.manifest_ts_file.open() as fd: + timestamp = fd.read() + logger.debug("security: read manifest timestamp %r", timestamp) + except FileNotFoundError: + logger.debug("security: manifest timestamp file %s not found", self.manifest_ts_file) + timestamp = "" + except OSError as exc: + logger.warning("Could not read previous location file: %s", exc) + timestamp = "" + logger.debug("security: determined newest manifest timestamp as %s", timestamp) + # If repository is older than the cache or security dir something fishy is going on + if timestamp and timestamp > manifest.timestamp: + if isinstance(key, PlaintextKey): + raise RepositoryIDNotUnique() + else: + raise RepositoryReplay() + + def assert_key_type(self, key): + # Make sure an encrypted repository has not been swapped for an unencrypted repository + if self.known() and not self.key_matches(key): + raise EncryptionMethodMismatch() + + def assert_secure(self, manifest, key, *, warn_if_unencrypted=True): + # warn_if_unencrypted=False is only used for initializing a new repository. + # Thus, avoiding asking about a repository that's currently initializing. + self.assert_access_unknown(warn_if_unencrypted, manifest, key) + self._assert_secure(manifest, key) + logger.debug("security: repository checks ok, allowing access") + + def _assert_secure(self, manifest, key): + self.assert_location_matches() + self.assert_key_type(key) + self.assert_no_manifest_replay(manifest, key) + if not self.known(): + logger.debug("security: remembering previously unknown repository") + self.save(manifest, key) + + def assert_access_unknown(self, warn_if_unencrypted, manifest, key): + # warn_if_unencrypted=False is only used for initializing a new repository. + # Thus, avoiding asking about a repository that's currently initializing. + if not key.logically_encrypted and not self.known(): + msg = ( + "Warning: Attempting to access a previously unknown unencrypted repository!\n" + + "Do you want to continue? [yN] " + ) + allow_access = not warn_if_unencrypted or yes( + msg, + false_msg="Aborting.", + invalid_msg="Invalid answer, aborting.", + retry=False, + env_var_override="BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK", + ) + if allow_access: + if warn_if_unencrypted: + logger.debug("security: remembering unknown unencrypted repository (explicitly allowed)") + else: + logger.debug("security: initializing unencrypted repository") + self.save(manifest, key) + else: + raise CacheInitAbortedError() + + +def assert_secure(repository, manifest): + sm = SecurityManager(repository) + sm.assert_secure(manifest, manifest.key)