1import binascii
2import pkgutil
3import textwrap
4from binascii import unhexlify, a2b_base64, b2a_base64
5from hashlib import sha256
6
7from ..helpers import Manifest, NoManifestError, Error, yes, bin_to_hex, dash_open
8from ..repository import Repository
9
10from .key import KeyfileKey, KeyfileNotFoundError, KeyBlobStorage, identify_key
11
12
13class UnencryptedRepo(Error):
14    """Keymanagement not available for unencrypted repositories."""
15
16
17class UnknownKeyType(Error):
18    """Keytype {0} is unknown."""
19
20
21class RepoIdMismatch(Error):
22    """This key backup seems to be for a different backup repository, aborting."""
23
24
25class NotABorgKeyFile(Error):
26    """This file is not a borg key backup, aborting."""
27
28
29def sha256_truncated(data, num):
30    h = sha256()
31    h.update(data)
32    return h.hexdigest()[:num]
33
34
35class KeyManager:
36    def __init__(self, repository):
37        self.repository = repository
38        self.keyblob = None
39        self.keyblob_storage = None
40
41        try:
42            manifest_data = self.repository.get(Manifest.MANIFEST_ID)
43        except Repository.ObjectNotFound:
44            raise NoManifestError
45
46        key = identify_key(manifest_data)
47        self.keyblob_storage = key.STORAGE
48        if self.keyblob_storage == KeyBlobStorage.NO_STORAGE:
49            raise UnencryptedRepo()
50
51    def load_keyblob(self):
52        if self.keyblob_storage == KeyBlobStorage.KEYFILE:
53            k = KeyfileKey(self.repository)
54            target = k.find_key()
55            with open(target, 'r') as fd:
56                self.keyblob = ''.join(fd.readlines()[1:])
57
58        elif self.keyblob_storage == KeyBlobStorage.REPO:
59            self.keyblob = self.repository.load_key().decode()
60
61    def store_keyblob(self, args):
62        if self.keyblob_storage == KeyBlobStorage.KEYFILE:
63            k = KeyfileKey(self.repository)
64            try:
65                target = k.find_key()
66            except KeyfileNotFoundError:
67                target = k.get_new_target(args)
68
69            self.store_keyfile(target)
70        elif self.keyblob_storage == KeyBlobStorage.REPO:
71            self.repository.save_key(self.keyblob.encode('utf-8'))
72
73    def get_keyfile_data(self):
74        data = '%s %s\n' % (KeyfileKey.FILE_ID, bin_to_hex(self.repository.id))
75        data += self.keyblob
76        if not self.keyblob.endswith('\n'):
77            data += '\n'
78        return data
79
80    def store_keyfile(self, target):
81        with open(target, 'w') as fd:
82            fd.write(self.get_keyfile_data())
83
84    def export(self, path):
85        self.store_keyfile(path)
86
87    def export_qr(self, path):
88        with open(path, 'wb') as fd:
89            key_data = self.get_keyfile_data()
90            html = pkgutil.get_data('borg', 'paperkey.html')
91            html = html.replace(b'</textarea>', key_data.encode() + b'</textarea>')
92            fd.write(html)
93
94    def export_paperkey(self, path):
95        def grouped(s):
96            ret = ''
97            i = 0
98            for ch in s:
99                if i and i % 6 == 0:
100                    ret += ' '
101                ret += ch
102                i += 1
103            return ret
104
105        export = 'To restore key use borg key import --paper /path/to/repo\n\n'
106
107        binary = a2b_base64(self.keyblob)
108        export += 'BORG PAPER KEY v1\n'
109        lines = (len(binary) + 17) // 18
110        repoid = bin_to_hex(self.repository.id)[:18]
111        complete_checksum = sha256_truncated(binary, 12)
112        export += 'id: {0:d} / {1} / {2} - {3}\n'.format(lines,
113                                       grouped(repoid),
114                                       grouped(complete_checksum),
115                                       sha256_truncated((str(lines) + '/' + repoid + '/' + complete_checksum).encode('ascii'), 2))
116        idx = 0
117        while len(binary):
118            idx += 1
119            binline = binary[:18]
120            checksum = sha256_truncated(idx.to_bytes(2, byteorder='big') + binline, 2)
121            export += '{0:2d}: {1} - {2}\n'.format(idx, grouped(bin_to_hex(binline)), checksum)
122            binary = binary[18:]
123
124        if path:
125            with open(path, 'w') as fd:
126                fd.write(export)
127        else:
128            print(export)
129
130    def import_keyfile(self, args):
131        file_id = KeyfileKey.FILE_ID
132        first_line = file_id + ' ' + bin_to_hex(self.repository.id) + '\n'
133        with dash_open(args.path, 'r') as fd:
134            file_first_line = fd.read(len(first_line))
135            if file_first_line != first_line:
136                if not file_first_line.startswith(file_id):
137                    raise NotABorgKeyFile()
138                else:
139                    raise RepoIdMismatch()
140            self.keyblob = fd.read()
141
142        self.store_keyblob(args)
143
144    def import_paperkey(self, args):
145        try:
146            # imported here because it has global side effects
147            import readline
148        except ImportError:
149            print('Note: No line editing available due to missing readline support')
150
151        repoid = bin_to_hex(self.repository.id)[:18]
152        try:
153            while True:  # used for repeating on overall checksum mismatch
154                # id line input
155                while True:
156                    idline = input('id: ').replace(' ', '')
157                    if idline == '':
158                        if yes('Abort import? [yN]:'):
159                            raise EOFError()
160
161                    try:
162                        (data, checksum) = idline.split('-')
163                    except ValueError:
164                        print("each line must contain exactly one '-', try again")
165                        continue
166                    try:
167                        (id_lines, id_repoid, id_complete_checksum) = data.split('/')
168                    except ValueError:
169                        print("the id line must contain exactly three '/', try again")
170                        continue
171                    if sha256_truncated(data.lower().encode('ascii'), 2) != checksum:
172                        print('line checksum did not match, try same line again')
173                        continue
174                    try:
175                        lines = int(id_lines)
176                    except ValueError:
177                        print('internal error while parsing length')
178
179                    break
180
181                if repoid != id_repoid:
182                    raise RepoIdMismatch()
183
184                result = b''
185                idx = 1
186                # body line input
187                while True:
188                    inline = input('{0:2d}: '.format(idx))
189                    inline = inline.replace(' ', '')
190                    if inline == '':
191                        if yes('Abort import? [yN]:'):
192                            raise EOFError()
193                    try:
194                        (data, checksum) = inline.split('-')
195                    except ValueError:
196                        print("each line must contain exactly one '-', try again")
197                        continue
198                    try:
199                        part = unhexlify(data)
200                    except binascii.Error:
201                        print("only characters 0-9 and a-f and '-' are valid, try again")
202                        continue
203                    if sha256_truncated(idx.to_bytes(2, byteorder='big') + part, 2) != checksum:
204                        print('line checksum did not match, try line {0} again'.format(idx))
205                        continue
206                    result += part
207                    if idx == lines:
208                        break
209                    idx += 1
210
211                if sha256_truncated(result, 12) != id_complete_checksum:
212                    print('The overall checksum did not match, retry or enter a blank line to abort.')
213                    continue
214
215                self.keyblob = '\n'.join(textwrap.wrap(b2a_base64(result).decode('ascii'))) + '\n'
216                self.store_keyblob(args)
217                break
218
219        except EOFError:
220            print('\n - aborted')
221            return
222