key.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. from binascii import hexlify, a2b_base64, b2a_base64
  2. from getpass import getpass
  3. import os
  4. import msgpack
  5. import textwrap
  6. import hmac
  7. from hashlib import sha256
  8. import zlib
  9. try:
  10. import lzma # python >= 3.3
  11. except ImportError:
  12. try:
  13. from backports import lzma # backports.lzma from pypi
  14. except ImportError:
  15. lzma = None
  16. from attic.crypto import pbkdf2_sha256, get_random_bytes, AES, bytes_to_long, long_to_bytes, bytes_to_int, num_aes_blocks
  17. from attic.helpers import IntegrityError, get_keys_dir, Error
  18. PREFIX = b'\0' * 8
  19. class UnsupportedPayloadError(Error):
  20. """Unsupported payload type {}. A newer version is required to access this 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 == 'passphrase':
  31. return PassphraseKey.create(repository, args)
  32. else:
  33. return PlaintextKey.create(repository, args)
  34. def key_factory(repository, manifest_data):
  35. if manifest_data[0] == KeyfileKey.TYPE:
  36. return KeyfileKey.detect(repository, manifest_data)
  37. elif manifest_data[0] == PassphraseKey.TYPE:
  38. return PassphraseKey.detect(repository, manifest_data)
  39. elif manifest_data[0] == PlaintextKey.TYPE:
  40. return PlaintextKey.detect(repository, manifest_data)
  41. else:
  42. raise UnsupportedPayloadError(manifest_data[0])
  43. class CompressorBase(object):
  44. def compress(self, data):
  45. pass
  46. def decompress(self, data):
  47. pass
  48. class ZlibCompressor(CompressorBase):
  49. def compress(self, data):
  50. return zlib.compress(data)
  51. def decompress(self, data):
  52. return zlib.decompress(data)
  53. class LzmaCompressor(CompressorBase):
  54. def __init__(self):
  55. if lzma is None:
  56. raise NotImplemented("lzma compression needs Python >= 3.3 or backports.lzma from PyPi")
  57. def compress(self, data):
  58. return lzma.compress(data)
  59. def decompress(self, data):
  60. return lzma.decompress(data)
  61. class KeyBase(object):
  62. def __init__(self):
  63. self.TYPE_STR = bytes([self.TYPE])
  64. self.compressor = ZlibCompressor()
  65. def id_hash(self, data):
  66. """Return HMAC hash using the "id" HMAC key
  67. """
  68. def encrypt(self, data):
  69. pass
  70. def decrypt(self, id, data):
  71. pass
  72. class PlaintextKey(KeyBase):
  73. TYPE = 0x02
  74. chunk_seed = 0
  75. @classmethod
  76. def create(cls, repository, args):
  77. print('Encryption NOT enabled.\nUse the "--encryption=passphrase|keyfile" to enable encryption.')
  78. return cls()
  79. @classmethod
  80. def detect(cls, repository, manifest_data):
  81. return cls()
  82. def id_hash(self, data):
  83. return sha256(data).digest()
  84. def encrypt(self, data):
  85. return b''.join([self.TYPE_STR, self.compressor.compress(data)])
  86. def decrypt(self, id, data):
  87. if data[0] != self.TYPE:
  88. raise IntegrityError('Invalid encryption envelope')
  89. data = self.compressor.decompress(memoryview(data)[1:])
  90. if id and sha256(data).digest() != id:
  91. raise IntegrityError('Chunk id verification failed')
  92. return data
  93. class AESKeyBase(KeyBase):
  94. """Common base class shared by KeyfileKey and PassphraseKey
  95. Chunks are encrypted using 256bit AES in Counter Mode (CTR)
  96. Payload layout: TYPE(1) + HMAC(32) + NONCE(8) + CIPHERTEXT
  97. To reduce payload size only 8 bytes of the 16 bytes nonce is saved
  98. in the payload, the first 8 bytes are always zeros. This does not
  99. affect security but limits the maximum repository capacity to
  100. only 295 exabytes!
  101. """
  102. PAYLOAD_OVERHEAD = 1 + 32 + 8 # TYPE + HMAC + NONCE
  103. def id_hash(self, data):
  104. """Return HMAC hash using the "id" HMAC key
  105. """
  106. return HMAC(self.id_key, data, sha256).digest()
  107. def encrypt(self, data):
  108. data = self.compressor.compress(data)
  109. self.enc_cipher.reset()
  110. data = b''.join((self.enc_cipher.iv[8:], self.enc_cipher.encrypt(data)))
  111. hmac = HMAC(self.enc_hmac_key, data, sha256).digest()
  112. return b''.join((self.TYPE_STR, hmac, data))
  113. def decrypt(self, id, data):
  114. if data[0] != self.TYPE:
  115. raise IntegrityError('Invalid encryption envelope')
  116. hmac = memoryview(data)[1:33]
  117. if memoryview(HMAC(self.enc_hmac_key, memoryview(data)[33:], sha256).digest()) != hmac:
  118. raise IntegrityError('Encryption envelope checksum mismatch')
  119. self.dec_cipher.reset(iv=PREFIX + data[33:41])
  120. data = self.compressor.decompress(self.dec_cipher.decrypt(data[41:])) # should use memoryview
  121. if id and HMAC(self.id_key, data, sha256).digest() != id:
  122. raise IntegrityError('Chunk id verification failed')
  123. return data
  124. def extract_nonce(self, payload):
  125. if payload[0] != self.TYPE:
  126. raise IntegrityError('Invalid encryption envelope')
  127. nonce = bytes_to_long(payload[33:41])
  128. return nonce
  129. def init_from_random_data(self, data):
  130. self.enc_key = data[0:32]
  131. self.enc_hmac_key = data[32:64]
  132. self.id_key = data[64:96]
  133. self.chunk_seed = bytes_to_int(data[96:100])
  134. # Convert to signed int32
  135. if self.chunk_seed & 0x80000000:
  136. self.chunk_seed = self.chunk_seed - 0xffffffff - 1
  137. def init_ciphers(self, enc_iv=b''):
  138. self.enc_cipher = AES(self.enc_key, enc_iv)
  139. self.dec_cipher = AES(self.enc_key)
  140. class PassphraseKey(AESKeyBase):
  141. TYPE = 0x01
  142. iterations = 100000
  143. @classmethod
  144. def create(cls, repository, args):
  145. key = cls()
  146. passphrase = os.environ.get('ATTIC_PASSPHRASE')
  147. if passphrase is not None:
  148. passphrase2 = passphrase
  149. else:
  150. passphrase, passphrase2 = 1, 2
  151. while passphrase != passphrase2:
  152. passphrase = getpass('Enter passphrase: ')
  153. if not passphrase:
  154. print('Passphrase must not be blank')
  155. continue
  156. passphrase2 = getpass('Enter same passphrase again: ')
  157. if passphrase != passphrase2:
  158. print('Passphrases do not match')
  159. key.init(repository, passphrase)
  160. if passphrase:
  161. print('Remember your passphrase. Your data will be inaccessible without it.')
  162. return key
  163. @classmethod
  164. def detect(cls, repository, manifest_data):
  165. prompt = 'Enter passphrase for %s: ' % repository._location.orig
  166. key = cls()
  167. passphrase = os.environ.get('ATTIC_PASSPHRASE')
  168. if passphrase is None:
  169. passphrase = getpass(prompt)
  170. while True:
  171. key.init(repository, passphrase)
  172. try:
  173. key.decrypt(None, manifest_data)
  174. num_blocks = num_aes_blocks(len(manifest_data) - 41)
  175. key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
  176. return key
  177. except IntegrityError:
  178. passphrase = getpass(prompt)
  179. def init(self, repository, passphrase):
  180. self.init_from_random_data(pbkdf2_sha256(passphrase.encode('utf-8'), repository.id, self.iterations, 100))
  181. self.init_ciphers()
  182. class KeyfileKey(AESKeyBase):
  183. FILE_ID = 'ATTIC KEY'
  184. TYPE = 0x00
  185. @classmethod
  186. def detect(cls, repository, manifest_data):
  187. key = cls()
  188. path = cls.find_key_file(repository)
  189. prompt = 'Enter passphrase for key file %s: ' % path
  190. passphrase = os.environ.get('ATTIC_PASSPHRASE', '')
  191. while not key.load(path, passphrase):
  192. passphrase = getpass(prompt)
  193. num_blocks = num_aes_blocks(len(manifest_data) - 41)
  194. key.init_ciphers(PREFIX + long_to_bytes(key.extract_nonce(manifest_data) + num_blocks))
  195. return key
  196. @classmethod
  197. def find_key_file(cls, repository):
  198. id = hexlify(repository.id).decode('ascii')
  199. keys_dir = get_keys_dir()
  200. for name in os.listdir(keys_dir):
  201. filename = os.path.join(keys_dir, name)
  202. with open(filename, 'r') as fd:
  203. line = fd.readline().strip()
  204. if line and line.startswith(cls.FILE_ID) and line[10:] == id:
  205. return filename
  206. raise Exception('Key file for repository with ID %s not found' % id)
  207. def load(self, filename, passphrase):
  208. with open(filename, 'r') as fd:
  209. cdata = a2b_base64(''.join(fd.readlines()[1:]).encode('ascii')) # .encode needed for Python 3.[0-2]
  210. data = self.decrypt_key_file(cdata, passphrase)
  211. if data:
  212. key = msgpack.unpackb(data)
  213. if key[b'version'] != 1:
  214. raise IntegrityError('Invalid key file header')
  215. self.repository_id = key[b'repository_id']
  216. self.enc_key = key[b'enc_key']
  217. self.enc_hmac_key = key[b'enc_hmac_key']
  218. self.id_key = key[b'id_key']
  219. self.chunk_seed = key[b'chunk_seed']
  220. self.path = filename
  221. return True
  222. def decrypt_key_file(self, data, passphrase):
  223. d = msgpack.unpackb(data)
  224. assert d[b'version'] == 1
  225. assert d[b'algorithm'] == b'sha256'
  226. key = pbkdf2_sha256(passphrase.encode('utf-8'), d[b'salt'], d[b'iterations'], 32)
  227. data = AES(key).decrypt(d[b'data'])
  228. if HMAC(key, data, sha256).digest() != d[b'hash']:
  229. return None
  230. return data
  231. def encrypt_key_file(self, data, passphrase):
  232. salt = get_random_bytes(32)
  233. iterations = 100000
  234. key = pbkdf2_sha256(passphrase.encode('utf-8'), salt, iterations, 32)
  235. hash = HMAC(key, data, sha256).digest()
  236. cdata = AES(key).encrypt(data)
  237. d = {
  238. 'version': 1,
  239. 'salt': salt,
  240. 'iterations': iterations,
  241. 'algorithm': 'sha256',
  242. 'hash': hash,
  243. 'data': cdata,
  244. }
  245. return msgpack.packb(d)
  246. def save(self, path, passphrase):
  247. key = {
  248. 'version': 1,
  249. 'repository_id': self.repository_id,
  250. 'enc_key': self.enc_key,
  251. 'enc_hmac_key': self.enc_hmac_key,
  252. 'id_key': self.id_key,
  253. 'chunk_seed': self.chunk_seed,
  254. }
  255. data = self.encrypt_key_file(msgpack.packb(key), passphrase)
  256. with open(path, 'w') as fd:
  257. fd.write('%s %s\n' % (self.FILE_ID, hexlify(self.repository_id).decode('ascii')))
  258. fd.write('\n'.join(textwrap.wrap(b2a_base64(data).decode('ascii'))))
  259. fd.write('\n')
  260. self.path = path
  261. def change_passphrase(self):
  262. passphrase, passphrase2 = 1, 2
  263. while passphrase != passphrase2:
  264. passphrase = getpass('New passphrase: ')
  265. passphrase2 = getpass('Enter same passphrase again: ')
  266. if passphrase != passphrase2:
  267. print('Passphrases do not match')
  268. self.save(self.path, passphrase)
  269. print('Key file "%s" updated' % self.path)
  270. @classmethod
  271. def create(cls, repository, args):
  272. filename = args.repository.to_key_filename()
  273. path = filename
  274. i = 1
  275. while os.path.exists(path):
  276. i += 1
  277. path = filename + '.%d' % i
  278. passphrase = os.environ.get('ATTIC_PASSPHRASE')
  279. if passphrase is not None:
  280. passphrase2 = passphrase
  281. else:
  282. passphrase, passphrase2 = 1, 2
  283. while passphrase != passphrase2:
  284. passphrase = getpass('Enter passphrase (empty for no passphrase):')
  285. passphrase2 = getpass('Enter same passphrase again: ')
  286. if passphrase != passphrase2:
  287. print('Passphrases do not match')
  288. key = cls()
  289. key.repository_id = repository.id
  290. key.init_from_random_data(get_random_bytes(100))
  291. key.init_ciphers()
  292. key.save(path, passphrase)
  293. print('Key file "%s" created.' % key.path)
  294. print('Keep this file safe. Your data will be inaccessible without it.')
  295. return key