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