diff --git a/CHANGES b/CHANGES index 2263a5448..0183a56d0 100644 --- a/CHANGES +++ b/CHANGES @@ -7,6 +7,7 @@ Version 0.15 ------------ (feature release, released on X) +- Require approval before accessing previously unknown unencrypted repositories (#271) - Fix issue with hash index files larger than 2GB. - Fix Python 3.2 compatibility issue with noatime open() (#164) - Include missing pyx files in dist files (#168) diff --git a/attic/archiver.py b/attic/archiver.py index 3ff09741d..ebb2ef1d6 100644 --- a/attic/archiver.py +++ b/attic/archiver.py @@ -62,6 +62,7 @@ class Archiver: manifest.key = key manifest.write() repository.commit() + Cache(repository, key, manifest, warn_if_unencrypted=False) return self.exit_code def do_check(self, args): diff --git a/attic/cache.py b/attic/cache.py index 8f35f00c5..cf7ff8a4b 100644 --- a/attic/cache.py +++ b/attic/cache.py @@ -2,9 +2,11 @@ from configparser import RawConfigParser from attic.remote import cache_if_remote import msgpack import os +import sys from binascii import hexlify import shutil +from .key import PlaintextKey from .helpers import Error, get_cache_dir, decode_dict, st_mtime_ns, unhexlify, UpgradableLock, int_to_bigint, \ bigint_to_int from .hashindex import ChunkIndex @@ -16,7 +18,17 @@ class Cache: class RepositoryReplay(Error): """Cache is newer than repository, refusing to continue""" - def __init__(self, repository, key, manifest, path=None, sync=True, do_files=False): + + class CacheInitAbortedError(Error): + """Cache initialization aborted""" + + + class EncryptionMethodMismatch(Error): + """Repository encryption method changed since last acccess, refusing to continue + """ + + def __init__(self, repository, key, manifest, path=None, sync=True, do_files=False, warn_if_unencrypted=True): + self.lock = None self.timestamp = None self.lock = None self.txn_active = False @@ -26,12 +38,21 @@ class Cache: self.path = path or os.path.join(get_cache_dir(), hexlify(repository.id).decode('ascii')) self.do_files = do_files if not os.path.exists(self.path): + if warn_if_unencrypted and isinstance(key, PlaintextKey): + if 'ATTIC_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK' not in os.environ: + print("""Warning: Attempting to access a previously unknown unencrypted repository\n""", file=sys.stderr) + answer = input('Do you want to continue? [yN] ') + if not (answer and answer in 'Yy'): + raise self.CacheInitAbortedError() self.create() self.open() if sync and self.manifest.id != self.manifest_id: # If repository is older than the cache something fishy is going on if self.timestamp and self.timestamp > manifest.timestamp: raise self.RepositoryReplay() + # Make sure an encrypted repository has not been swapped for an unencrypted repository + if self.key_type is not None and self.key_type != str(key.TYPE): + raise self.EncryptionMethodMismatch() self.sync() self.commit() @@ -67,6 +88,7 @@ class Cache: self.id = self.config.get('cache', 'repository') self.manifest_id = unhexlify(self.config.get('cache', 'manifest')) self.timestamp = self.config.get('cache', 'timestamp', fallback=None) + self.key_type = self.config.get('cache', 'key_type', fallback=None) self.chunks = ChunkIndex.read(os.path.join(self.path, 'chunks').encode('utf-8')) self.files = None @@ -116,6 +138,7 @@ class Cache: msgpack.pack((path_hash, item), fd) self.config.set('cache', 'manifest', hexlify(self.manifest.id).decode('ascii')) self.config.set('cache', 'timestamp', self.manifest.timestamp) + self.config.set('cache', 'key_type', str(self.key.TYPE)) with open(os.path.join(self.path, 'config'), 'w') as fd: self.config.write(fd) self.chunks.write(os.path.join(self.path, 'chunks').encode('utf-8')) diff --git a/attic/testsuite/archiver.py b/attic/testsuite/archiver.py index 0cbad383e..d65acc699 100644 --- a/attic/testsuite/archiver.py +++ b/attic/testsuite/archiver.py @@ -1,3 +1,5 @@ +from binascii import hexlify +from configparser import RawConfigParser import os from io import StringIO import stat @@ -11,6 +13,7 @@ from hashlib import sha256 from attic import xattr from attic.archive import Archive, ChunkBuffer from attic.archiver import Archiver +from attic.cache import Cache from attic.crypto import bytes_to_long, num_aes_blocks from attic.helpers import Manifest from attic.remote import RemoteRepository, PathNotAllowed @@ -41,6 +44,22 @@ class changedir: os.chdir(self.old) +class environment_variable: + def __init__(self, **values): + self.values = values + self.old_values = {} + + def __enter__(self): + for k, v in self.values.items(): + self.old_values[k] = os.environ.get(k) + os.environ[k] = v + + def __exit__(self, *args, **kw): + for k, v in self.old_values.items(): + if v is not None: + os.environ[k] = v + + class ArchiverTestCaseBase(AtticTestCase): prefix = '' @@ -170,11 +189,35 @@ class ArchiverTestCase(ArchiverTestCaseBase): info_output = self.attic('info', self.repository_location + '::test') self.assert_in('Number of files: 4', info_output) shutil.rmtree(self.cache_path) - info_output2 = self.attic('info', self.repository_location + '::test') + with environment_variable(ATTIC_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK='1'): + info_output2 = self.attic('info', self.repository_location + '::test') # info_output2 starts with some "initializing cache" text but should # end the same way as info_output assert info_output2.endswith(info_output) + def _extract_repository_id(self, path): + return Repository(self.repository_path).id + + def _set_repository_id(self, path, id): + config = RawConfigParser() + config.read(os.path.join(path, 'config')) + config.set('repository', 'id', hexlify(id).decode('ascii')) + with open(os.path.join(path, 'config'), 'w') as fd: + config.write(fd) + return Repository(self.repository_path).id + + def test_repository_swap_detection(self): + self.create_test_files() + os.environ['ATTIC_PASSPHRASE'] = 'passphrase' + self.attic('init', '--encryption=passphrase', self.repository_location) + repository_id = self._extract_repository_id(self.repository_path) + self.attic('create', self.repository_location + '::test', 'input') + shutil.rmtree(self.repository_path) + self.attic('init', '--encryption=none', self.repository_location) + self._set_repository_id(self.repository_path, repository_id) + self.assert_equal(repository_id, self._extract_repository_id(self.repository_path)) + self.assert_raises(Cache.EncryptionMethodMismatch, lambda :self.attic('create', self.repository_location + '::test.2', 'input')) + def test_strip_components(self): self.attic('init', self.repository_location) self.create_regular_file('dir/file')