add support for building fully dm-verity protected images with systemd-repart (#343252)

This commit is contained in:
Ryan Lahfa 2024-09-20 19:59:50 +02:00 committed by GitHub
commit fed418aaf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 487 additions and 0 deletions

View File

@ -46,6 +46,9 @@
If you experience any issues, please report them.
The original Perl script can still be used for now by setting `system.switch.enableNg` to `false`.
- Support for mounting filesystems from block devices protected with [dm-verity](https://docs.kernel.org/admin-guide/device-mapper/verity.html)
was added through the `boot.initrd.systemd.dmVerity` option.
- The [Xen Hypervisor](https://xenproject.org) is once again available as a virtualisation option under [`virtualisation.xen`](#opt-virtualisation.xen.enable).
- This release includes Xen [4.17.5](https://wiki.xenproject.org/wiki/Xen_Project_4.17_Release_Notes), [4.18.3](https://wiki.xenproject.org/wiki/Xen_Project_4.18_Release_Notes) and [4.19.0](https://wiki.xenproject.org/wiki/Xen_Project_4.19_Release_Notes), as well as support for booting the hypervisor on EFI systems.
::: {.warning}

View File

@ -0,0 +1,78 @@
import json
import sys
store_verity_type = "@NIX_STORE_VERITY@" # replaced at import by Nix
def extract_uki_cmdline_params(ukify_json: dict) -> dict[str, str]:
"""
Return a dict of the parameters in the .cmdline section of the UKI
Exits early if "usrhash" is not included.
"""
cmdline = ukify_json.get(".cmdline", {}).get("text")
if cmdline is None:
print("Failed to get cmdline from ukify output")
params = {}
for param in cmdline.split():
key, val = param.partition("=")[::2]
params[key] = val
if "usrhash" not in params:
print(
f"UKI cmdline does not contain a usrhash:\n{cmdline}"
)
exit(1)
return params
def hashes_match(partition: dict[str, str], expected: str) -> bool:
"""
Checks if the value of the "roothash" key in the passed partition object matches `expected`.
"""
if partition.get("roothash") != expected:
pretty_part = json.dumps(partition, indent=2)
print(
f"hash mismatch, expected to find roothash {expected} in:\n{pretty_part}"
)
return False
else:
return True
def check_partitions(
partitions: list[dict], uki_params: dict[str, str]
) -> bool:
"""
Checks if the usrhash from `uki_params` has a matching roothash
for the corresponding partition in `partitions`.
"""
for part in partitions:
if part.get("type") == store_verity_type:
expected = uki_params["usrhash"]
return hashes_match(part, expected)
return False
def main() -> None:
ukify_json = json.load(sys.stdin)
repart_json_output = sys.argv[1]
with open(repart_json_output, "r") as r:
repart_json = json.load(r)
uki_params = extract_uki_cmdline_params(ukify_json)
if check_partitions(repart_json, uki_params):
print("UKI and repart verity hashes match")
else:
print("Compatibility check for UKI and image failed!")
print(f"UKI cmdline parameters:\n{uki_params}")
print(f"repart config: {repart_json_output}")
exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,209 @@
# opinionated module that can be used to build nixos images with
# a dm-verity protected nix store
{
config,
pkgs,
lib,
...
}:
let
cfg = config.image.repart.verityStore;
verityMatchKey = "store";
# TODO: make these and other arch mappings available from systemd-lib for example
partitionTypes = {
usr =
{
"x86_64" = "usr-x86-64";
"arm64" = "usr-arm64";
}
."${pkgs.stdenv.hostPlatform.linuxArch}";
usr-verity =
{
"x86_64" = "usr-x86-64-verity";
"arm64" = "usr-arm64-verity";
}
."${pkgs.stdenv.hostPlatform.linuxArch}";
};
verityHashCheck =
pkgs.buildPackages.writers.writePython3Bin "assert_uki_repart_match.py"
{
flakeIgnore = [ "E501" ]; # ignores PEP8's line length limit of 79 (black defaults to 88 characters)
}
(
builtins.replaceStrings [ "@NIX_STORE_VERITY@" ] [
partitionTypes.usr-verity
] (builtins.readFile ./assert_uki_repart_match.py)
);
in
{
options.image.repart.verityStore = {
enable = lib.mkEnableOption "building images with a dm-verity protected nix store";
ukiPath = lib.mkOption {
type = lib.types.str;
default = "/EFI/Linux/${config.system.boot.loader.ukiFile}";
defaultText = "/EFI/Linux/\${config.system.boot.loader.ukiFile}";
description = ''
Specify the location on the ESP where the UKI is placed.
'';
};
partitionIds = {
esp = lib.mkOption {
type = lib.types.str;
default = "00-esp";
description = ''
Specify the attribute name of the ESP.
'';
};
store-verity = lib.mkOption {
type = lib.types.str;
default = "10-store-verity";
description = ''
Specify the attribute name of the store's dm-verity hash partition.
'';
};
store = lib.mkOption {
type = lib.types.str;
default = "20-store";
description = ''
Specify the attribute name of the store partition.
'';
};
};
};
config = lib.mkIf cfg.enable {
boot.initrd.systemd.dmVerity.enable = true;
image.repart.partitions = {
# dm-verity hash partition
${cfg.partitionIds.store-verity}.repartConfig = {
Type = partitionTypes.usr-verity;
Verity = "hash";
VerityMatchKey = lib.mkDefault verityMatchKey;
Label = lib.mkDefault "store-verity";
};
# dm-verity data partition that contains the nix store
${cfg.partitionIds.store} = {
storePaths = [ config.system.build.toplevel ];
repartConfig = {
Type = partitionTypes.usr;
Verity = "data";
Format = lib.mkDefault "erofs";
VerityMatchKey = lib.mkDefault verityMatchKey;
Label = lib.mkDefault "store";
};
};
};
system.build = {
# intermediate system image without ESP
intermediateImage =
(config.system.build.image.override {
# always disable compression for the intermediate image
compression.enable = false;
}).overrideAttrs
(
_: previousAttrs: {
# make it easier to identify the intermediate image in build logs
pname = "${previousAttrs.pname}-intermediate";
# do not prepare the ESP, this is done in the final image
systemdRepartFlags = previousAttrs.systemdRepartFlags ++ [ "--defer-partitions=esp" ];
# the image will be self-contained so we can drop references
# to the closure that was used to build it
unsafeDiscardReferences.out = true;
}
);
# UKI with embedded usrhash from intermediateImage
uki =
let
inherit (config.system.boot.loader) ukiFile;
cmdline = "init=${config.system.build.toplevel}/init ${toString config.boot.kernelParams}";
in
# override the default UKI
lib.mkOverride 99 (
pkgs.runCommand ukiFile
{
nativeBuildInputs = [
pkgs.jq
pkgs.systemdUkify
];
}
''
mkdir -p $out
# Extract the usrhash from the output of the systemd-repart invocation for the intermediate image.
usrhash=$(jq -r \
'.[] | select(.type=="${partitionTypes.usr-verity}") | .roothash' \
${config.system.build.intermediateImage}/repart-output.json
)
# Build UKI with the embedded usrhash.
ukify build \
--config=${config.boot.uki.configFile} \
--cmdline="${cmdline} usrhash=$usrhash" \
--output="$out/${ukiFile}"
''
);
# final system image that is created from the intermediate image by injecting the UKI from above
finalImage =
(config.system.build.image.override {
# continue building with existing intermediate image
createEmpty = false;
}).overrideAttrs
(
finalAttrs: previousAttrs:
let
copyUki = "CopyFiles=${config.system.build.uki}/${config.system.boot.loader.ukiFile}:${cfg.ukiPath}";
in
{
nativeBuildInputs = previousAttrs.nativeBuildInputs ++ [
pkgs.systemdUkify
verityHashCheck
];
postPatch = ''
# add entry to inject UKI into ESP
echo '${copyUki}' >> $finalRepartDefinitions/${cfg.partitionIds.esp}.conf
'';
preBuild = ''
# check that we build the final image with the same intermediate image for
# which the injected UKI was built by comparing the UKI cmdline with the repart output
# of the intermediate image
#
# This is necessary to notice incompatible substitutions of
# non-reproducible store paths, for example when working with distributed
# builds, or when offline-signing the UKI.
ukify --json=short inspect ${config.system.build.uki}/${config.system.boot.loader.ukiFile} \
| assert_uki_repart_match.py "${config.system.build.intermediateImage}/repart-output.json"
# copy the uncompressed intermediate image, so that systemd-repart picks it up
cp -v ${config.system.build.intermediateImage}/${config.image.repart.imageFileBasename}.raw .
chmod +w ${config.image.repart.imageFileBasename}.raw
'';
# the image will be self-contained so we can drop references
# to the closure that was used to build it
unsafeDiscardReferences.out = true;
}
);
};
};
meta.maintainers = with lib.maintainers; [
nikstur
willibutz
];
}

View File

@ -69,6 +69,10 @@ let
}) opts;
in
{
imports = [
./repart-verity-store.nix
];
options.image.repart = {
name = lib.mkOption {

View File

@ -1625,6 +1625,7 @@
./system/boot/stage-2.nix
./system/boot/systemd.nix
./system/boot/systemd/coredump.nix
./system/boot/systemd/dm-verity.nix
./system/boot/systemd/initrd-secrets.nix
./system/boot/systemd/initrd.nix
./system/boot/systemd/journald.nix

View File

@ -0,0 +1,61 @@
{ config, lib, ... }:
let
cfg = config.boot.initrd.systemd.dmVerity;
in
{
options = {
boot.initrd.systemd.dmVerity = {
enable = lib.mkEnableOption "dm-verity" // {
description = ''
Mount verity-protected block devices in the initrd.
Enabling this option allows to use `systemd-veritysetup` and
`systemd-veritysetup-generator` in the initrd.
'';
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = config.boot.initrd.systemd.enable;
message = ''
'boot.initrd.systemd.dmVerity.enable' requires 'boot.initrd.systemd.enable' to be enabled.
'';
}
];
boot.initrd = {
availableKernelModules = [
"dm_mod"
"dm_verity"
];
# dm-verity needs additional udev rules from LVM to work.
services.lvm.enable = true;
# The additional targets and store paths allow users to integrate verity-protected devices
# through the systemd tooling.
systemd = {
additionalUpstreamUnits = [
"veritysetup-pre.target"
"veritysetup.target"
"remote-veritysetup.target"
];
storePaths = [
"${config.boot.initrd.systemd.package}/lib/systemd/systemd-veritysetup"
"${config.boot.initrd.systemd.package}/lib/systemd/system-generators/systemd-veritysetup-generator"
];
};
};
};
meta.maintainers = with lib.maintainers; [
msanft
nikstur
willibutz
];
}

View File

@ -128,6 +128,7 @@ in {
apcupsd = handleTest ./apcupsd.nix {};
apfs = runTest ./apfs.nix;
appliance-repart-image = runTest ./appliance-repart-image.nix;
appliance-repart-image-verity-store = runTest ./appliance-repart-image-verity-store.nix;
apparmor = handleTest ./apparmor.nix {};
archi = handleTest ./archi.nix {};
aria2 = handleTest ./aria2.nix {};

View File

@ -0,0 +1,130 @@
# similar to the appliance-repart-image test but with a dm-verity
# protected nix store and tmpfs as rootfs
{ lib, ... }:
{
name = "appliance-repart-image-verity-store";
meta.maintainers = with lib.maintainers; [
nikstur
willibutz
];
nodes.machine =
{
config,
lib,
pkgs,
...
}:
let
inherit (config.image.repart.verityStore) partitionIds;
in
{
imports = [ ../modules/image/repart.nix ];
virtualisation.fileSystems = lib.mkVMOverride {
"/" = {
fsType = "tmpfs";
options = [ "mode=0755" ];
};
"/usr" = {
device = "/dev/mapper/usr";
# explicitly mount it read-only otherwise systemd-remount-fs will fail
options = [ "ro" ];
fsType = config.image.repart.partitions.${partitionIds.store}.repartConfig.Format;
};
# bind-mount the store
"/nix/store" = {
device = "/usr/nix/store";
options = [ "bind" ];
};
};
image.repart = {
verityStore = {
enable = true;
# by default the module works with systemd-boot, for simplicity this test directly boots the UKI
ukiPath = "/EFI/BOOT/BOOT${lib.toUpper config.nixpkgs.hostPlatform.efiArch}.EFI";
};
name = "appliance-verity-store-image";
partitions = {
${partitionIds.esp} = {
# the UKI is injected into this partition by the verityStore module
repartConfig = {
Type = "esp";
Format = "vfat";
SizeMinBytes = if config.nixpkgs.hostPlatform.isx86_64 then "64M" else "96M";
};
};
${partitionIds.store-verity}.repartConfig = {
Minimize = "best";
};
${partitionIds.store}.repartConfig = {
Minimize = "best";
};
};
};
virtualisation = {
directBoot.enable = false;
mountHostNixStore = false;
useEFIBoot = true;
};
boot = {
loader.grub.enable = false;
initrd.systemd.enable = true;
};
system.image = {
id = "nixos-appliance";
version = "1";
};
# don't create /usr/bin/env
# this would require some extra work on read-only /usr
# and it is not a strict necessity
system.activationScripts.usrbinenv = lib.mkForce "";
};
testScript =
{ nodes, ... }: # python
''
import os
import subprocess
import tempfile
tmp_disk_image = tempfile.NamedTemporaryFile()
subprocess.run([
"${nodes.machine.virtualisation.qemu.package}/bin/qemu-img",
"create",
"-f",
"qcow2",
"-b",
"${nodes.machine.system.build.finalImage}/${nodes.machine.image.repart.imageFile}",
"-F",
"raw",
tmp_disk_image.name,
])
os.environ['NIX_DISK_IMAGE'] = tmp_disk_image.name
machine.wait_for_unit("default.target")
with subtest("Running with volatile root"):
machine.succeed("findmnt --kernel --type tmpfs /")
with subtest("/nix/store is backed by dm-verity protected fs"):
verity_info = machine.succeed("dmsetup info --target verity usr")
assert "ACTIVE" in verity_info,f"unexpected verity info: {verity_info}"
backing_device = machine.succeed("df --output=source /nix/store | tail -n1").strip()
assert "/dev/mapper/usr" == backing_device,"unexpected backing device: {backing_device}"
'';
}