key.py 12 KB

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