guildhouse-spire-plugins/pkg/keylime/keylime.go
Tyler J King 5f62da6ca9 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>
2026-04-15 20:35:45 -04:00

188 lines
5.3 KiB
Go

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