feat(spire): Keylime node attestor plugin — single TPM authority

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 <tking@guildhouse.dev>
Signed-off-by: Tyler J King <tking727@gmail.com>
This commit is contained in:
Tyler J King 2026-04-15 20:35:45 -04:00
parent a58d548518
commit 5f62da6ca9
3 changed files with 357 additions and 0 deletions

View file

@ -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,
})
}

188
pkg/keylime/keylime.go Normal file
View file

@ -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"
}
}

116
pkg/keylime/keylime_test.go Normal file
View file

@ -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)
}
}