|  | @@ -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):
 |