key.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. from binascii import hexlify, a2b_base64, b2a_base64
  2. import configparser
  3. import getpass
  4. import os
  5. import msgpack
  6. import textwrap
  7. import hmac
  8. from hashlib import sha256
  9. import zlib
  10. from .crypto import pbkdf2_sha256, get_random_bytes, AES, bytes_to_long, long_to_bytes, bytes_to_int, num_aes_blocks
  11. from .helpers import IntegrityError, get_keys_dir, Error
  12. PREFIX = b'\0' * 8
  13. class UnsupportedPayloadError(Error):
  14. """Unsupported payload type {}. A newer version is required to access this repository.
  15. """
  16. class KeyfileNotFoundError(Error):
  17. """No key file for repository {} found in {}.
  18. """
  19. class RepoKeyNotFoundError(Error):
  20. """No key entry found in the config of repository {}.
  21. """
  22. class HMAC(hmac.HMAC):
  23. """Workaround a bug in Python < 3.4 Where HMAC does not accept memoryviews
  24. """
  25. def update(self, msg):
  26. self.inner.update(msg)
  27. def key_creator(repository, args):
  28. if args.encryption == 'keyfile':
  29. return KeyfileKey.create(repository, args)
  30. elif args.encryption == 'repokey':
  31. return RepoKey.create(repository, args)
  32. elif args.encryption == 'passphrase': # deprecated, kill in 1.x
  33. return PassphraseKey.create(repository, args)
  34. else:
  35. return PlaintextKey.create(repository, args)
  36. def key_factory(repository, manifest_data):
  37. key_type = manifest_data[0]
  38. if key_type == KeyfileKey.TYPE:
  39. return KeyfileKey.detect(repository, manifest_data)
  40. elif key_type == RepoKey.TYPE:
  41. return RepoKey.detect(repository, manifest_data)
  42. elif key_type == PassphraseKey.TYPE: # deprecated, kill in 1.x
  43. return PassphraseKey.detect(repository, manifest_data)
  44. elif key_type == PlaintextKey.TYPE:
  45. return PlaintextKey.detect(repository, manifest_data)
  46. else:
  47. raise UnsupportedPayloadError(key_type)
  48. class KeyBase:
  49. TYPE = None # override in subclasses
  50. def __init__(self, repository):
  51. self.TYPE_STR = bytes([self.TYPE])
  52. self.repository = repository
  53. self.target = None # key location file path / repo obj
  54. self.compression_level = 0
  55. def id_hash(self, data):
  56. """Return HMAC hash using the "id" HMAC key
  57. """
  58. def encrypt(self, data):
  59. pass
  60. def decrypt(self, id, data):
  61. pass
  62. class PlaintextKey(KeyBase):
  63. TYPE = 0x02
  64. chunk_seed = 0
  65. @classmethod
  66. def create(cls, repository, args):
  67. print('Encryption NOT enabled.\nUse the "--encryption=repokey|keyfile|passphrase" to enable encryption.')
  68. return cls(repository)
  69. @classmethod
  70. def detect(cls, repository, manifest_data):
  71. return cls(repository)
  72. def id_hash(self, data):
  73. return sha256(data).digest()
  74. def encrypt(self, data):
  75. return b''.join([self.TYPE_STR, zlib.compress(data, self.compression_level)])
  76. def decrypt(self, id, data):
  77. if data[0] != self.TYPE:
  78. raise IntegrityError('Invalid encryption envelope')
  79. data = zlib.decompress(memoryview(data)[1:])
  80. if id and sha256(data).digest() != id:
  81. raise IntegrityError('Chunk id verification failed')
  82. return data
  83. class AESKeyBase(KeyBase):
  84. """Common base class shared by KeyfileKey and PassphraseKey
  85. Chunks are encrypted using 256bit AES in Counter Mode (CTR)
  86. Payload layout: TYPE(1) + HMAC(32) + NONCE(8) + CIPHERTEXT
  87. To reduce payload size only 8 bytes of the 16 bytes nonce is saved
  88. in the payload, the first 8 bytes are always zeros. This does not
  89. affect security but limits the maximum repository capacity to
  90. only 295 exabytes!
  91. """
  92. PAYLOAD_OVERHEAD = 1 + 32 + 8 # TYPE + HMAC + NONCE
  93. def id_hash(self, data):
  94. """Return HMAC hash using the "id" HMAC key
  95. """
  96. return HMAC(self.id_key, data, sha256).digest()
  97. def encrypt(self, data):
  98. data = zlib.compress(data, self.compression_level)
  99. self.enc_cipher.reset()
  100. data = b''.join((self.enc_cipher.iv[8:], self.enc_cipher.encrypt(data)))
  101. hmac = HMAC(self.enc_hmac_key, data, sha256).digest()
  102. return b''.join((self.TYPE_STR, hmac, data))
  103. def decrypt(self, id, data):
  104. if data[0] != self.TYPE:
  105. raise IntegrityError('Invalid encryption envelope')
  106. hmac = memoryview(data)[1:33]
  107. if memoryview(HMAC(self.enc_hmac_key, memoryview(data)[33:], sha256).digest()) != hmac:
  108. raise IntegrityError('Encryption envelope checksum mismatch')
  109. self.dec_cipher.reset(iv=PREFIX + data[33:41])
  110. data = zlib.decompress(self.dec_cipher.decrypt(data[41:])) # should use memoryview
  111. if id and HMAC(self.id_key, data, sha256).digest() != id:
  112. raise IntegrityError('Chunk id verification failed')
  113. return data
  114. def extract_nonce(self, payload):
  115. if payload[0] != self.TYPE:
  116. raise IntegrityError('Invalid encryption envelope')
  117. nonce = bytes_to_long(payload[33:41])
  118. return nonce
  119. def init_from_random_data(self, data):
  120. self.enc_key = data[0:32]
  121. self.enc_hmac_key = data[32:64]
  122. self.id_key = data[64:96]
  123. self.chunk_seed = bytes_to_int(data[96:100])
  124. # Convert to signed int32
  125. if self.chunk_seed & 0x80000000:
  126. self.chunk_seed = self.chunk_seed - 0xffffffff - 1
  127. def init_ciphers(self, enc_iv=b''):
  128. self.enc_cipher = AES(is_encrypt=True, key=self.enc_key, iv=enc_iv)
  129. self.dec_cipher = AES(is_encrypt=False, key=self.enc_key)
  130. class Passphrase(str):
  131. @classmethod
  132. def env_passphrase(cls, default=None):
  133. passphrase = os.environ.get('BORG_PASSPHRASE', default)
  134. if passphrase is not None:
  135. return cls(passphrase)
  136. @classmethod
  137. def getpass(cls, prompt):
  138. return cls(getpass.getpass(prompt))
  139. @classmethod
  140. def new(cls, allow_empty=False):
  141. passphrase = cls.env_passphrase()
  142. if passphrase is not None:
  143. return passphrase
  144. while True:
  145. passphrase = cls.getpass('Enter new passphrase: ')
  146. if allow_empty or passphrase:
  147. passphrase2 = cls.getpass('Enter same passphrase again: ')
  148. if passphrase == passphrase2:
  149. print('Remember your passphrase. Your data will be inaccessible without it.')
  150. return passphrase
  151. else:
  152. print('Passphrases do not match')
  153. else:
  154. print('Passphrase must not be blank')
  155. def __repr__(self):
  156. return '<Passphrase "***hidden***">'
  157. def kdf(self, salt, iterations, length):
  158. return pbkdf2_sha256(self.encode('utf-8'), salt, iterations, length)
  159. class PassphraseKey(AESKeyBase):
  160. # This mode is DEPRECATED and will be killed at 1.0 release.
  161. # With this mode:
  162. # - you can never ever change your passphrase for existing repos.
  163. # - you can never ever use a different iterations count for existing repos.
  164. TYPE = 0x01
  165. iterations = 100000 # must not be changed ever!
  166. @classmethod
  167. def create(cls, repository, args):
  168. key = cls(repository)
  169. print('WARNING: "passphrase" mode is deprecated and will be removed in 1.0.')
  170. print('If you want something similar (but with less issues), use "repokey" mode.')
  171. passphrase = Passphrase.new(allow_empty=False)
  172. key.init(repository, passphrase)
  173. return key
  174. @classmethod
  175. def detect(cls, repository, manifest_data):
  176. prompt = 'Enter passphrase for %s: ' % repository._location.orig
  177. key = cls(repository)
  178. passphrase = Passphrase.env_passphrase()
  179. if passphrase is None:
  180. passphrase = Passphrase.getpass(prompt)
  181. while True:
  182. key.init(repository, passphrase)
  183. try:
  184. key.decrypt(None, manifest_data)
  185. num_blocks = num_aes_blocks(len(manifest_data) - 41)
  186. key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
  187. return key
  188. except IntegrityError:
  189. passphrase = Passphrase.getpass(prompt)
  190. def change_passphrase(self):
  191. class ImmutablePassphraseError(Error):
  192. """The passphrase for this encryption key type can't be changed."""
  193. raise ImmutablePassphraseError
  194. def init(self, repository, passphrase):
  195. self.init_from_random_data(passphrase.kdf(repository.id, self.iterations, 100))
  196. self.init_ciphers()
  197. class KeyfileKeyBase(AESKeyBase):
  198. @classmethod
  199. def detect(cls, repository, manifest_data):
  200. key = cls(repository)
  201. target = key.find_key()
  202. prompt = 'Enter passphrase for key %s: ' % target
  203. passphrase = Passphrase.env_passphrase(default='')
  204. while not key.load(target, passphrase):
  205. passphrase = Passphrase.getpass(prompt)
  206. num_blocks = num_aes_blocks(len(manifest_data) - 41)
  207. key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
  208. return key
  209. def find_key(self):
  210. raise NotImplementedError
  211. def load(self, target, passphrase):
  212. raise NotImplementedError
  213. def _load(self, key_data, passphrase):
  214. cdata = a2b_base64(key_data.encode('ascii')) # .encode needed for Python 3.[0-2]
  215. data = self.decrypt_key_file(cdata, passphrase)
  216. if data:
  217. key = msgpack.unpackb(data)
  218. if key[b'version'] != 1:
  219. raise IntegrityError('Invalid key file header')
  220. self.repository_id = key[b'repository_id']
  221. self.enc_key = key[b'enc_key']
  222. self.enc_hmac_key = key[b'enc_hmac_key']
  223. self.id_key = key[b'id_key']
  224. self.chunk_seed = key[b'chunk_seed']
  225. return True
  226. return False
  227. def decrypt_key_file(self, data, passphrase):
  228. d = msgpack.unpackb(data)
  229. assert d[b'version'] == 1
  230. assert d[b'algorithm'] == b'sha256'
  231. key = passphrase.kdf(d[b'salt'], d[b'iterations'], 32)
  232. data = AES(is_encrypt=False, key=key).decrypt(d[b'data'])
  233. if HMAC(key, data, sha256).digest() == d[b'hash']:
  234. return data
  235. def encrypt_key_file(self, data, passphrase):
  236. salt = get_random_bytes(32)
  237. iterations = 100000
  238. key = passphrase.kdf(salt, iterations, 32)
  239. hash = HMAC(key, data, sha256).digest()
  240. cdata = AES(is_encrypt=True, key=key).encrypt(data)
  241. d = {
  242. 'version': 1,
  243. 'salt': salt,
  244. 'iterations': iterations,
  245. 'algorithm': 'sha256',
  246. 'hash': hash,
  247. 'data': cdata,
  248. }
  249. return msgpack.packb(d)
  250. def _save(self, passphrase):
  251. key = {
  252. 'version': 1,
  253. 'repository_id': self.repository_id,
  254. 'enc_key': self.enc_key,
  255. 'enc_hmac_key': self.enc_hmac_key,
  256. 'id_key': self.id_key,
  257. 'chunk_seed': self.chunk_seed,
  258. }
  259. data = self.encrypt_key_file(msgpack.packb(key), passphrase)
  260. key_data = '\n'.join(textwrap.wrap(b2a_base64(data).decode('ascii')))
  261. return key_data
  262. def change_passphrase(self):
  263. passphrase = Passphrase.new(allow_empty=True)
  264. self.save(self.target, passphrase)
  265. print('Key updated')
  266. @classmethod
  267. def create(cls, repository, args):
  268. passphrase = Passphrase.new(allow_empty=True)
  269. key = cls(repository)
  270. key.repository_id = repository.id
  271. key.init_from_random_data(get_random_bytes(100))
  272. key.init_ciphers()
  273. target = key.get_new_target(args)
  274. key.save(target, passphrase)
  275. print('Key in "%s" created.' % target)
  276. print('Keep this key safe. Your data will be inaccessible without it.')
  277. return key
  278. def save(self, target, passphrase):
  279. raise NotImplementedError
  280. def get_new_target(self, args):
  281. raise NotImplementedError
  282. class KeyfileKey(KeyfileKeyBase):
  283. TYPE = 0x00
  284. FILE_ID = 'BORG_KEY'
  285. def find_key(self):
  286. id = hexlify(self.repository.id).decode('ascii')
  287. keys_dir = get_keys_dir()
  288. for name in os.listdir(keys_dir):
  289. filename = os.path.join(keys_dir, name)
  290. with open(filename, 'r') as fd:
  291. line = fd.readline().strip()
  292. if line.startswith(self.FILE_ID) and line[len(self.FILE_ID)+1:] == id:
  293. return filename
  294. raise KeyfileNotFoundError(self.repository._location.canonical_path(), get_keys_dir())
  295. def get_new_target(self, args):
  296. filename = args.repository.to_key_filename()
  297. path = filename
  298. i = 1
  299. while os.path.exists(path):
  300. i += 1
  301. path = filename + '.%d' % i
  302. return path
  303. def load(self, target, passphrase):
  304. with open(target, 'r') as fd:
  305. key_data = ''.join(fd.readlines()[1:])
  306. success = self._load(key_data, passphrase)
  307. if success:
  308. self.target = target
  309. return success
  310. def save(self, target, passphrase):
  311. key_data = self._save(passphrase)
  312. with open(target, 'w') as fd:
  313. fd.write('%s %s\n' % (self.FILE_ID, hexlify(self.repository_id).decode('ascii')))
  314. fd.write(key_data)
  315. fd.write('\n')
  316. self.target = target
  317. class RepoKey(KeyfileKeyBase):
  318. TYPE = 0x03
  319. def find_key(self):
  320. loc = self.repository._location.canonical_path()
  321. try:
  322. self.repository.load_key()
  323. return loc
  324. except configparser.NoOptionError:
  325. raise RepoKeyNotFoundError(loc)
  326. def get_new_target(self, args):
  327. return self.repository
  328. def load(self, target, passphrase):
  329. # what we get in target is just a repo location, but we already have the repo obj:
  330. target = self.repository
  331. key_data = target.load_key()
  332. key_data = key_data.decode('utf-8') # remote repo: msgpack issue #99, getting bytes
  333. success = self._load(key_data, passphrase)
  334. if success:
  335. self.target = target
  336. return success
  337. def save(self, target, passphrase):
  338. key_data = self._save(passphrase)
  339. key_data = key_data.encode('utf-8') # remote repo: msgpack issue #99, giving bytes
  340. target.save_key(key_data)
  341. self.target = target