From fcb45b439ba039fd88c332fd912949d52cfe290f Mon Sep 17 00:00:00 2001
From: Eric Harney <eharney@redhat.com>
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 <lyarwood@redhat.com>
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.