From 55a33319424a349fe5189c22cf328c8abc42f964 Mon Sep 17 00:00:00 2001 From: Martin Hostettler Date: Sun, 18 Sep 2016 17:20:27 +0200 Subject: [PATCH] Implement key import / export We recommed that users backup their keys, this adds simple to use commands to do so. Supported formats are the keyfile format used by borg internally and a special format with by line checksums for printed backups. For this format the import is an interactive process which checks each line as soon as it is input. Fixes #1555 --- borg/archiver.py | 62 +++++++++++ borg/keymanager.py | 213 +++++++++++++++++++++++++++++++++++++ borg/testsuite/archiver.py | 104 +++++++++++++++++- 3 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 borg/keymanager.py diff --git a/borg/archiver.py b/borg/archiver.py index bb8e33f77..6ef1d0fb8 100644 --- a/borg/archiver.py +++ b/borg/archiver.py @@ -30,6 +30,7 @@ from .upgrader import AtticRepositoryUpgrader, BorgRepositoryUpgrader from .repository import Repository from .cache import Cache from .key import key_creator, RepoKey, PassphraseKey +from .keymanager import KeyManager from .archive import backup_io, BackupOSError, Archive, ArchiveChecker, CHUNKER_PARAMS, is_special from .remote import RepositoryServer, RemoteRepository, cache_if_remote @@ -159,6 +160,39 @@ class Archiver: key.change_passphrase() return EXIT_SUCCESS + @with_repository(lock=False, exclusive=False, manifest=False, cache=False) + def do_key_export(self, args, repository): + """Export the repository key for backup""" + manager = KeyManager(repository) + manager.load_keyblob() + if args.paper: + manager.export_paperkey(args.path) + else: + if not args.path: + self.print_error("output file to export key to expected") + return EXIT_ERROR + manager.export(args.path) + return EXIT_SUCCESS + + @with_repository(lock=False, exclusive=False, manifest=False, cache=False) + def do_key_import(self, args, repository): + """Import the repository key from backup""" + manager = KeyManager(repository) + if args.paper: + if args.path: + self.print_error("with --paper import from file is not supported") + return EXIT_ERROR + manager.import_paperkey(args) + else: + if not args.path: + self.print_error("input file to import key from expected") + return EXIT_ERROR + if not os.path.exists(args.path): + self.print_error("input file does not exist: " + args.path) + return EXIT_ERROR + manager.import_keyfile(args) + return EXIT_SUCCESS + @with_repository(manifest=False) def do_migrate_to_repokey(self, args, repository): """Migrate passphrase -> repokey""" @@ -1076,6 +1110,34 @@ class Archiver: subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='', type=location_validator(archive=False)) + subparser = subparsers.add_parser('key-export', parents=[common_parser], + description=self.do_key_export.__doc__, + epilog="", + formatter_class=argparse.RawDescriptionHelpFormatter, + help='export repository key for backup') + subparser.set_defaults(func=self.do_key_export) + subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='', + type=location_validator(archive=False)) + subparser.add_argument('path', metavar='PATH', nargs='?', type=str, + help='where to store the backup') + subparser.add_argument('--paper', dest='paper', action='store_true', + default=False, + help='Create an export suitable for printing and later type-in') + + subparser = subparsers.add_parser('key-import', parents=[common_parser], + description=self.do_key_import.__doc__, + epilog="", + formatter_class=argparse.RawDescriptionHelpFormatter, + help='import repository key from backup') + subparser.set_defaults(func=self.do_key_import) + subparser.add_argument('location', metavar='REPOSITORY', nargs='?', default='', + type=location_validator(archive=False)) + subparser.add_argument('path', metavar='PATH', nargs='?', type=str, + help='path to the backup') + subparser.add_argument('--paper', dest='paper', action='store_true', + default=False, + help='interactively import from a backup done with --paper') + migrate_to_repokey_epilog = textwrap.dedent(""" This command migrates a repository from passphrase mode (not supported any more) to repokey mode. diff --git a/borg/keymanager.py b/borg/keymanager.py new file mode 100644 index 000000000..244e16c69 --- /dev/null +++ b/borg/keymanager.py @@ -0,0 +1,213 @@ +from binascii import hexlify, unhexlify, a2b_base64, b2a_base64 +import binascii +import textwrap +from hashlib import sha256 + +from .key import KeyfileKey, RepoKey, PassphraseKey, KeyfileNotFoundError, PlaintextKey +from .helpers import Manifest, NoManifestError, Error, yes +from .repository import Repository + + +class UnencryptedRepo(Error): + """Keymanagement not available for unencrypted repositories.""" + + +class UnknownKeyType(Error): + """Keytype {0} is unknown.""" + + +class RepoIdMismatch(Error): + """This key backup seems to be for a different backup repository, aborting.""" + + +class NotABorgKeyFile(Error): + """This file is not a borg key backup, aborting.""" + + +def sha256_truncated(data, num): + h = sha256() + h.update(data) + return h.hexdigest()[:num] + + +KEYBLOB_LOCAL = 'local' +KEYBLOB_REPO = 'repo' + + +class KeyManager: + def __init__(self, repository): + self.repository = repository + self.keyblob = None + self.keyblob_storage = None + + try: + cdata = self.repository.get(Manifest.MANIFEST_ID) + except Repository.ObjectNotFound: + raise NoManifestError + + key_type = cdata[0] + if key_type == KeyfileKey.TYPE: + self.keyblob_storage = KEYBLOB_LOCAL + elif key_type == RepoKey.TYPE or key_type == PassphraseKey.TYPE: + self.keyblob_storage = KEYBLOB_REPO + elif key_type == PlaintextKey.TYPE: + raise UnencryptedRepo() + else: + raise UnknownKeyType(key_type) + + def load_keyblob(self): + if self.keyblob_storage == KEYBLOB_LOCAL: + k = KeyfileKey(self.repository) + target = k.find_key() + with open(target, 'r') as fd: + self.keyblob = ''.join(fd.readlines()[1:]) + + elif self.keyblob_storage == KEYBLOB_REPO: + self.keyblob = self.repository.load_key().decode() + + def store_keyblob(self, args): + if self.keyblob_storage == KEYBLOB_LOCAL: + k = KeyfileKey(self.repository) + try: + target = k.find_key() + except KeyfileNotFoundError: + target = k.get_new_target(args) + + self.store_keyfile(target) + elif self.keyblob_storage == KEYBLOB_REPO: + self.repository.save_key(self.keyblob.encode('utf-8')) + + def store_keyfile(self, target): + with open(target, 'w') as fd: + fd.write('%s %s\n' % (KeyfileKey.FILE_ID, hexlify(self.repository.id).decode('ascii'))) + fd.write(self.keyblob) + if not self.keyblob.endswith('\n'): + fd.write('\n') + + def export(self, path): + self.store_keyfile(path) + + def export_paperkey(self, path): + def grouped(s): + ret = '' + i = 0 + for ch in s: + if i and i % 6 == 0: + ret += ' ' + ret += ch + i += 1 + return ret + + export = 'To restore key use borg key-import --paper /path/to/repo\n\n' + + binary = a2b_base64(self.keyblob) + export += 'BORG PAPER KEY v1\n' + lines = (len(binary) + 17) // 18 + repoid = hexlify(self.repository.id).decode('ascii')[:18] + complete_checksum = sha256_truncated(binary, 12) + export += 'id: {0:d} / {1} / {2} - {3}\n'.format(lines, + grouped(repoid), + grouped(complete_checksum), + sha256_truncated((str(lines) + '/' + repoid + '/' + complete_checksum).encode('ascii'), 2)) + idx = 0 + while len(binary): + idx += 1 + binline = binary[:18] + checksum = sha256_truncated(idx.to_bytes(2, byteorder='big') + binline, 2) + export += '{0:2d}: {1} - {2}\n'.format(idx, grouped(hexlify(binline).decode('ascii')), checksum) + binary = binary[18:] + + if path: + with open(path, 'w') as fd: + fd.write(export) + else: + print(export) + + def import_keyfile(self, args): + file_id = KeyfileKey.FILE_ID + first_line = file_id + ' ' + hexlify(self.repository.id).decode('ascii') + '\n' + with open(args.path, 'r') as fd: + file_first_line = fd.read(len(first_line)) + if file_first_line != first_line: + if not file_first_line.startswith(file_id): + raise NotABorgKeyFile() + else: + raise RepoIdMismatch() + self.keyblob = fd.read() + + self.store_keyblob(args) + + def import_paperkey(self, args): + # imported here because it has global side effects + import readline + + repoid = hexlify(self.repository.id).decode('ascii')[:18] + try: + while True: # used for repeating on overall checksum mismatch + # id line input + while True: + idline = input('id: ').replace(' ', '') + if idline == "": + if yes("Abort import? [yN]:"): + raise EOFError() + + try: + (data, checksum) = idline.split('-') + except ValueError: + print("each line must contain exactly one '-', try again") + continue + try: + (id_lines, id_repoid, id_complete_checksum) = data.split('/') + except ValueError: + print("the id line must contain exactly three '/', try again") + if sha256_truncated(data.lower().encode('ascii'), 2) != checksum: + print('line checksum did not match, try same line again') + continue + try: + lines = int(id_lines) + except ValueError: + print('internal error while parsing length') + + break + + if repoid != id_repoid: + raise RepoIdMismatch() + + result = b'' + idx = 1 + # body line input + while True: + inline = input('{0:2d}: '.format(idx)) + inline = inline.replace(' ', '') + if inline == "": + if yes("Abort import? [yN]:"): + raise EOFError() + try: + (data, checksum) = inline.split('-') + except ValueError: + print("each line must contain exactly one '-', try again") + continue + try: + part = unhexlify(data) + except binascii.Error: + print("only characters 0-9 and a-f and '-' are valid, try again") + continue + if sha256_truncated(idx.to_bytes(2, byteorder='big') + part, 2) != checksum: + print('line checksum did not match, try line {0} again'.format(idx)) + continue + result += part + if idx == lines: + break + idx += 1 + + if sha256_truncated(result, 12) != id_complete_checksum: + print('The overall checksum did not match, retry or enter a blank line to abort.') + continue + + self.keyblob = '\n'.join(textwrap.wrap(b2a_base64(result).decode('ascii'))) + '\n' + self.store_keyblob(args) + break + + except EOFError: + print('\n - aborted') + return diff --git a/borg/testsuite/archiver.py b/borg/testsuite/archiver.py index 7b2193595..d5896c038 100644 --- a/borg/testsuite/archiver.py +++ b/borg/testsuite/archiver.py @@ -1,4 +1,4 @@ -from binascii import hexlify +from binascii import hexlify, unhexlify, b2a_base64 from configparser import ConfigParser import errno import os @@ -22,6 +22,8 @@ from ..archiver import Archiver from ..cache import Cache from ..crypto import bytes_to_long, num_aes_blocks from ..helpers import Manifest, PatternMatcher, parse_pattern, EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR +from ..key import RepoKey, KeyfileKey, Passphrase +from ..keymanager import RepoIdMismatch, NotABorgKeyFile from ..remote import RemoteRepository, PathNotAllowed from ..repository import Repository from . import BaseTestCase, changedir, environment_variable @@ -1194,6 +1196,106 @@ class ArchiverTestCase(ArchiverTestCaseBase): output = self.cmd('debug-delete-obj', self.repository_location, 'invalid') assert "is invalid" in output + def test_key_export_keyfile(self): + export_file = self.output_path + '/exported' + self.cmd('init', self.repository_location, '--encryption', 'keyfile') + repo_id = self._extract_repository_id(self.repository_path) + self.cmd('key-export', self.repository_location, export_file) + + with open(export_file, 'r') as fd: + export_contents = fd.read() + + assert export_contents.startswith('BORG_KEY ' + hexlify(repo_id).decode() + '\n') + + key_file = self.keys_path + '/' + os.listdir(self.keys_path)[0] + + with open(key_file, 'r') as fd: + key_contents = fd.read() + + assert key_contents == export_contents + + os.unlink(key_file) + + self.cmd('key-import', self.repository_location, export_file) + + with open(key_file, 'r') as fd: + key_contents2 = fd.read() + + assert key_contents2 == key_contents + + def test_key_export_repokey(self): + export_file = self.output_path + '/exported' + self.cmd('init', self.repository_location, '--encryption', 'repokey') + repo_id = self._extract_repository_id(self.repository_path) + self.cmd('key-export', self.repository_location, export_file) + + with open(export_file, 'r') as fd: + export_contents = fd.read() + + assert export_contents.startswith('BORG_KEY ' + hexlify(repo_id).decode() + '\n') + + with Repository(self.repository_path) as repository: + repo_key = RepoKey(repository) + repo_key.load(None, Passphrase.env_passphrase()) + + backup_key = KeyfileKey(None) + backup_key.load(export_file, Passphrase.env_passphrase()) + + assert repo_key.enc_key == backup_key.enc_key + + with Repository(self.repository_path) as repository: + repository.save_key(b'') + + self.cmd('key-import', self.repository_location, export_file) + + with Repository(self.repository_path) as repository: + repo_key2 = RepoKey(repository) + repo_key2.load(None, Passphrase.env_passphrase()) + + assert repo_key2.enc_key == repo_key2.enc_key + + def test_key_import_errors(self): + export_file = self.output_path + '/exported' + self.cmd('init', self.repository_location, '--encryption', 'keyfile') + + self.cmd('key-import', self.repository_location, export_file, exit_code=EXIT_ERROR) + + with open(export_file, 'w') as fd: + fd.write('something not a key\n') + + self.assert_raises(NotABorgKeyFile, lambda: self.cmd('key-import', self.repository_location, export_file)) + + with open(export_file, 'w') as fd: + fd.write('BORG_KEY a0a0a0\n') + + self.assert_raises(RepoIdMismatch, lambda: self.cmd('key-import', self.repository_location, export_file)) + + def test_key_export_paperkey(self): + repo_id = 'e294423506da4e1ea76e8dcdf1a3919624ae3ae496fddf905610c351d3f09239' + + export_file = self.output_path + '/exported' + self.cmd('init', self.repository_location, '--encryption', 'keyfile') + self._set_repository_id(self.repository_path, unhexlify(repo_id)) + + key_file = self.keys_path + '/' + os.listdir(self.keys_path)[0] + + with open(key_file, 'w') as fd: + fd.write(KeyfileKey.FILE_ID + ' ' + repo_id + '\n') + fd.write(b2a_base64(b'abcdefghijklmnopqrstu').decode()) + + self.cmd('key-export', '--paper', self.repository_location, export_file) + + with open(export_file, 'r') as fd: + export_contents = fd.read() + + assert export_contents == """To restore key use borg key-import --paper /path/to/repo + +BORG PAPER KEY v1 +id: 2 / e29442 3506da 4e1ea7 / 25f62a 5a3d41 - 02 + 1: 616263 646566 676869 6a6b6c 6d6e6f 707172 - 6d + 2: 737475 - 88 +""" + @unittest.skipUnless('binary' in BORG_EXES, 'no borg.exe available') class ArchiverTestCaseBinary(ArchiverTestCase):