curtin-dev team mailing list archive
-
curtin-dev team
-
Mailing list archive
-
Message #03364
[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
-
[Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: mp+461402, 2024-02-29
-
Re: [Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Server Team CI bot, 2024-02-29
-
Re: [Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Dan Bungert, 2024-02-29
-
Re: [Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Olivier Gayot, 2024-02-29
-
Re: [Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Michael Hudson-Doyle, 2024-02-29
-
Re: [Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Server Team CI bot, 2024-02-28
-
[Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Dan Bungert, 2024-02-28
-
Re: [Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Dan Bungert, 2024-02-28
-
Re: [Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Olivier Gayot, 2024-02-28
-
[Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Dan Bungert, 2024-02-28
-
Re: [Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Server Team CI bot, 2024-02-28
-
Re: [Merge] ~dbungert/curtin:zpool-keystore into curtin:master
From: Server Team CI bot, 2024-02-28