borg/darc/key.py

399 lines
14 KiB
Python

from binascii import hexlify, unhexlify, a2b_base64, b2a_base64
from getpass import getpass
import os
import msgpack
import re
import shutil
import tempfile
import textwrap
import unittest
import hmac
from hashlib import sha256
import zlib
from .crypto import pbkdf2_sha256, get_random_bytes, AES, bytes_to_long, long_to_bytes, bytes_to_int
from .helpers import IntegrityError, get_keys_dir, Location
PREFIX = b'\0' * 8
KEYFILE = b'\0'
PASSPHRASE = b'\1'
PLAINTEXT = b'\2'
class HMAC(hmac.HMAC):
def update(self, msg):
self.inner.update(msg)
def key_creator(repository, args):
if args.keyfile:
return KeyfileKey.create(repository, args)
elif args.passphrase:
return PassphraseKey.create(repository, args)
else:
return PlaintextKey.create(repository, args)
def key_factory(repository, manifest_data):
if manifest_data[:1] == KEYFILE:
return KeyfileKey.detect(repository, manifest_data)
elif manifest_data[:1] == PASSPHRASE:
return PassphraseKey.detect(repository, manifest_data)
elif manifest_data[:1] == PLAINTEXT:
return PlaintextKey.detect(repository, manifest_data)
else:
raise Exception('Unkown Key type %d' % ord(manifest_data[0]))
class KeyBase(object):
def id_hash(self, data):
"""Return HMAC hash using the "id" HMAC key
"""
def encrypt(self, data):
pass
def decrypt(self, id, data):
pass
class PlaintextKey(KeyBase):
TYPE = PLAINTEXT
chunk_seed = 0
@classmethod
def create(cls, repository, args):
print('Encryption NOT enabled.\nUse the --key-file or --passphrase options to enable encryption.')
return cls()
@classmethod
def detect(cls, repository, manifest_data):
return cls()
def id_hash(self, data):
return sha256(data).digest()
def encrypt(self, data):
return b''.join([self.TYPE, zlib.compress(data)])
def decrypt(self, id, data):
if data[:1] != self.TYPE:
raise IntegrityError('Invalid encryption envelope')
data = zlib.decompress(memoryview(data)[1:])
if id and sha256(data).digest() != id:
raise IntegrityError('Chunk id verification failed')
return data
class AESKeyBase(KeyBase):
def id_hash(self, data):
"""Return HMAC hash using the "id" HMAC key
"""
return HMAC(self.id_key, data, sha256).digest()
def encrypt(self, data):
data = zlib.compress(data)
self.enc_cipher.reset()
data = b''.join((self.enc_cipher.iv[8:], self.enc_cipher.encrypt(data)))
hash = HMAC(self.enc_hmac_key, data, sha256).digest()
return b''.join((self.TYPE, hash, data))
def decrypt(self, id, data):
if data[:1] != self.TYPE:
raise IntegrityError('Invalid encryption envelope')
hash = memoryview(data)[1:33]
if memoryview(HMAC(self.enc_hmac_key, memoryview(data)[33:], sha256).digest()) != hash:
raise IntegrityError('Encryption envelope checksum mismatch')
self.dec_cipher.reset(iv=PREFIX + data[33:41])
data = zlib.decompress(self.dec_cipher.decrypt(data[41:])) # should use memoryview
if id and HMAC(self.id_key, data, sha256).digest() != id:
raise IntegrityError('Chunk id verification failed')
return data
def extract_iv(self, payload):
if payload[:1] != self.TYPE:
raise IntegrityError('Invalid encryption envelope')
nonce = bytes_to_long(payload[33:41])
return nonce
def init_from_random_data(self, data):
self.enc_key = data[0:32]
self.enc_hmac_key = data[32:64]
self.id_key = data[64:96]
self.chunk_seed = bytes_to_int(data[96:100])
# Convert to signed int32
if self.chunk_seed & 0x80000000:
self.chunk_seed = self.chunk_seed - 0xffffffff - 1
def init_ciphers(self, enc_iv=b''):
self.enc_cipher = AES(self.enc_key, enc_iv)
self.dec_cipher = AES(self.enc_key)
class PassphraseKey(AESKeyBase):
TYPE = PASSPHRASE
iterations = 100000
@classmethod
def create(cls, repository, args):
key = cls()
passphrase = os.environ.get('DARC_PASSPHRASE')
if passphrase is not None:
passphrase2 = passphrase
else:
passphrase, passphrase2 = 1, 2
while passphrase != passphrase2:
passphrase = getpass('Enter passphrase: ')
if not passphrase:
print('Passphrase must not be blank')
continue
passphrase2 = getpass('Enter same passphrase again: ')
if passphrase != passphrase2:
print('Passphrases do not match')
key.init(repository, passphrase)
if passphrase:
print('Remember your passphrase. Your data will be inaccessible without it.')
return key
@classmethod
def detect(cls, repository, manifest_data):
prompt = 'Enter passphrase for %s: ' % repository._location.orig
key = cls()
passphrase = os.environ.get('DARC_PASSPHRASE')
if passphrase is None:
passphrase = getpass(prompt)
while True:
key.init(repository, passphrase)
try:
key.decrypt(None, manifest_data)
key.init_ciphers(PREFIX + long_to_bytes(key.extract_iv(manifest_data) + 1000))
return key
except IntegrityError:
passphrase = getpass(prompt)
def init(self, repository, passphrase):
self.init_from_random_data(pbkdf2_sha256(passphrase.encode('utf-8'), repository.id, self.iterations, 100))
self.init_ciphers()
class KeyfileKey(AESKeyBase):
FILE_ID = 'DARC KEY'
TYPE = KEYFILE
@classmethod
def detect(cls, repository, manifest_data):
key = cls()
path = cls.find_key_file(repository)
prompt = 'Enter passphrase for key file %s: ' % path
passphrase = os.environ.get('DARC_PASSPHRASE', '')
while not key.load(path, passphrase):
passphrase = getpass(prompt)
key.init_ciphers(PREFIX + long_to_bytes(key.extract_iv(manifest_data) + 1000))
return key
@classmethod
def find_key_file(cls, repository):
id = hexlify(repository.id).decode('ascii')
keys_dir = get_keys_dir()
for name in os.listdir(keys_dir):
filename = os.path.join(keys_dir, name)
with open(filename, 'r') as fd:
line = fd.readline().strip()
if line and line.startswith(cls.FILE_ID) and line[9:] == id:
return filename
raise Exception('Key file for repository with ID %s not found' % id)
def load(self, filename, passphrase):
with open(filename, 'r') as fd:
cdata = a2b_base64(''.join(fd.readlines()[1:]).encode('ascii')) # .encode needed for Python 3.[0-2]
data = self.decrypt_key_file(cdata, passphrase)
if data:
key = msgpack.unpackb(data)
if key[b'version'] != 1:
raise IntegrityError('Invalid key file header')
self.repository_id = key[b'repository_id']
self.enc_key = key[b'enc_key']
self.enc_hmac_key = key[b'enc_hmac_key']
self.id_key = key[b'id_key']
self.chunk_seed = key[b'chunk_seed']
self.path = filename
return True
def decrypt_key_file(self, data, passphrase):
d = msgpack.unpackb(data)
assert d[b'version'] == 1
assert d[b'algorithm'] == b'sha256'
key = pbkdf2_sha256(passphrase.encode('utf-8'), d[b'salt'], d[b'iterations'], 32)
data = AES(key).decrypt(d[b'data'])
if HMAC(key, data, sha256).digest() != d[b'hash']:
return None
return data
def encrypt_key_file(self, data, passphrase):
salt = get_random_bytes(32)
iterations = 100000
key = pbkdf2_sha256(passphrase.encode('utf-8'), salt, iterations, 32)
hash = HMAC(key, data, sha256).digest()
cdata = AES(key).encrypt(data)
d = {
'version': 1,
'salt': salt,
'iterations': iterations,
'algorithm': 'sha256',
'hash': hash,
'data': cdata,
}
return msgpack.packb(d)
def save(self, path, passphrase):
key = {
'version': 1,
'repository_id': self.repository_id,
'enc_key': self.enc_key,
'enc_hmac_key': self.enc_hmac_key,
'id_key': self.id_key,
'chunk_seed': self.chunk_seed,
}
data = self.encrypt_key_file(msgpack.packb(key), passphrase)
with open(path, 'w') as fd:
fd.write('%s %s\n' % (self.FILE_ID, hexlify(self.repository_id).decode('ascii')))
fd.write('\n'.join(textwrap.wrap(b2a_base64(data).decode('ascii'))))
self.path = path
def change_passphrase(self):
passphrase, passphrase2 = 1, 2
while passphrase != passphrase2:
passphrase = getpass('New passphrase: ')
passphrase2 = getpass('Enter same passphrase again: ')
if passphrase != passphrase2:
print('Passphrases do not match')
self.save(self.path, passphrase)
print('Key file "%s" updated' % self.path)
@classmethod
def create(cls, repository, args):
filename = args.repository.to_key_filename()
path = filename
i = 1
while os.path.exists(path):
i += 1
path = filename + '.%d' % i
passphrase = os.environ.get('DARC_PASSPHRASE')
if passphrase is not None:
passphrase2 = passphrase
else:
passphrase, passphrase2 = 1, 2
while passphrase != passphrase2:
passphrase = getpass('Enter passphrase (empty for no passphrase):')
passphrase2 = getpass('Enter same passphrase again: ')
if passphrase != passphrase2:
print('Passphrases do not match')
key = cls()
key.repository_id = repository.id
key.init_from_random_data(get_random_bytes(100))
key.init_ciphers()
key.save(path, passphrase)
print('Key file "%s" created.' % key.path)
print('Keep this file safe. Your data will be inaccessible without it.')
return key
class KeyTestCase(unittest.TestCase):
class MockArgs(object):
repository = Location(tempfile.mkstemp()[1])
keyfile2_key_file = """
DARC KEY 0000000000000000000000000000000000000000000000000000000000000000
hqppdGVyYXRpb25zzgABhqCkaGFzaNoAIMyonNI+7Cjv0qHi0AOBM6bLGxACJhfgzVD2oq
bIS9SFqWFsZ29yaXRobaZzaGEyNTakc2FsdNoAINNK5qqJc1JWSUjACwFEWGTdM7Nd0a5l
1uBGPEb+9XM9p3ZlcnNpb24BpGRhdGHaANAYDT5yfPpU099oBJwMomsxouKyx/OG4QIXK2
hQCG2L2L/9PUu4WIuKvGrsXoP7syemujNfcZws5jLp2UPva4PkQhQsrF1RYDEMLh2eF9Ol
rwtkThq1tnh7KjWMG9Ijt7/aoQtq0zDYP/xaFF8XXSJxiyP5zjH5+spB6RL0oQHvbsliSh
/cXJq7jrqmrJ1phd6dg4SHAM/i+hubadZoS6m25OQzYAW09wZD/phG8OVa698Z5ed3HTaT
SmrtgJL3EoOKgUI9d6BLE4dJdBqntifo""".strip()
keyfile2_cdata = unhexlify(re.sub('\W', '', """
0055f161493fcfc16276e8c31493c4641e1eb19a79d0326fad0291e5a9c98e5933
00000000000003e8d21eaf9b86c297a8cd56432e1915bb
"""))
keyfile2_id = unhexlify('c3fbf14bc001ebcc3cd86e696c13482ed071740927cd7cbe1b01b4bfcee49314')
def setUp(self):
self.tmppath = tempfile.mkdtemp()
os.environ['DARC_KEYS_DIR'] = self.tmppath
def tearDown(self):
shutil.rmtree(self.tmppath)
class MockRepository(object):
class _Location(object):
orig = '/some/place'
_location = _Location()
id = bytes(32)
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
os.environ['DARC_KEYS_DIR'] = self.tmpdir
def tearDown(self):
shutil.rmtree(self.tmpdir)
def test_plaintext(self):
key = PlaintextKey.create(None, None)
data = b'foo'
self.assertEqual(hexlify(key.id_hash(data)), b'2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae')
self.assertEqual(data, key.decrypt(key.id_hash(data), key.encrypt(data)))
def test_keyfile(self):
os.environ['DARC_PASSPHRASE'] = 'test'
key = KeyfileKey.create(self.MockRepository(), self.MockArgs())
self.assertEqual(bytes_to_long(key.enc_cipher.iv, 8), 0)
manifest = key.encrypt(b'')
iv = key.extract_iv(manifest)
key2 = KeyfileKey.detect(self.MockRepository(), manifest)
self.assertEqual(bytes_to_long(key2.enc_cipher.iv, 8), iv + 1000)
# Key data sanity check
self.assertEqual(len(set([key2.id_key, key2.enc_key, key2.enc_hmac_key])), 3)
self.assertEqual(key2.chunk_seed == 0, False)
data = b'foo'
self.assertEqual(data, key2.decrypt(key.id_hash(data), key.encrypt(data)))
def test_keyfile2(self):
with open(os.path.join(os.environ['DARC_KEYS_DIR'], 'keyfile'), 'w') as fd:
fd.write(self.keyfile2_key_file)
os.environ['DARC_PASSPHRASE'] = 'passphrase'
key = KeyfileKey.detect(self.MockRepository(), self.keyfile2_cdata)
self.assertEqual(key.decrypt(self.keyfile2_id, self.keyfile2_cdata), b'payload')
def test_passphrase(self):
os.environ['DARC_PASSPHRASE'] = 'test'
key = PassphraseKey.create(self.MockRepository(), None)
self.assertEqual(bytes_to_long(key.enc_cipher.iv, 8), 0)
self.assertEqual(hexlify(key.id_key), b'793b0717f9d8fb01c751a487e9b827897ceea62409870600013fbc6b4d8d7ca6')
self.assertEqual(hexlify(key.enc_hmac_key), b'b885a05d329a086627412a6142aaeb9f6c54ab7950f996dd65587251f6bc0901')
self.assertEqual(hexlify(key.enc_key), b'2ff3654c6daf7381dbbe718d2b20b4f1ea1e34caa6cc65f6bb3ac376b93fed2a')
self.assertEqual(key.chunk_seed, -775740477)
manifest = key.encrypt(b'')
iv = key.extract_iv(manifest)
key2 = PassphraseKey.detect(self.MockRepository(), manifest)
self.assertEqual(bytes_to_long(key2.enc_cipher.iv, 8), iv + 1000)
self.assertEqual(key.id_key, key2.id_key)
self.assertEqual(key.enc_hmac_key, key2.enc_hmac_key)
self.assertEqual(key.enc_key, key2.enc_key)
self.assertEqual(key.chunk_seed, key2.chunk_seed)
data = b'foo'
self.assertEqual(hexlify(key.id_hash(data)), b'818217cf07d37efad3860766dcdf1d21e401650fed2d76ed1d797d3aae925990')
self.assertEqual(data, key2.decrypt(key2.id_hash(data), key.encrypt(data)))
def suite():
return unittest.TestLoader().loadTestsFromTestCase(KeyTestCase)
if __name__ == '__main__':
unittest.main()