key.py 11 KB

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