nixos/spire: add spire-tpm-plugin support

This commit is contained in:
Arian van Putten
2026-04-27 14:37:44 +02:00
parent 89952b521a
commit e27ef5378c
5 changed files with 237 additions and 25 deletions
@@ -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
];
};
}
@@ -125,6 +125,8 @@ in
};
};
imports = [ ./agent-tpm.nix ];
config = lib.mkIf cfg.enable {
environment.systemPackages = [ cfg.package ];
@@ -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.
'';
};
};
};
};
};
}
);
};
}
@@ -11,6 +11,8 @@ in
{
meta.maintainers = [ lib.maintainers.arianvp ];
imports = [ ./server-tpm.nix ];
options.services.spire.server = {
enable = lib.mkEnableOption "SPIRE Server";
+140 -25
View File
@@ -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
'';
}