From e27ef5378cdea60940f246675e7f663cb76ac5e3 Mon Sep 17 00:00:00 2001 From: Arian van Putten Date: Fri, 24 Apr 2026 11:14:17 +0200 Subject: [PATCH] nixos/spire: add spire-tpm-plugin support --- .../services/security/spire/agent-tpm.nix | 34 ++++ .../modules/services/security/spire/agent.nix | 2 + .../services/security/spire/server-tpm.nix | 59 +++++++ .../services/security/spire/server.nix | 2 + nixos/tests/spire.nix | 165 +++++++++++++++--- 5 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 nixos/modules/services/security/spire/agent-tpm.nix create mode 100644 nixos/modules/services/security/spire/server-tpm.nix diff --git a/nixos/modules/services/security/spire/agent-tpm.nix b/nixos/modules/services/security/spire/agent-tpm.nix new file mode 100644 index 000000000000..6630050aee57 --- /dev/null +++ b/nixos/modules/services/security/spire/agent-tpm.nix @@ -0,0 +1,34 @@ +{ + lib, + pkgs, + config, + ... +}: +let + cfg = config.services.spire.agent; +in +{ + options.services.spire.agent.settings.plugins.NodeAttestor.tpm = lib.mkOption { + default = null; + description = "TPM 2.0 node attestation plugin. When set, automatically enables security.tpm2 and grants the spire-agent user access to the TPM device."; + type = lib.types.nullOr ( + lib.types.submodule { + freeformType = (pkgs.formats.hcl1 { }).type; + options.plugin_cmd = lib.mkOption { + type = lib.types.str; + default = lib.getExe' pkgs.spire-tpm-plugin "tpm_attestor_agent"; + defaultText = lib.literalExpression ''lib.getExe' pkgs.spire-tpm-plugin "tpm_attestor_agent"''; + description = "Path to the TPM attestor agent plugin binary."; + }; + } + ); + }; + + config = lib.mkIf (cfg.enable && cfg.settings.plugins.NodeAttestor.tpm != null) { + security.tpm2.enable = true; + + systemd.services.spire-agent.serviceConfig.SupplementaryGroups = [ + config.security.tpm2.tssGroup + ]; + }; +} diff --git a/nixos/modules/services/security/spire/agent.nix b/nixos/modules/services/security/spire/agent.nix index b1b6815d6d5a..dc5cfa7a992e 100644 --- a/nixos/modules/services/security/spire/agent.nix +++ b/nixos/modules/services/security/spire/agent.nix @@ -125,6 +125,8 @@ in }; }; + imports = [ ./agent-tpm.nix ]; + config = lib.mkIf cfg.enable { environment.systemPackages = [ cfg.package ]; diff --git a/nixos/modules/services/security/spire/server-tpm.nix b/nixos/modules/services/security/spire/server-tpm.nix new file mode 100644 index 000000000000..1d8c7c4a1b95 --- /dev/null +++ b/nixos/modules/services/security/spire/server-tpm.nix @@ -0,0 +1,59 @@ +{ + lib, + pkgs, + ... +}: +let + format = pkgs.formats.hcl1 { }; +in +{ + options.services.spire.server.settings.plugins.NodeAttestor.tpm = lib.mkOption { + default = null; + description = '' + TPM 2.0 node attestation plugin from + [spire-tpm-plugin](https://github.com/spiffe/spire-tpm-plugin). + ''; + type = lib.types.nullOr ( + lib.types.submodule { + freeformType = format.type; + options = { + plugin_cmd = lib.mkOption { + type = lib.types.str; + default = lib.getExe' pkgs.spire-tpm-plugin "tpm_attestor_server"; + defaultText = lib.literalExpression ''lib.getExe' pkgs.spire-tpm-plugin "tpm_attestor_server"''; + description = "Path to the TPM attestor server plugin binary."; + }; + plugin_data = lib.mkOption { + default = { }; + description = '' + Plugin data for the TPM NodeAttestor. Either `ca_path`, + `hash_path`, or both must be configured. + ''; + type = lib.types.submodule { + freeformType = format.type; + options = { + ca_path = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + The path to the CA directory. Contains the manufacturer CA + cert that signed the TPM's EK certificate in PEM or DER + format. + ''; + }; + hash_path = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = '' + The path to the Hash directory. Contains empty files named + after the EK public key hash. + ''; + }; + }; + }; + }; + }; + } + ); + }; +} diff --git a/nixos/modules/services/security/spire/server.nix b/nixos/modules/services/security/spire/server.nix index 834403b6f084..53e232988450 100644 --- a/nixos/modules/services/security/spire/server.nix +++ b/nixos/modules/services/security/spire/server.nix @@ -11,6 +11,8 @@ in { meta.maintainers = [ lib.maintainers.arianvp ]; + imports = [ ./server-tpm.nix ]; + options.services.spire.server = { enable = lib.mkEnableOption "SPIRE Server"; diff --git a/nixos/tests/spire.nix b/nixos/tests/spire.nix index e23732faa099..dfdeba26e635 100644 --- a/nixos/tests/spire.nix +++ b/nixos/tests/spire.nix @@ -1,5 +1,51 @@ +{ pkgs, ... }: let trustDomain = "example.com"; + + # TODO: tpm-ek test has similar stuff. Also the whole tpm provisioning should probably + # just use the vtpm provisioning commands in the future? + + # OpenSSL config to sign the swtpm EK with TPM-specific certificate extensions + ekSignConf = pkgs.writeText "ek-sign.cnf" '' + [ tpm_policy ] + basicConstraints = critical, CA:FALSE + keyUsage = critical, keyEncipherment + certificatePolicies = 2.23.133.2.1 + extendedKeyUsage = 2.23.133.8.1 + subjectAltName = ASN1:SEQUENCE:dirname_tpm + + [ dirname_tpm ] + seq = EXPLICIT:4,SEQUENCE:dirname_tpm_seq + + [ dirname_tpm_seq ] + set = SET:dirname_tpm_set + + [ dirname_tpm_set ] + seq.1 = SEQUENCE:dirname_tpm_seq_manufacturer + seq.2 = SEQUENCE:dirname_tpm_seq_model + seq.3 = SEQUENCE:dirname_tpm_seq_version + + [dirname_tpm_seq_manufacturer] + oid = OID:2.23.133.2.1 + str = UTF8:"id:53544D20" + + [dirname_tpm_seq_model] + oid = OID:2.23.133.2.2 + str = UTF8:"ST33HTPHAHD4" + + [dirname_tpm_seq_version] + oid = OID:2.23.133.2.3 + str = UTF8:"id:00010101" + ''; + + agent = + { config, ... }: + { + environment.variables.SPIFFE_ENDPOINT_SOCKET = + config.services.spire.agent.settings.agent.socket_path; + virtualisation.credentials."spire.trust_bundle".source = "./trust_bundle"; + systemd.services.spire-agent.serviceConfig.ImportCredential = [ "spire.trust_bundle" ]; + }; in { name = "spire"; @@ -9,6 +55,7 @@ in { config, ... }: { networking.domain = trustDomain; + environment.etc."spire/server/certs/tpm-ca.crt".source = ./tpm-ek/ca.crt; services.spire.server = { enable = true; openFirewall = true; @@ -21,21 +68,17 @@ in connection_string = "$STATE_DIRECTORY/datastore.sqlite3"; }; NodeAttestor.join_token.plugin_data = { }; + NodeAttestor.tpm.plugin_data.ca_path = "/etc/spire/server/certs"; }; }; }; }; agent = { - virtualisation.credentials = { - "spire.join_token".source = "./join_token"; - "spire.trust_bundle".source = "./trust_bundle"; - }; + imports = [ agent ]; - systemd.services.spire-agent.serviceConfig.ImportCredential = [ - "spire.join_token" - "spire.trust_bundle" - ]; + virtualisation.credentials."spire.join_token".source = "./join_token"; + systemd.services.spire-agent.serviceConfig.ImportCredential = [ "spire.join_token" ]; services.spire.agent = { enable = true; @@ -56,44 +99,116 @@ in }; }; }; + + tpmAgent = + { pkgs, lib, ... }: + { + imports = [ agent ]; + virtualisation = { + useEFIBoot = true; + tpm = { + enable = true; + # Provision the swtpm with an EK certificate signed by testCA so that + # the SPIRE server can verify the agent's identity. + provisioning = '' + export PATH=${ + lib.makeBinPath [ + pkgs.openssl + pkgs.tpm2-tools + ] + }:$PATH + + tpm2_createek -G rsa -u ek.pub -c ek.ctx -f pem + + openssl x509 \ + -extfile ${ekSignConf} \ + -new -days 365 \ + -subj "/CN=swtpm-ekcert" \ + -extensions tpm_policy \ + -CA ${./tpm-ek/ca.crt} -CAkey ${./tpm-ek/ca.priv} \ + -out ekcert.der -outform der \ + -force_pubkey ek.pub + + tpm2_nvdefine 0x01c00002 \ + -C o \ + -a "ownerread|policyread|policywrite|ownerwrite|authread|authwrite" \ + -s "$(wc -c < ekcert.der)" + + tpm2_nvwrite 0x01c00002 -C o -i ekcert.der + ''; + }; + }; + + environment.systemPackages = [ pkgs.spire-tpm-plugin ]; + + services.spire.agent = { + enable = true; + settings = { + agent = { + trust_domain = trustDomain; + server_address = "server.${trustDomain}"; + trust_bundle_format = "pem"; + trust_bundle_path = "$CREDENTIALS_DIRECTORY/spire.trust_bundle"; + }; + plugins = { + KeyManager.memory.plugin_data = { }; + NodeAttestor.tpm.plugin_data = { }; + WorkloadAttestor.systemd.plugin_data = { }; + WorkloadAttestor.unix.plugin_data = { }; + }; + }; + }; + }; }; testScript = { nodes, ... }: let adminSocket = nodes.server.services.spire.server.settings.server.socket_path; - workloadSocket = nodes.agent.services.spire.agent.settings.agent.socket_path; in '' - # TODO: instead of trust bundle to talk to the spire-server, use an upstream CA? - def provision(agent, spiffe_id): + spiffe_id = "spiffe://${trustDomain}/service/backdoor" - # expose as system credentials + def provision_trust_bundle(agent): + # TODO: instead of trust bundle to talk to the spire-server, use an upstream CA? bundle = server.succeed("spire-server bundle show -socketPath ${adminSocket}") with open(agent.state_dir / "trust_bundle", "w") as f: f.write(bundle) + + + def provision_join_token(agent): join_token = server.succeed("spire-server token generate -socketPath ${adminSocket}").split()[1] with open(agent.state_dir / "join_token", "w") as f: f.write(join_token) + parent_id = f"spiffe://${trustDomain}/spire/agent/join_token/{join_token}" + server.succeed(f"spire-server entry create -socketPath ${adminSocket} -selector 'systemd:id:backdoor.service' -parentID {parent_id} -spiffeID {spiffe_id}") + + + def provision_tpm(agent): + agent.wait_for_unit("tpm2.target") + ek_hash = agent.succeed("get_tpm_pubhash").strip() + parent_id = f"spiffe://${trustDomain}/spire/agent/tpm/{ek_hash}" + server.succeed(f"spire-server entry create -socketPath ${adminSocket} -selector 'systemd:id:backdoor.service' -parentID '{parent_id}' -spiffeID {spiffe_id}") + + + def test_agent(name, agent_node, provision_fn): + with subtest(f"Setup SPIRE agent with {name} attestation"): + provision_trust_bundle(agent_node) + provision_fn(agent_node) + agent_node.wait_for_unit("spire-agent.service") + agent_node.wait_until_succeeds("spire-agent healthcheck -socketPath $SPIFFE_ENDPOINT_SOCKET", timeout=90) + with subtest(f"Test certificate authentication from {name} agent"): + agent_node.wait_until_succeeds("spire-agent api fetch x509 -socketPath $SPIFFE_ENDPOINT_SOCKET -write .") + # TODO: Add something to communicate with - # register a workload on the node - parent_id=f"spiffe://${trustDomain}/spire/agent/join_token/{join_token}" - server.succeed(f"spire-server entry create -socketPath ${adminSocket} -selector 'systemd:id:backdoor.service' -parentID {parent_id} -spiffeID 'spiffe://${trustDomain}/service/backdoor'") with subtest("SPIRE server startup and health checks"): server.wait_for_unit("spire-server.service") - server.wait_until_succeeds("spire-server healthcheck -socketPath ${adminSocket}", timeout=5) + server.wait_until_succeeds("spire-server healthcheck -socketPath ${adminSocket}", timeout=30) - with subtest("Setup SPIRE agent on agent node"): - provision(agent, "spiffe://${trustDomain}/server/agent") - agent.wait_for_unit("spire-agent.service") - agent.wait_until_succeeds("spire-agent healthcheck -socketPath ${workloadSocket}", timeout=5) + test_agent("join_token", agent, provision_join_token) + test_agent("tpm", tpmAgent, provision_tpm) - - with subtest("Test certificate authentication from agent node"): - agent.succeed("spire-agent api fetch x509 -socketPath ${workloadSocket} -write .") - - # TODO: Add something to communicate with ''; }