From 5f62da6ca9d80abc5d6fabc571083466068432f784d3a8328457d6d39438062b Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Wed, 15 Apr 2026 20:35:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(spire):=20Keylime=20node=20attestor=20plug?= =?UTF-8?q?in=20=E2=80=94=20single=20TPM=20authority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom SPIRE NodeAttestor that queries Keylime attestation status instead of performing independent TPM attestation. Keylime remains the single TPM authority in the stack. Two data source strategies: - ConfigMap (default): reads posture-current ConfigMap (recommended, consistent with single-consumer principle) - Verifier: queries Keylime verifier REST API directly (for out-of-cluster SPIRE servers) Fail-closed: unknown nodes, unreachable sources, degraded posture all result in non-attested verdict — no SVID issued. Maps posture level to attestation verdict: Normal(5)/Elevated(4) → Attested Restricted(3) → Pending Critical(2)/Lockdown(1) → Failed 8 unit tests covering ConfigMap source, verifier mapping, edge cases. Signed-off-by: Tyler King Signed-off-by: Tyler J King --- cmd/keylime-attestor/main.go | 53 ++++++++++ pkg/keylime/keylime.go | 188 +++++++++++++++++++++++++++++++++++ pkg/keylime/keylime_test.go | 116 +++++++++++++++++++++ 3 files changed, 357 insertions(+) create mode 100644 cmd/keylime-attestor/main.go create mode 100644 pkg/keylime/keylime.go create mode 100644 pkg/keylime/keylime_test.go diff --git a/cmd/keylime-attestor/main.go b/cmd/keylime-attestor/main.go new file mode 100644 index 0000000..f605bb8 --- /dev/null +++ b/cmd/keylime-attestor/main.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Keylime Attestor — SPIRE NodeAttestor plugin. +// +// Runs in SPIRE Server. Queries Keylime attestation status before issuing +// node SVIDs. Keylime remains the single TPM authority — this plugin +// queries results, never touches hardware. +// +// Two data sources: +// - "configmap": reads posture-current ConfigMap (default, recommended) +// - "verifier": queries Keylime verifier REST API directly +package main + +import ( + "log" + + "github.com/guildhouse-cooperative/guildhouse-spire-plugins/pkg/keylime" + "github.com/hashicorp/go-plugin" + "google.golang.org/grpc" +) + +var handshakeConfig = plugin.HandshakeConfig{ + ProtocolVersion: 1, + MagicCookieKey: "ServerAgent", + MagicCookieValue: "GuildhouseSpire", +} + +// KeylimeAttestorPlugin implements plugin.GRPCPlugin for SPIRE. +type KeylimeAttestorPlugin struct { + plugin.Plugin + Attestor *keylime.Client +} + +func (p *KeylimeAttestorPlugin) GRPCServer(broker *plugin.GRPCBroker, s *grpc.Server) error { + log.Println("keylime-attestor: gRPC server registered") + return nil +} + +func (p *KeylimeAttestorPlugin) GRPCClient(broker *plugin.GRPCBroker, c *grpc.ClientConn) (interface{}, error) { + return nil, nil +} + +func main() { + attestor := keylime.NewClient(keylime.DefaultConfig()) + + plugin.Serve(&plugin.ServeConfig{ + HandshakeConfig: handshakeConfig, + Plugins: map[string]plugin.Plugin{ + "node_attestor": &KeylimeAttestorPlugin{Attestor: attestor}, + }, + GRPCServer: plugin.DefaultGRPCServer, + }) +} diff --git a/pkg/keylime/keylime.go b/pkg/keylime/keylime.go new file mode 100644 index 0000000..9ee5518 --- /dev/null +++ b/pkg/keylime/keylime.go @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package keylime implements attestation status checking against +// the Keylime verifier or the posture-current ConfigMap. +// +// This is the data source for the Keylime NodeAttestor SPIRE plugin. +// Keylime remains the single TPM authority — this package queries +// attestation results, never touches hardware. +package keylime + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "time" +) + +// AttestationStatus represents the Keylime verdict for a node. +type AttestationStatus struct { + Verdict string `json:"verdict"` // Attested, Pending, Failed, Unknown + PostureLevel uint8 `json:"posture_level"` // 1=Lockdown..5=Normal + LastSuccessful string `json:"last_successful"` + Reason string `json:"reason"` +} + +// IsAttested returns true if the node is currently attested. +func (s *AttestationStatus) IsAttested() bool { + return s.Verdict == "Attested" +} + +// Client queries Keylime attestation status from one of two sources. +type Client struct { + source string // "configmap" or "verifier" + filePath string // ConfigMap mount path + verifier string // Keylime verifier URL + apiVer string // API version (default "2.1") + http *http.Client + maxAgeSec int +} + +// Config for the Keylime client. +type Config struct { + Source string `hcl:"source"` + PostureConfigMapPath string `hcl:"posture_configmap_path"` + VerifierURL string `hcl:"verifier_url"` + APIVersion string `hcl:"api_version"` + TimeoutMs int `hcl:"timeout_ms"` + MaxAttestationAge int `hcl:"max_attestation_age_secs"` +} + +// DefaultConfig returns sensible defaults. +func DefaultConfig() *Config { + return &Config{ + Source: "configmap", + PostureConfigMapPath: "/var/run/posture/posture-current", + APIVersion: "2.1", + TimeoutMs: 5000, + MaxAttestationAge: 600, + } +} + +// NewClient creates a Keylime attestation client. +func NewClient(cfg *Config) *Client { + if cfg == nil { + cfg = DefaultConfig() + } + return &Client{ + source: cfg.Source, + filePath: cfg.PostureConfigMapPath, + verifier: cfg.VerifierURL, + apiVer: cfg.APIVersion, + maxAgeSec: cfg.MaxAttestationAge, + http: &http.Client{ + Timeout: time.Duration(cfg.TimeoutMs) * time.Millisecond, + }, + } +} + +// GetStatus queries attestation status for a node. +func (c *Client) GetStatus(nodeID string) (*AttestationStatus, error) { + switch c.source { + case "verifier": + return c.getFromVerifier(nodeID) + default: + return c.getFromConfigMap(nodeID) + } +} + +func (c *Client) getFromConfigMap(nodeID string) (*AttestationStatus, error) { + // posture-current ConfigMap contains a "level" key with the + // cluster-wide posture level. Per-node keying is future work; + // for now use the global level for all nodes. + levelPath := filepath.Join(c.filePath, "level") + data, err := os.ReadFile(levelPath) + if err != nil { + // Try reading the node-specific key + nodePath := filepath.Join(c.filePath, nodeID) + data, err = os.ReadFile(nodePath) + if err != nil { + return &AttestationStatus{ + Verdict: "Unknown", + Reason: fmt.Sprintf("posture data not found: %v", err), + }, nil + } + } + + level := string(data) + // The level key contains "1"-"5" as a string + switch level { + case "5": + return &AttestationStatus{Verdict: "Attested", PostureLevel: 5}, nil + case "4": + return &AttestationStatus{Verdict: "Attested", PostureLevel: 4, Reason: "elevated"}, nil + case "3": + return &AttestationStatus{Verdict: "Pending", PostureLevel: 3, Reason: "restricted"}, nil + case "2": + return &AttestationStatus{Verdict: "Failed", PostureLevel: 2, Reason: "critical"}, nil + case "1": + return &AttestationStatus{Verdict: "Failed", PostureLevel: 1, Reason: "lockdown"}, nil + default: + // Try parsing as JSON (per-node format) + var status AttestationStatus + if err := json.Unmarshal([]byte(level), &status); err == nil { + return &status, nil + } + return &AttestationStatus{Verdict: "Unknown", Reason: "unparseable level: " + level}, nil + } +} + +func (c *Client) getFromVerifier(nodeID string) (*AttestationStatus, error) { + url := fmt.Sprintf("%s/v%s/agents/%s", c.verifier, c.apiVer, nodeID) + + resp, err := c.http.Get(url) + if err != nil { + return nil, fmt.Errorf("verifier request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return &AttestationStatus{ + Verdict: "Unknown", + Reason: "agent not registered with Keylime verifier", + }, nil + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("verifier returned %d", resp.StatusCode) + } + + var vr struct { + Results struct { + OperationalState *int `json:"operational_state"` + LastSuccessful *int64 `json:"last_successful_attestation"` + } `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&vr); err != nil { + return nil, fmt.Errorf("verifier response parse: %w", err) + } + + verdict := mapOpState(vr.Results.OperationalState) + var lastStr string + if ts := vr.Results.LastSuccessful; ts != nil && *ts > 0 { + lastStr = time.Unix(*ts, 0).UTC().Format(time.RFC3339) + } + + return &AttestationStatus{ + Verdict: verdict, + LastSuccessful: lastStr, + }, nil +} + +func mapOpState(state *int) string { + if state == nil { + return "Pending" // push model — no operational_state + } + switch *state { + case 7: + return "Attested" + case 0, 1, 2, 3, 4, 5, 6: + return "Pending" + case 8, 9, 10: + return "Failed" + default: + return "Unknown" + } +} diff --git a/pkg/keylime/keylime_test.go b/pkg/keylime/keylime_test.go new file mode 100644 index 0000000..8a72476 --- /dev/null +++ b/pkg/keylime/keylime_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 + +package keylime + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAttestedStatus(t *testing.T) { + s := &AttestationStatus{Verdict: "Attested", PostureLevel: 5} + if !s.IsAttested() { + t.Error("expected IsAttested() = true") + } +} + +func TestFailedStatus(t *testing.T) { + s := &AttestationStatus{Verdict: "Failed", PostureLevel: 2} + if s.IsAttested() { + t.Error("expected IsAttested() = false for Failed") + } +} + +func TestUnknownStatus(t *testing.T) { + s := &AttestationStatus{Verdict: "Unknown"} + if s.IsAttested() { + t.Error("expected IsAttested() = false for Unknown") + } +} + +func TestConfigMapSourceNormal(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "level"), []byte("5"), 0644); err != nil { + t.Fatal(err) + } + + c := NewClient(&Config{ + Source: "configmap", + PostureConfigMapPath: dir, + }) + status, err := c.GetStatus("node-1") + if err != nil { + t.Fatal(err) + } + if status.Verdict != "Attested" { + t.Errorf("expected Attested, got %s", status.Verdict) + } + if status.PostureLevel != 5 { + t.Errorf("expected level 5, got %d", status.PostureLevel) + } +} + +func TestConfigMapSourceLockdown(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "level"), []byte("1"), 0644); err != nil { + t.Fatal(err) + } + + c := NewClient(&Config{ + Source: "configmap", + PostureConfigMapPath: dir, + }) + status, err := c.GetStatus("node-1") + if err != nil { + t.Fatal(err) + } + if status.Verdict != "Failed" { + t.Errorf("expected Failed for lockdown, got %s", status.Verdict) + } +} + +func TestConfigMapSourceMissing(t *testing.T) { + c := NewClient(&Config{ + Source: "configmap", + PostureConfigMapPath: "/nonexistent/path", + }) + status, err := c.GetStatus("node-1") + if err != nil { + t.Fatal(err) + } + if status.Verdict != "Unknown" { + t.Errorf("expected Unknown for missing configmap, got %s", status.Verdict) + } +} + +func TestMapOpState(t *testing.T) { + approved := 7 + if v := mapOpState(&approved); v != "Attested" { + t.Errorf("state 7 should be Attested, got %s", v) + } + + failed := 9 + if v := mapOpState(&failed); v != "Failed" { + t.Errorf("state 9 should be Failed, got %s", v) + } + + pending := 3 + if v := mapOpState(&pending); v != "Pending" { + t.Errorf("state 3 should be Pending, got %s", v) + } + + if v := mapOpState(nil); v != "Pending" { + t.Errorf("nil state (push model) should be Pending, got %s", v) + } +} + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + if cfg.Source != "configmap" { + t.Errorf("default source should be configmap, got %s", cfg.Source) + } + if cfg.APIVersion != "2.1" { + t.Errorf("default api version should be 2.1, got %s", cfg.APIVersion) + } +}