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>
188 lines
5.3 KiB
Go
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"
|
|
}
|
|
}
|