Identity
SPIRE on spore gives each folly node with a TPM 2.0 an identity rooted in its TPM, by LDevID or EK hash, gives its pods SPIFFE IDs, and lets a pod reach GCP with no key file.
SPIRE gives folly nodes and their pods identities in the trust domain lolwtf.ca. Its server runs on spore.
A node's agent proves that it holds a trusted TPM. SPIRE accepts either a device certificate or an approved TPM endorsement key. The agent then gives credentials to authorized pods.
Terms#
| Term | Meaning |
|---|---|
| SPIFFE ID | The name of a node or workload, such as spiffe://lolwtf.ca/node/<name> |
| SVID | An X.509 or JWT credential that proves a SPIFFE identity |
| TPM | The hardware security module that holds a node's keys |
| EK | The TPM's endorsement key |
| LDevID | A device identity certificate for a key held in the TPM |
| LAK | The certified attestation key used to prove the node's boot state |
| DevID CA | The certificate authority that signs device identities |
Read Node lifecycle first for the installation sequence. The technical design explains the trust limits.
Server#
| Part | Job | Where it runs |
|---|---|---|
spire-server | Attests agents and signs SVIDs with its own CA, which lasts 24 hours. Agents reach it on TCP 8081 at spore.lolwtf.ca. | spore, data in /var/lib/spire |
tpm node attestor | Checks an agent's EK hash against /etc/spire/tpm-hashes, and proves that its attestation key is in the same TPM. It reads no PCRs. It runs while homelab.spire.ekHashes is not empty. | spore, the boxboat spire-tpm-plugin built in nix/lib/spire.nix |
tpm_devid node attestor | Checks the LDevID's chain against the DevID CA, the EK certificate's chain against the manufacturer CAs in nix/services/spire/roots/, and that the LDevID key and the EK are in one TPM. It runs when homelab.spire.devid.enable is true. | spore, built into SPIRE |
k8sbundle notifier | Writes the server's bundle into folly's spire/spire-bundle at startup and on every CA rotation, as the ServiceAccount spire-bundle-writer | spore, in the server, when homelab.spire.bundleNotifier.enable is true |
spire-server-entries | Creates, updates and prunes the registration entries to match git | spore, a oneshot after the server is healthy |
spire-server-fleet-entries | Registers the nodes fleetd lists, withdraws the ones it drops, and bans the agents of the ones it revokes | spore, a oneshot that a path unit starts when fleetd rewrites /var/lib/fleetd/spire-nodes.json, and a timer every 10 minutes |
spire-server-agent-count | Writes spire_server_attested_agents for the node exporter every minute | spore, a timer |
The server has no k8s_psat node attestor, so only a TPM can attest a node.
| SPIFFE ID | Issued to | Selector |
|---|---|---|
spiffe://lolwtf.ca/spire/agent/tpm/<hash> | An agent, by the EK plugin | tpm:pub_hash:<hash> |
spiffe://lolwtf.ca/spire/agent/tpm_devid/<fingerprint> | An agent, by tpm_devid | tpm_devid:subject:cn:<name>, tpm_devid:issuer:cn:<ca>, tpm_devid:ca:fingerprint:<sha1> |
spiffe://lolwtf.ca/node/<name> | A node alias, one entry for each attestor | tpm:pub_hash:<hash>, or tpm_devid:subject:cn:<name> |
spiffe://lolwtf.ca/ns/<ns>/sa/<sa> | A pod, parented to its node's alias | k8s:ns:<ns>, k8s:sa:<sa> |
The hash is the SHA-256 of the EK public key in DER form, in lowercase hex. The fingerprint is the SHA-1 of the LDevID certificate. The attest service sets the LDevID's subject to CN=<name>, serialNumber=<serial>, OU=<class>, O=<site>: the node's name, its SMBIOS serial, NetBox's class and the site. Every JWT-SVID carries the issuer https://spire.lolwtf.ca, set by homelab.spire.jwtIssuer.
SPIRE 1.15.5's tpm_devid gives the selectors in the table and no others: no selector names the subject's OU or O. So an alias cannot select a class or a site, and each machine gets its own alias by subject CN.
- Each node alias, by LDevID CN or EK hash, is a
fleet-*entry for a node fleetd lists inspire-nodes.json, so a node gets it with no deploy and loses it when fleetd withdraws it. spore declares none. - Change the EK allow list in
ekHashesand the workload entries inworkloadsofnix/hosts/spore.nix. The allow list admits an agent; its alias by EK hash comes from fleetd's file, as the LDevID alias does. spore declares no entry, and the sync deletes any entry whose ID starts withgit-and leaves entries made by hand. - An agent matches a node entry only when it has every selector, so a node has one alias for each attestor: by EK hash, and by LDevID CN. A node with no entry in
ekHashesattests only by LDevID. - Add a node's EK hash before its agent starts. The plugin refuses an EK that is not on the list.
homelab.spire.devid.caChainstakes one file for each issuing DevID CA,folly-devid-ca-<name>.peminterraform/pki/certs/. The server reads the first certificate of each file, so list the CA files and not the bundlefolly-devid-ca.pem. The fml intermediate or root in that pool would admit any certificate it signs.- Run the CLI as root with
-socketPath /run/spire/server/private/api.sock. Its default socket path is wrong for this server. - The firewall admits the agent port and the exporter only from folly's
K8S_NODE_CIDRandCILIUM_POD_CIDR. Cilium does not masquerade traffic to spore, so pods arrive from their own addresses.
fleetd entries#
Two owners write registration entries, and the entry ID says which.
| Owner | IDs | Holds |
|---|---|---|
Nix, spire-server-entries | git-* | The entries in homelab.spire.entries of nix/hosts/spore.nix, which declares none, so the sync prunes every git-* entry |
fleetd and spire-server-fleet-entries | fleet-* | The same aliases and workloads for each node in spire-nodes.json, and the bans of withdrawn and revoked nodes' agents |
Registration and withdrawal#
fleetd writes spire-nodes.json from NetBox. It has no SPIRE credential. The applier runs as root through the server's socket.
The applier accepts these values:
- Node names that match
[a-z0-9-]. - EK hashes with 64 lowercase hexadecimal characters.
- LDevID fingerprints with 40 lowercase hexadecimal characters.
A malformed file changes nothing. The same applies if a node or EK hash appears as both registered and revoked.
| Export list | Admission condition | Applier action |
|---|---|---|
nodes | The device is active and its verdict is attested. | Register its aliases and workloads. |
Absent from nodes | The node does not meet that condition. | Delete its fleet-* entries. Ban agents whose node selector has no remaining entry. |
revoked | fleetd holds a record, but the device is decommissioning, offline or absent from NetBox. | Ban matching agents, even if a git-* entry carries their selector. |
A revoked record includes the node name and EK hashes. It also includes the stored LDevID's SHA-1 fingerprint until the certificate's notAfter time.
The applier matches revoked agents by agent ID, EK hash or tpm_devid:subject:cn:<name>. The CN match covers renewed LDevIDs too. An EK hash held by a registered node is never revoked. A TPM can therefore move to a new node name.
Bans and readmission#
A banned agent cannot attest with the same agent ID. An unexpired LDevID or an allowed EK does not override the ban.
SPIRE has no unban operation. When the node registers again, the applier removes its banned agent record if nothing still revokes it. The agent can then attest again.
A new LDevID produces a new agent ID. An EK agent retains its hash-based ID, so it needs the removal step.
The applier also removes manual bans on registered nodes. To keep a node out, set its NetBox device to failed.
Credential expiry#
SPIRE checks no LDevID revocation beyond the ban. Setting decommissioning does not immediately end access:
- fleetd updates its export on the next pass.
- The path unit starts the applier. A 10-minute timer retries if the path unit misses the update.
- The applier bans the agent. Existing workload SVIDs remain valid until their TTL expires, normally one hour.
BanAgent needs an attested-node record. If an agent first attests after an applier run, the next run bans it.
Protection against bulk withdrawal#
One run can withdraw or ban agents for at most homelab.spire.fleetEntries.maxWithdrawals nodes. The default is one.
A node counts toward the limit if:
- It has a
fleet-*alias but is absent fromnodes. - The run bans one of its agents. This includes revoked nodes and listed nodes that lose an EK alias.
A bulk edit that clears ek_hash pins can exceed the limit. The applier then changes nothing and exits with an error. SporeSpireFleetEntriesFailed fires after 15 minutes.
The applier refuses further runs until the export is below the limit or the owner authorizes the withdrawal. The owner records authorized node names in /var/lib/spire-fleet-entries/withdraw on spore, as root, one name per line. The next run acts on those names and removes the file.
The applier bans agents before deleting entries. If it fails between these operations, the next run plans the bans again.
Source: nix/services/spire/fleet-entries.sh. mise run spire:test tests it against a stub server.
Duplicate registrations#
SPIRE rejects entries with identical parents, SPIFFE IDs and selectors.
The fleet applier skips an entry already supplied by a git-* or manual entry. The Nix sync deletes a duplicate fleet-* entry when its declaration supplies the same identity.
The declared entry wins in either order. The node keeps its alias while registration ownership changes.
The chain#
A node's identity starts before Talos runs. Each step relies on the one before it.
Technical design separates these proofs from installation authority and lists the threats that remain. A claimed serial and signed LLDP report do not prove chassis identity or physical location.
| Step | Proves | Where it runs |
|---|---|---|
| EK certificate | The TPM is a genuine Infineon or Nuvoton part | The TPM maker's CA, pinned in apps/fleetd/attest/roots/ |
| Credential activation | The attestation key (AK) is in that TPM | fleetd, from HookOS |
| Quote and event log | The machine booted with Secure Boot on, under the lab's PK, through the signed iPXE and HookOS | fleetd, against the profiles in apps/fleetd/attest/policy/ |
| TPM2_Certify | The LDevID key is in that TPM and cannot leave it | fleetd |
| LDevID and LAK certificates | The DevID CA vouches for the key and the AK | FML DevID folly CA, in PKI |
tpm_devid | The agent holds the LDevID key in a TPM with a genuine EK | spore's SPIRE server |
Provision a node runs the first five steps.
Lifecycle#
A folly node's identity follows its device's status in NetBox. fleetd and SPIRE read it.
| Status | Meaning | Written by |
|---|---|---|
planned | Ordered, with its serial known, or in attestation or install. It may carry an approved EK binding and ek_hash pins. | The owner, through the nodes file; fleetd, on a provision or wipe of an installed node |
active | Its config is released, and it is expected Ready | fleetd, when the node fetches its config |
failed | Held out by the owner | The owner |
decommissioning | Retiring: the disk is wiped, and the exports drop it | The owner |
offline | Retired, and kept for history | fleetd, after the retirement run succeeds |
Only the owner moves a device out of failed or into decommissioning. fleetd refuses a failed device.
Quarantine#
Quarantine changes the attestation verdict, not the device status.
| Evidence | Result |
|---|---|
| An authenticated worker quote fails policy | Set attest_verdict to quarantined: <reason>, refuse /devid with HTTP 403 and cordon the node. |
| A control-plane quote fails policy | Record failed-no-quarantine: <reason> and alert without a cordon. Fleet-owned SPIRE registration eligibility can also change. |
A device is decommissioning or offline | Refuse LDevID renewal. Its LDevID remains valid until expiry. |
A quarantined worker gets no new identity until the owner clears the verdict. Its next hourly check or renewal must prove both keys and pass policy. fleetd then restores its record and LDevID files. The owner uncordons the node.
Use Provision a node, Reprovision a node or Retire a node for the procedures.
Agents#
The DaemonSet spire-agent in the spire namespace runs on each folly node labelled lolwtf.ca/tpm=true. Its image, ghcr.io/jonpulsifer/spire-agent-tpm, adds the agent half of the EK plugin and spire-devid-init to SPIRE's agent.
Agent initialization#
The init container devid-init reads the SMBIOS serial. It fetches <ATTEST_URL>/devid/<serial> from fleetd on spore. The spire-attest ConfigMap supplies ATTEST_URL.
It selects tpm_devid only if the LDevID:
- Names this node and serial.
- Is current.
- Matches its key blob.
Otherwise, or if ATTEST_URL is empty, it selects the EK attestor.
The tpm_devid agent stores state in /var/lib/spire-agent/tpm_devid. The TPM's storage root key wraps the blobs. The agent derives that key from the TCG H-1 or H-2 template. The blobs cannot load on another TPM.
The container is privileged, since the device cgroup refuses /dev/tpmrm0 to any other container. It shares the host's PID namespace and network, so the k8s workload attestor can ask the kubelet for a caller's pod. Its key and SVID stay in /var/lib/spire-agent on the node, so a new pod resumes with no second attestation.
Pod access#
A pod reaches the Workload API at /run/spire/sockets/agent.sock through a hostPath volume, which needs a privileged namespace. A node with no label runs no agent, so its pods get no SVID. The Job spire-smoke prints the SPIFFE ID that the agent serves it.
- An agent's first attestation trusts the
spire-bundleConfigMap. spore's notifier pushes the bundle into it, and Flux declares nodata, so its server-side apply never overwrites the push. If the ConfigMap is empty or older than the server's CA, a new agent fails. - The notifier reads folly's API server from
cluster-topology.json, the CA fromclusters/folly/config/kubernetes-ca.pem, and the token fromspire/folly-bundle-writer-tokeninnix/secrets/spore.sops.yaml. If it cannot write the bundle at startup, the server exits and retries every 30 seconds. A push that fails at a rotation waits for the next rotation or restart. - Reloader restarts the agents when
spire-agentorspire-attestchanges, not when the bundle changes. An attested agent follows the rotations itself. - A workload gets an SVID only after spore registers its entry under its node's alias.
- A node pulls the agent image only while its GHCR package is public.
Add a TPM node to SPIRE admits a node by LDevID or EK hash, turns on tpm_devid, and turns on the bundle notifier.
Renewal#
The DaemonSet tpm-reattest in the spire namespace runs tink-attest renew -every 1h on each node labelled lolwtf.ca/tpm=true, as clusters/folly/apps/spire/tpm-reattest.yaml declares. It reads the LDevID and LAK certificates from NV 0x01c90000 and 0x01c90001. When the LDevID expires within 30 days, or NV holds the LAK and no LDevID, it renews the LDevID on fleetd's DevID listener, at the same ATTEST_URL as devid-init. fleetd issues the renewal from spore's YubiKey CA, whatever CA issued the LDevID on file. Otherwise it runs the hourly check, which the next section describes.
- It fetches
/devid/<serial>and checks that the key blobs are the keys the certificates in NV name. POST /v1/renewwith the serial answers a session and a nonce.- It loads the LAK and the LDevID key under the H-2 storage root key. With the LAK it quotes PCRs 0 to 7 and certifies the LDevID key, both qualified by the nonce.
POST /v1/renew/<session>/evidencecarries the quote, the PCR values, the event log from securityfs, and the certification. The service replays the log as at intake and answers a new certificate for the same key.- It checks that the certificate chains to a DevID CA in the ConfigMap
spire-devid-ca, certifies the same key, and names the node and its serial. The routes are plain HTTP, so this check is what keeps a forged answer out of NV. - It writes a renewed LAK certificate first, then the LDevID certificate, so a failed write leaves the renewal due for the next run. It then deletes the
spire-agentpod on its node, and fails when no pod matches. The new pod'sdevid-initfetches the new certificate.
scripts/render-spire-devid-ca.sh writes spire-devid-ca from terraform/pki/certs/folly-devid-ca.pem, and the kustomize workflow fails when the two differ. A run that fails runs again an hour later. A node with no LDevID in NV has nothing to renew. The ServiceAccount tpm-reattest may list and delete pods in spire. The ValidatingAdmissionPolicy tpm-reattest allows it to delete only a spire-agent pod on the node that its token names. TalosAttestLDevIDExpiring fires when an LDevID expires within 23 days, which is a week of failed renewals.
Hourly check#
On a day with no renewal due, the run takes steps 1 to 4 with check set in the request and the BIOS version from /sys/class/dmi/id/bios_version in the evidence. The service judges the boot by the renewal policy, records the verdict, last_attested and PCR 0 in NetBox, and answers no certificate, so NV and the spire-agent pod stay as they are.
A boot that misses the policy after the LAK's proof quarantines a worker: its attest_verdict reads quarantined: <reason>, and fleetd serves it no LDevID and cordons it. A controlplane node stays in place and alerts at critical, so the cluster's API stays up. fleetd has the rules.
A run opens /dev/tpmrm0 and reads the event log before it sends anything. Without the TPM it logs NO TPM, sends nothing, and tries again an hour later; a node whose TPM is off has no device to mount, so its pod does not start. A service that predates the check answers without check, and the run sends it no evidence. TalosAttestNotChecked fires when fleetd has recorded no passing quote from a node for 3 hours.
GCP access#
| Part | Job | Where it runs |
|---|---|---|
oidc-discovery-provider | Publishes the keys that check JWT-SVIDs, as the issuer https://spire.lolwtf.ca | Two pods in spire-oidc, each on a different labelled node |
| folly Cloudflare Tunnel | Serves /.well-known/openid-configuration and /keys on spire.lolwtf.ca, and no other path | Cloudflare, to the provider's Service |
fml-pool provider spire | Trades a JWT-SVID for a GCP token whose subject is the SPIFFE ID | GCP |
A workload gets GCP access in four steps.
- spore registers its SPIFFE ID.
- A grant in
terraform/gcp/projects/homelab-ng/names the principalprincipal://iam.googleapis.com/<fml-pool>/subject/spiffe://lolwtf.ca/ns/<ns>/sa/<sa>. - The pod mounts the agent socket and runs
spire-agent api fetch jwtwith the audience//iam.googleapis.com/<fml-pool>/providers/spire. - The pod writes the SVID to a file, and an
external_accountcredential config whosecredential_sourceis that file. With noservice_account_impersonation_url, GCP sees the SPIFFE ID itself.
<fml-pool> is projects/<project-number>/locations/global/workloadIdentityPools/fml-pool. A JWT-SVID is short-lived, so a long-running workload rewrites the file before it expires.
The test Job in spire-test runs as uploader, which can only create objects in the bucket homelab-ng-spire-test. Its log names the SPIFFE ID and the object it wrote. Bump the suffix of the Job's name to run it again.
- The provider
spireadmits every SPIFFE ID in the trust domain. Grant on one subject, never on the pool or on anattribute.spiffe_idprefix. - The server's
jwt_issuer, the discovery provider'sjwt_issuerand the provider'sissuer_uriare one value. GCP rejects an SVID whoseissdiffers. - The discovery provider reads its keys through the Workload API as
spire-oidc/oidc-discovery-provider. Without that entry,spire.lolwtf.caserves no keys and every exchange fails. iam.workloadIdentityPoolProvidersinterraform/gcp/projects/homelab-ng/policy.tflists the issuer. A new issuer needs an entry there first.
Live state#
riptide, surge and swell carry the lolwtf.ca/tpm=true label. riptide's and surge's agents attest by their LDevIDs, and fleetd lists both nodes. swell is down. optiplex has no SPIRE identity: its node has no /dev/tpmrm0, so tpm_off in clusters/folly/talos/nodes.tf keeps the label off it, its EK hash is off the allow list, and fleetd does not list it. The allow list holds riptide's EK hash. shale has a TPM 1.2 and runs no agent. The smoke Job is suspended.
Where it lives#
nix/services/spire-server.nix: the server, both node attestors, the bundle notifier, the entry sync, the agent count and the firewallnix/services/spire/roots/: the Infineon and Nuvoton EK CAs thattpm_devidtrusts, the same files asapps/fleetd/attest/roots/apps/fleetd/andnix/services/fleetd.nix: fleetd and its deployment on sporenix/hosts/spore.nix: the EK allow listekHashesand theworkloadsnix/lib/spire.nix: the SPIRE and plugin builds, withnix/lib/spire-tpm-plugin-sdk.patchclusters/folly/apps/: the agent, its bundle, the bundle writer,tpm-reattestand the smoke Job inspire/, the discovery provider inspire-oidc/, and the GCP test inspire-test/images/: the agent image inspire-agent-tpm/, withspire-devid-initinspire-agent-tpm/devid-init/terraform/gcp/projects/homelab-ng/workload-identity.tf: the providerspireinfml-pool, with the test bucket and its grant inspire-test.tfbeside itterraform/network/cloudflare/lolwtf.ca.tf: the tunnel rule forspire.lolwtf.caclusters/folly/monitoring/spore.yaml: the scrape and the SPIRE alertsclusters/folly/config/cluster-topology.json:K8S_NODE_CIDR,CILIUM_POD_CIDR,API_SERVER_HOSTNAME,API_SERVER_PORT
Related#
Linked from 12
- fleetd attestation and installationThe checks that authorize a node and deliver its configuration.
- KeylimeThe remote attestation verifier for folly's running nodes, being introduced beside fleetd's own check.
- sporeA Raspberry Pi 5 on NVMe that serves x86 netboot, forge's HTTP boot image, the Talos boot image and Tinkerbell, lab DNS and time, and the SPIRE server.
- Cloud accountsThe Google Cloud projects, Cloudflare zones and Google Workspace domain that OpenTofu declares, and what each one is for.
- PlatformThe shared systems that the apps and hosts depend on, and what applies each layer from git.
- Ingress and DNSThe Gateways, DNS records, certificates, tunnels and lab resolvers that give cluster services a name and a path from the LAN, tailnet or internet.
- Routing and firewallThe BGP routes and zone firewall that connect the folly and offsite clusters through the UniFi gateways, and folly's node ports and boot network.
- Node lifecycleHow a folly node goes from a NetBox device to an attested Talos node with a SPIRE identity, and back out, with the system that owns each step.
- PKIThe FML certificate chain, the Kubernetes cluster CAs and token signers, the cluster OIDC issuers, workload identity federation and the hardware DevID CAs.
- Folly node lifecycle and trust designThe technical design, tradeoffs, evidence and threat boundaries for node provisioning, trusted boot and workload identity at folly.
- Add a TPM node to SPIREAdmit a folly node with a TPM 2.0 to spore's SPIRE server by its LDevID, or by its endorsement key hash as the fallback, and make sure its agent serves SVIDs.
- Retire a nodeDrain a folly worker, back up its volumes, and set its NetBox device to decommissioning, so that fleetd wipes it, then remove its entry from the nodes file.