From d42e6f2c414098119073c75cc07b481aece80ac2 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 6 Mar 2022 17:03:58 +0100 Subject: [PATCH 1/8] key: uses key_cls.TYPES_ACCEPTABLE to dispatch for now only a quite simple change, replacing the hardcoded PassphraseKey -> RepoKey dispatching. --- src/borg/crypto/key.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index bf2d23817..f49cd25b4 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -145,6 +145,8 @@ def tam_required(repository): class KeyBase: # Numeric key type ID, must fit in one byte. TYPE = None # override in subclasses + # set of key type IDs the class can handle as input + TYPES_ACCEPTABLE = None # override in subclasses # Human-readable name NAME = 'UNDEFINED' @@ -259,6 +261,7 @@ def unpack_and_verify_manifest(self, data, force_tam_not_required=False): class PlaintextKey(KeyBase): TYPE = 0x02 + TYPES_ACCEPTABLE = {TYPE} NAME = 'plaintext' ARG_NAME = 'none' STORAGE = KeyBlobStorage.NO_STORAGE @@ -287,7 +290,7 @@ def encrypt(self, chunk): return b''.join([self.TYPE_STR, data]) def decrypt(self, id, data, decompress=True): - if data[0] != self.TYPE: + if data[0] not in self.TYPES_ACCEPTABLE: id_str = bin_to_hex(id) if id is not None else '(unknown)' raise IntegrityError('Chunk %s: Invalid encryption envelope' % id_str) payload = memoryview(data)[1:] @@ -367,8 +370,7 @@ def encrypt(self, chunk): return self.cipher.encrypt(data, header=self.TYPE_STR, iv=next_iv) def decrypt(self, id, data, decompress=True): - if not (data[0] == self.TYPE or - data[0] == PassphraseKey.TYPE and isinstance(self, RepoKey)): + if data[0] not in self.TYPES_ACCEPTABLE: id_str = bin_to_hex(id) if id is not None else '(unknown)' raise IntegrityError('Chunk %s: Invalid encryption envelope' % id_str) try: @@ -396,8 +398,7 @@ def init_ciphers(self, manifest_data=None): if manifest_data is None: nonce = 0 else: - if not (manifest_data[0] == self.TYPE or - manifest_data[0] == PassphraseKey.TYPE and isinstance(self, RepoKey)): + if manifest_data[0] not in self.TYPES_ACCEPTABLE: raise IntegrityError('Manifest: Invalid encryption envelope') # manifest_blocks is a safe upper bound on the amount of cipher blocks needed # to encrypt the manifest. depending on the ciphersuite and overhead, it might @@ -641,6 +642,7 @@ def get_new_target(self, args): class KeyfileKey(ID_HMAC_SHA_256, KeyfileKeyBase): TYPE = 0x00 + TYPES_ACCEPTABLE = {TYPE} NAME = 'key file' ARG_NAME = 'keyfile' STORAGE = KeyBlobStorage.KEYFILE @@ -731,6 +733,7 @@ def save(self, target, passphrase, create=False): class RepoKey(ID_HMAC_SHA_256, KeyfileKeyBase): TYPE = 0x03 + TYPES_ACCEPTABLE = {TYPE, PassphraseKey.TYPE} NAME = 'repokey' ARG_NAME = 'repokey' STORAGE = KeyBlobStorage.REPO @@ -770,6 +773,7 @@ def save(self, target, passphrase, create=False): class Blake2KeyfileKey(ID_BLAKE2b_256, KeyfileKey): TYPE = 0x04 + TYPES_ACCEPTABLE = {0x04} NAME = 'key file BLAKE2b' ARG_NAME = 'keyfile-blake2' STORAGE = KeyBlobStorage.KEYFILE @@ -780,6 +784,7 @@ class Blake2KeyfileKey(ID_BLAKE2b_256, KeyfileKey): class Blake2RepoKey(ID_BLAKE2b_256, RepoKey): TYPE = 0x05 + TYPES_ACCEPTABLE = {TYPE} NAME = 'repokey BLAKE2b' ARG_NAME = 'repokey-blake2' STORAGE = KeyBlobStorage.REPO @@ -824,12 +829,14 @@ def decrypt(self, id, data, decompress=True): class AuthenticatedKey(AuthenticatedKeyBase): TYPE = 0x07 + TYPES_ACCEPTABLE = {TYPE} NAME = 'authenticated' ARG_NAME = 'authenticated' class Blake2AuthenticatedKey(ID_BLAKE2b_256, AuthenticatedKeyBase): TYPE = 0x06 + TYPES_ACCEPTABLE = {TYPE} NAME = 'authenticated BLAKE2b' ARG_NAME = 'authenticated-blake2' From ad405892ca09a6abe35c7c88f86f45c139e0d952 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 6 Mar 2022 17:07:17 +0100 Subject: [PATCH 2/8] rename KeyfileKeyBase -> FlexiKeyBase it is used as direct base class for both: - RepoKey - KeyfileKey --- src/borg/crypto/key.py | 6 +++--- src/borg/testsuite/archiver.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index f49cd25b4..49cea56c0 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -527,7 +527,7 @@ class PassphraseKey: NAME = 'passphrase' -class KeyfileKeyBase(AESKeyBase): +class FlexiKeyBase(AESKeyBase): @classmethod def detect(cls, repository, manifest_data): key = cls(repository) @@ -640,7 +640,7 @@ def get_new_target(self, args): raise NotImplementedError -class KeyfileKey(ID_HMAC_SHA_256, KeyfileKeyBase): +class KeyfileKey(ID_HMAC_SHA_256, FlexiKeyBase): TYPE = 0x00 TYPES_ACCEPTABLE = {TYPE} NAME = 'key file' @@ -731,7 +731,7 @@ def save(self, target, passphrase, create=False): self.target = target -class RepoKey(ID_HMAC_SHA_256, KeyfileKeyBase): +class RepoKey(ID_HMAC_SHA_256, FlexiKeyBase): TYPE = 0x03 TYPES_ACCEPTABLE = {TYPE, PassphraseKey.TYPE} NAME = 'repokey' diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index 58b760be3..5021b7aa7 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -36,7 +36,7 @@ from ..chunker import has_seek_hole from ..constants import * # NOQA from ..crypto.low_level import bytes_to_long, num_cipher_blocks -from ..crypto.key import KeyfileKeyBase, RepoKey, KeyfileKey, Passphrase, TAMRequiredError +from ..crypto.key import FlexiKeyBase, RepoKey, KeyfileKey, Passphrase, TAMRequiredError from ..crypto.keymanager import RepoIdMismatch, NotABorgKeyFile from ..crypto.file_integrity import FileIntegrityError from ..helpers import Location, get_security_dir @@ -2850,7 +2850,7 @@ def test_init_interrupt(self): def raise_eof(*args): raise EOFError - with patch.object(KeyfileKeyBase, 'create', raise_eof): + with patch.object(FlexiKeyBase, 'create', raise_eof): self.cmd('init', '--encryption=repokey', self.repository_location, exit_code=1) assert not os.path.exists(self.repository_location) From 8011fade91f4a3e1d2fc550b7256a71e19890b2c Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 6 Mar 2022 19:44:35 +0100 Subject: [PATCH 3/8] move key loading/saving code to FlexiKey --- src/borg/crypto/key.py | 201 ++++++++++++++++++++------------------ src/borg/testsuite/key.py | 4 +- 2 files changed, 111 insertions(+), 94 deletions(-) diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 49cea56c0..46c81dc62 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -103,6 +103,17 @@ class KeyBlobStorage: REPO = 'repository' +class KeyType: + KEYFILE = 0x00 + PASSPHRASE = 0x01 # legacy, attic and borg < 1.0 + PLAINTEXT = 0x02 + REPO = 0x03 + BLAKE2KEYFILE = 0x04 + BLAKE2REPO = 0x05 + BLAKE2AUTHENTICATED = 0x06 + AUTHENTICATED = 0x07 + + def key_creator(repository, args): for key in AVAILABLE_KEY_TYPES: if key.ARG_NAME == args.encryption: @@ -260,7 +271,7 @@ def unpack_and_verify_manifest(self, data, force_tam_not_required=False): class PlaintextKey(KeyBase): - TYPE = 0x02 + TYPE = KeyType.PLAINTEXT TYPES_ACCEPTABLE = {TYPE} NAME = 'plaintext' ARG_NAME = 'none' @@ -518,15 +529,6 @@ def kdf(self, salt, iterations, length): return pbkdf2_hmac('sha256', self.encode('utf-8'), salt, iterations, length) -class PassphraseKey: - # this is only a stub, repos with this mode could not be created any more since borg 1.0, see #97. - # in borg 1.3 all of its code and also the "borg key migrate-to-repokey" command was removed. - # if you still need to, you can use "borg key migrate-to-repokey" with borg 1.0, 1.1 and 1.2. - # Nowadays, we just dispatch this to RepoKey and assume the passphrase was migrated to a repokey. - TYPE = 0x01 - NAME = 'passphrase' - - class FlexiKeyBase(AESKeyBase): @classmethod def detect(cls, repository, manifest_data): @@ -640,12 +642,8 @@ def get_new_target(self, args): raise NotImplementedError -class KeyfileKey(ID_HMAC_SHA_256, FlexiKeyBase): - TYPE = 0x00 - TYPES_ACCEPTABLE = {TYPE} - NAME = 'key file' - ARG_NAME = 'keyfile' - STORAGE = KeyBlobStorage.KEYFILE +class FlexiKey(ID_HMAC_SHA_256, FlexiKeyBase): + TYPES_ACCEPTABLE = {KeyType.KEYFILE, KeyType.REPO, KeyType.PASSPHRASE} FILE_ID = 'BORG_KEY' @@ -662,13 +660,22 @@ def sanity_check(self, filename, id): return filename def find_key(self): - keyfile = self._find_key_file_from_environment() - if keyfile is not None: - return self.sanity_check(keyfile, self.repository.id) - keyfile = self._find_key_in_keys_dir() - if keyfile is not None: - return keyfile - raise KeyfileNotFoundError(self.repository._location.canonical_path(), get_keys_dir()) + if self.STORAGE == KeyBlobStorage.KEYFILE: + keyfile = self._find_key_file_from_environment() + if keyfile is not None: + return self.sanity_check(keyfile, self.repository.id) + keyfile = self._find_key_in_keys_dir() + if keyfile is not None: + return keyfile + raise KeyfileNotFoundError(self.repository._location.canonical_path(), get_keys_dir()) + + if self.STORAGE == KeyBlobStorage.REPO: + loc = self.repository._location.canonical_path() + try: + self.repository.load_key() + return loc + except configparser.NoOptionError: + raise RepoKeyNotFoundError(loc) from None def get_existing_or_new_target(self, args): keyfile = self._find_key_file_from_environment() @@ -690,10 +697,14 @@ def _find_key_in_keys_dir(self): pass def get_new_target(self, args): - keyfile = self._find_key_file_from_environment() - if keyfile is not None: - return keyfile - return self._get_new_target_in_keys_dir(args) + if self.STORAGE == KeyBlobStorage.KEYFILE: + keyfile = self._find_key_file_from_environment() + if keyfile is not None: + return keyfile + return self._get_new_target_in_keys_dir(args) + + if self.STORAGE == KeyBlobStorage.REPO: + return self.repository def _find_key_file_from_environment(self): keyfile = os.environ.get('BORG_KEY_FILE') @@ -710,89 +721,93 @@ def _get_new_target_in_keys_dir(self, args): return path def load(self, target, passphrase): - with open(target) as fd: - key_data = ''.join(fd.readlines()[1:]) - success = self._load(key_data, passphrase) - if success: - self.target = target - return success + if self.STORAGE == KeyBlobStorage.KEYFILE: + with open(target) as fd: + key_data = ''.join(fd.readlines()[1:]) + success = self._load(key_data, passphrase) + if success: + self.target = target + return success + + if self.STORAGE == KeyBlobStorage.REPO: + # While the repository is encrypted, we consider a repokey repository with a blank + # passphrase an unencrypted repository. + self.logically_encrypted = passphrase != '' + + # what we get in target is just a repo location, but we already have the repo obj: + target = self.repository + key_data = target.load_key() + key_data = key_data.decode('utf-8') # remote repo: msgpack issue #99, getting bytes + success = self._load(key_data, passphrase) + if success: + self.target = target + return success def save(self, target, passphrase, create=False): - if create and os.path.isfile(target): - # if a new keyfile key repository is created, ensure that an existing keyfile of another - # keyfile key repo is not accidentally overwritten by careless use of the BORG_KEY_FILE env var. - # see issue #6036 - raise Error('Aborting because key in "%s" already exists.' % target) - key_data = self._save(passphrase) - with SaveFile(target) as fd: - fd.write(f'{self.FILE_ID} {bin_to_hex(self.repository_id)}\n') - fd.write(key_data) - fd.write('\n') - self.target = target + if self.STORAGE == KeyBlobStorage.KEYFILE: + if create and os.path.isfile(target): + # if a new keyfile key repository is created, ensure that an existing keyfile of another + # keyfile key repo is not accidentally overwritten by careless use of the BORG_KEY_FILE env var. + # see issue #6036 + raise Error('Aborting because key in "%s" already exists.' % target) + key_data = self._save(passphrase) + with SaveFile(target) as fd: + fd.write(f'{self.FILE_ID} {bin_to_hex(self.repository_id)}\n') + fd.write(key_data) + fd.write('\n') + self.target = target + + if self.STORAGE == KeyBlobStorage.REPO: + self.logically_encrypted = passphrase != '' + key_data = self._save(passphrase) + key_data = key_data.encode('utf-8') # remote repo: msgpack issue #99, giving bytes + target.save_key(key_data) + self.target = target -class RepoKey(ID_HMAC_SHA_256, FlexiKeyBase): - TYPE = 0x03 - TYPES_ACCEPTABLE = {TYPE, PassphraseKey.TYPE} +class PassphraseKey: + # this is only a stub, repos with this mode could not be created any more since borg 1.0, see #97. + # in borg 1.3 all of its code and also the "borg key migrate-to-repokey" command was removed. + # if you still need to, you can use "borg key migrate-to-repokey" with borg 1.0, 1.1 and 1.2. + # Nowadays, we just dispatch this to RepoKey and assume the passphrase was migrated to a repokey. + TYPE = KeyType.PASSPHRASE + NAME = 'passphrase' + + +class KeyfileKey(FlexiKey): + TYPE = KeyType.KEYFILE + NAME = 'key file' + ARG_NAME = 'keyfile' + STORAGE = KeyBlobStorage.KEYFILE + + +class RepoKey(FlexiKey): + TYPE = KeyType.REPO NAME = 'repokey' ARG_NAME = 'repokey' STORAGE = KeyBlobStorage.REPO - def find_key(self): - loc = self.repository._location.canonical_path() - try: - self.repository.load_key() - return loc - except configparser.NoOptionError: - raise RepoKeyNotFoundError(loc) from None - def get_new_target(self, args): - return self.repository - - def load(self, target, passphrase): - # While the repository is encrypted, we consider a repokey repository with a blank - # passphrase an unencrypted repository. - self.logically_encrypted = passphrase != '' - - # what we get in target is just a repo location, but we already have the repo obj: - target = self.repository - key_data = target.load_key() - key_data = key_data.decode('utf-8') # remote repo: msgpack issue #99, getting bytes - success = self._load(key_data, passphrase) - if success: - self.target = target - return success - - def save(self, target, passphrase, create=False): - self.logically_encrypted = passphrase != '' - key_data = self._save(passphrase) - key_data = key_data.encode('utf-8') # remote repo: msgpack issue #99, giving bytes - target.save_key(key_data) - self.target = target +class Blake2FlexiKey(ID_BLAKE2b_256, FlexiKey): + TYPES_ACCEPTABLE = {KeyType.BLAKE2KEYFILE, KeyType.BLAKE2REPO} + CIPHERSUITE = AES256_CTR_BLAKE2b -class Blake2KeyfileKey(ID_BLAKE2b_256, KeyfileKey): - TYPE = 0x04 - TYPES_ACCEPTABLE = {0x04} +class Blake2KeyfileKey(Blake2FlexiKey): + TYPE = KeyType.BLAKE2KEYFILE NAME = 'key file BLAKE2b' ARG_NAME = 'keyfile-blake2' STORAGE = KeyBlobStorage.KEYFILE - FILE_ID = 'BORG_KEY' - CIPHERSUITE = AES256_CTR_BLAKE2b - -class Blake2RepoKey(ID_BLAKE2b_256, RepoKey): - TYPE = 0x05 - TYPES_ACCEPTABLE = {TYPE} +class Blake2RepoKey(Blake2FlexiKey): + TYPE = KeyType.BLAKE2REPO NAME = 'repokey BLAKE2b' ARG_NAME = 'repokey-blake2' STORAGE = KeyBlobStorage.REPO - CIPHERSUITE = AES256_CTR_BLAKE2b - -class AuthenticatedKeyBase(RepoKey): +class AuthenticatedKeyBase(FlexiKey): STORAGE = KeyBlobStorage.REPO # It's only authenticated, not encrypted. @@ -808,7 +823,7 @@ def save(self, target, passphrase, create=False): self.logically_encrypted = False def init_ciphers(self, manifest_data=None): - if manifest_data is not None and manifest_data[0] != self.TYPE: + if manifest_data is not None and manifest_data[0] not in self.TYPES_ACCEPTABLE: raise IntegrityError('Manifest: Invalid encryption envelope') def encrypt(self, chunk): @@ -816,7 +831,7 @@ def encrypt(self, chunk): return b''.join([self.TYPE_STR, data]) def decrypt(self, id, data, decompress=True): - if data[0] != self.TYPE: + if data[0] not in self.TYPES_ACCEPTABLE: id_str = bin_to_hex(id) if id is not None else '(unknown)' raise IntegrityError('Chunk %s: Invalid envelope' % id_str) payload = memoryview(data)[1:] @@ -828,14 +843,14 @@ def decrypt(self, id, data, decompress=True): class AuthenticatedKey(AuthenticatedKeyBase): - TYPE = 0x07 + TYPE = KeyType.AUTHENTICATED TYPES_ACCEPTABLE = {TYPE} NAME = 'authenticated' ARG_NAME = 'authenticated' class Blake2AuthenticatedKey(ID_BLAKE2b_256, AuthenticatedKeyBase): - TYPE = 0x06 + TYPE = KeyType.BLAKE2AUTHENTICATED TYPES_ACCEPTABLE = {TYPE} NAME = 'authenticated BLAKE2b' ARG_NAME = 'authenticated-blake2' diff --git a/src/borg/testsuite/key.py b/src/borg/testsuite/key.py index 534b4738a..6254cc6e3 100644 --- a/src/borg/testsuite/key.py +++ b/src/borg/testsuite/key.py @@ -184,7 +184,9 @@ def test_keyfile_blake2(self, monkeypatch, keys_dir): def _corrupt_byte(self, key, data, offset): data = bytearray(data) - data[offset] ^= 1 + # note: we corrupt in a way so that even corruption of the unauthenticated encryption type byte + # will trigger an IntegrityError (does not happen while we stay within TYPES_ACCEPTABLE). + data[offset] ^= 64 with pytest.raises(IntegrityErrorBase): key.decrypt(b'', data) From 766d976f46ea139dd76e9e8cfba51d43babeba9a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 6 Mar 2022 21:25:43 +0100 Subject: [PATCH 4/8] move passphrase related stuff to borg.helpers.passphrase --- src/borg/crypto/key.py | 134 +------------------------------ src/borg/helpers/passphrase.py | 141 +++++++++++++++++++++++++++++++++ src/borg/testsuite/helpers.py | 43 ++++++++++ src/borg/testsuite/key.py | 43 +--------- 4 files changed, 187 insertions(+), 174 deletions(-) create mode 100644 src/borg/helpers/passphrase.py diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 46c81dc62..6511f6463 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -1,13 +1,9 @@ import configparser -import getpass import hmac import os -import shlex -import sys import textwrap -import subprocess from binascii import a2b_base64, b2a_base64, hexlify -from hashlib import sha256, sha512, pbkdf2_hmac +from hashlib import sha256 from ..logger import create_logger @@ -17,11 +13,10 @@ from ..compress import Compressor from ..helpers import StableDict from ..helpers import Error, IntegrityError -from ..helpers import yes from ..helpers import get_keys_dir, get_security_dir from ..helpers import get_limited_unpacker from ..helpers import bin_to_hex -from ..helpers import prepare_subprocess_env +from ..helpers.passphrase import Passphrase, PasswordRetriesExceeded, PassphraseWrong from ..helpers import msgpack from ..item import Key, EncryptedKey from ..platform import SaveFile @@ -31,22 +26,6 @@ from .low_level import AES256_CTR_HMAC_SHA256, AES256_CTR_BLAKE2b -class NoPassphraseFailure(Error): - """can not acquire a passphrase: {}""" - - -class PassphraseWrong(Error): - """passphrase supplied in BORG_PASSPHRASE, by BORG_PASSCOMMAND or via BORG_PASSPHRASE_FD is incorrect.""" - - -class PasscommandFailure(Error): - """passcommand supplied in BORG_PASSCOMMAND failed: {}""" - - -class PasswordRetriesExceeded(Error): - """exceeded the maximum password retries""" - - class UnsupportedPayloadError(Error): """Unsupported payload type {}. A newer version is required to access this repository.""" @@ -420,115 +399,6 @@ def init_ciphers(self, manifest_data=None): self.nonce_manager = NonceManager(self.repository, nonce) -class Passphrase(str): - @classmethod - def _env_passphrase(cls, env_var, default=None): - passphrase = os.environ.get(env_var, default) - if passphrase is not None: - return cls(passphrase) - - @classmethod - def env_passphrase(cls, default=None): - passphrase = cls._env_passphrase('BORG_PASSPHRASE', default) - if passphrase is not None: - return passphrase - passphrase = cls.env_passcommand() - if passphrase is not None: - return passphrase - passphrase = cls.fd_passphrase() - if passphrase is not None: - return passphrase - - @classmethod - def env_passcommand(cls, default=None): - passcommand = os.environ.get('BORG_PASSCOMMAND', None) - if passcommand is not None: - # passcommand is a system command (not inside pyinstaller env) - env = prepare_subprocess_env(system=True) - try: - passphrase = subprocess.check_output(shlex.split(passcommand), universal_newlines=True, env=env) - except (subprocess.CalledProcessError, FileNotFoundError) as e: - raise PasscommandFailure(e) - return cls(passphrase.rstrip('\n')) - - @classmethod - def fd_passphrase(cls): - try: - fd = int(os.environ.get('BORG_PASSPHRASE_FD')) - except (ValueError, TypeError): - return None - with os.fdopen(fd, mode='r') as f: - passphrase = f.read() - return cls(passphrase.rstrip('\n')) - - @classmethod - def env_new_passphrase(cls, default=None): - return cls._env_passphrase('BORG_NEW_PASSPHRASE', default) - - @classmethod - def getpass(cls, prompt): - try: - pw = getpass.getpass(prompt) - except EOFError: - if prompt: - print() # avoid err msg appearing right of prompt - msg = [] - for env_var in 'BORG_PASSPHRASE', 'BORG_PASSCOMMAND': - env_var_set = os.environ.get(env_var) is not None - msg.append('{} is {}.'.format(env_var, 'set' if env_var_set else 'not set')) - msg.append('Interactive password query failed.') - raise NoPassphraseFailure(' '.join(msg)) from None - else: - return cls(pw) - - @classmethod - def verification(cls, passphrase): - msg = 'Do you want your passphrase to be displayed for verification? [yN]: ' - if yes(msg, retry_msg=msg, invalid_msg='Invalid answer, try again.', - retry=True, env_var_override='BORG_DISPLAY_PASSPHRASE'): - print('Your passphrase (between double-quotes): "%s"' % passphrase, - file=sys.stderr) - print('Make sure the passphrase displayed above is exactly what you wanted.', - file=sys.stderr) - try: - passphrase.encode('ascii') - except UnicodeEncodeError: - print('Your passphrase (UTF-8 encoding in hex): %s' % - bin_to_hex(passphrase.encode('utf-8')), - file=sys.stderr) - print('As you have a non-ASCII passphrase, it is recommended to keep the UTF-8 encoding in hex together with the passphrase at a safe place.', - file=sys.stderr) - - @classmethod - def new(cls, allow_empty=False): - passphrase = cls.env_new_passphrase() - if passphrase is not None: - return passphrase - passphrase = cls.env_passphrase() - if passphrase is not None: - return passphrase - for retry in range(1, 11): - passphrase = cls.getpass('Enter new passphrase: ') - if allow_empty or passphrase: - passphrase2 = cls.getpass('Enter same passphrase again: ') - if passphrase == passphrase2: - cls.verification(passphrase) - logger.info('Remember your passphrase. Your data will be inaccessible without it.') - return passphrase - else: - print('Passphrases do not match', file=sys.stderr) - else: - print('Passphrase must not be blank', file=sys.stderr) - else: - raise PasswordRetriesExceeded - - def __repr__(self): - return '' - - def kdf(self, salt, iterations, length): - return pbkdf2_hmac('sha256', self.encode('utf-8'), salt, iterations, length) - - class FlexiKeyBase(AESKeyBase): @classmethod def detect(cls, repository, manifest_data): diff --git a/src/borg/helpers/passphrase.py b/src/borg/helpers/passphrase.py new file mode 100644 index 000000000..e415a0b76 --- /dev/null +++ b/src/borg/helpers/passphrase.py @@ -0,0 +1,141 @@ +import getpass +import os +import shlex +import subprocess +import sys +from hashlib import pbkdf2_hmac + +from . import bin_to_hex +from . import Error +from . import yes +from . import prepare_subprocess_env + +from ..logger import create_logger + +logger = create_logger() + + +class NoPassphraseFailure(Error): + """can not acquire a passphrase: {}""" + + +class PassphraseWrong(Error): + """passphrase supplied in BORG_PASSPHRASE, by BORG_PASSCOMMAND or via BORG_PASSPHRASE_FD is incorrect.""" + + +class PasscommandFailure(Error): + """passcommand supplied in BORG_PASSCOMMAND failed: {}""" + + +class PasswordRetriesExceeded(Error): + """exceeded the maximum password retries""" + + +class Passphrase(str): + @classmethod + def _env_passphrase(cls, env_var, default=None): + passphrase = os.environ.get(env_var, default) + if passphrase is not None: + return cls(passphrase) + + @classmethod + def env_passphrase(cls, default=None): + passphrase = cls._env_passphrase('BORG_PASSPHRASE', default) + if passphrase is not None: + return passphrase + passphrase = cls.env_passcommand() + if passphrase is not None: + return passphrase + passphrase = cls.fd_passphrase() + if passphrase is not None: + return passphrase + + @classmethod + def env_passcommand(cls, default=None): + passcommand = os.environ.get('BORG_PASSCOMMAND', None) + if passcommand is not None: + # passcommand is a system command (not inside pyinstaller env) + env = prepare_subprocess_env(system=True) + try: + passphrase = subprocess.check_output(shlex.split(passcommand), universal_newlines=True, env=env) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + raise PasscommandFailure(e) + return cls(passphrase.rstrip('\n')) + + @classmethod + def fd_passphrase(cls): + try: + fd = int(os.environ.get('BORG_PASSPHRASE_FD')) + except (ValueError, TypeError): + return None + with os.fdopen(fd, mode='r') as f: + passphrase = f.read() + return cls(passphrase.rstrip('\n')) + + @classmethod + def env_new_passphrase(cls, default=None): + return cls._env_passphrase('BORG_NEW_PASSPHRASE', default) + + @classmethod + def getpass(cls, prompt): + try: + pw = getpass.getpass(prompt) + except EOFError: + if prompt: + print() # avoid err msg appearing right of prompt + msg = [] + for env_var in 'BORG_PASSPHRASE', 'BORG_PASSCOMMAND': + env_var_set = os.environ.get(env_var) is not None + msg.append('{} is {}.'.format(env_var, 'set' if env_var_set else 'not set')) + msg.append('Interactive password query failed.') + raise NoPassphraseFailure(' '.join(msg)) from None + else: + return cls(pw) + + @classmethod + def verification(cls, passphrase): + msg = 'Do you want your passphrase to be displayed for verification? [yN]: ' + if yes(msg, retry_msg=msg, invalid_msg='Invalid answer, try again.', + retry=True, env_var_override='BORG_DISPLAY_PASSPHRASE'): + print('Your passphrase (between double-quotes): "%s"' % passphrase, + file=sys.stderr) + print('Make sure the passphrase displayed above is exactly what you wanted.', + file=sys.stderr) + try: + passphrase.encode('ascii') + except UnicodeEncodeError: + print('Your passphrase (UTF-8 encoding in hex): %s' % + bin_to_hex(passphrase.encode('utf-8')), + file=sys.stderr) + print('As you have a non-ASCII passphrase, it is recommended to keep the ' + 'UTF-8 encoding in hex together with the passphrase at a safe place.', + file=sys.stderr) + + @classmethod + def new(cls, allow_empty=False): + passphrase = cls.env_new_passphrase() + if passphrase is not None: + return passphrase + passphrase = cls.env_passphrase() + if passphrase is not None: + return passphrase + for retry in range(1, 11): + passphrase = cls.getpass('Enter new passphrase: ') + if allow_empty or passphrase: + passphrase2 = cls.getpass('Enter same passphrase again: ') + if passphrase == passphrase2: + cls.verification(passphrase) + logger.info('Remember your passphrase. Your data will be inaccessible without it.') + return passphrase + else: + print('Passphrases do not match', file=sys.stderr) + else: + print('Passphrase must not be blank', file=sys.stderr) + else: + raise PasswordRetriesExceeded + + def __repr__(self): + return '' + + def kdf(self, salt, iterations, length): + return pbkdf2_hmac('sha256', self.encode('utf-8'), salt, iterations, length) diff --git a/src/borg/testsuite/helpers.py b/src/borg/testsuite/helpers.py index 5a5df302e..1dd66d02e 100644 --- a/src/borg/testsuite/helpers.py +++ b/src/borg/testsuite/helpers.py @@ -1,4 +1,5 @@ import errno +import getpass import hashlib import os import shutil @@ -32,6 +33,7 @@ from ..helpers import iter_separated from ..helpers import eval_escapes from ..helpers import safe_unlink +from ..helpers.passphrase import Passphrase, PasswordRetriesExceeded from . import BaseTestCase, FakeInputs @@ -1164,3 +1166,44 @@ def os_unlink(_): safe_unlink(hard_link) assert victim.read_binary() == contents + + +class TestPassphrase: + def test_passphrase_new_verification(self, capsys, monkeypatch): + monkeypatch.setattr(getpass, 'getpass', lambda prompt: "12aöäü") + monkeypatch.setenv('BORG_DISPLAY_PASSPHRASE', 'no') + Passphrase.new() + out, err = capsys.readouterr() + assert "12" not in out + assert "12" not in err + + monkeypatch.setenv('BORG_DISPLAY_PASSPHRASE', 'yes') + passphrase = Passphrase.new() + out, err = capsys.readouterr() + assert "313261c3b6c3a4c3bc" not in out + assert "313261c3b6c3a4c3bc" in err + assert passphrase == "12aöäü" + + monkeypatch.setattr(getpass, 'getpass', lambda prompt: "1234/@=") + Passphrase.new() + out, err = capsys.readouterr() + assert "1234/@=" not in out + assert "1234/@=" in err + + def test_passphrase_new_empty(self, capsys, monkeypatch): + monkeypatch.delenv('BORG_PASSPHRASE', False) + monkeypatch.setattr(getpass, 'getpass', lambda prompt: "") + with pytest.raises(PasswordRetriesExceeded): + Passphrase.new(allow_empty=False) + out, err = capsys.readouterr() + assert "must not be blank" in err + + def test_passphrase_new_retries(self, monkeypatch): + monkeypatch.delenv('BORG_PASSPHRASE', False) + ascending_numbers = iter(range(20)) + monkeypatch.setattr(getpass, 'getpass', lambda prompt: str(next(ascending_numbers))) + with pytest.raises(PasswordRetriesExceeded): + Passphrase.new() + + def test_passphrase_repr(self): + assert "secret" not in repr(Passphrase("secret")) diff --git a/src/borg/testsuite/key.py b/src/borg/testsuite/key.py index 6254cc6e3..6d2207a9e 100644 --- a/src/borg/testsuite/key.py +++ b/src/borg/testsuite/key.py @@ -6,7 +6,7 @@ import pytest -from ..crypto.key import Passphrase, PasswordRetriesExceeded, bin_to_hex +from ..crypto.key import bin_to_hex from ..crypto.key import PlaintextKey, AuthenticatedKey, RepoKey, KeyfileKey, \ Blake2KeyfileKey, Blake2RepoKey, Blake2AuthenticatedKey from ..crypto.key import ID_HMAC_SHA_256, ID_BLAKE2b_256 @@ -255,47 +255,6 @@ def test_blake2_authenticated_encrypt(self, monkeypatch): assert authenticated == b'\x06\x00\x00' + plaintext -class TestPassphrase: - def test_passphrase_new_verification(self, capsys, monkeypatch): - monkeypatch.setattr(getpass, 'getpass', lambda prompt: "12aöäü") - monkeypatch.setenv('BORG_DISPLAY_PASSPHRASE', 'no') - Passphrase.new() - out, err = capsys.readouterr() - assert "12" not in out - assert "12" not in err - - monkeypatch.setenv('BORG_DISPLAY_PASSPHRASE', 'yes') - passphrase = Passphrase.new() - out, err = capsys.readouterr() - assert "313261c3b6c3a4c3bc" not in out - assert "313261c3b6c3a4c3bc" in err - assert passphrase == "12aöäü" - - monkeypatch.setattr(getpass, 'getpass', lambda prompt: "1234/@=") - Passphrase.new() - out, err = capsys.readouterr() - assert "1234/@=" not in out - assert "1234/@=" in err - - def test_passphrase_new_empty(self, capsys, monkeypatch): - monkeypatch.delenv('BORG_PASSPHRASE', False) - monkeypatch.setattr(getpass, 'getpass', lambda prompt: "") - with pytest.raises(PasswordRetriesExceeded): - Passphrase.new(allow_empty=False) - out, err = capsys.readouterr() - assert "must not be blank" in err - - def test_passphrase_new_retries(self, monkeypatch): - monkeypatch.delenv('BORG_PASSPHRASE', False) - ascending_numbers = iter(range(20)) - monkeypatch.setattr(getpass, 'getpass', lambda prompt: str(next(ascending_numbers))) - with pytest.raises(PasswordRetriesExceeded): - Passphrase.new() - - def test_passphrase_repr(self): - assert "secret" not in repr(Passphrase("secret")) - - class TestTAM: @pytest.fixture def key(self, monkeypatch): From 2e536bcbe257ba3062a31ea2bed43133b9155f9a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Mon, 7 Mar 2022 02:59:22 +0100 Subject: [PATCH 5/8] borg key change-location --- src/borg/archiver.py | 81 +++++++++++++++++++++++++++++++++- src/borg/crypto/key.py | 7 +++ src/borg/repository.py | 2 + src/borg/testsuite/archiver.py | 32 ++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver.py b/src/borg/archiver.py index 522af30b2..17a95e0b1 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -44,7 +44,8 @@ from .cache import Cache, assert_secure, SecurityManager from .constants import * # NOQA from .compress import CompressionSpec - from .crypto.key import key_creator, key_argument_names, tam_required_file, tam_required, RepoKey + from .crypto.key import key_creator, key_argument_names, tam_required_file, tam_required + from .crypto.key import RepoKey, KeyfileKey, Blake2RepoKey, Blake2KeyfileKey from .crypto.keymanager import KeyManager from .helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, EXIT_SIGNAL_BASE from .helpers import Error, NoManifestError, set_ec @@ -363,6 +364,62 @@ def do_change_passphrase(self, args, repository, manifest, key): logger.info('Key location: %s', key.find_key()) return EXIT_SUCCESS + @with_repository(exclusive=True, manifest=True, cache=True, compatibility=(Manifest.Operation.CHECK,)) + def do_change_location(self, args, repository, manifest, key, cache): + """Change repository key location""" + if not hasattr(key, 'change_passphrase'): + print('This repository is not encrypted, cannot change the key location.') + return EXIT_ERROR + + if args.key_mode == 'keyfile': + if isinstance(key, RepoKey): + key_new = KeyfileKey(repository) + elif isinstance(key, Blake2RepoKey): + key_new = Blake2KeyfileKey(repository) + elif isinstance(key, (KeyfileKey, Blake2KeyfileKey)): + print(f"Location already is {args.key_mode}") + return EXIT_SUCCESS + else: + raise Error("Unsupported key type") + if args.key_mode == 'repokey': + if isinstance(key, KeyfileKey): + key_new = RepoKey(repository) + elif isinstance(key, Blake2KeyfileKey): + key_new = Blake2RepoKey(repository) + elif isinstance(key, (RepoKey, Blake2RepoKey)): + print(f"Location already is {args.key_mode}") + return EXIT_SUCCESS + else: + raise Error("Unsupported key type") + + for name in ('repository_id', 'enc_key', 'enc_hmac_key', 'id_key', 'chunk_seed', + 'tam_required', 'nonce_manager', 'cipher'): + value = getattr(key, name) + setattr(key_new, name, value) + + key_new.target = key_new.get_new_target(args) + key_new.save(key_new.target, key._passphrase, create=True) # save with same passphrase + + # rewrite the manifest with the new key, so that the key-type byte of the manifest changes + manifest.key = key_new + manifest.write() + repository.commit(compact=False) + + # we need to rewrite cache config and security key-type info, + # so that the cached key-type will match the repo key-type. + cache.begin_txn() # need to start a cache transaction, otherwise commit() does nothing. + cache.key = key_new + cache.commit() + + loc = key_new.find_key() if hasattr(key_new, 'find_key') else None + if args.keep: + logger.info(f'Key copied to {loc}') + else: + key.remove(key.target) # remove key from current location + logger.info(f'Key moved to {loc}') + + return EXIT_SUCCESS + @with_repository(lock=False, exclusive=False, manifest=False, cache=False) def do_key_export(self, args, repository): """Export the repository key for backup""" @@ -4250,6 +4307,28 @@ def define_borg_mount(parser): subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='', type=location_validator(archive=False)) + change_location_epilog = process_epilog(""" + Change the location of a borg key. The key can be stored at different locations: + + keyfile: locally, usually in the home directory + repokey: inside the repo (in the repo config) + + Note: this command does NOT change the crypto algorithms, just the key location, + thus you must ONLY give the key location (keyfile or repokey). + """) + subparser = key_parsers.add_parser('change-location', parents=[common_parser], add_help=False, + description=self.do_change_location.__doc__, + epilog=change_location_epilog, + formatter_class=argparse.RawDescriptionHelpFormatter, + help='change key location') + subparser.set_defaults(func=self.do_change_location) + subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='', + type=location_validator(archive=False)) + subparser.add_argument('key_mode', metavar='KEY_LOCATION', choices=('repokey', 'keyfile'), + help='select key location') + subparser.add_argument('--keep', dest='keep', action='store_true', + help='keep the key also at the current location (default: remove it)') + # borg list list_epilog = process_epilog(""" This command lists the contents of a repository or an archive. diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 6511f6463..ab86df528 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -634,6 +634,13 @@ def save(self, target, passphrase, create=False): target.save_key(key_data) self.target = target + def remove(self, target): + if self.STORAGE == KeyBlobStorage.KEYFILE: + os.remove(target) + + if self.STORAGE == KeyBlobStorage.REPO: + target.save_key(b'') # save empty key (no new api at remote repo necessary) + class PassphraseKey: # this is only a stub, repos with this mode could not be created any more since borg 1.0, see #97. diff --git a/src/borg/repository.py b/src/borg/repository.py index bb67c5a3f..53ac9a1cb 100644 --- a/src/borg/repository.py +++ b/src/borg/repository.py @@ -334,11 +334,13 @@ def save_config(self, path, config): def save_key(self, keydata): assert self.config keydata = keydata.decode('utf-8') # remote repo: msgpack issue #99, getting bytes + # note: saving an empty key means that there is no repokey any more self.config.set('repository', 'key', keydata) self.save_config(self.path, self.config) def load_key(self): keydata = self.config.get('repository', 'key') + # note: if we return an empty string, it means there is no repo key return keydata.encode('utf-8') # remote repo: msgpack issue #99, returning bytes def get_free_nonce(self): diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index 5021b7aa7..2eb552bd4 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -2490,6 +2490,38 @@ def test_change_passphrase(self): os.environ['BORG_PASSPHRASE'] = 'newpassphrase' self.cmd('list', self.repository_location) + def test_change_location_to_keyfile(self): + self.cmd('init', '--encryption=repokey', self.repository_location) + log = self.cmd('info', self.repository_location) + assert '(repokey)' in log + self.cmd('key', 'change-location', self.repository_location, 'keyfile') + log = self.cmd('info', self.repository_location) + assert '(key file)' in log + + def test_change_location_to_b2keyfile(self): + self.cmd('init', '--encryption=repokey-blake2', self.repository_location) + log = self.cmd('info', self.repository_location) + assert '(repokey BLAKE2b)' in log + self.cmd('key', 'change-location', self.repository_location, 'keyfile') + log = self.cmd('info', self.repository_location) + assert '(key file BLAKE2b)' in log + + def test_change_location_to_repokey(self): + self.cmd('init', '--encryption=keyfile', self.repository_location) + log = self.cmd('info', self.repository_location) + assert '(key file)' in log + self.cmd('key', 'change-location', self.repository_location, 'repokey') + log = self.cmd('info', self.repository_location) + assert '(repokey)' in log + + def test_change_location_to_b2repokey(self): + self.cmd('init', '--encryption=keyfile-blake2', self.repository_location) + log = self.cmd('info', self.repository_location) + assert '(key file BLAKE2b)' in log + self.cmd('key', 'change-location', self.repository_location, 'repokey') + log = self.cmd('info', self.repository_location) + assert '(repokey BLAKE2b)' in log + def test_break_lock(self): self.cmd('init', '--encryption=repokey', self.repository_location) self.cmd('break-lock', self.repository_location) From a63614e35ba94e2963160298b97ea4b3db4a028e Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 11 Mar 2022 21:20:26 +0100 Subject: [PATCH 6/8] move key type/storage constants to borg.constants --- src/borg/constants.py | 21 +++++++++++++++++++++ src/borg/crypto/key.py | 30 ++---------------------------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/borg/constants.py b/src/borg/constants.py index 0aac942a9..6b9544a31 100644 --- a/src/borg/constants.py +++ b/src/borg/constants.py @@ -104,6 +104,27 @@ PBKDF2_ITERATIONS = 100000 +class KeyBlobStorage: + NO_STORAGE = 'no_storage' + KEYFILE = 'keyfile' + REPO = 'repository' + + +class KeyType: + KEYFILE = 0x00 + # repos with PASSPHRASE mode could not be created any more since borg 1.0, see #97. + # in borg 1.3 all of its code and also the "borg key migrate-to-repokey" command was removed. + # if you still need to, you can use "borg key migrate-to-repokey" with borg 1.0, 1.1 and 1.2. + # Nowadays, we just dispatch this to RepoKey and assume the passphrase was migrated to a repokey. + PASSPHRASE = 0x01 # legacy, attic and borg < 1.0 + PLAINTEXT = 0x02 + REPO = 0x03 + BLAKE2KEYFILE = 0x04 + BLAKE2REPO = 0x05 + BLAKE2AUTHENTICATED = 0x06 + AUTHENTICATED = 0x07 + + REPOSITORY_README = """This is a Borg Backup repository. See https://borgbackup.readthedocs.io/ """ diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index ab86df528..59be43a85 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -76,23 +76,6 @@ class TAMUnsupportedSuiteError(IntegrityError): traceback = False -class KeyBlobStorage: - NO_STORAGE = 'no_storage' - KEYFILE = 'keyfile' - REPO = 'repository' - - -class KeyType: - KEYFILE = 0x00 - PASSPHRASE = 0x01 # legacy, attic and borg < 1.0 - PLAINTEXT = 0x02 - REPO = 0x03 - BLAKE2KEYFILE = 0x04 - BLAKE2REPO = 0x05 - BLAKE2AUTHENTICATED = 0x06 - AUTHENTICATED = 0x07 - - def key_creator(repository, args): for key in AVAILABLE_KEY_TYPES: if key.ARG_NAME == args.encryption: @@ -108,8 +91,8 @@ def key_argument_names(): def identify_key(manifest_data): key_type = manifest_data[0] - if key_type == PassphraseKey.TYPE: - return RepoKey # see comment in PassphraseKey class. + if key_type == KeyType.PASSPHRASE: # legacy, see comment in KeyType class. + return RepoKey for key in AVAILABLE_KEY_TYPES: if key.TYPE == key_type: @@ -642,15 +625,6 @@ def remove(self, target): target.save_key(b'') # save empty key (no new api at remote repo necessary) -class PassphraseKey: - # this is only a stub, repos with this mode could not be created any more since borg 1.0, see #97. - # in borg 1.3 all of its code and also the "borg key migrate-to-repokey" command was removed. - # if you still need to, you can use "borg key migrate-to-repokey" with borg 1.0, 1.1 and 1.2. - # Nowadays, we just dispatch this to RepoKey and assume the passphrase was migrated to a repokey. - TYPE = KeyType.PASSPHRASE - NAME = 'passphrase' - - class KeyfileKey(FlexiKey): TYPE = KeyType.KEYFILE NAME = 'key file' From b3e7e90c298351405615b81a2d9a7dd31256c0c2 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 11 Mar 2022 21:43:23 +0100 Subject: [PATCH 7/8] improve storage type handling, dedup code --- src/borg/crypto/key.py | 43 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 59be43a85..938c9d106 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -521,14 +521,15 @@ def find_key(self): if keyfile is not None: return keyfile raise KeyfileNotFoundError(self.repository._location.canonical_path(), get_keys_dir()) - - if self.STORAGE == KeyBlobStorage.REPO: + elif self.STORAGE == KeyBlobStorage.REPO: loc = self.repository._location.canonical_path() try: self.repository.load_key() return loc except configparser.NoOptionError: raise RepoKeyNotFoundError(loc) from None + else: + raise TypeError('Unsupported borg key storage type') def get_existing_or_new_target(self, args): keyfile = self._find_key_file_from_environment() @@ -555,9 +556,10 @@ def get_new_target(self, args): if keyfile is not None: return keyfile return self._get_new_target_in_keys_dir(args) - - if self.STORAGE == KeyBlobStorage.REPO: + elif self.STORAGE == KeyBlobStorage.REPO: return self.repository + else: + raise TypeError('Unsupported borg key storage type') def _find_key_file_from_environment(self): keyfile = os.environ.get('BORG_KEY_FILE') @@ -577,12 +579,7 @@ def load(self, target, passphrase): if self.STORAGE == KeyBlobStorage.KEYFILE: with open(target) as fd: key_data = ''.join(fd.readlines()[1:]) - success = self._load(key_data, passphrase) - if success: - self.target = target - return success - - if self.STORAGE == KeyBlobStorage.REPO: + elif self.STORAGE == KeyBlobStorage.REPO: # While the repository is encrypted, we consider a repokey repository with a blank # passphrase an unencrypted repository. self.logically_encrypted = passphrase != '' @@ -591,38 +588,40 @@ def load(self, target, passphrase): target = self.repository key_data = target.load_key() key_data = key_data.decode('utf-8') # remote repo: msgpack issue #99, getting bytes - success = self._load(key_data, passphrase) - if success: - self.target = target - return success + else: + raise TypeError('Unsupported borg key storage type') + success = self._load(key_data, passphrase) + if success: + self.target = target + return success def save(self, target, passphrase, create=False): + key_data = self._save(passphrase) if self.STORAGE == KeyBlobStorage.KEYFILE: if create and os.path.isfile(target): # if a new keyfile key repository is created, ensure that an existing keyfile of another # keyfile key repo is not accidentally overwritten by careless use of the BORG_KEY_FILE env var. # see issue #6036 raise Error('Aborting because key in "%s" already exists.' % target) - key_data = self._save(passphrase) with SaveFile(target) as fd: fd.write(f'{self.FILE_ID} {bin_to_hex(self.repository_id)}\n') fd.write(key_data) fd.write('\n') - self.target = target - - if self.STORAGE == KeyBlobStorage.REPO: + elif self.STORAGE == KeyBlobStorage.REPO: self.logically_encrypted = passphrase != '' - key_data = self._save(passphrase) key_data = key_data.encode('utf-8') # remote repo: msgpack issue #99, giving bytes target.save_key(key_data) - self.target = target + else: + raise TypeError('Unsupported borg key storage type') + self.target = target def remove(self, target): if self.STORAGE == KeyBlobStorage.KEYFILE: os.remove(target) - - if self.STORAGE == KeyBlobStorage.REPO: + elif self.STORAGE == KeyBlobStorage.REPO: target.save_key(b'') # save empty key (no new api at remote repo necessary) + else: + raise TypeError('Unsupported borg key storage type') class KeyfileKey(FlexiKey): From 0b3b78e139796286e0180ae3e48883cb31f8bbbb Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 11 Mar 2022 22:01:20 +0100 Subject: [PATCH 8/8] dedup code: assert_type --- src/borg/crypto/key.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 938c9d106..374799571 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -169,6 +169,11 @@ def assert_id(self, id, data): if not hmac.compare_digest(id_computed, id): raise IntegrityError('Chunk %s: id verification failed' % bin_to_hex(id)) + def assert_type(self, type_byte, id=None): + if type_byte not in self.TYPES_ACCEPTABLE: + id_str = bin_to_hex(id) if id is not None else '(unknown)' + raise IntegrityError(f'Chunk {id_str}: Invalid encryption envelope') + def _tam_key(self, salt, context): return hkdf_hmac_sha512( ikm=self.id_key + self.enc_key + self.enc_hmac_key, @@ -263,9 +268,7 @@ def encrypt(self, chunk): return b''.join([self.TYPE_STR, data]) def decrypt(self, id, data, decompress=True): - if data[0] not in self.TYPES_ACCEPTABLE: - id_str = bin_to_hex(id) if id is not None else '(unknown)' - raise IntegrityError('Chunk %s: Invalid encryption envelope' % id_str) + self.assert_type(data[0], id) payload = memoryview(data)[1:] if not decompress: return payload @@ -343,9 +346,7 @@ def encrypt(self, chunk): return self.cipher.encrypt(data, header=self.TYPE_STR, iv=next_iv) def decrypt(self, id, data, decompress=True): - if data[0] not in self.TYPES_ACCEPTABLE: - id_str = bin_to_hex(id) if id is not None else '(unknown)' - raise IntegrityError('Chunk %s: Invalid encryption envelope' % id_str) + self.assert_type(data[0], id) try: payload = self.cipher.decrypt(data) except IntegrityError as e: @@ -371,8 +372,7 @@ def init_ciphers(self, manifest_data=None): if manifest_data is None: nonce = 0 else: - if manifest_data[0] not in self.TYPES_ACCEPTABLE: - raise IntegrityError('Manifest: Invalid encryption envelope') + self.assert_type(manifest_data[0]) # manifest_blocks is a safe upper bound on the amount of cipher blocks needed # to encrypt the manifest. depending on the ciphersuite and overhead, it might # be a bit too high, but that does not matter. @@ -673,17 +673,15 @@ def save(self, target, passphrase, create=False): self.logically_encrypted = False def init_ciphers(self, manifest_data=None): - if manifest_data is not None and manifest_data[0] not in self.TYPES_ACCEPTABLE: - raise IntegrityError('Manifest: Invalid encryption envelope') + if manifest_data is not None: + self.assert_type(manifest_data[0]) def encrypt(self, chunk): data = self.compressor.compress(chunk) return b''.join([self.TYPE_STR, data]) def decrypt(self, id, data, decompress=True): - if data[0] not in self.TYPES_ACCEPTABLE: - id_str = bin_to_hex(id) if id is not None else '(unknown)' - raise IntegrityError('Chunk %s: Invalid envelope' % id_str) + self.assert_type(data[0], id) payload = memoryview(data)[1:] if not decompress: return payload