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:
parent
a58d548518
commit
5f62da6ca9
3 changed files with 357 additions and 0 deletions
53
cmd/keylime-attestor/main.go
Normal file
53
cmd/keylime-attestor/main.go
Normal 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
188
pkg/keylime/keylime.go
Normal 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
116
pkg/keylime/keylime_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue