Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d65f3d5
feat(signature): add shared JWS signer and SPIFFE signer for AgentCar…
kevincogan Apr 14, 2026
911c546
feat(agentcard): add ConfigMap writer and refactor init-container signer
kevincogan Apr 14, 2026
59de614
feat(operator): enable operator-side AgentCard signing with SPIFFE id…
kevincogan Apr 14, 2026
23f13d6
feat(agentcard): enforce strict mode in NetworkPolicy controller
kevincogan Apr 17, 2026
ac0c2ee
chore: regenerate CRD, RBAC, and webhook manifests
kevincogan Apr 17, 2026
c98d034
fix(helm): add RBAC for deployment and statefulset finalizers
kevincogan Apr 17, 2026
bcd55b4
docs(demos): update enforcement prereqs and add operator-signing demo
kevincogan Apr 17, 2026
71a2235
feat(agentcard): add strict mode and gate operator signing behind con…
kevincogan Apr 17, 2026
14842f7
Merge remote-tracking branch 'upstream/main' into feat/operator-agent…
kevincogan May 6, 2026
b0a2f25
feat: remove operator-signing in favor of mTLS verified fetch
kevincogan May 7, 2026
e83627a
feat: implement mTLS verified fetch for AgentCard trust
kevincogan May 7, 2026
6d94ff6
feat: add Helm chart support for verified fetch and fix NP ingress
kevincogan May 7, 2026
d9f299a
feat: add test-tls-agent binary for mTLS E2E verification
kevincogan May 7, 2026
9c5b36a
chore: update Envoy sidecar template for agent-tls port exposure
kevincogan May 7, 2026
6c2caf8
Merge remote-tracking branch 'upstream/main' into feat/operator-agent…
kevincogan May 7, 2026
eb780c3
fix: resolve lint errors in test-tls-agent
kevincogan May 7, 2026
f1469f6
Merge remote-tracking branch 'upstream/main' into feat/operator-agent…
kevincogan May 8, 2026
68cb38b
Merge remote-tracking branch 'upstream/main' into feat/operator-agent…
kevincogan May 11, 2026
1502f39
Merge remote-tracking branch 'upstream/main' into feat/operator-agent…
kevincogan May 11, 2026
96ce6ec
fix: address reviewer feedback on mTLS verified fetch
kevincogan May 13, 2026
c2e4866
fix: guard nil Recorder and return error from cleanupVerifiedFetchFields
kevincogan May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions charts/kagenti-operator/templates/manager/clusterspiffeid.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{{- if .Values.verifiedFetch.enabled }}
apiVersion: spire.spiffe.io/v1alpha1
kind: ClusterSPIFFEID
metadata:
name: kagenti-operator
labels:
{{- include "chart.labels" . | nindent 4 }}
spec:
spiffeIDTemplate: "spiffe://{{ "{{ .TrustDomain }}" }}/ns/{{ "{{ .PodMeta.Namespace }}" }}/sa/{{ "{{ .PodSpec.ServiceAccountName }}" }}"
podSelector:
matchLabels:
app.kubernetes.io/name: {{ include "chart.name" . }}
control-plane: controller-manager
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: {{ .Release.Namespace }}
{{- end }}
24 changes: 24 additions & 0 deletions charts/kagenti-operator/templates/manager/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ spec:
{{- if .Values.mlflow.enable }}
- "--enable-mlflow=true"
{{- end }}
{{- if .Values.verifiedFetch.enabled }}
- "--enable-verified-fetch=true"
- "--verified-fetch-spiffe-socket={{ .Values.verifiedFetch.spiffeEndpointSocket }}"
{{- if and .Values.verifiedFetch.spireTrustDomain (not .Values.signatureVerification.enabled) }}
- "--spire-trust-domain={{ .Values.verifiedFetch.spireTrustDomain }}"
{{- end }}
{{- end }}
{{- if or .Values.enforceNetworkPolicies .Values.signatureVerification.enforceNetworkPolicies }}
- "--enforce-network-policies=true"
{{- end }}
{{- if .Values.signatureVerification.enabled }}
- "--require-a2a-signature=true"
{{- if .Values.signatureVerification.auditMode }}
Expand All @@ -42,6 +52,9 @@ spec:
{{- if .Values.signatureVerification.enforceNetworkPolicies }}
- "--enforce-network-policies=true"
{{- end }}
{{- if .Values.signatureVerification.spireTrustDomain }}
- "--spire-trust-domain={{ .Values.signatureVerification.spireTrustDomain }}"
{{- end }}
{{- if .Values.signatureVerification.spireTrustBundle.configMapName }}
- "--spire-trust-bundle-configmap={{ .Values.signatureVerification.spireTrustBundle.configMapName }}"
{{- end }}
Expand Down Expand Up @@ -121,6 +134,11 @@ spec:
mountPath: /tmp/k8s-metrics-server/metrics-certs
readOnly: true
{{- end }}
{{- if .Values.verifiedFetch.enabled }}
- name: spiffe-workload-api
mountPath: /spiffe-workload-api
readOnly: true
{{- end }}
securityContext:
{{- toYaml .Values.controllerManager.securityContext | nindent 8 }}
serviceAccountName: {{ .Values.controllerManager.serviceAccountName }}
Expand All @@ -144,3 +162,9 @@ spec:
secret:
secretName: kagenti-operator-metrics-server-cert
{{- end }}
{{- if .Values.verifiedFetch.enabled }}
- name: spiffe-workload-api
csi:
driver: "csi.spiffe.io"
readOnly: true
{{- end }}
15 changes: 15 additions & 0 deletions charts/kagenti-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,25 @@ certmanager:
networkPolicy:
enable: false

