mirror of
https://github.com/borgbackup/borg.git
synced 2024-12-24 08:45:13 +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:
parent
b3fac01683
commit
55a3331942
3 changed files with 378 additions and 1 deletions
|
@ -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.
|
||||
|
|
213
borg/keymanager.py
Normal file
213
borg/keymanager.py
Normal 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
|
|
@ -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):
|
||||
|
|
Loading…
Reference in a new issue