key.py 14 KB

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