Repository: borgbackup/borg

Issue:
key import/export command
In the docs, we of course recommend having a **separate** backup of the encryption key.

For keyfile mode, the key is easily accessible via the local filesystem (if you know / read about where to look for it) - just copy it somewhere safe.

For repokey mode, the (passphrase protected) key is in the repo config. If your repo is local, just copy the config to somewhere safe. But if your repo is remote and your only access to it is via a restricted "borg serve" ssh command (no ssh shell, scp or sshfs access), you can't easily get a copy of your key.

---

:moneybag: [there is a bounty for this](https://www.bountysource.com/issues/37428131-key-import-export-command)



Discussion:
Maybe this could be implemented in a rather generic way, like "borg key-export repo targetfile".

That would locate and read the corresponding key and export it to targetfile, no matter whether the repo uses a keyfile or repokey. The targetfile could have same format as a normal borg keyfile or as the config's key section.

In case something really gets lost, we also need "borg key-import sourcefile".


Resolving patch:
diff --git a/borg/archiver.py b/borg/archiver.py
--- a/borg/archiver.py
+++ b/borg/archiver.py
@@ -30,6 +30,7 @@
 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 @@ def do_change_passphrase(self, args, repository, manifest, key):
         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 @@ def build_parser(self, args=None, prog=None):
         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
--- /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
--- 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 ..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 @@ def test_debug_put_get_delete_obj(self):
         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):


Test patch: