|
@@ -5,15 +5,17 @@ import os
|
|
|
import sys
|
|
|
import textwrap
|
|
|
from hmac import HMAC, compare_digest
|
|
|
-from hashlib import sha256, pbkdf2_hmac
|
|
|
+from hashlib import sha256, sha512, pbkdf2_hmac
|
|
|
|
|
|
-from .helpers import IntegrityError, get_keys_dir, Error, yes, bin_to_hex
|
|
|
+import msgpack
|
|
|
+
|
|
|
+from .helpers import StableDict, IntegrityError, get_keys_dir, get_security_dir, Error, yes, bin_to_hex
|
|
|
from .logger import create_logger
|
|
|
logger = create_logger()
|
|
|
|
|
|
from .crypto import AES, bytes_to_long, long_to_bytes, bytes_to_int, num_aes_blocks
|
|
|
-from .compress import Compressor
|
|
|
-import msgpack
|
|
|
+from .crypto import hkdf_hmac_sha512
|
|
|
+from .compress import Compressor, CNONE
|
|
|
|
|
|
PREFIX = b'\0' * 8
|
|
|
|
|
@@ -30,6 +32,10 @@ class UnsupportedPayloadError(Error):
|
|
|
"""Unsupported payload type {}. A newer version is required to access this repository."""
|
|
|
|
|
|
|
|
|
+class UnsupportedManifestError(Error):
|
|
|
+ """Unsupported manifest envelope. A newer version is required to access this repository."""
|
|
|
+
|
|
|
+
|
|
|
class KeyfileNotFoundError(Error):
|
|
|
"""No key file for repository {} found in {}."""
|
|
|
|
|
@@ -38,6 +44,32 @@ class RepoKeyNotFoundError(Error):
|
|
|
"""No key entry found in the config of repository {}."""
|
|
|
|
|
|
|
|
|
+class TAMRequiredError(IntegrityError):
|
|
|
+ __doc__ = textwrap.dedent("""
|
|
|
+ Manifest is unauthenticated, but authentication is required for this repository.
|
|
|
+
|
|
|
+ This either means that you are under attack, or that you modified this repository
|
|
|
+ with a Borg version older than 1.0.9 after TAM authentication was enabled.
|
|
|
+
|
|
|
+ In the latter case, use "borg upgrade --tam --force '{}'" to re-authenticate the manifest.
|
|
|
+ """).strip()
|
|
|
+ traceback = False
|
|
|
+
|
|
|
+
|
|
|
+class TAMInvalid(IntegrityError):
|
|
|
+ __doc__ = IntegrityError.__doc__
|
|
|
+ traceback = False
|
|
|
+
|
|
|
+ def __init__(self):
|
|
|
+ # Error message becomes: "Data integrity error: Manifest authentication did not verify"
|
|
|
+ super().__init__('Manifest authentication did not verify')
|
|
|
+
|
|
|
+
|
|
|
+class TAMUnsupportedSuiteError(IntegrityError):
|
|
|
+ """Could not verify manifest: Unsupported suite {!r}; a newer version is needed."""
|
|
|
+ traceback = False
|
|
|
+
|
|
|
+
|
|
|
def key_creator(repository, args):
|
|
|
if args.encryption == 'keyfile':
|
|
|
return KeyfileKey.create(repository, args)
|
|
@@ -63,6 +95,16 @@ def key_factory(repository, manifest_data):
|
|
|
raise UnsupportedPayloadError(key_type)
|
|
|
|
|
|
|
|
|
+def tam_required_file(repository):
|
|
|
+ security_dir = get_security_dir(bin_to_hex(repository.id))
|
|
|
+ return os.path.join(security_dir, 'tam_required')
|
|
|
+
|
|
|
+
|
|
|
+def tam_required(repository):
|
|
|
+ file = tam_required_file(repository)
|
|
|
+ return os.path.isfile(file)
|
|
|
+
|
|
|
+
|
|
|
class KeyBase:
|
|
|
TYPE = None # override in subclasses
|
|
|
|
|
@@ -71,23 +113,90 @@ class KeyBase:
|
|
|
self.repository = repository
|
|
|
self.target = None # key location file path / repo obj
|
|
|
self.compressor = Compressor('none')
|
|
|
+ self.tam_required = True
|
|
|
|
|
|
def id_hash(self, data):
|
|
|
"""Return HMAC hash using the "id" HMAC key
|
|
|
"""
|
|
|
|
|
|
- def encrypt(self, data):
|
|
|
+ def encrypt(self, data, none_compression=False):
|
|
|
pass
|
|
|
|
|
|
def decrypt(self, id, data):
|
|
|
pass
|
|
|
|
|
|
+ def _tam_key(self, salt, context):
|
|
|
+ return hkdf_hmac_sha512(
|
|
|
+ ikm=self.id_key + self.enc_key + self.enc_hmac_key,
|
|
|
+ salt=salt,
|
|
|
+ info=b'borg-metadata-authentication-' + context,
|
|
|
+ output_length=64
|
|
|
+ )
|
|
|
+
|
|
|
+ def pack_and_authenticate_metadata(self, metadata_dict, context=b'manifest'):
|
|
|
+ metadata_dict = StableDict(metadata_dict)
|
|
|
+ tam = metadata_dict['tam'] = StableDict({
|
|
|
+ 'type': 'HKDF_HMAC_SHA512',
|
|
|
+ 'hmac': bytes(64),
|
|
|
+ 'salt': os.urandom(64),
|
|
|
+ })
|
|
|
+ packed = msgpack.packb(metadata_dict, unicode_errors='surrogateescape')
|
|
|
+ tam_key = self._tam_key(tam['salt'], context)
|
|
|
+ tam['hmac'] = HMAC(tam_key, packed, sha512).digest()
|
|
|
+ return msgpack.packb(metadata_dict, unicode_errors='surrogateescape')
|
|
|
+
|
|
|
+ def unpack_and_verify_manifest(self, data, force_tam_not_required=False):
|
|
|
+ """Unpack msgpacked *data* and return (object, did_verify)."""
|
|
|
+ if data.startswith(b'\xc1' * 4):
|
|
|
+ # This is a manifest from the future, we can't read it.
|
|
|
+ raise UnsupportedManifestError()
|
|
|
+ tam_required = self.tam_required
|
|
|
+ if force_tam_not_required and tam_required:
|
|
|
+ logger.warning('Manifest authentication DISABLED.')
|
|
|
+ tam_required = False
|
|
|
+ data = bytearray(data)
|
|
|
+ # Since we don't trust these bytes we use the slower Python unpacker,
|
|
|
+ # which is assumed to have a lower probability of security issues.
|
|
|
+ unpacked = msgpack.fallback.unpackb(data, object_hook=StableDict, unicode_errors='surrogateescape')
|
|
|
+ if b'tam' not in unpacked:
|
|
|
+ if tam_required:
|
|
|
+ raise TAMRequiredError(self.repository._location.canonical_path())
|
|
|
+ else:
|
|
|
+ logger.debug('TAM not found and not required')
|
|
|
+ return unpacked, False
|
|
|
+ tam = unpacked.pop(b'tam', None)
|
|
|
+ if not isinstance(tam, dict):
|
|
|
+ raise TAMInvalid()
|
|
|
+ tam_type = tam.get(b'type', b'<none>').decode('ascii', 'replace')
|
|
|
+ if tam_type != 'HKDF_HMAC_SHA512':
|
|
|
+ if tam_required:
|
|
|
+ raise TAMUnsupportedSuiteError(repr(tam_type))
|
|
|
+ else:
|
|
|
+ logger.debug('Ignoring TAM made with unsupported suite, since TAM is not required: %r', tam_type)
|
|
|
+ return unpacked, False
|
|
|
+ tam_hmac = tam.get(b'hmac')
|
|
|
+ tam_salt = tam.get(b'salt')
|
|
|
+ if not isinstance(tam_salt, bytes) or not isinstance(tam_hmac, bytes):
|
|
|
+ raise TAMInvalid()
|
|
|
+ offset = data.index(tam_hmac)
|
|
|
+ data[offset:offset + 64] = bytes(64)
|
|
|
+ tam_key = self._tam_key(tam_salt, context=b'manifest')
|
|
|
+ calculated_hmac = HMAC(tam_key, data, sha512).digest()
|
|
|
+ if not compare_digest(calculated_hmac, tam_hmac):
|
|
|
+ raise TAMInvalid()
|
|
|
+ logger.debug('TAM-verified manifest')
|
|
|
+ return unpacked, True
|
|
|
+
|
|
|
|
|
|
class PlaintextKey(KeyBase):
|
|
|
TYPE = 0x02
|
|
|
|
|
|
chunk_seed = 0
|
|
|
|
|
|
+ def __init__(self, repository):
|
|
|
+ super().__init__(repository)
|
|
|
+ self.tam_required = False
|
|
|
+
|
|
|
@classmethod
|
|
|
def create(cls, repository, args):
|
|
|
logger.info('Encryption NOT enabled.\nUse the "--encryption=repokey|keyfile" to enable encryption.')
|
|
@@ -100,8 +209,12 @@ class PlaintextKey(KeyBase):
|
|
|
def id_hash(self, data):
|
|
|
return sha256(data).digest()
|
|
|
|
|
|
- def encrypt(self, data):
|
|
|
- return b''.join([self.TYPE_STR, self.compressor.compress(data)])
|
|
|
+ def encrypt(self, data, none_compression=False):
|
|
|
+ if none_compression:
|
|
|
+ compressed = CNONE().compress(data)
|
|
|
+ else:
|
|
|
+ compressed = self.compressor.compress(data)
|
|
|
+ return b''.join([self.TYPE_STR, compressed])
|
|
|
|
|
|
def decrypt(self, id, data):
|
|
|
if data[0] != self.TYPE:
|
|
@@ -112,6 +225,9 @@ class PlaintextKey(KeyBase):
|
|
|
raise IntegrityError('Chunk %s: id verification failed' % bin_to_hex(id))
|
|
|
return data
|
|
|
|
|
|
+ def _tam_key(self, salt, context):
|
|
|
+ return salt + context
|
|
|
+
|
|
|
|
|
|
class AESKeyBase(KeyBase):
|
|
|
"""Common base class shared by KeyfileKey and PassphraseKey
|
|
@@ -133,8 +249,11 @@ class AESKeyBase(KeyBase):
|
|
|
"""
|
|
|
return HMAC(self.id_key, data, sha256).digest()
|
|
|
|
|
|
- def encrypt(self, data):
|
|
|
- data = self.compressor.compress(data)
|
|
|
+ def encrypt(self, data, none_compression=False):
|
|
|
+ if none_compression:
|
|
|
+ data = CNONE().compress(data)
|
|
|
+ else:
|
|
|
+ data = self.compressor.compress(data)
|
|
|
self.enc_cipher.reset()
|
|
|
data = b''.join((self.enc_cipher.iv[8:], self.enc_cipher.encrypt(data)))
|
|
|
hmac = HMAC(self.enc_hmac_key, data, sha256).digest()
|
|
@@ -269,6 +388,7 @@ class PassphraseKey(AESKeyBase):
|
|
|
key.decrypt(None, manifest_data)
|
|
|
num_blocks = num_aes_blocks(len(manifest_data) - 41)
|
|
|
key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
|
|
|
+ key._passphrase = passphrase
|
|
|
return key
|
|
|
except IntegrityError:
|
|
|
passphrase = Passphrase.getpass(prompt)
|
|
@@ -284,6 +404,7 @@ class PassphraseKey(AESKeyBase):
|
|
|
def init(self, repository, passphrase):
|
|
|
self.init_from_random_data(passphrase.kdf(repository.id, self.iterations, 100))
|
|
|
self.init_ciphers()
|
|
|
+ self.tam_required = False
|
|
|
|
|
|
|
|
|
class KeyfileKeyBase(AESKeyBase):
|
|
@@ -307,6 +428,7 @@ class KeyfileKeyBase(AESKeyBase):
|
|
|
raise PassphraseWrong
|
|
|
num_blocks = num_aes_blocks(len(manifest_data) - 41)
|
|
|
key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
|
|
|
+ key._passphrase = passphrase
|
|
|
return key
|
|
|
|
|
|
def find_key(self):
|
|
@@ -327,6 +449,7 @@ class KeyfileKeyBase(AESKeyBase):
|
|
|
self.enc_hmac_key = key[b'enc_hmac_key']
|
|
|
self.id_key = key[b'id_key']
|
|
|
self.chunk_seed = key[b'chunk_seed']
|
|
|
+ self.tam_required = key.get(b'tam_required', tam_required(self.repository))
|
|
|
return True
|
|
|
return False
|
|
|
|
|
@@ -363,15 +486,16 @@ class KeyfileKeyBase(AESKeyBase):
|
|
|
'enc_hmac_key': self.enc_hmac_key,
|
|
|
'id_key': self.id_key,
|
|
|
'chunk_seed': self.chunk_seed,
|
|
|
+ 'tam_required': self.tam_required,
|
|
|
}
|
|
|
data = self.encrypt_key_file(msgpack.packb(key), passphrase)
|
|
|
key_data = '\n'.join(textwrap.wrap(b2a_base64(data).decode('ascii')))
|
|
|
return key_data
|
|
|
|
|
|
- def change_passphrase(self):
|
|
|
- passphrase = Passphrase.new(allow_empty=True)
|
|
|
+ def change_passphrase(self, passphrase=None):
|
|
|
+ if passphrase is None:
|
|
|
+ passphrase = Passphrase.new(allow_empty=True)
|
|
|
self.save(self.target, passphrase)
|
|
|
- logger.info('Key updated')
|
|
|
|
|
|
@classmethod
|
|
|
def create(cls, repository, args):
|