← Back to team overview

curtin-dev team mailing list archive

[Merge] ~dbungert/curtin:zpool-keystore into curtin:master

 

Dan Bungert has proposed merging ~dbungert/curtin:zpool-keystore into curtin:master.

Commit message:
zpool: create keystore encrypted setups

Add support for the 'luks_keystore' style encrypted ZFS install.  Native
ZFS encryption is supported by a small dm_crypt dataset containing the
real key.  This is a functional transcription of the way that Ubiquity
has implemented encrypted guided ZFS.



Requested reviews:
  curtin developers (curtin-dev)

For more details, see:
https://code.launchpad.net/~dbungert/curtin/+git/curtin/+merge/461402
-- 
Your team curtin developers is requested to review the proposed merge of ~dbungert/curtin:zpool-keystore into curtin:master.
diff --git a/curtin/block/zfs.py b/curtin/block/zfs.py
index 01ba9b9..99973d5 100644
--- a/curtin/block/zfs.py
+++ b/curtin/block/zfs.py
@@ -5,6 +5,9 @@ Wrap calls to the zfsutils-linux package (zpool, zfs) for creating zpools
 and volumes."""
 
 import os
+import tempfile
+import shutil
+from pathlib import Path
 
 from curtin.config import merge_config
 from curtin import distro
@@ -26,6 +29,97 @@ ZFS_UNSUPPORTED_ARCHES = ['i386']
 ZFS_UNSUPPORTED_RELEASES = ['precise', 'trusty']
 
 
+class ZPoolEncryption:
+    def __init__(self, poolname, style, keyfile):
+        self.poolname = poolname
+        self.style = style
+        self.keyfile = keyfile
+        self.system_key = None
+
+    def get_system_key(self):
+        if self.system_key is None:
+            fd, self.system_key = tempfile.mkstemp()
+            with open(fd, "wb") as writer:
+                with open("/dev/urandom", "rb") as reader:
+                    writer.write(reader.read(32))
+        return self.system_key
+
+    def validate(self):
+        if self.style is None:
+            return
+
+        if not self.poolname:
+            raise ValueError("valid pool name required")
+
+        if self.style != "luks_keystore":
+            raise ValueError(f"unrecognized encryption style {self.style}")
+
+        if not self.keyfile:
+            raise ValueError(f"keyfile required when using {self.style}")
+
+        if not Path(self.keyfile).is_file():
+            raise ValueError(f"invalid keyfile path {self.keyfile}")
+
+    def in_use(self):
+        return self.style is not None
+
+    def dataset_properties(self):
+        if not self.in_use():
+            return {}
+
+        ks_system_key = self.get_system_key()
+        return {
+            "encryption": "on",
+            "keylocation": f"file://{ks_system_key}",
+            "keyformat": "raw",
+            # as we'll be formatting this as another fs, ext4,
+            # mounting before the ext4 is no help
+            "canmount": "off",
+        }
+
+    def setup(self, storage_config, context):
+        if not self.in_use():
+            return
+
+        # Create the dataset for the keystore.  This is a bit special as it
+        # won't be ZFS despite being on the zpool.
+        zfs_create(
+            self.poolname, "keystore", {"encryption": "off"}, size=20 << 20,
+        )
+
+        # cryptsetup format and open this keystore
+        keystore_volume = f"/dev/zvol/{self.poolname}/keystore"
+        cmd = ["cryptsetup", "luksFormat", keystore_volume, self.keyfile]
+        util.subp(cmd)
+        dm_name = f"keystore-{self.poolname}"
+        cmd = [
+            "cryptsetup", "open", "--type", "luks", keystore_volume, dm_name,
+            "--key-file", self.keyfile,
+        ]
+        util.subp(cmd)
+
+        # format as ext4, mount it, move the previously-generated system key
+        dmpath = f"/dev/mapper/{dm_name}"
+        cmd = ["mke2fs", "-t", "ext4", dmpath, "-L", dm_name]
+        util.subp(cmd, capture=True)
+
+        keystore_root = f"/run/keystore/{self.poolname}"
+        with util.mount(dmpath, keystore_root):
+            ks_system_key = f"{keystore_root}/system.key"
+            shutil.move(self.system_key, ks_system_key)
+            Path(ks_system_key).chmod(0o400)
+
+            # update the pool with the real keylocation
+            keylocation = f"keylocation=file://{ks_system_key}"
+            cmd = ["zfs", "set", keylocation, self.poolname]
+            util.subp(cmd, capture=True)
+
+        # The keystore crypsetup device needs to be closed so we can (at the
+        # end of the install) allow the zpool to complete the export step.
+        # Once we have moved the key over we can close the keystore.
+        util.subp(["cryptsetup", "close", dmpath], capture=True)
+
+
 def _join_flags(optflag, params):
     """
     Insert optflag for each param in params and return combined list.
