From fcb45b439ba039fd88c332fd912949d52cfe290f Mon Sep 17 00:00:00 2001 From: Eric Harney Date: Wed, 17 Jan 2018 20:50:25 -0500 Subject: [PATCH] RBD: Support encrypted volumes When creating an encrypted RBD volume, initialize LUKS on the volume using the volume's encryption key. This is required because os-brick only handles this step for volumes that attach via block devices. This requires qemu-img 2.10. Co-Authored-By: Lee Yarwood Related-Bug: #1463525 Implements: blueprint libvirt-qemu-native-luks Change-Id: Id02130e9af8bdf90a712968916017d05c3213c32 --- cinder/image/image_utils.py | 43 ++++++- cinder/tests/unit/test_image_utils.py | 7 ++ cinder/tests/unit/volume/drivers/test_rbd.py | 68 ++++++++-- cinder/volume/drivers/rbd.py | 117 +++++++++++++++++- ...ncrypted-rbd-volumes-35d3536505e6309b.yaml | 8 ++ 5 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 releasenotes/notes/allow-encrypted-rbd-volumes-35d3536505e6309b.yaml diff --git a/cinder/image/image_utils.py b/cinder/image/image_utils.py index dcc256cf49a..3c06537dfd6 100644 --- a/cinder/image/image_utils.py +++ b/cinder/image/image_utils.py @@ -73,6 +73,7 @@ QEMU_IMG_FORMAT_MAP_INV = {v: k for k, v in QEMU_IMG_FORMAT_MAP.items()} QEMU_IMG_VERSION = None QEMU_IMG_MIN_FORCE_SHARE_VERSION = [2, 10, 0] +QEMU_IMG_MIN_CONVERT_LUKS_VERSION = '2.10' def validate_disk_format(disk_format): @@ -140,7 +141,7 @@ def qemu_img_supports_force_share(): def _get_qemu_convert_cmd(src, dest, out_format, src_format=None, out_subformat=None, cache_mode=None, - prefix=None): + prefix=None, cipher_spec=None, passphrase_file=None): if out_format == 'vhd': # qemu-img still uses the legacy vpc name @@ -165,6 +166,18 @@ def _get_qemu_convert_cmd(src, dest, out_format, src_format=None, if (src_format or '').lower() not in ('', 'ami'): cmd += ('-f', src_format) # prevent detection of format + # NOTE(lyarwood): When converting to LUKS add the cipher spec if present + # and create a secret for the passphrase, written to a temp file + if out_format == 'luks': + check_qemu_img_version(QEMU_IMG_MIN_CONVERT_LUKS_VERSION) + if cipher_spec: + cmd += ('-o', 'cipher-alg=%s,cipher-mode=%s,ivgen-alg=%s' % + (cipher_spec['cipher_alg'], cipher_spec['cipher_mode'], + cipher_spec['ivgen_alg'])) + cmd += ('--object', + 'secret,id=luks_sec,format=raw,file=%s' % passphrase_file, + '-o', 'key-secret=luks_sec') + cmd += [src, dest] return cmd @@ -193,7 +206,7 @@ def check_qemu_img_version(minimum_version): def _convert_image(prefix, source, dest, out_format, out_subformat=None, src_format=None, - run_as_root=True): + run_as_root=True, cipher_spec=None, passphrase_file=None): """Convert image to other format.""" # Check whether O_DIRECT is supported and set '-t none' if it is @@ -219,7 +232,9 @@ def _convert_image(prefix, source, dest, out_format, src_format=src_format, out_subformat=out_subformat, cache_mode=cache_mode, - prefix=prefix) + prefix=prefix, + cipher_spec=cipher_spec, + passphrase_file=passphrase_file) start_time = timeutils.utcnow() utils.execute(*cmd, run_as_root=run_as_root) @@ -254,7 +269,8 @@ def _convert_image(prefix, source, dest, out_format, def convert_image(source, dest, out_format, out_subformat=None, - src_format=None, run_as_root=True, throttle=None): + src_format=None, run_as_root=True, throttle=None, + cipher_spec=None, passphrase_file=None): if not throttle: throttle = throttling.Throttle.get_default() with throttle.subcommand(source, dest) as throttle_cmd: @@ -263,7 +279,9 @@ def convert_image(source, dest, out_format, out_subformat=None, out_format, out_subformat=out_subformat, src_format=src_format, - run_as_root=run_as_root) + run_as_root=run_as_root, + cipher_spec=cipher_spec, + passphrase_file=passphrase_file) def resize_image(source, size, run_as_root=False): @@ -699,6 +717,21 @@ def replace_xenserver_image_with_coalesced_vhd(image_file): os.rename(coalesced, image_file) +def decode_cipher(cipher_spec, key_size): + """Decode a dm-crypt style cipher specification string + + The assumed format being cipher[:keycount]-chainmode-ivmode[:ivopts] as + documented under linux/Documentation/device-mapper/dm-crypt.txt in the + kernel source tree. + """ + cipher_alg, cipher_mode, ivgen_alg = cipher_spec.split('-') + cipher_alg = cipher_alg + '-' + str(key_size) + + return {'cipher_alg': cipher_alg, + 'cipher_mode': cipher_mode, + 'ivgen_alg': ivgen_alg} + + class TemporaryImages(object): """Manage temporarily downloaded images to avoid downloading it twice. diff --git a/cinder/tests/unit/test_image_utils.py b/cinder/tests/unit/test_image_utils.py index 94f0a8f34e3..4b5c6efbc9b 100644 --- a/cinder/tests/unit/test_image_utils.py +++ b/cinder/tests/unit/test_image_utils.py @@ -1703,3 +1703,10 @@ class TestImageUtils(test.TestCase): virtual_size, volume_size, image_id) + + def test_decode_cipher(self): + expected = {'cipher_alg': 'aes-256', + 'cipher_mode': 'xts', + 'ivgen_alg': 'essiv'} + result = image_utils.decode_cipher('aes-xts-essiv', 256) + self.assertEqual(expected, result) diff --git a/cinder/tests/unit/volume/drivers/test_rbd.py b/cinder/tests/unit/volume/drivers/test_rbd.py index 1a1bfc64042..2a2bb72195f 100644 --- a/cinder/tests/unit/volume/drivers/test_rbd.py +++ b/cinder/tests/unit/volume/drivers/test_rbd.py @@ -14,11 +14,12 @@ # License for the specific language governing permissions and limitations # under the License. -import ddt import math import os import tempfile +import castellan +import ddt import mock from mock import call from oslo_utils import imageutils @@ -34,6 +35,7 @@ from cinder import test from cinder.tests.unit import fake_constants as fake from cinder.tests.unit import fake_snapshot from cinder.tests.unit import fake_volume +from cinder.tests.unit.keymgr import fake as fake_keymgr from cinder.tests.unit import utils from cinder.tests.unit.volume import test_driver from cinder.volume import configuration as conf @@ -65,6 +67,11 @@ class MockImageExistsException(MockException): """Used as mock for rbd.ImageExists.""" +class KeyObject(object): + def get_encoded(arg): + return "asdf".encode('utf-8') + + def common_mocks(f): """Decorator to set mocks common to all tests. @@ -185,6 +192,13 @@ class RBDTestCase(test.TestCase): 'id': '0c7d1f44-5a06-403f-bb82-ae7ad0d693a6', 'size': 10}) + self.volume_c = fake_volume.fake_volume_obj( + self.context, + **{'name': u'volume-0000000a', + 'id': '55555555-222f-4b32-b585-9991b3bf0a99', + 'size': 12, + 'encryption_key_id': 'set_in_test'}) + self.snapshot = fake_snapshot.fake_snapshot_obj( self.context, name='snapshot-0000000a') @@ -459,14 +473,6 @@ class RBDTestCase(test.TestCase): client.__enter__.assert_called_once_with() client.__exit__.assert_called_once_with(None, None, None) - @common_mocks - def test_create_encrypted_volume(self): - self.volume_a.encryption_key_id = \ - '00000000-0000-0000-0000-000000000000' - self.assertRaises(exception.VolumeDriverException, - self.driver.create_volume, - self.volume_a) - @common_mocks def test_manage_existing_get_size(self): with mock.patch.object(self.driver.rbd.Image(), 'size') as \ @@ -2023,6 +2029,50 @@ class RBDTestCase(test.TestCase): mock_delete.assert_called_once_with(self.volume_a) self.assertEqual((True, None), ret) + @mock.patch('tempfile.NamedTemporaryFile') + @mock.patch('cinder.volume.drivers.rbd.RBDDriver.' + '_check_encryption_provider', + return_value={'encryption_key_id': fake.ENCRYPTION_KEY_ID}) + def test_create_encrypted_volume(self, + mock_check_enc_prov, + mock_temp_file): + class DictObj(object): + # convert a dict to object w/ attributes + def __init__(self, d): + self.__dict__ = d + + mock_temp_file.return_value.__enter__.side_effect = [ + DictObj({'name': '/imgfile'}), + DictObj({'name': '/passfile'})] + + key_mgr = fake_keymgr.fake_api() + + self.mock_object(castellan.key_manager, 'API', return_value=key_mgr) + key_id = key_mgr.store(self.context, KeyObject()) + self.volume_c.encryption_key_id = key_id + + enc_info = {'encryption_key_id': key_id, + 'cipher': 'aes-xts-essiv', + 'key_size': 256} + + with mock.patch('cinder.volume.drivers.rbd.RBDDriver.' + '_check_encryption_provider', return_value=enc_info), \ + mock.patch('cinder.volume.drivers.rbd.open') as mock_open, \ + mock.patch.object(self.driver, '_execute') as mock_exec: + self.driver._create_encrypted_volume(self.volume_c, + self.context) + mock_open.assert_called_with('/passfile', 'w') + + mock_exec.assert_any_call( + 'qemu-img', 'create', '-f', 'luks', '-o', + 'cipher-alg=aes-256,cipher-mode=xts,ivgen-alg=essiv', + '--object', + 'secret,id=luks_sec,format=raw,file=/passfile', + '-o', 'key-secret=luks_sec', '/imgfile', '12288M') + mock_exec.assert_any_call( + 'rbd', 'import', '--pool', 'rbd', '--order', 22, + '/imgfile', self.volume_c.name) + class ManagedRBDTestCase(test_driver.BaseDriverTestCase): driver_name = "cinder.volume.drivers.rbd.RBDDriver" diff --git a/cinder/volume/drivers/rbd.py b/cinder/volume/drivers/rbd.py index 8f64dcffcfc..34dd8c6e123 100644 --- a/cinder/volume/drivers/rbd.py +++ b/cinder/volume/drivers/rbd.py @@ -14,12 +14,15 @@ """RADOS Block Device Driver""" from __future__ import absolute_import +import binascii import json import math import os import tempfile +from castellan import key_manager from eventlet import tpool +from os_brick import encryptors from os_brick.initiator import linuxrbd from oslo_config import cfg from oslo_log import log as logging @@ -681,12 +684,81 @@ class RBDDriver(driver.CloneableImageVD, return {'replication_status': fields.ReplicationStatus.DISABLED} return None + def _check_encryption_provider(self, volume, context): + """Check that this is a LUKS encryption provider. + + :returns: encryption dict + """ + + encryption = self.db.volume_encryption_metadata_get(context, volume.id) + provider = encryption['provider'] + if provider in encryptors.LEGACY_PROVIDER_CLASS_TO_FORMAT_MAP: + provider = encryptors.LEGACY_PROVIDER_CLASS_TO_FORMAT_MAP[provider] + if provider != encryptors.LUKS: + message = _("Provider %s not supported.") % provider + raise exception.VolumeDriverException(message=message) + + if 'cipher' not in encryption or 'key_size' not in encryption: + msg = _('encryption spec must contain "cipher" and' + '"key_size"') + raise exception.VolumeDriverException(message=msg) + + return encryption + + def _create_encrypted_volume(self, volume, context): + """Create an encrypted volume. + + This works by creating an encrypted image locally, + and then uploading it to the volume. + """ + + encryption = self._check_encryption_provider(volume, context) + + # Fetch the key associated with the volume and decode the passphrase + keymgr = key_manager.API(CONF) + key = keymgr.get(context, encryption['encryption_key_id']) + passphrase = binascii.hexlify(key.get_encoded()).decode('utf-8') + + # create a file + tmp_dir = self._image_conversion_dir() + + with tempfile.NamedTemporaryFile(dir=tmp_dir) as tmp_image: + with tempfile.NamedTemporaryFile(dir=tmp_dir) as tmp_key: + with open(tmp_key.name, 'w') as f: + f.write(passphrase) + + cipher_spec = image_utils.decode_cipher(encryption['cipher'], + encryption['key_size']) + + create_cmd = ( + 'qemu-img', 'create', '-f', 'luks', + '-o', 'cipher-alg=%(cipher_alg)s,' + 'cipher-mode=%(cipher_mode)s,' + 'ivgen-alg=%(ivgen_alg)s' % cipher_spec, + '--object', 'secret,id=luks_sec,' + 'format=raw,file=%(passfile)s' % {'passfile': + tmp_key.name}, + '-o', 'key-secret=luks_sec', + tmp_image.name, + '%sM' % (volume.size * 1024)) + self._execute(*create_cmd) + + # Copy image into RBD + chunk_size = self.configuration.rbd_store_chunk_size * units.Mi + order = int(math.log(chunk_size, 2)) + + cmd = ['rbd', 'import', + '--pool', self.configuration.rbd_pool, + '--order', order, + tmp_image.name, volume.name] + cmd.extend(self._ceph_args()) + self._execute(*cmd) + def create_volume(self, volume): """Creates a logical volume.""" if volume.encryption_key_id: - message = _("Encryption is not yet supported.") - raise exception.VolumeDriverException(message=message) + return self._create_encrypted_volume(volume, volume.obj_context) size = int(volume.size) * units.Gi @@ -1262,7 +1334,45 @@ class RBDDriver(driver.CloneableImageVD, return tmpdir + def copy_image_to_encrypted_volume(self, context, volume, image_service, + image_id): + self._copy_image_to_volume(context, volume, image_service, image_id, + encrypted=True) + def copy_image_to_volume(self, context, volume, image_service, image_id): + self._copy_image_to_volume(context, volume, image_service, image_id) + + def _encrypt_image(self, context, volume, tmp_dir, src_image_path): + encryption = self._check_encryption_provider(volume, context) + + # Fetch the key associated with the volume and decode the passphrase + keymgr = key_manager.API(CONF) + key = keymgr.get(context, encryption['encryption_key_id']) + passphrase = binascii.hexlify(key.get_encoded()).decode('utf-8') + + # Decode the dm-crypt style cipher spec into something qemu-img can use + cipher_spec = image_utils.decode_cipher(encryption['cipher'], + encryption['key_size']) + + tmp_dir = self._image_conversion_dir() + + with tempfile.NamedTemporaryFile(prefix='luks_', + dir=tmp_dir) as pass_file: + with open(pass_file.name, 'w') as f: + f.write(passphrase) + + # Convert the raw image to luks + dest_image_path = src_image_path + '.luks' + image_utils.convert_image(src_image_path, dest_image_path, + 'luks', src_format='raw', + cipher_spec=cipher_spec, + passphrase_file=pass_file.name) + + # Replace the original image with the now encrypted image + os.rename(dest_image_path, src_image_path) + + def _copy_image_to_volume(self, context, volume, image_service, image_id, + encrypted=False): tmp_dir = self._image_conversion_dir() @@ -1272,6 +1382,9 @@ class RBDDriver(driver.CloneableImageVD, self.configuration.volume_dd_blocksize, size=volume.size) + if encrypted: + self._encrypt_image(context, volume, tmp_dir, tmp.name) + self.delete_volume(volume) chunk_size = self.configuration.rbd_store_chunk_size * units.Mi diff --git a/releasenotes/notes/allow-encrypted-rbd-volumes-35d3536505e6309b.yaml b/releasenotes/notes/allow-encrypted-rbd-volumes-35d3536505e6309b.yaml new file mode 100644 index 00000000000..74e2c3d9c38 --- /dev/null +++ b/releasenotes/notes/allow-encrypted-rbd-volumes-35d3536505e6309b.yaml @@ -0,0 +1,8 @@ +--- +features: + - | + LUKS Encrypted RBD volumes can now be created by cinder-volume. This + capability was previously blocked by the rbd volume driver due to the lack + of any encryptors capable of attaching to an encrypted RBD volume. These + volumes can also be seeded with RAW image data from Glance through the use + of QEMU 2.10 and the qemu-img convert command.