diff --git a/src/borg/archiver.py b/src/borg/archiver.py index 785ef668f..4999440ae 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -959,7 +959,7 @@ class Archiver: else: encrypted = 'Yes (%s)' % key.NAME print('Encrypted: %s' % encrypted) - if key.NAME == 'key file': + if key.NAME.startswith('key file'): print('Key file: %s' % key.find_key()) print('Cache: %s' % cache.path) print(DASHES) @@ -1556,6 +1556,7 @@ class Archiver: 'Access to all sub-directories is granted implicitly; PATH doesn\'t need to directly point to a repository.') subparser.add_argument('--append-only', dest='append_only', action='store_true', help='only allow appending to repository segment files') + init_epilog = textwrap.dedent(""" This command initializes an empty repository. A repository is a filesystem directory containing the deduplicated data from zero or more archives. @@ -1599,8 +1600,21 @@ class Archiver: You can change your passphrase for existing repos at any time, it won't affect the encryption/decryption key or other secrets. - When encrypting, AES-CTR-256 is used for encryption, and HMAC-SHA256 for - authentication. Hardware acceleration will be used automatically. + Encryption modes + ++++++++++++++++ + + repokey and keyfile use AES-CTR-256 for encryption and HMAC-SHA256 for + authentication in an encrypt-then-MAC (EtM) construction. The chunk ID hash + is HMAC-SHA256 as well (with a separate key). + + repokey-blake2 and keyfile-blake2 use the same authenticated encryption, but + use a keyed BLAKE2b-256 hash for the chunk ID hash. + + "authenticated" mode uses no encryption, but authenticates repository contents + through the same keyed BLAKE2b-256 hash as the other blake2 modes. + The key is stored like repokey. + + Hardware acceleration will be used automatically. """) subparser = subparsers.add_parser('init', parents=[common_parser], add_help=False, description=self.do_init.__doc__, epilog=init_epilog, @@ -1611,7 +1625,8 @@ class Archiver: type=location_validator(archive=False), help='repository to create') subparser.add_argument('-e', '--encryption', dest='encryption', - choices=('none', 'keyfile', 'repokey'), default='repokey', + choices=('none', 'keyfile', 'repokey', 'keyfile-blake2', 'repokey-blake2', 'authenticated'), + default='repokey', help='select encryption key mode (default: "%(default)s")') subparser.add_argument('-a', '--append-only', dest='append_only', action='store_true', help='create an append-only mode repository') diff --git a/src/borg/blake2/openssl-b2.c b/src/borg/blake2/openssl-b2.c new file mode 100644 index 000000000..e69de29bb diff --git a/src/borg/crypto.pyx b/src/borg/crypto.pyx index 4e19e5303..bd61955e2 100644 --- a/src/borg/crypto.pyx +++ b/src/borg/crypto.pyx @@ -231,6 +231,11 @@ def blake2b_256(key, data): md = bytes(32) cdef unsigned char *md_ptr = md + # This is secure, because BLAKE2 is not vulnerable to length-extension attacks (unlike SHA-1/2, MD-5 and others). + # See the BLAKE2 paper section 2.9 "Keyed hashing (MAC and PRF)" for details. + # A nice benefit is that this simpler prefix-MAC mode has less overhead than the more complex HMAC mode. + # We don't use the BLAKE2 parameter block (via blake2s_init_key) for this to + # avoid incompatibility with the limited API of OpenSSL. blake2b_update_from_buffer(&state, key) blake2b_update_from_buffer(&state, data) diff --git a/src/borg/key.py b/src/borg/key.py index aa603c0cb..f5636ba38 100644 --- a/src/borg/key.py +++ b/src/borg/key.py @@ -14,7 +14,7 @@ logger = create_logger() from .constants import * # NOQA from .compress import Compressor, get_compressor -from .crypto import AES, bytes_to_long, long_to_bytes, bytes_to_int, num_aes_blocks, hmac_sha256 +from .crypto import AES, bytes_to_long, long_to_bytes, bytes_to_int, num_aes_blocks, hmac_sha256, blake2b_256 from .helpers import Chunk from .helpers import Error, IntegrityError from .helpers import yes @@ -62,6 +62,12 @@ def key_creator(repository, args): return KeyfileKey.create(repository, args) elif args.encryption == 'repokey': return RepoKey.create(repository, args) + elif args.encryption == 'keyfile-blake2': + return Blake2KeyfileKey.create(repository, args) + elif args.encryption == 'repokey-blake2': + return Blake2RepoKey.create(repository, args) + elif args.encryption == 'authenticated': + return AuthenticatedKey.create(repository, args) else: return PlaintextKey.create(repository, args) @@ -78,6 +84,12 @@ def key_factory(repository, manifest_data): return RepoKey.detect(repository, manifest_data) elif key_type == PlaintextKey.TYPE: return PlaintextKey.detect(repository, manifest_data) + elif key_type == Blake2KeyfileKey.TYPE: + return Blake2KeyfileKey.detect(repository, manifest_data) + elif key_type == Blake2RepoKey.TYPE: + return Blake2RepoKey.detect(repository, manifest_data) + elif key_type == AuthenticatedKey.TYPE: + return AuthenticatedKey.detect(repository, manifest_data) else: raise UnsupportedPayloadError(key_type) @@ -149,6 +161,28 @@ class PlaintextKey(KeyBase): return Chunk(data) +class ID_BLAKE2b_256: + """ + Key mix-in class for using BLAKE2b-256 for the id key. + + The id_key length must be 32 bytes. + """ + + def id_hash(self, data): + return blake2b_256(self.id_key, data) + + +class ID_HMAC_SHA_256: + """ + Key mix-in class for using HMAC-SHA-256 for the id key. + + The id_key length must be 32 bytes. + """ + + def id_hash(self, data): + return hmac_sha256(self.id_key, data) + + class AESKeyBase(KeyBase): """Common base class shared by KeyfileKey and PassphraseKey @@ -164,11 +198,6 @@ class AESKeyBase(KeyBase): PAYLOAD_OVERHEAD = 1 + 32 + 8 # TYPE + HMAC + NONCE - def id_hash(self, data): - """Return HMAC hash using the "id" HMAC key - """ - return hmac_sha256(self.id_key, data) - def encrypt(self, chunk): chunk = self.compress(chunk) self.nonce_manager.ensure_reservation(num_aes_blocks(len(chunk.data))) @@ -272,7 +301,7 @@ class Passphrase(str): return pbkdf2_hmac('sha256', self.encode('utf-8'), salt, iterations, length) -class PassphraseKey(AESKeyBase): +class PassphraseKey(ID_HMAC_SHA_256, AESKeyBase): # This mode was killed in borg 1.0, see: https://github.com/borgbackup/borg/issues/97 # Reasons: # - you can never ever change your passphrase for existing repos. @@ -432,7 +461,7 @@ class KeyfileKeyBase(AESKeyBase): raise NotImplementedError -class KeyfileKey(KeyfileKeyBase): +class KeyfileKey(ID_HMAC_SHA_256, KeyfileKeyBase): TYPE = 0x00 NAME = 'key file' FILE_ID = 'BORG_KEY' @@ -492,7 +521,7 @@ class KeyfileKey(KeyfileKeyBase): self.target = target -class RepoKey(KeyfileKeyBase): +class RepoKey(ID_HMAC_SHA_256, KeyfileKeyBase): TYPE = 0x03 NAME = 'repokey' @@ -522,3 +551,33 @@ class RepoKey(KeyfileKeyBase): key_data = key_data.encode('utf-8') # remote repo: msgpack issue #99, giving bytes target.save_key(key_data) self.target = target + + +class Blake2KeyfileKey(ID_BLAKE2b_256, KeyfileKey): + TYPE = 0x04 + NAME = 'key file BLAKE2b' + FILE_ID = 'BORG_KEY' + + +class Blake2RepoKey(ID_BLAKE2b_256, RepoKey): + TYPE = 0x05 + NAME = 'repokey BLAKE2b' + + +class AuthenticatedKey(ID_BLAKE2b_256, RepoKey): + TYPE = 0x06 + NAME = 'authenticated BLAKE2b' + + def encrypt(self, chunk): + chunk = self.compress(chunk) + return b''.join([self.TYPE_STR, chunk.data]) + + def decrypt(self, id, data, decompress=True): + if data[0] != self.TYPE: + raise IntegrityError('Chunk %s: Invalid envelope' % bin_to_hex(id)) + payload = memoryview(data)[1:] + if not decompress: + return Chunk(payload) + data = self.compressor.decompress(payload) + self.assert_id(id, data) + return Chunk(data) diff --git a/src/borg/testsuite/key.py b/src/borg/testsuite/key.py index 95c74e36f..5f456b0bf 100644 --- a/src/borg/testsuite/key.py +++ b/src/borg/testsuite/key.py @@ -11,7 +11,8 @@ from ..helpers import Location from ..helpers import Chunk from ..helpers import IntegrityError from ..helpers import get_nonces_dir -from ..key import PlaintextKey, PassphraseKey, KeyfileKey, Passphrase, PasswordRetriesExceeded, bin_to_hex +from ..key import PlaintextKey, PassphraseKey, KeyfileKey, RepoKey, Blake2KeyfileKey, Blake2RepoKey, AuthenticatedKey +from ..key import Passphrase, PasswordRetriesExceeded, bin_to_hex class TestKey: @@ -34,6 +35,24 @@ class TestKey: """)) keyfile2_id = unhexlify('c3fbf14bc001ebcc3cd86e696c13482ed071740927cd7cbe1b01b4bfcee49314') + keyfile_blake2_key_file = """ + BORG_KEY 0000000000000000000000000000000000000000000000000000000000000000 + hqlhbGdvcml0aG2mc2hhMjU2pGRhdGHaANAwo4EbUPF/kLQXhQnT4LxRc1advS8lUiegDa + q2Q6oOkP1Jc7MwBa7ZVMgoBG1sBeKYO6Sn6W6BBrHbMR8Dxv7xquaQIh8jIpnjLWpzyFIk + JlijFiTWI58Sxj+2D19b2ayFolnGkF9PJSARgfaieo0GkryqjcIgcXuKHO/H9NfaUDk5YJ + UqrJ9TUMohXSQzwF1pO4ak2BHPZKnbeJ7XL/8fFN8VFQZl27R0et4WlTFRBI1qQYyQaTiL + +/1ICMUpVsQM0mvyW6dc8/zGMsAlmZVApGhhc2jaACDdRF7uPv90UN3zsZy5Be89728RBl + zKvtzupDyTsfrJMqppdGVyYXRpb25zzgABhqCkc2FsdNoAIGTK3TR09UZqw1bPi17gyHOi + 7YtSp4BVK7XptWeKh6Vip3ZlcnNpb24B""".strip() + + keyfile_blake2_cdata = bytes.fromhex('04dd21cc91140ef009bc9e4dd634d075e39d39025ccce1289c' + '5536f9cb57f5f8130404040404040408ec852921309243b164') + # Verified against b2sum. Entire string passed to BLAKE2, including the 32 byte key contained in + # keyfile_blake2_key_file above is + # 037fb9b75b20d623f1d5a568050fccde4a1b7c5f5047432925e941a17c7a2d0d7061796c6f6164 + # p a y l o a d + keyfile_blake2_id = bytes.fromhex('a22d4fc81bb61c3846c334a09eaf28d22dd7df08c9a7a41e713ef28d80eebd45') + @pytest.fixture def keys_dir(self, request, monkeypatch, tmpdir): monkeypatch.setenv('BORG_KEYS_DIR', tmpdir) @@ -41,7 +60,11 @@ class TestKey: @pytest.fixture(params=( KeyfileKey, - PlaintextKey + PlaintextKey, + RepoKey, + Blake2KeyfileKey, + Blake2RepoKey, + AuthenticatedKey, )) def key(self, request, monkeypatch): monkeypatch.setenv('BORG_PASSPHRASE', 'test') @@ -61,6 +84,12 @@ class TestKey: def commit_nonce_reservation(self, next_unreserved, start_nonce): pass + def save_key(self, data): + self.key_data = data + + def load_key(self): + return self.key_data + def test_plaintext(self): key = PlaintextKey.create(None, None) chunk = Chunk(b'foo') @@ -128,6 +157,13 @@ class TestKey: key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata) assert key.decrypt(self.keyfile2_id, self.keyfile2_cdata).data == b'payload' + def test_keyfile_blake2(self, monkeypatch, keys_dir): + with keys_dir.join('keyfile').open('w') as fd: + fd.write(self.keyfile_blake2_key_file) + monkeypatch.setenv('BORG_PASSPHRASE', 'passphrase') + key = Blake2KeyfileKey.detect(self.MockRepository(), self.keyfile_blake2_cdata) + assert key.decrypt(self.keyfile_blake2_id, self.keyfile_blake2_cdata).data == b'payload' + def test_passphrase(self, keys_dir, monkeypatch): monkeypatch.setenv('BORG_PASSPHRASE', 'test') key = PassphraseKey.create(self.MockRepository(), None) @@ -193,6 +229,14 @@ class TestKey: with pytest.raises(IntegrityError): key.assert_id(id, plaintext_changed) + def test_authenticated_encrypt(self, monkeypatch): + monkeypatch.setenv('BORG_PASSPHRASE', 'test') + key = AuthenticatedKey.create(self.MockRepository(), self.MockArgs()) + plaintext = Chunk(b'123456789') + authenticated = key.encrypt(plaintext) + # 0x06 is the key TYPE, 0x0000 identifies CNONE compression + assert authenticated == b'\x06\x00\x00' + plaintext.data + class TestPassphrase: def test_passphrase_new_verification(self, capsys, monkeypatch):