@@ -107,9 +201,11 @@ def zfs_assert_supported():
         raise RuntimeError("Missing zfs utils: %s" % ','.join(missing_progs))
 
 
-def zpool_create(poolname, vdevs, mountpoint=None, altroot=None,
+def zpool_create(poolname, vdevs, storage_config=None, context=None,
+                 mountpoint=None, altroot=None,
                  default_features=True,
-                 pool_properties=None, zfs_properties=None):
+                 pool_properties=None, zfs_properties=None,
+                 encryption_style=None, keyfile=None):
     """
     Create a zpool called <poolname> comprised of devices specified in <vdevs>.
 
@@ -129,6 +225,13 @@ def zpool_create(poolname, vdevs, mountpoint=None, altroot=None,
                            value is None, then ZFS_DEFAULT_PROPERTIES will be
                            used. `key: null` may be use to unset a
                             ZFS_DEFAULT_PROPERTIES value.
+    :param encryption_style: 'luks_keystore' or None.  If luks_keystore,
+                             a Ubiquity-style keystore is created, and the
+                             `keyfile` argument is mandatory.
+    :param keyfile: Use with `encryption_style` to create an encrypted ZFS
+                    install.  The `keyfile` contains the password of the
+                    encryption key.  The target system will prompt for this
+                    password in order to mount the disk.
     :returns: None on success.
     :raises: ValueError: raises exceptions on missing/badd input
     :raises: ProcessExecutionError: raised on unhandled exceptions from
@@ -152,6 +255,11 @@ def zpool_create(poolname, vdevs, mountpoint=None, altroot=None,
     if zfs_properties:
         merge_config(zfs_cfg, zfs_properties)
 
+    encryption = ZPoolEncryption(poolname, encryption_style, keyfile)
+    encryption.validate()
+    if encryption.in_use():
+        merge_config(zfs_cfg, encryption.dataset_properties())
+
     options = _join_flags('-o', pool_cfg)
     options.extend(_join_flags('-O', zfs_cfg))
 
@@ -171,8 +279,11 @@ def zpool_create(poolname, vdevs, mountpoint=None, altroot=None,
     cmd = ["zpool", "set", "cachefile=/etc/zfs/zpool.cache", poolname]
     util.subp(cmd, capture=True)
 
+    if encryption.in_use():
+        encryption.setup(storage_config, context)
+
 
-def zfs_create(poolname, volume, zfs_properties=None):
+def zfs_create(poolname, volume, zfs_properties=None, size=None):
     """
     Create a filesystem dataset within the specified zpool.
 
@@ -184,6 +295,7 @@ def zfs_create(poolname, volume, zfs_properties=None):
                            of the filesystems created under the pool. If
                            value is None then no properties will be set on
                            the filesystem.
+    :param size: integer size in bytes of the dataset.  Not normally required.
     :returns: None
     :raises: ValueError: raises exceptions on missing/bad input.
     :raises: ProcessExecutionError: raised on unhandled exceptions from
@@ -200,6 +312,8 @@ def zfs_create(poolname, volume, zfs_properties=None):
         merge_config(zfs_cfg, zfs_properties)
 
     options = _join_flags('-o', zfs_cfg)
+    if size is not None:
+        options.extend(["-V", str(size)])
 
     cmd = ["zfs", "create"] + options + [_join_pool_volume(poolname, volume)]
     util.subp(cmd, capture=True)
diff --git a/curtin/commands/block_meta.py b/curtin/commands/block_meta.py
index 9fde9c6..71dcc25 100644
--- a/curtin/commands/block_meta.py
+++ b/curtin/commands/block_meta.py
@@ -2001,6 +2001,8 @@ def zpool_handler(info, storage_config, context):
     pool_properties = info.get('pool_properties', {})
     fs_properties = info.get('fs_properties', {})
     default_features = info.get('default_features', True)
