Browse Source

Merge pull request #1616 from textshell/issue/1555

Implement key import / export
TW 8 years ago
parent
commit
b055bb025f
3 changed files with 378 additions and 1 deletions
  1. 62 0
      borg/archiver.py
  2. 213 0
      borg/keymanager.py
  3. 103 1
      borg/testsuite/archiver.py

+ 62 - 0
borg/archiver.py

@@ -30,6 +30,7 @@ from .upgrader import AtticRepositoryUpgrader, BorgRepositoryUpgrader
 from .repository import Repository
 from .repository import Repository
 from .cache import Cache
 from .cache import Cache
 from .key import key_creator, RepoKey, PassphraseKey
 from .key import key_creator, RepoKey, PassphraseKey
+from .keymanager import KeyManager
 from .archive import backup_io, BackupOSError, Archive, ArchiveChecker, CHUNKER_PARAMS, is_special
 from .archive import backup_io, BackupOSError, Archive, ArchiveChecker, CHUNKER_PARAMS, is_special
 from .remote import RepositoryServer, RemoteRepository, cache_if_remote
 from .remote import RepositoryServer, RemoteRepository, cache_if_remote
 
 
@@ -159,6 +160,39 @@ class Archiver:
         key.change_passphrase()
         key.change_passphrase()
         return EXIT_SUCCESS
         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)
     @with_repository(manifest=False)
     def do_migrate_to_repokey(self, args, repository):
     def do_migrate_to_repokey(self, args, repository):
         """Migrate passphrase -> repokey"""
         """Migrate passphrase -> repokey"""
@@ -1076,6 +1110,34 @@ class Archiver:
         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))
 
 
+        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("""
         migrate_to_repokey_epilog = textwrap.dedent("""
         This command migrates a repository from passphrase mode (not supported any
         This command migrates a repository from passphrase mode (not supported any
         more) to repokey mode.
         more) to repokey mode.

+ 213 - 0
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

+ 103 - 1
borg/testsuite/archiver.py

@@ -1,4 +1,4 @@
-from binascii import hexlify
+from binascii import hexlify, unhexlify, b2a_base64
 from configparser import ConfigParser
 from configparser import ConfigParser
 import errno
 import errno
 import os
 import os
@@ -22,6 +22,8 @@ from ..archiver import Archiver
 from ..cache import Cache
 from ..cache import Cache
 from ..crypto import bytes_to_long, num_aes_blocks
 from ..crypto import bytes_to_long, num_aes_blocks
 from ..helpers import Manifest, PatternMatcher, parse_pattern, EXIT_SUCCESS, EXIT_WARNING, EXIT_ERROR
 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 ..remote import RemoteRepository, PathNotAllowed
 from ..repository import Repository
 from ..repository import Repository
 from . import BaseTestCase, changedir, environment_variable
 from . import BaseTestCase, changedir, environment_variable
@@ -1194,6 +1196,106 @@ class ArchiverTestCase(ArchiverTestCaseBase):
         output = self.cmd('debug-delete-obj', self.repository_location, 'invalid')
         output = self.cmd('debug-delete-obj', self.repository_location, 'invalid')
         assert "is invalid" in output
         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')
 @unittest.skipUnless('binary' in BORG_EXES, 'no borg.exe available')
 class ArchiverTestCaseBinary(ArchiverTestCase):
 class ArchiverTestCaseBinary(ArchiverTestCase):