# Enforce NetworkPolicies based on AgentCard verification status.
# Works with both verifiedFetch (mTLS) and signatureVerification (init-signer).
enforceNetworkPolicies: false

# [MLFLOW]: MLflow experiment tracking integration
mlflow:
enable: false

# [VERIFIED FETCH]: mTLS-authenticated fetch of agent cards via SPIFFE identity (Phase 1)
# When enabled, the operator uses go-spiffe mTLS to fetch agent cards and records
# the agent's attested SPIFFE ID in CRD status.
verifiedFetch:
enabled: false
spiffeEndpointSocket: "unix:///spiffe-workload-api/spire-agent.sock"
# Agents must expose a Service port named "agent-tls" (default 8443).
# The operator discovers the TLS endpoint by port name, not number.
# SPIRE trust domain for identity binding (required when enabled)
spireTrustDomain: ""

# [SIGNATURE VERIFICATION]: A2A agent card signature verification via SPIRE x5c
signatureVerification:
# Enable signature verification for agent cards
Expand Down
15 changes: 12 additions & 3 deletions kagenti-operator/api/v1alpha1/agentcard_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,11 @@ type IdentityBinding struct {
// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$`
TrustDomain string `json:"trustDomain,omitempty"`

// Strict enables enforcement mode: binding failures trigger network isolation.
// When false (default), results are recorded in status only (audit mode).
// Strict controls whether binding failures trigger enforcement actions
// (label removal, restrictive NetworkPolicy).
// When true, binding failure removes the verified label and applies restrictive NetworkPolicy.
// When false (default), binding results are recorded in status only;
// the workload retains its verified label and permissive policy.
// +optional
// +kubebuilder:default=false
Strict bool `json:"strict,omitempty"`
Expand Down Expand Up @@ -116,6 +119,11 @@ type AgentCardStatus struct {
// +optional
CardId string `json:"cardId,omitempty"`

// AttestedAgentSpiffeID is the SPIFFE ID extracted from the agent's TLS peer certificate
// during authenticated (mTLS) fetch. Set only when verifiedFetch is enabled and successful.
// +optional
AttestedAgentSpiffeID string `json:"attestedAgentSpiffeId,omitempty"`

// ExpectedSpiffeID is the SPIFFE ID used for binding evaluation.
// +optional
ExpectedSpiffeID string `json:"expectedSpiffeID,omitempty"`
Expand Down Expand Up @@ -339,11 +347,12 @@ type SkillParameter struct {
// +kubebuilder:printcolumn:name="Kind",type="string",JSONPath=".status.targetRef.kind",description="Workload Kind"
// +kubebuilder:printcolumn:name="Target",type="string",JSONPath=".status.targetRef.name",description="Target Workload"
// +kubebuilder:printcolumn:name="Agent",type="string",JSONPath=".status.card.name",description="Agent Name"
// +kubebuilder:printcolumn:name="Verified",type="boolean",JSONPath=".status.validSignature",description="Signature Verified"
// +kubebuilder:printcolumn:name="Verified",type="string",JSONPath=".status.conditions[?(@.type=='Verified')].status",description="Identity Verified"
// +kubebuilder:printcolumn:name="Bound",type="boolean",JSONPath=".status.bindingStatus.bound",description="Identity Bound"
// +kubebuilder:printcolumn:name="Synced",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status",description="Sync Status"
// +kubebuilder:printcolumn:name="LastSync",type="date",JSONPath=".status.lastSyncTime",description="Last Sync Time"
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
// +kubebuilder:printcolumn:name="AttestedAgent",type="string",JSONPath=".status.attestedAgentSpiffeId",description="Attested Agent SPIFFE ID",priority=1

// AgentCard is the Schema for the agentcards API.
type AgentCard struct {
Expand Down
172 changes: 5 additions & 167 deletions kagenti-operator/cmd/agentcard-signer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,6 @@ package main

import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"os"
Expand All @@ -42,6 +34,7 @@ import (
"github.com/spiffe/go-spiffe/v2/workloadapi"

agentv1alpha1 "github.com/kagenti/operator/api/v1alpha1"
"github.com/kagenti/operator/internal/agentcard"
"github.com/kagenti/operator/internal/signature"
)

Expand Down Expand Up @@ -84,7 +77,7 @@ func run() error {
if err != nil {
return fmt.Errorf("failed to fetch X.509-SVID: %w", err)
}
defer zeroPrivateKey(svid.PrivateKey)
defer signature.ZeroPrivateKey(svid.PrivateKey)

spiffeID := svid.ID.String()
logJSON("info", "fetched SVID", "spiffe_id", spiffeID)
Expand All @@ -99,7 +92,7 @@ func run() error {
return fmt.Errorf("failed to parse unsigned card JSON: %w", err)
}

signedCard, err := signCard(&cardData, svid.PrivateKey, svid.Certificates)
signedCard, err := signature.SignCard(&cardData, svid.PrivateKey, svid.Certificates)
if err != nil {
return fmt.Errorf("signing failed: %w", err)
}
Expand Down Expand Up @@ -148,7 +141,7 @@ func writeConfigMapWithClient(
ctx context.Context, clientset k8sclient.Interface,
agentName, namespace string, signedCard []byte,
) error {
cmName := agentName + "-card-signed"
cmName := agentcard.ConfigMapName(agentName)
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: cmName, Namespace: namespace},
Data: map[string]string{"agent-card.json": string(signedCard)},
Expand All @@ -171,7 +164,7 @@ func fetchSVID(ctx context.Context, socketPath string) (*x509svid.SVID, error) {
if err != nil {
return nil, fmt.Errorf("failed to create workload API client: %w", err)
}
defer client.Close()
defer client.Close() //nolint:errcheck // best-effort cleanup

svid, err := client.FetchX509SVID(ctx)
if err != nil {
Expand All @@ -180,161 +173,6 @@ func fetchSVID(ctx context.Context, socketPath string) (*x509svid.SVID, error) {
return svid, nil
}

// signCard signs AgentCard data and returns the signed JSON.
func signCard(cardData *agentv1alpha1.AgentCardData, privateKey crypto.Signer, certs []*x509.Certificate) ([]byte, error) {
if cardData == nil {
return nil, fmt.Errorf("card data is nil")
}
if len(certs) == 0 {
return nil, fmt.Errorf("no certificates in SVID chain")
}
leaf := certs[0]

alg, err := algorithmForKey(privateKey.Public())
if err != nil {
return nil, err
}

kid := computeKID(leaf)

x5c := make([]string, len(certs))
for i, cert := range certs {
x5c[i] = base64.StdEncoding.EncodeToString(cert.Raw)
}

header := &signature.ProtectedHeader{
Algorithm: alg,
KeyID: kid,
Type: "JOSE",
X5C: x5c,
}

protectedB64, err := signature.EncodeProtectedHeader(header)
if err != nil {
return nil, fmt.Errorf("failed to encode protected header: %w", err)
}

payload, err := signature.CreateCanonicalCardJSON(cardData)
if err != nil {
return nil, fmt.Errorf("failed to create canonical JSON: %w", err)
}

payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
signingInput := []byte(protectedB64 + "." + payloadB64)

sigBytes, err := signInput(privateKey, alg, signingInput)
if err != nil {
return nil, fmt.Errorf("signing failed: %w", err)
}

sigB64 := base64.RawURLEncoding.EncodeToString(sigBytes)

cardData.Signatures = []agentv1alpha1.AgentCardSignature{
{
Protected: protectedB64,
Signature: sigB64,
},
}

output, err := json.MarshalIndent(cardData, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal signed card: %w", err)
}

return output, nil
}

// algorithmForKey maps a public key type to its JWS algorithm.
func algorithmForKey(pub crypto.PublicKey) (string, error) {
switch k := pub.(type) {
case *rsa.PublicKey:
if k.N.BitLen() < 2048 {
return "", fmt.Errorf("RSA key too small: %d bits (minimum 2048)", k.N.BitLen())
}
return "RS256", nil
case *ecdsa.PublicKey:
switch k.Curve {
case elliptic.P256():
return "ES256", nil
case elliptic.P384():
return "ES384", nil
case elliptic.P521():
return "ES512", nil
default:
return "", fmt.Errorf("unsupported ECDSA curve: %s", k.Curve.Params().Name)
}
default:
return "", fmt.Errorf("unsupported key type: %T", pub)
}
}

// computeKID derives a key ID from the leaf cert's SHA-256 fingerprint (first 8 bytes).
func computeKID(leaf *x509.Certificate) string {
fp := sha256.Sum256(leaf.Raw)
return fmt.Sprintf("%x", fp[:8])
}

func signInput(signer crypto.Signer, alg string, input []byte) ([]byte, error) {
hashFunc, err := signature.HashForAlgorithm(alg)
if err != nil {
return nil, err
}

h := hashFunc.New()
h.Write(input)
hashed := h.Sum(nil)

switch alg {
case "RS256", "RS384", "RS512":
return signer.Sign(rand.Reader, hashed, hashFunc)
case "ES256", "ES384", "ES512":
return signECDSARaw(signer, hashed, alg)
default:
return nil, fmt.Errorf("unsupported algorithm: %s", alg)
}
}

// signECDSARaw signs with ECDSA and encodes as fixed-width R||S (RFC 7518 §3.4).
func signECDSARaw(signer crypto.Signer, hashed []byte, alg string) ([]byte, error) {
ecKey, ok := signer.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("expected *ecdsa.PrivateKey, got %T", signer)
}

r, s, err := ecdsa.Sign(rand.Reader, ecKey, hashed)
if err != nil {
return nil, fmt.Errorf("ECDSA sign failed: %w", err)
}

keySize := signature.CurveByteSize(ecKey.Curve)
sig := make([]byte, 2*keySize)
rBytes := r.Bytes()
sBytes := s.Bytes()
copy(sig[keySize-len(rBytes):keySize], rBytes)
copy(sig[2*keySize-len(sBytes):], sBytes)

return sig, nil
}

// zeroPrivateKey zeroes private key material in memory (best-effort).
func zeroPrivateKey(key crypto.Signer) {
switch k := key.(type) {
case *ecdsa.PrivateKey:
if k.D != nil {
k.D.SetInt64(0)
}
case *rsa.PrivateKey:
if k.D != nil {
k.D.SetInt64(0)
}
for _, p := range k.Primes {
if p != nil {
p.SetInt64(0)
}
}
}
}

func envOrDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
return v
Expand Down
Loading
Loading