+    encryption_style = info.get('encryption_style', None)
+    keyfile = info.get('keyfile', None)
     altroot = state['target']
 
     if not vdevs or not poolname:
@@ -2020,10 +2022,13 @@ def zpool_handler(info, storage_config, context):
 
     LOG.info('Creating zpool %s with vdevs %s', poolname, vdevs_byid)
     zfs.zpool_create(poolname, vdevs_byid,
+                     storage_config, context,
                      mountpoint=mountpoint, altroot=altroot,
                      default_features=default_features,
                      pool_properties=pool_properties,
-                     zfs_properties=fs_properties)
+                     zfs_properties=fs_properties,
+                     encryption_style=encryption_style,
+                     keyfile=keyfile)
 
 
 def nvme_controller_handler(info, storage_config, context):
diff --git a/doc/topics/storage.rst b/doc/topics/storage.rst
index 7650c4d..60bedb1 100644
--- a/doc/topics/storage.rst
+++ b/doc/topics/storage.rst
@@ -1162,6 +1162,27 @@ set is used.
     - sda1
    mountpoint: /
 
+**encryption_style**: *luks_keystore, null*
+
+If set to **luks_keystore**, an encrypted pool is created using a LUKS backed
+keystore system.  When **encryption_style** is not null, **keyfile** is
+required.
+
+This works as follows:
+* A LUKS device is created as a ZFS dataset in the ZPool.
+* The supplied passphase (see **keyfile**) is used to encrypt the LUKS device.
+* The real key for the ZFS dataset is contained in the LUKS device as a simple
+  file.
+* The zpool is decrypted using this simple file inside the encrypted LUKS
+  device.
+
+Default value is **null**, which means the resulting zpool is unencrypted.
+
+**keyfile**: *<keyfile>*
+
+The ``keyfile`` contains the password of the encryption key.  The target
+system will prompt for this password in order to mount the zpool.
+
 ZFS Command
 ~~~~~~~~~~~~~~
 ZFS Support is **experimental**.
diff --git a/tests/integration/test_block_meta.py b/tests/integration/test_block_meta.py
index ec9b33c..53e571d 100644
--- a/tests/integration/test_block_meta.py
+++ b/tests/integration/test_block_meta.py
@@ -8,6 +8,7 @@ import os
 from parameterized import parameterized
 from pathlib import Path
 import re
+import stat
 import sys
 from typing import Optional
 from unittest import skipIf
