Unverified Commit 942588c6 authored by WilliButz's avatar WilliButz
Browse files

nixos/repart-verity-store: init

This module provides some abstraction for a multi-stage build to create
a dm-verity protected NixOS repart image.

The opinionated approach realized by this module is to first create an
immutable, verity-protected nix store partition, then embed the root
hash of the corresponding verity hash partition in a UKI, that is then
injected into the ESP of the resulting image.
The UKI can then precisely identify the corresponding data from which
the entire system is bootstrapped.

The module comes with a script that checks the UKI used in the final
image corresponds to the intermediate image created in the first step.
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.
parent 5ee6467b
Loading
Loading
Loading
Loading
+78 −0
Original line number Diff line number Diff line
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()
+209 −0
Original line number Diff line number Diff line
# 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
  ];
}
+4 −0
Original line number Diff line number Diff line
@@ -69,6 +69,10 @@ let
  }) opts;
in
{
  imports = [
    ./repart-verity-store.nix
  ];

  options.image.repart = {

    name = lib.mkOption {