mirror of
https://github.com/borgbackup/borg.git
synced 2025-02-21 13:47:16 +00:00
Merge pull request #6415 from ThomasWaldmann/borg-key
borg key change-location, cleanups
This commit is contained in:
commit
d9d1e44b67
8 changed files with 432 additions and 283 deletions
|
@ -44,7 +44,8 @@
|
||||||
from .cache import Cache, assert_secure, SecurityManager
|
from .cache import Cache, assert_secure, SecurityManager
|
||||||
from .constants import * # NOQA
|
from .constants import * # NOQA
|
||||||
from .compress import CompressionSpec
|
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 .crypto.keymanager import KeyManager
|
||||||
from .helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, EXIT_SIGNAL_BASE
|
from .helpers import EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR, EXIT_SIGNAL_BASE
|
||||||
from .helpers import Error, NoManifestError, set_ec
|
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())
|
logger.info('Key location: %s', key.find_key())
|
||||||
return EXIT_SUCCESS
|
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)
|
@with_repository(lock=False, exclusive=False, manifest=False, cache=False)
|
||||||
def do_key_export(self, args, repository):
|
def do_key_export(self, args, repository):
|
||||||
"""Export the repository key for backup"""
|
"""Export the repository key for backup"""
|
||||||
|
@ -4250,6 +4307,28 @@ def define_borg_mount(parser):
|
||||||
subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
|
subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='',
|
||||||
type=location_validator(archive=False))
|
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
|
# borg list
|
||||||
list_epilog = process_epilog("""
|
list_epilog = process_epilog("""
|
||||||
This command lists the contents of a repository or an archive.
|
This command lists the contents of a repository or an archive.
|
||||||
|
|
|
@ -104,6 +104,27 @@
|
||||||
PBKDF2_ITERATIONS = 100000
|
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.
|
REPOSITORY_README = """This is a Borg Backup repository.
|
||||||
See https://borgbackup.readthedocs.io/
|
See https://borgbackup.readthedocs.io/
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1,13 +1,9 @@
|
||||||
import configparser
|
import configparser
|
||||||
import getpass
|
|
||||||
import hmac
|
import hmac
|
||||||
import os
|
import os
|
||||||
import shlex
|
|
||||||
import sys
|
|
||||||
import textwrap
|
import textwrap
|
||||||
import subprocess
|
|
||||||
from binascii import a2b_base64, b2a_base64, hexlify
|
from binascii import a2b_base64, b2a_base64, hexlify
|
||||||
from hashlib import sha256, sha512, pbkdf2_hmac
|
from hashlib import sha256
|
||||||
|
|
||||||
from ..logger import create_logger
|
from ..logger import create_logger
|
||||||
|
|
||||||
|
@ -17,11 +13,10 @@
|
||||||
from ..compress import Compressor
|
from ..compress import Compressor
|
||||||
from ..helpers import StableDict
|
from ..helpers import StableDict
|
||||||
from ..helpers import Error, IntegrityError
|
from ..helpers import Error, IntegrityError
|
||||||
from ..helpers import yes
|
|
||||||
from ..helpers import get_keys_dir, get_security_dir
|
from ..helpers import get_keys_dir, get_security_dir
|
||||||
from ..helpers import get_limited_unpacker
|
from ..helpers import get_limited_unpacker
|
||||||
from ..helpers import bin_to_hex
|
from ..helpers import bin_to_hex
|
||||||
from ..helpers import prepare_subprocess_env
|
from ..helpers.passphrase import Passphrase, PasswordRetriesExceeded, PassphraseWrong
|
||||||
from ..helpers import msgpack
|
from ..helpers import msgpack
|
||||||
from ..item import Key, EncryptedKey
|
from ..item import Key, EncryptedKey
|
||||||
from ..platform import SaveFile
|
from ..platform import SaveFile
|
||||||
|
@ -31,22 +26,6 @@
|
||||||
from .low_level import AES256_CTR_HMAC_SHA256, AES256_CTR_BLAKE2b
|
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):
|
class UnsupportedPayloadError(Error):
|
||||||
"""Unsupported payload type {}. A newer version is required to access this repository."""
|
"""Unsupported payload type {}. A newer version is required to access this repository."""
|
||||||
|
|
||||||
|
@ -97,12 +76,6 @@ class TAMUnsupportedSuiteError(IntegrityError):
|
||||||
traceback = False
|
traceback = False
|
||||||
|
|
||||||
|
|
||||||
class KeyBlobStorage:
|
|
||||||
NO_STORAGE = 'no_storage'
|
|
||||||
KEYFILE = 'keyfile'
|
|
||||||
REPO = 'repository'
|
|
||||||
|
|
||||||
|
|
||||||
def key_creator(repository, args):
|
def key_creator(repository, args):
|
||||||
for key in AVAILABLE_KEY_TYPES:
|
for key in AVAILABLE_KEY_TYPES:
|
||||||
if key.ARG_NAME == args.encryption:
|
if key.ARG_NAME == args.encryption:
|
||||||
|
@ -118,8 +91,8 @@ def key_argument_names():
|
||||||
|
|
||||||
def identify_key(manifest_data):
|
def identify_key(manifest_data):
|
||||||
key_type = manifest_data[0]
|
key_type = manifest_data[0]
|
||||||
if key_type == PassphraseKey.TYPE:
|
if key_type == KeyType.PASSPHRASE: # legacy, see comment in KeyType class.
|
||||||
return RepoKey # see comment in PassphraseKey class.
|
return RepoKey
|
||||||
|
|
||||||
for key in AVAILABLE_KEY_TYPES:
|
for key in AVAILABLE_KEY_TYPES:
|
||||||
if key.TYPE == key_type:
|
if key.TYPE == key_type:
|
||||||
|
@ -145,6 +118,8 @@ def tam_required(repository):
|
||||||
class KeyBase:
|
class KeyBase:
|
||||||
# Numeric key type ID, must fit in one byte.
|
# Numeric key type ID, must fit in one byte.
|
||||||
TYPE = None # override in subclasses
|
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
|
# Human-readable name
|
||||||
NAME = 'UNDEFINED'
|
NAME = 'UNDEFINED'
|
||||||
|
@ -194,6 +169,11 @@ def assert_id(self, id, data):
|
||||||
if not hmac.compare_digest(id_computed, id):
|
if not hmac.compare_digest(id_computed, id):
|
||||||
raise IntegrityError('Chunk %s: id verification failed' % bin_to_hex(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):
|
def _tam_key(self, salt, context):
|
||||||
return hkdf_hmac_sha512(
|
return hkdf_hmac_sha512(
|
||||||
ikm=self.id_key + self.enc_key + self.enc_hmac_key,
|
ikm=self.id_key + self.enc_key + self.enc_hmac_key,
|
||||||
|
@ -258,7 +238,8 @@ def unpack_and_verify_manifest(self, data, force_tam_not_required=False):
|
||||||
|
|
||||||
|
|
||||||
class PlaintextKey(KeyBase):
|
class PlaintextKey(KeyBase):
|
||||||
TYPE = 0x02
|
TYPE = KeyType.PLAINTEXT
|
||||||
|
TYPES_ACCEPTABLE = {TYPE}
|
||||||
NAME = 'plaintext'
|
NAME = 'plaintext'
|
||||||
ARG_NAME = 'none'
|
ARG_NAME = 'none'
|
||||||
STORAGE = KeyBlobStorage.NO_STORAGE
|
STORAGE = KeyBlobStorage.NO_STORAGE
|
||||||
|
@ -287,9 +268,7 @@ def encrypt(self, chunk):
|
||||||
return b''.join([self.TYPE_STR, data])
|
return b''.join([self.TYPE_STR, data])
|
||||||
|
|
||||||
def decrypt(self, id, data, decompress=True):
|
def decrypt(self, id, data, decompress=True):
|
||||||
if data[0] != self.TYPE:
|
self.assert_type(data[0], id)
|
||||||
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:]
|
payload = memoryview(data)[1:]
|
||||||
if not decompress:
|
if not decompress:
|
||||||
return payload
|
return payload
|
||||||
|
@ -367,10 +346,7 @@ def encrypt(self, chunk):
|
||||||
return self.cipher.encrypt(data, header=self.TYPE_STR, iv=next_iv)
|
return self.cipher.encrypt(data, header=self.TYPE_STR, iv=next_iv)
|
||||||
|
|
||||||
def decrypt(self, id, data, decompress=True):
|
def decrypt(self, id, data, decompress=True):
|
||||||
if not (data[0] == self.TYPE or
|
self.assert_type(data[0], id)
|
||||||
data[0] == PassphraseKey.TYPE and isinstance(self, RepoKey)):
|
|
||||||
id_str = bin_to_hex(id) if id is not None else '(unknown)'
|
|
||||||
raise IntegrityError('Chunk %s: Invalid encryption envelope' % id_str)
|
|
||||||
try:
|
try:
|
||||||
payload = self.cipher.decrypt(data)
|
payload = self.cipher.decrypt(data)
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
|
@ -396,9 +372,7 @@ def init_ciphers(self, manifest_data=None):
|
||||||
if manifest_data is None:
|
if manifest_data is None:
|
||||||
nonce = 0
|
nonce = 0
|
||||||
else:
|
else:
|
||||||
if not (manifest_data[0] == self.TYPE or
|
self.assert_type(manifest_data[0])
|
||||||
manifest_data[0] == PassphraseKey.TYPE and isinstance(self, RepoKey)):
|
|
||||||
raise IntegrityError('Manifest: Invalid encryption envelope')
|
|
||||||
# manifest_blocks is a safe upper bound on the amount of cipher blocks needed
|
# 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
|
# to encrypt the manifest. depending on the ciphersuite and overhead, it might
|
||||||
# be a bit too high, but that does not matter.
|
# be a bit too high, but that does not matter.
|
||||||
|
@ -408,125 +382,7 @@ def init_ciphers(self, manifest_data=None):
|
||||||
self.nonce_manager = NonceManager(self.repository, nonce)
|
self.nonce_manager = NonceManager(self.repository, nonce)
|
||||||
|
|
||||||
|
|
||||||
class Passphrase(str):
|
class FlexiKeyBase(AESKeyBase):
|
||||||
@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 '<Passphrase "***hidden***">'
|
|
||||||
|
|
||||||
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 KeyfileKeyBase(AESKeyBase):
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def detect(cls, repository, manifest_data):
|
def detect(cls, repository, manifest_data):
|
||||||
key = cls(repository)
|
key = cls(repository)
|
||||||
|
@ -639,11 +495,8 @@ def get_new_target(self, args):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class KeyfileKey(ID_HMAC_SHA_256, KeyfileKeyBase):
|
class FlexiKey(ID_HMAC_SHA_256, FlexiKeyBase):
|
||||||
TYPE = 0x00
|
TYPES_ACCEPTABLE = {KeyType.KEYFILE, KeyType.REPO, KeyType.PASSPHRASE}
|
||||||
NAME = 'key file'
|
|
||||||
ARG_NAME = 'keyfile'
|
|
||||||
STORAGE = KeyBlobStorage.KEYFILE
|
|
||||||
|
|
||||||
FILE_ID = 'BORG_KEY'
|
FILE_ID = 'BORG_KEY'
|
||||||
|
|
||||||
|
@ -660,13 +513,23 @@ def sanity_check(self, filename, id):
|
||||||
return filename
|
return filename
|
||||||
|
|
||||||
def find_key(self):
|
def find_key(self):
|
||||||
keyfile = self._find_key_file_from_environment()
|
if self.STORAGE == KeyBlobStorage.KEYFILE:
|
||||||
if keyfile is not None:
|
keyfile = self._find_key_file_from_environment()
|
||||||
return self.sanity_check(keyfile, self.repository.id)
|
if keyfile is not None:
|
||||||
keyfile = self._find_key_in_keys_dir()
|
return self.sanity_check(keyfile, self.repository.id)
|
||||||
if keyfile is not None:
|
keyfile = self._find_key_in_keys_dir()
|
||||||
return keyfile
|
if keyfile is not None:
|
||||||
raise KeyfileNotFoundError(self.repository._location.canonical_path(), get_keys_dir())
|
return keyfile
|
||||||
|
raise KeyfileNotFoundError(self.repository._location.canonical_path(), get_keys_dir())
|
||||||
|
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):
|
def get_existing_or_new_target(self, args):
|
||||||
keyfile = self._find_key_file_from_environment()
|
keyfile = self._find_key_file_from_environment()
|
||||||
|
@ -688,10 +551,15 @@ def _find_key_in_keys_dir(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_new_target(self, args):
|
def get_new_target(self, args):
|
||||||
keyfile = self._find_key_file_from_environment()
|
if self.STORAGE == KeyBlobStorage.KEYFILE:
|
||||||
if keyfile is not None:
|
keyfile = self._find_key_file_from_environment()
|
||||||
return keyfile
|
if keyfile is not None:
|
||||||
return self._get_new_target_in_keys_dir(args)
|
return keyfile
|
||||||
|
return self._get_new_target_in_keys_dir(args)
|
||||||
|
elif self.STORAGE == KeyBlobStorage.REPO:
|
||||||
|
return self.repository
|
||||||
|
else:
|
||||||
|
raise TypeError('Unsupported borg key storage type')
|
||||||
|
|
||||||
def _find_key_file_from_environment(self):
|
def _find_key_file_from_environment(self):
|
||||||
keyfile = os.environ.get('BORG_KEY_FILE')
|
keyfile = os.environ.get('BORG_KEY_FILE')
|
||||||
|
@ -708,86 +576,88 @@ def _get_new_target_in_keys_dir(self, args):
|
||||||
return path
|
return path
|
||||||
|
|
||||||
def load(self, target, passphrase):
|
def load(self, target, passphrase):
|
||||||
with open(target) as fd:
|
if self.STORAGE == KeyBlobStorage.KEYFILE:
|
||||||
key_data = ''.join(fd.readlines()[1:])
|
with open(target) as fd:
|
||||||
|
key_data = ''.join(fd.readlines()[1:])
|
||||||
|
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 != ''
|
||||||
|
|
||||||
|
# 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
|
||||||
|
else:
|
||||||
|
raise TypeError('Unsupported borg key storage type')
|
||||||
success = self._load(key_data, passphrase)
|
success = self._load(key_data, passphrase)
|
||||||
if success:
|
if success:
|
||||||
self.target = target
|
self.target = target
|
||||||
return success
|
return success
|
||||||
|
|
||||||
def save(self, target, passphrase, create=False):
|
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)
|
key_data = self._save(passphrase)
|
||||||
with SaveFile(target) as fd:
|
if self.STORAGE == KeyBlobStorage.KEYFILE:
|
||||||
fd.write(f'{self.FILE_ID} {bin_to_hex(self.repository_id)}\n')
|
if create and os.path.isfile(target):
|
||||||
fd.write(key_data)
|
# if a new keyfile key repository is created, ensure that an existing keyfile of another
|
||||||
fd.write('\n')
|
# 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)
|
||||||
|
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')
|
||||||
|
elif self.STORAGE == KeyBlobStorage.REPO:
|
||||||
|
self.logically_encrypted = passphrase != ''
|
||||||
|
key_data = key_data.encode('utf-8') # remote repo: msgpack issue #99, giving bytes
|
||||||
|
target.save_key(key_data)
|
||||||
|
else:
|
||||||
|
raise TypeError('Unsupported borg key storage type')
|
||||||
self.target = target
|
self.target = target
|
||||||
|
|
||||||
|
def remove(self, target):
|
||||||
|
if self.STORAGE == KeyBlobStorage.KEYFILE:
|
||||||
|
os.remove(target)
|
||||||
|
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 RepoKey(ID_HMAC_SHA_256, KeyfileKeyBase):
|
|
||||||
TYPE = 0x03
|
class KeyfileKey(FlexiKey):
|
||||||
|
TYPE = KeyType.KEYFILE
|
||||||
|
NAME = 'key file'
|
||||||
|
ARG_NAME = 'keyfile'
|
||||||
|
STORAGE = KeyBlobStorage.KEYFILE
|
||||||
|
|
||||||
|
|
||||||
|
class RepoKey(FlexiKey):
|
||||||
|
TYPE = KeyType.REPO
|
||||||
NAME = 'repokey'
|
NAME = 'repokey'
|
||||||
ARG_NAME = 'repokey'
|
ARG_NAME = 'repokey'
|
||||||
STORAGE = KeyBlobStorage.REPO
|
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):
|
class Blake2FlexiKey(ID_BLAKE2b_256, FlexiKey):
|
||||||
return self.repository
|
TYPES_ACCEPTABLE = {KeyType.BLAKE2KEYFILE, KeyType.BLAKE2REPO}
|
||||||
|
CIPHERSUITE = AES256_CTR_BLAKE2b
|
||||||
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 Blake2KeyfileKey(ID_BLAKE2b_256, KeyfileKey):
|
class Blake2KeyfileKey(Blake2FlexiKey):
|
||||||
TYPE = 0x04
|
TYPE = KeyType.BLAKE2KEYFILE
|
||||||
NAME = 'key file BLAKE2b'
|
NAME = 'key file BLAKE2b'
|
||||||
ARG_NAME = 'keyfile-blake2'
|
ARG_NAME = 'keyfile-blake2'
|
||||||
STORAGE = KeyBlobStorage.KEYFILE
|
STORAGE = KeyBlobStorage.KEYFILE
|
||||||
|
|
||||||
FILE_ID = 'BORG_KEY'
|
|
||||||
CIPHERSUITE = AES256_CTR_BLAKE2b
|
|
||||||
|
|
||||||
|
class Blake2RepoKey(Blake2FlexiKey):
|
||||||
class Blake2RepoKey(ID_BLAKE2b_256, RepoKey):
|
TYPE = KeyType.BLAKE2REPO
|
||||||
TYPE = 0x05
|
|
||||||
NAME = 'repokey BLAKE2b'
|
NAME = 'repokey BLAKE2b'
|
||||||
ARG_NAME = 'repokey-blake2'
|
ARG_NAME = 'repokey-blake2'
|
||||||
STORAGE = KeyBlobStorage.REPO
|
STORAGE = KeyBlobStorage.REPO
|
||||||
|
|
||||||
CIPHERSUITE = AES256_CTR_BLAKE2b
|
|
||||||
|
|
||||||
|
class AuthenticatedKeyBase(FlexiKey):
|
||||||
class AuthenticatedKeyBase(RepoKey):
|
|
||||||
STORAGE = KeyBlobStorage.REPO
|
STORAGE = KeyBlobStorage.REPO
|
||||||
|
|
||||||
# It's only authenticated, not encrypted.
|
# It's only authenticated, not encrypted.
|
||||||
|
@ -803,17 +673,15 @@ def save(self, target, passphrase, create=False):
|
||||||
self.logically_encrypted = False
|
self.logically_encrypted = False
|
||||||
|
|
||||||
def init_ciphers(self, manifest_data=None):
|
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:
|
||||||
raise IntegrityError('Manifest: Invalid encryption envelope')
|
self.assert_type(manifest_data[0])
|
||||||
|
|
||||||
def encrypt(self, chunk):
|
def encrypt(self, chunk):
|
||||||
data = self.compressor.compress(chunk)
|
data = self.compressor.compress(chunk)
|
||||||
return b''.join([self.TYPE_STR, data])
|
return b''.join([self.TYPE_STR, data])
|
||||||
|
|
||||||
def decrypt(self, id, data, decompress=True):
|
def decrypt(self, id, data, decompress=True):
|
||||||
if data[0] != self.TYPE:
|
self.assert_type(data[0], id)
|
||||||
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:]
|
payload = memoryview(data)[1:]
|
||||||
if not decompress:
|
if not decompress:
|
||||||
return payload
|
return payload
|
||||||
|
@ -823,13 +691,15 @@ def decrypt(self, id, data, decompress=True):
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatedKey(AuthenticatedKeyBase):
|
class AuthenticatedKey(AuthenticatedKeyBase):
|
||||||
TYPE = 0x07
|
TYPE = KeyType.AUTHENTICATED
|
||||||
|
TYPES_ACCEPTABLE = {TYPE}
|
||||||
NAME = 'authenticated'
|
NAME = 'authenticated'
|
||||||
ARG_NAME = 'authenticated'
|
ARG_NAME = 'authenticated'
|
||||||
|
|
||||||
|
|
||||||
class Blake2AuthenticatedKey(ID_BLAKE2b_256, AuthenticatedKeyBase):
|
class Blake2AuthenticatedKey(ID_BLAKE2b_256, AuthenticatedKeyBase):
|
||||||
TYPE = 0x06
|
TYPE = KeyType.BLAKE2AUTHENTICATED
|
||||||
|
TYPES_ACCEPTABLE = {TYPE}
|
||||||
NAME = 'authenticated BLAKE2b'
|
NAME = 'authenticated BLAKE2b'
|
||||||
ARG_NAME = 'authenticated-blake2'
|
ARG_NAME = 'authenticated-blake2'
|
||||||
|
|
||||||
|
|
141
src/borg/helpers/passphrase.py
Normal file
141
src/borg/helpers/passphrase.py
Normal file
|
@ -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 '<Passphrase "***hidden***">'
|
||||||
|
|
||||||
|
def kdf(self, salt, iterations, length):
|
||||||
|
return pbkdf2_hmac('sha256', self.encode('utf-8'), salt, iterations, length)
|
|
@ -334,11 +334,13 @@ def save_config(self, path, config):
|
||||||
def save_key(self, keydata):
|
def save_key(self, keydata):
|
||||||
assert self.config
|
assert self.config
|
||||||
keydata = keydata.decode('utf-8') # remote repo: msgpack issue #99, getting bytes
|
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.config.set('repository', 'key', keydata)
|
||||||
self.save_config(self.path, self.config)
|
self.save_config(self.path, self.config)
|
||||||
|
|
||||||
def load_key(self):
|
def load_key(self):
|
||||||
keydata = self.config.get('repository', 'key')
|
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
|
return keydata.encode('utf-8') # remote repo: msgpack issue #99, returning bytes
|
||||||
|
|
||||||
def get_free_nonce(self):
|
def get_free_nonce(self):
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
from ..chunker import has_seek_hole
|
from ..chunker import has_seek_hole
|
||||||
from ..constants import * # NOQA
|
from ..constants import * # NOQA
|
||||||
from ..crypto.low_level import bytes_to_long, num_cipher_blocks
|
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.keymanager import RepoIdMismatch, NotABorgKeyFile
|
||||||
from ..crypto.file_integrity import FileIntegrityError
|
from ..crypto.file_integrity import FileIntegrityError
|
||||||
from ..helpers import Location, get_security_dir
|
from ..helpers import Location, get_security_dir
|
||||||
|
@ -2490,6 +2490,38 @@ def test_change_passphrase(self):
|
||||||
os.environ['BORG_PASSPHRASE'] = 'newpassphrase'
|
os.environ['BORG_PASSPHRASE'] = 'newpassphrase'
|
||||||
self.cmd('list', self.repository_location)
|
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):
|
def test_break_lock(self):
|
||||||
self.cmd('init', '--encryption=repokey', self.repository_location)
|
self.cmd('init', '--encryption=repokey', self.repository_location)
|
||||||
self.cmd('break-lock', self.repository_location)
|
self.cmd('break-lock', self.repository_location)
|
||||||
|
@ -2850,7 +2882,7 @@ def test_init_interrupt(self):
|
||||||
def raise_eof(*args):
|
def raise_eof(*args):
|
||||||
raise EOFError
|
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)
|
self.cmd('init', '--encryption=repokey', self.repository_location, exit_code=1)
|
||||||
assert not os.path.exists(self.repository_location)
|
assert not os.path.exists(self.repository_location)
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import errno
|
import errno
|
||||||
|
import getpass
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
@ -32,6 +33,7 @@
|
||||||
from ..helpers import iter_separated
|
from ..helpers import iter_separated
|
||||||
from ..helpers import eval_escapes
|
from ..helpers import eval_escapes
|
||||||
from ..helpers import safe_unlink
|
from ..helpers import safe_unlink
|
||||||
|
from ..helpers.passphrase import Passphrase, PasswordRetriesExceeded
|
||||||
|
|
||||||
from . import BaseTestCase, FakeInputs
|
from . import BaseTestCase, FakeInputs
|
||||||
|
|
||||||
|
@ -1164,3 +1166,44 @@ def os_unlink(_):
|
||||||
safe_unlink(hard_link)
|
safe_unlink(hard_link)
|
||||||
|
|
||||||
assert victim.read_binary() == contents
|
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"))
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import pytest
|
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, \
|
from ..crypto.key import PlaintextKey, AuthenticatedKey, RepoKey, KeyfileKey, \
|
||||||
Blake2KeyfileKey, Blake2RepoKey, Blake2AuthenticatedKey
|
Blake2KeyfileKey, Blake2RepoKey, Blake2AuthenticatedKey
|
||||||
from ..crypto.key import ID_HMAC_SHA_256, ID_BLAKE2b_256
|
from ..crypto.key import ID_HMAC_SHA_256, ID_BLAKE2b_256
|
||||||
|
@ -184,7 +184,9 @@ def test_keyfile_blake2(self, monkeypatch, keys_dir):
|
||||||
|
|
||||||
def _corrupt_byte(self, key, data, offset):
|
def _corrupt_byte(self, key, data, offset):
|
||||||
data = bytearray(data)
|
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):
|
with pytest.raises(IntegrityErrorBase):
|
||||||
key.decrypt(b'', data)
|
key.decrypt(b'', data)
|
||||||
|
|
||||||
|
@ -253,47 +255,6 @@ def test_blake2_authenticated_encrypt(self, monkeypatch):
|
||||||
assert authenticated == b'\x06\x00\x00' + plaintext
|
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:
|
class TestTAM:
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def key(self, monkeypatch):
|
def key(self, monkeypatch):
|
||||||
|
|
Loading…
Reference in a new issue