nixos/virtualisation: Add support for passing credentials

Allows you to declaratively set systemd credentials
passed into the VM
This commit is contained in:
Arian van Putten
2025-08-22 15:46:28 +02:00
parent f0ed9a5495
commit b44f36a96d
4 changed files with 198 additions and 21 deletions
+87
View File
@@ -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 [
+8
View File
@@ -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;
+83
View File
@@ -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}")
'';
}
+20 -21
View File
@@ -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")
'';
}