move SecurityManager code from borg.cache to borg.security

This commit is contained in:
Thomas Waldmann 2026-05-28 14:20:28 +02:00
parent 1a359eacb5
commit b45f233490
No known key found for this signature in database
GPG key ID: 243ACFA951F78E01
4 changed files with 218 additions and 193 deletions

View file

@ -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"]

View file

@ -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

View file

@ -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):

206
src/borg/security.py Normal file
View file

@ -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)