diff --git a/nixos/modules/virtualisation/qemu-vm.nix b/nixos/modules/virtualisation/qemu-vm.nix index 18d0a9937152..abded7525062 100644 --- a/nixos/modules/virtualisation/qemu-vm.nix +++ b/nixos/modules/virtualisation/qemu-vm.nix @@ -1143,6 +1143,85 @@ in ''; }; + virtualisation.credentials = mkOption { + description = '' + Credentials to pass to the VM using systemd's credential system. + + See {manpage}`systemd.exec(5)` , {manpage}`systemd-creds(1)` and https://systemd.io/CREDENTIALS/ for more + information about systemd credentials. + ''; + default = { }; + example = { + database-password = { + text = "my-secret-password"; + }; + ssl-cert = { + source = "./cert.pem"; + }; + binary-key = { + mechanism = "fw_cfg"; + source = "./private.der"; + }; + config-file = { + mechanism = "smbios"; + text = '' + [database] + host=localhost + port=5432 + ''; + }; + }; + type = types.attrsOf ( + lib.types.submodule ( + { + name, + options, + config, + ... + }: + { + options = { + mechanism = lib.mkOption { + type = lib.types.enum [ + "fw_cfg" + "smbios" + ]; + default = if pkgs.stdenv.hostPlatform.isx86 then "smbios" else "fw_cfg"; + defaultText = lib.literalExpression ''if pkgs.stdenv.hostPlatform.isx86 then "smbios" else "fw_cfg"''; + description = '' + The mechanism used to pass the credential to the VM. + ''; + }; + source = lib.mkOption { + type = lib.types.nullOr (lib.types.pathWith { }); + default = null; + description = '' + Source file on the host containing the credential data. + ''; + }; + text = lib.mkOption { + default = null; + type = lib.types.nullOr lib.types.str; + description = '' + Text content of the credential. + + For binary data or when the credential content should come from + an existing file, use `source` instead. + + ::: {.warning} + The text here is stored in the host's nix store as a file. + ::: + ''; + }; + }; + config.source = lib.mkIf (config.text != null) ( + lib.mkDerivedConfig options.text (pkgs.writeText name) + ); + } + ) + ); + }; + }; config = { @@ -1331,6 +1410,14 @@ in "-global" "driver=cfi.pflash01,property=secure,value=on" ]) + (lib.mapAttrsToList ( + name: cred: + if cred.mechanism == "fw_cfg" then + "-fw_cfg name=opt/io.systemd.credentials/${name},file=${cred.source}" + # smbios - must use base64 encoding (SMBIOS can't handle null bytes) + else + "-smbios type=11,path=<(echo 'io.systemd.credential.binary:${name}='; base64 -w0 '${cred.source}')" + ) cfg.credentials) ]; virtualisation.qemu.drives = mkMerge [ diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index bbb72047c109..2d464d63e184 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1311,6 +1311,14 @@ in pyload = runTest ./pyload.nix; qbittorrent = runTest ./qbittorrent.nix; qboot = handleTestOn [ "x86_64-linux" "i686-linux" ] ./qboot.nix { }; + qemu-vm-credentials-fwcfg = runTest { + imports = [ ./qemu-vm-credentials.nix ]; + _module.args.mechanism = "fw_cfg"; + }; + qemu-vm-credentials-smbios = runTestOn [ "x86_64-linux" ] { + imports = [ ./qemu-vm-credentials.nix ]; + _module.args.mechanism = "smbios"; + }; qemu-vm-external-disk-image = runTest ./qemu-vm-external-disk-image.nix; qemu-vm-restrictnetwork = handleTest ./qemu-vm-restrictnetwork.nix { }; qemu-vm-store = runTest ./qemu-vm-store.nix; diff --git a/nixos/tests/qemu-vm-credentials.nix b/nixos/tests/qemu-vm-credentials.nix new file mode 100644 index 000000000000..9ee2d270d8f0 --- /dev/null +++ b/nixos/tests/qemu-vm-credentials.nix @@ -0,0 +1,83 @@ +{ + lib, + pkgs, + mechanism, + ... +}: + +let + secret = '' + foo + bar + baz + ''; + secret-file = "bar"; +in + +{ + name = "qemu-vm-credentials-${mechanism}"; + + meta.maintainers = with lib.maintainers; [ arianvp ]; + + nodes = { + machine = { + virtualisation.credentials = { + secret = { + inherit mechanism; + text = secret; + }; + secret-default-mechanism = { + text = "default-mechanism"; + }; + secret-file-nix-store = { + inherit mechanism; + source = pkgs.writeText "secret-file-nix-store" secret-file; + }; + secret-file-host = { + inherit mechanism; + source = "./secret-file-host"; + }; + secret-file-host-binary = { + inherit mechanism; + source = "./secret-file-host-binary"; + }; + }; + }; + }; + + testScript = '' + import base64 + secret_file_host = "baz" + # Binary data with null bytes, high bytes, and all sorts of problematic characters + secret_file_host_binary = bytes([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, # null and control chars + 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, + 0xDE, 0xAD, 0xBE, 0xEF, # classic binary pattern + 0xFF, 0xFE, 0xFD, 0xFC, # high bytes + 0x00, 0x00, 0x00, 0x00, # multiple nulls + 0x80, 0x81, 0x82, 0x83, # more high bytes + ]) + + with open(machine.state_dir / "secret-file-host", "w") as f: + f.write(secret_file_host) + with open(machine.state_dir / "secret-file-host-binary", "wb") as f2: + f2.write(secret_file_host_binary) + + + # Test text credential + t.assertEqual(machine.succeed("systemd-creds --system cat secret").strip(), "foo\nbar\nbaz") + + t.assertEqual(machine.succeed("systemd-creds --system cat secret-default-mechanism").strip(), "default-mechanism") + + # Test credential from nix store + t.assertEqual(machine.succeed("systemd-creds --system cat secret-file-nix-store").strip(), "${secret-file}") + + # Test credential from host file + t.assertEqual(machine.succeed("systemd-creds --system cat secret-file-host").strip(), secret_file_host) + + # Test binary credential - verify exact binary content + result = machine.succeed("systemd-creds --system cat secret-file-host-binary --transcode=base64").strip() + expected = base64.b64encode(secret_file_host_binary).decode('ascii') + t.assertEqual(result, expected, f"Binary credential mismatch: got {result}, expected {expected}") + ''; +} diff --git a/nixos/tests/systemd-initrd-credentials.nix b/nixos/tests/systemd-initrd-credentials.nix index 50893d2cec78..7904ddc58476 100644 --- a/nixos/tests/systemd-initrd-credentials.nix +++ b/nixos/tests/systemd-initrd-credentials.nix @@ -1,32 +1,31 @@ -{ lib, pkgs, ... }: { name = "systemd-initrd-credentials"; - nodes.machine = - { pkgs, ... }: - { - virtualisation = { - qemu.options = [ - "-smbios type=11,value=io.systemd.credential:cred-smbios=secret-smbios" - ]; - }; + nodes.machine = { + testing.initrdBackdoor = true; - boot.initrd.availableKernelModules = [ "dmi_sysfs" ]; - - boot.kernelParams = [ "systemd.set_credential=cred-cmdline:secret-cmdline" ]; - - boot.initrd.systemd = { - enable = true; - }; + virtualisation.credentials.cred-test.text = "secret-test"; + virtualisation.credentials.cred-test-fw_cfg = { + mechanism = "fw_cfg"; + text = "secret-fw_cfg"; }; + boot.initrd.availableKernelModules = [ + "dmi_sysfs" + "qemu_fw_cfg" + ]; + + boot.kernelParams = [ "systemd.set_credential=cred-cmdline:secret-cmdline" ]; + + boot.initrd.systemd.enable = true; + }; + testScript = '' - machine.wait_for_unit("multi-user.target") + machine.wait_for_unit("initrd.target") - # Check credential passed via kernel command line - assert "secret-cmdline" in machine.succeed("systemd-creds --system cat cred-cmdline") + t.assertIn("secret-cmdline", machine.succeed("systemd-creds --system cat cred-cmdline")) + t.assertIn("secret-test", machine.succeed("systemd-creds --system cat cred-test")) + t.assertIn("secret-fw_cfg", machine.succeed("systemd-creds --system cat cred-test-fw_cfg")) - # Check credential passed via SMBIOS - assert "secret-smbios" in machine.succeed("systemd-creds --system cat cred-smbios") ''; }