@@ -1328,6 +1329,43 @@ table-length: 256'''.encode()
         self.assertEqual("/dev/urandom", tokens[2])
         self.assertEqual("swap,initramfs", tokens[3])
 
+    def test_zfs_luks_keystore(self):
+        self.img = self.tmp_path('image.img')
+        keyfile = self.tmp_path('zfs-luks-keystore-keyfile')
+        with open(keyfile, "w") as fp:
+            fp.write(self.random_string())
+        config = StorageConfigBuilder(version=2)
+        config.add_image(path=self.img, create=True, size='200M', ptable='gpt')
+        p1 = config.add_part(number=1, offset=1 << 20, size=198 << 20)
+        poolname = self.random_string()
+        config._add(
+            type='zpool',
+            pool=poolname,
+            vdevs=[p1["id"]],
+            mountpoint="/",
+            pool_properties=dict(ashift=12, autotrim="on", version=None),
+            encryption_style="luks_keystore",
+            keyfile=keyfile,
+        )
+        self.run_bm(config.render())
+
+        keystore_volume = f"/dev/zvol/{poolname}/keystore"
+        dm_name = f"keystore-{poolname}"
+        dmpath = f"/dev/mapper/{dm_name}"
+        util.subp([
+            "cryptsetup", "open", "--type", "luks", keystore_volume,
+            dm_name, "--key-file", keyfile,
+        ])
+        mntdir = self.tmp_dir()
+        try:
+            with util.mount(dmpath, mntdir):
+                system_key = Path(mntdir) / "system.key"
+                st_mode = system_key.stat().st_mode
+                self.assertEqual(0o400, stat.S_IMODE(st_mode))
+                self.assertTrue(stat.S_ISREG(st_mode))
+        finally:
+            util.subp(["cryptsetup", "close", dmpath])
+
     @parameterized.expand(((1,), (2,)))
     def test_msftres(self, sv):
         self.img = self.tmp_path('image.img')
diff --git a/tests/unittests/test_block_zfs.py b/tests/unittests/test_block_zfs.py
index 45c4855..a66704c 100644
--- a/tests/unittests/test_block_zfs.py
+++ b/tests/unittests/test_block_zfs.py
@@ -604,4 +604,66 @@ class TestZfsGetPoolFromConfig(CiTestCase):
         self.assertEqual(['rpool'], zfs.get_zpool_from_config(sconfig))
 
 
+class TestZfsKeystore(CiTestCase):
+    def setUp(self):
+        self.keyfile = self.tmp_path("zfs-keystore")
+        with open(self.keyfile, "w") as fp:
+            fp.write(self.random_string())
+
+    def test_create_system_key(self):
+        key = self.random_string()
+        m_open = writer = mock.mock_open()
+        reader = mock.mock_open(read_data=key)
+        m_open.side_effect = [writer.return_value, reader.return_value]
+        with mock.patch("builtins.open", m_open):
+            zpe = zfs.ZPoolEncryption(None, None, None)
+            system_key = zpe.get_system_key()
+            self.assertIsNotNone(system_key)
+            self.assertEqual(system_key, zpe.get_system_key())
+        writer.return_value.write.assert_called_with(key)
+
+    def test_validate_good(self):
+        zpe = zfs.ZPoolEncryption("pool1", "luks_keystore", self.keyfile)
+        try:
+            zpe.validate()
+        except Exception:
+            self.fail("ZPoolEncryption validation failure")
+        self.assertTrue(zpe.in_use())
+
+    def test_validate_missing_pool(self):
+        zpe = zfs.ZPoolEncryption(None, "luks_keystore", self.keyfile)
+        with self.assertRaises(ValueError):
+            zpe.validate()
+
+    def test_validate_missing_key(self):
+        zpe = zfs.ZPoolEncryption("pool1", "luks_keystore", None)
+        with self.assertRaises(ValueError):
+            zpe.validate()
+
+    def test_validate_missing_key_file(self):
+        zpe = zfs.ZPoolEncryption("pool1", "luks_keystore", "not-exist")
+        with self.assertRaises(ValueError):
+            zpe.validate()
+
+    def test_validate_unencrypted_ok(self):
+        zpe = zfs.ZPoolEncryption("pool1", None, None)
+        try:
+            zpe.validate()
+        except Exception:
+            self.fail("ZPoolEncryption validation failure")
+        self.assertFalse(zpe.in_use())
+
+    def test_dataset_properties(self):
+        zpe = zfs.ZPoolEncryption("pool1", "luks_keystore", self.keyfile)
+        keyloc = self.random_string()
+        with mock.patch.object(zpe, "get_system_key", return_value=keyloc):
+            props = zpe.dataset_properties()
+        expected = {
+            "encryption": "on",
+            "keylocation": f"file://{keyloc}",
+            "keyformat": "raw",
+            "canmount": "off",
+        }
+        self.assertEqual(expected, props)
+
 # vi: ts=4 expandtab syntax=python
diff --git a/tests/unittests/test_commands_block_meta.py b/tests/unittests/test_commands_block_meta.py
index 5adb46c..5d0cc09 100644
--- a/tests/unittests/test_commands_block_meta.py
+++ b/tests/unittests/test_commands_block_meta.py
@@ -931,11 +931,15 @@ class TestZpoolHandler(CiTestCase):
         block_meta.zpool_handler(info, storage_config, empty_context)
         m_zfs.zpool_create.assert_called_with(
             info['pool'], [disk_path],
+            storage_config,
+            empty_context,
             mountpoint="/",
             altroot="mytarget",
             default_features=True,
             pool_properties={'ashift': 42},
-            zfs_properties={'compression': 'lz4'})
+            zfs_properties={'compression': 'lz4'},
+            encryption_style=None,
+            keyfile=None)
 
 
 class TestZFSRootUpdates(CiTestCase):

Follow ups