key.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import getpass
  2. import os.path
  3. import re
  4. import tempfile
  5. from binascii import hexlify, unhexlify, a2b_base64
  6. from unittest.mock import MagicMock
  7. import pytest
  8. from ..crypto.key import bin_to_hex
  9. from ..crypto.key import PlaintextKey, AuthenticatedKey, Blake2AuthenticatedKey
  10. from ..crypto.key import RepoKey, KeyfileKey, Blake2RepoKey, Blake2KeyfileKey
  11. from ..crypto.key import AESOCBRepoKey, AESOCBKeyfileKey, CHPORepoKey, CHPOKeyfileKey
  12. from ..crypto.key import Blake2AESOCBRepoKey, Blake2AESOCBKeyfileKey, Blake2CHPORepoKey, Blake2CHPOKeyfileKey
  13. from ..crypto.key import ID_HMAC_SHA_256, ID_BLAKE2b_256
  14. from ..crypto.key import TAMRequiredError, TAMInvalid, TAMUnsupportedSuiteError, UnsupportedManifestError, UnsupportedKeyFormatError
  15. from ..crypto.key import identify_key
  16. from ..crypto.low_level import bytes_to_long
  17. from ..crypto.low_level import IntegrityError as IntegrityErrorBase
  18. from ..helpers import IntegrityError
  19. from ..helpers import Location
  20. from ..helpers import StableDict
  21. from ..helpers import get_security_dir
  22. from ..helpers import msgpack
  23. from ..constants import KEY_ALGORITHMS
  24. class TestKey:
  25. class MockArgs:
  26. location = Location(tempfile.mkstemp()[1])
  27. key_algorithm = "argon2"
  28. keyfile2_key_file = """
  29. BORG_KEY 0000000000000000000000000000000000000000000000000000000000000000
  30. hqlhbGdvcml0aG2mc2hhMjU2pGRhdGHaAN4u2SiN7hqISe3OA8raBWNuvHn1R50ZU7HVCn
  31. 11vTJNEaj9soxUaIGcW+pAB2N5yYoKMg/sGCMuZa286iJ008DvN99rf/ORfcKrK2GmzslO
  32. N3uv9Tk9HtqV/Sq5zgM9xuY9rEeQGDQVQ+AOsFamJqSUrAemGJbJqw9IerXC/jN4XPnX6J
  33. pi1cXCFxHfDaEhmWrkdPNoZdirCv/eP/dOVOLmwU58YsS+MvkZNfEa16el/fSb/ENdrwJ/
  34. 2aYMQrDdk1d5MYzkjotv/KpofNwPXZchu2EwH7OIHWQjEVL1DZWkaGFzaNoAIO/7qn1hr3
  35. F84MsMMiqpbz4KVICeBZhfAaTPs4W7BC63qml0ZXJhdGlvbnPOAAGGoKRzYWx02gAgLENQ
  36. 2uVCoR7EnAoiRzn8J+orbojKtJlNCnQ31SSC8rendmVyc2lvbgE=""".strip()
  37. keyfile2_cdata = unhexlify(re.sub(r'\W', '', """
  38. 0055f161493fcfc16276e8c31493c4641e1eb19a79d0326fad0291e5a9c98e5933
  39. 00000000000003e8d21eaf9b86c297a8cd56432e1915bb
  40. """))
  41. keyfile2_id = unhexlify('c3fbf14bc001ebcc3cd86e696c13482ed071740927cd7cbe1b01b4bfcee49314')
  42. keyfile_blake2_key_file = """
  43. BORG_KEY 0000000000000000000000000000000000000000000000000000000000000000
  44. hqlhbGdvcml0aG2mc2hhMjU2pGRhdGHaAZ7VCsTjbLhC1ipXOyhcGn7YnROEhP24UQvOCi
  45. Oar1G+JpwgO9BIYaiCODUpzPuDQEm6WxyTwEneJ3wsuyeqyh7ru2xo9FAUKRf6jcqqZnan
  46. ycTfktkUC+CPhKR7W6MTu5fPvy99chyL09/RGdD15aswR5PjNoFu4626sfMrBReyPdlxqt
  47. F80m+fbNE/vln2Trqoz9EMHQ3IxjIK4q0m4Aj7TwCu7ZankFtwt898+tYsWE7lb2Ps/gXB
  48. F8PM/5wHpYps2AKhDCpwKp5HyqIqlF5IzR2ydL9QP20QBjp/rSi6b+xwrfxNJZfw78f8ef
  49. A2Yj7xIsxNQ0kmVmTL/UF6d7+Mw1JfurWrySiDU7QQ+RiZpWUZ0DdReB+e4zn6/KNKC884
  50. 34SGywADuLIQe2FKU+5jBCbutEyEGILQbAR/cgeLy5+V2XwXMJh4ytwXVIeT6Lk+qhYAdz
  51. Klx4ub7XijKcOxJyBE+4k33DAhcfIT2r4/sxgMhXrIOEQPKsMAixzdcqVYkpou+6c4PZeL
  52. nr+UjfJwOqK1BlWk1NgwE4GXYIKkaGFzaNoAIAzjUtpBPPh6kItZtHQZvnQG6FpucZNfBC
  53. UTHFJg343jqml0ZXJhdGlvbnPOAAGGoKRzYWx02gAgz3YaUZZ/s+UWywj97EY5b4KhtJYi
  54. qkPqtDDxs2j/T7+ndmVyc2lvbgE=""".strip()
  55. keyfile_blake2_cdata = bytes.fromhex('04fdf9475cf2323c0ba7a99ddc011064f2e7d039f539f2e448'
  56. '0e6f5fc6ff9993d604040404040404098c8cee1c6db8c28947')
  57. # Verified against b2sum. Entire string passed to BLAKE2, including the padded 64 byte key contained in
  58. # keyfile_blake2_key_file above is
  59. # 19280471de95185ec27ecb6fc9edbb4f4db26974c315ede1cd505fab4250ce7cd0d081ea66946c
  60. # 95f0db934d5f616921efbd869257e8ded2bd9bd93d7f07b1a30000000000000000000000000000
  61. # 000000000000000000000000000000000000000000000000000000000000000000000000000000
  62. # 00000000000000000000007061796c6f6164
  63. # p a y l o a d
  64. keyfile_blake2_id = bytes.fromhex('d8bc68e961c79f99be39061589e5179b2113cd9226e07b08ddd4a1fef7ce93fb')
  65. @pytest.fixture
  66. def keys_dir(self, request, monkeypatch, tmpdir):
  67. monkeypatch.setenv('BORG_KEYS_DIR', str(tmpdir))
  68. return tmpdir
  69. @pytest.fixture(params=(
  70. # not encrypted
  71. PlaintextKey,
  72. AuthenticatedKey, Blake2AuthenticatedKey,
  73. # legacy crypto
  74. KeyfileKey, Blake2KeyfileKey,
  75. RepoKey, Blake2RepoKey,
  76. # new crypto
  77. AESOCBKeyfileKey, AESOCBRepoKey,
  78. Blake2AESOCBKeyfileKey, Blake2AESOCBRepoKey,
  79. CHPOKeyfileKey, CHPORepoKey,
  80. Blake2CHPOKeyfileKey, Blake2CHPORepoKey,
  81. ))
  82. def key(self, request, monkeypatch):
  83. monkeypatch.setenv('BORG_PASSPHRASE', 'test')
  84. return request.param.create(self.MockRepository(), self.MockArgs())
  85. class MockRepository:
  86. class _Location:
  87. raw = processed = '/some/place'
  88. def canonical_path(self):
  89. return self.processed
  90. _location = _Location()
  91. id = bytes(32)
  92. id_str = bin_to_hex(id)
  93. def get_free_nonce(self):
  94. return None
  95. def commit_nonce_reservation(self, next_unreserved, start_nonce):
  96. pass
  97. def save_key(self, data):
  98. self.key_data = data
  99. def load_key(self):
  100. return self.key_data
  101. def test_plaintext(self):
  102. key = PlaintextKey.create(None, None)
  103. chunk = b'foo'
  104. id = key.id_hash(chunk)
  105. assert hexlify(id) == b'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'
  106. assert chunk == key.decrypt(id, key.encrypt(id, chunk))
  107. def test_keyfile(self, monkeypatch, keys_dir):
  108. monkeypatch.setenv('BORG_PASSPHRASE', 'test')
  109. key = KeyfileKey.create(self.MockRepository(), self.MockArgs())
  110. assert key.cipher.next_iv() == 0
  111. chunk = b'ABC'
  112. id = key.id_hash(chunk)
  113. manifest = key.encrypt(id, chunk)
  114. assert key.cipher.extract_iv(manifest) == 0
  115. manifest2 = key.encrypt(id, chunk)
  116. assert manifest != manifest2
  117. assert key.decrypt(id, manifest) == key.decrypt(id, manifest2)
  118. assert key.cipher.extract_iv(manifest2) == 1
  119. iv = key.cipher.extract_iv(manifest)
  120. key2 = KeyfileKey.detect(self.MockRepository(), manifest)
  121. assert key2.cipher.next_iv() >= iv + key2.cipher.block_count(len(manifest) - KeyfileKey.PAYLOAD_OVERHEAD)
  122. # Key data sanity check
  123. assert len({key2.id_key, key2.enc_key, key2.enc_hmac_key}) == 3
  124. assert key2.chunk_seed != 0
  125. chunk = b'foo'
  126. id = key.id_hash(chunk)
  127. assert chunk == key2.decrypt(id, key.encrypt(id, chunk))
  128. def test_keyfile_kfenv(self, tmpdir, monkeypatch):
  129. keyfile = tmpdir.join('keyfile')
  130. monkeypatch.setenv('BORG_KEY_FILE', str(keyfile))
  131. monkeypatch.setenv('BORG_PASSPHRASE', 'testkf')
  132. assert not keyfile.exists()
  133. key = CHPOKeyfileKey.create(self.MockRepository(), self.MockArgs())
  134. assert keyfile.exists()
  135. chunk = b'ABC'
  136. chunk_id = key.id_hash(chunk)
  137. chunk_cdata = key.encrypt(chunk_id, chunk)
  138. key = CHPOKeyfileKey.detect(self.MockRepository(), chunk_cdata)
  139. assert chunk == key.decrypt(chunk_id, chunk_cdata)
  140. keyfile.remove()
  141. with pytest.raises(FileNotFoundError):
  142. CHPOKeyfileKey.detect(self.MockRepository(), chunk_cdata)
  143. def test_keyfile2(self, monkeypatch, keys_dir):
  144. with keys_dir.join('keyfile').open('w') as fd:
  145. fd.write(self.keyfile2_key_file)
  146. monkeypatch.setenv('BORG_PASSPHRASE', 'passphrase')
  147. key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata)
  148. assert key.decrypt(self.keyfile2_id, self.keyfile2_cdata) == b'payload'
  149. def test_keyfile2_kfenv(self, tmpdir, monkeypatch):
  150. keyfile = tmpdir.join('keyfile')
  151. with keyfile.open('w') as fd:
  152. fd.write(self.keyfile2_key_file)
  153. monkeypatch.setenv('BORG_KEY_FILE', str(keyfile))
  154. monkeypatch.setenv('BORG_PASSPHRASE', 'passphrase')
  155. key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata)
  156. assert key.decrypt(self.keyfile2_id, self.keyfile2_cdata) == b'payload'
  157. def test_keyfile_blake2(self, monkeypatch, keys_dir):
  158. with keys_dir.join('keyfile').open('w') as fd:
  159. fd.write(self.keyfile_blake2_key_file)
  160. monkeypatch.setenv('BORG_PASSPHRASE', 'passphrase')
  161. key = Blake2KeyfileKey.detect(self.MockRepository(), self.keyfile_blake2_cdata)
  162. assert key.decrypt(self.keyfile_blake2_id, self.keyfile_blake2_cdata) == b'payload'
  163. def _corrupt_byte(self, key, data, offset):
  164. data = bytearray(data)
  165. # note: we corrupt in a way so that even corruption of the unauthenticated encryption type byte
  166. # will trigger an IntegrityError (does not happen while we stay within TYPES_ACCEPTABLE).
  167. data[offset] ^= 64
  168. with pytest.raises(IntegrityErrorBase):
  169. key.decrypt(b'', data)
  170. def test_decrypt_integrity(self, monkeypatch, keys_dir):
  171. with keys_dir.join('keyfile').open('w') as fd:
  172. fd.write(self.keyfile2_key_file)
  173. monkeypatch.setenv('BORG_PASSPHRASE', 'passphrase')
  174. key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata)
  175. data = self.keyfile2_cdata
  176. for i in range(len(data)):
  177. self._corrupt_byte(key, data, i)
  178. with pytest.raises(IntegrityError):
  179. data = bytearray(self.keyfile2_cdata)
  180. id = bytearray(key.id_hash(data)) # corrupt chunk id
  181. id[12] = 0
  182. key.decrypt(id, data)
  183. def test_roundtrip(self, key):
  184. repository = key.repository
  185. plaintext = b'foo'
  186. id = key.id_hash(plaintext)
  187. encrypted = key.encrypt(id, plaintext)
  188. identified_key_class = identify_key(encrypted)
  189. assert identified_key_class == key.__class__
  190. loaded_key = identified_key_class.detect(repository, encrypted)
  191. decrypted = loaded_key.decrypt(id, encrypted)
  192. assert decrypted == plaintext
  193. def test_decrypt_decompress(self, key):
  194. plaintext = b'123456789'
  195. id = key.id_hash(plaintext)
  196. encrypted = key.encrypt(id, plaintext)
  197. assert key.decrypt(id, encrypted, decompress=False) != plaintext
  198. assert key.decrypt(id, encrypted) == plaintext
  199. def test_assert_id(self, key):
  200. plaintext = b'123456789'
  201. id = key.id_hash(plaintext)
  202. key.assert_id(id, plaintext)
  203. id_changed = bytearray(id)
  204. id_changed[0] ^= 1
  205. with pytest.raises(IntegrityError):
  206. key.assert_id(id_changed, plaintext)
  207. plaintext_changed = plaintext + b'1'
  208. with pytest.raises(IntegrityError):
  209. key.assert_id(id, plaintext_changed)
  210. def test_authenticated_encrypt(self, monkeypatch):
  211. monkeypatch.setenv('BORG_PASSPHRASE', 'test')
  212. key = AuthenticatedKey.create(self.MockRepository(), self.MockArgs())
  213. assert AuthenticatedKey.id_hash is ID_HMAC_SHA_256.id_hash
  214. assert len(key.id_key) == 32
  215. plaintext = b'123456789'
  216. id = key.id_hash(plaintext)
  217. authenticated = key.encrypt(id, plaintext)
  218. # 0x07 is the key TYPE, \x00ff identifies no compression / unknown level.
  219. assert authenticated == b'\x07\x00\xff' + plaintext
  220. def test_blake2_authenticated_encrypt(self, monkeypatch):
  221. monkeypatch.setenv('BORG_PASSPHRASE', 'test')
  222. key = Blake2AuthenticatedKey.create(self.MockRepository(), self.MockArgs())
  223. assert Blake2AuthenticatedKey.id_hash is ID_BLAKE2b_256.id_hash
  224. assert len(key.id_key) == 128
  225. plaintext = b'123456789'
  226. id = key.id_hash(plaintext)
  227. authenticated = key.encrypt(id, plaintext)
  228. # 0x06 is the key TYPE, 0x00ff identifies no compression / unknown level.
  229. assert authenticated == b'\x06\x00\xff' + plaintext
  230. class TestTAM:
  231. @pytest.fixture
  232. def key(self, monkeypatch):
  233. monkeypatch.setenv('BORG_PASSPHRASE', 'test')
  234. return CHPOKeyfileKey.create(TestKey.MockRepository(), TestKey.MockArgs())
  235. def test_unpack_future(self, key):
  236. blob = b'\xc1\xc1\xc1\xc1foobar'
  237. with pytest.raises(UnsupportedManifestError):
  238. key.unpack_and_verify_manifest(blob)
  239. blob = b'\xc1\xc1\xc1'
  240. with pytest.raises(msgpack.UnpackException):
  241. key.unpack_and_verify_manifest(blob)
  242. def test_missing_when_required(self, key):
  243. blob = msgpack.packb({})
  244. with pytest.raises(TAMRequiredError):
  245. key.unpack_and_verify_manifest(blob)
  246. def test_missing(self, key):
  247. blob = msgpack.packb({})
  248. key.tam_required = False
  249. unpacked, verified = key.unpack_and_verify_manifest(blob)
  250. assert unpacked == {}
  251. assert not verified
  252. def test_unknown_type_when_required(self, key):
  253. blob = msgpack.packb({
  254. 'tam': {
  255. 'type': 'HMAC_VOLLBIT',
  256. },
  257. })
  258. with pytest.raises(TAMUnsupportedSuiteError):
  259. key.unpack_and_verify_manifest(blob)
  260. def test_unknown_type(self, key):
  261. blob = msgpack.packb({
  262. 'tam': {
  263. 'type': 'HMAC_VOLLBIT',
  264. },
  265. })
  266. key.tam_required = False
  267. unpacked, verified = key.unpack_and_verify_manifest(blob)
  268. assert unpacked == {}
  269. assert not verified
  270. @pytest.mark.parametrize('tam, exc', (
  271. ({}, TAMUnsupportedSuiteError),
  272. ({'type': b'\xff'}, TAMUnsupportedSuiteError),
  273. (None, TAMInvalid),
  274. (1234, TAMInvalid),
  275. ))
  276. def test_invalid(self, key, tam, exc):
  277. blob = msgpack.packb({
  278. 'tam': tam,
  279. })
  280. with pytest.raises(exc):
  281. key.unpack_and_verify_manifest(blob)
  282. @pytest.mark.parametrize('hmac, salt', (
  283. ({}, bytes(64)),
  284. (bytes(64), {}),
  285. (None, bytes(64)),
  286. (bytes(64), None),
  287. ))
  288. def test_wrong_types(self, key, hmac, salt):
  289. data = {
  290. 'tam': {
  291. 'type': 'HKDF_HMAC_SHA512',
  292. 'hmac': hmac,
  293. 'salt': salt
  294. },
  295. }
  296. tam = data['tam']
  297. if hmac is None:
  298. del tam['hmac']
  299. if salt is None:
  300. del tam['salt']
  301. blob = msgpack.packb(data)
  302. with pytest.raises(TAMInvalid):
  303. key.unpack_and_verify_manifest(blob)
  304. def test_round_trip(self, key):
  305. data = {'foo': 'bar'}
  306. blob = key.pack_and_authenticate_metadata(data)
  307. assert blob.startswith(b'\x82')
  308. unpacked = msgpack.unpackb(blob)
  309. assert unpacked['tam']['type'] == 'HKDF_HMAC_SHA512'
  310. unpacked, verified = key.unpack_and_verify_manifest(blob)
  311. assert verified
  312. assert unpacked['foo'] == 'bar'
  313. assert 'tam' not in unpacked
  314. @pytest.mark.parametrize('which', ('hmac', 'salt'))
  315. def test_tampered(self, key, which):
  316. data = {'foo': 'bar'}
  317. blob = key.pack_and_authenticate_metadata(data)
  318. assert blob.startswith(b'\x82')
  319. unpacked = msgpack.unpackb(blob, object_hook=StableDict)
  320. assert len(unpacked['tam'][which]) == 64
  321. unpacked['tam'][which] = unpacked['tam'][which][0:32] + bytes(32)
  322. assert len(unpacked['tam'][which]) == 64
  323. blob = msgpack.packb(unpacked)
  324. with pytest.raises(TAMInvalid):
  325. key.unpack_and_verify_manifest(blob)
  326. def test_decrypt_key_file_unsupported_algorithm():
  327. """We will add more algorithms in the future. We should raise a helpful error."""
  328. key = CHPOKeyfileKey(None)
  329. encrypted = msgpack.packb({
  330. 'algorithm': 'THIS ALGORITHM IS NOT SUPPORTED',
  331. 'version': 1,
  332. })
  333. with pytest.raises(UnsupportedKeyFormatError):
  334. key.decrypt_key_file(encrypted, "hello, pass phrase")
  335. def test_decrypt_key_file_v2_is_unsupported():
  336. """There may eventually be a version 2 of the format. For now we should raise a helpful error."""
  337. key = CHPOKeyfileKey(None)
  338. encrypted = msgpack.packb({
  339. 'version': 2,
  340. })
  341. with pytest.raises(UnsupportedKeyFormatError):
  342. key.decrypt_key_file(encrypted, "hello, pass phrase")
  343. def test_key_file_roundtrip(monkeypatch):
  344. def to_dict(key):
  345. extract = 'repository_id', 'enc_key', 'enc_hmac_key', 'id_key', 'chunk_seed'
  346. return {a: getattr(key, a) for a in extract}
  347. repository = MagicMock(id=b'repository_id')
  348. monkeypatch.setenv('BORG_PASSPHRASE', "hello, pass phrase")
  349. save_me = AESOCBRepoKey.create(repository, args=MagicMock(key_algorithm='argon2'))
  350. saved = repository.save_key.call_args.args[0]
  351. repository.load_key.return_value = saved
  352. load_me = AESOCBRepoKey.detect(repository, manifest_data=None)
  353. assert to_dict(load_me) == to_dict(save_me)
  354. assert msgpack.unpackb(a2b_base64(saved))['algorithm'] == KEY_ALGORITHMS['argon2']