1
0
Fork 0
mirror of https://github.com/borgbackup/borg.git synced 2024-12-25 01:06:50 +00:00

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
This commit is contained in:
Martin Hostettler 2016-09-18 17:20:27 +02:00
parent b3fac01683
commit 55a3331942
3 changed files with 378 additions and 1 deletions

View file

@ -30,6 +30,7 @@
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 @@ def do_change_passphrase(self, args, repository, manifest, key):
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 @@ def build_parser(self, args=None, prog=None):
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
borg/keymanager.py Normal file
View file

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

View file

@ -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 ..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 @@ def test_debug_put_get_delete_obj(self):
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):