From cad49b844e37a42a0ced0faf375d088568c9e6d9 Mon Sep 17 00:00:00 2001 From: Marian Beermann Date: Thu, 11 May 2017 17:49:02 +0200 Subject: [PATCH 1/3] key: authenticated mode = not passphrase protected --- docs/changes.rst | 12 ++++++++++-- src/borg/crypto/key.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index ebd4e0886..a15f7fb03 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -133,13 +133,21 @@ Version 1.1.0b6 (unreleased) Compatibility notes: -- Repositories in a repokey mode with a blank passphrase are now treated - as unencrypted repositories for security checks +- Repositories in a repokey mode (including "authenticated" mode) with a + blank passphrase are now treated as unencrypted repositories for security checks (e.g. BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK). - Running "borg init" via a "borg serve --append-only" server will *not* create an append-only repository anymore. Use "borg init --append-only" to initialize an append-only repository. + Previously there would be no prompts nor messages if an unknown repository + in one of these modes with a blank passphrase was encountered. This would + allow an attacker to swap a repository, if one assumed that the lack of + password prompts was due to a set BORG_PASSPHRASE. + + Since the "trick" does not work if BORG_PASSPHRASE is set, this does generally + not affect scripts. + Version 1.1.0b5 (2017-04-30) ---------------------------- diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 9469be297..772b4ae5d 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -749,6 +749,18 @@ class AuthenticatedKey(ID_BLAKE2b_256, RepoKey): ARG_NAME = 'authenticated' STORAGE = KeyBlobStorage.REPO + # It's only authenticated, not encrypted. + passphrase_protected = False + + def load(self, target, passphrase): + success = super().load(target, passphrase) + self.passphrase_protected = False + return success + + def save(self, target, passphrase): + super().save(target, passphrase) + self.passphrase_protected = False + def encrypt(self, chunk): data = self.compressor.compress(chunk) return b''.join([self.TYPE_STR, data]) From 848df38d080885b08c2c8f9b6907e3899ee3486e Mon Sep 17 00:00:00 2001 From: Marian Beermann Date: Thu, 11 May 2017 21:03:32 +0200 Subject: [PATCH 2/3] Rename Key.passphrase_protected -> logically_encrypted & document --- docs/changes.rst | 9 ++++++--- src/borg/cache.py | 2 +- src/borg/crypto/key.py | 33 +++++++++++++++++++++++++-------- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index a15f7fb03..e511716a4 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -133,15 +133,18 @@ Version 1.1.0b6 (unreleased) Compatibility notes: -- Repositories in a repokey mode (including "authenticated" mode) with a - blank passphrase are now treated as unencrypted repositories for security checks +- Repositories in the "repokey" and "repokey-blake2" modes with an empty passphrase + are now treated as unencrypted repositories for security checks (e.g. BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK). - Running "borg init" via a "borg serve --append-only" server will *not* create an append-only repository anymore. Use "borg init --append-only" to initialize an append-only repository. + Repositories in the "authenticated" mode are now treated as the unencrypted repositories + they are. + Previously there would be no prompts nor messages if an unknown repository - in one of these modes with a blank passphrase was encountered. This would + in one of these modes with an empty passphrase was encountered. This would allow an attacker to swap a repository, if one assumed that the lack of password prompts was due to a set BORG_PASSPHRASE. diff --git a/src/borg/cache.py b/src/borg/cache.py index aeb9d3d4d..40ed925a6 100644 --- a/src/borg/cache.py +++ b/src/borg/cache.py @@ -130,7 +130,7 @@ class SecurityManager: self.save(manifest, key, cache) def assert_access_unknown(self, warn_if_unencrypted, key): - if warn_if_unencrypted and not key.passphrase_protected and not self.known(): + if warn_if_unencrypted and 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] ") if not yes(msg, false_msg="Aborting.", invalid_msg="Invalid answer, aborting.", diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 772b4ae5d..24bb81038 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -129,15 +129,31 @@ def tam_required(repository): class KeyBase: + # Numeric key type ID, must fit in one byte. TYPE = None # override in subclasses # Human-readable name NAME = 'UNDEFINED' + # Name used in command line / API (e.g. borg init --encryption=...) ARG_NAME = 'UNDEFINED' + # Storage type (no key blob storage / keyfile / repo) STORAGE = KeyBlobStorage.NO_STORAGE + # Seed for the buzhash chunker (borg.algorithms.chunker.Chunker) + # type: int + chunk_seed = None + + # Whether this *particular instance* is encrypted from a practical point of view, + # i.e. when it's using encryption with a empty passphrase, then + # that may be *technically* called encryption, but for all intents and purposes + # that's as good as not encrypting in the first place, and this member should be False. + # + # The empty passphrase is also special because Borg tries it first when no passphrase + # was supplied, and if an empty passphrase works, then Borg won't ask for one. + logically_encrypted = False + def __init__(self, repository): self.TYPE_STR = bytes([self.TYPE]) self.repository = repository @@ -234,7 +250,7 @@ class PlaintextKey(KeyBase): STORAGE = KeyBlobStorage.NO_STORAGE chunk_seed = 0 - passphrase_protected = False + logically_encrypted = False def __init__(self, repository): super().__init__(repository) @@ -314,7 +330,8 @@ class ID_HMAC_SHA_256: class AESKeyBase(KeyBase): - """Common base class shared by KeyfileKey and PassphraseKey + """ + Common base class shared by KeyfileKey and PassphraseKey Chunks are encrypted using 256bit AES in Counter Mode (CTR) @@ -330,7 +347,7 @@ class AESKeyBase(KeyBase): MAC = hmac_sha256 - passphrase_protected = True + logically_encrypted = True def encrypt(self, chunk): data = self.compressor.compress(chunk) @@ -705,7 +722,7 @@ class RepoKey(ID_HMAC_SHA_256, KeyfileKeyBase): def load(self, target, passphrase): # While the repository is encrypted, we consider a repokey repository with a blank # passphrase an unencrypted repository. - self.passphrase_protected = passphrase != '' + self.logically_encrypted = passphrase != '' # what we get in target is just a repo location, but we already have the repo obj: target = self.repository @@ -717,7 +734,7 @@ class RepoKey(ID_HMAC_SHA_256, KeyfileKeyBase): return success def save(self, target, passphrase): - self.passphrase_protected = passphrase != '' + 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) @@ -750,16 +767,16 @@ class AuthenticatedKey(ID_BLAKE2b_256, RepoKey): STORAGE = KeyBlobStorage.REPO # It's only authenticated, not encrypted. - passphrase_protected = False + logically_encrypted = False def load(self, target, passphrase): success = super().load(target, passphrase) - self.passphrase_protected = False + self.logically_encrypted = False return success def save(self, target, passphrase): super().save(target, passphrase) - self.passphrase_protected = False + self.logically_encrypted = False def encrypt(self, chunk): data = self.compressor.compress(chunk) From a16d81271a3b4915c7fe14a3444df6dd0e88cfad Mon Sep 17 00:00:00 2001 From: Marian Beermann Date: Fri, 12 May 2017 20:48:47 +0200 Subject: [PATCH 3/3] key: add round-trip test --- src/borg/crypto/key.py | 11 +++++++++++ src/borg/testsuite/key.py | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/borg/crypto/key.py b/src/borg/crypto/key.py index 24bb81038..37cf3f552 100644 --- a/src/borg/crypto/key.py +++ b/src/borg/crypto/key.py @@ -778,6 +778,17 @@ class AuthenticatedKey(ID_BLAKE2b_256, RepoKey): super().save(target, passphrase) self.logically_encrypted = False + def extract_nonce(self, payload): + # This is called during set-up of the AES ciphers we're not actually using for this + # key. Therefore the return value of this method doesn't matter; it's just around + # to not have it crash should key identification be run against a very small chunk + # by "borg check" when the manifest is lost. (The manifest is always large enough + # to have the original method read some garbage from bytes 33-41). (Also, the return + # value must be larger than the 41 byte bloat of the original format). + if payload[0] != self.TYPE: + raise IntegrityError('Manifest: Invalid encryption envelope') + return 42 + def encrypt(self, chunk): data = self.compressor.compress(chunk) return b''.join([self.TYPE_STR, data]) diff --git a/src/borg/testsuite/key.py b/src/borg/testsuite/key.py index 5f0ad367b..34399f9ba 100644 --- a/src/borg/testsuite/key.py +++ b/src/borg/testsuite/key.py @@ -11,6 +11,7 @@ from ..crypto.key import Passphrase, PasswordRetriesExceeded, bin_to_hex from ..crypto.key import PlaintextKey, PassphraseKey, KeyfileKey, RepoKey, Blake2KeyfileKey, Blake2RepoKey, \ AuthenticatedKey from ..crypto.key import TAMRequiredError, TAMInvalid, TAMUnsupportedSuiteError, UnsupportedManifestError +from ..crypto.key import identify_key from ..crypto.low_level import bytes_to_long, num_aes_blocks from ..helpers import IntegrityError from ..helpers import Location @@ -224,6 +225,16 @@ class TestKey: id[12] = 0 key.decrypt(id, data) + def test_roundtrip(self, key): + repository = key.repository + plaintext = b'foo' + encrypted = key.encrypt(plaintext) + identified_key_class = identify_key(encrypted) + assert identified_key_class == key.__class__ + loaded_key = identified_key_class.detect(repository, encrypted) + decrypted = loaded_key.decrypt(None, encrypted) + assert decrypted == plaintext + def test_decrypt_decompress(self, key): plaintext = b'123456789' encrypted = key.encrypt(plaintext)