diff --git a/contrib/extract_text_va.py b/contrib/extract_text_va.py new file mode 100644 index 00000000..cada9f59 --- /dev/null +++ b/contrib/extract_text_va.py @@ -0,0 +1,10 @@ +import pefile +import sys + +target = sys.argv[1] +t_section = sys.argv if len(sys.argv) > 2 else ".text" +pe = pefile.PE(target, fast_load=True) + +for section in pe.sections: + if section.Name.decode('ascii').rstrip('\x00') == t_section: + print(hex(section.VirtualAddress)) diff --git a/flake.lock b/flake.lock index 722f3186..35421eff 100644 --- a/flake.lock +++ b/flake.lock @@ -97,16 +97,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1699354722, - "narHash": "sha256-abmqUReg4PsyQSwv4d0zjcWpMHrd3IFJiTb2tZpfF04=", - "owner": "NixOS", + "lastModified": 1704332549, + "narHash": "sha256-pYW/wU5Te6UsKgjOSbcX/TeH2UU2DfTThA/JMb7iOEU=", + "owner": "RaitoBezarius", "repo": "nixpkgs", - "rev": "cfbb29d76949ae53c457f152c52c173ea4bdd862", + "rev": "5b98cc93390a7c299ce4231f63ad7a5e59295154", "type": "github" }, "original": { - "owner": "NixOS", - "ref": "nixos-unstable-small", + "owner": "RaitoBezarius", + "ref": "initrd-secrets", "repo": "nixpkgs", "type": "github" } diff --git a/flake.nix b/flake.nix index 08652be7..21a54274 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "Secure Boot for NixOS"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small"; + nixpkgs.url = "github:RaitoBezarius/nixpkgs/initrd-secrets"; flake-parts.url = "github:hercules-ci/flake-parts"; flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs"; @@ -70,6 +70,17 @@ } ); + flake.nixosModules.lanzasignd = moduleWithSystem ( + perSystem@{ config }: + { ... }: { + imports = [ + ./nix/modules/lanzasignd.nix + ]; + + services.lanzasignd.package = perSystem.config.packages.lanzasignd; + } + ); + flake.nixosModules.uki = moduleWithSystem ( perSystem@{ config }: { lib, ... }: { @@ -109,6 +120,8 @@ , src , target ? null , doCheck ? true + # By default, it builds the default members of the workspace. + , packages ? null , extraArgs ? { } }: let @@ -136,7 +149,9 @@ #[cfg_attr(any(target_os = "none", target_os = "uefi"), export_name = "efi_main")] fn main() {} ''; - } // extraArgs; + + cargoExtraArgs = (extraArgs.cargoExtraArgs or "") + (if packages != null then (lib.concatStringsSep " " (map (p: "--package ${p}") packages)) else ""); + } // builtins.removeAttrs extraArgs [ "cargoExtraArgs" ]; cargoArtifacts = craneLib.buildDepsOnly commonArgs; in @@ -167,6 +182,14 @@ }; }; + lanzasigndCrane = buildRustApp { + pname = "lanzasignd"; + src = craneLib.cleanCargoSource ./rust/tool; + doCheck = false; + packages = [ "lanzasignd" ]; + }; + + lanzasignd = lanzasigndCrane.package; stub = stubCrane.package; fatStub = fatStubCrane.package; @@ -201,18 +224,22 @@ in { packages = { - inherit stub fatStub; + inherit stub fatStub lanzasignd; tool = wrappedTool; lzbt = wrappedTool; }; overlayAttrs = { - inherit (config.packages) tool; + inherit (config.packages) tool lanzasignd; }; checks = let nixosLib = import (pkgs.path + "/nixos/lib") { }; + lanzaLib = import ./nix/tests/lib.nix { + inherit pkgs; + lanzabooteModule = self.nixosModules.lanzaboote; + }; runTest = module: nixosLib.runTest { imports = [ module ]; hostPkgs = pkgs; @@ -225,11 +252,14 @@ toolFmt = toolCrane.rustfmt; stubFmt = stubCrane.rustfmt; } // (import ./nix/tests/lanzaboote.nix { - inherit pkgs; + inherit pkgs lanzaLib; lanzabooteModule = self.nixosModules.lanzaboote; }) // (import ./nix/tests/stub.nix { inherit pkgs runTest; ukiModule = self.nixosModules.uki; + }) // (import ./nix/tests/remote-signing.nix { + inherit pkgs lanzaLib; + lanzasigndModule = self.nixosModules.lanzasignd; }); pre-commit = { @@ -252,6 +282,40 @@ pkgs.cargo-release pkgs.cargo-machete + # This is a special script to print out all the offsets + # related to OVMF debug binaries. + # To use it, you should obtain a debug log (serial console or the 0x402 port) + # It contains various offsets necessary to relocate all the offsets. + # Then, you need a OVMF tree, you can bring yours or put it in the Nix one. + # Once you are done, you can pipe the result of that script in /tmp/gdb-script or something like that. + # You can source it with gdb, then you should use `set substitute-paths /build/edk2... /nix/store/...edk2/...` + # to rewire the EDK2 source tree to the Nix store. + # Usage: `print-debug-script-for-ovmf $location_of_ovmf_debug_output $location_of_edk2_debug_outputs_in_nix_store > /tmp/gdbscript` + (pkgs.writeScriptBin "print-debug-script-for-ovmf" + ( + let + pePythonEnv = pkgs.python3.withPackages (ps: with ps; [ pefile ]); + in + '' + #!${pkgs.stdenv.shell} + LOG=''${1:-build/debug.log} + BUILD=''${2} + SEARCHPATHS="''${BUILD}" + + cat ''${LOG} | grep Loading | grep -i efi | while read LINE; do + BASE="`echo ''${LINE} | cut -d " " -f4`" + NAME="`echo ''${LINE} | cut -d " " -f6 | tr -d "[:cntrl:]"`" + EFIFILE="`find ''${SEARCHPATHS} -name ''${NAME} -maxdepth 1 -type f`" + ADDR="`${pePythonEnv}/bin/python3 contrib/extract_text_va.py ''${EFIFILE} 2>/dev/null`" + [ ! -z "$ADDR" ] && TEXT="`${pkgs.python3}/bin/python -c "print(hex(''${BASE} + ''${ADDR}))"`" + SYMS="`echo ''${NAME} | sed -e "s/\.efi/\.debug/g"`" + SYMFILE="`find ''${SEARCHPATHS} -name ''${SYMS} -maxdepth 1 -type f`" + [ ! -z "$ADDR" ] && echo "add-symbol-file ''${SYMFILE} ''${TEXT}" + done + '' + ) + ) + # Convenience for test fixtures in nix/tests. pkgs.openssl (pkgs.sbctl.override { databasePath = "pki"; }) diff --git a/nix/modules/lanzaboote.nix b/nix/modules/lanzaboote.nix index d1dd8876..e72c5239 100644 --- a/nix/modules/lanzaboote.nix +++ b/nix/modules/lanzaboote.nix @@ -36,23 +36,33 @@ in ''; }; - pkiBundle = mkOption { - type = types.nullOr types.path; - description = "PKI bundle containing db, PK, KEK"; + localSigning = { + enable = mkEnableOption "local signing" // { default = cfg.pkiBundle != null; }; + publicKeyFile = mkOption { + type = types.path; + default = "${cfg.pkiBundle}/keys/db/db.pem"; + description = "Public key to sign your boot files"; + }; + + privateKeyFile = mkOption { + type = types.path; + default = "${cfg.pkiBundle}/keys/db/db.key"; + description = "Private key to sign your boot files"; + }; }; - publicKeyFile = mkOption { - type = types.path; - default = "${cfg.pkiBundle}/keys/db/db.pem"; - defaultText = "\${cfg.pkiBundle}/keys/db/db.pem"; - description = "Public key to sign your boot files"; + remoteSigning = { + enable = mkEnableOption "remote signing"; + serverUrl = mkOption { + type = types.nullOr types.str; + default = null; + description = "Remote signing server to contact to ask for signatures"; + }; }; - privateKeyFile = mkOption { - type = types.path; - default = "${cfg.pkiBundle}/keys/db/db.key"; - defaultText = "\${cfg.pkiBundle}/keys/db/db.key"; - description = "Private key to sign your boot files"; + pkiBundle = mkOption { + type = types.nullOr types.path; + description = "PKI bundle containing db, PK, KEK"; }; package = mkOption { @@ -103,31 +113,56 @@ in }; config = mkIf cfg.enable { + assertions = [ + { + assertion = !(cfg.localSigning.enable && cfg.remoteSigning.enable); + message = '' + You cannot enable local and remote signing at the same time, pick either of the strategy. + + Did you set `pkiBundle` and forgot to set `localSigning.enable` to false? + ''; + } + ]; boot.bootspec = { enable = true; }; boot.loader.supportsInitrdSecrets = true; boot.loader.external = { enable = true; - installHook = pkgs.writeShellScript "bootinstall" '' - ${optionalString cfg.enrollKeys '' - mkdir -p /tmp/pki - cp -r ${cfg.pkiBundle}/* /tmp/pki - ${sbctlWithPki}/bin/sbctl enroll-keys --yes-this-might-brick-my-machine - ''} - - # Use the system from the kernel's hostPlatform because this should - # always, even in the cross compilation case, be the right system. - ${cfg.package}/bin/lzbt install \ - --system ${config.boot.kernelPackages.stdenv.hostPlatform.system} \ - --systemd ${config.systemd.package} \ - --systemd-boot-loader-config ${loaderConfigFile} \ - --public-key ${cfg.publicKeyFile} \ - --private-key ${cfg.privateKeyFile} \ - --configuration-limit ${toString configurationLimit} \ - ${config.boot.loader.efi.efiSysMountPoint} \ - /nix/var/nix/profiles/system-*-link - ''; + installHook = + let + lzbtArgs = [ + "install" + "--system" + config.boot.kernelPackages.stdenv.hostPlatform.system + "--systemd" + config.systemd.package + "--systemd-boot-loader-config" + loaderConfigFile + ] ++ lib.optionals cfg.localSigning.enable [ + "--public-key" + cfg.localSigning.publicKeyFile + "--private-key" + cfg.localSigning.privateKeyFile + ] ++ lib.optionals cfg.remoteSigning.enable [ + "--remote-signing-server-url" + cfg.remoteSigning.serverUrl + ] ++ [ + "--configuration-limit" + (toString configurationLimit) + config.boot.loader.efi.efiSysMountPoint + "/nix/var/nix/profiles/system-*-link" + ]; + in + pkgs.writeShellScript "bootinstall" '' + ${optionalString cfg.enrollKeys '' + mkdir -p /tmp/pki + cp -r ${cfg.pkiBundle}/* /tmp/pki + ${sbctlWithPki}/bin/sbctl enroll-keys --yes-this-might-brick-my-machine + ''} + + ${cfg.package}/bin/lzbt ${concatStringsSep " " lzbtArgs} + ''; }; systemd.services.fwupd = lib.mkIf config.services.fwupd.enable { diff --git a/nix/modules/lanzasignd.nix b/nix/modules/lanzasignd.nix new file mode 100644 index 00000000..7dc71b6f --- /dev/null +++ b/nix/modules/lanzasignd.nix @@ -0,0 +1,76 @@ +{ lib, config, pkgs, ... }: +let + inherit (lib) mkOption mkEnableOption mkPackageOptionMD types mkIf; + cfg = config.services.lanzasignd; + policyFile = (pkgs.formats.json { }).generate "lanzasignd-policy.json" { + allowedKernelCmdlineItems = cfg.policy.allowedCommandLineItems; + }; +in +{ + options.services.lanzasignd = { + enable = mkEnableOption "lanzasignd, a Secure Boot remote signing server for NixOS"; + + package = mkPackageOptionMD pkgs "lanzasignd" { }; + + port = mkOption { + type = types.port; + default = 9999; + description = "Port to run lanzasignd on"; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Open the firewall for the port lanzasignd is running on"; + }; + + policy = { + allowedCommandLineItems = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + example = [ "quiet" "init=some init script" ]; + }; + }; + + pkiBundle = mkOption { + type = types.nullOr types.path; + description = "PKI bundle containing db, PK, KEK"; + }; + + publicKeyFile = mkOption { + type = types.path; + default = "${cfg.pkiBundle}/keys/db/db.pem"; + description = "Public key to sign your boot files"; + }; + + privateKeyFile = mkOption { + type = types.path; + default = "${cfg.pkiBundle}/keys/db/db.key"; + description = "Private key to sign your boot files"; + }; + + }; + + config = mkIf cfg.enable { + systemd.services.lanzasignd = { + description = "Sign on demand bootables files compatible with Lanzaboote scheme"; + wants = [ "network.target" ]; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig.Type = "simple"; + path = [ + pkgs.binutils + pkgs.sbsigntool + ]; + script = '' + ${cfg.package}/bin/lanzasignd -vvv serve \ + --policy-file ${policyFile} \ + --public-key ${cfg.publicKeyFile} \ + --private-key ${cfg.privateKeyFile} \ + --port ${toString cfg.port} + ''; + }; + + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; + }; +} diff --git a/nix/tests/lanzaboote.nix b/nix/tests/lanzaboote.nix index f6e0ceba..d76029f5 100644 --- a/nix/tests/lanzaboote.nix +++ b/nix/tests/lanzaboote.nix @@ -1,154 +1,11 @@ { pkgs , lanzabooteModule +, lanzaLib }: let - inherit (pkgs) lib system; - defaultTimeout = 5 * 60; # = 5 minutes - - mkSecureBootTest = { name, machine ? { }, useSecureBoot ? true, useTPM2 ? false, readEfiVariables ? false, testScript }: - let - tpmSocketPath = "/tmp/swtpm-sock"; - tpmDeviceModels = { - x86_64-linux = "tpm-tis"; - aarch64-linux = "tpm-tis-device"; - }; - # Should go to nixpkgs. - efiVariablesHelpers = '' - import struct - - SD_LOADER_GUID = "4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" - def read_raw_variable(var: str) -> bytes: - attr_var = machine.succeed(f"cat /sys/firmware/efi/efivars/{var}-{SD_LOADER_GUID}").encode('raw_unicode_escape') - _ = attr_var[:4] # First 4 bytes are attributes according to https://www.kernel.org/doc/html/latest/filesystems/efivarfs.html - value = attr_var[4:] - return value - def read_string_variable(var: str, encoding='utf-16-le') -> str: - return read_raw_variable(var).decode(encoding).rstrip('\x00') - # By default, it will read a 4 byte value, read `struct` docs to change the format. - def assert_variable_uint(var: str, expected: int, format: str = 'I'): - with subtest(f"Is `{var}` set to {expected} (uint)"): - value, = struct.unpack(f'<{format}', read_raw_variable(var)) - assert value == expected, f"Unexpected variable value in `{var}`, expected: `{expected}`, actual: `{value}`" - def assert_variable_string(var: str, expected: str, encoding='utf-16-le'): - with subtest(f"Is `{var}` correctly set"): - value = read_string_variable(var, encoding) - assert value == expected, f"Unexpected variable value in `{var}`, expected: `{expected.encode(encoding)!r}`, actual: `{value.encode(encoding)!r}`" - def assert_variable_string_contains(var: str, expected_substring: str): - with subtest(f"Do `{var}` contain expected substrings"): - value = read_string_variable(var).strip() - assert expected_substring in value, f"Did not find expected substring in `{var}`, expected substring: `{expected_substring}`, actual value: `{value}`" - ''; - tpm2Initialization = '' - import subprocess - from tempfile import TemporaryDirectory - - # From systemd-initrd-luks-tpm2.nix - class Tpm: - def __init__(self): - self.state_dir = TemporaryDirectory() - self.start() - - def start(self): - self.proc = subprocess.Popen(["${pkgs.swtpm}/bin/swtpm", - "socket", - "--tpmstate", f"dir={self.state_dir.name}", - "--ctrl", "type=unixio,path=${tpmSocketPath}", - "--tpm2", - ]) - - # Check whether starting swtpm failed - try: - exit_code = self.proc.wait(timeout=0.2) - if exit_code is not None and exit_code != 0: - raise Exception("failed to start swtpm") - except subprocess.TimeoutExpired: - pass - - """Check whether the swtpm process exited due to an error""" - def check(self): - exit_code = self.proc.poll() - if exit_code is not None and exit_code != 0: - raise Exception("swtpm process died") - - tpm = Tpm() - - @polling_condition - def swtpm_running(): - tpm.check() - ''; - in - pkgs.nixosTest { - inherit name; - globalTimeout = defaultTimeout; - - testScript = '' - ${lib.optionalString useTPM2 tpm2Initialization} - ${lib.optionalString readEfiVariables efiVariablesHelpers} - ${testScript} - ''; - - nodes.machine = { pkgs, lib, ... }: { - imports = [ - lanzabooteModule - machine - ]; - - virtualisation = { - useBootLoader = true; - useEFIBoot = true; - - # We actually only want to enable features in OVMF, but at - # the moment edk2 202308 is also broken. So we downgrade it - # here as well. How painful! - # - # See #240. - efi.OVMF = - let - edk2Version = "202305"; - edk2Src = pkgs.fetchFromGitHub { - owner = "tianocore"; - repo = "edk2"; - rev = "edk2-stable${edk2Version}"; - fetchSubmodules = true; - hash = "sha256-htOvV43Hw5K05g0SF3po69HncLyma3BtgpqYSdzRG4s="; - }; - - edk2 = pkgs.edk2.overrideAttrs (old: rec { - version = edk2Version; - src = edk2Src; - }); - in - (pkgs.OVMF.override { - secureBoot = useSecureBoot; - tpmSupport = useTPM2; # This is needed otherwise OVMF won't initialize the TPM2 protocol. - - edk2 = edk2; - }).overrideAttrs (old: { - src = edk2Src; - }); - - qemu.options = lib.mkIf useTPM2 [ - "-chardev socket,id=chrtpm,path=${tpmSocketPath}" - "-tpmdev emulator,id=tpm_dev_0,chardev=chrtpm" - "-device ${tpmDeviceModels.${system}},tpmdev=tpm_dev_0" - ]; - - inherit useSecureBoot; - }; - - boot.initrd.availableKernelModules = lib.mkIf useTPM2 [ "tpm_tis" ]; - - boot.loader.efi = { - canTouchEfiVariables = true; - }; - boot.lanzaboote = { - enable = true; - enrollKeys = lib.mkDefault true; - pkiBundle = ./fixtures/uefi-keys; - }; - }; - }; + inherit (pkgs) lib; + inherit (lanzaLib) mkSecureBootTest; # Execute a boot test that has an intentionally broken secure boot # chain. This test is expected to fail with Secure Boot and should diff --git a/nix/tests/lib.nix b/nix/tests/lib.nix new file mode 100644 index 00000000..29974810 --- /dev/null +++ b/nix/tests/lib.nix @@ -0,0 +1,158 @@ +{ pkgs, lanzabooteModule }: +let + inherit (pkgs) lib system; + defaultTimeout = 5 * 60; # = 5 minutes +in +{ + mkSecureBootTest = { name, machine ? { }, useSecureBoot ? true, useTPM2 ? false, readEfiVariables ? false, testScript, extraNodes ? { } }: + let + tpmSocketPath = "/tmp/swtpm-sock"; + tpmDeviceModels = { + x86_64-linux = "tpm-tis"; + aarch64-linux = "tpm-tis-device"; + }; + # Should go to nixpkgs. + efiVariablesHelpers = '' + import struct + + SD_LOADER_GUID = "4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" + def read_raw_variable(var: str) -> bytes: + attr_var = machine.succeed(f"cat /sys/firmware/efi/efivars/{var}-{SD_LOADER_GUID}").encode('raw_unicode_escape') + _ = attr_var[:4] # First 4 bytes are attributes according to https://www.kernel.org/doc/html/latest/filesystems/efivarfs.html + value = attr_var[4:] + return value + def read_string_variable(var: str, encoding='utf-16-le') -> str: + return read_raw_variable(var).decode(encoding).rstrip('\x00') + # By default, it will read a 4 byte value, read `struct` docs to change the format. + def assert_variable_uint(var: str, expected: int, format: str = 'I'): + with subtest(f"Is `{var}` set to {expected} (uint)"): + value, = struct.unpack(f'<{format}', read_raw_variable(var)) + assert value == expected, f"Unexpected variable value in `{var}`, expected: `{expected}`, actual: `{value}`" + def assert_variable_string(var: str, expected: str, encoding='utf-16-le'): + with subtest(f"Is `{var}` correctly set"): + value = read_string_variable(var, encoding) + assert value == expected, f"Unexpected variable value in `{var}`, expected: `{expected.encode(encoding)!r}`, actual: `{value.encode(encoding)!r}`" + def assert_variable_string_contains(var: str, expected_substring: str): + with subtest(f"Do `{var}` contain expected substrings"): + value = read_string_variable(var).strip() + assert expected_substring in value, f"Did not find expected substring in `{var}`, expected substring: `{expected_substring}`, actual value: `{value}`" + ''; + tpm2Initialization = '' + import subprocess + from tempfile import TemporaryDirectory + + # From systemd-initrd-luks-tpm2.nix + class Tpm: + def __init__(self): + self.state_dir = TemporaryDirectory() + self.start() + + def start(self): + self.proc = subprocess.Popen(["${pkgs.swtpm}/bin/swtpm", + "socket", + "--tpmstate", f"dir={self.state_dir.name}", + "--ctrl", "type=unixio,path=${tpmSocketPath}", + "--tpm2", + ]) + + # Check whether starting swtpm failed + try: + exit_code = self.proc.wait(timeout=0.2) + if exit_code is not None and exit_code != 0: + raise Exception("failed to start swtpm") + except subprocess.TimeoutExpired: + pass + + """Check whether the swtpm process exited due to an error""" + def check(self): + exit_code = self.proc.poll() + if exit_code is not None and exit_code != 0: + raise Exception("swtpm process died") + + tpm = Tpm() + + @polling_condition + def swtpm_running(): + tpm.check() + ''; + in + pkgs.nixosTest { + inherit name; + globalTimeout = defaultTimeout; + + testScript = { ... }@args: + let + testScript' = if lib.isFunction testScript then testScript args else testScript; + in + '' + ${lib.optionalString useTPM2 tpm2Initialization} + ${lib.optionalString readEfiVariables efiVariablesHelpers} + ${testScript'} + ''; + + + nodes = extraNodes // { + machine = { lib, ... }: { + imports = [ + lanzabooteModule + machine + ]; + + virtualisation = { + useBootLoader = true; + useEFIBoot = true; + + # We actually only want to enable features in OVMF, but at + # the moment edk2 202308 is also broken. So we downgrade it + # here as well. How painful! + # + # See #240. + efi.OVMF = + let + edk2Version = "202305"; + edk2Src = pkgs.fetchFromGitHub { + owner = "tianocore"; + repo = "edk2"; + rev = "edk2-stable${edk2Version}"; + fetchSubmodules = true; + hash = "sha256-htOvV43Hw5K05g0SF3po69HncLyma3BtgpqYSdzRG4s="; + }; + + edk2 = pkgs.edk2.overrideAttrs (old: rec { + version = edk2Version; + src = edk2Src; + }); + in + (pkgs.OVMF.override { + secureBoot = useSecureBoot; + tpmSupport = useTPM2; # This is needed otherwise OVMF won't initialize the TPM2 protocol. + + edk2 = edk2; + }).overrideAttrs (old: { + src = edk2Src; + }); + + qemu.options = lib.mkIf useTPM2 [ + "-chardev socket,id=chrtpm,path=${tpmSocketPath}" + "-tpmdev emulator,id=tpm_dev_0,chardev=chrtpm" + "-device ${tpmDeviceModels.${system}},tpmdev=tpm_dev_0" + ]; + + inherit useSecureBoot; + }; + + boot.initrd.availableKernelModules = lib.mkIf useTPM2 [ "tpm_tis" ]; + + boot.loader.efi = { + canTouchEfiVariables = true; + }; + boot.lanzaboote = { + enable = true; + enrollKeys = lib.mkDefault true; + pkiBundle = ./fixtures/uefi-keys; + }; + }; + }; + }; + +} diff --git a/nix/tests/remote-signing.nix b/nix/tests/remote-signing.nix new file mode 100644 index 00000000..df765590 --- /dev/null +++ b/nix/tests/remote-signing.nix @@ -0,0 +1,80 @@ +{ pkgs, lanzasigndModule, lanzaLib }: +let + inherit (lanzaLib) mkSecureBootTest; + inherit (pkgs) lib; + mkRemoteSigningTest = { name, machine ? { }, useSecureBoot ? true, useTPM2 ? false, testScript }: + mkSecureBootTest { + inherit name useSecureBoot useTPM2; + testScript = { nodes, ... }: + let + remoteClientSystem = "${nodes.machine.system.build.toplevel}/specialisation/remote"; + in + '' + server.start() + machine.start(allow_reboot=True) + server.wait_for_unit("lanzasignd.service") + server.wait_for_open_port(9999) + # Perform a switch to the remote configuration + # and contact the server to get the right bootables. + with subtest("Activation will request for remote signing"): + machine.fail("hello") + machine.succeed( + "${remoteClientSystem}/bin/switch-to-configuration boot >&2" + ) + with subtest("Reboot into remote signed generation is successful"): + machine.succeed("bootctl set-default nixos-generation-1-specialisation-remote-\*.efi") + machine.reboot() + machine.wait_for_unit("multi-user.target") + machine.succeed("hello") + ${testScript} + ''; + machine = { + imports = [ + machine + ]; + + specialisation.remote.configuration = { + boot.lanzaboote = { + # We disable explicitly local signing because `mkSecureBootTest` will set + # `pkiBundle` which will set local signing to true by default. + localSigning.enable = lib.mkForce false; + # Keys were already enrolled by the local setup. + enrollKeys = lib.mkForce false; + remoteSigning = { + enable = true; + serverUrl = "http://server:9999"; + }; + }; + environment.systemPackages = [ pkgs.hello ]; + }; + }; + extraNodes.server = { nodes, ... }: { + imports = [ + lanzasigndModule + ]; + + services.lanzasignd = { + enable = true; + pkiBundle = ./fixtures/uefi-keys; + openFirewall = true; + }; + + system.extraDependencies = [ + # Trust `machine` store paths! + nodes.machine.system.build.toplevel + ]; + }; + }; +in +{ + remote-signing-basic = mkRemoteSigningTest { + name = "remote-signing-basic"; + testScript = '' + assert "Secure Boot: enabled (user)" in machine.succeed("bootctl status") + ''; + }; + + # TODO: attack the signing server + # send a fake store path + # send ... +} diff --git a/rust/tool/Cargo.lock b/rust/tool/Cargo.lock index 88454cd5..6e50bfba 100644 --- a/rust/tool/Cargo.lock +++ b/rust/tool/Cargo.lock @@ -2,6 +2,33 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -19,9 +46,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab91ebe16eb252986481c5b62f6098f3b698a45e34b5b98200cf20dd2484a44" +checksum = "d664a92ecae85fd0a7392615844904654d1d5f5514837f471ddef4a057aba1b6" dependencies = [ "anstyle", "anstyle-parse", @@ -39,30 +66,30 @@ checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" [[package]] name = "anstyle-parse" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.1" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" dependencies = [ "anstyle", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -71,6 +98,12 @@ version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "assert_cmd" version = "2.0.12" @@ -92,7 +125,7 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" dependencies = [ - "hermit-abi", + "hermit-abi 0.1.19", "libc", "winapi", ] @@ -109,6 +142,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396664016f30ad5ab761000391a5c0b436f7bfac738858263eb25897658b98c9" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" + [[package]] name = "bitflags" version = "1.3.2" @@ -141,17 +186,48 @@ dependencies = [ "thiserror", ] +[[package]] +name = "brotli" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516074a47ef4bce09577a3b379392300159ce5b1ba2e501ff1c819950066100f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" -version = "1.7.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c79ad7fb2dd38f3dabd76b09c6a5a20c038fc0213ef1e9afd30eb777f120f019" +checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" dependencies = [ "memchr", "regex-automata", "serde", ] +[[package]] +name = "buf_redux" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b953a6887648bb07a535631f2bc00fbdb2a2216f135552cb3f534ed136b9c07f" +dependencies = [ + "memchr", + "safemem", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -187,11 +263,17 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clap" -version = "4.4.12" +version = "4.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcfab8ba68f3668e89f6ff60f5b205cea56aa7b769451a59f34b8682f51c056d" +checksum = "52bdc885e4cacc7f7c9eedc1ef6da641603180c783c41a15c264944deeaab642" dependencies = [ "clap_builder", "clap_derive", @@ -235,19 +317,28 @@ checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" [[package]] name = "core-foundation-sys" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" [[package]] name = "cpufeatures" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -258,11 +349,21 @@ dependencies = [ "typenum", ] +[[package]] +name = "deflate" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86f7e25f518f4b81808a2cf1c50996a61f5c2eb394b2393bd87f2a4780a432f" +dependencies = [ + "adler32", + "gzip-header", +] + [[package]] name = "deranged" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", ] @@ -339,6 +440,25 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -360,6 +480,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "goblin" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6b4de4a8eb6c46a8c77e1d3be942cb9a8bf073c22374578e5ba4b08ed0ff68" +dependencies = [ + "log", + "plain", + "scroll 0.11.0", +] + [[package]] name = "goblin" version = "0.7.1" @@ -368,7 +499,26 @@ checksum = "f27c1b4369c2cd341b5de549380158b105a04c331be5db9110eef7b6d2742134" dependencies = [ "log", "plain", - "scroll", + "scroll 0.11.0", +] + +[[package]] +name = "goblin" +version = "0.8.0" +source = "git+https://github.com/RaitoBezarius/goblin?branch=pe-sections-manipulation#eacb26a095b2b5c1691371be53c67ddac657c914" +dependencies = [ + "log", + "plain", + "scroll 0.12.0", +] + +[[package]] +name = "gzip-header" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cc527b92e6029a62960ad99aa8a6660faa4555fe5f731aab13aa6a921795a2" +dependencies = [ + "crc32fast", ] [[package]] @@ -386,11 +536,29 @@ dependencies = [ "libc", ] +[[package]] +name = "hermit-abi" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "iana-time-zone" -version = "0.1.58" +version = "0.1.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" +checksum = "b6a67363e2aa4443928ce15e57ebae94fd8949958fd1223c4cfc0cd473ad7539" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -409,6 +577,16 @@ dependencies = [ "cc", ] +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "indoc" version = "2.0.4" @@ -426,15 +604,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "js-sys" -version = "0.3.65" +version = "0.3.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54c0c35952f67de54bb584e9fd912b3023117cbafc0a77d8f3dee1fb5f572fe8" +checksum = "cee9c64da59eae3b50095c18d3e74f8b73c0b86d2792824ff01bbce68ba229ca" dependencies = [ "wasm-bindgen", ] @@ -444,28 +622,62 @@ name = "lanzaboote_tool" version = "0.3.0" dependencies = [ "anyhow", + "assert_cmd", "bootspec", + "expect-test", "fastrand", - "goblin", + "filetime", + "goblin 0.8.0", + "indoc", "log", + "rand", + "serde", "serde_json", "sha2", + "stderrlog", "tempfile", "time", + "ureq", + "url", + "walkdir", +] + +[[package]] +name = "lanzasignd" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "expect-test", + "filetime", + "goblin 0.6.1", + "indoc", + "lanzaboote_tool", + "log", + "nix 0.26.4", + "rand", + "rouille", + "serde", + "serde_json", + "sha2", + "stderrlog", + "tempfile", + "thiserror", "walkdir", ] [[package]] name = "libc" -version = "0.2.150" +version = "0.2.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" [[package]] name = "linux-raw-sys" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" +checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" [[package]] name = "log" @@ -483,11 +695,11 @@ dependencies = [ "clap", "expect-test", "filetime", - "goblin", + "goblin 0.7.1", "indoc", "lanzaboote_tool", "log", - "nix", + "nix 0.27.1", "rand", "serde_json", "sha2", @@ -498,9 +710,63 @@ dependencies = [ [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +dependencies = [ + "adler", +] + +[[package]] +name = "multipart" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "00dec633863867f29cb39df64a397cdf4a6354708ddd7759f70c7fb51c5f9182" +dependencies = [ + "buf_redux", + "httparse", + "log", + "mime", + "mime_guess", + "quick-error", + "rand", + "safemem", + "tempfile", + "twoway", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] [[package]] name = "nix" @@ -522,11 +788,36 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.3", + "libc", +] + +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "percent-encoding" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "plain" @@ -576,13 +867,19 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.74" +version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2de98502f212cfcea8d0bb305bd0f49d7ebdd75b64ba0a68f937d888f4e0d6db" +checksum = "907a61bd0f64c2f29cd1cf1dc34d05176426a3f504a78010f08416ddb7b13708" dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.35" @@ -637,6 +934,44 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +[[package]] +name = "ring" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +dependencies = [ + "cc", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.48.0", +] + +[[package]] +name = "rouille" +version = "3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3716fbf57fc1084d7a706adf4e445298d123e4a44294c4e8213caf1b85fcc921" +dependencies = [ + "base64 0.13.1", + "brotli", + "chrono", + "deflate", + "filetime", + "multipart", + "percent-encoding", + "rand", + "serde", + "serde_derive", + "serde_json", + "sha1_smol", + "threadpool", + "time", + "tiny_http", + "url", +] + [[package]] name = "rustix" version = "0.38.28" @@ -650,11 +985,39 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "ryu" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" [[package]] name = "same-file" @@ -671,7 +1034,16 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04c565b551bafbef4157586fa379538366e4385d42082f255bfd96e4fe8519da" dependencies = [ - "scroll_derive", + "scroll_derive 0.11.1", +] + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive 0.12.0", ] [[package]] @@ -685,6 +1057,27 @@ dependencies = [ "syn", ] +[[package]] +name = "scroll_derive" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f81c2fde025af7e69b1d1420531c8a8811ca898919db177141a85313b1cb932" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "serde" version = "1.0.194" @@ -707,15 +1100,21 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.110" +version = "1.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fbd975230bada99c8bb618e0c365c2eefa219158d5c6c29610fd09ff1833257" +checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" dependencies = [ "itoa", "ryu", "serde", ] +[[package]] +name = "sha1_smol" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012" + [[package]] name = "sha2" version = "0.10.8" @@ -727,6 +1126,12 @@ dependencies = [ "digest", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stderrlog" version = "0.5.4" @@ -748,9 +1153,9 @@ checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "syn" -version = "2.0.46" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89456b690ff72fddcecf231caedbe615c59480c93358a93dfae7fc29e3ebbf0e" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", @@ -787,18 +1192,18 @@ checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" [[package]] name = "thiserror" -version = "1.0.50" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.50" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", @@ -815,13 +1220,24 @@ dependencies = [ "once_cell", ] +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + [[package]] name = "time" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" +checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" dependencies = [ "deranged", + "libc", + "num_threads", "powerfmt", "serde", "time-core", @@ -833,18 +1249,113 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "twoway" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b11b2b5241ba34be09c3cc85a36e56e48f9888862e19cedf23336d35316ed1" +dependencies = [ + "memchr", +] + [[package]] name = "typenum" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cdd25c339e200129fe4de81451814e5228c9b771d57378817d6117cc2b3f97" +dependencies = [ + "base64 0.21.5", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-webpki", + "serde", + "serde_json", + "url", + "webpki-roots", +] + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + [[package]] name = "utf8parse" version = "0.2.1" @@ -884,9 +1395,9 @@ checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] name = "wasm-bindgen" -version = "0.2.88" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7daec296f25a1bae309c0cd5c29c4b260e510e6d813c286b19eaadf409d40fce" +checksum = "0ed0d4f68a3015cc185aff4db9506a015f4b96f95303897bfa23f846db54064e" dependencies = [ "cfg-if", "wasm-bindgen-macro", @@ -894,9 +1405,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.88" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e397f4664c0e4e428e8313a469aaa58310d302159845980fd23b0f22a847f217" +checksum = "1b56f625e64f3a1084ded111c4d5f477df9f8c92df113852fa5a374dbda78826" dependencies = [ "bumpalo", "log", @@ -909,9 +1420,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.88" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5961017b3b08ad5f3fe39f1e79877f8ee7c23c5e5fd5eb80de95abc41f1f16b2" +checksum = "0162dbf37223cd2afce98f3d0785506dcb8d266223983e4b5b525859e6e182b2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -919,9 +1430,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.88" +version = "0.2.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5353b8dab669f5e10f5bd76df26a9360c748f054f862ff5f3f8aae0c7fb3907" +checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283" dependencies = [ "proc-macro2", "quote", @@ -932,9 +1443,15 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.88" +version = "0.2.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab9b36309365056cd639da3134bf87fa8f3d86008abf99e612384a6eecd459f" + +[[package]] +name = "webpki-roots" +version = "0.25.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b" +checksum = "1778a42e8b3b90bff8d0f5032bf22250792889a5cdc752aa0020c84abe3aaf10" [[package]] name = "winapi" @@ -969,11 +1486,11 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" -version = "0.51.1" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.48.5", + "windows-targets 0.52.0", ] [[package]] diff --git a/rust/tool/Cargo.toml b/rust/tool/Cargo.toml index b7306c1d..e52908a1 100644 --- a/rust/tool/Cargo.toml +++ b/rust/tool/Cargo.toml @@ -4,6 +4,7 @@ resolver = "2" members = [ "shared", "systemd", + "server", ] default-members = [ diff --git a/rust/tool/server/Cargo.toml b/rust/tool/server/Cargo.toml new file mode 100644 index 00000000..e84081c2 --- /dev/null +++ b/rust/tool/server/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "lanzasignd" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0.71" +stderrlog = "0.5.4" +log = { version = "0.4.18", features = ["std"] } +clap = { version = "4.3.1", features = ["derive"] } +lanzaboote_tool = { path = "../shared" } +indoc = "2.0.1" +tempfile = "3.5.0" +nix = { version = "0.26.2", default-features = false, features = [ "fs" ] } +rouille = "3.6.2" +serde = { version = "1.0.163", features = ["derive"] } +serde_json = "1.0.107" +thiserror = "1.0.49" + +[dev-dependencies] +assert_cmd = "2.0.11" +expect-test = "1.4.1" +filetime = "0.2.21" +rand = "0.8.5" +serde_json = "1.0.96" +goblin = "0.6.1" +walkdir = "2.3.3" +sha2 = "0.10.6" diff --git a/rust/tool/server/README.md b/rust/tool/server/README.md new file mode 100644 index 00000000..bc5d564f --- /dev/null +++ b/rust/tool/server/README.md @@ -0,0 +1,43 @@ +# Lanzaboote Remote Signature Server (`lanzasignd`) + +`lanzasignd` is a model of how to offer a remote signature server which +will serve the only two things of importance: + +- the lanzaboote stub +- a potential first stage bootloader, e.g. systemd-boot + +Instead of sending any binary on the wire which is wasteful, we can exploit the Nix store model here +and send store paths that are expected to be realizable on the signing server. + +Furthermore, this serves as a good enough way to protect the user against sending tampered stubs. + +## Theory of operations + +`lanzasignd` is expected to run as a hardened daemon with a potential access to the private key material +or an already authenticated socket to perform signing operations. + +No authentication or authorization is built-in as this is out of scope, it is recommended to run the daemon +behind a reverse proxy with authentication or authorization or in a trusted network via a VPN. + +No rate-limit is applied to protect against denial of service, PRs are welcome to figure out a reasonable solution on that, +otherwise rate-limits can be applied at the system level. + +## Endpoints + +- `POST /sign-stub`: assembles a signed stub based on the stub parameters sent, 200 OK with a signed binary as body, 400 with a plaintext error if failed. +- `POST /sign-store-path`: assembles a signed binary based on the store path sent, 200 OK with a signed binary as body, 400 with a plaintext error if failed. +- `GET /verify`: verify that the binary sent is signed according to the current keyring, returns a JSON `{ signed: bool, valid_according_to_secureboot_policy: bool }`, a signed binary can be invalid for the current Secure Boot policy, the two attributes represents this fact. + +## Operating it + +`lanzasignd` has hard requirements on possessing a Nix store. + +```nix + services.lanzasignd = { + enable = true; + port = 9999; + settings = { + kernel-cmdline-allowed = [ "..." ]; + }; + }; +``` diff --git a/rust/tool/server/src/handlers.rs b/rust/tool/server/src/handlers.rs new file mode 100644 index 00000000..bf2e9ca1 --- /dev/null +++ b/rust/tool/server/src/handlers.rs @@ -0,0 +1,89 @@ +use std::{io::Read, path::PathBuf}; + +use lanzaboote_tool::{ + pe::StubParameters, + signature::{remote::VerificationResponse, LanzabooteSigner}, + utils::SecureTempDirExt, +}; +use log::{debug, trace, warn}; +use rouille::{try_or_400, Request, Response}; +use thiserror::Error; + +use crate::policy::{Policy, TrivialPolicy}; + +#[derive(Error, Debug)] +pub enum ErrorKind { + #[error("body was already opened in request")] + BodyAlreadyOpened, +} + +pub struct Handlers { + policy: TrivialPolicy, + signer: S, +} + +impl Handlers { + pub fn new(signer: S, policy: TrivialPolicy) -> Self { + Self { signer, policy } + } + + pub fn sign_stub(&self, req: &Request) -> Response { + debug!("Signing stub request"); + let stub_parameters: StubParameters = try_or_400!(rouille::input::json_input(req)); + trace!("Stub parameters: {:#?}", stub_parameters); + + // Validate the stub according to the policy + if !self.policy.trusted_stub_parameters(&stub_parameters) { + warn!("Untrusted stub parameters"); + return Response::empty_400(); + } + + let working_tree = tempfile::tempdir().expect("Failed to create a directory"); + + // Assemble the stub + let image = stub_parameters + .into_image() + .expect("Failed to build the stub"); + + // Sign the stub now + let image_from = working_tree + .write_secure_file(image) + .expect("Failed to write a file in a secure fashion in the temporary working tree"); + let image_to = image_from.with_extension(".signed"); + self.signer.sign_and_copy(&image_from, &image_to).unwrap(); + + Response::from_data( + "application/octet-stream", + std::fs::read(image_to).expect("Failed to read the stub"), + ) + } + + pub fn sign_store_path(&self, req: &Request) -> Response { + debug!("Signing store path request"); + let store_path: PathBuf = PathBuf::from(try_or_400!(rouille::input::plain_text_body(req))); + debug!("Request for {}", store_path.display()); + + if !self.policy.trusted_store_path(&store_path) { + warn!("Untrusted store path: {}", store_path.display()); + Response::empty_400() + } else { + Response::from_data( + "application/octet-stream", + self.signer.sign_store_path(&store_path).unwrap(), + ) + } + } + + pub fn verify(&self, req: &Request) -> Response { + let mut data = try_or_400!(req.data().ok_or(ErrorKind::BodyAlreadyOpened)); + let mut buf = Vec::new(); + try_or_400!(data.read_to_end(&mut buf)); + + let signed_according_to_signer = self.signer.verify(buf.as_slice()).unwrap(); + + Response::json(&VerificationResponse { + signed: signed_according_to_signer, + valid_according_secureboot_policy: signed_according_to_signer, + }) + } +} diff --git a/rust/tool/server/src/main.rs b/rust/tool/server/src/main.rs new file mode 100644 index 00000000..d1b771bd --- /dev/null +++ b/rust/tool/server/src/main.rs @@ -0,0 +1,104 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use lanzaboote_tool::signature::local::LocalKeyPair; +use log::{info, trace}; +use policy::TrivialPolicy; +use rouille::router; +use rouille::Response; + +mod handlers; +mod policy; + +use crate::handlers::Handlers; + +#[derive(Parser)] +struct Cli { + /// Verbose mode (-v, -vv, etc.) + #[arg(short, long, action = clap::ArgAction::Count)] + verbose: u8, + #[clap(subcommand)] + commands: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Serve(ServeCommand), +} + +#[derive(Parser)] +struct ServeCommand { + /// Port for the service + #[arg(long)] + port: u16, + + /// Policy file settings + #[arg(long)] + policy_file: PathBuf, + + /// sbsign Public Key + #[arg(long)] + public_key: PathBuf, + + /// sbsign Private Key + #[arg(long)] + private_key: PathBuf, +} + +/// The default log level. +/// +/// 2 corresponds to the level INFO. +const DEFAULT_LOG_LEVEL: usize = 2; + +impl Cli { + pub fn call(self, module: &str) { + stderrlog::new() + .module(module) + .show_level(false) + .verbosity(DEFAULT_LOG_LEVEL + usize::from(self.verbose)) + .init() + .expect("Failed to setup logger."); + + if let Err(e) = self.commands.call() { + log::error!("{e:#}"); + std::process::exit(1); + }; + } +} + +impl Commands { + pub fn call(self) -> Result<()> { + match self { + Commands::Serve(args) => serve(args), + } + } +} + +fn serve(args: ServeCommand) -> Result<()> { + let keypair = LocalKeyPair::new(&args.public_key, &args.private_key); + let policy: TrivialPolicy = serde_json::from_slice(&std::fs::read(args.policy_file)?)?; + let handlers = Handlers::new(keypair, policy); + info!("Listening on 0.0.0.0:{}", args.port); + rouille::start_server(format!("0.0.0.0:{}", args.port), move |request| { + trace!("Receiving {:#?}", request); + router!(request, + (POST) (/sign-stub) => { + handlers.sign_stub(request) + }, + (POST) (/sign-store-path) => { + handlers.sign_store_path(request) + }, + (POST) (/verify) => { + handlers.verify(request) + }, + _ => { + Response::text("lanzasignd signature endpoint") + } + ) + }); +} + +fn main() { + Cli::parse().call(module_path!()) +} diff --git a/rust/tool/server/src/policy.rs b/rust/tool/server/src/policy.rs new file mode 100644 index 00000000..324b4162 --- /dev/null +++ b/rust/tool/server/src/policy.rs @@ -0,0 +1,63 @@ +use std::{collections::HashSet, path::Path}; + +use lanzaboote_tool::pe::StubParameters; +use log::trace; +use serde::{Deserialize, Serialize}; + +pub trait Policy { + /// Validate if this store path is trusted for signature. + fn trusted_store_path(&self, store_path: &Path) -> bool; + /// Validate if these stub parameters are trusted for signature. + fn trusted_stub_parameters(&self, parameters: &StubParameters) -> bool; +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TrivialPolicy { + allowed_kernel_cmdline_items: Option>, +} + +impl Policy for TrivialPolicy { + /// For now, we will only assume it does exist in our local store. + /// This scenario makes sense if you deploy all your closures via this local machine's store, + /// e.g. a big builder, NFS nix store, etc. + fn trusted_store_path(&self, store_path: &Path) -> bool { + trace!( + "trusted store path {} → {}", + store_path.display(), + store_path.exists() + ); + store_path.exists() + } + + fn trusted_stub_parameters(&self, parameters: &StubParameters) -> bool { + if !self.trusted_store_path(¶meters.lanzaboote_store_path) + || !self.trusted_store_path(¶meters.kernel_store_path) + || !self.trusted_store_path(¶meters.initrd_store_path) + { + return false; + } + + if let Some(allowed_cmdline_items) = &self.allowed_kernel_cmdline_items { + for item in ¶meters.kernel_cmdline { + if !allowed_cmdline_items.contains(item) { + trace!("untrusted command line item: {item}"); + return false; + } + } + } + + // XXX: validate os_release_contents + // parse then check if it contains allowed stuff? + + // kernel/initrd paths doesn't need to be validated per se. + // let's assume they are manipulated, let be K the kernel path in ESP. + // if the stub loads K, we will validate that hash(K) = hash in the stub. + // because of how the stub works, if hash(K) = hash in the stub and the hash function + // is strong enough, we know that K's contents = the kernel's contents we expected. + // Therefore, integrity is ensured. + // The only concern is that user could overwrite his bootables with the wrong K. + // Is that a concern for this signing server? Not really. + + true + } +} diff --git a/rust/tool/shared/Cargo.toml b/rust/tool/shared/Cargo.toml index 9d25e381..ecdc8d71 100644 --- a/rust/tool/shared/Cargo.toml +++ b/rust/tool/shared/Cargo.toml @@ -7,7 +7,7 @@ edition.workspace = true [dependencies] anyhow = "1" -goblin = "0.7" +goblin = { git = "https://github.com/RaitoBezarius/goblin", branch = "pe-sections-manipulation" } serde_json = "1" tempfile = "3.9.0" bootspec = "1" @@ -18,3 +18,14 @@ sha2 = "0.10" # different versions. fastrand = "2.0.1" log = { version = "0.4", features = ["std"] } +serde = { version = "1.0.194", features = ["derive"] } +stderrlog = "0.5" +indoc = "2" +ureq = { version = "2.7.1", features = [ "json" ] } +url = "2.4.1" + +[dev-dependencies] +assert_cmd = "2.0.12" +expect-test = "1.4.1" +filetime = "0.2.21" +rand = "0.8.5" diff --git a/rust/tool/shared/src/pe.rs b/rust/tool/shared/src/pe.rs index 3333c61f..f1cca1db 100644 --- a/rust/tool/shared/src/pe.rs +++ b/rust/tool/shared/src/pe.rs @@ -1,117 +1,140 @@ -use std::ffi::OsString; -use std::fs; -use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result}; -use goblin::pe::PE; -use tempfile::TempDir; - -use crate::utils::{file_hash, tmpname, SecureTempDirExt}; - -/// Assemble a lanzaboote image. -#[allow(clippy::too_many_arguments)] -pub fn lanzaboote_image( - // Because the returned path of this function is inside the tempdir as well, the tempdir must - // live longer than the function. This is why it cannot be created inside the function. - tempdir: &TempDir, - lanzaboote_stub: &Path, - os_release: &Path, - kernel_cmdline: &[String], - kernel_source: &Path, - kernel_target: &Path, - initrd_source: &Path, - initrd_target: &Path, - esp: &Path, -) -> Result { - // objcopy can only copy files into the PE binary. That's why we - // have to write the contents of some bootspec properties to disk. - let kernel_cmdline_file = tempdir.write_secure_file(kernel_cmdline.join(" "))?; - - let kernel_path_file = - tempdir.write_secure_file(esp_relative_uefi_path(esp, kernel_target)?)?; - let kernel_hash_file = tempdir.write_secure_file(file_hash(kernel_source)?.as_slice())?; - - let initrd_path_file = - tempdir.write_secure_file(esp_relative_uefi_path(esp, initrd_target)?)?; - let initrd_hash_file = tempdir.write_secure_file(file_hash(initrd_source)?.as_slice())?; - - let os_release_offs = stub_offset(lanzaboote_stub)?; - let kernel_cmdline_offs = os_release_offs + file_size(os_release)?; - let initrd_path_offs = kernel_cmdline_offs + file_size(&kernel_cmdline_file)?; - let kernel_path_offs = initrd_path_offs + file_size(&initrd_path_file)?; - let initrd_hash_offs = kernel_path_offs + file_size(&kernel_path_file)?; - let kernel_hash_offs = initrd_hash_offs + file_size(&initrd_hash_file)?; - - let sections = vec![ - s(".osrel", os_release, os_release_offs), - s(".cmdline", kernel_cmdline_file, kernel_cmdline_offs), - s(".initrdp", initrd_path_file, initrd_path_offs), - s(".kernelp", kernel_path_file, kernel_path_offs), - s(".initrdh", initrd_hash_file, initrd_hash_offs), - s(".kernelh", kernel_hash_file, kernel_hash_offs), - ]; - - let image_path = tempdir.path().join(tmpname()); - wrap_in_pe(lanzaboote_stub, sections, &image_path)?; - Ok(image_path) +use goblin::pe::section_table::{IMAGE_SCN_CNT_INITIALIZED_DATA, IMAGE_SCN_MEM_READ}; +use serde::{Deserialize, Serialize}; + +use crate::utils::file_hash; + +/// Stub parameters is the sufficient +/// data to produce a "partial kernel image", +/// i.e. something like a unified kernel image (UKIs) but +/// which contains only references to the kernel, the initrd and more. +/// This is the trick that lanzaboote devised to avoid paying a UKI penalty +/// for each NixOS generation in the UKI model. +#[derive(Debug, Serialize, Deserialize)] +pub struct StubParameters { + pub lanzaboote_store_path: PathBuf, + pub kernel_cmdline: Vec, + pub os_release_contents: Vec, + pub kernel_store_path: PathBuf, + pub initrd_store_path: PathBuf, + /// Kernel path rooted at the ESP + /// i.e. if you refer to /boot/efi/EFI/NixOS/kernel.efi + /// this gets turned into \\EFI\\NixOS\\kernel.efi as a UTF-16 string + /// at assembling time. + pub kernel_path_at_esp: String, + /// Same as kernel. + pub initrd_path_at_esp: String, } -/// Take a PE binary stub and attach sections to it. -/// -/// The resulting binary is then written to a newly created file at the provided output path. -fn wrap_in_pe(stub: &Path, sections: Vec
, output: &Path) -> Result<()> { - let mut args: Vec = sections.iter().flat_map(Section::to_objcopy).collect(); +impl StubParameters { + pub fn new( + lanzaboote_stub: &Path, + kernel_path: &Path, + initrd_path: &Path, + kernel_target: &Path, + initrd_target: &Path, + esp: &Path, + ) -> Result { + // Resolve maximally those paths + // We won't verify they are store paths, otherwise the mocking strategy will fail for our + // unit tests. + + Ok(Self { + lanzaboote_store_path: lanzaboote_stub.to_path_buf(), + kernel_store_path: kernel_path.to_path_buf(), + initrd_store_path: initrd_path.to_path_buf(), + kernel_path_at_esp: esp_relative_uefi_path(esp, kernel_target)?, + initrd_path_at_esp: esp_relative_uefi_path(esp, initrd_target)?, + kernel_cmdline: Vec::new(), + os_release_contents: Vec::new(), + }) + } - [stub.as_os_str(), output.as_os_str()] - .iter() - .for_each(|a| args.push(a.into())); + pub fn with_os_release_contents(mut self, os_release_contents: &[u8]) -> Self { + self.os_release_contents = os_release_contents.to_vec(); + self + } + + pub fn with_cmdline(mut self, cmdline: &[String]) -> Self { + self.kernel_cmdline = cmdline.to_vec(); + self + } + + pub fn all_signables_in_store(&self) -> bool { + self.lanzaboote_store_path.starts_with("/nix/store") + && self.kernel_store_path.starts_with("/nix/store") + && self.initrd_store_path.starts_with("/nix/store") + } + + /// Assemble into a final PE image + /// this stub. + pub fn into_image(&self) -> Result> { + let initrd_hash = file_hash(&self.initrd_store_path)?; + let kernel_hash = file_hash(&self.kernel_store_path)?; + let final_kernel_cmdline = self.kernel_cmdline.join(" "); + + let sections = vec![ + s(".osrel", &self.os_release_contents)?, + s(".cmdline", final_kernel_cmdline.as_bytes())?, + s(".initrdp", self.initrd_path_at_esp.as_bytes())?, + s(".kernelp", self.kernel_path_at_esp.as_bytes())?, + s(".initrdh", initrd_hash.as_slice())?, + s(".kernelh", kernel_hash.as_slice())?, + ]; + + let template_pe_data = std::fs::read(&self.lanzaboote_store_path)?; + let template_pe = goblin::pe::PE::parse(&template_pe_data)?; - let status = Command::new("objcopy") - .args(&args) + let mut pe_writer = goblin::pe::writer::PEWriter::new(template_pe)?; + + for section in sections { + pe_writer.insert_section(section)?; + } + + Ok(pe_writer.write_into()?) + } +} + +/// Performs the evil operation +/// of calling the appender script to append +/// initrd "secrets" (not really) to the initrd. +pub fn append_initrd_secrets( + append_initrd_secrets_path: &Path, + initrd_path: &PathBuf, + generation_version: u64, +) -> Result<()> { + let status = Command::new(append_initrd_secrets_path) + .args(vec![initrd_path]) .status() - .context("Failed to run objcopy. Most likely, the binary is not on PATH.")?; + .context("Failed to append initrd secrets")?; if !status.success() { return Err(anyhow::anyhow!( - "Failed to wrap in pe with args `{:?}`", - &args + "Failed to append initrd secrets for generation {} with args `{:?}`", + generation_version, + vec![append_initrd_secrets_path, initrd_path] )); } Ok(()) } -struct Section { - name: &'static str, - file_path: PathBuf, - offset: u64, -} - -impl Section { - /// Create objcopy `-add-section` command line parameters that - /// attach the section to a PE file. - fn to_objcopy(&self) -> Vec { - // There is unfortunately no format! for OsString, so we cannot - // just format a path. - let mut map_str: OsString = format!("{}=", self.name).into(); - map_str.push(&self.file_path); - - vec![ - OsString::from("--add-section"), - map_str, - OsString::from("--change-section-vma"), - format!("{}={:#x}", self.name, self.offset).into(), - ] - } -} - -fn s(name: &'static str, file_path: impl AsRef, offset: u64) -> Section { - Section { - name, - file_path: file_path.as_ref().into(), - offset, - } +/// Data-only section. +#[inline] +fn s<'a>(name: &str, contents: &'a [u8]) -> anyhow::Result> { + use goblin::pe::section_table::Section; + + // This is infallible (upstream), this might be my fault. + let mut section_name = [0u8; 8]; + section_name[..name.len()].copy_from_slice(name.as_bytes()); + Ok(Section::new( + §ion_name, + Some(contents.into()), + IMAGE_SCN_MEM_READ | IMAGE_SCN_CNT_INITIALIZED_DATA, + ) + .unwrap()) } /// Convert a path to an UEFI path relative to the specified ESP. @@ -134,41 +157,6 @@ fn uefi_path(path: &Path) -> Result { .with_context(|| format!("Failed to convert {:?} to an UEFI path", path)) } -fn stub_offset(binary: &Path) -> Result { - let pe_binary = fs::read(binary).context("Failed to read PE binary file")?; - let pe = PE::parse(&pe_binary).context("Failed to parse PE binary file")?; - - let image_base = image_base(&pe); - - // The Virtual Memory Address (VMA) is relative to the image base, aka the image base - // needs to be added to the virtual address to get the actual (but still virtual address) - Ok(u64::from( - pe.sections - .last() - .map(|s| s.virtual_size + s.virtual_address) - .expect("Failed to calculate offset"), - ) + image_base) -} - -fn image_base(pe: &PE) -> u64 { - pe.header - .optional_header - .expect("Failed to find optional header, you're fucked") - .windows_fields - .image_base -} - -fn file_size(path: impl AsRef) -> Result { - Ok(fs::metadata(&path) - .with_context(|| { - format!( - "Failed to read file metadata to calculate its size: {:?}", - path.as_ref() - ) - })? - .size()) -} - /// Read the data from a section of a PE binary. /// /// The binary is supplied as a `u8` slice. diff --git a/rust/tool/shared/src/signature.rs b/rust/tool/shared/src/signature.rs deleted file mode 100644 index b78dfbac..00000000 --- a/rust/tool/shared/src/signature.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::ffi::OsString; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::Command; - -use anyhow::{Context, Result}; - -pub struct KeyPair { - pub private_key: PathBuf, - pub public_key: PathBuf, -} - -impl KeyPair { - pub fn new(public_key: &Path, private_key: &Path) -> Self { - Self { - public_key: public_key.into(), - private_key: private_key.into(), - } - } - - pub fn sign_and_copy(&self, from: &Path, to: &Path) -> Result<()> { - let args: Vec = vec![ - OsString::from("--key"), - self.private_key.clone().into(), - OsString::from("--cert"), - self.public_key.clone().into(), - from.as_os_str().to_owned(), - OsString::from("--output"), - to.as_os_str().to_owned(), - ]; - - let output = Command::new("sbsign") - .args(&args) - .output() - .context("Failed to run sbsign. Most likely, the binary is not on PATH.")?; - - if !output.status.success() { - std::io::stderr() - .write_all(&output.stderr) - .context("Failed to write output of sbsign to stderr.")?; - log::debug!("sbsign failed with args: `{args:?}`."); - return Err(anyhow::anyhow!("Failed to sign {to:?}.")); - } - - Ok(()) - } - - /// Verify the signature of a PE binary. Return true if the signature was verified. - pub fn verify(&self, path: &Path) -> bool { - let args: Vec = vec![ - OsString::from("--cert"), - self.public_key.clone().into(), - path.as_os_str().to_owned(), - ]; - - let output = Command::new("sbverify") - .args(&args) - .output() - .expect("Failed to run sbverify. Most likely, the binary is not on PATH."); - - if !output.status.success() { - if std::io::stderr().write_all(&output.stderr).is_err() { - return false; - }; - log::debug!("sbverify failed with args: `{args:?}`."); - return false; - } - true - } -} diff --git a/rust/tool/shared/src/signature/README.md b/rust/tool/shared/src/signature/README.md new file mode 100644 index 00000000..166946fd --- /dev/null +++ b/rust/tool/shared/src/signature/README.md @@ -0,0 +1,14 @@ +# Signatures capabilities of Lanzaboote + +Currently, lanzaboote can perform signatures of PE binaries based on local keypairs present on disk. + +## Local keypairs + +Storing your signature keys in the disk in some location we can read is the most trivial signature capability +we can offer. + +You are responsible for securing them and ensuring they are not accessible by an attacker. + +Signature happens via `sbsign` which will copy your input inside a temporary directory, sign it, read it and offers it to you again. + +In the future, we may remove `sbsign` dependency to perform signature in-memory without any temporary directory. diff --git a/rust/tool/shared/src/signature/local.rs b/rust/tool/shared/src/signature/local.rs new file mode 100644 index 00000000..9f0f2a95 --- /dev/null +++ b/rust/tool/shared/src/signature/local.rs @@ -0,0 +1,114 @@ +use crate::utils::SecureTempDirExt; +use std::ffi::OsString; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result}; +use tempfile::tempdir; + +use super::LanzabooteSigner; + +#[derive(Debug, Clone)] +pub struct LocalKeyPair { + pub private_key: PathBuf, + pub public_key: PathBuf, +} + +impl LocalKeyPair { + pub fn new(public_key: &Path, private_key: &Path) -> Self { + Self { + public_key: public_key.into(), + private_key: private_key.into(), + } + } +} + +impl LanzabooteSigner for LocalKeyPair { + fn get_public_key(&self) -> Result> { + Ok(std::fs::read(&self.public_key)?) + } + + fn can_sign_stub(&self, _stub: &crate::pe::StubParameters) -> bool { + true + } + + fn sign_and_copy(&self, from: &Path, to: &Path) -> Result<()> { + let args: Vec = vec![ + OsString::from("--key"), + self.private_key.clone().into(), + OsString::from("--cert"), + self.public_key.clone().into(), + from.as_os_str().to_owned(), + OsString::from("--output"), + to.as_os_str().to_owned(), + ]; + + let output = Command::new("sbsign") + .args(&args) + .output() + .context("Failed to run sbsign. Most likely, the binary is not on PATH.")?; + + if !output.status.success() { + std::io::stderr() + .write_all(&output.stderr) + .context("Failed to write output of sbsign to stderr.")?; + log::debug!("sbsign failed with args: `{args:?}`."); + return Err(anyhow::anyhow!("Failed to sign {to:?}.")); + } + + Ok(()) + } + + fn sign_store_path(&self, store_path: &Path) -> Result> { + let working_tree = tempdir()?; + let to = &working_tree.path().join("signed.efi"); + self.sign_and_copy(store_path, to)?; + + Ok(std::fs::read(to)?) + } + + fn build_and_sign_stub(&self, stub: &crate::pe::StubParameters) -> Result> { + let working_tree = tempdir()?; + let lzbt_image = stub + .into_image() + .context("Failed to build a lanzaboote image")?; + let lzbt_image_path = working_tree.write_secure_file(lzbt_image)?; + let to = working_tree.path().join("signed-stub.efi"); + self.sign_and_copy(&lzbt_image_path, &to)?; + + std::fs::read(&to).context("Failed to read a lanzaboote image") + } + + fn verify(&self, pe_binary: &[u8]) -> Result { + let working_tree = tempdir().context("Failed to get a temporary working tree")?; + let from = working_tree + .write_secure_file(pe_binary) + .context("Failed to write the PE binary in a secure file for verification")?; + + self.verify_path(&from) + } + + fn verify_path(&self, path: &Path) -> Result { + let args: Vec = vec![ + OsString::from("--cert"), + self.public_key.clone().into(), + path.as_os_str().to_owned(), + ]; + + let output = Command::new("sbverify") + .args(&args) + .output() + .context("Failed to run sbverify. Most likely, the binary is not on PATH.")?; + + if !output.status.success() { + if std::io::stderr().write_all(&output.stderr).is_err() { + return Ok(false); + }; + // XXX(Raito): do we want to bubble up this type of errors? :/ + log::debug!("sbverify failed with args: `{args:?}`."); + return Ok(false); + } + Ok(true) + } +} diff --git a/rust/tool/shared/src/signature/mod.rs b/rust/tool/shared/src/signature/mod.rs new file mode 100644 index 00000000..fec2b65a --- /dev/null +++ b/rust/tool/shared/src/signature/mod.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use std::path::Path; + +use crate::pe::StubParameters; + +pub trait LanzabooteSigner { + fn sign_store_path(&self, store_path: &Path) -> Result>; + fn can_sign_stub(&self, stub: &StubParameters) -> bool; + fn build_and_sign_stub(&self, stub: &StubParameters) -> Result>; + fn get_public_key(&self) -> Result>; + + fn sign_and_copy(&self, from: &Path, to: &Path) -> Result<()> { + Ok(std::fs::write(to, self.sign_store_path(from)?)?) + } + + /// Verify the signature of a PE binary, provided as bytes. + /// Return true if the signature was verified. + fn verify(&self, pe_binary: &[u8]) -> Result; + /// Verify the signature of a PE binary, provided by its path. + /// Return true if the signature was verified. + fn verify_path(&self, from: &Path) -> Result { + self.verify(&std::fs::read(from).expect("Failed to read the path to verify")) + } +} + +pub mod local; +pub mod remote; diff --git a/rust/tool/shared/src/signature/pkcs11.rs b/rust/tool/shared/src/signature/pkcs11.rs new file mode 100644 index 00000000..1307d023 --- /dev/null +++ b/rust/tool/shared/src/signature/pkcs11.rs @@ -0,0 +1,76 @@ +use crate::pe::lanzaboote_image; + +use super::LanzabooteSigner; +use anyhow::Context; +use cryptoki::{context::Pkcs11, session::Session}; +use signature::Keypair; +use tempfile::tempdir; + +pub type P256Signer = cryptoki_rustcrypto::ecdsa::Signer; + +/// This can only really sign PE binaries and nothing else. +pub struct Pkcs11Signer { + context: Pkcs11, + token_uri: String, + session: Session, + /// Signing certificate for this signer + /// FIXME: CA/SubCA/Leaf setup are not supported yet. + pub signing_certificate: x509_cert::Certificate, + pub signer: P256Signer, +} + +impl Pkcs11Signer { + fn new(&self, context: Pkcs11, token_uri: String) -> Self { + // TODO: if there's a pin in the token_uri, done + // if there's no pin, start user interaction. + // login the session. + // fetch the signing certificate: input is label and subject. + Self { context, token_uri } + } + + fn sign_bytes(&self, bytes: &[u8]) -> anyhow::Result> { + let pe = goblin::pe::PE::parse(bytes)?; + let pe_certificate = goblin_signing::sign::create_certificate( + &pe, + vec![self.signing_certificate.clone()], + self.signing_certificate.clone(), + &self.signer, + ); + } +} + +impl LanzabooteSigner for Pkcs11Signer { + fn get_public_key(&self) -> anyhow::Result> { + Ok(self.signer.verifying_key().to_sec1_bytes()) + } + + fn sign_store_path(&self, store_path: &std::path::Path) -> anyhow::Result> { + let contents = std::fs::read(store_path)?; + self.sign_bytes(&contents) + } + + fn build_and_sign_stub(&self, stub: &crate::pe::StubParameters) -> anyhow::Result> { + let working_tree = tempdir()?; + let lzbt_image_path = + lanzaboote_image(&working_tree, stub).context("Failed to build a lanzaboote image")?; + let to = working_tree.path().join("signed-stub.efi"); + self.sign_and_copy(&lzbt_image_path, &to); + + std::fs::read(&to).context("Failed to read a lanzaboote image") + } + + fn can_sign_stub(&self, stub: &crate::pe::StubParameters) -> bool { + // If we can login and we have a RW session, + // we can sign any stub, yes. + true + } + + fn verify(&self, pe_binary: &[u8]) -> anyhow::Result { + Ok( + goblin_signing::verify::verify_pe_signatures_no_trust(&goblin::pe::PE::parse( + pe_binary, + )?)? + .0, + ) + } +} diff --git a/rust/tool/shared/src/signature/remote.rs b/rust/tool/shared/src/signature/remote.rs new file mode 100644 index 00000000..d2f05ebb --- /dev/null +++ b/rust/tool/shared/src/signature/remote.rs @@ -0,0 +1,200 @@ +use std::time::Duration; + +use crate::pe::StubParameters; + +use super::LanzabooteSigner; +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use ureq::{Agent, AgentBuilder}; +use url::Url; + +/// Remote signing server +/// +/// It will perform classical signature operations over HTTP +/// using the "Lanzaboote Remote Signing server" API. +/// +/// This API relies on the server exposing three endpoints: +/// +/// - `/sign-stub`: takes a StubParameter as input and reply with a signed stub +/// - `/sign-store-path`: takes a string store path as input and reply with the signed data +/// - `/verify`: takes PE binary as input and reply a `VerificationResponse` +/// +/// lanzasignd is an example of implementation. +pub struct RemoteSigningServer { + server_url: Url, + user_agent: String, + client: Agent, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct VerificationResponse { + /// If the binary has any signature attached + pub signed: bool, + /// If the binary is valid according to the Secure Boot policy + /// attached to this machine + /// This is not always a reliable piece of information + /// TODO: rework me. + pub valid_according_secureboot_policy: bool, +} + +impl RemoteSigningServer { + pub fn new(server_url: &str, user_agent: &str) -> Result { + let client = AgentBuilder::new() + .timeout_read(Duration::from_secs(5)) + .timeout_write(Duration::from_secs(5)) + .build(); + Ok(Self { + server_url: Url::parse(server_url) + .with_context(|| format!("Failed to parse {} as an URL", server_url))?, + user_agent: user_agent.to_string(), + client, + }) + } + + /// Asks for the remote server to send back a stub + /// assembled with the parameters provided. + /// + /// If the remote server agrees on providing that stub + /// It will return it signed. + fn request_signature(&self, stub_parameters: &StubParameters) -> Result> { + if !stub_parameters.all_signables_in_store() { + bail!("Signable stub parameters contains non-Nix store paths, the remote server cannot sign that!"); + } + + let response = self + .client + .post(self.server_url.join("/sign-stub")?.as_str()) + .set("User-Agent", &self.user_agent) + .send_json(stub_parameters) + .context("Failed to request signature")?; + + let len: Option = if response.has("Transfer-Encoding") + && response.header("Transfer-Encoding").unwrap() == "chunked" + { + None + } else { + Some( + response + .header("Content-Length") + .ok_or(anyhow::anyhow!( + "No content length in server response for stub signature" + ))? + .parse()?, + ) + }; + + let mut reader = response.into_reader(); + + let mut binary = match len { + Some(len) => Vec::with_capacity(len), + None => Vec::new(), + }; + + reader.read_to_end(&mut binary)?; + + Ok(binary) + } + + /// Asks for the remote server to sign an arbitrary + /// store path. + fn request_store_path_signature(&self, store_path: &str) -> Result> { + let response = self + .client + .post(self.server_url.join("/sign-store-path")?.as_str()) + .set("User-Agent", &self.user_agent) + .set("Content-Type", "text/plain; charset=utf8") + .send_string(store_path) + .context("Failed to request signature")?; + + let len: Option = if response.has("Transfer-Encoding") + && response.header("Transfer-Encoding").unwrap() == "chunked" + { + None + } else { + Some( + response + .header("Content-Length") + .ok_or(anyhow::anyhow!( + "No content length in server response for stub signature" + ))? + .parse()?, + ) + }; + + let mut reader = response.into_reader(); + + let mut binary = match len { + Some(len) => Vec::with_capacity(len), + None => Vec::new(), + }; + + reader.read_to_end(&mut binary)?; + + Ok(binary) + } +} + +impl LanzabooteSigner for RemoteSigningServer { + fn get_public_key(&self) -> Result> { + let response = self + .client + .get(self.server_url.join("/publickey")?.as_str()) + .set("User-Agent", &self.user_agent) + .set("Content-Type", "application/octet-stream") + .call() + .context("Failed to request public key")?; + + let len: Option = if response.has("Transfer-Encoding") + && response.header("Transfer-Encoding").unwrap() == "chunked" + { + None + } else { + Some( + response + .header("Content-Length") + .ok_or(anyhow::anyhow!( + "No content length in server response for stub signature" + ))? + .parse()?, + ) + }; + + let mut reader = response.into_reader(); + + let mut binary = match len { + Some(len) => Vec::with_capacity(len), + None => Vec::new(), + }; + + reader.read_to_end(&mut binary)?; + Ok(binary) + } + + fn can_sign_stub(&self, stub: &StubParameters) -> bool { + stub.all_signables_in_store() + } + + fn build_and_sign_stub(&self, stub: &StubParameters) -> Result> { + self.request_signature(stub) + } + fn sign_store_path(&self, store_path: &std::path::Path) -> Result> { + self.request_store_path_signature( + store_path.to_str().ok_or_else(|| { + anyhow::anyhow!("Failed to transform store path into valid UTF-8") + })?, + ) + } + + fn verify(&self, pe_binary: &[u8]) -> Result { + let resp: VerificationResponse = self + .client + .post(self.server_url.join("/verify")?.as_str()) + .set("User-Agent", &self.user_agent) + .set("Content-Type", "application/octet-stream") + .send_bytes(pe_binary) + .context("Failed to request verification")? + .into_json()?; + + Ok(resp.signed) + } +} diff --git a/rust/tool/systemd/src/cli.rs b/rust/tool/systemd/src/cli.rs index 90d91c03..be9ce2bb 100644 --- a/rust/tool/systemd/src/cli.rs +++ b/rust/tool/systemd/src/cli.rs @@ -1,16 +1,24 @@ use std::path::PathBuf; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; use crate::install; -use lanzaboote_tool::architecture::Architecture; -use lanzaboote_tool::signature::KeyPair; +use lanzaboote_tool::{ + architecture::Architecture, + signature::{local::LocalKeyPair, remote::RemoteSigningServer, LanzabooteSigner}, +}; /// The default log level. /// /// 2 corresponds to the level INFO. const DEFAULT_LOG_LEVEL: usize = 2; +/// Lanzaboote user agent +pub static USER_AGENT: &str = concat!( + "lanzaboote tool (backend: systemd, version: ", + env!("CARGO_PKG_VERSION"), + ")" +); #[derive(Parser)] pub struct Cli { @@ -30,6 +38,7 @@ enum Commands { } #[derive(Parser)] +#[command(group = clap::ArgGroup::new("local-keys").multiple(true).requires_all(["private_key", "public_key"]).conflicts_with("remote_signing_server_url"))] struct InstallCommand { /// System for lanzaboote binaries, e.g. defines the EFI fallback path #[arg(long)] @@ -44,12 +53,16 @@ struct InstallCommand { systemd_boot_loader_config: PathBuf, /// sbsign Public Key - #[arg(long)] - public_key: PathBuf, + #[arg(long, group = "local-keys")] + public_key: Option, /// sbsign Private Key + #[arg(long, group = "local-keys")] + private_key: Option, + + /// Remote signing server #[arg(long)] - private_key: PathBuf, + remote_signing_server_url: Option, /// Configuration limit #[arg(long, default_value_t = 1)] @@ -87,21 +100,40 @@ impl Commands { } } -fn install(args: InstallCommand) -> Result<()> { +fn install_with_signer(args: InstallCommand, signer: S) -> Result<()> { let lanzaboote_stub = std::env::var("LANZABOOTE_STUB").context("Failed to read LANZABOOTE_STUB env variable")?; - let key_pair = KeyPair::new(&args.public_key, &args.private_key); - install::Installer::new( PathBuf::from(lanzaboote_stub), Architecture::from_nixos_system(&args.system)?, args.systemd, args.systemd_boot_loader_config, - key_pair, + signer, args.configuration_limit, args.esp, args.generations, ) .install() } + +fn install(args: InstallCommand) -> Result<()> { + // Many bail are impossible because of Clap ensuring they don't happen. + // For completeness, we provide them. + if let Some(public_key) = &args.public_key { + if let Some(private_key) = &args.private_key { + let signer = LocalKeyPair::new(public_key, private_key); + install_with_signer(args, signer) + } else { + bail!("Missing private key for local signature scheme!"); + } + } else if let Some(_private_key) = &args.private_key { + bail!("Missing public key for local signature scheme!"); + } else if let Some(remote_signing_server_url) = &args.remote_signing_server_url { + let signer = RemoteSigningServer::new(remote_signing_server_url, USER_AGENT) + .expect("Failed to create a remote signing server"); + install_with_signer(args, signer) + } else { + bail!("No mechanism for signature was provided, pass either a local pair of keys or a remote signing server"); + } +} diff --git a/rust/tool/systemd/src/install.rs b/rust/tool/systemd/src/install.rs index a40dca44..d58117af 100644 --- a/rust/tool/systemd/src/install.rs +++ b/rust/tool/systemd/src/install.rs @@ -4,7 +4,6 @@ use std::fs::{self, File}; use std::os::fd::AsRawFd; use std::os::unix::prelude::{OsStrExt, PermissionsExt}; use std::path::{Path, PathBuf}; -use std::process::Command; use std::string::ToString; use anyhow::{anyhow, Context, Result}; @@ -21,31 +20,31 @@ use lanzaboote_tool::esp::EspPaths; use lanzaboote_tool::gc::Roots; use lanzaboote_tool::generation::{Generation, GenerationLink}; use lanzaboote_tool::os_release::OsRelease; -use lanzaboote_tool::pe; -use lanzaboote_tool::signature::KeyPair; +use lanzaboote_tool::pe::{self, append_initrd_secrets}; +use lanzaboote_tool::signature::LanzabooteSigner; use lanzaboote_tool::utils::{file_hash, SecureTempDirExt}; -pub struct Installer { +pub struct Installer { broken_gens: BTreeSet, gc_roots: Roots, lanzaboote_stub: PathBuf, systemd: PathBuf, systemd_boot_loader_config: PathBuf, - key_pair: KeyPair, + signer: S, configuration_limit: usize, esp_paths: SystemdEspPaths, generation_links: Vec, arch: Architecture, } -impl Installer { - #[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments)] +impl Installer { pub fn new( lanzaboote_stub: PathBuf, arch: Architecture, systemd: PathBuf, systemd_boot_loader_config: PathBuf, - key_pair: KeyPair, + signer: S, configuration_limit: usize, esp: PathBuf, generation_links: Vec, @@ -60,7 +59,7 @@ impl Installer { lanzaboote_stub, systemd, systemd_boot_loader_config, - key_pair, + signer, configuration_limit, esp_paths, generation_links, @@ -211,17 +210,27 @@ impl Installer { .context("Failed to install the kernel.")?; // Assemble and install the initrd, and record its path on the ESP. - let initrd_location = tempdir - .write_secure_file( - fs::read( - bootspec - .initrd - .as_ref() - .context("Lanzaboote does not support missing initrd yet.")?, + // It is not needed to write the initrd in a temporary directory + // if we do not have any initrd secret. + let initrd_location = if bootspec.initrd_secrets.is_some() { + tempdir + .write_secure_file( + fs::read( + bootspec + .initrd + .as_ref() + .context("Lanzaboote does not support missing initrd yet.")?, + ) + .context("Failed to read the initrd.")?, ) - .context("Failed to read the initrd.")?, - ) - .context("Failed to copy the initrd to the temporary directory.")?; + .context("Failed to copy the initrd to the temporary directory.")? + } else { + bootspec + .initrd + .clone() + .expect("Lanzaboote does not support missing initrd yet.") + }; + if let Some(initrd_secrets_script) = &bootspec.initrd_secrets { append_initrd_secrets(initrd_secrets_script, &initrd_location, generation.version)?; } @@ -232,29 +241,45 @@ impl Installer { // Assemble, sign and install the Lanzaboote stub. let os_release = OsRelease::from_generation(generation) .context("Failed to build OsRelease from generation.")?; - let os_release_path = tempdir - .write_secure_file(os_release.to_string().as_bytes()) - .context("Failed to write os-release file.")?; + + let os_release_contents = os_release.to_string(); + let kernel_cmdline = assemble_kernel_cmdline(&bootspec.init, bootspec.kernel_params.clone()); - let lanzaboote_image = pe::lanzaboote_image( - &tempdir, + + let parameters = pe::StubParameters::new( &self.lanzaboote_stub, - &os_release_path, - &kernel_cmdline, &bootspec.kernel, - &kernel_target, &initrd_location, + &kernel_target, &initrd_target, &self.esp_paths.esp, - ) - .context("Failed to assemble lanzaboote image.")?; + )? + .with_cmdline(&kernel_cmdline) + .with_os_release_contents(os_release_contents.as_bytes()); + + // TODO: how should we handle those cases? + if !self.signer.can_sign_stub(¶meters) { + log::warn!("Signer is not able to sign this stub, skipping..."); + return Ok(()); + } + + let lanzaboote_image = self + .signer + .build_and_sign_stub(¶meters) + .context("Failed to build and sign lanzaboote stub image.")?; + let lanzaboote_image_path = tempdir + .write_secure_file(lanzaboote_image) + .context("Failed to write securely the signed lanzaboote stub image.")?; + let stub_target = self .esp_paths .linux - .join(stub_name(generation, &self.key_pair.public_key)?); + .join(stub_name(generation, &self.signer)?); + self.gc_roots.extend([&stub_target]); - install_signed(&self.key_pair, &lanzaboote_image, &stub_target) + + install(&lanzaboote_image_path, &stub_target) .context("Failed to install the Lanzaboote stub.")?; Ok(()) @@ -267,7 +292,7 @@ impl Installer { let stub_target = self .esp_paths .linux - .join(stub_name(generation, &self.key_pair.public_key)?); + .join(stub_name(generation, &self.signer)?); let stub = fs::read(&stub_target)?; let kernel_path = resolve_efi_path( &self.esp_paths.esp, @@ -327,13 +352,13 @@ impl Installer { if newer_systemd_boot_available { log::info!("Updating {to:?}...") }; - let systemd_boot_is_signed = &self.key_pair.verify(to); + let systemd_boot_is_signed = &self.signer.verify_path(to)?; if !systemd_boot_is_signed { log::warn!("${to:?} is not signed. Replacing it with a signed binary...") }; if newer_systemd_boot_available || !systemd_boot_is_signed { - install_signed(&self.key_pair, from, to) + install_signed(&self.signer, from, to) .with_context(|| format!("Failed to install systemd-boot binary to: {to:?}"))?; } } @@ -361,15 +386,16 @@ fn resolve_efi_path(esp: &Path, efi_path: &[u8]) -> Result { /// Compute the file name to be used for the stub of a certain generation, signed with the given key. /// /// The generated name is input-addressed by the toplevel corresponding to the generation and the public part of the signing key. -fn stub_name(generation: &Generation, public_key: &Path) -> Result { +fn stub_name(generation: &Generation, signer: &S) -> Result { let bootspec = &generation.spec.bootspec.bootspec; + let public_key = signer.get_public_key()?; let stub_inputs = [ // Generation numbers can be reused if the latest generation was deleted. // To detect this, the stub path depends on the actual toplevel used. ("toplevel", bootspec.toplevel.0.as_os_str().as_bytes()), // If the key is rotated, the signed stubs must be re-generated. // So we make their path depend on the public key used for signature. - ("public_key", &fs::read(public_key)?), + ("public_key", &public_key), ]; let stub_input_hash = Base32Unpadded::encode_string(&Sha256::digest( serde_json::to_string(&stub_inputs).unwrap(), @@ -394,11 +420,11 @@ fn stub_name(generation: &Generation, public_key: &Path) -> Result { /// This is implemented as an atomic write. The file is first written to the destination with a /// `.tmp` suffix and then renamed to its final name. This is atomic, because a rename is an atomic /// operation on POSIX platforms. -fn install_signed(key_pair: &KeyPair, from: &Path, to: &Path) -> Result<()> { +fn install_signed(signer: &impl LanzabooteSigner, from: &Path, to: &Path) -> Result<()> { log::debug!("Signing and installing {to:?}..."); let to_tmp = to.with_extension(".tmp"); ensure_parent_dir(&to_tmp); - key_pair + signer .sign_and_copy(from, &to_tmp) .with_context(|| format!("Failed to copy and sign file from {from:?} to {to:?}"))?; fs::rename(&to_tmp, to).with_context(|| { @@ -435,26 +461,6 @@ fn force_install(from: &Path, to: &Path) -> Result<()> { Ok(()) } -pub fn append_initrd_secrets( - append_initrd_secrets_path: &Path, - initrd_path: &PathBuf, - generation_version: u64, -) -> Result<()> { - let status = Command::new(append_initrd_secrets_path) - .args(vec![initrd_path]) - .status() - .context("Failed to append initrd secrets")?; - if !status.success() { - return Err(anyhow::anyhow!( - "Failed to append initrd secrets for generation {} with args `{:?}`", - generation_version, - vec![append_initrd_secrets_path, initrd_path], - )); - } - - Ok(()) -} - fn assemble_kernel_cmdline(init: &Path, kernel_params: Vec) -> Vec { let init_string = String::from( init.to_str() diff --git a/rust/tool/systemd/tests/install.rs b/rust/tool/systemd/tests/install.rs index 90931147..46f5170e 100644 --- a/rust/tool/systemd/tests/install.rs +++ b/rust/tool/systemd/tests/install.rs @@ -52,6 +52,7 @@ fn do_not_overwrite_images() -> Result<()> { let output1 = common::lanzaboote_install(0, esp.path(), generation_links.clone())?; assert!(output1.status.success()); + assert!(verify_signature(&image1)?); remove_signature(&image1)?; assert!(!verify_signature(&image1)?); assert!(verify_signature(&image2)?);